images fixing with database saves
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2025-12-09 20:12:08 +01:00
parent 8eb2be8628
commit c45e054787
5 changed files with 718 additions and 496 deletions

View File

@ -5,67 +5,56 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Install build dependencies for native modules (better-sqlite3, sharp)
RUN apk add --no-cache python3 make g++ vips-dev RUN apk add --no-cache python3 make g++ vips-dev
# Install dependencies
COPY package*.json ./ COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev
RUN npm ci || npm install RUN npm ci || npm install
# Copy source
COPY . . COPY . .
# Build TypeScript RUN npm run db:generate || true
RUN npm run build RUN npm run build
# Stage 2: Production # Stage 2: Production
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp)
# Note: python3, make, g++ are needed for native module compilation
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++ RUN apk add --no-cache git sqlite vips vips-dev python3 make g++
# Copy package files first
COPY --from=builder /app/package*.json ./ COPY --from=builder /app/package*.json ./
# Install all production dependencies and rebuild sharp for linuxmusl-x64
RUN npm ci --omit=dev || npm install --production && \ RUN npm ci --omit=dev || npm install --production && \
npm rebuild sharp npm rebuild sharp
# Clean up build dependencies after installation to reduce image size
RUN apk del python3 make g++ vips-dev RUN apk del python3 make g++ vips-dev
# Copy built files from builder
# Copy built files
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/db/migrations ./dist/db/migrations COPY --from=builder /app/src/db/migrations ./dist/db/migrations
# Copy migration script and migrated images
COPY --from=builder /app/migrate-production.js ./migrate-production.js COPY --from=builder /app/migrate-production.js ./migrate-production.js
COPY --from=builder /app/data/images ./data/images COPY --from=builder /app/data/images ./data/images
# Create directories
RUN mkdir -p /app/workspace /app/data RUN mkdir -p /app/workspace /app/data
# Ensure proper permissions
RUN chown -R node:node /app RUN chown -R node:node /app
# Switch to non-root user
USER node USER node
# Expose port
EXPOSE 8080 EXPOSE 8080
# Set environment
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=8080 ENV PORT=8080
ENV DATABASE_PATH=/app/data/gallus_cms.db ENV DATABASE_PATH=/app/data/gallus_cms.db
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Run DB migrations if present, then start application CMD ["/bin/sh", "-lc", "\
CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"] mkdir -p /app/data/images/events /app/data/images/gallery; \
echo 'Running Drizzle migrations...'; \
npx drizzle-kit generate:sqlite || true; \
npx drizzle-kit migrate || true; \
node dist/index.js \
"]

View File

@ -20,7 +20,8 @@ export const events = sqliteTable('events', {
title: text('title').notNull(), title: text('title').notNull(),
date: text('date').notNull(), date: text('date').notNull(),
description: text('description').notNull(), description: text('description').notNull(),
imageUrl: text('image_url').notNull(), imageData: text('image_data').notNull(), // base64 encoded image
mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg'
displayOrder: integer('display_order').notNull(), displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true), isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`), createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
@ -30,7 +31,8 @@ export const events = sqliteTable('events', {
// Gallery images table // Gallery images table
export const galleryImages = sqliteTable('gallery_images', { export const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
imageUrl: text('image_url').notNull(), imageData: text('image_data').notNull(), // base64 encoded image
mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg'
altText: text('alt_text').notNull(), altText: text('alt_text').notNull(),
displayOrder: integer('display_order').notNull(), displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true), isPublished: integer('is_published', { mode: 'boolean' }).default(true),

View File

@ -2,20 +2,18 @@ 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';
// Fastify JSON schema for event body
const eventBodyJsonSchema = { const eventBodyJsonSchema = {
type: 'object', type: 'object',
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'], required: ['title', 'date', 'description', 'displayOrder'],
properties: { properties: {
title: { type: 'string', minLength: 1, maxLength: 200 }, title: { type: 'string', minLength: 1, maxLength: 200 },
date: { type: 'string', minLength: 1, maxLength: 100 }, date: { type: 'string', minLength: 1, maxLength: 100 },
description: { type: 'string', minLength: 1 }, description: { type: 'string', minLength: 1 },
imageUrl: { type: 'string', minLength: 1 },
displayOrder: { type: 'integer', minimum: 0 }, displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' }, isPublished: { type: 'boolean' },
imageData: { type: 'string' },
mimeType: { type: 'string' },
}, },
} as const; } as const;
@ -38,50 +36,126 @@ const reorderBodyJsonSchema = {
} as const; } as const;
const eventsRoute: FastifyPluginAsync = async (fastify) => { const eventsRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published events (no auth required) // PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => { fastify.get('/events/public', async () => {
const all = await db.select().from(events) const all = await db.select({
id: events.id,
title: events.title,
date: events.date,
description: events.description,
displayOrder: events.displayOrder,
isPublished: events.isPublished,
}).from(events)
.where(eq(events.isPublished, true)) .where(eq(events.isPublished, true))
.orderBy(events.displayOrder); .orderBy(events.displayOrder);
return { events: all };
return {
events: all.map(ev => ({
...ev,
imageUrl: `/api/events/${ev.id}/image`,
}))
};
}); });
// List all events (by displayOrder) - admin only // Serve event image
fastify.get('/events/:id/image', async (request, reply) => {
const { id } = request.params as { id: string };
const [event] = await db.select({
imageData: events.imageData,
mimeType: events.mimeType,
}).from(events).where(eq(events.id, id)).limit(1);
if (!event || !event.imageData) {
return reply.code(404).send({ error: 'Image not found' });
}
const buffer = Buffer.from(event.imageData, 'base64');
return reply
.header('Content-Type', event.mimeType || 'image/webp')
.header('Cache-Control', 'public, max-age=31536000')
.send(buffer);
});
// List all events - admin only
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => { fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder); const all = await db.select({
return { events: all }; id: events.id,
title: events.title,
date: events.date,
description: events.description,
displayOrder: events.displayOrder,
isPublished: events.isPublished,
}).from(events).orderBy(events.displayOrder);
return {
events: all.map(ev => ({
...ev,
imageUrl: `/api/events/${ev.id}/image`,
}))
};
}); });
// Get single event // Get single event
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1); const [event] = await db.select({
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' }); id: events.id,
return { event: rows[0] }; title: events.title,
date: events.date,
description: events.description,
displayOrder: events.displayOrder,
isPublished: events.isPublished,
}).from(events).where(eq(events.id, id)).limit(1);
if (!event) {
return reply.code(404).send({ error: 'Event not found' });
}
return {
event: {
...event,
imageUrl: `/api/events/${event.id}/image`,
}
};
}); });
// Create event // Create event
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => { fastify.post('/events', {
schema: { body: eventBodyJsonSchema },
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as any; const data = request.body as any;
const [row] = await db.insert(events).values(data).returning(); const [row] = await db.insert(events).values({
return reply.code(201).send({ event: row }); title: data.title,
date: data.date,
description: data.description,
displayOrder: data.displayOrder,
isPublished: data.isPublished ?? true,
imageData: data.imageData || '',
mimeType: data.mimeType || '',
}).returning({
id: events.id,
title: events.title,
date: events.date,
description: events.description,
displayOrder: events.displayOrder,
isPublished: events.isPublished,
}); });
// Update event return reply.code(201).send({
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => { event: {
const { id } = request.params as { id: string }; ...row,
const data = request.body as any; imageUrl: `/api/events/${row.id}/image`,
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' }); });
return { event: row };
}); });
// Upload event image file (multipart) // Upload event image (returns base64 data)
fastify.post('/events/upload', { fastify.post('/events/upload', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
try { try {
// Expect a single file field named "file"
const file = await (request as any).file(); const file = await (request as any).file();
if (!file) { if (!file) {
return reply.code(400).send({ error: 'No file uploaded' }); return reply.code(400).send({ error: 'No file uploaded' });
@ -92,11 +166,6 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' }); 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 });
// Read uploaded stream into buffer // Read uploaded stream into buffer
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of file.file) { for await (const chunk of file.file) {
@ -104,38 +173,25 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
} }
const inputBuffer = Buffer.concat(chunks); const inputBuffer = Buffer.concat(chunks);
// Generate filename // Process image and convert to base64
const stamp = Date.now().toString(36); let imageData: string;
const rand = Math.random().toString(36).slice(2, 8); let mimeType = 'image/webp';
const baseName = `${stamp}-${rand}`;
// Try to convert to webp and limit size; fallback to original
let outBuffer: Buffer | null = null;
let outExt = '.webp';
try { try {
// Lazy load sharp only when needed
const sharp = (await import('sharp')).default; const sharp = (await import('sharp')).default;
outBuffer = await sharp(inputBuffer) const processedBuffer = await sharp(inputBuffer)
.rotate() .rotate()
.resize({ width: 1600, withoutEnlargement: true }) .resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 }) .webp({ quality: 82 })
.toBuffer(); .toBuffer();
imageData = processedBuffer.toString('base64');
} catch (err) { } catch (err) {
fastify.log.warn({ err }, 'Sharp processing failed, using original image'); fastify.log.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer; imageData = inputBuffer.toString('base64');
// naive extension from mimetype mimeType = mime;
const extFromMime = mime.split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
} }
const filename = baseName + outExt; return reply.code(201).send({ imageData, mimeType });
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) { } catch (err) {
fastify.log.error({ err }, 'Upload failed'); fastify.log.error({ err }, 'Upload failed');
@ -143,6 +199,110 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
} }
}); });
// Update event
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 updateData: any = {
title: data.title,
date: data.date,
description: data.description,
displayOrder: data.displayOrder,
isPublished: data.isPublished,
updatedAt: new Date(),
};
// Only update image if provided
if (data.imageData) {
updateData.imageData = data.imageData;
updateData.mimeType = data.mimeType || 'image/webp';
}
const [row] = await db.update(events)
.set(updateData)
.where(eq(events.id, id))
.returning({
id: events.id,
title: events.title,
date: events.date,
description: events.description,
displayOrder: events.displayOrder,
isPublished: events.isPublished,
});
if (!row) {
return reply.code(404).send({ error: 'Event not found' });
}
return {
event: {
...row,
imageUrl: `/api/events/${row.id}/image`,
}
};
});
// Update event image only
fastify.put('/events/:id/image', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
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 chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const inputBuffer = Buffer.concat(chunks);
let imageData: string;
let mimeType = 'image/webp';
try {
const sharp = (await import('sharp')).default;
const processedBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
imageData = processedBuffer.toString('base64');
} catch (err) {
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
imageData = inputBuffer.toString('base64');
mimeType = mime;
}
const [row] = await db.update(events)
.set({ imageData, mimeType, updatedAt: new Date() })
.where(eq(events.id, id))
.returning({ id: events.id });
if (!row) {
return reply.code(404).send({ error: 'Event not found' });
}
return { message: 'Image updated', imageUrl: `/api/events/${id}/image` };
} catch (err) {
fastify.log.error({ err }, 'Image update failed');
return reply.code(500).send({ error: 'Failed to update 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 };
@ -151,8 +311,11 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
return { message: 'Event deleted successfully' }; return { message: 'Event deleted successfully' };
}); });
// Reorder events (synchronous transaction for better-sqlite3) // Reorder events
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => { fastify.put('/events/reorder', {
schema: { body: reorderBodyJsonSchema },
preHandler: [fastify.authenticate],
}, async (request) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> }; const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
db.transaction((tx: any) => { db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) { for (const { id, displayOrder } of orders) {

View File

@ -1,17 +1,12 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js'; import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
// Fastify JSON schema for gallery image body
const galleryBodyJsonSchema = { const galleryBodyJsonSchema = {
type: 'object', type: 'object',
required: ['imageUrl', 'altText', 'displayOrder'], required: ['altText', 'displayOrder'],
properties: { properties: {
imageUrl: { type: 'string', minLength: 1 },
altText: { type: 'string', minLength: 1, maxLength: 200 }, altText: { type: 'string', minLength: 1, maxLength: 200 },
displayOrder: { type: 'integer', minimum: 0 }, displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' }, isPublished: { type: 'boolean' },
@ -22,46 +17,99 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published gallery images (no auth required) // PUBLIC: List published gallery images (no auth required)
fastify.get('/gallery/public', async () => { fastify.get('/gallery/public', async () => {
const images = await db.select().from(galleryImages) const images = await db.select({
id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
}).from(galleryImages)
.where(eq(galleryImages.isPublished, true)) .where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder); .orderBy(galleryImages.displayOrder);
return { images };
// Return with generated URLs pointing to the image endpoint
return {
images: images.map(img => ({
...img,
imageUrl: `/api/gallery/${img.id}/image`,
}))
};
});
// Serve image data
fastify.get('/gallery/:id/image', async (request, reply) => {
const { id } = request.params as { id: string };
const [image] = await db.select({
imageData: galleryImages.imageData,
mimeType: galleryImages.mimeType,
}).from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
if (!image || !image.imageData) {
return reply.code(404).send({ error: 'Image not found' });
}
const buffer = Buffer.from(image.imageData, 'base64');
return reply
.header('Content-Type', image.mimeType || 'image/webp')
.header('Cache-Control', 'public, max-age=31536000')
.send(buffer);
}); });
// List all gallery images - admin only // List all gallery images - admin only
fastify.get('/gallery', { fastify.get('/gallery', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async () => {
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder); const images = await db.select({
return { images }; id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
}).from(galleryImages).orderBy(galleryImages.displayOrder);
return {
images: images.map(img => ({
...img,
imageUrl: `/api/gallery/${img.id}/image`,
}))
};
}); });
// Get single gallery image // Get single gallery image metadata
fastify.get('/gallery/:id', { fastify.get('/gallery/:id', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1); const [image] = await db.select({
id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
}).from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
if (image.length === 0) { if (!image) {
return reply.code(404).send({ error: 'Image not found' }); return reply.code(404).send({ error: 'Image not found' });
} }
return { image: image[0] }; return {
image: {
...image,
imageUrl: `/api/gallery/${image.id}/image`,
}
};
}); });
// Create gallery image // Create gallery image (JSON, no file)
fastify.post('/gallery', { fastify.post('/gallery', {
schema: { schema: { body: galleryBodyJsonSchema },
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const data = request.body as any; const data = request.body as any;
const [newImage] = await db.insert(galleryImages).values(data).returning(); const [newImage] = await db.insert(galleryImages).values(data).returning();
return reply.code(201).send({
return reply.code(201).send({ image: newImage }); image: {
...newImage,
imageUrl: `/api/gallery/${newImage.id}/image`,
}
});
}); });
// Upload image file (multipart) // Upload image file (multipart)
@ -69,7 +117,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
try { try {
// Expect a single file field named "file"
const file = await (request as any).file(); const file = await (request as any).file();
if (!file) { if (!file) {
return reply.code(400).send({ error: 'No file uploaded' }); return reply.code(400).send({ error: 'No file uploaded' });
@ -84,11 +131,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' }); 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 // Read uploaded stream into buffer
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of file.file) { for await (const chunk of file.file) {
@ -96,46 +138,44 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
} }
const inputBuffer = Buffer.concat(chunks); const inputBuffer = Buffer.concat(chunks);
// Generate filename // Process image and convert to base64
const stamp = Date.now().toString(36); let imageData: string;
const rand = Math.random().toString(36).slice(2, 8); let mimeType = 'image/webp';
const baseName = `${stamp}-${rand}`;
// Try to convert to webp and limit size; fallback to original
let outBuffer: Buffer | null = null;
let outExt = '.webp';
try { try {
// Lazy load sharp only when needed
const sharp = (await import('sharp')).default; const sharp = (await import('sharp')).default;
outBuffer = await sharp(inputBuffer) const processedBuffer = await sharp(inputBuffer)
.rotate() .rotate()
.resize({ width: 1600, withoutEnlargement: true }) .resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 }) .webp({ quality: 82 })
.toBuffer(); .toBuffer();
imageData = processedBuffer.toString('base64');
} catch (err) { } catch (err) {
fastify.log.warn({ err }, 'Sharp processing failed, using original image'); fastify.log.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer; imageData = inputBuffer.toString('base64');
// naive extension from mimetype mimeType = mime;
const extFromMime = mime.split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
} }
const filename = baseName + outExt; // Store in DB
const destPath = path.join(uploadDir, filename);
fs.writeFileSync(destPath, outBuffer);
// Public URL (served via /static)
const publicUrl = `/images/gallery/${filename}`;
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({ const [row] = await db.insert(galleryImages).values({
imageUrl: publicUrl, imageData,
altText: altText || filename, mimeType,
altText: altText || 'Gallery image',
displayOrder, displayOrder,
isPublished: true, isPublished: true,
}).returning(); }).returning({
id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
});
return reply.code(201).send({ image: row }); return reply.code(201).send({
image: {
...row,
imageUrl: `/api/gallery/${row.id}/image`,
}
});
} catch (err) { } catch (err) {
fastify.log.error({ err }, 'Upload failed'); fastify.log.error({ err }, 'Upload failed');
@ -145,9 +185,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Update gallery image // Update gallery image
fastify.put('/gallery/:id', { fastify.put('/gallery/:id', {
schema: { schema: { body: galleryBodyJsonSchema },
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
@ -157,13 +195,23 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
.update(galleryImages) .update(galleryImages)
.set(data) .set(data)
.where(eq(galleryImages.id, id)) .where(eq(galleryImages.id, id))
.returning(); .returning({
id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
});
if (!updated) { if (!updated) {
return reply.code(404).send({ error: 'Image not found' }); return reply.code(404).send({ error: 'Image not found' });
} }
return { image: updated }; return {
image: {
...updated,
imageUrl: `/api/gallery/${updated.id}/image`,
}
};
}); });
// Delete gallery image // Delete gallery image
@ -209,7 +257,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => { }, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> }; const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
// Update all in synchronous transaction (better-sqlite3 requirement)
db.transaction((tx: any) => { db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) { for (const { id, displayOrder } of orders) {
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.(); tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();

View File

@ -92,7 +92,6 @@ const title = 'Admin';
</section> </section>
<script> <script>
// Base-URL des Backends (separate Subdomain)
const API_BASE = 'https://cms.gallus-pub.ch'; const API_BASE = 'https://cms.gallus-pub.ch';
const api = async (path, opts = {}) => { const api = async (path, opts = {}) => {
@ -102,33 +101,38 @@ const title = 'Admin';
return ct.includes('application/json') ? res.json() : res.text(); return ct.includes('application/json') ? res.json() : res.text();
}; };
// Helper to get full image URL
function getImageUrl(imageUrl) {
if (!imageUrl) return '';
// API image endpoints need the base URL prefix
if (imageUrl.startsWith('/api/')) {
return API_BASE + imageUrl;
}
return imageUrl;
}
async function refreshAuth() { async function refreshAuth() {
try { try {
const me = await api('/api/auth/me'); const me = await api('/api/auth/me');
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`; document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
// UI-Bereiche für eingeloggte Nutzer einblenden
document.getElementById('sec-events').style.display = ''; document.getElementById('sec-events').style.display = '';
document.getElementById('sec-gallery').style.display = ''; document.getElementById('sec-gallery').style.display = '';
document.getElementById('sec-publish').style.display = ''; document.getElementById('sec-publish').style.display = '';
// Direkt Events laden und auf Sektion fokussieren
await loadEvents(); await loadEvents();
await loadGallery(); await loadGallery();
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' }); document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
} catch (e) { } catch (e) {
const el = document.getElementById('auth-status'); const el = document.getElementById('auth-status');
el.textContent = 'Nicht angemeldet'; el.textContent = 'Nicht angemeldet';
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
document.getElementById('sec-events').style.display = 'none'; document.getElementById('sec-events').style.display = 'none';
document.getElementById('sec-gallery').style.display = 'none'; document.getElementById('sec-gallery').style.display = 'none';
document.getElementById('sec-publish').style.display = 'none'; document.getElementById('sec-publish').style.display = 'none';
} }
} }
// Fallback: falls der Link von Browser/Extensions blockiert wäre
const loginLink = document.getElementById('login-link'); const loginLink = document.getElementById('login-link');
loginLink.addEventListener('click', (e) => { loginLink.addEventListener('click', (e) => {
try { try {
// Stelle sicher, dass Navigieren erzwungen wird
window.location.assign(API_BASE + '/api/auth/gitea'); window.location.assign(API_BASE + '/api/auth/gitea');
} catch {} } catch {}
}); });
@ -143,13 +147,13 @@ const title = 'Admin';
await refreshAuth(); await refreshAuth();
}); });
// ========== Events & Publish ========== // Upload image and get base64 data back
async function uploadEventImage(file) { async function uploadEventImage(file) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' }); const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json(); // Returns { imageData, mimeType }
} }
async function uploadGalleryImage(file, altText) { async function uploadGalleryImage(file, altText) {
@ -176,15 +180,12 @@ const title = 'Admin';
try { try {
const data = await api('/api/events'); const data = await api('/api/events');
listEl.innerHTML = ''; listEl.innerHTML = '';
// Merken, globale Liste aktualisieren
lastEvents = (data.events || []).slice(); lastEvents = (data.events || []).slice();
let renderList = lastEvents.slice(); let renderList = lastEvents.slice();
if (!reorderMode) { if (!reorderMode) {
// Automatisch nach Datum sortieren (neueste zuerst)
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date)); renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)'; document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
} else { } else {
// Nach displayOrder aufsteigend
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0)); renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)'; document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
} }
@ -195,14 +196,15 @@ const title = 'Admin';
card.setAttribute('draggable', String(reorderMode)); card.setAttribute('draggable', String(reorderMode));
card.dataset.id = ev.id; card.dataset.id = ev.id;
card.dataset.displayOrder = String(ev.displayOrder ?? idx); card.dataset.displayOrder = String(ev.displayOrder ?? idx);
const imgUrl = getImageUrl(ev.imageUrl);
card.innerHTML = ` card.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:center"> <div class="row" style="justify-content:space-between;align-items:center">
<div><strong>${ev.title}</strong></div> <div><strong>${ev.title}</strong></div>
${reorderMode ? '<span class="drag-handle"> Ziehen</span>' : ''} ${reorderMode ? '<span class="drag-handle">&#8597; Ziehen</span>' : ''}
</div> </div>
<div class="muted">${ev.date}</div> <div class="muted">${ev.date}</div>
<div>${ev.description || ''}</div> <div>${ev.description || ''}</div>
<div class="muted">Bild: ${ev.imageUrl || ''}</div> ${imgUrl ? `<img src="${imgUrl}" alt="${ev.title}" class="thumb" style="margin-top:0.5rem;max-height:120px;object-fit:cover;" />` : '<div class="muted">Kein Bild</div>'}
<div class="row-buttons"> <div class="row-buttons">
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button> <button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
</div>`; </div>`;
@ -226,7 +228,6 @@ const title = 'Admin';
} }
} }
// Drag & Drop Reorder
function enableDragAndDrop(container){ function enableDragAndDrop(container){
let draggingEl = null; let draggingEl = null;
container.querySelectorAll('.card').forEach(card => { container.querySelectorAll('.card').forEach(card => {
@ -256,27 +257,49 @@ const title = 'Admin';
const title = (document.getElementById('ev-title')).value.trim(); const title = (document.getElementById('ev-title')).value.trim();
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 = (document.getElementById('ev-file')).files[0];
const msg = document.getElementById('ev-create-msg'); const msg = document.getElementById('ev-create-msg');
if (!title || !date) {
msg.textContent = 'Titel und Datum sind erforderlich';
return;
}
msg.textContent = 'Lade Bild hoch...'; msg.textContent = 'Lade Bild hoch...';
try { try {
let imageUrl = ''; let imageData = '';
let mimeType = '';
if (file) { if (file) {
const up = await uploadEventImage(file); const uploadResult = await uploadEventImage(file);
imageUrl = up?.imageUrl || ''; imageData = uploadResult.imageData || '';
mimeType = uploadResult.mimeType || '';
} }
msg.textContent = 'Lege Event an...'; msg.textContent = 'Lege Event an...';
await api('/api/events', { await api('/api/events', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true }) headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
date,
description: desc,
imageData,
mimeType,
displayOrder: 0,
isPublished: true
})
}); });
msg.textContent = 'Event erstellt'; msg.textContent = 'Event erstellt';
(document.getElementById('ev-title')).value = ''; document.getElementById('ev-title').value = '';
(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 = '';
await loadEvents(); await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message } } catch(e) {
msg.textContent = 'Fehler: ' + e.message;
}
}); });
document.getElementById('btn-publish').addEventListener('click', async () => { document.getElementById('btn-publish').addEventListener('click', async () => {
@ -289,14 +312,12 @@ const title = 'Admin';
} catch(e){ s.textContent = 'Fehler: '+e.message } } catch(e){ s.textContent = 'Fehler: '+e.message }
}); });
// Toggle Reorder
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => { document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
reorderMode = !reorderMode; reorderMode = !reorderMode;
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none'; document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
await loadEvents(); await loadEvents();
}); });
// Save Order
document.getElementById('btn-save-order').addEventListener('click', async () => { document.getElementById('btn-save-order').addEventListener('click', async () => {
const container = document.getElementById('events-list'); const container = document.getElementById('events-list');
const cards = Array.from(container.querySelectorAll('.card')); const cards = Array.from(container.querySelectorAll('.card'));
@ -306,14 +327,13 @@ const title = 'Admin';
try { try {
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) }); await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
msg.textContent = 'Reihenfolge gespeichert'; msg.textContent = 'Reihenfolge gespeichert';
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
reorderMode = false; reorderMode = false;
document.getElementById('btn-save-order').style.display = 'none'; document.getElementById('btn-save-order').style.display = 'none';
await loadEvents(); await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message } } catch(e){ msg.textContent = 'Fehler: '+e.message }
}); });
// ========== Gallery ========== // Gallery
async function loadGallery() { async function loadGallery() {
const listEl = document.getElementById('gallery-list'); const listEl = document.getElementById('gallery-list');
listEl.innerHTML = '<div class="muted">Lade...</div>'; listEl.innerHTML = '<div class="muted">Lade...</div>';
@ -326,8 +346,9 @@ const title = 'Admin';
galleryImages.forEach((img) => { galleryImages.forEach((img) => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card'; card.className = 'card';
const imgUrl = getImageUrl(img.imageUrl);
card.innerHTML = ` card.innerHTML = `
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" class="thumb" /> <img src="${imgUrl}" alt="${img.altText}" class="thumb" />
<div class="muted">${img.altText || ''}</div> <div class="muted">${img.altText || ''}</div>
<div class="row-buttons"> <div class="row-buttons">
<button data-id="${img.id}" class="btn-del-gal">Löschen</button> <button data-id="${img.id}" class="btn-del-gal">Löschen</button>
@ -349,7 +370,7 @@ const title = 'Admin';
} }
document.getElementById('btn-create-gal').addEventListener('click', async () => { document.getElementById('btn-create-gal').addEventListener('click', async () => {
const file = /** @type {HTMLInputElement} */ (document.getElementById('gal-file')).files[0]; const file = (document.getElementById('gal-file')).files[0];
const alt = (document.getElementById('gal-alt')).value.trim(); const alt = (document.getElementById('gal-alt')).value.trim();
const msg = document.getElementById('gal-create-msg'); const msg = document.getElementById('gal-create-msg');
if (!file) { if (!file) {
@ -360,8 +381,8 @@ const title = 'Admin';
try { try {
await uploadGalleryImage(file, alt); await uploadGalleryImage(file, alt);
msg.textContent = 'Bild hochgeladen'; msg.textContent = 'Bild hochgeladen';
(document.getElementById('gal-file')).value = ''; document.getElementById('gal-file').value = '';
(document.getElementById('gal-alt')).value = ''; document.getElementById('gal-alt').value = '';
await loadGallery(); await loadGallery();
} catch(e){ msg.textContent = 'Fehler: '+e.message } } catch(e){ msg.textContent = 'Fehler: '+e.message }
}); });