feat(cms): Aufraeumen von Uploads, Texte-Bearbeitung und PDF-Slot
Bilder wurden nie geloescht - weder beim Loeschen eines Datensatzes noch beim Austauschen. Dazu waren die Textbereiche zwar im Backend vorhanden, aber ohne Oberflaeche, und die Getraenkekarte hing hartkodiert im Markup. Aufraeumen: - asset.service.ts loescht ausschliesslich Dateien, die das CMS selbst angelegt hat (Muster <zeitstempel>-<zufall>.<ext>) und nur innerhalb der Upload-Ordner. Handgepflegte Assets wie event_karaoke.jpg oder Welcome.png bleiben unangetastet, auch wenn sie nirgends referenziert sind. - image-refs.service.ts sammelt alle benutzten Bild-URLs, inklusive der Pfade aus den Textbereichen. Geloescht wird nur, was wirklich niemand mehr benutzt - ein von zwei Events geteiltes Bild bleibt liegen. - Events und Gallery raeumen beim Loeschen und beim Bildwechsel mit auf. - Der Publish entfernt zusaetzlich Verwaiste. Die Referenzliste deckt bewusst alle Zeilen ab, auch unveroeffentlichte, sonst verlieren die ihr Bild. Die Loeschungen werden mitcommittet, das Repo schrumpft also. Texte: - Neuer Adminbereich fuer Hero, Willkommen und Drinks ueber die schon vorhandenen /api/content-Endpunkte, samt Highlights-Liste und Bildern. - Der Generator maskiert Texte jetzt. Ohne das haette ein "<" oder "&" in einem Feld gereicht, um den Astro-Build und damit den Deploy zu killen. PDF: - POST /api/pdf/drinks nimmt die Getraenkekarte entgegen, hinterlegt die URL in der drinks-Section und raeumt den Vorgaenger weg. Der Generator verlinkt sie, statt den Pfad fest im Markup zu haben. Ausserdem: - Vorschaubilder im Admin auf eine feste Box gezwungen. Uploads sind bis 1600px breit und haben das Layout je nach Seitenverhaeltnis zerrissen. - git.service.ts sichert public/pdf beim Neu-Clone mit weg, nicht nur public/images. - Der Publish scheitert nicht mehr, wenn nur das Audit-Log nicht geschrieben werden kann - der Push ist da laengst durch. - Upload-Logik lag dreifach kopiert vor, jetzt in upload.service.ts. Der Helfer prueft auch auf abgeschnittene Dateien; bisher landete bei zu grossen Uploads ein kaputtes Bild auf der Platte. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -18,6 +18,7 @@ import contentRoute from './routes/content.js';
|
||||
import settingsRoute from './routes/settings.js';
|
||||
import publishRoute from './routes/publish.js';
|
||||
import bannersRoute from './routes/banners.js';
|
||||
import pdfRoute from './routes/pdf.js';
|
||||
|
||||
// Validate environment variables
|
||||
try {
|
||||
@@ -99,6 +100,15 @@ fastify.register(fastifyStatic, {
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
// Dasselbe fuer die hochgeladenen PDFs (Getraenkekarte)
|
||||
const pdfDir = path.join(dataDir, 'public', 'pdf');
|
||||
fs.mkdirSync(pdfDir, { recursive: true });
|
||||
fastify.register(fastifyStatic, {
|
||||
root: pdfDir,
|
||||
prefix: '/pdf/',
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
// Decorate fastify with authenticate method
|
||||
fastify.decorate('authenticate', authenticate);
|
||||
|
||||
@@ -110,6 +120,7 @@ fastify.register(contentRoute, { prefix: '/api' });
|
||||
fastify.register(settingsRoute, { prefix: '/api' });
|
||||
fastify.register(publishRoute, { prefix: '/api' });
|
||||
fastify.register(bannersRoute, { prefix: '/api' });
|
||||
fastify.register(pdfRoute, { prefix: '/api' });
|
||||
|
||||
// Health check
|
||||
fastify.get('/health', async () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ 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';
|
||||
|
||||
// Fastify JSON schema for content section body
|
||||
const contentBodyJsonSchema = {
|
||||
@@ -78,6 +80,20 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||
.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,
|
||||
@@ -85,6 +101,34 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||
};
|
||||
});
|
||||
|
||||
// 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();
|
||||
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 saved = await saveUploadedImage(file, 'content', 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],
|
||||
|
||||
@@ -2,8 +2,19 @@ import { FastifyPluginAsync } from 'fastify';
|
||||
import { db } from '../config/database.js';
|
||||
import { events } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { dropImageIfUnused } from '../services/image-refs.service.js';
|
||||
import { saveUploadedImage } from '../services/upload.service.js';
|
||||
|
||||
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
|
||||
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
|
||||
try {
|
||||
if (await dropImageIfUnused(url)) {
|
||||
fastify.log.info(`Removed ${reason} ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err }, `Could not remove ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fastify JSON schema for event body
|
||||
const eventBodyJsonSchema = {
|
||||
@@ -71,8 +82,17 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as any;
|
||||
|
||||
const [previous] = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||
|
||||
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||
|
||||
// Ausgetauschtes Bild wegraeumen, sonst bleibt es fuer immer liegen
|
||||
if (previous && previous.imageUrl !== row.imageUrl) {
|
||||
await dropUnusedImage(fastify, previous.imageUrl, 'replaced event image');
|
||||
}
|
||||
|
||||
return { event: row };
|
||||
});
|
||||
|
||||
@@ -92,52 +112,14 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||
}
|
||||
|
||||
// Prepare directories - use persistent volume for Fly.io
|
||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||
const uploadDir = path.join(dataDir, 'public', 'images', 'events');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const saved = await saveUploadedImage(file, 'events', fastify.log);
|
||||
|
||||
// Read uploaded stream into buffer
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
||||
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
return reply.code(413).send({ error: err.message });
|
||||
}
|
||||
const inputBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Generate filename
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
const baseName = `${stamp}-${rand}`;
|
||||
|
||||
// Try to convert to webp and limit size; fallback to original
|
||||
let outBuffer: Buffer | null = null;
|
||||
let outExt = '.webp';
|
||||
try {
|
||||
// Lazy load sharp only when needed
|
||||
const sharp = (await import('sharp')).default;
|
||||
outBuffer = await sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
||||
outBuffer = inputBuffer;
|
||||
// naive extension from mimetype
|
||||
const extFromMime = mime.split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
const filename = baseName + outExt;
|
||||
const destPath = path.join(uploadDir, filename);
|
||||
fs.writeFileSync(destPath, outBuffer);
|
||||
|
||||
// Public URL (served via /static)
|
||||
const publicUrl = `/images/events/${filename}`;
|
||||
|
||||
return reply.code(201).send({ imageUrl: publicUrl });
|
||||
|
||||
} catch (err) {
|
||||
fastify.log.error({ err }, 'Upload failed');
|
||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||
}
|
||||
@@ -148,6 +130,10 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||
|
||||
// Zugehoerige Bilddatei mitnehmen
|
||||
await dropUnusedImage(fastify, row.imageUrl, 'event image');
|
||||
|
||||
return { message: 'Event deleted successfully' };
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,19 @@ import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { galleryImages } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { dropImageIfUnused } from '../services/image-refs.service.js';
|
||||
import { saveUploadedImage } from '../services/upload.service.js';
|
||||
|
||||
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
|
||||
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
|
||||
try {
|
||||
if (await dropImageIfUnused(url)) {
|
||||
fastify.log.info(`Removed ${reason} ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err }, `Could not remove ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fastify JSON schema for gallery image body
|
||||
const galleryBodyJsonSchema = {
|
||||
@@ -84,60 +95,22 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||
}
|
||||
|
||||
// Prepare directories - use persistent volume for Fly.io
|
||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||
const uploadDir = path.join(dataDir, 'public', 'images', 'gallery');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
// Read uploaded stream into buffer
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const inputBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Generate filename
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
const baseName = `${stamp}-${rand}`;
|
||||
|
||||
// Try to convert to webp and limit size; fallback to original
|
||||
let outBuffer: Buffer | null = null;
|
||||
let outExt = '.webp';
|
||||
try {
|
||||
// Lazy load sharp only when needed
|
||||
const sharp = (await import('sharp')).default;
|
||||
outBuffer = await sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
||||
outBuffer = inputBuffer;
|
||||
// naive extension from mimetype
|
||||
const extFromMime = mime.split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
const filename = baseName + outExt;
|
||||
const destPath = path.join(uploadDir, filename);
|
||||
fs.writeFileSync(destPath, outBuffer);
|
||||
|
||||
// Public URL (served via /static)
|
||||
const publicUrl = `/images/gallery/${filename}`;
|
||||
const saved = await saveUploadedImage(file, 'gallery', fastify.log);
|
||||
|
||||
// Store in DB (optional but useful)
|
||||
const [row] = await db.insert(galleryImages).values({
|
||||
imageUrl: publicUrl,
|
||||
altText: altText || filename,
|
||||
imageUrl: saved.imageUrl,
|
||||
altText: altText || saved.filename,
|
||||
displayOrder,
|
||||
isPublished: true,
|
||||
}).returning();
|
||||
|
||||
return reply.code(201).send({ image: row });
|
||||
|
||||
} catch (err) {
|
||||
} 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' });
|
||||
}
|
||||
@@ -153,6 +126,8 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as any;
|
||||
|
||||
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||
|
||||
const [updated] = await db
|
||||
.update(galleryImages)
|
||||
.set(data)
|
||||
@@ -163,6 +138,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
// Ausgetauschte Datei wegraeumen
|
||||
if (previous && previous.imageUrl !== updated.imageUrl) {
|
||||
await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image');
|
||||
}
|
||||
|
||||
return { image: updated };
|
||||
});
|
||||
|
||||
@@ -181,6 +161,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
// Zugehoerige Bilddatei mitnehmen
|
||||
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
|
||||
|
||||
return { message: 'Image deleted successfully' };
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
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 { 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();
|
||||
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_FILE_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 uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
const filename = `${stamp}-${rand}.pdf`;
|
||||
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
||||
|
||||
const pdfUrl = `/pdf/${filename}`;
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
if (assets.deletePdf(previousUrl)) {
|
||||
fastify.log.info(`Removed replaced PDF ${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_FILE_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;
|
||||
@@ -2,6 +2,8 @@ import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { GitService } from '../services/git.service.js';
|
||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||
import { AssetService } from '../services/asset.service.js';
|
||||
import { collectReferencedImageUrls } from '../services/image-refs.service.js';
|
||||
import { db } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -34,6 +36,17 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
fastify.log.info('Git repository initialized');
|
||||
|
||||
// Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste
|
||||
// deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich
|
||||
// die Bildpfade aus den Textbereichen.
|
||||
const assetService = new AssetService();
|
||||
const referencedImages = await collectReferencedImageUrls();
|
||||
const removedImages = assetService.sweepOrphanedImages([...referencedImages]);
|
||||
|
||||
if (removedImages.length > 0) {
|
||||
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
|
||||
}
|
||||
|
||||
// Fetch all content from database
|
||||
const eventsData = await db
|
||||
.select()
|
||||
@@ -78,16 +91,23 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||
|
||||
// Record in history
|
||||
await db.insert(publishHistory).values({
|
||||
userId,
|
||||
commitHash,
|
||||
commitMessage,
|
||||
});
|
||||
// Record in history. Der Push ist an dieser Stelle bereits durch -
|
||||
// ein Fehler im Protokoll darf die Veroeffentlichung nicht als
|
||||
// gescheitert melden und den Workspace zuruecksetzen.
|
||||
try {
|
||||
await db.insert(publishHistory).values({
|
||||
userId,
|
||||
commitHash,
|
||||
commitMessage,
|
||||
});
|
||||
} catch (historyError) {
|
||||
fastify.log.warn({ err: historyError }, 'Could not record publish history');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitHash,
|
||||
removedImages: removedImages.length,
|
||||
message: 'Changes published successfully',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
/**
|
||||
* Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt.
|
||||
*
|
||||
* Grundregel: geloescht wird ausschliesslich, was das CMS selbst angelegt hat.
|
||||
* Uploads bekommen den Namen <base36-zeitstempel>-<6 zufaellige zeichen>.<ext>
|
||||
* (siehe events.ts / gallery.ts). Handgepflegte Assets aus dem Repo heissen
|
||||
* anders - event_karaoke.jpg, Welcome.png, Gallery1.webp - und werden dadurch
|
||||
* nie angefasst, auch wenn sie in keinem Datensatz mehr vorkommen.
|
||||
*/
|
||||
|
||||
// Unterordner unterhalb von public/, in denen das CMS aufraeumen darf
|
||||
const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
|
||||
const MANAGED_PDF_DIR = 'pdf';
|
||||
|
||||
// Namensmuster der CMS-Uploads
|
||||
const GENERATED_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
|
||||
|
||||
export class AssetService {
|
||||
private publicDir: string;
|
||||
|
||||
constructor() {
|
||||
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
|
||||
}
|
||||
|
||||
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */
|
||||
private resolveInPublic(url: string): string | null {
|
||||
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
|
||||
|
||||
const relative = url.replace(/^\/+/, '').split('?')[0].split('#')[0];
|
||||
const absolute = path.resolve(this.publicDir, relative);
|
||||
|
||||
// Traversal-Schutz: muss unterhalb von public/ bleiben
|
||||
if (absolute !== this.publicDir && !absolute.startsWith(this.publicDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pfad einer Datei, die das CMS loeschen darf.
|
||||
* Liefert null fuer fremde Pfade und fuer handgepflegte Dateien.
|
||||
*/
|
||||
resolveDeletable(url: string, dirs: string[]): string | null {
|
||||
const absolute = this.resolveInPublic(url);
|
||||
if (!absolute) return null;
|
||||
|
||||
const relative = path.relative(this.publicDir, absolute);
|
||||
const dir = path.dirname(relative).split(path.sep).join('/');
|
||||
if (!dirs.includes(dir)) return null;
|
||||
|
||||
if (!GENERATED_NAME.test(path.basename(absolute))) return null;
|
||||
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */
|
||||
deleteImage(url: string): boolean {
|
||||
return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS));
|
||||
}
|
||||
|
||||
/** Loescht ein hochgeladenes PDF. */
|
||||
deletePdf(url: string): boolean {
|
||||
return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR]));
|
||||
}
|
||||
|
||||
private unlink(absolute: string | null): boolean {
|
||||
if (!absolute) return false;
|
||||
try {
|
||||
fs.unlinkSync(absolute);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs
|
||||
* mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch
|
||||
* unveroeffentlichte - sonst verlieren die ihr Bild.
|
||||
*/
|
||||
sweepOrphanedImages(referencedUrls: string[]): string[] {
|
||||
const keep = new Set<string>();
|
||||
for (const url of referencedUrls) {
|
||||
const absolute = this.resolveInPublic(url);
|
||||
if (absolute) keep.add(absolute);
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const dir of MANAGED_IMAGE_DIRS) {
|
||||
const absoluteDir = path.join(this.publicDir, dir);
|
||||
if (!fs.existsSync(absoluteDir)) continue;
|
||||
|
||||
for (const name of fs.readdirSync(absoluteDir)) {
|
||||
const absolute = path.join(absoluteDir, name);
|
||||
|
||||
if (!GENERATED_NAME.test(name)) continue;
|
||||
if (keep.has(absolute)) continue;
|
||||
if (!fs.statSync(absolute).isFile()) continue;
|
||||
|
||||
fs.unlinkSync(absolute);
|
||||
removed.push('/' + dir + '/' + name);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,27 @@ export class FileGeneratorService {
|
||||
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte aus dem Adminbereich landen direkt im Astro-Markup. Ohne Maskierung
|
||||
* reicht ein "<" oder "&" in einem Feld, damit der Build der Seite scheitert
|
||||
* und der Deploy stehen bleibt.
|
||||
*/
|
||||
escapeHtml(value: any): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Wie escapeHtml, aber mit Rueckfallwert wenn nichts gesetzt ist. */
|
||||
text(value: any, fallback = ''): string {
|
||||
const raw = value === undefined || value === null || value === '' ? fallback : value;
|
||||
return this.escapeHtml(raw);
|
||||
}
|
||||
|
||||
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||
const eventsCode = events.map(e => `\t{
|
||||
\t\timage: "${e.imageUrl}",
|
||||
@@ -86,9 +107,9 @@ const { id } = Astro.props;
|
||||
|
||||
\t\t<div class="hero-content">
|
||||
|
||||
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
|
||||
|
||||
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
|
||||
|
||||
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||
\t\t</div>
|
||||
@@ -105,7 +126,7 @@ const { id } = Astro.props;
|
||||
|
||||
generateWelcomeComponent(content: ContentSection): string {
|
||||
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||
`\t\t\t<li>\n\t\t\t\t<b>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
|
||||
).join('\n\n');
|
||||
|
||||
return `---
|
||||
@@ -119,11 +140,11 @@ const { id } = Astro.props;
|
||||
|
||||
\t<div class="welcome-text">
|
||||
|
||||
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
|
||||
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.introText || ''}
|
||||
\t\t\t${this.text(content.introText)}
|
||||
\t\t</p>
|
||||
|
||||
\t\t<p><b>Unsere Highlights:</b></p>
|
||||
@@ -133,14 +154,14 @@ ${highlightsList}
|
||||
\t\t</ul>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.closingText || ''}
|
||||
\t\t\t${this.text(content.closingText)}
|
||||
\t\t</p>
|
||||
|
||||
\t</div>
|
||||
|
||||
|
||||
\t<div class="welcome-image">
|
||||
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
|
||||
\t</div>
|
||||
|
||||
</section>
|
||||
@@ -157,36 +178,36 @@ const { id } = Astro.props;
|
||||
<h2 class="title">Drinks</h2>
|
||||
|
||||
<p class="note">
|
||||
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||
${this.text(content.introText, 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.')}
|
||||
</p>
|
||||
|
||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
<a href="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<h3 class="monats-hit">Monats Hit</h3>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||
<div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
|
||||
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||
${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}
|
||||
</p>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||
<img src="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||
<img src="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||
<img src="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,13 @@ import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
// Verzeichnisse, in die das CMS hochlaedt. Die muessen einen Neu-Clone des
|
||||
// Workspace ueberleben, sonst sind Bilder und Getraenkekarte weg.
|
||||
const UPLOAD_DIRS = [
|
||||
path.join('public', 'images'),
|
||||
path.join('public', 'pdf'),
|
||||
];
|
||||
|
||||
export class GitService {
|
||||
private git: SimpleGit;
|
||||
private workspaceDir: string;
|
||||
@@ -51,15 +58,17 @@ export class GitService {
|
||||
if (!usable) {
|
||||
console.log('Cloning repository...');
|
||||
|
||||
// Hochgeladene Bilder liegen im Workspace und wuerden beim Loeschen
|
||||
// Hochgeladene Dateien liegen im Workspace und wuerden beim Loeschen
|
||||
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
|
||||
const imagesDir = path.join(this.workspaceDir, 'public', 'images');
|
||||
const backupDir = path.join(this.parentDir, '.workspace-images-backup');
|
||||
const hasImages = existsSync(imagesDir);
|
||||
const backupRoot = path.join(this.parentDir, '.workspace-upload-backup');
|
||||
await rm(backupRoot, { recursive: true, force: true });
|
||||
|
||||
await rm(backupDir, { recursive: true, force: true });
|
||||
if (hasImages) {
|
||||
await cp(imagesDir, backupDir, { recursive: true });
|
||||
const saved: string[] = [];
|
||||
for (const relative of UPLOAD_DIRS) {
|
||||
const source = path.join(this.workspaceDir, relative);
|
||||
if (!existsSync(source)) continue;
|
||||
await cp(source, path.join(backupRoot, relative), { recursive: true });
|
||||
saved.push(relative);
|
||||
}
|
||||
|
||||
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||
@@ -70,15 +79,16 @@ export class GitService {
|
||||
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||
this.git = simpleGit(this.workspaceDir);
|
||||
|
||||
if (hasImages) {
|
||||
for (const relative of saved) {
|
||||
// force: false -> was schon im Repo liegt, bleibt unangetastet
|
||||
await cp(backupDir, imagesDir, {
|
||||
await cp(path.join(backupRoot, relative), path.join(this.workspaceDir, relative), {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: false,
|
||||
});
|
||||
await rm(backupDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await rm(backupRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Configure git user
|
||||
@@ -117,6 +127,7 @@ export class GitService {
|
||||
const git = simpleGit(this.workspaceDir);
|
||||
await git.reset(['--hard', 'HEAD']);
|
||||
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
|
||||
await git.clean('f', ['-d', '-e', 'public/images']);
|
||||
const excludes = UPLOAD_DIRS.flatMap((dir) => ['-e', dir.split(path.sep).join('/')]);
|
||||
await git.clean('f', ['-d', ...excludes]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections } from '../db/schema.js';
|
||||
import { AssetService } from './asset.service.js';
|
||||
|
||||
const assets = new AssetService();
|
||||
|
||||
/**
|
||||
* Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird.
|
||||
* Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden.
|
||||
*/
|
||||
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
const referenced = await collectReferencedImageUrls();
|
||||
if (referenced.has(url)) return false;
|
||||
return assets.deleteImage(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt.
|
||||
*
|
||||
* Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde
|
||||
* ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht.
|
||||
*
|
||||
* Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit,
|
||||
* Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht.
|
||||
*/
|
||||
export async function collectReferencedImageUrls(): Promise<Set<string>> {
|
||||
const urls = new Set<string>();
|
||||
|
||||
for (const row of (await db.select().from(events)) as any[]) {
|
||||
if (row.imageUrl) urls.add(row.imageUrl);
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||
if (row.imageUrl) urls.add(row.imageUrl);
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||
collectFromJson(row.contentJson, urls);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
|
||||
export function extractImageUrls(value: any): Set<string> {
|
||||
const out = new Set<string>();
|
||||
collectFromJson(value, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectFromJson(value: any, out: Set<string>): void {
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('/images/')) {
|
||||
out.add(value);
|
||||
return;
|
||||
}
|
||||
// Je nach Treiber kommt das JSON als String zurueck
|
||||
if (value.startsWith('{') || value.startsWith('[')) {
|
||||
try {
|
||||
collectFromJson(JSON.parse(value), out);
|
||||
} catch {
|
||||
// kein JSON - ignorieren
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectFromJson(entry, out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) collectFromJson(entry, out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export type UploadSubdir = 'events' | 'gallery' | 'content';
|
||||
|
||||
export interface SavedImage {
|
||||
filename: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und
|
||||
* legt ihn unter public/images/<subdir> im Git-Workspace ab.
|
||||
*
|
||||
* Der Dateiname folgt dem Muster <zeitstempel>-<zufall>.<ext>. Daran erkennt
|
||||
* der AssetService spaeter, dass er die Datei wieder loeschen darf.
|
||||
*/
|
||||
export async function saveUploadedImage(
|
||||
file: any,
|
||||
subdir: UploadSubdir,
|
||||
log?: { warn: (obj: any, msg: string) => void }
|
||||
): Promise<SavedImage> {
|
||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const inputBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Ohne diese Pruefung landet bei zu grossen Dateien ein abgeschnittenes,
|
||||
// kaputtes Bild auf der Platte
|
||||
if (file.file?.truncated) {
|
||||
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
|
||||
const error: any = new Error(`Image too large. Maximum is ${limit} MB`);
|
||||
error.statusCode = 413;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
let outBuffer: Buffer;
|
||||
let outExt = '.webp';
|
||||
|
||||
try {
|
||||
// Sharp erst laden wenn wirklich gebraucht
|
||||
const sharp = (await import('sharp')).default;
|
||||
outBuffer = await sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
} catch (err) {
|
||||
log?.warn({ err }, 'Sharp processing failed, using original image');
|
||||
outBuffer = inputBuffer;
|
||||
const extFromMime = (file.mimetype || '').split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
const filename = `${stamp}-${rand}${outExt}`;
|
||||
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
||||
|
||||
return { filename, imageUrl: `/images/${subdir}/${filename}` };
|
||||
}
|
||||
Reference in New Issue
Block a user