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

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

View File

@@ -73,7 +73,7 @@ async function main() {
const hash = await bcrypt.hash(password, 12);
const admin = await prisma.adminUser.create({
data: { email, passwordHash: hash },
data: { email, passwordHash: hash, role: "SUPER_ADMIN" },
});
console.log(`Admin created: ${admin.email} (${admin.id})`);

View File

@@ -3,10 +3,11 @@ import { z } from "zod";
const schema = z.object({
DATABASE_URL: z.string(),
APP_MASTER_KEY: z.string().regex(/^[0-9a-fA-F]{64}$/, "Must be hex-encoded 256-bit key"),
APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]+$/, "Must be hex-encoded"),
TOKEN_SECRET: z.string(),
JWT_SECRET: z.string(),
ALTCHA_HMAC_KEY: z.string(),
APP_MASTER_KEY_PREVIOUS: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(),
APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]{32,}$/, "Must be hex-encoded, at least 128 bits"),
TOKEN_SECRET: z.string().min(32),
JWT_SECRET: z.string().min(32),
ALTCHA_HMAC_KEY: z.string().min(32, "ALTCHA HMAC key must be at least 32 characters"),
WEBAUTHN_RP_NAME: z.string().default("Echoboard"),
WEBAUTHN_RP_ID: z.string(),
@@ -38,4 +39,7 @@ if (!parsed.success) {
export const config = parsed.data;
export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex");
export const previousMasterKey = config.APP_MASTER_KEY_PREVIOUS
? Buffer.from(config.APP_MASTER_KEY_PREVIOUS, "hex")
: null;
export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex");

View File

@@ -1,9 +1,12 @@
import cron from "node-cron";
import { PrismaClient } from "@prisma/client";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { config } from "../config.js";
import { cleanExpiredChallenges } from "../routes/passkey.js";
const prisma = new PrismaClient();
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
import { getPluginCronJobs } from "../plugins/loader.js";
import { cleanupViews } from "../lib/view-tracker.js";
export function startCronJobs() {
// prune old activity events - daily at 3am
@@ -38,23 +41,92 @@ export function startCronJobs() {
}
});
// clean webauthn challenges - every 10 minutes
cron.schedule("*/10 * * * *", () => {
// clean webauthn challenges - every minute
cron.schedule("* * * * *", () => {
cleanExpiredChallenges();
});
// remove failed push subscriptions - daily at 5am
cron.schedule("0 5 * * *", async () => {
// subscriptions with no associated user get cleaned by cascade
// this handles any other stale ones
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 30);
const result = await prisma.pushSubscription.deleteMany({
where: { createdAt: { lt: cutoff } },
// clean expired recovery codes - daily at 3:30am
cron.schedule("30 3 * * *", async () => {
const result = await prisma.recoveryCode.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
if (result.count > 0) {
console.log(`Cleaned ${result.count} old push subscriptions`);
console.log(`Cleaned ${result.count} expired recovery codes`);
}
});
// clean expired blocked tokens - every 30 minutes
cron.schedule("*/30 * * * *", async () => {
const count = await cleanupExpiredTokens();
if (count > 0) {
console.log(`Cleaned ${count} expired blocked tokens`);
}
});
// remove push subscriptions with too many failures - daily at 5am
cron.schedule("0 5 * * *", async () => {
const result = await prisma.pushSubscription.deleteMany({
where: { failureCount: { gte: 3 } },
});
if (result.count > 0) {
console.log(`Cleaned ${result.count} failed push subscriptions`);
}
});
// clean orphaned attachments (uploaded but never linked) - hourly
cron.schedule("30 * * * *", async () => {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const orphans = await prisma.attachment.findMany({
where: {
postId: null,
commentId: null,
createdAt: { lt: cutoff },
},
select: { id: true, path: true },
});
if (orphans.length === 0) return;
const ids = orphans.map((a) => a.id);
const result = await prisma.attachment.deleteMany({
where: { id: { in: ids }, postId: null, commentId: null },
});
if (result.count > 0) {
console.log(`Cleaned ${result.count} orphaned attachments`);
}
for (const att of orphans) {
try {
await unlink(resolve(process.cwd(), "uploads", att.path));
} catch {}
}
});
// clean expired view-tracker entries - every 5 minutes
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
for (const job of getPluginCronJobs()) {
if (!cron.validate(job.schedule)) {
console.error(`Plugin cron "${job.name}" has invalid schedule: ${job.schedule}, skipping`);
continue;
}
// reject schedules with 6 fields (seconds) to prevent sub-minute execution
const fields = job.schedule.trim().split(/\s+/);
if (fields.length > 5) {
console.error(`Plugin cron "${job.name}" uses sub-minute schedule, skipping`);
continue;
}
cron.schedule(job.schedule, async () => {
try {
await job.handler();
} catch (err) {
console.error(`Plugin cron job "${job.name}" failed:`, err);
}
});
console.log(`Registered plugin cron: ${job.name} (${job.schedule})`);
}
}

View File

@@ -1,11 +1,27 @@
import prisma from "./lib/prisma.js";
import { createServer } from "./server.js";
import { config } from "./config.js";
import { startCronJobs } from "./cron/index.js";
import { reEncryptIfNeeded } from "./services/key-rotation.js";
import { validateManifest } from "./services/manifest-validator.js";
async function main() {
validateManifest();
// ensure pg_trgm extension exists for similarity search
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`;
// search indexes for fuzzy + full-text search
await Promise.all([
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_trgm ON "Post" USING gin (title gin_trgm_ops)`),
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_fts ON "Post" USING gin (to_tsvector('english', title))`),
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_board_name_trgm ON "Board" USING gin (name gin_trgm_ops)`),
]);
const app = await createServer();
startCronJobs();
reEncryptIfNeeded().catch((err) => console.error("Key rotation error:", err));
try {
await app.listen({ port: config.PORT, host: "0.0.0.0" });

View File

@@ -1,6 +1,4 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
import prisma from "./prisma.js";
export function getCurrentPeriod(resetSchedule: string): string {
const now = new Date();
@@ -9,17 +7,27 @@ export function getCurrentPeriod(resetSchedule: string): string {
switch (resetSchedule) {
case "weekly": {
const startOfYear = new Date(year, 0, 1);
const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000);
const week = Math.ceil((days + startOfYear.getDay() + 1) / 7);
return `${year}-W${String(week).padStart(2, "0")}`;
// ISO 8601 week number
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
case "biweekly": {
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
const biweek = Math.ceil(week / 2);
return `${d.getUTCFullYear()}-BW${String(biweek).padStart(2, "0")}`;
}
case "quarterly": {
const q = Math.ceil((now.getMonth() + 1) / 3);
return `${year}-Q${q}`;
}
case "yearly":
return `${year}`;
case "per_release":
return "per_release";
case "never":
return "lifetime";
case "monthly":
@@ -28,17 +36,25 @@ export function getCurrentPeriod(resetSchedule: string): string {
}
}
export async function getRemainingBudget(userId: string, boardId: string): Promise<number> {
const board = await prisma.board.findUnique({ where: { id: boardId } });
export async function getRemainingBudget(userId: string, boardId: string, db: any = prisma): Promise<number> {
const board = await db.board.findUnique({ where: { id: boardId } });
if (!board) return 0;
if (board.voteBudgetReset === "never" && board.voteBudget === 0) {
return Infinity;
}
const period = getCurrentPeriod(board.voteBudgetReset);
// per_release uses the timestamp of the last manual reset as the period key
let period: string;
if (board.voteBudgetReset === "per_release") {
period = board.lastBudgetReset
? `release-${board.lastBudgetReset.toISOString()}`
: "release-initial";
} else {
period = getCurrentPeriod(board.voteBudgetReset);
}
const used = await prisma.vote.aggregate({
const used = await db.vote.aggregate({
where: { voterId: userId, post: { boardId }, budgetPeriod: period },
_sum: { weight: true },
});
@@ -53,7 +69,16 @@ export function getNextResetDate(resetSchedule: string): Date {
switch (resetSchedule) {
case "weekly": {
const d = new Date(now);
d.setDate(d.getDate() + (7 - d.getDay()));
const daysUntilMonday = ((8 - d.getDay()) % 7) || 7;
d.setDate(d.getDate() + daysUntilMonday);
d.setHours(0, 0, 0, 0);
return d;
}
case "biweekly": {
const d = new Date(now);
const dayOfWeek = d.getDay();
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
d.setDate(d.getDate() + daysUntilMonday + 7);
d.setHours(0, 0, 0, 0);
return d;
}
@@ -61,10 +86,11 @@ export function getNextResetDate(resetSchedule: string): Date {
const q = Math.ceil((now.getMonth() + 1) / 3);
return new Date(now.getFullYear(), q * 3, 1);
}
case "yearly":
return new Date(now.getFullYear() + 1, 0, 1);
case "per_release":
// manual reset only - no automatic next date
return new Date(8640000000000000);
case "never":
return new Date(8640000000000000); // max date
return new Date(8640000000000000);
case "monthly":
default: {
const d = new Date(now.getFullYear(), now.getMonth() + 1, 1);

View File

@@ -0,0 +1,158 @@
import { PrismaClient } from "@prisma/client";
interface TemplateField {
key: string;
label: string;
type: "text" | "textarea" | "select";
required: boolean;
placeholder?: string;
options?: string[];
}
interface TemplateDef {
name: string;
fields: TemplateField[];
isDefault: boolean;
position: number;
}
const BUG_TEMPLATES: TemplateDef[] = [
{
name: "Bug Report",
isDefault: true,
position: 0,
fields: [
{ key: "steps_to_reproduce", label: "Steps to reproduce", type: "textarea", required: true, placeholder: "1. Go to...\n2. Click on...\n3. See error" },
{ key: "expected_behavior", label: "Expected behavior", type: "textarea", required: true, placeholder: "What should have happened?" },
{ key: "actual_behavior", label: "Actual behavior", type: "textarea", required: true, placeholder: "What happened instead?" },
{ key: "environment", label: "Environment", type: "text", required: false, placeholder: "e.g. Chrome 120, Windows 11" },
{ key: "severity", label: "Severity", type: "select", required: true, options: ["Critical", "Major", "Minor", "Cosmetic"] },
],
},
{
name: "UI/Visual Bug",
isDefault: false,
position: 1,
fields: [
{ key: "what_looks_wrong", label: "What looks wrong", type: "textarea", required: true, placeholder: "Describe the visual issue" },
{ key: "where_in_app", label: "Where in the app", type: "text", required: true, placeholder: "Page or screen name" },
{ key: "browser_device", label: "Browser and device", type: "text", required: true, placeholder: "e.g. Safari on iPhone 15" },
{ key: "screen_size", label: "Screen size", type: "text", required: false, placeholder: "e.g. 1920x1080 or mobile" },
],
},
{
name: "Performance Issue",
isDefault: false,
position: 2,
fields: [
{ key: "whats_slow", label: "What's slow or laggy", type: "textarea", required: true },
{ key: "when_it_happens", label: "When does it happen", type: "textarea", required: true, placeholder: "Always, sometimes, under specific conditions..." },
{ key: "how_long", label: "How long does it take", type: "text", required: false, placeholder: "e.g. 10+ seconds to load" },
{ key: "device_network", label: "Device and network", type: "text", required: false, placeholder: "e.g. MacBook Pro, WiFi" },
],
},
{
name: "Crash/Error Report",
isDefault: false,
position: 3,
fields: [
{ key: "what_happened", label: "What happened", type: "textarea", required: true },
{ key: "error_message", label: "Error message", type: "textarea", required: false, placeholder: "Copy the error text if visible" },
{ key: "steps_before_crash", label: "Steps before the crash", type: "textarea", required: true },
{ key: "frequency", label: "How often does it happen", type: "select", required: true, options: ["Every time", "Often", "Sometimes", "Once"] },
],
},
{
name: "Data Issue",
isDefault: false,
position: 4,
fields: [
{ key: "what_data_is_wrong", label: "What data is wrong", type: "textarea", required: true },
{ key: "where_you_see_it", label: "Where do you see it", type: "text", required: true, placeholder: "Page, section, or API endpoint" },
{ key: "what_it_should_be", label: "What it should be", type: "textarea", required: true },
{ key: "impact", label: "Impact", type: "select", required: true, options: ["Blocking", "Major", "Minor"] },
],
},
];
const FEATURE_TEMPLATES: TemplateDef[] = [
{
name: "Feature Request",
isDefault: false,
position: 5,
fields: [
{ key: "use_case", label: "Use case", type: "textarea", required: true, placeholder: "What problem would this solve?" },
{ key: "proposed_solution", label: "Proposed solution", type: "textarea", required: true, placeholder: "How do you imagine this working?" },
{ key: "alternatives", label: "Alternatives considered", type: "textarea", required: false },
{ key: "who_benefits", label: "Who benefits", type: "text", required: false, placeholder: "e.g. All users, admins, new users" },
],
},
{
name: "UX Improvement",
isDefault: false,
position: 6,
fields: [
{ key: "whats_confusing", label: "What's confusing or difficult", type: "textarea", required: true },
{ key: "how_should_it_work", label: "How should it work instead", type: "textarea", required: true },
{ key: "who_runs_into_this", label: "Who runs into this", type: "text", required: false, placeholder: "e.g. New users, power users" },
{ key: "frequency", label: "How often does this come up", type: "select", required: false, options: ["Daily", "Weekly", "Occasionally", "Rarely"] },
],
},
{
name: "Integration Request",
isDefault: false,
position: 7,
fields: [
{ key: "tool_or_service", label: "Tool or service", type: "text", required: true, placeholder: "e.g. Slack, Jira, GitHub" },
{ key: "what_it_should_do", label: "What it should do", type: "textarea", required: true, placeholder: "Describe the integration behavior" },
{ key: "current_workaround", label: "Current workaround", type: "textarea", required: false },
{ key: "priority", label: "Priority", type: "select", required: true, options: ["Must have", "Should have", "Nice to have"] },
],
},
{
name: "Content Change",
isDefault: false,
position: 8,
fields: [
{ key: "where", label: "Where is it", type: "text", required: true, placeholder: "Page, section, or URL" },
{ key: "current_text", label: "Current text", type: "textarea", required: true },
{ key: "suggested_text", label: "Suggested text", type: "textarea", required: true },
{ key: "why_change", label: "Why change it", type: "textarea", required: false },
],
},
{
name: "Workflow Improvement",
isDefault: false,
position: 9,
fields: [
{ key: "current_workflow", label: "Current workflow", type: "textarea", required: true, placeholder: "Describe the steps you take today" },
{ key: "pain_point", label: "Pain point", type: "textarea", required: true, placeholder: "What's slow, repetitive, or error-prone?" },
{ key: "ideal_workflow", label: "Ideal workflow", type: "textarea", required: true, placeholder: "How should it work?" },
{ key: "impact", label: "Impact", type: "select", required: false, options: ["Saves significant time", "Reduces errors", "Improves quality", "Minor convenience"] },
],
},
];
export const DEFAULT_TEMPLATES = [...BUG_TEMPLATES, ...FEATURE_TEMPLATES];
export async function seedTemplatesForBoard(prisma: PrismaClient, boardId: string) {
const existing = await prisma.boardTemplate.count({ where: { boardId } });
if (existing > 0) return;
await prisma.boardTemplate.createMany({
data: DEFAULT_TEMPLATES.map((t) => ({
boardId,
name: t.name,
fields: t.fields as any,
isDefault: t.isDefault,
position: t.position,
})),
});
}
export async function seedAllBoardTemplates(prisma: PrismaClient) {
const boards = await prisma.board.findMany({ select: { id: true } });
for (const board of boards) {
await seedTemplatesForBoard(prisma, board.id);
}
}

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export default prisma;

View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
import prisma from "./prisma.js";
function hashForBlock(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function blockToken(token: string, ttlMs: number = 25 * 60 * 60 * 1000): Promise<void> {
const tokenHash = hashForBlock(token);
const expiresAt = new Date(Date.now() + ttlMs);
await prisma.blockedToken.upsert({
where: { tokenHash },
create: { tokenHash, expiresAt },
update: { expiresAt },
});
}
export async function isTokenBlocked(token: string): Promise<boolean> {
const tokenHash = hashForBlock(token);
const entry = await prisma.blockedToken.findUnique({ where: { tokenHash } });
if (!entry) return false;
return entry.expiresAt > new Date();
}
export async function cleanupExpiredTokens(): Promise<number> {
const result = await prisma.blockedToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}

View File

@@ -0,0 +1,25 @@
const MAX_ENTRIES = 50000;
const seen = new Map<string, number>();
export function shouldCount(postId: string, identifier: string): boolean {
const key = `${postId}:${identifier}`;
const expiry = seen.get(key);
if (expiry && expiry > Date.now()) return false;
if (seen.size >= MAX_ENTRIES) {
const now = Date.now();
for (const [k, v] of seen) {
if (v < now) seen.delete(k);
if (seen.size < MAX_ENTRIES * 0.8) break;
}
if (seen.size >= MAX_ENTRIES) return false;
}
seen.set(key, Date.now() + 15 * 60 * 1000);
return true;
}
export function cleanupViews(): void {
const now = Date.now();
for (const [k, v] of seen) {
if (v < now) seen.delete(k);
}
}

View File

@@ -0,0 +1,46 @@
import { randomInt } from "node:crypto";
// 256 common, unambiguous English words (4-7 letters)
// 256^6 = ~2.8 * 10^14 combinations - strong enough with rate limiting + ALTCHA
const WORDS = [
"acre", "alps", "arch", "army", "atom", "aura", "axis", "balm",
"band", "bark", "barn", "base", "bath", "beam", "bell", "belt",
"bend", "bird", "bite", "blow", "blur", "boat", "bold", "bolt",
"bond", "bone", "book", "bore", "boss", "bowl", "brim", "bulb",
"bulk", "burn", "bush", "buzz", "cafe", "cage", "calm", "camp",
"cape", "card", "cart", "case", "cast", "cave", "cell", "chat",
"chip", "city", "clam", "clan", "claw", "clay", "clip", "club",
"clue", "coal", "coat", "code", "coil", "coin", "cold", "cone",
"cook", "cool", "cope", "cord", "core", "cork", "corn", "cost",
"cove", "crew", "crop", "crow", "cube", "curb", "cure", "curl",
"dale", "dare", "dart", "dash", "dawn", "deck", "deer", "deli",
"demo", "dent", "desk", "dime", "disc", "dock", "dome", "door",
"dose", "dove", "draw", "drum", "dune", "dusk", "dust", "edge",
"emit", "epic", "exit", "face", "fact", "fame", "fawn", "felt",
"fern", "film", "find", "fire", "firm", "fish", "fist", "flag",
"flak", "flaw", "flex", "flip", "flow", "flux", "foam", "foil",
"fold", "folk", "font", "ford", "fork", "form", "fort", "frog",
"fuel", "fume", "fund", "fury", "fuse", "gale", "game", "gang",
"gate", "gaze", "gear", "germ", "gift", "gist", "glen", "glow",
"glue", "gold", "golf", "gong", "grab", "gram", "grid", "grin",
"grip", "grit", "gulf", "gust", "hail", "half", "hall", "halt",
"hare", "harp", "hawk", "haze", "heap", "helm", "herb", "herd",
"hero", "hike", "hill", "hint", "hive", "hold", "hole", "hood",
"hook", "hope", "horn", "host", "howl", "hull", "hunt", "husk",
"iris", "iron", "isle", "jade", "jazz", "jest", "jolt", "jump",
"jury", "keen", "kelp", "kite", "knob", "knot", "lace", "lake",
"lamb", "lamp", "lane", "lark", "lava", "lawn", "lead", "leaf",
"lens", "lien", "lime", "line", "link", "lion", "lock", "loft",
"loop", "loom", "lore", "luck", "lure", "lynx", "malt", "mane",
"maze", "mesa", "mild", "mill", "mine", "mint", "mist", "moat",
"mode", "mold", "monk", "moon", "moor", "moss", "myth", "navy",
"nest", "node", "norm", "note", "nova", "oaks", "opal", "orca",
];
export function generateRecoveryPhrase(): string {
const words: string[] = [];
for (let i = 0; i < 6; i++) {
words.push(WORDS[randomInt(WORDS.length)]);
}
return words.join("-");
}

View File

@@ -1,27 +1,33 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import fp from "fastify-plugin";
import jwt from "jsonwebtoken";
import { PrismaClient, User } from "@prisma/client";
import type { User } from "@prisma/client";
import prisma from "../lib/prisma.js";
import { hashToken } from "../services/encryption.js";
import { config } from "../config.js";
import { isTokenBlocked } from "../lib/token-blocklist.js";
declare module "fastify" {
interface FastifyRequest {
user?: User;
adminId?: string;
adminRole?: string;
}
}
const prisma = new PrismaClient();
async function authPlugin(app: FastifyInstance) {
app.decorateRequest("user", undefined);
app.decorateRequest("adminId", undefined);
app.decorateRequest("adminRole", undefined);
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
// try cookie auth first
const token = req.cookies?.echoboard_token;
if (token) {
if (await isTokenBlocked(token)) {
reply.status(401).send({ error: "Not authenticated" });
return;
}
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
@@ -30,11 +36,15 @@ async function authPlugin(app: FastifyInstance) {
}
}
// try bearer token (passkey sessions)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
// try passkey session cookie, then fall back to Authorization header
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
@@ -53,15 +63,23 @@ async function authPlugin(app: FastifyInstance) {
app.decorate("optionalUser", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_token;
if (token) {
if (await isTokenBlocked(token)) return;
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) req.user = user;
return;
if (user) {
req.user = user;
return;
}
}
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) req.user = user;
@@ -72,22 +90,103 @@ async function authPlugin(app: FastifyInstance) {
}
});
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
reply.status(401).send({ error: "Admin token required" });
return;
app.decorate("optionalAdmin", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_admin;
if (token && !(await isTokenBlocked(token))) {
try {
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "admin") {
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
if (admin) {
req.adminId = decoded.sub;
req.adminRole = admin.role;
return;
}
}
} catch {}
}
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type !== "admin") {
reply.status(403).send({ error: "Admin access required" });
// fallback: check if authenticated user is a linked team member
if (req.user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: req.user.id },
select: { id: true, role: true },
});
if (admin) {
req.adminId = admin.id;
req.adminRole = admin.role;
}
}
});
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
// try admin JWT cookie first (super admin login flow)
const token = req.cookies?.echoboard_admin ?? null;
if (token && !(await isTokenBlocked(token))) {
try {
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "admin") {
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
if (admin) {
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
} catch {}
}
// fallback: check if user is authenticated via cookie and linked to an admin
const cookieToken = req.cookies?.echoboard_token;
if (cookieToken && !(await isTokenBlocked(cookieToken))) {
const hash = hashToken(cookieToken);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: user.id },
select: { id: true, role: true },
});
if (admin) {
req.user = user;
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
}
// try passkey token
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: user.id },
select: { id: true, role: true },
});
if (admin) {
req.user = user;
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
}
} catch {}
}
reply.status(401).send({ error: "Unauthorized" });
});
app.decorate("requireRole", (...roles: string[]) => {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.adminId || !req.adminRole || !roles.includes(req.adminRole)) {
reply.status(403).send({ error: "Insufficient permissions" });
return;
}
req.adminId = decoded.sub;
} catch {
reply.status(401).send({ error: "Invalid admin token" });
}
};
});
}
@@ -95,7 +194,9 @@ declare module "fastify" {
interface FastifyInstance {
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
optionalUser: (req: FastifyRequest) => Promise<void>;
optionalAdmin: (req: FastifyRequest) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireRole: (...roles: string[]) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}

View File

@@ -2,26 +2,51 @@ import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
async function securityPlugin(app: FastifyInstance) {
app.addHook("onSend", async (_req, reply) => {
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "));
app.addHook("onSend", async (req, reply) => {
const isEmbed = req.url.startsWith("/api/v1/embed/") || req.url.startsWith("/embed/");
if (isEmbed) {
// embed routes need to be frameable by third-party sites
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"frame-ancestors *",
"base-uri 'self'",
"form-action 'none'",
].join("; "));
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
reply.header("Access-Control-Allow-Origin", "*");
reply.header("Access-Control-Allow-Credentials", "false");
} else {
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "));
reply.header("X-Frame-Options", "DENY");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
}
reply.header("Referrer-Policy", "no-referrer");
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY");
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
reply.header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
reply.header("X-DNS-Prefetch-Control", "off");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
});
}

View File

@@ -1,17 +1,46 @@
import { FastifyInstance } from "fastify";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { PluginManifest, EchoboardPlugin } from "./types.js";
const loadedPlugins: EchoboardPlugin[] = [];
export async function loadPlugins(app: FastifyInstance) {
const manifestPath = resolve(process.cwd(), "echoboard.plugins.json");
// try dynamic import of echoboard.plugins.ts (compiled to .js in production)
const tsPath = resolve(process.cwd(), "echoboard.plugins.js");
const tsSourcePath = resolve(process.cwd(), "echoboard.plugins.ts");
// The plugin directory must be write-protected in production to prevent
// unauthorized code from being loaded via this path.
if (existsSync(tsPath) || existsSync(tsSourcePath)) {
try {
const modPath = existsSync(tsPath) ? tsPath : tsSourcePath;
console.warn(`[plugins] loading plugin file: ${modPath}`);
const mod = await import(pathToFileURL(modPath).href);
const plugins: EchoboardPlugin[] = mod.plugins ?? mod.default?.plugins ?? [];
for (const plugin of plugins) {
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
await plugin.onRegister(app, {});
loadedPlugins.push(plugin);
}
return;
} catch (err) {
app.log.error(`Failed to load plugins from echoboard.plugins: ${err}`);
}
}
// fallback: JSON manifest
const jsonPath = resolve(process.cwd(), "echoboard.plugins.json");
let manifest: PluginManifest;
try {
const raw = await readFile(manifestPath, "utf-8");
const raw = await readFile(jsonPath, "utf-8");
manifest = JSON.parse(raw);
} catch {
app.log.info("No plugin manifest found, skipping plugin loading");
app.log.info("No plugin manifest found, running without plugins");
return;
}
@@ -20,13 +49,72 @@ export async function loadPlugins(app: FastifyInstance) {
for (const entry of manifest.plugins) {
if (!entry.enabled) continue;
// reject package names that look like paths or URLs to prevent arbitrary code loading
if (/[\/\\]|^\./.test(entry.name) || /^https?:/.test(entry.name)) {
app.log.error(`Skipping plugin "${entry.name}": paths and URLs not allowed in JSON manifest`);
continue;
}
// only allow scoped or plain npm package names
if (!/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(entry.name)) {
app.log.error(`Skipping plugin "${entry.name}": invalid package name`);
continue;
}
try {
const mod = await import(entry.name) as { default: EchoboardPlugin };
const plugin = mod.default;
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
await plugin.register(app, entry.config ?? {});
await plugin.onRegister(app, entry.config ?? {});
loadedPlugins.push(plugin);
} catch (err) {
app.log.error(`Failed to load plugin ${entry.name}: ${err}`);
}
}
}
export async function startupPlugins() {
for (const plugin of loadedPlugins) {
if (plugin.onStartup) {
await plugin.onStartup();
}
}
}
export async function shutdownPlugins() {
for (const plugin of loadedPlugins) {
if (plugin.onShutdown) {
await plugin.onShutdown();
}
}
}
export function getPluginCronJobs() {
return loadedPlugins.flatMap((p) => p.getCronJobs?.() ?? []);
}
export function getPluginAdminRoutes() {
return loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
}
export function getPluginComponents() {
const merged: Record<string, (() => unknown)[]> = {};
for (const plugin of loadedPlugins) {
const comps = plugin.getFrontendComponents?.();
if (!comps) continue;
for (const [slot, comp] of Object.entries(comps)) {
if (!merged[slot]) merged[slot] = [];
merged[slot].push(comp);
}
}
return merged;
}
export function getActivePluginInfo() {
return loadedPlugins.map((p) => ({
name: p.name,
version: p.version,
adminRoutes: p.getAdminRoutes?.() ?? [],
slots: Object.keys(p.getFrontendComponents?.() ?? {}),
hasBoardSource: !!p.getBoardSource,
}));
}

View File

@@ -1,9 +1,58 @@
import { FastifyInstance } from "fastify";
export interface AdminRoute {
path: string;
label: string;
component: string;
}
export interface CronJobDefinition {
name: string;
schedule: string;
handler: () => Promise<void>;
}
export interface BoardSource {
name: string;
fetchBoards: () => Promise<{ slug: string; name: string; description?: string; externalUrl?: string }[]>;
}
export interface ComponentMap {
[slotName: string]: () => unknown;
}
export interface PrismaMigration {
name: string;
sql: string;
}
export interface PrismaModelDefinition {
name: string;
schema: string;
}
export interface EchoboardPlugin {
name: string;
version: string;
register: (app: FastifyInstance, config: Record<string, unknown>) => Promise<void>;
// Lifecycle
onRegister(app: FastifyInstance, config: Record<string, unknown>): void | Promise<void>;
onStartup?(): Promise<void>;
onShutdown?(): Promise<void>;
// Database
getMigrations?(): PrismaMigration[];
getModels?(): PrismaModelDefinition[];
// UI
getAdminRoutes?(): AdminRoute[];
getFrontendComponents?(): ComponentMap;
// Scheduled tasks
getCronJobs?(): CronJobDefinition[];
// Board creation
getBoardSource?(): BoardSource;
}
export interface PluginConfig {

View File

@@ -1,19 +1,24 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import prisma from "../lib/prisma.js";
const prisma = new PrismaClient();
const VALID_EVENT_TYPES = [
"post_created", "admin_responded", "comment_created", "comment_edited",
"vote_cast", "vote_milestone", "status_changed", "post_deleted",
] as const;
const querySchema = z.object({
board: z.string().optional(),
type: z.string().optional(),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(30),
type: z.enum(VALID_EVENT_TYPES).optional(),
page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().int().min(1).max(100).default(30),
});
export default async function activityRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/activity",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const q = querySchema.parse(req.query);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,170 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { existsSync, mkdirSync, createReadStream } from "node:fs";
import { unlink, writeFile, realpath } from "node:fs/promises";
import { resolve, extname, sep } from "node:path";
import { randomBytes } from "node:crypto";
const UPLOAD_DIR = resolve(process.cwd(), "uploads");
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
const MAGIC_BYTES: Record<string, number[][]> = {
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
"image/gif": [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
};
function validateMagicBytes(buf: Buffer, mime: string): boolean {
const sigs = MAGIC_BYTES[mime];
if (!sigs) return false;
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
}
function sanitizeFilename(name: string): string {
return name.replace(/[\/\\:*?"<>|\x00-\x1F]/g, "_").slice(0, 200);
}
function uid() {
return randomBytes(16).toString("hex");
}
export default async function attachmentRoutes(app: FastifyInstance) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
app.post(
"/attachments",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const data = await req.file();
if (!data) {
reply.status(400).send({ error: "No file uploaded" });
return;
}
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
reply.status(400).send({ error: "Only jpg, png, gif, webp images are allowed" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of data.file) {
size += chunk.length;
if (size > MAX_SIZE) {
reply.status(400).send({ error: "File too large (max 5MB)" });
return;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (!validateMagicBytes(buffer, data.mimetype)) {
reply.status(400).send({ error: "File content does not match declared type" });
return;
}
const safeName = sanitizeFilename(data.filename);
const storedName = `${uid()}${ext}`;
const filePath = resolve(UPLOAD_DIR, storedName);
if (!filePath.startsWith(UPLOAD_DIR)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await writeFile(filePath, buffer);
let attachment;
try {
attachment = await prisma.attachment.create({
data: {
filename: safeName,
path: storedName,
size,
mimeType: data.mimetype,
uploaderId: req.user!.id,
},
});
} catch (err) {
await unlink(filePath).catch(() => {});
throw err;
}
reply.status(201).send(attachment);
}
);
app.get<{ Params: { id: string } }>(
"/attachments/:id",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => {
const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } });
if (!attachment) {
reply.status(404).send({ error: "Attachment not found" });
return;
}
const filePath = resolve(UPLOAD_DIR, attachment.path);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
reply.status(404).send({ error: "File missing" });
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(404).send({ error: "File missing" });
return;
}
const safeName = sanitizeFilename(attachment.filename).replace(/"/g, "");
reply
.header("Content-Type", attachment.mimeType)
.header("Content-Disposition", `inline; filename="${safeName}"`)
.header("Cache-Control", "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff")
.send(createReadStream(filePath));
}
);
app.delete<{ Params: { id: string } }>(
"/attachments/:id",
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } });
if (!attachment) {
reply.status(404).send({ error: "Attachment not found" });
return;
}
if (attachment.uploaderId !== req.user!.id && !req.adminId) {
reply.status(403).send({ error: "Not your attachment" });
return;
}
const filePath = resolve(UPLOAD_DIR, attachment.path);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
await prisma.attachment.delete({ where: { id: attachment.id } });
reply.status(204).send();
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await unlink(realFile).catch(() => {});
await prisma.attachment.delete({ where: { id: attachment.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,175 @@
import { FastifyInstance } from "fastify";
import { existsSync, mkdirSync, createReadStream } from "node:fs";
import { unlink, writeFile, realpath } from "node:fs/promises";
import { resolve, extname, sep } from "node:path";
import { randomBytes } from "node:crypto";
import prisma from "../lib/prisma.js";
const UPLOAD_DIR = resolve(process.cwd(), "uploads", "avatars");
const MAX_SIZE = 2 * 1024 * 1024;
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const MAGIC_BYTES: Record<string, number[][]> = {
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
};
function validateMagicBytes(buf: Buffer, mime: string): boolean {
const sigs = MAGIC_BYTES[mime];
if (!sigs) return false;
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
}
export default async function avatarRoutes(app: FastifyInstance) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
app.post(
"/me/avatar",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) {
reply.status(403).send({ error: "Not authenticated" });
return;
}
// allow passkey users and admin-linked users
if (user.authMethod !== "PASSKEY") {
const isTeamMember = await prisma.adminUser.findUnique({ where: { linkedUserId: user.id }, select: { id: true } });
if (!isTeamMember) {
reply.status(403).send({ error: "Save your identity with a passkey to upload avatars" });
return;
}
}
const data = await req.file();
if (!data) {
reply.status(400).send({ error: "No file uploaded" });
return;
}
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
reply.status(400).send({ error: "Only jpg, png, webp images are allowed" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of data.file) {
size += chunk.length;
if (size > MAX_SIZE) {
reply.status(400).send({ error: "File too large (max 2MB)" });
return;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (!validateMagicBytes(buffer, data.mimetype)) {
reply.status(400).send({ error: "File content does not match declared type" });
return;
}
// delete old avatar if exists (with traversal check)
if (user.avatarPath) {
const oldPath = resolve(UPLOAD_DIR, user.avatarPath);
try {
const realOld = await realpath(oldPath);
const realUpload = await realpath(UPLOAD_DIR);
if (realOld.startsWith(realUpload + sep)) {
await unlink(realOld).catch(() => {});
}
} catch {}
}
const storedName = `${randomBytes(16).toString("hex")}${ext}`;
const filePath = resolve(UPLOAD_DIR, storedName);
if (!filePath.startsWith(UPLOAD_DIR)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await writeFile(filePath, buffer);
await prisma.user.update({
where: { id: user.id },
data: { avatarPath: storedName },
});
reply.send({ avatarUrl: `/api/v1/avatars/${user.id}` });
}
);
app.delete(
"/me/avatar",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user || !user.avatarPath) {
reply.status(204).send();
return;
}
const filePath = resolve(UPLOAD_DIR, user.avatarPath);
try {
const realFile = await realpath(filePath);
const realUpload = await realpath(UPLOAD_DIR);
if (realFile.startsWith(realUpload + sep)) {
await unlink(realFile).catch(() => {});
}
} catch {}
await prisma.user.update({
where: { id: user.id },
data: { avatarPath: null },
});
reply.status(204).send();
}
);
app.get<{ Params: { userId: string } }>(
"/avatars/:userId",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({
where: { id: req.params.userId },
select: { avatarPath: true },
});
if (!user?.avatarPath) {
reply.status(404).send({ error: "No avatar" });
return;
}
const filePath = resolve(UPLOAD_DIR, user.avatarPath);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
reply.status(404).send({ error: "File missing" });
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(404).send({ error: "File missing" });
return;
}
const ext = extname(user.avatarPath).toLowerCase();
const mimeMap: Record<string, string> = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
};
reply
.header("Content-Type", mimeMap[ext] || "application/octet-stream")
.header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
.header("X-Content-Type-Options", "nosniff")
.send(createReadStream(filePath));
}
);
}

View File

@@ -1,35 +1,103 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { encrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
const prisma = new PrismaClient();
const PUSH_DOMAINS = [
"fcm.googleapis.com", "updates.push.services.mozilla.com",
"notify.windows.com", "push.apple.com", "web.push.apple.com",
];
function isValidPushEndpoint(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d));
} catch { return false; }
}
const subscribeBody = z.object({
endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"),
keys: z.object({
p256dh: z.string().min(1).max(200),
auth: z.string().min(1).max(200),
}),
});
const DEFAULT_STATUS_CONFIG: { status: string; label: string; color: string; position: number; enabled: boolean }[] = [
{ status: "OPEN", label: "Open", color: "#F59E0B", position: 0, enabled: true },
{ status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1, enabled: true },
{ status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2, enabled: true },
{ status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3, enabled: true },
{ status: "DONE", label: "Done", color: "#22C55E", position: 4, enabled: true },
{ status: "DECLINED", label: "Declined", color: "#EF4444", position: 5, enabled: true },
];
async function getStatusConfig(boardId: string) {
const config = await prisma.boardStatus.findMany({
where: { boardId },
orderBy: { position: "asc" },
});
if (config.length === 0) return DEFAULT_STATUS_CONFIG;
return config.map((s) => ({
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: s.enabled,
}));
}
export default async function boardRoutes(app: FastifyInstance) {
app.get("/boards", async (_req, reply) => {
app.get("/boards", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (_req, reply) => {
const boards = await prisma.board.findMany({
where: { isArchived: false },
include: {
_count: { select: { posts: true } },
},
orderBy: { createdAt: "asc" },
});
// use aggregation queries instead of loading all posts into memory
const boardIds = boards.map((b) => b.id);
const [openCounts, lastActivities] = await Promise.all([
prisma.post.groupBy({
by: ["boardId"],
where: { boardId: { in: boardIds }, status: { in: ["OPEN", "UNDER_REVIEW"] } },
_count: true,
}),
prisma.post.groupBy({
by: ["boardId"],
where: { boardId: { in: boardIds } },
_max: { updatedAt: true },
}),
]);
const openMap = new Map(openCounts.map((r) => [r.boardId, r._count]));
const activityMap = new Map(lastActivities.map((r) => [r.boardId, r._max.updatedAt]));
const result = boards.map((b) => ({
id: b.id,
slug: b.slug,
name: b.name,
description: b.description,
externalUrl: b.externalUrl,
iconName: b.iconName,
iconColor: b.iconColor,
voteBudget: b.voteBudget,
voteBudgetReset: b.voteBudgetReset,
allowMultiVote: b.allowMultiVote,
postCount: b._count.posts,
openCount: openMap.get(b.id) ?? 0,
lastActivity: activityMap.get(b.id)?.toISOString() ?? null,
archived: b.isArchived,
createdAt: b.createdAt,
}));
reply.send(result);
});
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => {
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => {
const board = await prisma.board.findUnique({
where: { slug: req.params.boardSlug },
include: {
@@ -46,19 +114,99 @@ export default async function boardRoutes(app: FastifyInstance) {
return;
}
const statuses = await getStatusConfig(board.id);
reply.send({
id: board.id,
slug: board.slug,
name: board.name,
description: board.description,
externalUrl: board.externalUrl,
iconName: board.iconName,
iconColor: board.iconColor,
isArchived: board.isArchived,
voteBudget: board.voteBudget,
voteBudgetReset: board.voteBudgetReset,
allowMultiVote: board.allowMultiVote,
postCount: board._count.posts,
statuses: statuses.filter((s) => s.enabled),
createdAt: board.createdAt,
updatedAt: board.updatedAt,
});
});
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug/statuses", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => {
const board = await prisma.board.findUnique({
where: { slug: req.params.boardSlug },
});
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const statuses = await getStatusConfig(board.id);
reply.send({ statuses: statuses.filter((s) => s.enabled) });
});
// Check board subscription status
app.get<{ Params: { slug: string } }>(
"/boards/:slug/subscription",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const sub = await prisma.pushSubscription.findFirst({
where: { userId: req.user!.id, boardId: board.id, postId: null },
});
reply.send({ subscribed: !!sub });
}
);
// Subscribe to board
app.post<{ Params: { slug: string } }>(
"/boards/:slug/subscribe",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const body = subscribeBody.parse(req.body);
const endpointEnc = encrypt(body.endpoint, masterKey);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const keysP256dh = encrypt(body.keys.p256dh, masterKey);
const keysAuth = encrypt(body.keys.auth, masterKey);
await prisma.pushSubscription.upsert({
where: { endpointIdx },
create: {
endpoint: endpointEnc,
endpointIdx,
keysP256dh,
keysAuth,
userId: req.user!.id,
boardId: board.id,
postId: null,
},
update: { keysP256dh, keysAuth, boardId: board.id, postId: null },
});
reply.send({ subscribed: true });
}
);
// Unsubscribe from board
app.delete<{ Params: { slug: string } }>(
"/boards/:slug/subscribe",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
await prisma.pushSubscription.deleteMany({
where: { userId: req.user!.id, boardId: board.id, postId: null },
});
reply.send({ subscribed: false });
}
);
}

View File

@@ -0,0 +1,26 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export default async function changelogRoutes(app: FastifyInstance) {
const handler = async (req: any, reply: any) => {
const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null;
const where: any = { publishedAt: { lte: new Date() } };
if (boardSlug) {
where.board = { slug: boardSlug };
}
const entries = await prisma.changelogEntry.findMany({
where,
include: { board: { select: { slug: true, name: true } } },
orderBy: { publishedAt: "desc" },
take: 50,
});
reply.send({ entries });
};
const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
app.get("/changelog", opts, handler);
app.get("/b/:boardSlug/changelog", opts, handler);
}

View File

@@ -1,28 +1,54 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js";
import { decrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
import { notifyPostSubscribers, notifyUserReply } from "../services/push.js";
const prisma = new PrismaClient();
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) {
if (!author) return null;
let name: string | null = null;
if (author.displayName) { try { name = decrypt(author.displayName, masterKey); } catch {} }
return {
id: author.id,
displayName: name,
avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null,
};
}
const createCommentSchema = z.object({
body: z.string().min(1).max(5000),
altcha: z.string(),
body: z.string().min(1).max(2000),
altcha: z.string().optional(),
replyToId: z.string().max(30).optional(),
attachmentIds: z.array(z.string()).max(10).optional(),
});
const updateCommentSchema = z.object({
body: z.string().min(1).max(5000),
body: z.string().min(1).max(2000),
});
export default async function commentRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>(
"/boards/:boardSlug/posts/:id/comments",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
const parsed = parseInt(req.query.page ?? "1", 10);
const page = Math.max(1, Math.min(500, Number.isNaN(parsed) ? 1 : parsed));
const limit = 50;
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
@@ -31,29 +57,43 @@ export default async function commentRoutes(app: FastifyInstance) {
prisma.comment.findMany({
where: { postId: post.id },
orderBy: { createdAt: "asc" },
skip: (page - 1) * limit,
skip: Math.min((page - 1) * limit, 50000),
take: limit,
include: {
author: { select: { id: true, displayName: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
reactions: {
select: { emoji: true, userId: true },
},
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
},
},
},
}),
prisma.comment.count({ where: { postId: post.id } }),
]);
const grouped = comments.map((c) => {
const reactionMap: Record<string, { count: number; userIds: string[] }> = {};
const reactionMap: Record<string, { count: number }> = {};
for (const r of c.reactions) {
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] };
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0 };
reactionMap[r.emoji].count++;
reactionMap[r.emoji].userIds.push(r.userId);
}
return {
id: c.id,
body: c.body,
author: c.author,
isAdmin: c.isAdmin,
author: cleanAuthor(c.author),
replyTo: c.replyTo ? {
id: c.replyTo.id,
body: c.replyTo.body.slice(0, 200),
isAdmin: c.replyTo.isAdmin,
authorName: cleanAuthor(c.replyTo.author)?.displayName ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`,
} : null,
reactions: reactionMap,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
@@ -66,84 +106,316 @@ export default async function commentRoutes(app: FastifyInstance) {
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof createCommentSchema> }>(
"/boards/:boardSlug/posts/:id/comments",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 hour" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
if (board.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
if (post.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const body = createCommentSchema.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
// admins skip ALTCHA, regular users must provide it
if (!req.adminId) {
if (!body.altcha) {
reply.status(400).send({ error: "Challenge response required" });
return;
}
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
}
// validate replyToId belongs to the same post
if (body.replyToId) {
const target = await prisma.comment.findUnique({
where: { id: body.replyToId },
select: { postId: true },
});
if (!target || target.postId !== post.id) {
reply.status(400).send({ error: "Invalid reply target" });
return;
}
}
const cleanBody = body.body.replace(INVISIBLE_RE, '');
const isAdmin = !!req.adminId;
const comment = await prisma.comment.create({
data: {
body: body.body,
body: cleanBody,
postId: post.id,
authorId: req.user!.id,
replyToId: body.replyToId ?? null,
isAdmin,
adminUserId: req.adminId ?? null,
},
include: {
author: { select: { id: true, displayName: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
authorId: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
},
},
},
});
await prisma.activityEvent.create({
data: {
type: "comment_created",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
});
if (body.attachmentIds?.length) {
await prisma.attachment.updateMany({
where: {
id: { in: body.attachmentIds },
uploaderId: req.user!.id,
postId: null,
commentId: null,
},
data: { commentId: comment.id },
});
}
reply.status(201).send(comment);
await Promise.all([
prisma.activityEvent.create({
data: {
type: isAdmin ? "admin_responded" : "comment_created",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
}),
prisma.post.update({
where: { id: post.id },
data: { lastActivityAt: new Date() },
}),
]);
// in-app notification: notify post author about new comment (unless they wrote it)
if (post.authorId !== req.user!.id) {
await prisma.notification.create({
data: {
type: isAdmin ? "admin_response" : "comment",
title: isAdmin ? "Official response" : "New comment",
body: `${isAdmin ? "Admin commented on" : "Someone commented on"} "${post.title}"`,
postId: post.id,
userId: post.authorId,
},
});
}
// in-app notification: notify the replied-to user
if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) {
await prisma.notification.create({
data: {
type: "reply",
title: isAdmin ? "Admin replied to you" : "New reply",
body: `${isAdmin ? "Admin" : "Someone"} replied to your comment on "${post.title}"`,
postId: post.id,
userId: comment.replyTo.authorId,
},
});
}
// push notify post subscribers for admin comments
if (isAdmin) {
await notifyPostSubscribers(post.id, {
title: "Official response",
body: body.body.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
}
// push notify the quoted user if this is a reply
if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) {
await notifyUserReply(comment.replyTo.authorId, {
title: isAdmin ? "Admin replied to your comment" : "Someone replied to your comment",
body: body.body.slice(0, 100),
url: `/post/${post.id}`,
tag: `reply-${comment.id}`,
});
}
// @mention notifications
const mentionRe = /@([a-zA-Z0-9_]{3,30})\b/g;
const mentions = [...new Set(Array.from(cleanBody.matchAll(mentionRe), (m) => m[1]))];
if (mentions.length > 0) {
const mentioned: string[] = [];
for (const name of mentions.slice(0, 10)) {
const idx = blindIndex(name, blindIndexKey);
const found = await prisma.user.findUnique({ where: { usernameIdx: idx }, select: { id: true } });
if (found && found.id !== req.user!.id) mentioned.push(found.id);
}
if (mentioned.length > 0) {
await prisma.notification.createMany({
data: mentioned.map((userId) => ({
type: "mention",
title: "You were mentioned",
body: `Mentioned in a comment on "${post.title}"`,
postId: post.id,
userId,
})),
});
for (const uid of mentioned) {
notifyUserReply(uid, {
title: "You were mentioned",
body: cleanBody.slice(0, 100),
url: `/post/${post.id}`,
tag: `mention-${comment.id}`,
});
}
}
}
reply.status(201).send({
id: comment.id,
body: comment.body,
isAdmin: comment.isAdmin,
author: cleanAuthor(comment.author),
replyTo: comment.replyTo ? {
id: comment.replyTo.id,
body: comment.replyTo.body.slice(0, 200),
isAdmin: comment.replyTo.isAdmin,
authorName: cleanAuthor(comment.replyTo.author)?.displayName ?? `Anonymous #${(comment.replyTo.author?.id ?? "0000").slice(-4)}`,
} : null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
});
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateCommentSchema> }>(
"/comments/:id",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, 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;
}
if (comment.authorId !== req.user!.id) {
// admins can edit their own admin comments, users can edit their own comments
const isOwnComment = comment.authorId === req.user!.id;
const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId;
if (!isOwnComment && !isOwnAdminComment) {
reply.status(403).send({ error: "Not your comment" });
return;
}
if (comment.isEditLocked) {
reply.status(403).send({ error: "Editing is locked on this comment" });
return;
}
const parentPost = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true },
});
if (parentPost?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const body = updateCommentSchema.parse(req.body);
const cleanBody = body.body.replace(INVISIBLE_RE, '');
if (cleanBody !== comment.body) {
await prisma.editHistory.create({
data: {
commentId: comment.id,
editedBy: req.user!.id,
previousBody: comment.body,
},
});
}
const updated = await prisma.comment.update({
where: { id: comment.id },
data: { body: body.body },
data: { body: cleanBody },
});
reply.send(updated);
reply.send({
id: updated.id, body: updated.body, isAdmin: updated.isAdmin,
createdAt: updated.createdAt, updatedAt: updated.updatedAt,
});
}
);
app.get<{ Params: { id: string } }>(
"/comments/:id/edits",
{ config: { rateLimit: { max: 30, 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 edits = await prisma.editHistory.findMany({
where: { commentId: comment.id },
orderBy: { createdAt: "desc" },
take: 50,
include: {
editor: { select: { id: true, displayName: true, avatarPath: true } },
},
});
reply.send(edits.map((e) => ({
id: e.id,
previousBody: e.previousBody,
editedBy: cleanAuthor(e.editor),
createdAt: e.createdAt,
})));
}
);
app.delete<{ Params: { id: string } }>(
"/comments/:id",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, 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;
}
if (comment.authorId !== req.user!.id) {
const isOwnComment = comment.authorId === req.user!.id;
const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId;
if (!isOwnComment && !isOwnAdminComment) {
reply.status(403).send({ error: "Not your comment" });
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(() => {});
}
reply.status(204).send();
}
);

View File

@@ -0,0 +1,74 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { z } from "zod";
const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const;
const embedQuery = z.object({
sort: z.enum(["newest", "top"]).default("top"),
status: z.enum(VALID_STATUSES).optional(),
limit: z.coerce.number().int().min(1).max(30).default(10),
});
export default async function embedRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
"/embed/:boardSlug/posts",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({
where: { slug: req.params.boardSlug },
});
if (!board || board.isArchived) {
reply.status(404).send({ error: "Board not found" });
return;
}
const q = embedQuery.safeParse(req.query);
if (!q.success) {
reply.status(400).send({ error: "Invalid parameters" });
return;
}
const { sort, status, limit } = q.data;
const where: Record<string, unknown> = { boardId: board.id };
if (status) where.status = status;
const orderBy = sort === "top"
? { voteCount: "desc" as const }
: { createdAt: "desc" as const };
const posts = await prisma.post.findMany({
where,
orderBy: [{ isPinned: "desc" }, orderBy],
take: limit,
select: {
id: true,
title: true,
type: true,
status: true,
voteCount: true,
isPinned: true,
createdAt: true,
_count: { select: { comments: true } },
},
});
// cache for 60s
reply.header("Cache-Control", "public, max-age=60");
reply.send({
board: { name: board.name, slug: board.slug },
posts: posts.map((p) => ({
id: p.id,
title: p.title,
type: p.type,
status: p.status,
voteCount: p.voteCount,
isPinned: p.isPinned,
commentCount: p._count.comments,
createdAt: p.createdAt,
})),
});
}
);
}

View File

@@ -1,12 +1,105 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import RSS from "rss";
import prisma from "../lib/prisma.js";
import { decrypt } from "../services/encryption.js";
import { masterKey, config } from "../config.js";
const prisma = new PrismaClient();
function decryptName(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
function stripHtml(s: string): string {
return s.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c] || c));
}
function excerpt(description: Record<string, unknown>, maxLen = 300): string {
const text = Object.values(description)
.filter((v) => typeof v === "string")
.join(" ")
.replace(/\s+/g, " ")
.trim();
const trimmed = text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
return stripHtml(trimmed);
}
interface FeedItem {
title: string;
description: string;
author: string;
url: string;
date: Date;
guid: string;
categories?: string[];
}
async function buildBoardFeedItems(boardId: string, boardSlug: string, baseUrl: string, itemCount = 50): Promise<FeedItem[]> {
const [posts, statusChanges, adminComments] = await Promise.all([
prisma.post.findMany({
where: { boardId },
orderBy: { createdAt: "desc" },
take: itemCount,
include: { author: { select: { id: true, displayName: true, avatarPath: true } } },
}),
prisma.statusChange.findMany({
where: { post: { boardId } },
orderBy: { createdAt: "desc" },
take: Math.ceil(itemCount / 2),
include: { post: { select: { id: true, title: true } } },
}),
prisma.comment.findMany({
where: { post: { boardId }, isAdmin: true },
orderBy: { createdAt: "desc" },
take: Math.ceil(itemCount / 2),
include: { post: { select: { id: true, title: true } } },
}),
]);
const items: FeedItem[] = [];
for (const post of posts) {
const typeLabel = post.type === "BUG_REPORT" ? "Bug Report" : "Feature Request";
items.push({
title: `[${typeLabel}] ${post.title}`,
description: `${excerpt(post.description as Record<string, unknown>)} (${post.voteCount} votes)`,
author: stripHtml(decryptName(post.author?.displayName ?? null) ?? `Anonymous #${(post.author?.id ?? "0000").slice(-4)}`),
url: `${baseUrl}/b/${boardSlug}/post/${post.id}`,
date: post.createdAt,
guid: post.id,
categories: [typeLabel, ...(post.category ? [post.category] : [])],
});
}
for (const sc of statusChanges) {
items.push({
title: `Status: ${sc.post.title} - ${sc.fromStatus} to ${sc.toStatus}`,
description: `"${sc.post.title}" moved from ${sc.fromStatus} to ${sc.toStatus}`,
author: "Admin",
url: `${baseUrl}/b/${boardSlug}/post/${sc.postId}`,
date: sc.createdAt,
guid: `${sc.postId}-status-${sc.id}`,
});
}
for (const ac of adminComments) {
items.push({
title: `Official response: ${ac.post.title}`,
description: stripHtml(ac.body.length > 300 ? ac.body.slice(0, 300) + "..." : ac.body),
author: "Admin",
url: `${baseUrl}/b/${boardSlug}/post/${ac.postId}`,
date: ac.createdAt,
guid: `${ac.postId}-response-${ac.id}`,
});
}
items.sort((a, b) => b.date.getTime() - a.date.getTime());
return items.slice(0, itemCount);
}
export default async function feedRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/feed.rss",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
@@ -14,54 +107,59 @@ export default async function feedRoutes(app: FastifyInstance) {
return;
}
const posts = await prisma.post.findMany({
where: { boardId: board.id },
orderBy: { createdAt: "desc" },
take: 50,
});
if (!board.rssEnabled) {
reply.status(404).send({ error: "RSS feed disabled for this board" });
return;
}
const baseUrl = config.WEBAUTHN_ORIGIN;
const items = await buildBoardFeedItems(board.id, board.slug, baseUrl, board.rssFeedCount);
const feed = new RSS({
title: `${board.name} - Echoboard`,
description: board.description ?? "",
feed_url: `${req.protocol}://${req.hostname}/api/v1/boards/${board.slug}/feed.rss`,
site_url: `${req.protocol}://${req.hostname}`,
feed_url: `${baseUrl}/api/v1/boards/${board.slug}/feed.rss`,
site_url: baseUrl,
});
for (const post of posts) {
feed.item({
title: post.title,
description: `[${post.type}] ${post.status} - ${post.voteCount} votes`,
url: `${req.protocol}://${req.hostname}/board/${board.slug}/post/${post.id}`,
date: post.createdAt,
categories: post.category ? [post.category] : [],
});
for (const item of items) {
feed.item(item);
}
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
}
);
app.get("/feed.rss", async (req, reply) => {
const posts = await prisma.post.findMany({
orderBy: { createdAt: "desc" },
take: 50,
include: { board: { select: { slug: true, name: true } } },
app.get("/feed.rss", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
const boards = await prisma.board.findMany({
where: { isArchived: false, rssEnabled: true },
select: { id: true, slug: true, name: true, rssFeedCount: true },
take: 20,
});
const baseUrl = config.WEBAUTHN_ORIGIN;
const allItems: FeedItem[] = [];
const boardResults = await Promise.all(
boards.map((board) =>
buildBoardFeedItems(board.id, board.slug, baseUrl, Math.min(board.rssFeedCount, 50))
.then((items) => items.map((item) => ({ ...item, title: `[${board.name}] ${item.title}` })))
)
);
for (const items of boardResults) {
allItems.push(...items);
}
allItems.sort((a, b) => b.date.getTime() - a.date.getTime());
const feed = new RSS({
title: "Echoboard - All Feedback",
feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`,
site_url: `${req.protocol}://${req.hostname}`,
feed_url: `${baseUrl}/api/v1/feed.rss`,
site_url: baseUrl,
});
for (const post of posts) {
feed.item({
title: `[${post.board.name}] ${post.title}`,
description: `[${post.type}] ${post.status} - ${post.voteCount} votes`,
url: `${req.protocol}://${req.hostname}/board/${post.board.slug}/post/${post.id}`,
date: post.createdAt,
categories: post.category ? [post.category] : [],
});
for (const item of allItems.slice(0, 50)) {
feed.item(item);
}
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));

View File

@@ -1,19 +1,22 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import { unlink } from "node:fs/promises";
import { z } from "zod";
import { hashToken, encrypt, decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
const prisma = new PrismaClient();
import prisma from "../lib/prisma.js";
import { hashToken, encrypt, decrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
import { verifyChallenge } from "../services/altcha.js";
import { blockToken } from "../lib/token-blocklist.js";
const updateMeSchema = z.object({
displayName: z.string().max(50).optional().nullable(),
darkMode: z.enum(["system", "light", "dark"]).optional(),
altcha: z.string().optional(),
});
export default async function identityRoutes(app: FastifyInstance) {
app.post("/identity", async (_req, reply) => {
app.post("/identity", { config: { rateLimit: { max: 5, timeWindow: "1 hour" } } }, async (_req, reply) => {
const token = randomBytes(32).toString("hex");
const hash = hashToken(token);
@@ -27,7 +30,7 @@ export default async function identityRoutes(app: FastifyInstance) {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
maxAge: 60 * 60 * 24 * 90,
})
.status(201)
.send({
@@ -37,15 +40,57 @@ export default async function identityRoutes(app: FastifyInstance) {
});
});
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
app.get(
"/me",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
include: { recoveryCode: { select: { expiresAt: true } } },
});
if (!user) {
reply.status(404).send({ error: "User not found" });
return;
}
reply.send({
id: user.id,
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
username: user.username ? decrypt(user.username, masterKey) : null,
isPasskeyUser: user.authMethod === "PASSKEY",
avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null,
darkMode: user.darkMode,
hasRecoveryCode: !!user.recoveryCode && user.recoveryCode.expiresAt > new Date(),
recoveryCodeExpiresAt: user.recoveryCode?.expiresAt ?? null,
createdAt: user.createdAt,
});
}
);
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
"/me",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = updateMeSchema.parse(req.body);
const data: Record<string, any> = {};
if (body.displayName !== undefined && body.displayName !== null) {
if (!body.altcha) {
reply.status(400).send({ error: "Verification required for display name changes" });
return;
}
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
}
if (body.displayName !== undefined) {
data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null;
const cleanName = body.displayName
? body.displayName.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g, '')
: null;
data.displayName = cleanName ? encrypt(cleanName, masterKey) : null;
}
if (body.darkMode !== undefined) {
data.darkMode = body.darkMode;
@@ -59,62 +104,276 @@ export default async function identityRoutes(app: FastifyInstance) {
reply.send({
id: updated.id,
displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null,
username: updated.username ? decrypt(updated.username, masterKey) : null,
isPasskeyUser: updated.authMethod === "PASSKEY",
avatarUrl: updated.avatarPath ? `/api/v1/avatars/${updated.id}` : null,
darkMode: updated.darkMode,
authMethod: updated.authMethod,
createdAt: updated.createdAt,
});
}
);
app.get(
"/me/posts",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const posts = await prisma.post.findMany({
where: { authorId: req.user!.id },
orderBy: { createdAt: "desc" },
include: {
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
},
});
const page = Math.max(1, Math.min(500, parseInt((req.query as any).page ?? '1', 10) || 1));
reply.send(posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
status: p.status,
voteCount: p.voteCount,
commentCount: p._count.comments,
board: p.board,
createdAt: p.createdAt,
})));
const [posts, total] = await Promise.all([
prisma.post.findMany({
where: { authorId: req.user!.id },
orderBy: { createdAt: "desc" },
take: 50,
skip: (page - 1) * 50,
include: {
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
},
}),
prisma.post.count({ where: { authorId: req.user!.id } }),
]);
reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
status: p.status,
voteCount: p.voteCount,
commentCount: p._count.comments,
board: p.board,
createdAt: p.createdAt,
})),
total,
page,
pages: Math.ceil(total / 50),
});
}
);
app.delete(
"/me",
{ preHandler: [app.requireUser] },
app.get(
"/me/profile",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
await prisma.user.delete({ where: { id: req.user!.id } });
const userId = req.user!.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { reply.status(404).send({ error: "User not found" }); return; }
const [postCount, commentCount, voteCount, votesReceived] = await Promise.all([
prisma.post.count({ where: { authorId: userId } }),
prisma.comment.count({ where: { authorId: userId } }),
prisma.vote.count({ where: { voterId: userId } }),
prisma.vote.aggregate({
_sum: { weight: true },
where: { post: { authorId: userId } },
}),
]);
const votedPosts = await prisma.vote.findMany({
where: { voterId: userId },
orderBy: { createdAt: "desc" },
take: 20,
include: {
post: {
select: {
id: true,
title: true,
status: true,
voteCount: true,
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
},
},
},
});
reply.send({
id: user.id,
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
username: user.username ? decrypt(user.username, masterKey) : null,
isPasskeyUser: user.authMethod === "PASSKEY",
avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null,
createdAt: user.createdAt,
stats: {
posts: postCount,
comments: commentCount,
votesGiven: voteCount,
votesReceived: votesReceived._sum.weight ?? 0,
},
votedPosts: votedPosts
.filter((v) => v.post)
.map((v) => ({
id: v.post.id,
title: v.post.title,
status: v.post.status,
voteCount: v.post.voteCount,
commentCount: v.post._count.comments,
board: v.post.board,
votedAt: v.createdAt,
})),
});
}
);
app.delete<{ Body: { altcha: string } }>(
"/me",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } },
async (req, reply) => {
const { altcha } = (req.body as any) || {};
if (!altcha) {
reply.status(400).send({ error: "Verification required to delete account" });
return;
}
const challengeValid = await verifyChallenge(altcha);
if (!challengeValid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
const userId = req.user!.id;
// collect user's avatar path before deletion
const userForAvatar = await prisma.user.findUnique({
where: { id: userId },
select: { avatarPath: true },
});
// collect attachment paths before deletion
const attachments = await prisma.attachment.findMany({
where: { uploaderId: userId },
select: { path: true },
});
// ensure a sentinel "deleted user" exists for orphaned content
const sentinelId = "deleted-user-sentinel";
await prisma.user.upsert({
where: { id: sentinelId },
create: { id: sentinelId, authMethod: "COOKIE", darkMode: "system" },
update: {},
});
await prisma.$transaction(async (tx) => {
// 1. remove votes and recalculate voteCount on affected posts
const votes = await tx.vote.findMany({
where: { voterId: userId },
select: { postId: true, weight: true },
});
await tx.vote.deleteMany({ where: { voterId: userId } });
for (const vote of votes) {
await tx.post.update({
where: { id: vote.postId },
data: { voteCount: { decrement: vote.weight } },
});
}
// 2. remove reactions
await tx.reaction.deleteMany({ where: { userId } });
// 3. delete push subscriptions
await tx.pushSubscription.deleteMany({ where: { userId } });
// 4. anonymize comments - body becomes "[deleted]", author set to sentinel
await tx.comment.updateMany({
where: { authorId: userId },
data: { body: "[deleted]", authorId: sentinelId },
});
// 5. anonymize posts - title/description replaced, author set to sentinel
const userPosts = await tx.post.findMany({
where: { authorId: userId },
select: { id: true },
});
for (const post of userPosts) {
await tx.post.update({
where: { id: post.id },
data: {
title: "[deleted by author]",
description: {},
authorId: sentinelId,
},
});
}
// 6. wipe passkeys and recovery codes
await tx.passkey.deleteMany({ where: { userId } });
await tx.recoveryCode.deleteMany({ where: { userId } });
// 7. remove attachment records
await tx.attachment.deleteMany({ where: { uploaderId: userId } });
// 8. purge user record
await tx.user.delete({ where: { id: userId } });
});
// clean up files on disk after successful transaction
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
if (userForAvatar?.avatarPath) {
await unlink(resolve(uploadDir, "avatars", userForAvatar.avatarPath)).catch(() => {});
}
const passkeyToken = req.cookies?.echoboard_passkey;
if (passkeyToken) await blockToken(passkeyToken);
reply
.clearCookie("echoboard_token", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_admin", { path: "/" })
.send({ ok: true });
}
);
app.get<{ Querystring: { q?: string } }>(
"/users/search",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const q = (req.query.q ?? "").trim().toLowerCase();
if (q.length < 2 || q.length > 30) {
reply.send({ users: [] });
return;
}
const users = await prisma.user.findMany({
where: { usernameIdx: { not: null } },
select: { id: true, username: true, avatarPath: true },
take: 200,
});
const matches = users
.map((u) => {
let name: string | null = null;
if (u.username) {
try { name = decrypt(u.username, masterKey); } catch {}
}
return {
id: u.id,
username: name,
avatarUrl: u.avatarPath ? `/api/v1/avatars/${u.id}` : null,
};
})
.filter((u) => u.username && u.username.toLowerCase().startsWith(q))
.slice(0, 10);
reply.send({ users: matches });
}
);
app.get(
"/me/export",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
async (req, reply) => {
const userId = req.user!.id;
const [user, posts, comments, votes, reactions] = await Promise.all([
const [user, posts, comments, votes, reactions, pushSubs, notifications] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.post.findMany({ where: { authorId: userId } }),
prisma.comment.findMany({ where: { authorId: userId } }),
prisma.vote.findMany({ where: { voterId: userId } }),
prisma.reaction.findMany({ where: { userId } }),
prisma.pushSubscription.findMany({
where: { userId },
select: { boardId: true, postId: true, createdAt: true },
}),
prisma.notification.findMany({ where: { userId } }),
]);
const decryptedUser = user ? {
@@ -128,10 +387,31 @@ export default async function identityRoutes(app: FastifyInstance) {
reply.send({
user: decryptedUser,
posts,
comments,
posts: posts.map((p) => ({
id: p.id, type: p.type, title: p.title, status: p.status,
voteCount: p.voteCount, category: p.category,
boardId: p.boardId, createdAt: p.createdAt,
})),
comments: comments.map((c) => ({
id: c.id, body: c.body, postId: c.postId, isAdmin: c.isAdmin, createdAt: c.createdAt,
})),
votes: votes.map((v) => ({ postId: v.postId, weight: v.weight, createdAt: v.createdAt })),
reactions: reactions.map((r) => ({ commentId: r.commentId, emoji: r.emoji, createdAt: r.createdAt })),
notifications: notifications.map((n) => ({
id: n.id, type: n.type, title: n.title, body: n.body,
postId: n.postId, read: n.read, createdAt: n.createdAt,
})),
pushSubscriptions: pushSubs,
dataManifest: {
trackedModels: ["User", "Passkey", "PushSubscription", "Notification", "RecoveryCode"],
fields: {
User: ["id", "authMethod", "displayName", "username", "avatarPath", "darkMode", "createdAt"],
Passkey: ["id", "credentialId", "credentialDeviceType", "transports", "createdAt"],
PushSubscription: ["id", "boardId", "postId", "createdAt"],
Notification: ["id", "type", "title", "body", "postId", "read", "createdAt"],
RecoveryCode: ["id", "expiresAt", "createdAt"],
},
},
exportedAt: new Date().toISOString(),
});
}

View File

@@ -0,0 +1,43 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export default async function notificationRoutes(app: FastifyInstance) {
app.get(
"/notifications",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const notifications = await prisma.notification.findMany({
where: { userId: req.user!.id },
orderBy: { createdAt: "desc" },
take: 30,
include: {
post: {
select: {
id: true,
title: true,
board: { select: { slug: true } },
},
},
},
});
const unread = await prisma.notification.count({
where: { userId: req.user!.id, read: false },
});
reply.send({ notifications, unread });
}
);
app.put(
"/notifications/read",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
await prisma.notification.updateMany({
where: { userId: req.user!.id, read: false },
data: { read: true },
});
reply.send({ ok: true });
}
);
}

View File

@@ -1,5 +1,4 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
@@ -9,28 +8,45 @@ import {
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from "@simplewebauthn/server";
} from "@simplewebauthn/types";
import jwt from "jsonwebtoken";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { config, masterKey, blindIndexKey } from "../config.js";
import { encrypt, decrypt, blindIndex } from "../services/encryption.js";
import { blockToken } from "../lib/token-blocklist.js";
const prisma = new PrismaClient();
// challenge store keyed by purpose:userId for proper isolation
const challenges = new Map<string, { challenge: string; expires: number; username?: string }>();
const challenges = new Map<string, { challenge: string; expires: number }>();
const MAX_CHALLENGES = 10000;
function storeChallenge(userId: string, challenge: string) {
challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 });
function storeChallenge(key: string, challenge: string, username?: string) {
if (challenges.size >= MAX_CHALLENGES) {
const now = Date.now();
for (const [k, v] of challenges) {
if (v.expires < now) challenges.delete(k);
}
if (challenges.size >= MAX_CHALLENGES) {
const iter = challenges.keys();
for (let i = 0; i < 100; i++) {
const k = iter.next();
if (k.done) break;
challenges.delete(k.value);
}
}
}
challenges.set(key, { challenge, expires: Date.now() + 60 * 1000, username });
}
function getChallenge(userId: string): string | null {
const entry = challenges.get(userId);
function getChallenge(key: string): { challenge: string; username?: string } | null {
const entry = challenges.get(key);
if (!entry || entry.expires < Date.now()) {
challenges.delete(userId);
challenges.delete(key);
return null;
}
challenges.delete(userId);
return entry.challenge;
challenges.delete(key);
return { challenge: entry.challenge, username: entry.username };
}
export function cleanExpiredChallenges() {
@@ -45,9 +61,11 @@ const registerBody = z.object({
});
export default async function passkeyRoutes(app: FastifyInstance) {
const passkeyRateLimit = { rateLimit: { max: 5, timeWindow: "1 minute" } };
app.post<{ Body: z.infer<typeof registerBody> }>(
"/auth/passkey/register/options",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: passkeyRateLimit },
async (req, reply) => {
const { username } = registerBody.parse(req.body);
const user = req.user!;
@@ -76,23 +94,30 @@ export default async function passkeyRoutes(app: FastifyInstance) {
},
});
storeChallenge(user.id, options.challenge);
storeChallenge("register:" + user.id, options.challenge, username);
reply.send(options);
}
);
app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>(
"/auth/passkey/register/verify",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: passkeyRateLimit },
async (req, reply) => {
const user = req.user!;
const { response, username } = req.body;
const expectedChallenge = getChallenge(user.id);
if (!expectedChallenge) {
const stored = getChallenge("register:" + user.id);
if (!stored) {
reply.status(400).send({ error: "Challenge expired" });
return;
}
const expectedChallenge = stored.challenge;
const validName = registerBody.safeParse({ username });
if (!validName.success || username !== stored.username) {
reply.status(400).send({ error: "Invalid username" });
return;
}
let verification;
try {
@@ -102,8 +127,8 @@ export default async function passkeyRoutes(app: FastifyInstance) {
expectedOrigin: config.WEBAUTHN_ORIGIN,
expectedRPID: config.WEBAUTHN_RP_ID,
});
} catch (err: any) {
reply.status(400).send({ error: err.message });
} catch {
reply.status(400).send({ error: "Registration verification failed" });
return;
}
@@ -116,11 +141,13 @@ export default async function passkeyRoutes(app: FastifyInstance) {
const credIdStr = Buffer.from(credential.id).toString("base64url");
const pubKeyEncrypted = encrypt(Buffer.from(credential.publicKey).toString("base64"), masterKey);
await prisma.passkey.create({
data: {
credentialId: encrypt(credIdStr, masterKey),
credentialIdIdx: blindIndex(credIdStr, blindIndexKey),
credentialPublicKey: Buffer.from(credential.publicKey),
credentialPublicKey: Buffer.from(pubKeyEncrypted),
counter: BigInt(credential.counter),
credentialDeviceType,
credentialBackedUp,
@@ -145,6 +172,7 @@ export default async function passkeyRoutes(app: FastifyInstance) {
app.post(
"/auth/passkey/login/options",
{ config: passkeyRateLimit },
async (_req, reply) => {
const options = await generateAuthenticationOptions({
rpID: config.WEBAUTHN_RP_ID,
@@ -158,6 +186,7 @@ export default async function passkeyRoutes(app: FastifyInstance) {
app.post<{ Body: { response: AuthenticationResponseJSON } }>(
"/auth/passkey/login/verify",
{ config: passkeyRateLimit },
async (req, reply) => {
const { response } = req.body;
@@ -166,24 +195,25 @@ export default async function passkeyRoutes(app: FastifyInstance) {
const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } });
if (!passkey) {
reply.status(400).send({ error: "Passkey not found" });
reply.status(400).send({ error: "Authentication failed" });
return;
}
const expectedChallenge = getChallenge("login:" + response.response.clientDataJSON);
// we stored with the challenge value, try to find it
let challenge: string | null = null;
for (const [key, val] of challenges) {
if (key.startsWith("login:") && val.expires > Date.now()) {
challenge = val.challenge;
challenges.delete(key);
break;
}
let clientData: { challenge?: string };
try {
clientData = JSON.parse(
Buffer.from(response.response.clientDataJSON, "base64url").toString()
);
} catch {
reply.status(400).send({ error: "Invalid client data" });
return;
}
if (!challenge) {
const stored = getChallenge("login:" + clientData.challenge);
if (!stored) {
reply.status(400).send({ error: "Challenge expired" });
return;
}
const challenge = stored.challenge;
let verification;
try {
@@ -194,15 +224,15 @@ export default async function passkeyRoutes(app: FastifyInstance) {
expectedRPID: config.WEBAUTHN_RP_ID,
credential: {
id: decrypt(passkey.credentialId, masterKey),
publicKey: new Uint8Array(passkey.credentialPublicKey),
publicKey: new Uint8Array(Buffer.from(decrypt(passkey.credentialPublicKey.toString(), masterKey), "base64")),
counter: Number(passkey.counter),
transports: passkey.transports
? JSON.parse(decrypt(passkey.transports, masterKey))
: undefined,
},
});
} catch (err: any) {
reply.status(400).send({ error: err.message });
} catch {
reply.status(400).send({ error: "Authentication failed" });
return;
}
@@ -211,36 +241,113 @@ export default async function passkeyRoutes(app: FastifyInstance) {
return;
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
});
try {
await prisma.$transaction(async (tx) => {
const current = await tx.passkey.findUnique({
where: { id: passkey.id },
select: { counter: true },
});
if (current && current.counter >= BigInt(verification.authenticationInfo.newCounter)) {
throw new Error("counter_rollback");
}
await tx.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
});
});
} catch (err: any) {
if (err.message === "counter_rollback") {
req.log.warn({ passkeyId: passkey.id, userId: passkey.userId }, "passkey counter rollback detected - possible credential cloning, disabling passkey");
await prisma.passkey.delete({ where: { id: passkey.id } }).catch(() => {});
}
reply.status(400).send({ error: "Authentication failed" });
return;
}
const token = jwt.sign(
{ sub: passkey.userId, type: "passkey" },
{ sub: passkey.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "30d" }
{ expiresIn: "24h" }
);
reply.send({ verified: true, token });
reply
.setCookie("echoboard_passkey", token, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
})
.send({ verified: true });
}
);
app.get(
"/me/passkeys",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const passkeys = await prisma.passkey.findMany({
where: { userId: req.user!.id },
select: {
id: true,
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
reply.send(passkeys);
}
);
app.delete<{ Params: { id: string } }>(
"/me/passkeys/:id",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const passkey = await prisma.passkey.findUnique({
where: { id: req.params.id },
});
if (!passkey || passkey.userId !== req.user!.id) {
reply.status(404).send({ error: "Passkey not found" });
return;
}
const count = await prisma.passkey.count({ where: { userId: req.user!.id } });
if (count <= 1) {
reply.status(400).send({ error: "Cannot remove your only passkey" });
return;
}
await prisma.passkey.delete({ where: { id: passkey.id } });
reply.status(204).send();
}
);
app.post(
"/auth/passkey/logout",
{ preHandler: [app.requireUser] },
async (_req, reply) => {
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const passkeyToken = req.cookies?.echoboard_passkey;
if (passkeyToken) await blockToken(passkeyToken);
reply
.clearCookie("echoboard_token", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_admin", { path: "/" })
.send({ ok: true });
}
);
app.get<{ Params: { name: string } }>(
"/auth/passkey/check-username/:name",
{ config: { rateLimit: { max: 5, timeWindow: "5 minutes" } } },
async (req, reply) => {
const start = Date.now();
const hash = blindIndex(req.params.name, blindIndexKey);
const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } });
// constant-time response to prevent timing-based enumeration
const elapsed = Date.now() - start;
if (elapsed < 100) await new Promise((r) => setTimeout(r, 100 - elapsed));
reply.send({ available: !existing });
}
);

View File

@@ -1,38 +1,163 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client";
import { PostType, Prisma } from "@prisma/client";
import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js";
import { fireWebhook } from "../services/webhooks.js";
import { decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
import { shouldCount } from "../lib/view-tracker.js";
import { notifyBoardSubscribers } from "../services/push.js";
const prisma = new PrismaClient();
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function decryptName(encrypted: string | null): string | null {
if (!encrypted) return null;
try { return decrypt(encrypted, masterKey); } catch { return null; }
}
function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) {
if (!author) return null;
return {
id: author.id,
displayName: decryptName(author.displayName),
avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null,
};
}
const bugReportSchema = z.object({
stepsToReproduce: z.string().min(1),
expectedBehavior: z.string().min(1),
actualBehavior: z.string().min(1),
environment: z.string().optional(),
additionalContext: z.string().optional(),
});
const featureRequestSchema = z.object({
useCase: z.string().min(1),
proposedSolution: z.string().optional(),
alternativesConsidered: z.string().optional(),
additionalContext: z.string().optional(),
});
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
const descriptionRecord = z.record(z.string().max(5000)).refine(
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
{ message: "Unknown description fields" }
);
const templateDescRecord = z.record(z.string().max(5000)).refine(
(obj) => Object.keys(obj).length <= 30,
{ message: "Too many description fields" }
);
const createPostSchema = z.object({
type: z.nativeEnum(PostType),
title: z.string().min(3).max(200),
description: z.any(),
title: z.string().min(5).max(200),
description: z.record(z.string().max(5000)),
category: z.string().optional(),
templateId: z.string().optional(),
attachmentIds: z.array(z.string()).max(10).optional(),
altcha: z.string(),
}).superRefine((data, ctx) => {
if (data.templateId) {
// template posts use flexible description keys
const tResult = templateDescRecord.safeParse(data.description);
if (!tResult.success) {
for (const issue of tResult.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ["description", ...issue.path],
});
}
}
} else {
// standard posts use strict schema
const dr = descriptionRecord.safeParse(data.description);
if (!dr.success) {
for (const issue of dr.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ["description", ...issue.path],
});
}
}
const result = data.type === PostType.BUG_REPORT
? bugReportSchema.safeParse(data.description)
: featureRequestSchema.safeParse(data.description);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ["description", ...issue.path],
});
}
}
}
const raw = JSON.stringify(data.description);
if (raw.length < 20) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too short (at least 20 characters required)",
path: ["description"],
});
}
if (raw.length > 5000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too long (max 5000 characters total)",
path: ["description"],
});
}
});
const updatePostSchema = z.object({
title: z.string().min(3).max(200).optional(),
description: z.any().optional(),
title: z.string().min(5).max(200).optional(),
description: z.record(z.string().max(5000)).optional(),
category: z.string().optional().nullable(),
altcha: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.description) {
const raw = JSON.stringify(data.description);
if (raw.length < 20) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too short (at least 20 characters required)",
path: ["description"],
});
}
if (raw.length > 5000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too long (max 5000 characters total)",
path: ["description"],
});
}
}
});
const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const;
const querySchema = z.object({
type: z.nativeEnum(PostType).optional(),
category: z.string().optional(),
status: z.nativeEnum(PostStatus).optional(),
sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"),
search: z.string().optional(),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
category: z.string().max(50).optional(),
status: z.enum(VALID_STATUSES).optional(),
sort: z.enum(["newest", "oldest", "top", "updated"]).default("newest"),
search: z.string().max(200).optional(),
page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export default async function postRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
"/boards/:boardSlug/posts",
{ preHandler: [app.optionalUser] },
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
@@ -46,13 +171,29 @@ export default async function postRoutes(app: FastifyInstance) {
if (q.type) where.type = q.type;
if (q.category) where.category = q.category;
if (q.status) where.status = q.status;
if (q.search) where.title = { contains: q.search, mode: "insensitive" };
if (q.search) {
const likeTerm = `%${q.search}%`;
const matchIds = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM "Post"
WHERE "boardId" = ${board.id}
AND (
word_similarity(${q.search}, title) > 0.15
OR to_tsvector('english', title) @@ plainto_tsquery('english', ${q.search})
OR description::text ILIKE ${likeTerm}
)
`;
if (matchIds.length === 0) {
reply.send({ posts: [], total: 0, page: q.page, pages: 0, staleDays: board.staleDays });
return;
}
where.id = { in: matchIds.map((m) => m.id) };
}
let orderBy: Prisma.PostOrderByWithRelationInput;
switch (q.sort) {
case "oldest": orderBy = { createdAt: "asc" }; break;
case "top": orderBy = { voteCount: "desc" }; break;
case "trending": orderBy = { voteCount: "desc" }; break;
case "updated": orderBy = { updatedAt: "desc" }; break;
default: orderBy = { createdAt: "desc" };
}
@@ -64,70 +205,266 @@ export default async function postRoutes(app: FastifyInstance) {
take: q.limit,
include: {
_count: { select: { comments: true } },
author: { select: { id: true, displayName: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
tags: { include: { tag: true } },
},
}),
prisma.post.count({ where }),
]);
const userVotes = new Map<string, number>();
if (req.user) {
const votes = await prisma.vote.findMany({
where: { voterId: req.user.id, postId: { in: posts.map((p) => p.id) } },
select: { postId: true, weight: true },
});
for (const v of votes) userVotes.set(v.postId, v.weight);
}
const staleCutoff = board.staleDays > 0
? new Date(Date.now() - board.staleDays * 86400000)
: null;
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,
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
commentCount: p._count.comments,
author: p.author,
author: cleanAuthor(p.author),
tags: p.tags.map((pt) => pt.tag),
onBehalfOf: p.onBehalfOf,
voted: userVotes.has(p.id),
voteWeight: userVotes.get(p.id) ?? 0,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
total,
page: q.page,
pages: Math.ceil(total / q.limit),
staleDays: board.staleDays,
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.optionalUser] },
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({
where: { id: req.params.id },
include: {
author: { select: { id: true, displayName: true } },
_count: { select: { comments: true, votes: true } },
adminResponses: {
include: { admin: { select: { id: true, email: true } } },
orderBy: { createdAt: "asc" },
},
author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true, votes: true, editHistory: true } },
statusChanges: { orderBy: { createdAt: "asc" } },
tags: { include: { tag: true } },
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
},
});
if (!post) {
if (!post || post.boardId !== board.id) {
// check if this post was merged into another
const merge = await prisma.postMerge.findFirst({
where: { sourcePostId: req.params.id },
orderBy: { createdAt: "desc" },
});
if (merge) {
const targetPost = await prisma.post.findUnique({ where: { id: merge.targetPostId }, select: { boardId: true } });
if (!targetPost || targetPost.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
reply.send({ merged: true, targetPostId: merge.targetPostId });
return;
}
reply.status(404).send({ error: "Post not found" });
return;
}
const viewKey = req.user?.id ?? req.ip;
if (shouldCount(post.id, viewKey)) {
prisma.post.update({ where: { id: post.id }, data: { viewCount: { increment: 1 } } }).catch(() => {});
}
let voted = false;
let voteWeight = 0;
let userImportance: string | null = null;
if (req.user) {
const existing = await prisma.vote.findUnique({
where: { postId_voterId: { postId: post.id, voterId: req.user.id } },
});
voted = !!existing;
voteWeight = existing?.weight ?? 0;
userImportance = existing?.importance ?? null;
}
reply.send({ ...post, voted });
const importanceVotes = await prisma.vote.groupBy({
by: ["importance"],
where: { postId: post.id, importance: { not: null } },
_count: { importance: true },
});
const importanceCounts: Record<string, number> = {
critical: 0, important: 0, nice_to_have: 0, minor: 0,
};
for (const row of importanceVotes) {
if (row.importance && row.importance in importanceCounts) {
importanceCounts[row.importance] = row._count.importance;
}
}
reply.send({
id: post.id,
type: post.type,
title: post.title,
description: post.description,
status: post.status,
statusReason: post.statusReason,
category: post.category,
voteCount: post.voteCount,
viewCount: post.viewCount,
isPinned: post.isPinned,
isEditLocked: post.isEditLocked,
isThreadLocked: post.isThreadLocked,
isVotingLocked: post.isVotingLocked,
onBehalfOf: post.onBehalfOf,
commentCount: post._count.comments,
voteTotal: post._count.votes,
author: cleanAuthor(post.author),
tags: post.tags.map((pt) => pt.tag),
attachments: post.attachments,
statusChanges: post.statusChanges.map((sc) => ({
id: sc.id,
fromStatus: sc.fromStatus,
toStatus: sc.toStatus,
reason: sc.reason,
createdAt: sc.createdAt,
})),
voted,
voteWeight,
userImportance,
importanceCounts,
editCount: post._count.editHistory,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/timeline",
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
const [statusChanges, comments] = await Promise.all([
prisma.statusChange.findMany({
where: { postId: post.id },
orderBy: { createdAt: "asc" },
take: 500,
}),
prisma.comment.findMany({
where: { postId: post.id },
include: {
author: { select: { id: true, displayName: true, avatarPath: true } },
adminUser: { select: { displayName: true, teamTitle: true } },
reactions: { select: { emoji: true, userId: true } },
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
_count: { select: { editHistory: true } },
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
adminUserId: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
adminUser: { select: { displayName: true } },
},
},
},
orderBy: { createdAt: "asc" },
take: 500,
}),
]);
const entries = [
...statusChanges.map((sc) => ({
id: sc.id,
type: "status_change" as const,
authorName: "System",
content: "",
oldStatus: sc.fromStatus,
newStatus: sc.toStatus,
reason: sc.reason ?? null,
createdAt: sc.createdAt,
isAdmin: true,
})),
...comments.map((c) => {
const emojiMap: Record<string, { count: number; userIds: string[] }> = {};
for (const r of c.reactions) {
if (!emojiMap[r.emoji]) emojiMap[r.emoji] = { count: 0, userIds: [] };
emojiMap[r.emoji].count++;
emojiMap[r.emoji].userIds.push(r.userId);
}
return {
id: c.id,
type: "comment" as const,
authorId: c.author?.id ?? null,
authorName: c.isAdmin ? (decryptName(c.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.author?.displayName ?? null) ?? `Anonymous #${(c.author?.id ?? "0000").slice(-4)}`),
authorTitle: c.isAdmin && c.adminUser?.teamTitle ? decryptName(c.adminUser.teamTitle) : null,
authorAvatarUrl: c.author?.avatarPath ? `/api/v1/avatars/${c.author.id}` : null,
content: c.body,
createdAt: c.createdAt,
isAdmin: c.isAdmin,
replyTo: c.replyTo ? {
id: c.replyTo.id,
body: c.replyTo.body.slice(0, 200),
isAdmin: c.replyTo.isAdmin,
authorName: c.replyTo.isAdmin ? (decryptName(c.replyTo.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.replyTo.author?.displayName ?? null) ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`),
} : null,
reactions: Object.entries(emojiMap).map(([emoji, data]) => ({
emoji,
count: data.count,
hasReacted: req.user ? data.userIds.includes(req.user.id) : false,
})),
attachments: c.attachments,
editCount: c._count.editHistory,
isEditLocked: c.isEditLocked,
};
}),
].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
reply.send({ entries, isCurrentAdmin: !!req.adminId });
}
);
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
"/boards/:boardSlug/posts",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board || board.isArchived) {
@@ -143,17 +480,59 @@ export default async function postRoutes(app: FastifyInstance) {
return;
}
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
const recentPosts = await prisma.post.findMany({
where: {
boardId: board.id,
authorId: req.user!.id,
createdAt: { gte: dayAgo },
},
select: { title: true },
take: 100,
});
const isDuplicate = recentPosts.some(p => {
const norm = p.title.replace(INVISIBLE_RE, '').trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
return norm === normalizedTitle;
});
if (isDuplicate) {
reply.status(409).send({ error: "You already posted something similar within the last 24 hours" });
return;
}
if (body.templateId) {
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
if (!tmpl || tmpl.boardId !== board.id) {
reply.status(400).send({ error: "Invalid template" });
return;
}
}
const post = await prisma.post.create({
data: {
type: body.type,
title: body.title,
title: cleanTitle,
description: body.description,
category: body.category,
templateId: body.templateId,
boardId: board.id,
authorId: req.user!.id,
},
});
if (body.attachmentIds?.length) {
await prisma.attachment.updateMany({
where: {
id: { in: body.attachmentIds },
uploaderId: req.user!.id,
postId: null,
commentId: null,
},
data: { postId: post.id },
});
}
await prisma.activityEvent.create({
data: {
type: "post_created",
@@ -163,16 +542,40 @@ export default async function postRoutes(app: FastifyInstance) {
},
});
reply.status(201).send(post);
fireWebhook("post_created", {
postId: post.id,
title: post.title,
type: post.type,
boardId: board.id,
boardSlug: board.slug,
});
notifyBoardSubscribers(board.id, {
title: `New post in ${board.name}`,
body: cleanTitle.slice(0, 100),
url: `/b/${board.slug}/post/${post.id}`,
tag: `board-${board.id}-new`,
});
reply.status(201).send({
id: post.id, type: post.type, title: post.title, description: post.description,
status: post.status, category: post.category, createdAt: post.createdAt,
});
}
);
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
@@ -181,26 +584,136 @@ export default async function postRoutes(app: FastifyInstance) {
return;
}
if (post.isEditLocked) {
reply.status(403).send({ error: "Editing is locked on this post" });
return;
}
const body = updatePostSchema.parse(req.body);
// admins skip ALTCHA, regular users must provide it
if (!req.adminId) {
if (!body.altcha) {
reply.status(400).send({ error: "Challenge response required" });
return;
}
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
}
if (body.description && !post.templateId) {
const dr = descriptionRecord.safeParse(body.description);
if (!dr.success) {
reply.status(400).send({ error: "Unknown description fields" });
return;
}
const schema = post.type === PostType.BUG_REPORT ? bugReportSchema : featureRequestSchema;
const result = schema.safeParse(body.description);
if (!result.success) {
reply.status(400).send({ error: "Invalid description for this post type" });
return;
}
}
if (body.description && post.templateId) {
const tr = templateDescRecord.safeParse(body.description);
if (!tr.success) {
reply.status(400).send({ error: "Too many description fields" });
return;
}
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: post.templateId } });
if (!tmpl) {
reply.status(400).send({ error: "Template no longer exists" });
return;
}
const templateKeys = new Set((tmpl.fields as any[]).map((f: any) => f.key));
const submitted = Object.keys(body.description);
const unknown = submitted.filter((k) => !templateKeys.has(k));
if (unknown.length > 0) {
reply.status(400).send({ error: "Unknown template fields: " + unknown.join(", ") });
return;
}
}
const titleChanged = body.title !== undefined && body.title !== post.title;
const descChanged = body.description !== undefined && JSON.stringify(body.description) !== JSON.stringify(post.description);
if (titleChanged || descChanged) {
await prisma.editHistory.create({
data: {
postId: post.id,
editedBy: req.user!.id,
previousTitle: post.title,
previousDescription: post.description as any,
},
});
}
const updated = await prisma.post.update({
where: { id: post.id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.title !== undefined && { title: body.title.replace(INVISIBLE_RE, '') }),
...(body.description !== undefined && { description: body.description }),
...(body.category !== undefined && { category: body.category }),
},
});
reply.send(updated);
reply.send({
id: updated.id, type: updated.type, title: updated.title,
description: updated.description, status: updated.status,
category: updated.category, updatedAt: updated.updatedAt,
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/edits",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
const edits = await prisma.editHistory.findMany({
where: { postId: post.id },
orderBy: { createdAt: "desc" },
take: 50,
include: {
editor: { select: { id: true, displayName: true, avatarPath: true } },
},
});
reply.send(edits.map((e) => ({
id: e.id,
previousTitle: e.previousTitle,
previousDescription: e.previousDescription,
editedBy: cleanAuthor(e.editor),
createdAt: e.createdAt,
})));
}
);
app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
@@ -209,7 +722,36 @@ export default async function postRoutes(app: FastifyInstance) {
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(() => {});
}
reply.status(204).send();
}
);

View File

@@ -1,47 +1,69 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { generateChallenge } from "../services/altcha.js";
import { config } from "../config.js";
const prisma = new PrismaClient();
const challengeQuery = z.object({
difficulty: z.enum(["normal", "light"]).default("normal"),
});
export default async function privacyRoutes(app: FastifyInstance) {
app.get("/altcha/challenge", async (req, reply) => {
const difficulty = req.query && (req.query as any).difficulty === "light" ? "light" : "normal";
const challenge = await generateChallenge(difficulty as "normal" | "light");
app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
const { difficulty } = challengeQuery.parse(req.query);
const challenge = await generateChallenge(difficulty);
reply.send(challenge);
});
app.get("/privacy/data-manifest", async (_req, reply) => {
app.get("/privacy/data-manifest", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => {
reply.send({
dataCollected: {
anonymous: {
cookieToken: "SHA-256 hashed, used for session identity",
displayName: "AES-256-GCM encrypted, optional",
posts: "Stored with author reference, deletable",
comments: "Stored with author reference, deletable",
votes: "Stored with voter reference, deletable",
reactions: "Stored with user reference, deletable",
},
passkey: {
username: "AES-256-GCM encrypted with blind index",
credentialId: "AES-256-GCM encrypted with blind index",
publicKey: "Encrypted at rest",
},
anonymousUser: [
{ field: "Token hash", purpose: "SHA-256 hashed, used for session identity", retention: "Until user deletes", deletable: true },
{ field: "Display name", purpose: "AES-256-GCM encrypted, optional cosmetic name", retention: "Until user deletes", deletable: true },
{ field: "Dark mode preference", purpose: "Theme setting (system/light/dark)", retention: "Until user deletes", deletable: true },
{ field: "Posts", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true },
{ field: "Comments", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true },
{ field: "Votes", purpose: "Stored with voter reference", retention: "Until user deletes", deletable: true },
{ field: "Reactions", purpose: "Emoji reactions on comments", retention: "Until user deletes", deletable: true },
],
passkeyUser: [
{ field: "Username", purpose: "AES-256-GCM encrypted with HMAC-SHA256 blind index", retention: "Until user deletes", deletable: true },
{ field: "Display name", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
{ field: "Avatar image", purpose: "Optional profile photo stored on disk", retention: "Until user deletes", deletable: true },
{ field: "Credential ID", purpose: "AES-256-GCM encrypted with blind index", retention: "Until user deletes", deletable: true },
{ field: "Public key", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
{ field: "Counter", purpose: "Replay detection (not PII)", retention: "Until user deletes", deletable: true },
{ field: "Device type", purpose: "singleDevice or multiDevice (not PII)", retention: "Until user deletes", deletable: true },
{ field: "Backed up flag", purpose: "Whether passkey is synced (not PII)", retention: "Until user deletes", deletable: true },
{ field: "Transports", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
],
cookieInfo: "This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.",
dataLocation: "Self-hosted PostgreSQL, encrypted at rest",
thirdParties: [],
neverStored: [
"Email address", "IP address", "Browser fingerprint", "User-agent string",
"Referrer URL", "Geolocation", "Device identifiers", "Behavioral data",
"Session replays", "Third-party tracking identifiers",
],
securityHeaders: {
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"X-DNS-Prefetch-Control": "off",
},
retention: {
activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`,
orphanedUsers: `${config.DATA_RETENTION_ORPHAN_USER_DAYS} days`,
},
encryption: "AES-256-GCM with 96-bit random IV per value",
indexing: "HMAC-SHA256 blind indexes for lookups",
thirdParty: "None - fully self-hosted",
export: "GET /api/v1/me/export",
deletion: "DELETE /api/v1/me",
});
});
app.get("/categories", async (_req, reply) => {
app.get("/categories", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => {
const cats = await prisma.category.findMany({
orderBy: { name: "asc" },
});

View File

@@ -1,16 +1,32 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { encrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
const prisma = new PrismaClient();
const PUSH_DOMAINS = [
"fcm.googleapis.com",
"updates.push.services.mozilla.com",
"notify.windows.com",
"push.apple.com",
"web.push.apple.com",
];
function isValidPushEndpoint(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d));
} catch {
return false;
}
}
const subscribeBody = z.object({
endpoint: z.string().url(),
endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
p256dh: z.string().max(200),
auth: z.string().max(200),
}),
boardId: z.string().optional(),
postId: z.string().optional(),
@@ -21,15 +37,44 @@ const unsubscribeBody = z.object({
});
export default async function pushRoutes(app: FastifyInstance) {
app.get(
"/push/vapid",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (_req, reply) => {
const publicKey = process.env.VAPID_PUBLIC_KEY ?? "";
reply.send({ publicKey });
}
);
app.post<{ Body: z.infer<typeof subscribeBody> }>(
"/push/subscribe",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = subscribeBody.parse(req.body);
if (body.boardId) {
const board = await prisma.board.findUnique({ where: { id: body.boardId }, select: { id: true } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
}
if (body.postId) {
const post = await prisma.post.findUnique({ where: { id: body.postId }, select: { id: true } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
}
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
if (existing) {
if (existing.userId !== req.user!.id) {
reply.status(403).send({ error: "Not your subscription" });
return;
}
await prisma.pushSubscription.update({
where: { id: existing.id },
data: {
@@ -41,6 +86,12 @@ export default async function pushRoutes(app: FastifyInstance) {
return;
}
const subCount = await prisma.pushSubscription.count({ where: { userId: req.user!.id } });
if (subCount >= 50) {
reply.status(400).send({ error: "Maximum 50 push subscriptions" });
return;
}
await prisma.pushSubscription.create({
data: {
endpoint: encrypt(body.endpoint, masterKey),
@@ -59,7 +110,7 @@ export default async function pushRoutes(app: FastifyInstance) {
app.delete<{ Body: z.infer<typeof unsubscribeBody> }>(
"/push/subscribe",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = unsubscribeBody.parse(req.body);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
@@ -79,7 +130,7 @@ export default async function pushRoutes(app: FastifyInstance) {
app.get(
"/push/subscriptions",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const subs = await prisma.pushSubscription.findMany({
where: { userId: req.user!.id },

View File

@@ -1,17 +1,15 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
import prisma from "../lib/prisma.js";
const reactionBody = z.object({
emoji: z.string().min(1).max(8),
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
});
export default async function reactionRoutes(app: FastifyInstance) {
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
"/comments/:id/reactions",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
@@ -19,6 +17,19 @@ export default async function reactionRoutes(app: FastifyInstance) {
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const { emoji } = reactionBody.parse(req.body);
const existing = await prisma.reaction.findUnique({
@@ -35,6 +46,14 @@ export default async function reactionRoutes(app: FastifyInstance) {
await prisma.reaction.delete({ where: { id: existing.id } });
reply.send({ toggled: false });
} else {
const distinctCount = await prisma.reaction.count({
where: { commentId: comment.id, userId: req.user!.id },
});
if (distinctCount >= 10) {
reply.status(400).send({ error: "Too many reactions" });
return;
}
await prisma.reaction.create({
data: {
emoji,
@@ -49,8 +68,33 @@ export default async function reactionRoutes(app: FastifyInstance) {
app.delete<{ Params: { id: string; emoji: string } }>(
"/comments/:id/reactions/:emoji",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const emoji = req.params.emoji;
if (!emoji || emoji.length > 8 || !/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u.test(emoji)) {
reply.status(400).send({ error: "Invalid emoji" });
return;
}
const comment = await prisma.comment.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const deleted = await prisma.reaction.deleteMany({
where: {
commentId: req.params.id,

View File

@@ -0,0 +1,142 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import bcrypt from "bcrypt";
import prisma from "../lib/prisma.js";
import { blindIndex, hashToken } from "../services/encryption.js";
import { blindIndexKey } from "../config.js";
import { verifyChallenge } from "../services/altcha.js";
import { generateRecoveryPhrase } from "../lib/wordlist.js";
const EXPIRY_DAYS = 90;
const recoverBody = z.object({
phrase: z.string().regex(/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/),
altcha: z.string(),
});
async function getFailedAttempts(ip: string): Promise<number> {
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
const count = await prisma.blockedToken.count({
where: { tokenHash: { startsWith: `recovery:${ip}:` }, createdAt: { gt: cutoff } },
});
return count;
}
async function recordFailedAttempt(ip: string): Promise<void> {
await prisma.blockedToken.create({
data: {
tokenHash: `recovery:${ip}:${Date.now()}`,
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
},
});
}
export default async function recoveryRoutes(app: FastifyInstance) {
// check if user has a recovery code
app.get(
"/me/recovery-code",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const code = await prisma.recoveryCode.findUnique({
where: { userId: req.user!.id },
select: { expiresAt: true },
});
if (code && code.expiresAt > new Date()) {
reply.send({ hasCode: true, expiresAt: code.expiresAt });
} else {
reply.send({ hasCode: false });
}
}
);
// generate a new recovery code
app.post(
"/me/recovery-code",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } },
async (req, reply) => {
if (req.user!.authMethod === "PASSKEY") {
reply.status(400).send({ error: "Passkey users don't need recovery codes" });
return;
}
const phrase = generateRecoveryPhrase();
const codeHash = await bcrypt.hash(phrase, 12);
const phraseIdx = blindIndex(phrase, blindIndexKey);
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await prisma.recoveryCode.upsert({
where: { userId: req.user!.id },
create: { codeHash, phraseIdx, userId: req.user!.id, expiresAt },
update: { codeHash, phraseIdx, expiresAt },
});
reply.send({ phrase, expiresAt });
}
);
// recover identity using a code (unauthenticated)
app.post(
"/auth/recover",
{ config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } },
async (req, reply) => {
const body = recoverBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
// exponential backoff per IP (persistent)
const attempts = await getFailedAttempts(req.ip);
if (attempts >= 3) {
const delay = Math.min(1000 * Math.pow(2, attempts - 3), 30000);
await new Promise((r) => setTimeout(r, delay));
}
const phrase = body.phrase.toLowerCase().trim();
const idx = blindIndex(phrase, blindIndexKey);
const record = await prisma.recoveryCode.findUnique({
where: { phraseIdx: idx },
include: { user: { select: { id: true } } },
});
if (!record || record.expiresAt < new Date()) {
await recordFailedAttempt(req.ip);
reply.status(401).send({ error: "Invalid or expired recovery code" });
return;
}
const matches = await bcrypt.compare(phrase, record.codeHash);
if (!matches) {
await recordFailedAttempt(req.ip);
reply.status(401).send({ error: "Invalid or expired recovery code" });
return;
}
// success - issue new session token and delete used code
const token = randomBytes(32).toString("hex");
const hash = hashToken(token);
await prisma.$transaction([
prisma.user.update({
where: { id: record.userId },
data: { tokenHash: hash },
}),
prisma.recoveryCode.delete({ where: { id: record.id } }),
]);
reply
.setCookie("echoboard_token", token, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
})
.send({ recovered: true });
}
);
}

View File

@@ -0,0 +1,54 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
const ROADMAP_STATUSES = ["PLANNED", "IN_PROGRESS", "DONE"] as const;
export default async function roadmapRoutes(app: FastifyInstance) {
const handler = async (req: any, reply: any) => {
const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null;
const where: any = {
status: { in: [...ROADMAP_STATUSES] },
board: { isArchived: false },
};
if (boardSlug) {
where.board.slug = boardSlug;
}
const posts = await prisma.post.findMany({
where,
select: {
id: true,
title: true,
type: true,
status: true,
category: true,
voteCount: true,
createdAt: true,
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
tags: { include: { tag: true } },
},
orderBy: [{ voteCount: "desc" }, { createdAt: "desc" }],
take: 200,
});
const mapped = posts.map((p) => ({
...p,
tags: p.tags.map((pt) => pt.tag),
}));
const columns = {
PLANNED: mapped.filter((p) => p.status === "PLANNED"),
IN_PROGRESS: mapped.filter((p) => p.status === "IN_PROGRESS"),
DONE: mapped.filter((p) => p.status === "DONE"),
};
reply.send({ columns });
};
const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
app.get("/roadmap", opts, handler);
app.get("/b/:boardSlug/roadmap", opts, handler);
}

View File

@@ -0,0 +1,109 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { z } from "zod";
import { decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
const searchQuery = z.object({
q: z.string().min(1).max(200),
});
function decryptName(encrypted: string | null): string | null {
if (!encrypted) return null;
try { return decrypt(encrypted, masterKey); } catch { return null; }
}
export default async function searchRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q: string } }>("/search", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
const { q } = searchQuery.parse(req.query);
const term = q.trim();
const likeTerm = `%${term}%`;
const [boardMatches, postMatches] = await Promise.all([
prisma.$queryRaw<{ id: string; slug: string; name: string; icon_name: string | null; icon_color: string | null; description: string | null; post_count: number }[]>`
SELECT id, slug, name, "iconName" as icon_name, "iconColor" as icon_color, description,
(SELECT COUNT(*)::int FROM "Post" WHERE "boardId" = "Board".id) as post_count
FROM "Board"
WHERE "isArchived" = false
AND (
word_similarity(${term}, name) > 0.15
OR word_similarity(${term}, COALESCE(description, '')) > 0.15
OR to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term})
)
ORDER BY (
word_similarity(${term}, name) +
CASE WHEN to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term})
THEN 1.0 ELSE 0.0 END
) DESC
LIMIT 5
`,
prisma.$queryRaw<{ id: string }[]>`
SELECT p.id
FROM "Post" p
JOIN "Board" b ON b.id = p."boardId"
WHERE
word_similarity(${term}, p.title) > 0.15
OR to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term})
OR p.description::text ILIKE ${likeTerm}
ORDER BY (
word_similarity(${term}, p.title) +
CASE WHEN to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term})
THEN 1.0 ELSE 0.0 END
) DESC, p."voteCount" DESC
LIMIT 15
`,
]);
const postIds = postMatches.map((p) => p.id);
const posts = postIds.length
? await prisma.post.findMany({
where: { id: { in: postIds } },
include: {
board: { select: { slug: true, name: true, iconName: true, iconColor: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true } },
},
})
: [];
// preserve relevance ordering from raw SQL
const postOrder = new Map(postIds.map((id, i) => [id, i]));
posts.sort((a, b) => (postOrder.get(a.id) ?? 0) - (postOrder.get(b.id) ?? 0));
const boards = boardMatches.map((b) => ({
type: "board" as const,
id: b.id,
title: b.name,
slug: b.slug,
iconName: b.icon_name,
iconColor: b.icon_color,
description: b.description,
postCount: b.post_count,
}));
const postResults = posts.map((p) => ({
type: "post" as const,
id: p.id,
title: p.title,
postType: p.type,
status: p.status,
voteCount: p.voteCount,
commentCount: p._count.comments,
boardSlug: p.board.slug,
boardName: p.board.name,
boardIconName: p.board.iconName,
boardIconColor: p.board.iconColor,
author: p.author
? {
id: p.author.id,
displayName: decryptName(p.author.displayName),
avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null,
}
: null,
createdAt: p.createdAt,
}));
reply.send({ boards, posts: postResults });
});
}

View File

@@ -0,0 +1,54 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { z } from "zod";
const similarQuery = z.object({
title: z.string().min(5).max(200),
boardId: z.string().min(1),
});
export default async function similarRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/similar",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const q = similarQuery.safeParse(req.query);
if (!q.success) {
reply.send({ posts: [] });
return;
}
const { title, boardId } = q.data;
const board = await prisma.board.findUnique({ where: { id: boardId }, select: { id: true } });
if (!board) {
reply.send({ posts: [] });
return;
}
// use pg_trgm similarity to find posts with similar titles
const similar = await prisma.$queryRaw<
{ id: string; title: string; status: string; vote_count: number; similarity: number }[]
>`
SELECT id, title, status, "voteCount" as vote_count,
similarity(title, ${title}) as similarity
FROM "Post"
WHERE "boardId" = ${boardId}
AND similarity(title, ${title}) > 0.25
AND status NOT IN ('DONE', 'DECLINED')
ORDER BY similarity DESC
LIMIT 5
`;
reply.send({
posts: similar.map((p) => ({
id: p.id,
title: p.title,
status: p.status,
voteCount: p.vote_count,
similarity: Math.round(p.similarity * 100),
})),
});
}
);
}

View File

@@ -0,0 +1,21 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export default async function templateRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/templates",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const templates = await prisma.boardTemplate.findMany({
where: { boardId: board.id },
orderBy: { position: "asc" },
select: { id: true, name: true, fields: true, isDefault: true, position: true },
});
reply.send({ templates });
}
);
}

View File

@@ -1,19 +1,134 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js";
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
const prisma = new PrismaClient();
const voteBody = z.object({
altcha: z.string(),
});
const importanceBody = z.object({
importance: z.enum(["critical", "important", "nice_to_have", "minor"]),
});
export default async function voteRoutes(app: FastifyInstance) {
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof voteBody> }>(
"/boards/:boardSlug/posts/:id/vote",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
if (board.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
if (post.authorId === req.user!.id) {
reply.status(403).send({ error: "Cannot vote on your own post" });
return;
}
if (post.isVotingLocked) {
reply.status(403).send({ error: "Voting is locked on this post" });
return;
}
const body = voteBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
const period = getCurrentPeriod(board.voteBudgetReset);
try {
await prisma.$transaction(async (tx) => {
const existing = await tx.vote.findUnique({
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } },
});
if (existing && !board.allowMultiVote) throw new Error("ALREADY_VOTED");
const remaining = await getRemainingBudget(req.user!.id, board.id, tx);
if (remaining <= 0) throw new Error("BUDGET_EXHAUSTED");
if (existing && board.allowMultiVote) {
if (existing.weight >= 3) throw new Error("MAX_VOTES");
await tx.vote.update({
where: { id: existing.id },
data: { weight: { increment: 1 } },
});
} else {
await tx.vote.create({
data: {
postId: post.id,
voterId: req.user!.id,
budgetPeriod: period,
},
});
}
await tx.post.update({
where: { id: post.id },
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
});
}, { isolationLevel: "Serializable" });
} catch (err: any) {
if (err.message === "ALREADY_VOTED") {
reply.status(409).send({ error: "Already voted" });
return;
}
if (err.message === "BUDGET_EXHAUSTED") {
reply.status(429).send({ error: "Vote budget exhausted" });
return;
}
if (err.message === "MAX_VOTES") {
reply.status(409).send({ error: "Max 3 votes per post" });
return;
}
throw err;
}
await prisma.activityEvent.create({
data: {
type: "vote_cast",
boardId: board.id,
postId: post.id,
metadata: {},
},
});
const newCount = post.voteCount + 1;
const milestones = [10, 50, 100, 250, 500];
if (milestones.includes(newCount)) {
await prisma.activityEvent.create({
data: {
type: "vote_milestone",
boardId: board.id,
postId: post.id,
metadata: { milestoneCount: newCount, title: post.title },
},
});
}
reply.send({ ok: true, voteCount: newCount });
}
);
app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/vote",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
@@ -27,66 +142,35 @@ export default async function voteRoutes(app: FastifyInstance) {
return;
}
const body = voteBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
if (post.isVotingLocked) {
reply.status(403).send({ error: "Voting is locked on this post" });
return;
}
const existing = await prisma.vote.findUnique({
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } },
});
if (existing && !board.allowMultiVote) {
reply.status(409).send({ error: "Already voted" });
return;
}
const remaining = await getRemainingBudget(req.user!.id, board.id);
if (remaining <= 0) {
reply.status(429).send({ error: "Vote budget exhausted" });
return;
}
const period = getCurrentPeriod(board.voteBudgetReset);
if (existing && board.allowMultiVote) {
await prisma.vote.update({
where: { id: existing.id },
data: { weight: existing.weight + 1 },
await prisma.$transaction(async (tx) => {
const vote = await tx.vote.findUnique({
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
});
} else {
await prisma.vote.create({
data: {
postId: post.id,
voterId: req.user!.id,
budgetPeriod: period,
},
});
}
if (!vote) throw new Error("NO_VOTE");
await prisma.post.update({
where: { id: post.id },
data: { voteCount: { increment: 1 } },
await tx.vote.delete({ where: { id: vote.id } });
await tx.$executeRaw`UPDATE "Post" SET "voteCount" = GREATEST(0, "voteCount" - ${vote.weight}) WHERE "id" = ${vote.postId}`;
}).catch((err) => {
if (err.message === "NO_VOTE") {
reply.status(404).send({ error: "No vote found" });
return;
}
throw err;
});
if (reply.sent) return;
await prisma.activityEvent.create({
data: {
type: "vote_cast",
boardId: board.id,
postId: post.id,
metadata: {},
},
});
reply.send({ ok: true, voteCount: post.voteCount + 1 });
reply.send({ ok: true });
}
);
app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/vote",
{ preHandler: [app.requireUser] },
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof importanceBody> }>(
"/boards/:boardSlug/posts/:id/vote/importance",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
@@ -94,6 +178,18 @@ export default async function voteRoutes(app: FastifyInstance) {
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
if (post.isVotingLocked) {
reply.status(403).send({ error: "Voting is locked" });
return;
}
const body = importanceBody.parse(req.body);
const vote = await prisma.vote.findUnique({
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
});
@@ -103,11 +199,9 @@ export default async function voteRoutes(app: FastifyInstance) {
return;
}
const weight = vote.weight;
await prisma.vote.delete({ where: { id: vote.id } });
await prisma.post.update({
where: { id: req.params.id },
data: { voteCount: { decrement: weight } },
await prisma.vote.update({
where: { id: vote.id },
data: { importance: body.importance },
});
reply.send({ ok: true });
@@ -116,7 +210,7 @@ export default async function voteRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/budget",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {

View File

@@ -1,14 +1,19 @@
import Fastify from "fastify";
import Fastify, { FastifyError } from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import fastifyStatic from "@fastify/static";
import multipart from "@fastify/multipart";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import { createHmac } from "node:crypto";
import { config } from "./config.js";
import prisma from "./lib/prisma.js";
import securityPlugin from "./middleware/security.js";
import authPlugin from "./middleware/auth.js";
import { loadPlugins } from "./plugins/loader.js";
import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
import { seedAllBoardTemplates } from "./lib/default-templates.js";
import boardRoutes from "./routes/boards.js";
import postRoutes from "./routes/posts.js";
@@ -26,6 +31,25 @@ import adminPostRoutes from "./routes/admin/posts.js";
import adminBoardRoutes from "./routes/admin/boards.js";
import adminCategoryRoutes from "./routes/admin/categories.js";
import adminStatsRoutes from "./routes/admin/stats.js";
import searchRoutes from "./routes/search.js";
import roadmapRoutes from "./routes/roadmap.js";
import similarRoutes from "./routes/similar.js";
import adminTagRoutes from "./routes/admin/tags.js";
import adminNoteRoutes from "./routes/admin/notes.js";
import adminChangelogRoutes from "./routes/admin/changelog.js";
import adminWebhookRoutes from "./routes/admin/webhooks.js";
import changelogRoutes from "./routes/changelog.js";
import notificationRoutes from "./routes/notifications.js";
import embedRoutes from "./routes/embed.js";
import adminStatusRoutes from "./routes/admin/statuses.js";
import adminExportRoutes from "./routes/admin/export.js";
import adminTemplateRoutes from "./routes/admin/templates.js";
import templateRoutes from "./routes/templates.js";
import attachmentRoutes from "./routes/attachments.js";
import avatarRoutes from "./routes/avatars.js";
import recoveryRoutes from "./routes/recovery.js";
import settingsRoutes from "./routes/admin/settings.js";
import adminTeamRoutes from "./routes/admin/team.js";
export async function createServer() {
const app = Fastify({
@@ -35,25 +59,70 @@ export async function createServer() {
return {
method: req.method,
url: req.url,
remoteAddress: req.ip,
};
},
},
},
});
await app.register(cookie, { secret: process.env.TOKEN_SECRET });
const cookieSecret = createHmac("sha256", config.TOKEN_SECRET).update("echoboard:cookie").digest("hex");
await app.register(cookie, { secret: cookieSecret });
await app.register(cors, {
origin: true,
origin: process.env.NODE_ENV === "production"
? config.WEBAUTHN_ORIGIN
: ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"],
credentials: true,
});
const allowedOrigins = new Set(
process.env.NODE_ENV === "production"
? [config.WEBAUTHN_ORIGIN]
: [config.WEBAUTHN_ORIGIN, "http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
);
app.addHook("onRequest", async (req, reply) => {
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
const origin = req.headers.origin;
// Server-to-server webhook calls don't send Origin headers
if (!origin && req.url.startsWith('/api/v1/plugins/') && req.url.includes('/webhook')) return;
if (!origin || !allowedOrigins.has(origin)) {
return reply.status(403).send({ error: "Forbidden" });
}
}
});
await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
});
app.setErrorHandler((error: FastifyError, req, reply) => {
req.log.error(error);
// zod validation errors
if (error.validation) {
reply.status(400).send({ error: "Validation failed" });
return;
}
// fastify rate limit
if (error.statusCode === 429) {
reply.status(429).send({ error: "Too many requests" });
return;
}
const status = error.statusCode ?? 500;
reply.status(status).send({
error: status >= 500 ? "Internal server error" : error.message,
});
});
await app.register(securityPlugin);
await app.register(authPlugin);
app.decorate("prisma", prisma);
// api routes under /api/v1
await app.register(async (api) => {
await api.register(boardRoutes);
@@ -72,6 +141,25 @@ export async function createServer() {
await api.register(adminBoardRoutes);
await api.register(adminCategoryRoutes);
await api.register(adminStatsRoutes);
await api.register(searchRoutes);
await api.register(roadmapRoutes);
await api.register(similarRoutes);
await api.register(adminTagRoutes);
await api.register(adminNoteRoutes);
await api.register(adminChangelogRoutes);
await api.register(adminWebhookRoutes);
await api.register(changelogRoutes);
await api.register(notificationRoutes);
await api.register(embedRoutes);
await api.register(adminStatusRoutes);
await api.register(adminExportRoutes);
await api.register(adminTemplateRoutes);
await api.register(templateRoutes);
await api.register(attachmentRoutes);
await api.register(avatarRoutes);
await api.register(recoveryRoutes);
await api.register(settingsRoutes);
await api.register(adminTeamRoutes);
}, { prefix: "/api/v1" });
// serve static frontend build in production
@@ -88,6 +176,29 @@ export async function createServer() {
}
await loadPlugins(app);
await startupPlugins();
// seed default templates for boards that have none
await seedAllBoardTemplates(prisma);
// register plugin discovery endpoint and admin routes
await app.register(async (api) => {
api.get("/plugins/active", {
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
}, async () => getActivePluginInfo());
// register plugin-provided admin routes
for (const route of getPluginAdminRoutes()) {
api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({
label: route.label,
component: route.component,
}));
}
}, { prefix: "/api/v1" });
app.addHook("onClose", async () => {
await shutdownPlugins();
});
return app;
}

View File

@@ -1,6 +1,23 @@
import { createChallenge, verifySolution } from "altcha-lib";
import { createHash } from "node:crypto";
import { config } from "../config.js";
// replay protection: track consumed challenge hashes (fingerprint -> timestamp)
const usedChallenges = new Map<string, number>();
const EXPIRY_MS = 300000;
// clean up expired entries every 5 minutes
setInterval(() => {
const cutoff = Date.now() - EXPIRY_MS;
for (const [fp, ts] of usedChallenges) {
if (ts < cutoff) usedChallenges.delete(fp);
}
}, EXPIRY_MS);
function challengeFingerprint(payload: string): string {
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
}
export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
const challenge = await createChallenge({
@@ -13,8 +30,30 @@ export async function generateChallenge(difficulty: "normal" | "light" = "normal
export async function verifyChallenge(payload: string): Promise<boolean> {
try {
const fp = challengeFingerprint(payload);
// reject replayed challenges
if (usedChallenges.has(fp)) return false;
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
return ok;
if (!ok) return false;
// evict expired entries if map is large
if (usedChallenges.size >= 50000) {
const cutoff = Date.now() - EXPIRY_MS;
for (const [key, ts] of usedChallenges) {
if (ts < cutoff) usedChallenges.delete(key);
}
}
// hard cap - drop oldest entries if still over limit
if (usedChallenges.size >= 50000) {
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]);
const toRemove = sorted.slice(0, sorted.length - 40000);
for (const [key] of toRemove) usedChallenges.delete(key);
}
usedChallenges.set(fp, Date.now());
return true;
} catch {
return false;
}

View File

@@ -21,6 +21,15 @@ export function decrypt(encoded: string, key: Buffer): string {
return decipher.update(ciphertext) + decipher.final("utf8");
}
export function decryptWithFallback(encoded: string, currentKey: Buffer, previousKey: Buffer | null): string {
try {
return decrypt(encoded, currentKey);
} catch {
if (previousKey) return decrypt(encoded, previousKey);
throw new Error("Decryption failed with all available keys");
}
}
export function blindIndex(value: string, key: Buffer): string {
return createHmac("sha256", key).update(value.toLowerCase()).digest("hex");
}

View File

@@ -0,0 +1,145 @@
import prisma from "../lib/prisma.js";
import { encrypt, decryptWithFallback } from "./encryption.js";
import { masterKey, previousMasterKey } from "../config.js";
export async function reEncryptIfNeeded() {
if (!previousMasterKey) return;
console.log("Key rotation: checking for records encrypted with previous key...");
let reEncrypted = 0;
let failures = 0;
// re-encrypt user fields
const users = await prisma.user.findMany({
where: { OR: [{ displayName: { not: null } }, { username: { not: null } }] },
select: { id: true, displayName: true, username: true },
});
for (const user of users) {
const updates: Record<string, string | null> = {};
let needsUpdate = false;
if (user.displayName) {
try {
const plain = decryptWithFallback(user.displayName, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== user.displayName) {
updates.displayName = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
if (user.username) {
try {
const plain = decryptWithFallback(user.username, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== user.username) {
updates.username = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
if (needsUpdate) {
await prisma.user.update({ where: { id: user.id }, data: updates });
reEncrypted++;
}
}
// re-encrypt passkey fields
const passkeys = await prisma.passkey.findMany({
select: { id: true, credentialId: true, credentialPublicKey: true, transports: true },
});
for (const pk of passkeys) {
const updates: Record<string, any> = {};
let needsUpdate = false;
for (const field of ["credentialId", "transports"] as const) {
const val = pk[field] as string;
if (val) {
try {
const plain = decryptWithFallback(val, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== val) {
updates[field] = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
}
// re-encrypt public key (stored as encrypted string in Bytes field)
if (pk.credentialPublicKey) {
try {
const pubKeyStr = pk.credentialPublicKey.toString();
const plain = decryptWithFallback(pubKeyStr, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== pubKeyStr) {
updates.credentialPublicKey = Buffer.from(reEnc);
needsUpdate = true;
}
} catch { failures++; }
}
if (needsUpdate) {
await prisma.passkey.update({ where: { id: pk.id }, data: updates });
reEncrypted++;
}
}
// re-encrypt push subscription fields
const pushSubs = await prisma.pushSubscription.findMany({
select: { id: true, endpoint: true, keysP256dh: true, keysAuth: true },
});
for (const sub of pushSubs) {
const updates: Record<string, string> = {};
let needsUpdate = false;
for (const field of ["endpoint", "keysP256dh", "keysAuth"] as const) {
const val = sub[field];
if (val) {
try {
const plain = decryptWithFallback(val, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== val) {
updates[field] = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
}
if (needsUpdate) {
await prisma.pushSubscription.update({ where: { id: sub.id }, data: updates });
reEncrypted++;
}
}
// re-encrypt webhook secrets
const webhooks = await prisma.webhook.findMany({
select: { id: true, secret: true },
});
for (const wh of webhooks) {
try {
const plain = decryptWithFallback(wh.secret, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== wh.secret) {
await prisma.webhook.update({ where: { id: wh.id }, data: { secret: reEnc } });
reEncrypted++;
}
} catch {}
}
if (failures > 0) {
console.warn(`Key rotation: ${failures} records failed to re-encrypt`);
}
if (reEncrypted > 0) {
console.log(`Key rotation: re-encrypted ${reEncrypted} records`);
} else {
console.log("Key rotation: all records already use current key");
}
}

View File

@@ -0,0 +1,40 @@
import { Prisma } from "@prisma/client";
const TRACKED_MODELS = ["User", "Passkey", "PushSubscription"] as const;
const MANIFEST_FIELDS: Record<string, string[]> = {
User: [
"id", "authMethod", "tokenHash", "username", "usernameIdx",
"displayName", "avatarPath", "darkMode", "createdAt", "updatedAt",
"posts", "comments", "votes", "reactions", "passkeys", "notifications", "pushSubscriptions", "adminLink", "attachments", "edits", "recoveryCode",
],
Passkey: [
"id", "credentialId", "credentialIdIdx", "credentialPublicKey",
"counter", "credentialDeviceType", "credentialBackedUp", "transports",
"userId", "user", "createdAt",
],
PushSubscription: [
"id", "endpoint", "endpointIdx", "keysP256dh", "keysAuth",
"userId", "user", "boardId", "board", "postId", "post", "failureCount", "createdAt",
],
};
export function validateManifest() {
for (const modelName of TRACKED_MODELS) {
const dmmfModel = Prisma.dmmf.datamodel.models.find((m) => m.name === modelName);
if (!dmmfModel) continue;
const schemaFields = dmmfModel.fields.map((f) => f.name);
const manifestFields = MANIFEST_FIELDS[modelName] || [];
for (const field of schemaFields) {
if (!manifestFields.includes(field)) {
console.error(
`Data manifest violation: field "${modelName}.${field}" exists in schema but is not declared in the data manifest. ` +
`Add it to the manifest or the app will not start.`
);
process.exit(1);
}
}
}
}

View File

@@ -1,11 +1,9 @@
import webpush from "web-push";
import { PrismaClient } from "@prisma/client";
import prisma from "../lib/prisma.js";
import { config } from "../config.js";
import { decrypt } from "./encryption.js";
import { masterKey } from "../config.js";
const prisma = new PrismaClient();
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) {
webpush.setVapidDetails(
config.VAPID_CONTACT,
@@ -21,7 +19,7 @@ interface PushPayload {
tag?: string;
}
export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) {
export async function sendNotification(sub: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload): Promise<"ok" | "gone" | "failed"> {
try {
await webpush.sendNotification(
{
@@ -33,39 +31,58 @@ export async function sendNotification(sub: { endpoint: string; keysP256dh: stri
},
JSON.stringify(payload)
);
return true;
return "ok";
} catch (err: any) {
if (err.statusCode === 404 || err.statusCode === 410) {
return false;
return "gone";
}
throw err;
// transient failure - increment counter
await prisma.pushSubscription.update({
where: { id: sub.id },
data: { failureCount: { increment: 1 } },
}).catch(() => {});
return "failed";
}
}
const MAX_FANOUT = 5000;
const BATCH_SIZE = 50;
async function processResults(subs: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }[], event: PushPayload) {
if (subs.length > MAX_FANOUT) {
console.warn(`push fanout capped: ${subs.length} subscribers truncated to ${MAX_FANOUT}`);
}
const capped = subs.slice(0, MAX_FANOUT);
const gone: string[] = [];
for (let i = 0; i < capped.length; i += BATCH_SIZE) {
const batch = capped.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map((sub) => sendNotification(sub, event).then((r) => ({ id: sub.id, result: r })))
);
for (const r of results) {
if (r.status === "fulfilled" && r.value.result === "gone") {
gone.push(r.value.id);
}
}
}
if (gone.length > 0) {
await prisma.pushSubscription.deleteMany({ where: { id: { in: gone } } });
}
}
export async function notifyPostSubscribers(postId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { postId } });
const failed: string[] = [];
for (const sub of subs) {
const ok = await sendNotification(sub, event);
if (!ok) failed.push(sub.id);
}
if (failed.length > 0) {
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
}
await processResults(subs, event);
}
export async function notifyBoardSubscribers(boardId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } });
const failed: string[] = [];
for (const sub of subs) {
const ok = await sendNotification(sub, event);
if (!ok) failed.push(sub.id);
}
if (failed.length > 0) {
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
}
await processResults(subs, event);
}
export async function notifyUserReply(userId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
await processResults(subs, event);
}

View File

@@ -0,0 +1,104 @@
import prisma from "../lib/prisma.js";
import { createHmac } from "node:crypto";
import { resolve as dnsResolve } from "node:dns/promises";
import { request as httpsRequest } from "node:https";
import { decrypt } from "./encryption.js";
import { masterKey } from "../config.js";
function isPrivateIp(ip: string): boolean {
// normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 -> 127.0.0.1)
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
if (normalized === "127.0.0.1" || normalized === "::1") return true;
if (normalized.startsWith("0.")) return true; // 0.0.0.0/8
if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(normalized)) return true; // 100.64.0.0/10 CGNAT
if (normalized.startsWith("198.18.") || normalized.startsWith("198.19.")) return true; // 198.18.0.0/15
if (/^24[0-9]\./.test(normalized) || normalized.startsWith("255.")) return true; // 240.0.0.0/4
if (normalized === "169.254.169.254" || normalized.startsWith("169.254.")) return true; // link-local
if (normalized.startsWith("fe80:") || normalized.startsWith("fc00:") || normalized.startsWith("fd")) return true;
return false;
}
export function isAllowedUrl(raw: string): boolean {
try {
const u = new URL(raw);
if (u.protocol !== "https:") return false;
const host = u.hostname;
if (host === "localhost" || isPrivateIp(host)) return false;
if (host.endsWith(".internal") || host.endsWith(".local")) return false;
return true;
} catch {
return false;
}
}
export async function resolvedIpIsAllowed(hostname: string): Promise<boolean> {
try {
const addresses = await dnsResolve(hostname);
for (const addr of addresses) {
if (isPrivateIp(addr)) return false;
}
return true;
} catch {
return false;
}
}
export async function fireWebhook(event: string, payload: Record<string, unknown>) {
const webhooks = await prisma.webhook.findMany({
where: { active: true, events: { has: event } },
});
for (const wh of webhooks) {
if (!isAllowedUrl(wh.url)) continue;
const url = new URL(wh.url);
let addresses: string[];
try {
addresses = await dnsResolve(url.hostname);
if (addresses.some((addr) => isPrivateIp(addr))) continue;
} catch {
continue;
}
let secret: string;
try {
secret = decrypt(wh.secret, masterKey);
} catch (err: any) {
console.warn(`webhook ${wh.id}: secret decryption failed - ${err?.message ?? "unknown error"}`);
continue;
}
const body = JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload });
const signature = createHmac("sha256", secret).update(body).digest("hex");
// connect directly to the resolved IP, use original hostname for TLS SNI
// and Host header - this closes the DNS rebinding window
const req = httpsRequest({
hostname: addresses[0],
port: url.port || 443,
path: url.pathname + url.search,
method: "POST",
headers: {
"Host": url.hostname,
"Content-Type": "application/json",
"X-Echoboard-Signature": signature,
"X-Echoboard-Event": event,
},
servername: url.hostname,
timeout: 10000,
}, (res) => {
res.resume(); // drain response
});
req.setTimeout(10000, () => {
req.destroy(new Error("timeout"));
});
req.on("error", (err) => {
console.warn(`webhook delivery failed for ${wh.id}: ${err.message}`);
});
req.write(body);
req.end();
}
}