ci/woodpecker/push/woodpecker Pipeline was successful
Der Upload lief gegen einen einzigen globalen Wert von 5 MB, der fuer alle
Dateitypen galt. Eine Getraenkekarte liegt aber schnell bei 15-20 MB, das
Hochladen scheiterte deshalb zuverlaessig.
- MAX_FILE_SIZE (Bilder) von 5 auf 20 MB. Bilder werden ohnehin auf 1600px
heruntergerechnet, die Grenze muss nur ein unbearbeitetes Handyfoto
durchlassen - 5 MB reichten dafuer schon nicht.
- MAX_PDF_SIZE neu, 40 MB. PDFs werden unveraendert abgelegt.
- Beides ueber Umgebungsvariablen uebersteuerbar.
Die Registrierung von multipart nimmt den groesseren der beiden Werte als
Obergrenze, die Routen setzen ihn per request.file({ limits }) auf ihren
eigenen herunter. Die Fehlermeldung im PDF-Zweig nannte bisher die
Bildgrenze.
Geprueft am laufenden Container, auch mit --memory=512m wie auf Fly: 18-MB-
PDF und 18-MB-Foto gehen durch, 45 MB bzw. 25 MB werden mit 413 und
passender Meldung abgelehnt, kein OOM.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
import { FastifyPluginAsync } from 'fastify';
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '../config/database.js';
|
|
import { contentSections } from '../db/schema.js';
|
|
import { AssetService } from '../services/asset.service.js';
|
|
import { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js';
|
|
import { saveUploadedPdf } from '../services/upload.service.js';
|
|
import { env } from '../config/env.js';
|
|
|
|
const assets = new AssetService();
|
|
|
|
/**
|
|
* Feste PDF-Plaetze. Die URL landet jeweils in einer Content-Section, damit
|
|
* der Generator sie beim Publish in die Astro-Komponente schreiben kann.
|
|
*/
|
|
const PDF_SLOTS: Record<string, { section: string; field: string }> = {
|
|
drinks: { section: 'drinks', field: 'pdfUrl' },
|
|
};
|
|
|
|
/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */
|
|
function asObject(value: any): Record<string, any> {
|
|
if (!value) return {};
|
|
if (typeof value === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return typeof value === 'object' ? value : {};
|
|
}
|
|
|
|
const pdfRoute: FastifyPluginAsync = async (fastify) => {
|
|
|
|
// PDF fuer einen festen Platz hochladen und verlinken
|
|
fastify.post('/pdf/:slot', {
|
|
preHandler: [fastify.authenticate],
|
|
}, async (request, reply) => {
|
|
const { slot } = request.params as { slot: string };
|
|
const target = PDF_SLOTS[slot];
|
|
|
|
if (!target) {
|
|
return reply.code(404).send({ error: `Unknown PDF slot "${slot}"` });
|
|
}
|
|
|
|
try {
|
|
const file = await (request as any).file({ limits: { fileSize: env.MAX_PDF_SIZE } });
|
|
if (!file) {
|
|
return reply.code(400).send({ error: 'No file uploaded' });
|
|
}
|
|
|
|
const mime = file.mimetype as string | undefined;
|
|
const originalName = (file.filename as string | undefined) || '';
|
|
if (mime !== 'application/pdf' && !originalName.toLowerCase().endsWith('.pdf')) {
|
|
return reply.code(400).send({ error: 'Only PDF uploads are allowed' });
|
|
}
|
|
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of file.file) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
const buffer = Buffer.concat(chunks);
|
|
|
|
if ((file.file as any)?.truncated) {
|
|
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
|
|
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
|
}
|
|
|
|
// Inhalt gegenpruefen, damit nicht irgendetwas mit .pdf-Endung landet
|
|
if (buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
|
return reply.code(400).send({ error: 'File is not a valid PDF' });
|
|
}
|
|
|
|
const pdfUrl = await saveUploadedPdf(file, buffer);
|
|
|
|
// URL in der Content-Section hinterlegen
|
|
const [existing] = await db
|
|
.select()
|
|
.from(contentSections)
|
|
.where(eq(contentSections.sectionName, target.section))
|
|
.limit(1);
|
|
|
|
const previousContent = asObject((existing as any)?.contentJson);
|
|
const previousUrl = previousContent[target.field];
|
|
const content = { ...previousContent, [target.field]: pdfUrl };
|
|
|
|
if (existing) {
|
|
await db
|
|
.update(contentSections)
|
|
.set({ contentJson: content, updatedAt: new Date() })
|
|
.where(eq(contentSections.sectionName, target.section));
|
|
} else {
|
|
await db
|
|
.insert(contentSections)
|
|
.values({ sectionName: target.section, contentJson: content });
|
|
}
|
|
|
|
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
|
|
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
|
|
if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) {
|
|
try {
|
|
if (assets.deletePdf(previousUrl)) {
|
|
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
|
|
}
|
|
await forgetManagedAsset(previousUrl);
|
|
} catch (err) {
|
|
fastify.log.warn({ err }, 'Could not remove replaced PDF');
|
|
}
|
|
}
|
|
|
|
return reply.code(201).send({ pdfUrl, section: target.section });
|
|
|
|
} catch (err: any) {
|
|
if (err?.code === 'FST_REQ_FILE_TOO_LARGE') {
|
|
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
|
|
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
|
}
|
|
fastify.log.error({ err }, 'PDF upload failed');
|
|
return reply.code(500).send({ error: 'Failed to upload PDF' });
|
|
}
|
|
});
|
|
};
|
|
|
|
export default pdfRoute;
|