security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,14 +1,19 @@
import Fastify from "fastify";
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 } from "./plugins/loader.js";
import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
import { seedAllBoardTemplates } from "./lib/default-templates.js";
import boardRoutes from "./routes/boards.js";
import postRoutes from "./routes/posts.js";
@@ -26,6 +31,25 @@ 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";
export async function createServer() {
const app = Fastify({
@@ -35,25 +59,70 @@ export async function createServer() {
return {
method: req.method,
url: req.url,
remoteAddress: req.ip,
};
},
},
},
});
await app.register(cookie, { secret: process.env.TOKEN_SECRET });
const cookieSecret = createHmac("sha256", config.TOKEN_SECRET).update("echoboard:cookie").digest("hex");
await app.register(cookie, { secret: cookieSecret });
await app.register(cors, {
origin: true,
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);
@@ -72,6 +141,25 @@ export async function createServer() {
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);
}, { prefix: "/api/v1" });
// serve static frontend build in production
@@ -88,6 +176,29 @@ export async function createServer() {
}
await loadPlugins(app);
await startupPlugins();
// 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());
// 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,
}));
}
}, { prefix: "/api/v1" });
app.addHook("onClose", async () => {
await shutdownPlugins();
});
return app;
}