import cors from '@fastify/cors'; import Fastify from 'fastify'; import { ZodError } from 'zod'; import { authPlugin } from './auth/plugin.js'; import { config } from './config.js'; import { pool } from './db/pool.js'; import { authRoutes } from './routes/auth.js'; import { courseRoutes } from './routes/courses.js'; import { manageCourseRoutes } from './routes/manage-courses.js'; import { learningRoutes } from './routes/learning.js'; import { adminRoutes } from './routes/admin.js'; import { mediaRoutes } from './routes/media.js'; import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js'; import { profileRoutes } from './routes/profiles.js'; export function buildApp() { const app = Fastify({ logger: true }); // Bunny signs the exact webhook byte sequence. Preserve the raw JSON body // before parsing it so the signature can be verified server-side. app.removeContentTypeParser('application/json'); app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => { const rawBody = Buffer.isBuffer(body) ? body : Buffer.from(body); (request as typeof request & { rawBody?: Buffer }).rawBody = rawBody; try { done(null, JSON.parse(rawBody.toString('utf8'))); } catch { done(new Error('Invalid JSON body')); } }); // Bunny uploads are streamed through the authenticated API. Keeping the body // as a stream avoids loading a whole course video into Node's memory. const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload); app.addContentTypeParser('application/octet-stream', rawUploadParser); app.addContentTypeParser(/^video\/.+$/, rawUploadParser); app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body)); app.register(cors, { origin: config.FRONTEND_ORIGIN, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], }); app.setErrorHandler((error, _request, reply) => { if (error instanceof ZodError) { return reply.code(400).send({ error: 'Invalid request', details: error.flatten() }); } app.log.error(error); return reply.code(500).send({ error: 'Internal server error' }); }); const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => { try { await pool.query('select 1'); return { status: 'ok', database: 'connected' }; } catch { return reply.code(503).send({ status: 'unavailable', database: 'unavailable' }); } }; app.get('/api/v1/health', checkDatabase); app.get('/api/v1/ready', checkDatabase); app.register(authPlugin); app.register(authRoutes, { prefix: '/api/v1/auth' }); app.register(profileRoutes, { prefix: '/api/v1/profiles' }); app.register(courseRoutes, { prefix: '/api/v1/courses' }); app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' }); app.register(mediaRoutes, { prefix: '/api/v1/manage/media' }); app.register(bunnyWebhookRoutes, { prefix: '/api/v1/webhooks' }); app.register(learningRoutes, { prefix: '/api/v1' }); app.register(adminRoutes, { prefix: '/api/v1/admin' }); return app; }