Add event image upload endpoint and refactor image upload handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@ -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 };
|
||||||
|
|||||||
@ -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 }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user