import { FastifyPluginAsync } from 'fastify'; 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'; // Fastify JSON schema for gallery image body const galleryBodyJsonSchema = { type: 'object', required: ['imageUrl', 'altText', 'displayOrder'], properties: { imageUrl: { type: 'string', minLength: 1 }, altText: { type: 'string', minLength: 1, maxLength: 200 }, displayOrder: { type: 'integer', minimum: 0 }, isPublished: { type: 'boolean' }, }, } as const; const galleryRoute: FastifyPluginAsync = async (fastify) => { // PUBLIC: List published gallery images (no auth required) fastify.get('/gallery/public', async () => { const images = await db.select().from(galleryImages) .where(eq(galleryImages.isPublished, true)) .orderBy(galleryImages.displayOrder); return { images }; }); // List all gallery images - admin only fastify.get('/gallery', { preHandler: [fastify.authenticate], }, async (request, reply) => { const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder); return { images }; }); // Get single gallery image fastify.get('/gallery/:id', { preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params as { id: string }; const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1); if (image.length === 0) { return reply.code(404).send({ error: 'Image not found' }); } return { image: image[0] }; }); // Create gallery image fastify.post('/gallery', { schema: { body: galleryBodyJsonSchema, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const data = request.body as any; const [newImage] = await db.insert(galleryImages).values(data).returning(); return reply.code(201).send({ image: newImage }); }); // Upload image file (multipart) fastify.post('/gallery/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 altText = (file.fields?.altText?.value as string | undefined) || ''; const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0'; const displayOrder = Number.parseInt(displayOrderRaw) || 0; 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', '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 = `/static/images/gallery/${filename}`; // Store in DB (optional but useful) const [row] = await db.insert(galleryImages).values({ imageUrl: publicUrl, altText: altText || filename, displayOrder, isPublished: true, }).returning(); return reply.code(201).send({ image: row }); } catch (err) { fastify.log.error({ err }, 'Upload failed'); return reply.code(500).send({ error: 'Failed to upload image' }); } }); // Update gallery image fastify.put('/gallery/:id', { schema: { body: galleryBodyJsonSchema, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params as { id: string }; const data = request.body as any; const [updated] = await db .update(galleryImages) .set(data) .where(eq(galleryImages.id, id)) .returning(); if (!updated) { return reply.code(404).send({ error: 'Image not found' }); } return { image: updated }; }); // Delete gallery image fastify.delete('/gallery/:id', { preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params as { id: string }; const [deleted] = await db .delete(galleryImages) .where(eq(galleryImages.id, id)) .returning(); if (!deleted) { return reply.code(404).send({ error: 'Image not found' }); } return { message: 'Image deleted successfully' }; }); // Reorder gallery images fastify.put('/gallery/reorder', { schema: { body: { type: 'object', required: ['orders'], properties: { orders: { type: 'array', items: { type: 'object', required: ['id', 'displayOrder'], properties: { id: { type: 'string' }, displayOrder: { type: 'integer', minimum: 0 }, }, }, }, }, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> }; // Update all in synchronous transaction (better-sqlite3 requirement) db.transaction((tx: any) => { for (const { id, displayOrder } of orders) { tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.(); } }); return { message: 'Gallery images reordered successfully' }; }); }; export default galleryRoute;