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

@ -1,17 +1,12 @@
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'],
required: ['altText', 'displayOrder'],
properties: {
imageUrl: { type: 'string', minLength: 1 },
altText: { type: 'string', minLength: 1, maxLength: 200 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
@ -22,46 +17,99 @@ 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 };
const images = await db.select({
id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
}).from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
// 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
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
return { images };
}, async () => {
const images = await db.select({
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', {
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);
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 { image: image[0] };
return {
image: {
...image,
imageUrl: `/api/gallery/${image.id}/image`,
}
};
});
// Create gallery image
// Create gallery image (JSON, no file)
fastify.post('/gallery', {
schema: {
body: galleryBodyJsonSchema,
},
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 });
return reply.code(201).send({
image: {
...newImage,
imageUrl: `/api/gallery/${newImage.id}/image`,
}
});
});
// Upload image file (multipart)
@ -69,7 +117,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
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' });
@ -84,11 +131,6 @@ 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) {
@ -96,46 +138,44 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
}
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}`;
// Process image and convert to base64
let imageData: string;
let mimeType = 'image/webp';
// 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();
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');
outBuffer = inputBuffer;
// naive extension from mimetype
const extFromMime = mime.split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
imageData = inputBuffer.toString('base64');
mimeType = mime;
}
const filename = baseName + outExt;
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)
// Store in DB
const [row] = await db.insert(galleryImages).values({
imageUrl: publicUrl,
altText: altText || filename,
imageData,
mimeType,
altText: altText || 'Gallery image',
displayOrder,
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) {
fastify.log.error({ err }, 'Upload failed');
@ -145,25 +185,33 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
body: galleryBodyJsonSchema,
},
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();
.update(galleryImages)
.set(data)
.where(eq(galleryImages.id, id))
.returning({
id: galleryImages.id,
altText: galleryImages.altText,
displayOrder: galleryImages.displayOrder,
isPublished: galleryImages.isPublished,
});
if (!updated) {
return reply.code(404).send({ error: 'Image not found' });
}
return { image: updated };
return {
image: {
...updated,
imageUrl: `/api/gallery/${updated.id}/image`,
}
};
});
// Delete gallery image
@ -173,9 +221,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
const { id } = request.params as { id: string };
const [deleted] = await db
.delete(galleryImages)
.where(eq(galleryImages.id, id))
.returning();
.delete(galleryImages)
.where(eq(galleryImages.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Image not found' });
@ -209,7 +257,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
}, 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?.();
@ -220,4 +267,4 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
});
};
export default galleryRoute;
export default galleryRoute;