Files
Gallus_Pub/backend/src/routes/content.ts
T
KenzoandClaude Opus 5 f46a24ee4e
ci/woodpecker/push/woodpecker Pipeline was successful
fix(upload): Groessengrenzen realistisch setzen, PDFs eigener Wert
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]>
2026-08-11 14:42:56 +02:00

155 lines
4.3 KiB
TypeScript

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { saveUploadedImage } from '../services/upload.service.js';
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
import { env } from '../config/env.js';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
type: 'object',
required: ['contentJson'],
properties: {
contentJson: {}, // allow any JSON
},
} as const;
const contentRoute: FastifyPluginAsync = async (fastify) => {
// Get content section
fastify.get('/content/:section', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const [content] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, section))
.limit(1);
if (!content) {
return reply.code(404).send({ error: 'Content section not found' });
}
return {
section: content.sectionName,
content: content.contentJson,
updatedAt: content.updatedAt,
};
});
// Update content section
fastify.put('/content/:section', {
schema: {
body: contentBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const { contentJson } = request.body as any;
// Check if section exists
const [existing] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, section))
.limit(1);
let result;
if (existing) {
// Update existing
[result] = await db
.update(contentSections)
.set({
contentJson,
updatedAt: new Date(),
})
.where(eq(contentSections.sectionName, section))
.returning();
} else {
// Create new
[result] = await db
.insert(contentSections)
.values({
sectionName: section,
contentJson,
})
.returning();
}
// Bilder, die durch die Aenderung herausgefallen sind, wegraeumen
const before = extractImageUrls((existing as any)?.contentJson);
const after = extractImageUrls(result.contentJson);
for (const url of before) {
if (after.has(url)) continue;
try {
if (await dropImageIfUnused(url)) {
fastify.log.info(`Removed unused content image ${url}`);
}
} catch (err) {
fastify.log.warn({ err }, 'Could not remove unused content image');
}
}
return {
section: result.sectionName,
content: result.contentJson,
updatedAt: result.updatedAt,
};
});
// Bild fuer einen Textbereich hochladen (Welcome-Bild, Monatshit, Whiskey)
fastify.post('/content/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const preferredName = (file.fields?.name?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'content', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
} catch (err: any) {
if (err?.statusCode === 413) {
return reply.code(413).send({ error: err.message });
}
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// List all content sections
fastify.get('/content', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const sections = await db.select().from(contentSections);
return {
sections: (sections as any[]).map((s: any) => ({
section: s.sectionName,
content: s.contentJson,
updatedAt: s.updatedAt,
})),
};
});
};
export default contentRoute;