Remove unused backend infrastructure and template files for Gallus CMS.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- Deleted `backend/.env.local`, admin page, and OAuth callback page.
- Removed legacy Docker configurations [`docker-compose.yml`, `Dockerfile.caddy`].
- Deprecated migration script, unused routes, and event/gallery migration documentation.
- Updated gitignore to reflect removed folder structure.
This commit is contained in:
2025-12-09 17:20:39 +01:00
parent 0a2aa84a8c
commit 2f66a0af25
43 changed files with 276 additions and 1188 deletions

View File

@@ -1,34 +0,0 @@
# Local development environment for Gallus CMS Backend
# Database
DB_CLIENT=sqlite
DATABASE_URL=
DATABASE_PATH=./data/gallus_cms.db
# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
# Frontend proxy callback in local dev
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
GITEA_ALLOWED_USERS=Gallus-maintanance
# Git repository for content versioning
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
GIT_USER_NAME=Gallus-maintanance
GIT_USER_EMAIL=Admin@gallus-pub.ch
GIT_WORKSPACE_DIR=./data/workspace
# JWT & Session secrets (use strong random strings in real deployments)
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
# Server & CORS
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:4321
CORS_ORIGIN=http://localhost:4321
# Upload limits
MAX_FILE_SIZE=5242880

10
backend/.gitignore vendored
View File

@@ -4,9 +4,7 @@ dist
*.log
.DS_Store
/tmp
/data/*.db
/data/*.db-wal
/data/*.db-shm
/data/workspace
# Allow images to be committed
!/data/images
/data
*.db
*.db-wal
*.db-shm

View File

@@ -1,6 +1,6 @@
# Deployment Guide
## Prerequisite
## Prerequisites
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
2. Fly.io account: `flyctl auth login`

View File

@@ -10,8 +10,7 @@ RUN apk add --no-cache python3 make g++
# Install dependencies
COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev
RUN npm ci || npm install
RUN npm ci
# Copy source
COPY . .
@@ -27,8 +26,17 @@ WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
RUN apk add --no-cache git sqlite
# Copy production dependencies from builder (already compiled native modules)
COPY --from=builder /app/node_modules ./node_modules
# Install build dependencies for better-sqlite3 (needed for npm ci)
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --production
# Remove build dependencies after install
RUN apk del python3 make g++
# Copy built files from builder
COPY --from=builder /app/dist ./dist
@@ -55,5 +63,5 @@ ENV DATABASE_PATH=/app/data/gallus_cms.db
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", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
# Start application
CMD ["node", "dist/index.js"]

View File

@@ -52,4 +52,4 @@ See parent directory for complete documentation:
- `CMS_CONCEPT.md` - System architecture
- `CMS_GITEA_AUTH.md` - Authentication details
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
- `CMS_SETUP_GUIDE.md` - Deployment guide
- `CMS_SETUP_GUIDE.md` - Deployment guide

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -3,8 +3,6 @@ app = "gallus-cms-backend"
primary_region = "ams"
[build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env]
PORT = "8080"
@@ -12,10 +10,6 @@ primary_region = "ams"
GITEA_URL = "https://git.bookageek.ch"
DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
# Cross-site frontend and OAuth
FRONTEND_URL = "https://gallus-pub.ch"
CORS_ORIGIN = "https://gallus-pub.ch"
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
[http_service]
internal_port = 8080

View File

@@ -9,15 +9,14 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.0",
"@fastify/static": "^6.12.0",
"@fastify/multipart": "^8.1.0",
"@fastify/session": "^10.8.0",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.33.0",

View File

@@ -2,103 +2,14 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from '../db/schema.js';
import { env } from './env.js';
import fs from 'fs';
import path from 'path';
if (!env.DATABASE_PATH) {
throw new Error('DATABASE_PATH environment variable is not set');
}
// Ensure directory exists BEFORE opening the database file
const dbDir = path.dirname(env.DATABASE_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const sqlite = new Database(env.DATABASE_PATH);
// Enable WAL mode for better concurrent access
sqlite.pragma('journal_mode = WAL');
export const db = drizzle(sqlite, { schema });
// Auto-create tables if they don't exist
export function initDatabase() {
console.log('🔧 Initializing database...');
try {
// Check if users table exists (acts as a sentinel for initial setup)
const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (!tableCheck) {
console.log('📝 Creating database schema...');
sqlite.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
gitea_id TEXT UNIQUE NOT NULL,
gitea_username TEXT NOT NULL,
gitea_email TEXT,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (unixepoch()),
last_login INTEGER
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS gallery_images (
id TEXT PRIMARY KEY,
image_url TEXT NOT NULL,
alt_text TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS content_sections (
id TEXT PRIMARY KEY,
section_name TEXT UNIQUE NOT NULL,
content_json TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS publish_history (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
commit_hash TEXT,
commit_message TEXT,
published_at INTEGER DEFAULT (unixepoch())
);
`);
console.log('✅ Database schema created successfully!');
} else {
console.log('✅ Database already initialized.');
}
} catch (error) {
console.error('❌ Error initializing database:', error);
throw error;
}
}

View File

@@ -3,11 +3,9 @@ import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import multipart from '@fastify/multipart';
import cookie from '@fastify/cookie';
import session from '@fastify/session';
import { authenticate } from './middleware/auth.middleware.js';
import { env, validateEnv } from './config/env.js';
import { db, initDatabase } from './config/database.js';
import fastifyStatic from '@fastify/static';
import path from 'path';
// Import routes
import authRoute from './routes/auth.js';
@@ -46,12 +44,17 @@ fastify.register(cors, {
fastify.register(cookie);
fastify.register(session, {
secret: env.SESSION_SECRET,
cookie: {
secure: env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 600000, // 10 minutes (only needed for OAuth flow)
},
});
fastify.register(jwt, {
secret: env.JWT_SECRET,
cookie: {
cookieName: 'token',
signed: false,
},
});
fastify.register(multipart, {
@@ -60,14 +63,6 @@ fastify.register(multipart, {
},
});
// Serve static files (uploaded images, etc.) from persistent volume
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
fastify.register(fastifyStatic, {
root: dataDir,
prefix: '/static/',
decorateReply: false
});
// Decorate fastify with authenticate method
fastify.decorate('authenticate', authenticate);
@@ -110,8 +105,6 @@ fastify.setErrorHandler((error, request, reply) => {
// Start server
const start = async () => {
try {
// Initialize database before starting server
initDatabase();
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
console.log(`🚀 Server listening on port ${env.PORT}`);
console.log(`📝 Environment: ${env.NODE_ENV}`);

View File

@@ -6,15 +6,10 @@ import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
// Use explicit JSON schema for Fastify route validation to avoid provider issues
const callbackQueryJsonSchema = {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
},
} as const;
const callbackSchema = z.object({
code: z.string(),
state: z.string(),
});
const authRoute: FastifyPluginAsync = async (fastify) => {
const giteaService = new GiteaService();
@@ -27,14 +22,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
// Generate CSRF state token
const state = giteaService.generateState();
// Store state in a short-lived cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'none',
secure: true,
maxAge: 10 * 60, // 10 minutes
});
// Store state in session
request.session.set('oauth_state', state);
// Generate authorization URL
const authUrl = giteaService.getAuthorizationUrl(state);
@@ -49,20 +38,20 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
*/
fastify.get('/auth/callback', {
schema: {
querystring: callbackQueryJsonSchema,
querystring: callbackSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as { code: string; state: string };
const { code, state } = request.query as z.infer<typeof callbackSchema>;
// Verify CSRF state from cookie
const expectedState = request.cookies?.oauth_state as string | undefined;
// Verify CSRF state
const expectedState = request.session.get('oauth_state');
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state cookie
reply.clearCookie('oauth_state', { path: '/' });
// Clear state from session
request.session.delete('oauth_state');
// Exchange code for access token
const tokenResponse = await giteaService.exchangeCodeForToken(code);
@@ -114,28 +103,18 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
{
id: user.id,
giteaId: user.giteaId,
username: user.giteaUsername || '',
role: user.role ?? 'admin',
username: user.giteaUsername,
role: user.role,
},
{ expiresIn: '24h' }
);
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
reply.setCookie('token', token, {
path: '/',
httpOnly: true,
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
maxAge: 60 * 60 * 24, // 24h
});
// Redirect to admin dashboard
// Redirect to frontend with token
const frontendUrl = env.FRONTEND_URL;
return reply.redirect(`${frontendUrl}/admin`);
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
} catch (error) {
fastify.log.error({ err: error }, 'OAuth callback error');
fastify.log.error('OAuth callback error:', error);
return reply.code(500).send({ error: 'Authentication failed' });
}
});
@@ -160,14 +139,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
}
return {
user: {
id: user.id,
giteaUsername: user.giteaUsername,
giteaEmail: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
},
id: user.id,
username: user.giteaUsername,
email: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
};
});
@@ -180,7 +157,6 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => {
// For JWT, logout is primarily client-side (delete token)
// You could maintain a token blacklist in Redis for production
reply.clearCookie('token', { path: '/' });
return { message: 'Logged out successfully' };
});
};

View File

@@ -4,14 +4,9 @@ import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
type: 'object',
required: ['contentJson'],
properties: {
contentJson: {}, // allow any JSON
},
} as const;
const contentSectionSchema = z.object({
contentJson: z.record(z.any()),
});
const contentRoute: FastifyPluginAsync = async (fastify) => {
@@ -41,12 +36,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
// Update content section
fastify.put('/content/:section', {
schema: {
body: contentBodyJsonSchema,
body: contentSectionSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const { contentJson } = request.body as any;
const { contentJson } = request.body as z.infer<typeof contentSectionSchema>;
// Check if section exists
const [existing] = await db
@@ -92,7 +87,7 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
const sections = await db.select().from(contentSections);
return {
sections: (sections as any[]).map((s: any) => ({
sections: sections.map(s => ({
section: s.sectionName,
content: s.contentJson,
updatedAt: s.updatedAt,

View File

@@ -1,95 +1,121 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for event body
const eventBodyJsonSchema = {
type: 'object',
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' },
},
} as const;
const reorderBodyJsonSchema = {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
} as const;
const eventSchema = z.object({
title: z.string().min(1).max(200),
date: z.string().min(1).max(100),
description: z.string().min(1),
imageUrl: z.string().url(),
displayOrder: z.number().int().min(0),
isPublished: z.boolean().optional().default(true),
});
const eventsRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => {
const all = await db.select().from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
return { events: all };
});
// List all events (by displayOrder) - admin only
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder);
return { events: all };
// List all events
fastify.get('/events', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allEvents = await db.select().from(events).orderBy(events.displayOrder);
return { events: allEvents };
});
// 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 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] };
const event = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (event.length === 0) {
return reply.code(404).send({ error: 'Event not found' });
}
return { event: event[0] };
});
// Create event
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(data).returning();
return reply.code(201).send({ event: row });
fastify.post('/events', {
schema: {
body: eventSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as z.infer<typeof eventSchema>;
const [newEvent] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: newEvent });
});
// Update event
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.put('/events/:id', {
schema: {
body: eventSchema,
},
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 };
const data = request.body as z.infer<typeof eventSchema>;
const [updated] = await db
.update(events)
.set({ ...data, updatedAt: new Date() })
.where(eq(events.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Event not found' });
}
return { event: updated };
});
// 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 [row] = await db.delete(events).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' });
const [deleted] = await db
.delete(events)
.where(eq(events.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Event not found' });
}
return { message: 'Event deleted successfully' };
});
// Reorder events (synchronous transaction for better-sqlite3)
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
// Reorder events
fastify.put('/events/reorder', {
schema: {
body: z.object({
orders: z.array(z.object({
id: z.string().uuid(),
displayOrder: z.number().int().min(0),
})),
}),
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
db.transaction((tx: any) => {
// Update all in transaction
await db.transaction(async (tx) => {
for (const { id, displayOrder } of orders) {
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
await tx
.update(events)
.set({ displayOrder })
.where(eq(events.id, id));
}
});
return { message: 'Events reordered successfully' };
});
};

View File

@@ -3,33 +3,17 @@ 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';
import sharp from 'sharp';
// 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 galleryImageSchema = z.object({
imageUrl: z.string().url(),
altText: z.string().min(1).max(200),
displayOrder: z.number().int().min(0),
isPublished: z.boolean().optional().default(true),
});
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
// List all gallery images
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@@ -54,102 +38,26 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Create gallery image
fastify.post('/gallery', {
schema: {
body: galleryBodyJsonSchema,
body: galleryImageSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as any;
const data = request.body as z.infer<typeof galleryImageSchema>;
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 {
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch {
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,
body: galleryImageSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const data = request.body as z.infer<typeof galleryImageSchema>;
const [updated] = await db
.update(galleryImages)
@@ -185,32 +93,24 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// 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 },
},
},
},
},
},
body: z.object({
orders: z.array(z.object({
id: z.string().uuid(),
displayOrder: z.number().int().min(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) => {
// Update all in transaction
await db.transaction(async (tx) => {
for (const { id, displayOrder } of orders) {
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
await tx
.update(galleryImages)
.set({ displayOrder })
.where(eq(galleryImages.id, id));
}
});

View File

@@ -6,24 +6,19 @@ import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for publish body
const publishBodyJsonSchema = {
type: 'object',
required: ['commitMessage'],
properties: {
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
},
} as const;
const publishSchema = z.object({
commitMessage: z.string().min(1).max(200),
});
const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.post('/publish', {
schema: {
body: publishBodyJsonSchema,
body: publishSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const { commitMessage } = request.body as any;
const { commitMessage } = request.body as z.infer<typeof publishSchema>;
const userId = request.user.id;
fastify.log.info('Starting publish process...');
@@ -48,8 +43,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
.orderBy(galleryImages.displayOrder);
const sectionsData = await db.select().from(contentSections);
const sectionsMap = new Map<string, any>(
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
const sectionsMap = new Map(
sectionsData.map(s => [s.sectionName, s.contentJson as any])
);
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
@@ -58,13 +53,13 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
const fileGenerator = new FileGeneratorService();
await fileGenerator.writeFiles(
gitService.getWorkspacePath(''),
(eventsData as any[]).map((e: any) => ({
eventsData.map(e => ({
title: e.title,
date: e.date,
description: e.description,
imageUrl: e.imageUrl,
})),
(galleryData as any[]).map((g: any) => ({
galleryData.map(g => ({
imageUrl: g.imageUrl,
altText: g.altText,
})),
@@ -92,14 +87,14 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
};
} catch (error) {
fastify.log.error({ err: error }, 'Publish error');
fastify.log.error('Publish error:', error);
// Attempt to reset git state on error
try {
const gitService = new GitService();
await gitService.reset();
} catch (resetError) {
fastify.log.error({ err: resetError }, 'Failed to reset git state');
fastify.log.error('Failed to reset git state:', resetError);
}
return reply.code(500).send({

View File

@@ -4,14 +4,9 @@ import { db } from '../config/database.js';
import { siteSettings } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for settings body
const settingBodyJsonSchema = {
type: 'object',
required: ['value'],
properties: {
value: { type: 'string' },
},
} as const;
const settingSchema = z.object({
value: z.string(),
});
const settingsRoute: FastifyPluginAsync = async (fastify) => {
@@ -55,12 +50,12 @@ const settingsRoute: FastifyPluginAsync = async (fastify) => {
// Update setting
fastify.put('/settings/:key', {
schema: {
body: settingBodyJsonSchema,
body: settingSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const { value } = request.body as any;
const { value } = request.body as z.infer<typeof settingSchema>;
// Check if setting exists
const [existing] = await db

View File

@@ -1,190 +0,0 @@
import { db } from '../config/database.js';
import { events, galleryImages } from '../db/schema.js';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Old events data
const oldEvents = [
{
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "2025-12-31", // Set as ongoing event
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>`,
displayOrder: 0,
},
{
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "2025-12-31", // Set as ongoing event
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>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 2,
},
{
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
displayOrder: 3,
},
{
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
displayOrder: 4,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
displayOrder: 5,
},
{
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
];
async function copyAndConvertImage(
sourcePath: string,
destDir: string,
filename: string
): Promise<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 });
}
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);
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
}
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 [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: newImageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error);
}
}
}
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 [newImage] = await db.insert(galleryImages).values({
imageUrl: newImageUrl,
altText: img.alt,
displayOrder: i,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
}
}
}
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();
await migrateGallery();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
}
}
main();