Add CMS features with admin interface and OAuth authentication integration

- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
This commit is contained in:
2025-12-08 16:00:40 +01:00
parent 22494084ce
commit a28d43db45
16 changed files with 603 additions and 186 deletions

34
backend/.env.local Normal file
View File

@ -0,0 +1,34 @@
# 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

View File

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

View File

@ -16,7 +16,6 @@
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.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

@ -3,7 +3,6 @@ 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';
@ -44,17 +43,12 @@ 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, {

View File

@ -6,10 +6,15 @@ import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
const callbackSchema = z.object({
code: z.string(),
state: z.string(),
});
// 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 authRoute: FastifyPluginAsync = async (fastify) => {
const giteaService = new GiteaService();
@ -22,8 +27,15 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
// Generate CSRF state token
const state = giteaService.generateState();
// Store state in session
request.session.set('oauth_state', state);
// Store state in a short-lived cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
maxAge: 10 * 60, // 10 minutes
});
// Generate authorization URL
const authUrl = giteaService.getAuthorizationUrl(state);
@ -38,20 +50,20 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
*/
fastify.get('/auth/callback', {
schema: {
querystring: callbackSchema,
querystring: callbackQueryJsonSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as z.infer<typeof callbackSchema>;
const { code, state } = request.query as { code: string; state: string };
// Verify CSRF state
const expectedState = request.session.get('oauth_state');
// Verify CSRF state from cookie
const expectedState = request.cookies?.oauth_state as string | undefined;
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state from session
request.session.delete('oauth_state');
// Clear state cookie
reply.clearCookie('oauth_state', { path: '/' });
// Exchange code for access token
const tokenResponse = await giteaService.exchangeCodeForToken(code);
@ -103,18 +115,27 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
{
id: user.id,
giteaId: user.giteaId,
username: user.giteaUsername,
role: user.role,
username: user.giteaUsername || '',
role: user.role ?? 'admin',
},
{ expiresIn: '24h' }
);
// Redirect to frontend with token
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
reply.setCookie('token', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
maxAge: 60 * 60 * 24, // 24h
});
// Redirect to admin dashboard
const frontendUrl = env.FRONTEND_URL;
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
return reply.redirect(`${frontendUrl}/admin`);
} catch (error) {
fastify.log.error('OAuth callback error:', error);
fastify.log.error({ err: error }, 'OAuth callback error');
return reply.code(500).send({ error: 'Authentication failed' });
}
});
@ -139,12 +160,14 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
}
return {
id: user.id,
username: user.giteaUsername,
email: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
user: {
id: user.id,
giteaUsername: user.giteaUsername,
giteaEmail: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
},
};
});
@ -157,6 +180,7 @@ 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,9 +4,14 @@ import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
const contentSectionSchema = z.object({
contentJson: z.record(z.any()),
});
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
type: 'object',
required: ['contentJson'],
properties: {
contentJson: {}, // allow any JSON
},
} as const;
const contentRoute: FastifyPluginAsync = async (fastify) => {
@ -36,12 +41,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
// Update content section
fastify.put('/content/:section', {
schema: {
body: contentSectionSchema,
body: contentBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const { contentJson } = request.body as z.infer<typeof contentSectionSchema>;
const { contentJson } = request.body as any;
// Check if section exists
const [existing] = await db
@ -87,7 +92,7 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
const sections = await db.select().from(contentSections);
return {
sections: sections.map(s => ({
sections: (sections as any[]).map((s: any) => ({
section: s.sectionName,
content: s.contentJson,
updatedAt: s.updatedAt,

View File

@ -1,121 +1,87 @@
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';
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),
});
// 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 eventsRoute: FastifyPluginAsync = async (fastify) => {
// 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 };
// List all events (by displayOrder)
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
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) => {
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
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] };
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: 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 });
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 });
});
// Update event
fastify.put('/events/:id', {
schema: {
body: eventSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
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 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 };
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 };
});
// 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 [deleted] = await db
.delete(events)
.where(eq(events.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Event not found' });
}
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' });
return { message: 'Event deleted successfully' };
});
// 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) => {
// 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 }> };
// Update all in transaction
await db.transaction(async (tx) => {
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
await tx
.update(events)
.set({ displayOrder })
.where(eq(events.id, id));
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
}
});
return { message: 'Events reordered successfully' };
});
};

View File

@ -4,12 +4,17 @@ import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
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),
});
// 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) => {
@ -38,11 +43,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Create gallery image
fastify.post('/gallery', {
schema: {
body: galleryImageSchema,
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as z.infer<typeof galleryImageSchema>;
const data = request.body as any;
const [newImage] = await db.insert(galleryImages).values(data).returning();
@ -52,12 +57,12 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
body: galleryImageSchema,
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as z.infer<typeof galleryImageSchema>;
const data = request.body as any;
const [updated] = await db
.update(galleryImages)
@ -93,24 +98,32 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Reorder gallery images
fastify.put('/gallery/reorder', {
schema: {
body: z.object({
orders: z.array(z.object({
id: z.string().uuid(),
displayOrder: z.number().int().min(0),
})),
}),
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 transaction
await db.transaction(async (tx) => {
// Update all in synchronous transaction (better-sqlite3 requirement)
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
await tx
.update(galleryImages)
.set({ displayOrder })
.where(eq(galleryImages.id, id));
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
}
});

View File

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

View File

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