Add event image upload endpoint and refactor image upload handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Introduced `/events/upload` endpoint for securely uploading and processing event images.
- Added image validation, resizing, and conversion to WebP with fallback support for original formats.
- Updated `uploadImage` to `uploadEventImage` and introduced `uploadGalleryImage` in `admin.astro`.
This commit is contained in:
2025-12-09 17:34:00 +01:00
parent 89640a3372
commit 9c3b4be79d
2 changed files with 78 additions and 6 deletions

View File

@ -2,6 +2,9 @@ import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { events } from '../db/schema.js'; import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Fastify JSON schema for event body // Fastify JSON schema for event body
const eventBodyJsonSchema = { const eventBodyJsonSchema = {
@ -74,6 +77,70 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
return { event: row }; return { event: row };
}); });
// Upload event image file (multipart)
fastify.post('/events/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
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' });
}
// 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, 'images', 'events');
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 {
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch {
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 = `/static/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' });
}
});
// Delete event // Delete event
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };

View File

@ -51,7 +51,6 @@ const title = 'Admin';
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label> <label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label> <label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label> <label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
<label>Alt-Text<input id="ev-alt" placeholder="Bildbeschreibung" /></label>
<button id="btn-create-ev">Event anlegen</button> <button id="btn-create-ev">Event anlegen</button>
<div id="ev-create-msg" class="muted"></div> <div id="ev-create-msg" class="muted"></div>
</div> </div>
@ -125,7 +124,15 @@ const title = 'Admin';
}); });
// ========== Events & Publish ========== // ========== Events & Publish ==========
async function uploadImage(file, altText) { async function uploadEventImage(file) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function uploadGalleryImage(file, altText) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
if (altText) fd.append('altText', altText); if (altText) fd.append('altText', altText);
@ -230,14 +237,13 @@ const title = 'Admin';
const date = (document.getElementById('ev-date')).value.trim(); const date = (document.getElementById('ev-date')).value.trim();
const desc = (document.getElementById('ev-desc')).value.trim(); const desc = (document.getElementById('ev-desc')).value.trim();
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0]; const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
const alt = (document.getElementById('ev-alt')).value.trim();
const msg = document.getElementById('ev-create-msg'); const msg = document.getElementById('ev-create-msg');
msg.textContent = 'Lade Bild hoch...'; msg.textContent = 'Lade Bild hoch...';
try { try {
let imageUrl = ''; let imageUrl = '';
if (file) { if (file) {
const up = await uploadImage(file, alt || title); const up = await uploadEventImage(file);
imageUrl = up?.image?.imageUrl || ''; imageUrl = up?.imageUrl || '';
} }
msg.textContent = 'Lege Event an...'; msg.textContent = 'Lege Event an...';
await api('/api/events', { await api('/api/events', {
@ -249,7 +255,6 @@ const title = 'Admin';
(document.getElementById('ev-date')).value = ''; (document.getElementById('ev-date')).value = '';
(document.getElementById('ev-desc')).value = ''; (document.getElementById('ev-desc')).value = '';
(document.getElementById('ev-file')).value = ''; (document.getElementById('ev-file')).value = '';
(document.getElementById('ev-alt')).value = '';
await loadEvents(); await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message } } catch(e){ msg.textContent = 'Fehler: '+e.message }
}); });