All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Added `vips-dev` and `vips` to build and runtime dependencies. - Updated installation process to ensure compilation
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
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;
|