220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
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;
|
|
}
|