- 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.
189 lines
5.3 KiB
TypeScript
189 lines
5.3 KiB
TypeScript
import { FastifyPluginAsync } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { db } from '../config/database.js';
|
|
import { users } from '../db/schema.js';
|
|
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 authRoute: FastifyPluginAsync = async (fastify) => {
|
|
const giteaService = new GiteaService();
|
|
|
|
/**
|
|
* GET /auth/gitea
|
|
* Initiate OAuth flow
|
|
*/
|
|
fastify.get('/auth/gitea', async (request, reply) => {
|
|
// Generate CSRF state token
|
|
const state = giteaService.generateState();
|
|
|
|
// 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);
|
|
|
|
// Redirect to Gitea
|
|
return reply.redirect(authUrl);
|
|
});
|
|
|
|
/**
|
|
* GET /auth/callback
|
|
* OAuth callback endpoint
|
|
*/
|
|
fastify.get('/auth/callback', {
|
|
schema: {
|
|
querystring: callbackQueryJsonSchema,
|
|
},
|
|
}, async (request, reply) => {
|
|
try {
|
|
const { code, state } = request.query as { code: string; state: string };
|
|
|
|
// 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 cookie
|
|
reply.clearCookie('oauth_state', { path: '/' });
|
|
|
|
// Exchange code for access token
|
|
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
|
|
|
// Fetch user info from Gitea
|
|
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
|
|
|
|
// Check if user is allowed
|
|
if (!giteaService.isUserAllowed(giteaUser.login)) {
|
|
return reply.code(403).send({
|
|
error: 'Access denied. You are not authorized to access this CMS.'
|
|
});
|
|
}
|
|
|
|
// Find or create user in database
|
|
let [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.giteaId, giteaUser.id.toString()))
|
|
.limit(1);
|
|
|
|
if (!user) {
|
|
// Create new user
|
|
[user] = await db.insert(users).values({
|
|
giteaId: giteaUser.id.toString(),
|
|
giteaUsername: giteaUser.login,
|
|
giteaEmail: giteaUser.email,
|
|
displayName: giteaUser.full_name,
|
|
avatarUrl: giteaUser.avatar_url,
|
|
lastLogin: new Date(),
|
|
}).returning();
|
|
} else {
|
|
// Update existing user
|
|
[user] = await db
|
|
.update(users)
|
|
.set({
|
|
giteaUsername: giteaUser.login,
|
|
giteaEmail: giteaUser.email,
|
|
displayName: giteaUser.full_name,
|
|
avatarUrl: giteaUser.avatar_url,
|
|
lastLogin: new Date(),
|
|
})
|
|
.where(eq(users.id, user.id))
|
|
.returning();
|
|
}
|
|
|
|
// Generate JWT for session management
|
|
const token = fastify.jwt.sign(
|
|
{
|
|
id: user.id,
|
|
giteaId: user.giteaId,
|
|
username: user.giteaUsername || '',
|
|
role: user.role ?? 'admin',
|
|
},
|
|
{ expiresIn: '24h' }
|
|
);
|
|
|
|
// 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}/admin`);
|
|
|
|
} catch (error) {
|
|
fastify.log.error({ err: error }, 'OAuth callback error');
|
|
return reply.code(500).send({ error: 'Authentication failed' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /auth/me
|
|
* Get current user info
|
|
*/
|
|
fastify.get('/auth/me', {
|
|
preHandler: [fastify.authenticate],
|
|
}, async (request, reply) => {
|
|
const userId = request.user.id;
|
|
|
|
const [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ error: 'User not found' });
|
|
}
|
|
|
|
return {
|
|
user: {
|
|
id: user.id,
|
|
giteaUsername: user.giteaUsername,
|
|
giteaEmail: user.giteaEmail,
|
|
displayName: user.displayName,
|
|
avatarUrl: user.avatarUrl,
|
|
role: user.role,
|
|
},
|
|
};
|
|
});
|
|
|
|
/**
|
|
* POST /auth/logout
|
|
* Logout (client-side token deletion)
|
|
*/
|
|
fastify.post('/auth/logout', {
|
|
preHandler: [fastify.authenticate],
|
|
}, 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' };
|
|
});
|
|
};
|
|
|
|
export default authRoute;
|