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,42 +1,156 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { config } from "../../config.js";
import { encrypt, decrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { blockToken } from "../../lib/token-blocklist.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient();
const DUMMY_HASH = bcrypt.hashSync("__timing_safe__", 12);
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
setInterval(() => {
const cutoff = Date.now() - 15 * 60 * 1000;
for (const [k, v] of failedAttempts) {
if (v.lastAttempt < cutoff) failedAttempts.delete(k);
}
}, 60 * 1000);
const loginBody = z.object({
email: z.string().email(),
password: z.string().min(1),
});
async function ensureLinkedUser(adminId: string): Promise<string> {
return prisma.$transaction(async (tx) => {
const admin = await tx.adminUser.findUnique({ where: { id: adminId } });
if (admin?.linkedUserId) return admin.linkedUserId;
const displayName = encrypt("Admin", masterKey);
const user = await tx.user.create({
data: { displayName, authMethod: "COOKIE" },
});
await tx.adminUser.update({
where: { id: adminId },
data: { linkedUserId: user.id },
});
return user.id;
}, { isolationLevel: "Serializable" });
}
export default async function adminAuthRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof loginBody> }>(
"/admin/login",
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
async (req, reply) => {
const body = loginBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { email: body.email } });
if (!admin) {
const valid = await bcrypt.compare(body.password, admin?.passwordHash ?? DUMMY_HASH);
if (!admin || !valid) {
const bruteKey = `${req.ip}:${body.email.toLowerCase()}`;
if (!failedAttempts.has(bruteKey) && failedAttempts.size > 10000) {
const sorted = [...failedAttempts.entries()].sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
for (let i = 0; i < sorted.length / 2; i++) failedAttempts.delete(sorted[i][0]);
}
const entry = failedAttempts.get(bruteKey) || { count: 0, lastAttempt: 0 };
entry.count++;
entry.lastAttempt = Date.now();
failedAttempts.set(bruteKey, entry);
const delay = Math.min(100 * Math.pow(2, entry.count - 1), 10000);
await new Promise((r) => setTimeout(r, delay));
reply.status(401).send({ error: "Invalid credentials" });
return;
}
failedAttempts.delete(`${req.ip}:${body.email.toLowerCase()}`);
const valid = await bcrypt.compare(body.password, admin.passwordHash);
if (!valid) {
reply.status(401).send({ error: "Invalid credentials" });
return;
// only auto-upgrade CLI-created admins (have email, not invited)
if (admin.role !== "SUPER_ADMIN" && admin.email && !admin.invitedById) {
await prisma.adminUser.update({ where: { id: admin.id }, data: { role: "SUPER_ADMIN" } });
}
const token = jwt.sign(
{ sub: admin.id, type: "admin" },
const linkedUserId = await ensureLinkedUser(admin.id);
const adminToken = jwt.sign(
{ sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "24h" }
{ expiresIn: "4h" }
);
reply.send({ token });
const userToken = jwt.sign(
{ sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "4h" }
);
reply
.setCookie("echoboard_admin", adminToken, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 4,
})
.setCookie("echoboard_passkey", userToken, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 4,
})
.send({ ok: true });
}
);
app.get(
"/admin/me",
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
if (!req.adminId) {
reply.send({ isAdmin: false });
return;
}
const admin = await prisma.adminUser.findUnique({
where: { id: req.adminId },
select: { role: true, displayName: true, teamTitle: true },
});
if (!admin) {
reply.send({ isAdmin: false });
return;
}
reply.send({
isAdmin: true,
role: admin.role,
displayName: admin.displayName ? decrypt(admin.displayName, masterKey) : null,
teamTitle: admin.teamTitle ? decrypt(admin.teamTitle, masterKey) : null,
});
}
);
app.post("/admin/exit", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
const adminToken = req.cookies?.echoboard_admin;
const passkeyToken = req.cookies?.echoboard_passkey;
if (adminToken) await blockToken(adminToken);
if (passkeyToken) await blockToken(passkeyToken);
reply
.clearCookie("echoboard_admin", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_token", { path: "/" })
.send({ ok: true });
});
app.post("/admin/logout", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
const adminToken = req.cookies?.echoboard_admin;
const passkeyToken = req.cookies?.echoboard_passkey;
if (adminToken) await blockToken(adminToken);
if (passkeyToken) await blockToken(passkeyToken);
reply
.clearCookie("echoboard_admin", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_token", { path: "/" })
.send({ ok: true });
});
}

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();
}
);

View File

@@ -1,8 +1,6 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
import { prisma } from "../../lib/prisma.js";
const createCategoryBody = z.object({
name: z.string().min(1).max(50),
@@ -12,26 +10,27 @@ const createCategoryBody = z.object({
export default async function adminCategoryRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof createCategoryBody> }>(
"/admin/categories",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createCategoryBody.parse(req.body);
const existing = await prisma.category.findFirst({
where: { OR: [{ name: body.name }, { slug: body.slug }] },
});
if (existing) {
reply.status(409).send({ error: "Category already exists" });
return;
try {
const cat = await prisma.category.create({ data: body });
req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category created");
reply.status(201).send(cat);
} catch (err: any) {
if (err.code === "P2002") {
reply.status(409).send({ error: "Category already exists" });
return;
}
throw err;
}
const cat = await prisma.category.create({ data: body });
reply.status(201).send(cat);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/categories/:id",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const cat = await prisma.category.findUnique({ where: { id: req.params.id } });
if (!cat) {
@@ -40,6 +39,7 @@ export default async function adminCategoryRoutes(app: FastifyInstance) {
}
await prisma.category.delete({ where: { id: cat.id } });
req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category deleted");
reply.status(204).send();
}
);

View File

@@ -0,0 +1,91 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const createBody = z.object({
title: z.string().min(1).max(200).trim(),
body: z.string().min(1).max(10000).trim(),
boardId: z.string().optional().nullable(),
publishedAt: z.coerce.date().optional(),
});
const updateBody = z.object({
title: z.string().min(1).max(200).trim().optional(),
body: z.string().min(1).max(10000).trim().optional(),
boardId: z.string().optional().nullable(),
publishedAt: z.coerce.date().optional(),
});
export default async function adminChangelogRoutes(app: FastifyInstance) {
app.get(
"/admin/changelog",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const entries = await prisma.changelogEntry.findMany({
include: { board: { select: { id: true, slug: true, name: true } } },
orderBy: { publishedAt: "desc" },
take: 200,
});
reply.send({ entries });
}
);
app.post<{ Body: z.infer<typeof createBody> }>(
"/admin/changelog",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createBody.parse(req.body);
const entry = await prisma.changelogEntry.create({
data: {
title: body.title,
body: body.body,
boardId: body.boardId || null,
publishedAt: body.publishedAt ?? new Date(),
},
});
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry created");
reply.status(201).send(entry);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
"/admin/changelog/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } });
if (!entry) {
reply.status(404).send({ error: "Entry not found" });
return;
}
const body = updateBody.parse(req.body);
const updated = await prisma.changelogEntry.update({
where: { id: entry.id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.body !== undefined && { body: body.body }),
...(body.boardId !== undefined && { boardId: body.boardId || null }),
...(body.publishedAt !== undefined && { publishedAt: body.publishedAt }),
},
});
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry updated");
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/changelog/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } });
if (!entry) {
reply.status(404).send({ error: "Entry not found" });
return;
}
await prisma.changelogEntry.delete({ where: { id: entry.id } });
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry deleted");
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,164 @@
import { FastifyInstance } from "fastify";
import { decryptWithFallback } from "../../services/encryption.js";
import { masterKey, previousMasterKey } from "../../config.js";
import { prisma } from "../../lib/prisma.js";
function decryptName(encrypted: string | null): string {
if (!encrypted) return "";
try {
return decryptWithFallback(encrypted, masterKey, previousMasterKey);
} catch {
return "[encrypted]";
}
}
function toCsv(headers: string[], rows: string[][]): string {
const escape = (v: string) => {
// prevent formula injection in spreadsheets
let safe = v;
if (/^[=+\-@\t\r|]/.test(safe)) {
safe = "'" + safe;
}
if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) {
return '"' + safe.replace(/"/g, '""') + '"';
}
return safe;
};
const lines = [headers.map(escape).join(",")];
for (const row of rows) {
lines.push(row.map((c) => escape(String(c ?? ""))).join(","));
}
return lines.join("\n");
}
async function fetchPosts() {
const posts = await prisma.post.findMany({
include: { board: { select: { name: true } }, author: { select: { displayName: true } } },
orderBy: { createdAt: "desc" },
take: 5000,
});
return posts.map((p) => ({
id: p.id,
title: p.title,
type: p.type,
status: p.status,
voteCount: p.voteCount,
board: p.board.name,
author: decryptName(p.author.displayName),
createdAt: p.createdAt.toISOString(),
}));
}
async function fetchVotes() {
const votes = await prisma.vote.findMany({
include: {
post: { select: { title: true } },
voter: { select: { displayName: true } },
},
orderBy: { createdAt: "desc" },
take: 10000,
});
return votes.map((v) => ({
postId: v.postId,
postTitle: v.post.title,
voter: decryptName(v.voter.displayName),
weight: v.weight,
importance: v.importance ?? "",
createdAt: v.createdAt.toISOString(),
}));
}
async function fetchComments() {
const comments = await prisma.comment.findMany({
include: {
post: { select: { title: true } },
author: { select: { displayName: true } },
},
orderBy: { createdAt: "desc" },
take: 10000,
});
return comments.map((c) => ({
postId: c.postId,
postTitle: c.post.title,
body: c.body,
author: decryptName(c.author.displayName),
isAdmin: c.isAdmin,
createdAt: c.createdAt.toISOString(),
}));
}
async function fetchUsers() {
const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 5000 });
return users.map((u) => ({
id: u.id,
authMethod: u.authMethod,
displayName: decryptName(u.displayName),
createdAt: u.createdAt.toISOString(),
}));
}
const postHeaders = ["id", "title", "type", "status", "voteCount", "board", "author", "createdAt"];
const voteHeaders = ["postId", "postTitle", "voter", "weight", "importance", "createdAt"];
const commentHeaders = ["postId", "postTitle", "body", "author", "isAdmin", "createdAt"];
const userHeaders = ["id", "authMethod", "displayName", "createdAt"];
function toRows(items: Record<string, unknown>[], headers: string[]): string[][] {
return items.map((item) => headers.map((h) => String(item[h] ?? "")));
}
export default async function adminExportRoutes(app: FastifyInstance) {
app.get(
"/admin/export",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 2, timeWindow: "1 minute" } } },
async (req, reply) => {
const { format = "json", type = "all" } = req.query as { format?: string; type?: string };
const validTypes = new Set(["all", "posts", "votes", "comments", "users"]);
const validFormats = new Set(["json", "csv"]);
if (!validTypes.has(type) || !validFormats.has(format)) {
reply.status(400).send({ error: "Invalid export type or format" });
return;
}
req.log.info({ adminId: req.adminId, exportType: type, format }, "admin data export");
const data: Record<string, unknown[]> = {};
if (type === "posts" || type === "all") data.posts = await fetchPosts();
if (type === "votes" || type === "all") data.votes = await fetchVotes();
if (type === "comments" || type === "all") data.comments = await fetchComments();
if (type === "users" || type === "all") data.users = await fetchUsers();
if (format === "csv") {
const parts: string[] = [];
if (data.posts) {
parts.push("# Posts");
parts.push(toCsv(postHeaders, toRows(data.posts as Record<string, unknown>[], postHeaders)));
}
if (data.votes) {
parts.push("# Votes");
parts.push(toCsv(voteHeaders, toRows(data.votes as Record<string, unknown>[], voteHeaders)));
}
if (data.comments) {
parts.push("# Comments");
parts.push(toCsv(commentHeaders, toRows(data.comments as Record<string, unknown>[], commentHeaders)));
}
if (data.users) {
parts.push("# Users");
parts.push(toCsv(userHeaders, toRows(data.users as Record<string, unknown>[], userHeaders)));
}
const csv = parts.join("\n\n");
reply
.header("Content-Type", "text/csv; charset=utf-8")
.header("Content-Disposition", `attachment; filename="echoboard-export-${type}.csv"`)
.send(csv);
return;
}
reply.send(data);
}
);
}

View File

@@ -0,0 +1,100 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
function redactEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain) return "***";
return local[0] + "***@" + domain;
}
const createNoteBody = z.object({
body: z.string().min(1).max(2000).trim(),
});
export default async function adminNoteRoutes(app: FastifyInstance) {
// Get notes for a post
app.get<{ Params: { id: string } }>(
"/admin/posts/:id/notes",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const notes = await prisma.adminNote.findMany({
where: { postId: post.id },
orderBy: { createdAt: "desc" },
take: 100,
include: {
admin: { select: { email: true } },
},
});
reply.send({
notes: notes.map((n) => ({
id: n.id,
body: n.body,
adminEmail: n.admin.email ? redactEmail(n.admin.email) : "Team member",
createdAt: n.createdAt,
})),
});
}
);
// Add a note to a post
app.post<{ Params: { id: string }; Body: z.infer<typeof createNoteBody> }>(
"/admin/posts/:id/notes",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const { body } = createNoteBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! }, select: { email: true } });
const note = await prisma.adminNote.create({
data: {
body,
postId: post.id,
adminId: req.adminId!,
},
});
reply.status(201).send({
id: note.id,
body: note.body,
postId: note.postId,
adminEmail: admin?.email ? redactEmail(admin.email) : "Team member",
createdAt: note.createdAt,
});
}
);
// Delete a note
app.delete<{ Params: { noteId: string } }>(
"/admin/notes/:noteId",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const note = await prisma.adminNote.findUnique({ where: { id: req.params.noteId } });
if (!note) {
reply.status(404).send({ error: "Note not found" });
return;
}
if (note.adminId !== req.adminId) {
reply.status(403).send({ error: "You can only delete your own notes" });
return;
}
await prisma.adminNote.delete({ where: { id: note.id } });
reply.status(204).send();
}
);
}

View File

@@ -1,27 +1,87 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, PostStatus, Prisma } from "@prisma/client";
import { Prisma, PostType } from "@prisma/client";
import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import { notifyPostSubscribers } from "../../services/push.js";
import { fireWebhook } from "../../services/webhooks.js";
import { decrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient();
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function decryptName(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
const statusBody = z.object({
status: z.nativeEnum(PostStatus),
status: z.string().min(1).max(50),
reason: z.string().max(2000).optional(),
});
const respondBody = z.object({
body: z.string().min(1).max(5000),
});
const mergeBody = z.object({
targetPostId: z.string().min(1),
});
const rollbackBody = z.object({
editHistoryId: z.string().min(1),
});
const bulkIds = z.array(z.string().min(1)).min(1).max(100);
const bulkStatusBody = z.object({
postIds: bulkIds,
status: z.string().min(1).max(50),
});
const bulkDeleteBody = z.object({
postIds: bulkIds,
});
const bulkTagBody = z.object({
postIds: bulkIds,
tagId: z.string().min(1),
action: z.enum(['add', 'remove']),
});
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
const descriptionRecord = z.record(z.string()).refine(
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
{ message: "Unknown description fields" }
);
const proxyPostBody = z.object({
type: z.nativeEnum(PostType),
title: z.string().min(5).max(200),
description: descriptionRecord,
onBehalfOf: z.string().min(1).max(200),
});
const adminPostsQuery = z.object({
page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
status: z.string().max(50).optional(),
boardId: z.string().min(1).optional(),
});
export default async function adminPostRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/admin/posts",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10)));
const status = req.query.status as PostStatus | undefined;
const boardId = req.query.boardId;
const q = adminPostsQuery.safeParse(req.query);
if (!q.success) {
reply.status(400).send({ error: "Invalid query parameters" });
return;
}
const { page, limit, status, boardId } = q.data;
const where: Prisma.PostWhereInput = {};
if (status) where.status = status;
@@ -31,24 +91,46 @@ export default async function adminPostRoutes(app: FastifyInstance) {
prisma.post.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
skip: Math.min((page - 1) * limit, 50000),
take: limit,
include: {
board: { select: { slug: true, name: true } },
author: { select: { id: true, displayName: true } },
_count: { select: { comments: true, votes: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true, votes: true, adminNotes: true } },
tags: { include: { tag: true } },
},
}),
prisma.post.count({ where }),
]);
reply.send({ posts, total, page, pages: Math.ceil(total / limit) });
reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
description: p.description,
status: p.status,
statusReason: p.statusReason,
category: p.category,
voteCount: p.voteCount,
viewCount: p.viewCount,
isPinned: p.isPinned,
onBehalfOf: p.onBehalfOf,
board: p.board,
author: p.author ? { id: p.author.id, displayName: decryptName(p.author.displayName), avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null } : null,
tags: p.tags.map((pt) => pt.tag),
_count: p._count,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
total, page, pages: Math.ceil(total / limit),
});
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
"/admin/posts/:id/status",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
@@ -56,17 +138,37 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return;
}
const { status } = statusBody.parse(req.body);
const { status, reason } = statusBody.parse(req.body);
const reasonText = reason?.trim() || null;
// check if the target status exists and is enabled for this board
const boardConfig = await prisma.boardStatus.findMany({
where: { boardId: post.boardId, enabled: true },
});
if (boardConfig.length === 0) {
reply.status(400).send({ error: "No statuses configured for this board" });
return;
}
const statusEntry = boardConfig.find((c) => c.status === status);
if (!statusEntry) {
reply.status(400).send({ error: `Status "${status}" is not available for this board` });
return;
}
const oldStatus = post.status;
const [updated] = await Promise.all([
prisma.post.update({ where: { id: post.id }, data: { status } }),
prisma.post.update({
where: { id: post.id },
data: { status, statusReason: reasonText, lastActivityAt: new Date() },
}),
prisma.statusChange.create({
data: {
postId: post.id,
fromStatus: oldStatus,
toStatus: status,
changedBy: req.adminId!,
reason: reasonText,
},
}),
prisma.activityEvent.create({
@@ -79,20 +181,55 @@ export default async function adminPostRoutes(app: FastifyInstance) {
}),
]);
// notify post author and voters
const voters = await prisma.vote.findMany({
where: { postId: post.id },
select: { voterId: true },
});
const statusLabel = statusEntry.label || status.replace(/_/g, " ");
const notifBody = reasonText
? `"${post.title}" moved to ${statusLabel} - Reason: ${reasonText}`
: `"${post.title}" moved to ${statusLabel}`;
const sentinelId = "deleted-user-sentinel";
const voterIds = voters.map((v) => v.voterId).filter((id) => id !== sentinelId);
if (voterIds.length > 1000) {
req.log.warn({ postId: post.id, totalVoters: voterIds.length }, "notification capped at 1000 voters");
}
const notifyIds = voterIds.slice(0, 1000);
const userIds = new Set([post.authorId, ...notifyIds]);
userIds.delete(sentinelId);
await prisma.notification.createMany({
data: [...userIds].map((userId) => ({
type: "status_changed",
title: "Status updated",
body: notifBody,
postId: post.id,
userId,
})),
});
await notifyPostSubscribers(post.id, {
title: "Status updated",
body: `"${post.title}" moved to ${status}`,
body: notifBody,
url: `/post/${post.id}`,
tag: `status-${post.id}`,
});
fireWebhook("status_changed", {
postId: post.id,
title: post.title,
boardId: post.boardId,
from: oldStatus,
to: status,
});
reply.send(updated);
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/pin",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
@@ -100,65 +237,323 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return;
}
if (!post.isPinned) {
try {
const updated = await prisma.$transaction(async (tx) => {
const pinnedCount = await tx.post.count({
where: { boardId: post.boardId, isPinned: true },
});
if (pinnedCount >= 3) throw new Error("PIN_LIMIT");
return tx.post.update({
where: { id: post.id },
data: { isPinned: true },
});
}, { isolationLevel: "Serializable" });
reply.send(updated);
} catch (err: any) {
if (err.message === "PIN_LIMIT") {
reply.status(409).send({ error: "Max 3 pinned posts per board" });
return;
}
throw err;
}
} else {
const updated = await prisma.post.update({
where: { id: post.id },
data: { isPinned: false },
});
reply.send(updated);
}
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
"/admin/posts/:id/respond",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin || !admin.linkedUserId) {
reply.status(500).send({ error: "Admin account not linked" });
return;
}
const { body } = respondBody.parse(req.body);
const cleanBody = body.replace(INVISIBLE_RE, '');
const comment = await prisma.comment.create({
data: {
body: cleanBody,
postId: post.id,
authorId: admin.linkedUserId,
isAdmin: true,
adminUserId: req.adminId!,
},
});
await prisma.activityEvent.create({
data: {
type: "admin_responded",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
});
await notifyPostSubscribers(post.id, {
title: "Official response",
body: cleanBody.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
reply.status(201).send(comment);
}
);
// Admin creates a post on behalf of a user
app.post<{ Params: { boardId: string }; Body: z.infer<typeof proxyPostBody> }>(
"/admin/boards/:boardId/posts",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board || board.isArchived) {
reply.status(404).send({ error: "Board not found or archived" });
return;
}
const body = proxyPostBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin || !admin.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user to submit posts" });
return;
}
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
const cleanDesc: Record<string, string> = {};
for (const [k, v] of Object.entries(body.description)) {
cleanDesc[k] = v.replace(INVISIBLE_RE, '');
}
const post = await prisma.post.create({
data: {
type: body.type,
title: cleanTitle,
description: cleanDesc,
boardId: board.id,
authorId: admin.linkedUserId,
onBehalfOf: body.onBehalfOf,
},
});
await prisma.activityEvent.create({
data: {
type: "post_created",
boardId: board.id,
postId: post.id,
metadata: { title: post.title, type: post.type, onBehalfOf: body.onBehalfOf },
},
});
fireWebhook("post_created", {
postId: post.id,
title: post.title,
type: post.type,
boardId: board.id,
boardSlug: board.slug,
onBehalfOf: body.onBehalfOf,
});
reply.status(201).send(post);
}
);
// Merge source post into target post
app.post<{ Params: { id: string }; Body: z.infer<typeof mergeBody> }>(
"/admin/posts/:id/merge",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const { targetPostId } = mergeBody.parse(req.body);
const source = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!source) {
reply.status(404).send({ error: "Source post not found" });
return;
}
const target = await prisma.post.findUnique({ where: { id: targetPostId } });
if (!target) {
reply.status(404).send({ error: "Target post not found" });
return;
}
if (source.id === target.id) {
reply.status(400).send({ error: "Cannot merge a post into itself" });
return;
}
if (source.boardId !== target.boardId) {
reply.status(400).send({ error: "Cannot merge posts across different boards" });
return;
}
// only load IDs and weights to minimize memory
const sourceVotes = await prisma.vote.findMany({
where: { postId: source.id },
select: { id: true, voterId: true, weight: true },
});
const targetVoterIds = new Set(
(await prisma.vote.findMany({ where: { postId: target.id }, select: { voterId: true } }))
.map((v) => v.voterId)
);
const votesToMove = sourceVotes.filter((v) => !targetVoterIds.has(v.voterId));
const targetTagIds = (await prisma.postTag.findMany({
where: { postId: target.id },
select: { tagId: true },
})).map((t) => t.tagId);
await prisma.$transaction(async (tx) => {
for (const v of votesToMove) {
await tx.vote.update({ where: { id: v.id }, data: { postId: target.id } });
}
await tx.vote.deleteMany({ where: { postId: source.id } });
await tx.comment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.attachment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.postTag.deleteMany({
where: { postId: source.id, tagId: { in: targetTagIds } },
});
await tx.postTag.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.post.update({
where: { id: target.id },
data: { voteCount: { increment: votesToMove.reduce((sum, v) => sum + v.weight, 0) } },
});
await tx.postMerge.create({
data: { sourcePostId: source.id, targetPostId: target.id, mergedBy: req.adminId! },
});
await tx.post.delete({ where: { id: source.id } });
}, { isolationLevel: "Serializable" });
const actualCount = await prisma.vote.aggregate({ where: { postId: target.id }, _sum: { weight: true } });
await prisma.post.update({ where: { id: target.id }, data: { voteCount: actualCount._sum.weight ?? 0 } });
req.log.info({ adminId: req.adminId, sourcePostId: source.id, targetPostId: target.id }, "posts merged");
reply.send({ merged: true, targetPostId: target.id });
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/lock-edits",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const updated = await prisma.post.update({
where: { id: post.id },
data: { isPinned: !post.isPinned },
data: { isEditLocked: !post.isEditLocked },
});
reply.send({ isEditLocked: updated.isEditLocked });
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/lock-thread",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = z.object({ lockVoting: z.boolean().optional() }).parse(req.body);
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const newThreadLocked = !post.isThreadLocked;
const data: Record<string, boolean> = { isThreadLocked: newThreadLocked };
if (newThreadLocked && body.lockVoting) {
data.isVotingLocked = true;
}
if (!newThreadLocked) {
data.isVotingLocked = false;
}
const updated = await prisma.post.update({ where: { id: post.id }, data });
reply.send({ isThreadLocked: updated.isThreadLocked, isVotingLocked: updated.isVotingLocked });
}
);
app.put<{ Params: { id: string } }>(
"/admin/comments/:id/lock-edits",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const updated = await prisma.comment.update({
where: { id: comment.id },
data: { isEditLocked: !comment.isEditLocked },
});
reply.send({ isEditLocked: updated.isEditLocked });
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
"/admin/posts/:id/rollback",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const { editHistoryId } = rollbackBody.parse(req.body);
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
if (!edit || edit.postId !== post.id) {
reply.status(404).send({ error: "Edit history entry not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin?.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user" });
return;
}
// save current state before rollback
await prisma.editHistory.create({
data: {
postId: post.id,
editedBy: admin.linkedUserId,
previousTitle: post.title,
previousDescription: post.description as any,
},
});
const data: Record<string, any> = {};
if (edit.previousTitle !== null) data.title = edit.previousTitle;
if (edit.previousDescription !== null) data.description = edit.previousDescription;
const updated = await prisma.post.update({
where: { id: post.id },
data,
});
reply.send(updated);
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
"/admin/posts/:id/respond",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const { body } = respondBody.parse(req.body);
const response = await prisma.adminResponse.create({
data: {
body,
postId: post.id,
adminId: req.adminId!,
},
include: { admin: { select: { id: true, email: true } } },
});
await notifyPostSubscribers(post.id, {
title: "Official response",
body: body.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
reply.status(201).send(response);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/posts/:id",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
await prisma.post.delete({ where: { id: post.id } });
reply.status(204).send();
}
);
app.delete<{ Params: { id: string } }>(
"/admin/comments/:id",
{ preHandler: [app.requireAdmin] },
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
"/admin/comments/:id/rollback",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
@@ -166,8 +561,284 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return;
}
await prisma.comment.delete({ where: { id: comment.id } });
const { editHistoryId } = rollbackBody.parse(req.body);
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
if (!edit || edit.commentId !== comment.id) {
reply.status(404).send({ error: "Edit history entry not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin?.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user" });
return;
}
await prisma.editHistory.create({
data: {
commentId: comment.id,
editedBy: admin.linkedUserId,
previousBody: comment.body,
},
});
if (edit.previousBody === null) {
reply.status(400).send({ error: "No previous body to restore" });
return;
}
const updated = await prisma.comment.update({
where: { id: comment.id },
data: { body: edit.previousBody },
});
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/posts/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const commentIds = (await prisma.comment.findMany({
where: { postId: post.id },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: post.id },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.postMerge.deleteMany({
where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] },
});
await prisma.post.delete({ where: { id: post.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, postId: post.id }, "admin post deleted");
reply.status(204).send();
}
);
app.delete<{ Params: { id: string } }>(
"/admin/comments/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const attachments = await prisma.attachment.findMany({
where: { commentId: comment.id },
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.comment.delete({ where: { id: comment.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, commentId: comment.id }, "admin comment deleted");
reply.status(204).send();
}
);
app.post<{ Body: z.infer<typeof bulkStatusBody> }>(
"/admin/posts/bulk-status",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkStatusBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds, status } = parsed.data;
const posts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true, status: true, boardId: true },
});
if (posts.length === 0) {
reply.send({ updated: 0 });
return;
}
// validate target status against each board's config
const boardIds = [...new Set(posts.map((p) => p.boardId))];
for (const boardId of boardIds) {
const boardStatuses = await prisma.boardStatus.findMany({
where: { boardId, enabled: true },
});
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === status)) {
reply.status(400).send({ error: `Status "${status}" is not enabled for board ${boardId}` });
return;
}
}
await prisma.$transaction([
prisma.post.updateMany({
where: { id: { in: posts.map((p) => p.id) } },
data: { status, lastActivityAt: new Date() },
}),
...posts.map((p) =>
prisma.statusChange.create({
data: {
postId: p.id,
fromStatus: p.status,
toStatus: status,
changedBy: req.adminId!,
},
})
),
]);
reply.send({ updated: posts.length });
}
);
app.post<{ Body: z.infer<typeof bulkDeleteBody> }>(
"/admin/posts/bulk-delete",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkDeleteBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds } = parsed.data;
const posts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true, boardId: true, title: true },
});
if (posts.length === 0) {
reply.send({ deleted: 0 });
return;
}
const validPostIds = posts.map((p) => p.id);
const commentIds = (await prisma.comment.findMany({
where: { postId: { in: validPostIds } },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: { in: validPostIds } },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.$transaction([
prisma.post.deleteMany({ where: { id: { in: validPostIds } } }),
...posts.map((p) =>
prisma.activityEvent.create({
data: {
type: "post_deleted",
boardId: p.boardId,
postId: p.id,
metadata: { title: p.title, deletedBy: req.adminId },
},
})
),
]);
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, count: posts.length }, "bulk posts deleted");
reply.send({ deleted: posts.length });
}
);
app.post<{ Body: z.infer<typeof bulkTagBody> }>(
"/admin/posts/bulk-tag",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkTagBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds, tagId, action } = parsed.data;
const tag = await prisma.tag.findUnique({ where: { id: tagId } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
const existingPosts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true },
});
const validIds = existingPosts.map((p) => p.id);
if (validIds.length === 0) {
reply.send({ affected: 0 });
return;
}
if (action === 'add') {
const existing = await prisma.postTag.findMany({
where: { tagId, postId: { in: validIds } },
select: { postId: true },
});
const alreadyTagged = new Set(existing.map((e) => e.postId));
const toAdd = validIds.filter((id) => !alreadyTagged.has(id));
if (toAdd.length > 0) {
await prisma.postTag.createMany({
data: toAdd.map((postId) => ({ postId, tagId })),
});
}
reply.send({ affected: toAdd.length });
} else {
const result = await prisma.postTag.deleteMany({
where: { tagId, postId: { in: validIds } },
});
reply.send({ affected: result.count });
}
}
);
}

View File

@@ -0,0 +1,59 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const updateSchema = z.object({
appName: z.string().min(1).max(100).optional(),
logoUrl: z.string().url().max(500).nullable().optional(),
faviconUrl: z.string().url().max(500).nullable().optional(),
accentColor: z.string().regex(HEX_COLOR).optional(),
headerFont: z.string().max(100).nullable().optional(),
bodyFont: z.string().max(100).nullable().optional(),
poweredByVisible: z.boolean().optional(),
customCss: z.string().max(10000).nullable().optional(),
});
export default async function settingsRoutes(app: FastifyInstance) {
app.get(
"/site-settings",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } });
reply.header("Cache-Control", "public, max-age=60");
reply.send(settings ?? {
appName: "Echoboard",
accentColor: "#F59E0B",
poweredByVisible: true,
});
}
);
app.put(
"/admin/site-settings",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = updateSchema.parse(req.body);
if (body.customCss) {
// decode CSS escape sequences before checking so \75\72\6c() can't bypass url() detection
const decoded = body.customCss
.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/\\(.)/g, '$1');
const forbidden = /@import|@font-face|@charset|@namespace|url\s*\(|expression\s*\(|javascript:|behavior\s*:|binding\s*:|-moz-binding\s*:/i;
if (forbidden.test(decoded)) {
reply.status(400).send({ error: "Custom CSS contains forbidden patterns" });
return;
}
}
const settings = await prisma.siteSettings.upsert({
where: { id: "default" },
create: { id: "default", ...body },
update: body,
});
reply.send(settings);
}
);
}

View File

@@ -1,27 +1,32 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { config } from "../../config.js";
const prisma = new PrismaClient();
import { prisma } from "../../lib/prisma.js";
export default async function adminStatsRoutes(app: FastifyInstance) {
app.get(
"/admin/stats",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const [
totalPosts,
totalUsers,
totalComments,
totalVotes,
thisWeek,
postsByStatus,
postsByType,
boardStats,
topUnresolved,
usersByAuth,
] = await Promise.all([
prisma.post.count(),
prisma.user.count(),
prisma.comment.count(),
prisma.vote.count(),
prisma.post.count({ where: { createdAt: { gte: weekAgo } } }),
prisma.post.groupBy({ by: ["status"], _count: true }),
prisma.post.groupBy({ by: ["type"], _count: true }),
prisma.board.findMany({
@@ -32,30 +37,51 @@ export default async function adminStatsRoutes(app: FastifyInstance) {
_count: { select: { posts: true } },
},
}),
prisma.post.findMany({
where: { status: { in: ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS"] } },
orderBy: { voteCount: "desc" },
take: 10,
select: {
id: true,
title: true,
voteCount: true,
board: { select: { slug: true } },
},
}),
prisma.user.groupBy({ by: ["authMethod"], _count: true }),
]);
reply.send({
totalPosts,
thisWeek,
byStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
byType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
topUnresolved: topUnresolved.map((p) => ({
id: p.id,
title: p.title,
voteCount: p.voteCount,
boardSlug: p.board.slug,
})),
totals: {
posts: totalPosts,
users: totalUsers,
comments: totalComments,
votes: totalVotes,
},
postsByStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
postsByType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
boards: boardStats.map((b) => ({
id: b.id,
slug: b.slug,
name: b.name,
postCount: b._count.posts,
})),
authMethodRatio: Object.fromEntries(usersByAuth.map((u) => [u.authMethod, u._count])),
});
}
);
app.get(
"/admin/data-retention",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const activityCutoff = new Date();
activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);

View File

@@ -0,0 +1,235 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const DEFAULT_STATUSES = [
{ status: "OPEN", label: "Open", color: "#F59E0B", position: 0 },
{ status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1 },
{ status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2 },
{ status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3 },
{ status: "DONE", label: "Done", color: "#22C55E", position: 4 },
{ status: "DECLINED", label: "Declined", color: "#EF4444", position: 5 },
];
const statusEntry = z.object({
status: z.string().min(1).max(50).trim(),
label: z.string().min(1).max(40).trim(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
position: z.number().int().min(0).max(50),
});
const bulkUpdateBody = z.object({
statuses: z.array(statusEntry).min(1).max(50),
});
const movePostsBody = z.object({
fromStatus: z.string().min(1).max(50),
toStatus: z.string().min(1).max(50),
});
export default async function adminStatusRoutes(app: FastifyInstance) {
app.get<{ Params: { boardId: string } }>(
"/admin/boards/:boardId/statuses",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const existing = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
orderBy: { position: "asc" },
});
if (existing.length === 0) {
reply.send({
statuses: DEFAULT_STATUSES.map((s) => ({
...s,
enabled: true,
isDefault: true,
})),
});
return;
}
reply.send({
statuses: existing.map((s) => ({
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: true,
})),
});
}
);
app.put<{ Params: { boardId: string }; Body: z.infer<typeof bulkUpdateBody> }>(
"/admin/boards/:boardId/statuses",
{ 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.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const { statuses } = bulkUpdateBody.parse(req.body);
const seen = new Set<string>();
for (const s of statuses) {
if (seen.has(s.status)) {
reply.status(400).send({ error: `Duplicate status: ${s.status}` });
return;
}
seen.add(s.status);
}
if (!statuses.some((s) => s.status === "OPEN")) {
reply.status(400).send({ error: "OPEN status must be included" });
return;
}
// find currently active statuses being removed
const sentStatuses = new Set(statuses.map((s) => s.status));
const currentActive = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
});
const removedStatuses = currentActive
.map((s) => s.status)
.filter((s) => !sentStatuses.has(s));
if (removedStatuses.length > 0) {
const inUse = await prisma.post.count({
where: { boardId: board.id, status: { in: removedStatuses } },
});
if (inUse > 0) {
reply.status(409).send({
error: "Cannot remove statuses that have posts. Move them first.",
inUseStatuses: removedStatuses,
});
return;
}
}
// upsert active, disable removed
const ops = [
...statuses.map((s) =>
prisma.boardStatus.upsert({
where: { boardId_status: { boardId: board.id, status: s.status } },
create: {
boardId: board.id,
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: true,
},
update: {
label: s.label,
color: s.color,
position: s.position,
enabled: true,
},
})
),
// disable any removed statuses
...(removedStatuses.length > 0
? [prisma.boardStatus.updateMany({
where: { boardId: board.id, status: { in: removedStatuses } },
data: { enabled: false },
})]
: []),
];
await prisma.$transaction(ops);
const result = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
orderBy: { position: "asc" },
});
reply.send({
statuses: result.map((s) => ({
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: true,
})),
});
}
);
// Move all posts from one status to another on a board
app.post<{ Params: { boardId: string }; Body: z.infer<typeof movePostsBody> }>(
"/admin/boards/:boardId/statuses/move",
{ 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.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const { fromStatus, toStatus } = movePostsBody.parse(req.body);
if (fromStatus === toStatus) {
reply.status(400).send({ error: "Source and target status must differ" });
return;
}
// validate that toStatus is enabled for this board
const boardStatuses = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
});
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === toStatus)) {
reply.status(400).send({ error: `Target status "${toStatus}" is not enabled for this board` });
return;
}
// find affected posts first for audit trail
const totalAffected = await prisma.post.count({
where: { boardId: board.id, status: fromStatus },
});
if (totalAffected > 500) {
reply.status(400).send({ error: `Too many posts (${totalAffected}). Move in smaller batches by filtering first.` });
return;
}
const affected = await prisma.post.findMany({
where: { boardId: board.id, status: fromStatus },
select: { id: true },
take: 500,
});
if (affected.length === 0) {
reply.send({ moved: 0 });
return;
}
const affectedIds = affected.map((p) => p.id);
await prisma.$transaction([
prisma.post.updateMany({
where: { id: { in: affectedIds } },
data: { status: toStatus },
}),
...affectedIds.map((postId) =>
prisma.statusChange.create({
data: {
postId,
fromStatus,
toStatus,
changedBy: req.adminId!,
reason: "Bulk status move",
},
})
),
]);
req.log.info({ adminId: req.adminId, boardId: board.id, fromStatus, toStatus, count: affectedIds.length }, "bulk status move");
reply.send({ moved: affectedIds.length });
}
);
}

View File

@@ -0,0 +1,137 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const createTagBody = z.object({
name: z.string().min(1).max(30).trim(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366F1"),
});
const updateTagBody = z.object({
name: z.string().min(1).max(30).trim().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
});
const tagPostBody = z.object({
tagIds: z.array(z.string().min(1)).max(10),
});
export default async function adminTagRoutes(app: FastifyInstance) {
app.get(
"/admin/tags",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const tags = await prisma.tag.findMany({
orderBy: { name: "asc" },
include: { _count: { select: { posts: true } } },
take: 500,
});
reply.send({ tags });
}
);
app.post<{ Body: z.infer<typeof createTagBody> }>(
"/admin/tags",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createTagBody.parse(req.body);
try {
const tag = await prisma.tag.create({ data: body });
req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag created");
reply.status(201).send(tag);
} catch (err: any) {
if (err.code === "P2002") {
reply.status(409).send({ error: "Tag already exists" });
return;
}
throw err;
}
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateTagBody> }>(
"/admin/tags/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const tag = await prisma.tag.findUnique({ where: { id: req.params.id } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
const body = updateTagBody.parse(req.body);
try {
const updated = await prisma.tag.update({
where: { id: tag.id },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.color !== undefined && { color: body.color }),
},
});
req.log.info({ adminId: req.adminId, tagId: tag.id }, "tag updated");
reply.send(updated);
} catch (err: any) {
if (err.code === "P2002") {
reply.status(409).send({ error: "Tag name already taken" });
return;
}
throw err;
}
}
);
app.delete<{ Params: { id: string } }>(
"/admin/tags/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const tag = await prisma.tag.findUnique({ where: { id: req.params.id } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
await prisma.tag.delete({ where: { id: tag.id } });
req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag deleted");
reply.status(204).send();
}
);
// Set tags on a post (replaces existing tags)
app.put<{ Params: { id: string }; Body: z.infer<typeof tagPostBody> }>(
"/admin/posts/:id/tags",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const { tagIds } = tagPostBody.parse(req.body);
// verify all tags exist
const tags = await prisma.tag.findMany({ where: { id: { in: tagIds } } });
if (tags.length !== tagIds.length) {
reply.status(400).send({ error: "One or more tags not found" });
return;
}
// replace all tags in a transaction
await prisma.$transaction([
prisma.postTag.deleteMany({ where: { postId: post.id } }),
...tagIds.map((tagId) =>
prisma.postTag.create({ data: { postId: post.id, tagId } })
),
]);
const updated = await prisma.postTag.findMany({
where: { postId: post.id },
include: { tag: true },
});
reply.send({ tags: updated.map((pt) => pt.tag) });
}
);
}

View File

@@ -0,0 +1,358 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { prisma } from "../../lib/prisma.js";
import { config, masterKey, blindIndexKey } from "../../config.js";
import { encrypt, decrypt, hashToken, blindIndex } from "../../services/encryption.js";
import { generateRecoveryPhrase } from "../../lib/wordlist.js";
function decryptSafe(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
const inviteBody = z.object({
role: z.enum(["ADMIN", "MODERATOR"]),
expiresInHours: z.number().int().min(1).max(168).default(72),
label: z.string().min(1).max(100).optional(),
generateRecovery: z.boolean().default(false),
});
const claimBody = z.object({
displayName: z.string().min(1).max(100).trim(),
teamTitle: z.string().max(100).trim().optional(),
});
const updateProfileBody = z.object({
displayName: z.string().min(1).max(100).trim().optional(),
teamTitle: z.string().max(100).trim().optional(),
});
export default async function adminTeamRoutes(app: FastifyInstance) {
// list team members
app.get(
"/admin/team",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const where = req.adminRole === "SUPER_ADMIN" ? {} : { invitedById: req.adminId };
const members = await prisma.adminUser.findMany({
where,
include: {
linkedUser: { select: { id: true, authMethod: true, avatarPath: true } },
invitedBy: { select: { id: true, displayName: true } },
_count: { select: { invitees: true } },
},
orderBy: { createdAt: "asc" },
});
reply.send({
members: members.map((m) => ({
id: m.id,
role: m.role,
displayName: decryptSafe(m.displayName),
teamTitle: decryptSafe(m.teamTitle),
email: m.email ?? null,
hasPasskey: m.linkedUser?.authMethod === "PASSKEY",
avatarUrl: m.linkedUser?.avatarPath ? `/api/v1/avatars/${m.linkedUser.id}` : null,
invitedBy: m.invitedBy ? { id: m.invitedBy.id, displayName: decryptSafe(m.invitedBy.displayName) } : null,
inviteeCount: m._count.invitees,
createdAt: m.createdAt,
})),
});
}
);
// generate invite
app.post(
"/admin/team/invite",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const body = inviteBody.parse(req.body);
// admins can only invite moderators
if (req.adminRole === "ADMIN" && body.role !== "MODERATOR") {
reply.status(403).send({ error: "Admins can only invite moderators" });
return;
}
const token = randomBytes(32).toString("hex");
const tokenH = hashToken(token);
const expiresAt = new Date(Date.now() + body.expiresInHours * 60 * 60 * 1000);
let recoveryPhrase: string | null = null;
let recoveryHash: string | null = null;
let recoveryIdx: string | null = null;
if (body.generateRecovery) {
recoveryPhrase = generateRecoveryPhrase();
recoveryHash = await bcrypt.hash(recoveryPhrase, 12);
recoveryIdx = blindIndex(recoveryPhrase, blindIndexKey);
}
await prisma.teamInvite.create({
data: {
tokenHash: tokenH,
role: body.role,
label: body.label ?? null,
expiresAt,
createdById: req.adminId!,
recoveryHash,
recoveryIdx,
},
});
const inviteUrl = `${config.WEBAUTHN_ORIGIN}/admin/join/${token}`;
reply.status(201).send({ inviteUrl, token, recoveryPhrase, expiresAt });
}
);
// list pending invites
app.get(
"/admin/team/invites",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const where: Record<string, unknown> = { claimedAt: null, expiresAt: { gt: new Date() } };
if (req.adminRole !== "SUPER_ADMIN") where.createdById = req.adminId;
const invites = await prisma.teamInvite.findMany({
where,
include: { createdBy: { select: { id: true, displayName: true } } },
orderBy: { createdAt: "desc" },
});
reply.send({
invites: invites.map((inv) => ({
id: inv.id,
role: inv.role,
label: inv.label,
expiresAt: inv.expiresAt,
hasRecovery: !!inv.recoveryHash,
createdBy: { id: inv.createdBy.id, displayName: decryptSafe(inv.createdBy.displayName) },
createdAt: inv.createdAt,
})),
});
}
);
// revoke invite
app.delete<{ Params: { id: string } }>(
"/admin/team/invites/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const invite = await prisma.teamInvite.findUnique({ where: { id: req.params.id } });
if (!invite || invite.claimedAt) {
reply.status(404).send({ error: "Invite not found" });
return;
}
if (req.adminRole !== "SUPER_ADMIN" && invite.createdById !== req.adminId) {
reply.status(403).send({ error: "Not your invite" });
return;
}
await prisma.teamInvite.delete({ where: { id: invite.id } });
reply.status(204).send();
}
);
// remove team member (super admin only)
app.delete<{ Params: { id: string } }>(
"/admin/team/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
if (req.params.id === req.adminId) {
reply.status(400).send({ error: "Cannot remove yourself" });
return;
}
const member = await prisma.adminUser.findUnique({ where: { id: req.params.id } });
if (!member) {
reply.status(404).send({ error: "Team member not found" });
return;
}
if (member.role === "SUPER_ADMIN") {
reply.status(403).send({ error: "Cannot remove the super admin" });
return;
}
await prisma.adminUser.delete({ where: { id: member.id } });
reply.status(204).send();
}
);
// update own profile
app.put(
"/admin/team/me",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = updateProfileBody.parse(req.body);
const data: Record<string, string> = {};
if (body.displayName !== undefined) data.displayName = encrypt(body.displayName, masterKey);
if (body.teamTitle !== undefined) data.teamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : "";
if (Object.keys(data).length === 0) {
reply.status(400).send({ error: "Nothing to update" });
return;
}
await prisma.adminUser.update({ where: { id: req.adminId! }, data });
reply.send({ ok: true });
}
);
// regenerate recovery phrase for a team member
app.post<{ Params: { id: string } }>(
"/admin/team/:id/recovery",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
async (req, reply) => {
const target = await prisma.adminUser.findUnique({
where: { id: req.params.id },
select: { id: true, role: true, invitedById: true, linkedUserId: true },
});
if (!target || !target.linkedUserId) {
reply.status(404).send({ error: "Team member not found" });
return;
}
if (target.role === "SUPER_ADMIN") {
reply.status(403).send({ error: "Cannot regenerate recovery for super admin" });
return;
}
// admins can only regenerate for people they invited
if (req.adminRole === "ADMIN" && target.invitedById !== req.adminId) {
reply.status(403).send({ error: "You can only regenerate recovery for people you invited" });
return;
}
const phrase = generateRecoveryPhrase();
const codeHash = await bcrypt.hash(phrase, 12);
const phraseIdx = blindIndex(phrase, blindIndexKey);
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
await prisma.recoveryCode.upsert({
where: { userId: target.linkedUserId },
create: { codeHash, phraseIdx, userId: target.linkedUserId, expiresAt },
update: { codeHash, phraseIdx, expiresAt },
});
reply.send({ phrase, expiresAt });
}
);
// validate invite token (public, for the claim page)
app.get<{ Params: { token: string } }>(
"/admin/join/:token",
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const tokenH = hashToken(req.params.token);
const invite = await prisma.teamInvite.findUnique({
where: { tokenHash: tokenH },
include: { createdBy: { select: { displayName: true } } },
});
if (!invite || invite.claimedAt || invite.expiresAt < new Date()) {
reply.status(404).send({ error: "Invalid or expired invite" });
return;
}
reply.send({
role: invite.role,
label: invite.label,
invitedBy: decryptSafe(invite.createdBy.displayName) ?? "Admin",
expiresAt: invite.expiresAt,
hasRecovery: !!invite.recoveryHash,
});
}
);
// claim invite
app.post<{ Params: { token: string } }>(
"/admin/join/:token",
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
async (req, reply) => {
const body = claimBody.parse(req.body);
const tokenH = hashToken(req.params.token);
const sessionToken = randomBytes(32).toString("hex");
const sessionHash = hashToken(sessionToken);
const encDisplayName = encrypt(body.displayName, masterKey);
const encTeamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : null;
let result;
try {
result = await prisma.$transaction(async (tx) => {
const invite = await tx.teamInvite.findUnique({ where: { tokenHash: tokenH } });
if (!invite || invite.claimedAt || invite.expiresAt < new Date()) {
throw new Error("INVITE_INVALID");
}
const user = await tx.user.create({
data: {
displayName: encDisplayName,
authMethod: "COOKIE",
tokenHash: sessionHash,
},
});
const admin = await tx.adminUser.create({
data: {
role: invite.role,
displayName: encDisplayName,
teamTitle: encTeamTitle,
linkedUserId: user.id,
invitedById: invite.createdById,
},
});
await tx.teamInvite.update({
where: { id: invite.id },
data: { claimedById: admin.id, claimedAt: new Date() },
});
if (invite.recoveryHash && invite.recoveryIdx) {
await tx.recoveryCode.create({
data: {
codeHash: invite.recoveryHash,
phraseIdx: invite.recoveryIdx,
userId: user.id,
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
},
});
}
return { userId: user.id, adminId: admin.id, hasRecovery: !!invite.recoveryHash };
}, { isolationLevel: "Serializable" });
} catch (err: any) {
if (err.message === "INVITE_INVALID") {
reply.status(404).send({ error: "Invalid or expired invite" });
return;
}
throw err;
}
const adminJwt = jwt.sign(
{ sub: result.adminId, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "24h" }
);
const userJwt = jwt.sign(
{ sub: result.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "24h" }
);
reply
.setCookie("echoboard_token", sessionToken, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
})
.setCookie("echoboard_admin", adminJwt, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
})
.setCookie("echoboard_passkey", userJwt, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
})
.send({ ok: true, needsSetup: !result.hasRecovery });
}
);
}

View File

@@ -0,0 +1,125 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const fieldSchema = z.object({
key: z.string().min(1).max(50).regex(/^[a-zA-Z_][a-zA-Z0-9_ ]*$/, "Invalid field key"),
label: z.string().min(1).max(100),
type: z.enum(["text", "textarea", "select"]),
required: z.boolean(),
placeholder: z.string().max(200).optional(),
options: z.array(z.string().max(100)).optional(),
});
const createBody = z.object({
name: z.string().min(1).max(100).trim(),
fields: z.array(fieldSchema).min(1).max(30),
isDefault: z.boolean().default(false),
});
const updateBody = z.object({
name: z.string().min(1).max(100).trim().optional(),
fields: z.array(fieldSchema).min(1).max(30).optional(),
isDefault: z.boolean().optional(),
position: z.number().int().min(0).optional(),
});
export default async function adminTemplateRoutes(app: FastifyInstance) {
app.get<{ Params: { boardId: string } }>(
"/admin/boards/:boardId/templates",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const templates = await prisma.boardTemplate.findMany({
where: { boardId: board.id },
orderBy: { position: "asc" },
});
reply.send({ templates });
}
);
app.post<{ Params: { boardId: string }; Body: z.infer<typeof createBody> }>(
"/admin/boards/:boardId/templates",
{ 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.boardId } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const body = createBody.parse(req.body);
const maxPos = await prisma.boardTemplate.aggregate({
where: { boardId: board.id },
_max: { position: true },
});
const nextPos = (maxPos._max.position ?? -1) + 1;
// if setting as default, unset others
if (body.isDefault) {
await prisma.boardTemplate.updateMany({
where: { boardId: board.id, isDefault: true },
data: { isDefault: false },
});
}
const template = await prisma.boardTemplate.create({
data: {
boardId: board.id,
name: body.name,
fields: body.fields as any,
isDefault: body.isDefault,
position: nextPos,
},
});
req.log.info({ adminId: req.adminId, templateId: template.id, boardId: board.id }, "template created");
reply.status(201).send(template);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
"/admin/templates/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } });
if (!existing) return reply.status(404).send({ error: "Template not found" });
const body = updateBody.parse(req.body);
if (body.isDefault) {
await prisma.boardTemplate.updateMany({
where: { boardId: existing.boardId, isDefault: true, id: { not: existing.id } },
data: { isDefault: false },
});
}
const updated = await prisma.boardTemplate.update({
where: { id: existing.id },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.fields !== undefined && { fields: body.fields as any }),
...(body.isDefault !== undefined && { isDefault: body.isDefault }),
...(body.position !== undefined && { position: body.position }),
},
});
req.log.info({ adminId: req.adminId, templateId: existing.id }, "template updated");
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/templates/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } });
if (!existing) return reply.status(404).send({ error: "Template not found" });
await prisma.boardTemplate.delete({ where: { id: existing.id } });
req.log.info({ adminId: req.adminId, templateId: existing.id, boardId: existing.boardId }, "template deleted");
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,144 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import { encrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { isAllowedUrl, resolvedIpIsAllowed } from "../../services/webhooks.js";
import { prisma } from "../../lib/prisma.js";
const VALID_EVENTS = ["status_changed", "post_created", "comment_added"] as const;
const httpsUrl = z.string().url().max(500).refine((val) => {
try {
return new URL(val).protocol === "https:";
} catch { return false; }
}, { message: "Only HTTPS URLs are allowed" });
const createBody = z.object({
url: httpsUrl,
events: z.array(z.enum(VALID_EVENTS)).min(1),
});
const updateBody = z.object({
url: httpsUrl.optional(),
events: z.array(z.enum(VALID_EVENTS)).min(1).optional(),
active: z.boolean().optional(),
});
export default async function adminWebhookRoutes(app: FastifyInstance) {
app.get(
"/admin/webhooks",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const webhooks = await prisma.webhook.findMany({ orderBy: { createdAt: "desc" }, take: 100 });
reply.send({
webhooks: webhooks.map((w) => ({
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.createdAt,
})),
});
}
);
app.post<{ Body: z.infer<typeof createBody> }>(
"/admin/webhooks",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createBody.parse(req.body);
if (!isAllowedUrl(body.url)) {
reply.status(400).send({ error: "URL not allowed" });
return;
}
const createHostname = new URL(body.url).hostname;
if (!await resolvedIpIsAllowed(createHostname)) {
reply.status(400).send({ error: "URL resolves to a disallowed address" });
return;
}
const plainSecret = randomBytes(32).toString("hex");
const encryptedSecret = encrypt(plainSecret, masterKey);
const webhook = await prisma.webhook.create({
data: {
url: body.url,
secret: encryptedSecret,
events: body.events,
},
});
req.log.info({ adminId: req.adminId, webhookId: webhook.id }, "webhook created");
// return plaintext secret only on creation - admin needs it to verify signatures
reply.status(201).send({
id: webhook.id,
url: webhook.url,
events: webhook.events,
active: webhook.active,
secret: plainSecret,
createdAt: webhook.createdAt,
});
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
"/admin/webhooks/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } });
if (!wh) {
reply.status(404).send({ error: "Webhook not found" });
return;
}
const body = updateBody.parse(req.body);
if (body.url) {
if (!isAllowedUrl(body.url)) {
reply.status(400).send({ error: "URL not allowed" });
return;
}
const updateHostname = new URL(body.url).hostname;
if (!await resolvedIpIsAllowed(updateHostname)) {
reply.status(400).send({ error: "URL resolves to a disallowed address" });
return;
}
}
const updated = await prisma.webhook.update({
where: { id: wh.id },
data: {
...(body.url !== undefined && { url: body.url }),
...(body.events !== undefined && { events: body.events }),
...(body.active !== undefined && { active: body.active }),
},
});
req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook updated");
reply.send({
id: updated.id,
url: updated.url,
events: updated.events,
active: updated.active,
createdAt: updated.createdAt,
});
}
);
app.delete<{ Params: { id: string } }>(
"/admin/webhooks/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } });
if (!wh) {
reply.status(404).send({ error: "Webhook not found" });
return;
}
await prisma.webhook.delete({ where: { id: wh.id } });
req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook deleted");
reply.status(204).send();
}
);
}