Revert "images fixing with database saves"
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This reverts commit c45e054787.
This commit is contained in:
@ -5,56 +5,67 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules (better-sqlite3, sharp)
|
||||
RUN apk add --no-cache python3 make g++ vips-dev
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
||||
RUN npm ci || npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
RUN npm run db:generate || true
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine
|
||||
|
||||
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++
|
||||
|
||||
# Copy package files first
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
# Install all production dependencies and rebuild sharp for linuxmusl-x64
|
||||
RUN npm ci --omit=dev || npm install --production && \
|
||||
npm rebuild sharp
|
||||
|
||||
# Clean up build dependencies after installation to reduce image size
|
||||
RUN apk del python3 make g++ vips-dev
|
||||
|
||||
|
||||
# Copy built files
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
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/data/images ./data/images
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/workspace /app/data
|
||||
|
||||
# Ensure proper permissions
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
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)})"
|
||||
|
||||
CMD ["/bin/sh", "-lc", "\
|
||||
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 \
|
||||
"]
|
||||
# Run DB migrations if present, then start application
|
||||
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"]
|
||||
|
||||
@ -20,8 +20,7 @@ export const events = sqliteTable('events', {
|
||||
title: text('title').notNull(),
|
||||
date: text('date').notNull(),
|
||||
description: text('description').notNull(),
|
||||
imageData: text('image_data').notNull(), // base64 encoded image
|
||||
mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg'
|
||||
imageUrl: text('image_url').notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
@ -31,8 +30,7 @@ export const events = sqliteTable('events', {
|
||||
// Gallery images table
|
||||
export const galleryImages = sqliteTable('gallery_images', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
imageData: text('image_data').notNull(), // base64 encoded image
|
||||
mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg'
|
||||
imageUrl: text('image_url').notNull(),
|
||||
altText: text('alt_text').notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||
@ -61,4 +59,4 @@ export const publishHistory = sqliteTable('publish_history', {
|
||||
commitHash: text('commit_hash'),
|
||||
commitMessage: text('commit_message'),
|
||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,18 +2,20 @@ import { FastifyPluginAsync } from 'fastify';
|
||||
import { db } from '../config/database.js';
|
||||
import { events } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Fastify JSON schema for event body
|
||||
const eventBodyJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['title', 'date', 'description', 'displayOrder'],
|
||||
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
date: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
description: { type: 'string', minLength: 1 },
|
||||
imageUrl: { type: 'string', minLength: 1 },
|
||||
displayOrder: { type: 'integer', minimum: 0 },
|
||||
isPublished: { type: 'boolean' },
|
||||
imageData: { type: 'string' },
|
||||
mimeType: { type: 'string' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -36,126 +38,50 @@ const reorderBodyJsonSchema = {
|
||||
} as const;
|
||||
|
||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// PUBLIC: List published events (no auth required)
|
||||
fastify.get('/events/public', async () => {
|
||||
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))
|
||||
.orderBy(events.displayOrder);
|
||||
|
||||
return {
|
||||
events: all.map(ev => ({
|
||||
...ev,
|
||||
imageUrl: `/api/events/${ev.id}/image`,
|
||||
}))
|
||||
};
|
||||
const all = await db.select().from(events)
|
||||
.where(eq(events.isPublished, true))
|
||||
.orderBy(events.displayOrder);
|
||||
return { events: all };
|
||||
});
|
||||
|
||||
// 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
|
||||
// List all events (by displayOrder) - admin only
|
||||
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||
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).orderBy(events.displayOrder);
|
||||
|
||||
return {
|
||||
events: all.map(ev => ({
|
||||
...ev,
|
||||
imageUrl: `/api/events/${ev.id}/image`,
|
||||
}))
|
||||
};
|
||||
const all = await db.select().from(events).orderBy(events.displayOrder);
|
||||
return { events: all };
|
||||
});
|
||||
|
||||
// Get single event
|
||||
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const [event] = 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.id, id)).limit(1);
|
||||
|
||||
if (!event) {
|
||||
return reply.code(404).send({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
event: {
|
||||
...event,
|
||||
imageUrl: `/api/events/${event.id}/image`,
|
||||
}
|
||||
};
|
||||
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
|
||||
return { event: rows[0] };
|
||||
});
|
||||
|
||||
// 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 [row] = await db.insert(events).values({
|
||||
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,
|
||||
});
|
||||
|
||||
return reply.code(201).send({
|
||||
event: {
|
||||
...row,
|
||||
imageUrl: `/api/events/${row.id}/image`,
|
||||
}
|
||||
});
|
||||
const [row] = await db.insert(events).values(data).returning();
|
||||
return reply.code(201).send({ event: row });
|
||||
});
|
||||
|
||||
// Upload event image (returns base64 data)
|
||||
// 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 [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)
|
||||
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' });
|
||||
@ -166,6 +92,11 @@ const eventsRoute: 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', '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) {
|
||||
@ -173,25 +104,38 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
const inputBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Process image and convert to base64
|
||||
let imageData: string;
|
||||
let mimeType = 'image/webp';
|
||||
// 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;
|
||||
const processedBuffer = await sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
imageData = processedBuffer.toString('base64');
|
||||
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');
|
||||
imageData = inputBuffer.toString('base64');
|
||||
mimeType = mime;
|
||||
outBuffer = inputBuffer;
|
||||
// naive extension from mimetype
|
||||
const extFromMime = mime.split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
return reply.code(201).send({ imageData, mimeType });
|
||||
const filename = baseName + outExt;
|
||||
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) {
|
||||
fastify.log.error({ err }, 'Upload failed');
|
||||
@ -199,110 +143,6 @@ 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
|
||||
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
@ -311,11 +151,8 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return { message: 'Event deleted successfully' };
|
||||
});
|
||||
|
||||
// Reorder events
|
||||
fastify.put('/events/reorder', {
|
||||
schema: { body: reorderBodyJsonSchema },
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request) => {
|
||||
// Reorder events (synchronous transaction for better-sqlite3)
|
||||
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||
db.transaction((tx: any) => {
|
||||
for (const { id, displayOrder } of orders) {
|
||||
@ -326,4 +163,4 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default eventsRoute;
|
||||
export default eventsRoute;
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
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: ['altText', 'displayOrder'],
|
||||
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' },
|
||||
@ -17,99 +22,46 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// PUBLIC: List published gallery images (no auth required)
|
||||
fastify.get('/gallery/public', async () => {
|
||||
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);
|
||||
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 () => {
|
||||
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`,
|
||||
}))
|
||||
};
|
||||
}, async (request, reply) => {
|
||||
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
|
||||
return { images };
|
||||
});
|
||||
|
||||
// Get single gallery image metadata
|
||||
// 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({
|
||||
id: galleryImages.id,
|
||||
altText: galleryImages.altText,
|
||||
displayOrder: galleryImages.displayOrder,
|
||||
isPublished: galleryImages.isPublished,
|
||||
}).from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||
|
||||
if (!image) {
|
||||
if (image.length === 0) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
...image,
|
||||
imageUrl: `/api/gallery/${image.id}/image`,
|
||||
}
|
||||
};
|
||||
return { image: image[0] };
|
||||
});
|
||||
|
||||
// Create gallery image (JSON, no file)
|
||||
// Create gallery image
|
||||
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,
|
||||
imageUrl: `/api/gallery/${newImage.id}/image`,
|
||||
}
|
||||
});
|
||||
|
||||
return reply.code(201).send({ image: newImage });
|
||||
});
|
||||
|
||||
// Upload image file (multipart)
|
||||
@ -117,6 +69,7 @@ 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' });
|
||||
@ -131,6 +84,11 @@ 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) {
|
||||
@ -138,44 +96,46 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
const inputBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Process image and convert to base64
|
||||
let imageData: string;
|
||||
let mimeType = 'image/webp';
|
||||
// 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;
|
||||
const processedBuffer = await sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
imageData = processedBuffer.toString('base64');
|
||||
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');
|
||||
imageData = inputBuffer.toString('base64');
|
||||
mimeType = mime;
|
||||
outBuffer = inputBuffer;
|
||||
// naive extension from mimetype
|
||||
const extFromMime = mime.split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
// Store in DB
|
||||
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)
|
||||
const [row] = await db.insert(galleryImages).values({
|
||||
imageData,
|
||||
mimeType,
|
||||
altText: altText || 'Gallery image',
|
||||
imageUrl: publicUrl,
|
||||
altText: altText || filename,
|
||||
displayOrder,
|
||||
isPublished: true,
|
||||
}).returning({
|
||||
id: galleryImages.id,
|
||||
altText: galleryImages.altText,
|
||||
displayOrder: galleryImages.displayOrder,
|
||||
isPublished: galleryImages.isPublished,
|
||||
});
|
||||
}).returning();
|
||||
|
||||
return reply.code(201).send({
|
||||
image: {
|
||||
...row,
|
||||
imageUrl: `/api/gallery/${row.id}/image`,
|
||||
}
|
||||
});
|
||||
return reply.code(201).send({ image: row });
|
||||
|
||||
} catch (err) {
|
||||
fastify.log.error({ err }, 'Upload failed');
|
||||
@ -185,33 +145,25 @@ 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({
|
||||
id: galleryImages.id,
|
||||
altText: galleryImages.altText,
|
||||
displayOrder: galleryImages.displayOrder,
|
||||
isPublished: galleryImages.isPublished,
|
||||
});
|
||||
.update(galleryImages)
|
||||
.set(data)
|
||||
.where(eq(galleryImages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
...updated,
|
||||
imageUrl: `/api/gallery/${updated.id}/image`,
|
||||
}
|
||||
};
|
||||
return { image: updated };
|
||||
});
|
||||
|
||||
// Delete gallery image
|
||||
@ -221,9 +173,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' });
|
||||
@ -257,6 +209,7 @@ 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?.();
|
||||
@ -267,4 +220,4 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default galleryRoute;
|
||||
export default galleryRoute;
|
||||
|
||||
@ -9,7 +9,7 @@ const oldEvents = [
|
||||
{
|
||||
image: "/images/events/event_karaoke.jpg",
|
||||
title: "Karaoke",
|
||||
date: "2025-12-31", // Set as ongoing event
|
||||
date: "2025-12-31",
|
||||
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>
|
||||
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
|
||||
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
|
||||
@ -18,7 +18,7 @@ Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 7
|
||||
{
|
||||
image: "/images/events/event_pub-quiz.jpg",
|
||||
title: "Pub Quiz",
|
||||
date: "2025-12-31", // Set as ongoing event
|
||||
date: "2025-12-31",
|
||||
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
|
||||
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
|
||||
Auch Einzelpersonen sind herzlich willkommen! <br>
|
||||
@ -75,62 +75,43 @@ const oldGalleryImages = [
|
||||
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
|
||||
];
|
||||
|
||||
async function copyAndConvertImage(
|
||||
sourcePath: string,
|
||||
destDir: string,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
async function processImageToBase64(sourcePath: string): Promise<{ imageData: string; mimeType: string }> {
|
||||
const projectRoot = path.join(process.cwd(), '..');
|
||||
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
|
||||
|
||||
// Ensure destination directory exists
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
console.log(`Processing: ${fullSourcePath}`);
|
||||
|
||||
const ext = path.extname(filename);
|
||||
const baseName = path.basename(filename, ext);
|
||||
const webpFilename = `${baseName}.webp`;
|
||||
const destPath = path.join(destDir, webpFilename);
|
||||
|
||||
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(fullSourcePath)) {
|
||||
console.error(`Source file not found: ${fullSourcePath}`);
|
||||
throw new Error(`Source file not found: ${fullSourcePath}`);
|
||||
}
|
||||
|
||||
// Convert to webp and copy
|
||||
await sharp(fullSourcePath)
|
||||
.rotate() // Auto-rotate based on EXIF
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toFile(destPath);
|
||||
// Convert to webp and get buffer
|
||||
const buffer = await sharp(fullSourcePath)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer();
|
||||
|
||||
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
|
||||
return {
|
||||
imageData: buffer.toString('base64'),
|
||||
mimeType: 'image/webp',
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateEvents() {
|
||||
console.log('\n=== Migrating Events ===\n');
|
||||
|
||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||
const eventsImageDir = path.join(dataDir, 'images', 'events');
|
||||
|
||||
for (const event of oldEvents) {
|
||||
try {
|
||||
const filename = path.basename(event.image);
|
||||
const newImageUrl = await copyAndConvertImage(
|
||||
event.image,
|
||||
eventsImageDir,
|
||||
filename
|
||||
);
|
||||
const { imageData, mimeType } = await processImageToBase64(event.image);
|
||||
|
||||
const [newEvent] = await db.insert(events).values({
|
||||
title: event.title,
|
||||
date: event.date,
|
||||
description: event.description,
|
||||
imageUrl: newImageUrl,
|
||||
imageData,
|
||||
mimeType,
|
||||
displayOrder: event.displayOrder,
|
||||
isPublished: true,
|
||||
}).returning();
|
||||
@ -145,21 +126,14 @@ async function migrateEvents() {
|
||||
async function migrateGallery() {
|
||||
console.log('\n=== Migrating Gallery Images ===\n');
|
||||
|
||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
|
||||
|
||||
for (let i = 0; i < oldGalleryImages.length; i++) {
|
||||
const img = oldGalleryImages[i];
|
||||
try {
|
||||
const filename = path.basename(img.src);
|
||||
const newImageUrl = await copyAndConvertImage(
|
||||
img.src,
|
||||
galleryImageDir,
|
||||
filename
|
||||
);
|
||||
const { imageData, mimeType } = await processImageToBase64(img.src);
|
||||
|
||||
const [newImage] = await db.insert(galleryImages).values({
|
||||
imageUrl: newImageUrl,
|
||||
imageData,
|
||||
mimeType,
|
||||
altText: img.alt,
|
||||
displayOrder: i,
|
||||
isPublished: true,
|
||||
@ -175,7 +149,6 @@ async function migrateGallery() {
|
||||
async function main() {
|
||||
console.log('Starting migration of old data...\n');
|
||||
console.log('Working directory:', process.cwd());
|
||||
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
|
||||
|
||||
try {
|
||||
await migrateEvents();
|
||||
@ -187,4 +160,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
Reference in New Issue
Block a user