75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
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;
|
|
}
|