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' };
});
};