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]>
155 lines
4.3 KiB
TypeScript
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;
|