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 3b6cb0a3fb
commit daccc43677
16 changed files with 603 additions and 186 deletions

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