security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
|
||||
91
packages/api/src/routes/admin/changelog.ts
Normal file
91
packages/api/src/routes/admin/changelog.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
164
packages/api/src/routes/admin/export.ts
Normal file
164
packages/api/src/routes/admin/export.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
100
packages/api/src/routes/admin/notes.ts
Normal file
100
packages/api/src/routes/admin/notes.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
59
packages/api/src/routes/admin/settings.ts
Normal file
59
packages/api/src/routes/admin/settings.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
235
packages/api/src/routes/admin/statuses.ts
Normal file
235
packages/api/src/routes/admin/statuses.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
137
packages/api/src/routes/admin/tags.ts
Normal file
137
packages/api/src/routes/admin/tags.ts
Normal 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) });
|
||||
}
|
||||
);
|
||||
}
|
||||
358
packages/api/src/routes/admin/team.ts
Normal file
358
packages/api/src/routes/admin/team.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
125
packages/api/src/routes/admin/templates.ts
Normal file
125
packages/api/src/routes/admin/templates.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
144
packages/api/src/routes/admin/webhooks.ts
Normal file
144
packages/api/src/routes/admin/webhooks.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user