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

@@ -5,7 +5,7 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "tsx watch --env-file=../../.env src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
@@ -16,6 +16,7 @@
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.0.0",
"@fastify/static": "^8.0.0",
"@prisma/client": "^6.0.0",
@@ -34,6 +35,8 @@
"@types/bcrypt": "^5.0.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/rss": "^0.0.32",
"@types/web-push": "^3.6.0",
"prisma": "^6.0.0",
"tsx": "^4.19.0",

View File

@@ -0,0 +1,270 @@
-- CreateEnum
CREATE TYPE "AuthMethod" AS ENUM ('COOKIE', 'PASSKEY');
-- CreateEnum
CREATE TYPE "PostType" AS ENUM ('FEATURE_REQUEST', 'BUG_REPORT');
-- CreateEnum
CREATE TYPE "PostStatus" AS ENUM ('OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED');
-- CreateTable
CREATE TABLE "Board" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"externalUrl" TEXT,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"voteBudget" INTEGER NOT NULL DEFAULT 10,
"voteBudgetReset" TEXT NOT NULL DEFAULT 'monthly',
"lastBudgetReset" TIMESTAMP(3),
"allowMultiVote" BOOLEAN NOT NULL DEFAULT false,
"rssEnabled" BOOLEAN NOT NULL DEFAULT true,
"rssFeedCount" INTEGER NOT NULL DEFAULT 50,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Board_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"authMethod" "AuthMethod" NOT NULL DEFAULT 'COOKIE',
"tokenHash" TEXT,
"username" TEXT,
"usernameIdx" TEXT,
"displayName" TEXT,
"darkMode" TEXT NOT NULL DEFAULT 'system',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Passkey" (
"id" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"credentialIdIdx" TEXT NOT NULL,
"credentialPublicKey" BYTEA NOT NULL,
"counter" BIGINT NOT NULL,
"credentialDeviceType" TEXT NOT NULL,
"credentialBackedUp" BOOLEAN NOT NULL,
"transports" TEXT,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL,
"type" "PostType" NOT NULL,
"title" TEXT NOT NULL,
"description" JSONB NOT NULL,
"status" "PostStatus" NOT NULL DEFAULT 'OPEN',
"category" TEXT,
"voteCount" INTEGER NOT NULL DEFAULT 0,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"boardId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StatusChange" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"fromStatus" "PostStatus" NOT NULL,
"toStatus" "PostStatus" NOT NULL,
"changedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StatusChange_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Reaction" (
"id" TEXT NOT NULL,
"emoji" TEXT NOT NULL,
"commentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vote" (
"id" TEXT NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1,
"postId" TEXT NOT NULL,
"voterId" TEXT NOT NULL,
"budgetPeriod" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Vote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminResponse" (
"id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminResponse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ActivityEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"postId" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActivityEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"endpointIdx" TEXT NOT NULL,
"keysP256dh" TEXT NOT NULL,
"keysAuth" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"boardId" TEXT,
"postId" TEXT,
"failureCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Board_slug_key" ON "Board"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "User_tokenHash_key" ON "User"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "User_usernameIdx_key" ON "User"("usernameIdx");
-- CreateIndex
CREATE UNIQUE INDEX "Passkey_credentialIdIdx_key" ON "Passkey"("credentialIdIdx");
-- CreateIndex
CREATE UNIQUE INDEX "Reaction_commentId_userId_emoji_key" ON "Reaction"("commentId", "userId", "emoji");
-- CreateIndex
CREATE UNIQUE INDEX "Vote_postId_voterId_key" ON "Vote"("postId", "voterId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE INDEX "ActivityEvent_boardId_createdAt_idx" ON "ActivityEvent"("boardId", "createdAt");
-- CreateIndex
CREATE INDEX "ActivityEvent_createdAt_idx" ON "ActivityEvent"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpointIdx_key" ON "PushSubscription"("endpointIdx");
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- AddForeignKey
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StatusChange" ADD CONSTRAINT "StatusChange_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_voterId_fkey" FOREIGN KEY ("voterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "EditHistory" (
"id" TEXT NOT NULL,
"postId" TEXT,
"commentId" TEXT,
"editedBy" TEXT NOT NULL,
"previousTitle" TEXT,
"previousDescription" JSONB,
"previousBody" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EditHistory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EditHistory_postId_createdAt_idx" ON "EditHistory"("postId", "createdAt");
-- CreateIndex
CREATE INDEX "EditHistory_commentId_createdAt_idx" ON "EditHistory"("commentId", "createdAt");
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Post" ADD COLUMN "isEditLocked" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "EditHistory" ALTER COLUMN "editedBy" DROP NOT NULL;
-- DropForeignKey
ALTER TABLE "EditHistory" DROP CONSTRAINT "EditHistory_editedBy_fkey";
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL DEFAULT 'default',
"appName" TEXT NOT NULL DEFAULT 'Echoboard',
"logoUrl" TEXT,
"faviconUrl" TEXT,
"accentColor" TEXT NOT NULL DEFAULT '#F59E0B',
"headerFont" TEXT,
"bodyFont" TEXT,
"poweredByVisible" BOOLEAN NOT NULL DEFAULT true,
"customCss" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
INSERT INTO "SiteSettings" ("id", "updatedAt") VALUES ('default', NOW());

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -17,32 +17,46 @@ enum PostType {
BUG_REPORT
}
enum PostStatus {
OPEN
UNDER_REVIEW
PLANNED
IN_PROGRESS
DONE
DECLINED
}
model Board {
id String @id @default(cuid())
slug String @unique
name String
description String?
externalUrl String?
iconName String?
iconColor String?
isArchived Boolean @default(false)
voteBudget Int @default(10)
voteBudgetReset String @default("monthly")
lastBudgetReset DateTime?
allowMultiVote Boolean @default(false)
rssEnabled Boolean @default(true)
rssFeedCount Int @default(50)
staleDays Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
activityEvents ActivityEvent[]
pushSubscriptions PushSubscription[]
statusConfig BoardStatus[]
templates BoardTemplate[]
changelogEntries ChangelogEntry[]
}
model BoardStatus {
id String @id @default(cuid())
boardId String
status String
label String
color String
position Int @default(0)
enabled Boolean @default(true)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@unique([boardId, status])
@@index([boardId, position])
}
model User {
@@ -52,16 +66,35 @@ model User {
username String?
usernameIdx String? @unique
displayName String?
avatarPath String?
darkMode String @default("system")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passkeys Passkey[]
posts Post[]
comments Comment[]
reactions Reaction[]
votes Vote[]
passkeys Passkey[]
posts Post[]
comments Comment[]
reactions Reaction[]
votes Vote[]
notifications Notification[]
pushSubscriptions PushSubscription[]
attachments Attachment[]
adminLink AdminUser?
edits EditHistory[]
recoveryCode RecoveryCode?
}
model RecoveryCode {
id String @id @default(cuid())
codeHash String
phraseIdx String @unique
userId String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
}
model Passkey {
@@ -80,18 +113,26 @@ model Passkey {
}
model Post {
id String @id @default(cuid())
type PostType
title String
description Json
status PostStatus @default(OPEN)
category String?
voteCount Int @default(0)
isPinned Boolean @default(false)
boardId String
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
type PostType
title String
description Json
status String @default("OPEN")
statusReason String?
category String?
templateId String?
voteCount Int @default(0)
viewCount Int @default(0)
isPinned Boolean @default(false)
isEditLocked Boolean @default(false)
isThreadLocked Boolean @default(false)
isVotingLocked Boolean @default(false)
onBehalfOf String?
boardId String
authorId String
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@ -99,15 +140,23 @@ model Post {
comments Comment[]
votes Vote[]
adminResponses AdminResponse[]
adminNotes AdminNote[]
notifications Notification[]
activityEvents ActivityEvent[]
pushSubscriptions PushSubscription[]
tags PostTag[]
attachments Attachment[]
editHistory EditHistory[]
@@index([boardId, status])
}
model StatusChange {
id String @id @default(cuid())
postId String
fromStatus PostStatus
toStatus PostStatus
fromStatus String
toStatus String
reason String?
changedBy String
createdAt DateTime @default(now())
@@ -115,16 +164,28 @@ model StatusChange {
}
model Comment {
id String @id @default(cuid())
body String
postId String
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
body String
postId String
authorId String
replyToId String?
isAdmin Boolean @default(false)
isEditLocked Boolean @default(false)
adminUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
reactions Reaction[]
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id], onDelete: SetNull)
replies Comment[] @relation("CommentReplies")
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: SetNull)
reactions Reaction[]
attachments Attachment[]
editHistory EditHistory[]
@@index([replyToId])
@@index([postId, createdAt])
}
model Reaction {
@@ -143,6 +204,7 @@ model Reaction {
model Vote {
id String @id @default(cuid())
weight Int @default(1)
importance String?
postId String
voterId String
budgetPeriod String
@@ -154,13 +216,50 @@ model Vote {
@@unique([postId, voterId])
}
model AdminUser {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
enum TeamRole {
SUPER_ADMIN
ADMIN
MODERATOR
}
responses AdminResponse[]
model AdminUser {
id String @id @default(cuid())
email String? @unique
passwordHash String?
role TeamRole @default(ADMIN)
displayName String?
teamTitle String?
linkedUserId String? @unique
invitedById String?
createdAt DateTime @default(now())
linkedUser User? @relation(fields: [linkedUserId], references: [id], onDelete: SetNull)
invitedBy AdminUser? @relation("TeamInviter", fields: [invitedById], references: [id], onDelete: SetNull)
invitees AdminUser[] @relation("TeamInviter")
responses AdminResponse[]
notes AdminNote[]
comments Comment[]
createdInvites TeamInvite[] @relation("InviteCreator")
claimedInvite TeamInvite? @relation("InviteClaimer")
}
model TeamInvite {
id String @id @default(cuid())
tokenHash String @unique
role TeamRole
label String?
expiresAt DateTime
createdById String
claimedById String? @unique
claimedAt DateTime?
recoveryHash String?
recoveryIdx String?
createdAt DateTime @default(now())
createdBy AdminUser @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade)
claimedBy AdminUser? @relation("InviteClaimer", fields: [claimedById], references: [id], onDelete: SetNull)
@@index([expiresAt])
}
model AdminResponse {
@@ -190,15 +289,16 @@ model ActivityEvent {
}
model PushSubscription {
id String @id @default(cuid())
endpoint String
endpointIdx String @unique
keysP256dh String
keysAuth String
userId String
boardId String?
postId String?
createdAt DateTime @default(now())
id String @id @default(cuid())
endpoint String
endpointIdx String @unique
keysP256dh String
keysAuth String
userId String
boardId String?
postId String?
failureCount Int @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@ -211,3 +311,151 @@ model Category {
slug String @unique
createdAt DateTime @default(now())
}
model Notification {
id String @id @default(cuid())
type String
title String
body String
postId String?
userId String
read Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
@@index([userId, read, createdAt])
}
model Webhook {
id String @id @default(cuid())
url String
secret String
events String[] @default(["status_changed", "post_created", "comment_added"])
active Boolean @default(true)
createdAt DateTime @default(now())
}
model PostMerge {
id String @id @default(cuid())
sourcePostId String
targetPostId String
mergedBy String
createdAt DateTime @default(now())
@@index([sourcePostId])
}
model ChangelogEntry {
id String @id @default(cuid())
title String
body String
boardId String?
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@index([boardId, publishedAt])
}
model AdminNote {
id String @id @default(cuid())
body String
postId String
adminId String
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
admin AdminUser @relation(fields: [adminId], references: [id], onDelete: Cascade)
}
model Tag {
id String @id @default(cuid())
name String @unique
color String @default("#6366F1")
createdAt DateTime @default(now())
posts PostTag[]
}
model PostTag {
postId String
tagId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([postId, tagId])
}
model Attachment {
id String @id @default(cuid())
filename String
path String
size Int
mimeType String
postId String?
commentId String?
uploaderId String
createdAt DateTime @default(now())
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
}
model BoardTemplate {
id String @id @default(cuid())
boardId String
name String
fields Json
isDefault Boolean @default(false)
position Int @default(0)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@index([boardId, position])
}
model SiteSettings {
id String @id @default("default")
appName String @default("Echoboard")
logoUrl String?
faviconUrl String?
accentColor String @default("#F59E0B")
headerFont String?
bodyFont String?
poweredByVisible Boolean @default(true)
customCss String?
updatedAt DateTime @updatedAt
}
model BlockedToken {
id String @id @default(cuid())
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([expiresAt])
}
model EditHistory {
id String @id @default(cuid())
postId String?
commentId String?
editedBy String?
previousTitle String?
previousDescription Json?
previousBody String?
createdAt DateTime @default(now())
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
editor User? @relation(fields: [editedBy], references: [id], onDelete: SetNull)
@@index([postId, createdAt])
@@index([commentId, createdAt])
}

1298
packages/api/prisma/seed.ts Normal file

File diff suppressed because it is too large Load Diff

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