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,33 +1,47 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { seedTemplatesForBoard } from "../../lib/default-templates.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient();
const safeUrl = z.string().url().refine((u) => /^https?:\/\//i.test(u), { message: "URL must use http or https" });
const iconName = z.string().max(80).regex(/^Icon[A-Za-z0-9]+$/).optional().nullable();
const iconColor = z.string().max(30).regex(/^#[0-9a-fA-F]{3,8}$/).optional().nullable();
const createBoardBody = z.object({
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
externalUrl: z.string().url().optional(),
externalUrl: safeUrl.optional(),
iconName,
iconColor,
voteBudget: z.number().int().min(0).default(10),
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"),
voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).default("monthly"),
allowMultiVote: z.boolean().default(false),
rssEnabled: z.boolean().default(true),
rssFeedCount: z.number().int().min(1).max(200).default(50),
staleDays: z.number().int().min(0).max(365).default(0),
});
const updateBoardBody = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
externalUrl: z.string().url().optional().nullable(),
externalUrl: safeUrl.optional().nullable(),
iconName,
iconColor,
isArchived: z.boolean().optional(),
voteBudget: z.number().int().min(0).optional(),
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(),
voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).optional(),
allowMultiVote: z.boolean().optional(),
rssEnabled: z.boolean().optional(),
rssFeedCount: z.number().int().min(1).max(200).optional(),
staleDays: z.number().int().min(0).max(365).optional(),
});
export default async function adminBoardRoutes(app: FastifyInstance) {
app.get(
"/admin/boards",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const boards = await prisma.board.findMany({
orderBy: { createdAt: "asc" },
@@ -41,7 +55,7 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof createBoardBody> }>(
"/admin/boards",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createBoardBody.parse(req.body);
@@ -52,13 +66,15 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
}
const board = await prisma.board.create({ data: body });
await seedTemplatesForBoard(prisma, board.id);
req.log.info({ adminId: req.adminId, boardId: board.id, slug: board.slug }, "board created");
reply.status(201).send(board);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
"/admin/boards/:id",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) {
@@ -72,13 +88,14 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
data: body,
});
req.log.info({ adminId: req.adminId, boardId: board.id }, "board updated");
reply.send(updated);
}
);
app.post<{ Params: { id: string } }>(
"/admin/boards/:id/reset-budget",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) {
@@ -91,21 +108,33 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
data: { lastBudgetReset: new Date() },
});
req.log.info({ adminId: req.adminId, boardId: board.id }, "budget reset");
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/boards/:id",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
const board = await prisma.board.findUnique({
where: { id: req.params.id },
include: { _count: { select: { posts: true } } },
});
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
if (board._count.posts > 0) {
reply.status(409).send({
error: `Board has ${board._count.posts} posts. Archive it or delete posts first.`,
});
return;
}
await prisma.board.delete({ where: { id: board.id } });
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
reply.status(204).send();
}
);