import Fastify, { FastifyError } from "fastify"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import fastifyStatic from "@fastify/static"; import multipart from "@fastify/multipart"; import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { createHmac } from "node:crypto"; import { config } from "./config.js"; import prisma from "./lib/prisma.js"; import securityPlugin from "./middleware/security.js"; import authPlugin from "./middleware/auth.js"; import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js"; import { setLogger, initDynamicPlugins, shutdownDynamicPlugins, getDynamicPluginInfo, getDynamicPluginAdminRoutes } from "./plugins/registry.js"; import { seedAllBoardTemplates } from "./lib/default-templates.js"; import boardRoutes from "./routes/boards.js"; import postRoutes from "./routes/posts.js"; import voteRoutes from "./routes/votes.js"; import commentRoutes from "./routes/comments.js"; import reactionRoutes from "./routes/reactions.js"; import identityRoutes from "./routes/identity.js"; import passkeyRoutes from "./routes/passkey.js"; import feedRoutes from "./routes/feed.js"; import activityRoutes from "./routes/activity.js"; import pushRoutes from "./routes/push.js"; import privacyRoutes from "./routes/privacy.js"; import adminAuthRoutes from "./routes/admin/auth.js"; import adminPostRoutes from "./routes/admin/posts.js"; import adminBoardRoutes from "./routes/admin/boards.js"; import adminCategoryRoutes from "./routes/admin/categories.js"; import adminStatsRoutes from "./routes/admin/stats.js"; import searchRoutes from "./routes/search.js"; import roadmapRoutes from "./routes/roadmap.js"; import similarRoutes from "./routes/similar.js"; import adminTagRoutes from "./routes/admin/tags.js"; import adminNoteRoutes from "./routes/admin/notes.js"; import adminChangelogRoutes from "./routes/admin/changelog.js"; import adminWebhookRoutes from "./routes/admin/webhooks.js"; import changelogRoutes from "./routes/changelog.js"; import notificationRoutes from "./routes/notifications.js"; import embedRoutes from "./routes/embed.js"; import adminStatusRoutes from "./routes/admin/statuses.js"; import adminExportRoutes from "./routes/admin/export.js"; import adminTemplateRoutes from "./routes/admin/templates.js"; import templateRoutes from "./routes/templates.js"; import attachmentRoutes from "./routes/attachments.js"; import avatarRoutes from "./routes/avatars.js"; import recoveryRoutes from "./routes/recovery.js"; import settingsRoutes from "./routes/admin/settings.js"; import adminTeamRoutes from "./routes/admin/team.js"; import adminPluginRoutes from "./routes/admin/plugins.js"; import pluginApiRoutes from "./routes/plugins-api.js"; export async function createServer() { const app = Fastify({ logger: { serializers: { req(req) { return { method: req.method, url: req.url, remoteAddress: req.ip, }; }, }, }, }); const cookieSecret = createHmac("sha256", config.TOKEN_SECRET).update("echoboard:cookie").digest("hex"); await app.register(cookie, { secret: cookieSecret }); await app.register(cors, { origin: process.env.NODE_ENV === "production" ? config.WEBAUTHN_ORIGIN : ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"], credentials: true, }); const allowedOrigins = new Set( process.env.NODE_ENV === "production" ? [config.WEBAUTHN_ORIGIN] : [config.WEBAUTHN_ORIGIN, "http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] ); app.addHook("onRequest", async (req, reply) => { if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) { const origin = req.headers.origin; // Server-to-server webhook calls don't send Origin headers if (!origin && req.url.startsWith('/api/v1/plugins/') && req.url.includes('/webhook')) return; if (!origin || !allowedOrigins.has(origin)) { return reply.status(403).send({ error: "Forbidden" }); } } }); await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); await app.register(rateLimit, { max: 100, timeWindow: "1 minute", }); app.setErrorHandler((error: FastifyError, req, reply) => { req.log.error(error); // zod validation errors if (error.validation) { reply.status(400).send({ error: "Validation failed" }); return; } // fastify rate limit if (error.statusCode === 429) { reply.status(429).send({ error: "Too many requests" }); return; } const status = error.statusCode ?? 500; reply.status(status).send({ error: status >= 500 ? "Internal server error" : error.message, }); }); await app.register(securityPlugin); await app.register(authPlugin); app.decorate("prisma", prisma); // api routes under /api/v1 await app.register(async (api) => { await api.register(boardRoutes); await api.register(postRoutes); await api.register(voteRoutes); await api.register(commentRoutes); await api.register(reactionRoutes); await api.register(identityRoutes); await api.register(passkeyRoutes); await api.register(feedRoutes); await api.register(activityRoutes); await api.register(pushRoutes); await api.register(privacyRoutes); await api.register(adminAuthRoutes); await api.register(adminPostRoutes); await api.register(adminBoardRoutes); await api.register(adminCategoryRoutes); await api.register(adminStatsRoutes); await api.register(searchRoutes); await api.register(roadmapRoutes); await api.register(similarRoutes); await api.register(adminTagRoutes); await api.register(adminNoteRoutes); await api.register(adminChangelogRoutes); await api.register(adminWebhookRoutes); await api.register(changelogRoutes); await api.register(notificationRoutes); await api.register(embedRoutes); await api.register(adminStatusRoutes); await api.register(adminExportRoutes); await api.register(adminTemplateRoutes); await api.register(templateRoutes); await api.register(attachmentRoutes); await api.register(avatarRoutes); await api.register(recoveryRoutes); await api.register(settingsRoutes); await api.register(adminTeamRoutes); await api.register(adminPluginRoutes); await api.register(pluginApiRoutes); }, { prefix: "/api/v1" }); // serve static frontend build in production const webDist = resolve(process.cwd(), "../web/dist"); if (process.env.NODE_ENV === "production" && existsSync(webDist)) { await app.register(fastifyStatic, { root: webDist, wildcard: false, }); app.setNotFoundHandler((_req, reply) => { reply.sendFile("index.html"); }); } await loadPlugins(app); await startupPlugins(); setLogger(app.log); await initDynamicPlugins(); // seed default templates for boards that have none await seedAllBoardTemplates(prisma); // register plugin discovery endpoint and admin routes await app.register(async (api) => { api.get("/plugins/active", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } }, }, async () => [...getActivePluginInfo(), ...getDynamicPluginInfo()]); // register plugin-provided admin routes for (const route of getPluginAdminRoutes()) { api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({ label: route.label, component: route.component, })); } // register dynamic plugin admin routes for (const route of getDynamicPluginAdminRoutes()) { api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({ label: route.label, })); } }, { prefix: "/api/v1" }); app.addHook("onClose", async () => { await shutdownPlugins(); await shutdownDynamicPlugins(); }); return app; }