initial project setup

Fastify + Prisma backend, React + Vite frontend, Docker deployment.
Multi-board feedback platform with anonymous cookie auth, passkey
upgrade path, ALTCHA spam protection, plugin system, and full
privacy-first architecture.
This commit is contained in:
2026-03-19 18:05:16 +02:00
commit f07eddf29e
77 changed files with 7031 additions and 0 deletions

42
packages/api/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@echoboard/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",
"db:deploy": "prisma migrate deploy",
"create-admin": "tsx src/cli/create-admin.ts"
},
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/rate-limit": "^10.0.0",
"@fastify/static": "^8.0.0",
"@prisma/client": "^6.0.0",
"@simplewebauthn/server": "^11.0.0",
"altcha-lib": "^0.5.0",
"bcrypt": "^5.1.0",
"fastify": "^5.0.0",
"fastify-plugin": "^5.0.0",
"jsonwebtoken": "^9.0.0",
"node-cron": "^3.0.0",
"rss": "^1.2.0",
"web-push": "^3.6.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0",
"@types/web-push": "^3.6.0",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,213 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum AuthMethod {
COOKIE
PASSKEY
}
enum PostType {
FEATURE_REQUEST
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?
isArchived Boolean @default(false)
voteBudget Int @default(10)
voteBudgetReset String @default("monthly")
lastBudgetReset DateTime?
allowMultiVote Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
activityEvents ActivityEvent[]
pushSubscriptions PushSubscription[]
}
model User {
id String @id @default(cuid())
authMethod AuthMethod @default(COOKIE)
tokenHash String? @unique
username String?
usernameIdx String? @unique
displayName String?
darkMode String @default("system")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passkeys Passkey[]
posts Post[]
comments Comment[]
reactions Reaction[]
votes Vote[]
pushSubscriptions PushSubscription[]
}
model Passkey {
id String @id @default(cuid())
credentialId String
credentialIdIdx String @unique
credentialPublicKey Bytes
counter BigInt
credentialDeviceType String
credentialBackedUp Boolean
transports String?
userId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
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
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
statusChanges StatusChange[]
comments Comment[]
votes Vote[]
adminResponses AdminResponse[]
activityEvents ActivityEvent[]
pushSubscriptions PushSubscription[]
}
model StatusChange {
id String @id @default(cuid())
postId String
fromStatus PostStatus
toStatus PostStatus
changedBy String
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
}
model Comment {
id String @id @default(cuid())
body String
postId String
authorId 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[]
}
model Reaction {
id String @id @default(cuid())
emoji String
commentId String
userId String
createdAt DateTime @default(now())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([commentId, userId, emoji])
}
model Vote {
id String @id @default(cuid())
weight Int @default(1)
postId String
voterId String
budgetPeriod String
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
voter User @relation(fields: [voterId], references: [id], onDelete: Cascade)
@@unique([postId, voterId])
}
model AdminUser {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
responses AdminResponse[]
}
model AdminResponse {
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 ActivityEvent {
id String @id @default(cuid())
type String
boardId String
postId String?
metadata Json
createdAt DateTime @default(now())
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
@@index([boardId, createdAt])
@@index([createdAt])
}
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())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
createdAt DateTime @default(now())
}

View File

@@ -0,0 +1,86 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt";
import { createInterface } from "node:readline";
const prisma = new PrismaClient();
function getArg(name: string): string | undefined {
const idx = process.argv.indexOf(`--${name}`);
if (idx === -1 || idx + 1 >= process.argv.length) return undefined;
return process.argv[idx + 1];
}
function readLine(prompt: string, hidden = false): Promise<string> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
if (hidden) {
process.stdout.write(prompt);
let input = "";
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.setEncoding("utf8");
const handler = (ch: string) => {
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
process.stdin.setRawMode?.(false);
process.stdin.removeListener("data", handler);
process.stdout.write("\n");
rl.close();
resolve(input);
} else if (ch === "\u007F" || ch === "\b") {
if (input.length > 0) {
input = input.slice(0, -1);
process.stdout.write("\b \b");
}
} else {
input += ch;
process.stdout.write("*");
}
};
process.stdin.on("data", handler);
} else {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer);
});
}
});
}
async function main() {
const email = getArg("email") ?? await readLine("Email: ");
if (!email) {
console.error("Email is required");
process.exit(1);
}
const existing = await prisma.adminUser.findUnique({ where: { email } });
if (existing) {
console.error("Admin with this email already exists");
process.exit(1);
}
const password = await readLine("Password: ", true);
if (!password || password.length < 8) {
console.error("Password must be at least 8 characters");
process.exit(1);
}
const confirm = await readLine("Confirm password: ", true);
if (password !== confirm) {
console.error("Passwords do not match");
process.exit(1);
}
const hash = await bcrypt.hash(password, 12);
const admin = await prisma.adminUser.create({
data: { email, passwordHash: hash },
});
console.log(`Admin created: ${admin.email} (${admin.id})`);
await prisma.$disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,41 @@
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(),
WEBAUTHN_RP_NAME: z.string().default("Echoboard"),
WEBAUTHN_RP_ID: z.string(),
WEBAUTHN_ORIGIN: z.string().url(),
PORT: z.coerce.number().default(3000),
ALTCHA_MAX_NUMBER: z.coerce.number().default(500000),
ALTCHA_MAX_NUMBER_VOTE: z.coerce.number().default(50000),
ALTCHA_EXPIRE_SECONDS: z.coerce.number().default(300),
VAPID_PUBLIC_KEY: z.string().optional(),
VAPID_PRIVATE_KEY: z.string().optional(),
VAPID_CONTACT: z.string().optional(),
DATA_RETENTION_ACTIVITY_DAYS: z.coerce.number().default(90),
DATA_RETENTION_ORPHAN_USER_DAYS: z.coerce.number().default(180),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment variables:");
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}
export const config = parsed.data;
export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex");
export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex");

View File

@@ -0,0 +1,60 @@
import cron from "node-cron";
import { PrismaClient } from "@prisma/client";
import { config } from "../config.js";
import { cleanExpiredChallenges } from "../routes/passkey.js";
const prisma = new PrismaClient();
export function startCronJobs() {
// prune old activity events - daily at 3am
cron.schedule("0 3 * * *", async () => {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);
const result = await prisma.activityEvent.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (result.count > 0) {
console.log(`Pruned ${result.count} old activity events`);
}
});
// prune orphaned anonymous users - daily at 4am
cron.schedule("0 4 * * *", async () => {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS);
const result = await prisma.user.deleteMany({
where: {
authMethod: "COOKIE",
createdAt: { lt: cutoff },
posts: { none: {} },
comments: { none: {} },
votes: { none: {} },
},
});
if (result.count > 0) {
console.log(`Pruned ${result.count} orphaned users`);
}
});
// clean webauthn challenges - every 10 minutes
cron.schedule("*/10 * * * *", () => {
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 } },
});
if (result.count > 0) {
console.log(`Cleaned ${result.count} old push subscriptions`);
}
});
}

19
packages/api/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createServer } from "./server.js";
import { config } from "./config.js";
import { startCronJobs } from "./cron/index.js";
async function main() {
const app = await createServer();
startCronJobs();
try {
await app.listen({ port: config.PORT, host: "0.0.0.0" });
console.log(`Echoboard API running on port ${config.PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,74 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export function getCurrentPeriod(resetSchedule: string): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
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")}`;
}
case "quarterly": {
const q = Math.ceil((now.getMonth() + 1) / 3);
return `${year}-Q${q}`;
}
case "yearly":
return `${year}`;
case "never":
return "lifetime";
case "monthly":
default:
return `${year}-${month}`;
}
}
export async function getRemainingBudget(userId: string, boardId: string): Promise<number> {
const board = await prisma.board.findUnique({ where: { id: boardId } });
if (!board) return 0;
if (board.voteBudgetReset === "never" && board.voteBudget === 0) {
return Infinity;
}
const period = getCurrentPeriod(board.voteBudgetReset);
const used = await prisma.vote.aggregate({
where: { voterId: userId, post: { boardId }, budgetPeriod: period },
_sum: { weight: true },
});
const spent = used._sum.weight ?? 0;
return Math.max(0, board.voteBudget - spent);
}
export function getNextResetDate(resetSchedule: string): Date {
const now = new Date();
switch (resetSchedule) {
case "weekly": {
const d = new Date(now);
d.setDate(d.getDate() + (7 - d.getDay()));
d.setHours(0, 0, 0, 0);
return d;
}
case "quarterly": {
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 "never":
return new Date(8640000000000000); // max date
case "monthly":
default: {
const d = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return d;
}
}
}

View File

@@ -0,0 +1,102 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import fp from "fastify-plugin";
import jwt from "jsonwebtoken";
import { PrismaClient, User } from "@prisma/client";
import { hashToken } from "../services/encryption.js";
import { config } from "../config.js";
declare module "fastify" {
interface FastifyRequest {
user?: User;
adminId?: string;
}
}
const prisma = new PrismaClient();
async function authPlugin(app: FastifyInstance) {
app.decorateRequest("user", undefined);
app.decorateRequest("adminId", undefined);
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
// try cookie auth first
const token = req.cookies?.echoboard_token;
if (token) {
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
req.user = user;
return;
}
}
// try bearer token (passkey sessions)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
req.user = user;
return;
}
}
} catch {
// invalid token
}
}
reply.status(401).send({ error: "Not authenticated" });
});
app.decorate("optionalUser", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_token;
if (token) {
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) req.user = user;
return;
}
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) req.user = user;
}
} catch {
// invalid
}
}
});
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;
}
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" });
return;
}
req.adminId = decoded.sub;
} catch {
reply.status(401).send({ error: "Invalid admin token" });
}
});
}
declare module "fastify" {
interface FastifyInstance {
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
optionalUser: (req: FastifyRequest) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}
export default fp(authPlugin, { name: "auth" });

View File

@@ -0,0 +1,28 @@
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("; "));
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("X-DNS-Prefetch-Control", "off");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
});
}
export default fp(securityPlugin, { name: "security" });

View File

@@ -0,0 +1,32 @@
import { FastifyInstance } from "fastify";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { PluginManifest, EchoboardPlugin } from "./types.js";
export async function loadPlugins(app: FastifyInstance) {
const manifestPath = resolve(process.cwd(), "echoboard.plugins.json");
let manifest: PluginManifest;
try {
const raw = await readFile(manifestPath, "utf-8");
manifest = JSON.parse(raw);
} catch {
app.log.info("No plugin manifest found, skipping plugin loading");
return;
}
if (!manifest.plugins || !Array.isArray(manifest.plugins)) return;
for (const entry of manifest.plugins) {
if (!entry.enabled) 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 ?? {});
} catch (err) {
app.log.error(`Failed to load plugin ${entry.name}: ${err}`);
}
}
}

View File

@@ -0,0 +1,17 @@
import { FastifyInstance } from "fastify";
export interface EchoboardPlugin {
name: string;
version: string;
register: (app: FastifyInstance, config: Record<string, unknown>) => Promise<void>;
}
export interface PluginConfig {
name: string;
enabled: boolean;
config?: Record<string, unknown>;
}
export interface PluginManifest {
plugins: PluginConfig[];
}

View File

@@ -0,0 +1,49 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, Prisma } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
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),
});
export default async function activityRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/activity",
async (req, reply) => {
const q = querySchema.parse(req.query);
const where: Prisma.ActivityEventWhereInput = {};
if (q.board) {
const board = await prisma.board.findUnique({ where: { slug: q.board } });
if (board) where.boardId = board.id;
}
if (q.type) where.type = q.type;
const [events, total] = await Promise.all([
prisma.activityEvent.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (q.page - 1) * q.limit,
take: q.limit,
include: {
board: { select: { slug: true, name: true } },
post: { select: { id: true, title: true } },
},
}),
prisma.activityEvent.count({ where }),
]);
reply.send({
events,
total,
page: q.page,
pages: Math.ceil(total / q.limit),
});
}
);
}

View File

@@ -0,0 +1,42 @@
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";
const prisma = new PrismaClient();
const loginBody = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export default async function adminAuthRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof loginBody> }>(
"/admin/login",
async (req, reply) => {
const body = loginBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { email: body.email } });
if (!admin) {
reply.status(401).send({ error: "Invalid credentials" });
return;
}
const valid = await bcrypt.compare(body.password, admin.passwordHash);
if (!valid) {
reply.status(401).send({ error: "Invalid credentials" });
return;
}
const token = jwt.sign(
{ sub: admin.id, type: "admin" },
config.JWT_SECRET,
{ expiresIn: "24h" }
);
reply.send({ token });
}
);
}

View File

@@ -0,0 +1,112 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
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(),
voteBudget: z.number().int().min(0).default(10),
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"),
allowMultiVote: z.boolean().default(false),
});
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(),
isArchived: z.boolean().optional(),
voteBudget: z.number().int().min(0).optional(),
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(),
allowMultiVote: z.boolean().optional(),
});
export default async function adminBoardRoutes(app: FastifyInstance) {
app.get(
"/admin/boards",
{ preHandler: [app.requireAdmin] },
async (_req, reply) => {
const boards = await prisma.board.findMany({
orderBy: { createdAt: "asc" },
include: {
_count: { select: { posts: true } },
},
});
reply.send(boards);
}
);
app.post<{ Body: z.infer<typeof createBoardBody> }>(
"/admin/boards",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const body = createBoardBody.parse(req.body);
const existing = await prisma.board.findUnique({ where: { slug: body.slug } });
if (existing) {
reply.status(409).send({ error: "Slug already taken" });
return;
}
const board = await prisma.board.create({ data: body });
reply.status(201).send(board);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
"/admin/boards/:id",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const body = updateBoardBody.parse(req.body);
const updated = await prisma.board.update({
where: { id: board.id },
data: body,
});
reply.send(updated);
}
);
app.post<{ Params: { id: string } }>(
"/admin/boards/:id/reset-budget",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const updated = await prisma.board.update({
where: { id: board.id },
data: { lastBudgetReset: new Date() },
});
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/boards/:id",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
await prisma.board.delete({ where: { id: board.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,46 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
const createCategoryBody = z.object({
name: z.string().min(1).max(50),
slug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
});
export default async function adminCategoryRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof createCategoryBody> }>(
"/admin/categories",
{ preHandler: [app.requireAdmin] },
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;
}
const cat = await prisma.category.create({ data: body });
reply.status(201).send(cat);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/categories/:id",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const cat = await prisma.category.findUnique({ where: { id: req.params.id } });
if (!cat) {
reply.status(404).send({ error: "Category not found" });
return;
}
await prisma.category.delete({ where: { id: cat.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,173 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, PostStatus, Prisma } from "@prisma/client";
import { z } from "zod";
import { notifyPostSubscribers } from "../../services/push.js";
const prisma = new PrismaClient();
const statusBody = z.object({
status: z.nativeEnum(PostStatus),
});
const respondBody = z.object({
body: z.string().min(1).max(5000),
});
export default async function adminPostRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/admin/posts",
{ preHandler: [app.requireAdmin] },
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 where: Prisma.PostWhereInput = {};
if (status) where.status = status;
if (boardId) where.boardId = boardId;
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
include: {
board: { select: { slug: true, name: true } },
author: { select: { id: true, displayName: true } },
_count: { select: { comments: true, votes: true } },
},
}),
prisma.post.count({ where }),
]);
reply.send({ posts, total, page, pages: Math.ceil(total / limit) });
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
"/admin/posts/:id/status",
{ 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 { status } = statusBody.parse(req.body);
const oldStatus = post.status;
const [updated] = await Promise.all([
prisma.post.update({ where: { id: post.id }, data: { status } }),
prisma.statusChange.create({
data: {
postId: post.id,
fromStatus: oldStatus,
toStatus: status,
changedBy: req.adminId!,
},
}),
prisma.activityEvent.create({
data: {
type: "status_changed",
boardId: post.boardId,
postId: post.id,
metadata: { from: oldStatus, to: status },
},
}),
]);
await notifyPostSubscribers(post.id, {
title: "Status updated",
body: `"${post.title}" moved to ${status}`,
url: `/post/${post.id}`,
tag: `status-${post.id}`,
});
reply.send(updated);
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/pin",
{ 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 updated = await prisma.post.update({
where: { id: post.id },
data: { isPinned: !post.isPinned },
});
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] },
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;
}
await prisma.comment.delete({ where: { id: comment.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,86 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { config } from "../../config.js";
const prisma = new PrismaClient();
export default async function adminStatsRoutes(app: FastifyInstance) {
app.get(
"/admin/stats",
{ preHandler: [app.requireAdmin] },
async (_req, reply) => {
const [
totalPosts,
totalUsers,
totalComments,
totalVotes,
postsByStatus,
postsByType,
boardStats,
] = await Promise.all([
prisma.post.count(),
prisma.user.count(),
prisma.comment.count(),
prisma.vote.count(),
prisma.post.groupBy({ by: ["status"], _count: true }),
prisma.post.groupBy({ by: ["type"], _count: true }),
prisma.board.findMany({
select: {
id: true,
slug: true,
name: true,
_count: { select: { posts: true } },
},
}),
]);
reply.send({
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,
})),
});
}
);
app.get(
"/admin/data-retention",
{ preHandler: [app.requireAdmin] },
async (_req, reply) => {
const activityCutoff = new Date();
activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);
const orphanCutoff = new Date();
orphanCutoff.setDate(orphanCutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS);
const [staleEvents, orphanUsers] = await Promise.all([
prisma.activityEvent.count({ where: { createdAt: { lt: activityCutoff } } }),
prisma.user.count({
where: {
createdAt: { lt: orphanCutoff },
posts: { none: {} },
comments: { none: {} },
votes: { none: {} },
},
}),
]);
reply.send({
activityRetentionDays: config.DATA_RETENTION_ACTIVITY_DAYS,
orphanRetentionDays: config.DATA_RETENTION_ORPHAN_USER_DAYS,
staleActivityEvents: staleEvents,
orphanedUsers: orphanUsers,
});
}
);
}

View File

@@ -0,0 +1,64 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default async function boardRoutes(app: FastifyInstance) {
app.get("/boards", async (_req, reply) => {
const boards = await prisma.board.findMany({
where: { isArchived: false },
include: {
_count: { select: { posts: true } },
},
orderBy: { createdAt: "asc" },
});
const result = boards.map((b) => ({
id: b.id,
slug: b.slug,
name: b.name,
description: b.description,
externalUrl: b.externalUrl,
voteBudget: b.voteBudget,
voteBudgetReset: b.voteBudgetReset,
allowMultiVote: b.allowMultiVote,
postCount: b._count.posts,
createdAt: b.createdAt,
}));
reply.send(result);
});
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => {
const board = await prisma.board.findUnique({
where: { slug: req.params.boardSlug },
include: {
_count: {
select: {
posts: true,
},
},
},
});
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
reply.send({
id: board.id,
slug: board.slug,
name: board.name,
description: board.description,
externalUrl: board.externalUrl,
isArchived: board.isArchived,
voteBudget: board.voteBudget,
voteBudgetReset: board.voteBudgetReset,
allowMultiVote: board.allowMultiVote,
postCount: board._count.posts,
createdAt: board.createdAt,
updatedAt: board.updatedAt,
});
});
}

View File

@@ -0,0 +1,150 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { verifyChallenge } from "../services/altcha.js";
const prisma = new PrismaClient();
const createCommentSchema = z.object({
body: z.string().min(1).max(5000),
altcha: z.string(),
});
const updateCommentSchema = z.object({
body: z.string().min(1).max(5000),
});
export default async function commentRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>(
"/boards/:boardSlug/posts/:id/comments",
async (req, reply) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
const limit = 50;
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const [comments, total] = await Promise.all([
prisma.comment.findMany({
where: { postId: post.id },
orderBy: { createdAt: "asc" },
skip: (page - 1) * limit,
take: limit,
include: {
author: { select: { id: true, displayName: true } },
reactions: {
select: { emoji: true, userId: true },
},
},
}),
prisma.comment.count({ where: { postId: post.id } }),
]);
const grouped = comments.map((c) => {
const reactionMap: Record<string, { count: number; userIds: string[] }> = {};
for (const r of c.reactions) {
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] };
reactionMap[r.emoji].count++;
reactionMap[r.emoji].userIds.push(r.userId);
}
return {
id: c.id,
body: c.body,
author: c.author,
reactions: reactionMap,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
});
reply.send({ comments: grouped, total, page, pages: Math.ceil(total / limit) });
}
);
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof createCommentSchema> }>(
"/boards/:boardSlug/posts/:id/comments",
{ preHandler: [app.requireUser] },
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 = createCommentSchema.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
const comment = await prisma.comment.create({
data: {
body: body.body,
postId: post.id,
authorId: req.user!.id,
},
include: {
author: { select: { id: true, displayName: true } },
},
});
await prisma.activityEvent.create({
data: {
type: "comment_created",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
});
reply.status(201).send(comment);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateCommentSchema> }>(
"/comments/:id",
{ preHandler: [app.requireUser] },
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) {
reply.status(403).send({ error: "Not your comment" });
return;
}
const body = updateCommentSchema.parse(req.body);
const updated = await prisma.comment.update({
where: { id: comment.id },
data: { body: body.body },
});
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/comments/:id",
{ preHandler: [app.requireUser] },
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) {
reply.status(403).send({ error: "Not your comment" });
return;
}
await prisma.comment.delete({ where: { id: comment.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,69 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import RSS from "rss";
const prisma = new PrismaClient();
export default async function feedRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/feed.rss",
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 posts = await prisma.post.findMany({
where: { boardId: board.id },
orderBy: { createdAt: "desc" },
take: 50,
});
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}`,
});
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] : [],
});
}
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 } } },
});
const feed = new RSS({
title: "Echoboard - All Feedback",
feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`,
site_url: `${req.protocol}://${req.hostname}`,
});
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] : [],
});
}
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
});
}

View File

@@ -0,0 +1,139 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { hashToken, encrypt, decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
const prisma = new PrismaClient();
const updateMeSchema = z.object({
displayName: z.string().max(50).optional().nullable(),
darkMode: z.enum(["system", "light", "dark"]).optional(),
});
export default async function identityRoutes(app: FastifyInstance) {
app.post("/identity", async (_req, reply) => {
const token = randomBytes(32).toString("hex");
const hash = hashToken(token);
const user = await prisma.user.create({
data: { tokenHash: hash },
});
reply
.setCookie("echoboard_token", token, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
})
.status(201)
.send({
id: user.id,
authMethod: user.authMethod,
darkMode: user.darkMode,
});
});
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
"/me",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const body = updateMeSchema.parse(req.body);
const data: Record<string, any> = {};
if (body.displayName !== undefined) {
data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null;
}
if (body.darkMode !== undefined) {
data.darkMode = body.darkMode;
}
const updated = await prisma.user.update({
where: { id: req.user!.id },
data,
});
reply.send({
id: updated.id,
displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null,
darkMode: updated.darkMode,
authMethod: updated.authMethod,
});
}
);
app.get(
"/me/posts",
{ preHandler: [app.requireUser] },
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 } },
},
});
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,
})));
}
);
app.delete(
"/me",
{ preHandler: [app.requireUser] },
async (req, reply) => {
await prisma.user.delete({ where: { id: req.user!.id } });
reply
.clearCookie("echoboard_token", { path: "/" })
.send({ ok: true });
}
);
app.get(
"/me/export",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const userId = req.user!.id;
const [user, posts, comments, votes, reactions] = 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 } }),
]);
const decryptedUser = user ? {
id: user.id,
authMethod: user.authMethod,
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
username: user.username ? decrypt(user.username, masterKey) : null,
darkMode: user.darkMode,
createdAt: user.createdAt,
} : null;
reply.send({
user: decryptedUser,
posts,
comments,
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 })),
exportedAt: new Date().toISOString(),
});
}
);
}

View File

@@ -0,0 +1,247 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from "@simplewebauthn/server";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { config, masterKey, blindIndexKey } from "../config.js";
import { encrypt, decrypt, blindIndex } from "../services/encryption.js";
const prisma = new PrismaClient();
const challenges = new Map<string, { challenge: string; expires: number }>();
function storeChallenge(userId: string, challenge: string) {
challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 });
}
function getChallenge(userId: string): string | null {
const entry = challenges.get(userId);
if (!entry || entry.expires < Date.now()) {
challenges.delete(userId);
return null;
}
challenges.delete(userId);
return entry.challenge;
}
export function cleanExpiredChallenges() {
const now = Date.now();
for (const [key, val] of challenges) {
if (val.expires < now) challenges.delete(key);
}
}
const registerBody = z.object({
username: z.string().min(3).max(30),
});
export default async function passkeyRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof registerBody> }>(
"/auth/passkey/register/options",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const { username } = registerBody.parse(req.body);
const user = req.user!;
const usernameHash = blindIndex(username, blindIndexKey);
const existing = await prisma.user.findUnique({ where: { usernameIdx: usernameHash } });
if (existing && existing.id !== user.id) {
reply.status(409).send({ error: "Username taken" });
return;
}
const existingPasskeys = await prisma.passkey.findMany({ where: { userId: user.id } });
const options = await generateRegistrationOptions({
rpName: config.WEBAUTHN_RP_NAME,
rpID: config.WEBAUTHN_RP_ID,
userID: new TextEncoder().encode(user.id),
userName: username,
attestationType: "none",
excludeCredentials: existingPasskeys.map((pk) => ({
id: decrypt(pk.credentialId, masterKey),
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
storeChallenge(user.id, options.challenge);
reply.send(options);
}
);
app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>(
"/auth/passkey/register/verify",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const user = req.user!;
const { response, username } = req.body;
const expectedChallenge = getChallenge(user.id);
if (!expectedChallenge) {
reply.status(400).send({ error: "Challenge expired" });
return;
}
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: config.WEBAUTHN_ORIGIN,
expectedRPID: config.WEBAUTHN_RP_ID,
});
} catch (err: any) {
reply.status(400).send({ error: err.message });
return;
}
if (!verification.verified || !verification.registrationInfo) {
reply.status(400).send({ error: "Verification failed" });
return;
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const credIdStr = Buffer.from(credential.id).toString("base64url");
await prisma.passkey.create({
data: {
credentialId: encrypt(credIdStr, masterKey),
credentialIdIdx: blindIndex(credIdStr, blindIndexKey),
credentialPublicKey: Buffer.from(credential.publicKey),
counter: BigInt(credential.counter),
credentialDeviceType,
credentialBackedUp,
transports: credential.transports ? encrypt(JSON.stringify(credential.transports), masterKey) : null,
userId: user.id,
},
});
const usernameHash = blindIndex(username, blindIndexKey);
await prisma.user.update({
where: { id: user.id },
data: {
authMethod: "PASSKEY",
username: encrypt(username, masterKey),
usernameIdx: usernameHash,
},
});
reply.send({ verified: true });
}
);
app.post(
"/auth/passkey/login/options",
async (_req, reply) => {
const options = await generateAuthenticationOptions({
rpID: config.WEBAUTHN_RP_ID,
userVerification: "preferred",
});
storeChallenge("login:" + options.challenge, options.challenge);
reply.send(options);
}
);
app.post<{ Body: { response: AuthenticationResponseJSON } }>(
"/auth/passkey/login/verify",
async (req, reply) => {
const { response } = req.body;
const credIdStr = response.id;
const credIdx = blindIndex(credIdStr, blindIndexKey);
const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } });
if (!passkey) {
reply.status(400).send({ error: "Passkey not found" });
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;
}
}
if (!challenge) {
reply.status(400).send({ error: "Challenge expired" });
return;
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: config.WEBAUTHN_ORIGIN,
expectedRPID: config.WEBAUTHN_RP_ID,
credential: {
id: decrypt(passkey.credentialId, masterKey),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
transports: passkey.transports
? JSON.parse(decrypt(passkey.transports, masterKey))
: undefined,
},
});
} catch (err: any) {
reply.status(400).send({ error: err.message });
return;
}
if (!verification.verified) {
reply.status(400).send({ error: "Verification failed" });
return;
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
});
const token = jwt.sign(
{ sub: passkey.userId, type: "passkey" },
config.JWT_SECRET,
{ expiresIn: "30d" }
);
reply.send({ verified: true, token });
}
);
app.post(
"/auth/passkey/logout",
{ preHandler: [app.requireUser] },
async (_req, reply) => {
reply
.clearCookie("echoboard_token", { path: "/" })
.send({ ok: true });
}
);
app.get<{ Params: { name: string } }>(
"/auth/passkey/check-username/:name",
async (req, reply) => {
const hash = blindIndex(req.params.name, blindIndexKey);
const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } });
reply.send({ available: !existing });
}
);
}

View File

@@ -0,0 +1,216 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client";
import { z } from "zod";
import { verifyChallenge } from "../services/altcha.js";
const prisma = new PrismaClient();
const createPostSchema = z.object({
type: z.nativeEnum(PostType),
title: z.string().min(3).max(200),
description: z.any(),
category: z.string().optional(),
altcha: z.string(),
});
const updatePostSchema = z.object({
title: z.string().min(3).max(200).optional(),
description: z.any().optional(),
category: z.string().optional().nullable(),
});
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),
});
export default async function postRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
"/boards/:boardSlug/posts",
{ preHandler: [app.optionalUser] },
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 q = querySchema.parse(req.query);
const where: Prisma.PostWhereInput = { boardId: board.id };
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" };
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;
default: orderBy = { createdAt: "desc" };
}
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (q.page - 1) * q.limit,
take: q.limit,
include: {
_count: { select: { comments: true } },
author: { select: { id: true, displayName: true } },
},
}),
prisma.post.count({ where }),
]);
reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
status: p.status,
category: p.category,
voteCount: p.voteCount,
isPinned: p.isPinned,
commentCount: p._count.comments,
author: p.author,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
total,
page: q.page,
pages: Math.ceil(total / q.limit),
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.optionalUser] },
async (req, reply) => {
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" },
},
statusChanges: { orderBy: { createdAt: "asc" } },
},
});
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
let voted = false;
if (req.user) {
const existing = await prisma.vote.findUnique({
where: { postId_voterId: { postId: post.id, voterId: req.user.id } },
});
voted = !!existing;
}
reply.send({ ...post, voted });
}
);
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
"/boards/:boardSlug/posts",
{ preHandler: [app.requireUser] },
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 or archived" });
return;
}
const body = createPostSchema.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
const post = await prisma.post.create({
data: {
type: body.type,
title: body.title,
description: body.description,
category: body.category,
boardId: board.id,
authorId: req.user!.id,
},
});
await prisma.activityEvent.create({
data: {
type: "post_created",
boardId: board.id,
postId: post.id,
metadata: { title: post.title, type: post.type },
},
});
reply.status(201).send(post);
}
);
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.requireUser] },
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;
}
if (post.authorId !== req.user!.id) {
reply.status(403).send({ error: "Not your post" });
return;
}
const body = updatePostSchema.parse(req.body);
const updated = await prisma.post.update({
where: { id: post.id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.description !== undefined && { description: body.description }),
...(body.category !== undefined && { category: body.category }),
},
});
reply.send(updated);
}
);
app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.requireUser] },
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;
}
if (post.authorId !== req.user!.id) {
reply.status(403).send({ error: "Not your post" });
return;
}
await prisma.post.delete({ where: { id: post.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,50 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { generateChallenge } from "../services/altcha.js";
import { config } from "../config.js";
const prisma = new PrismaClient();
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");
reply.send(challenge);
});
app.get("/privacy/data-manifest", 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",
},
},
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) => {
const cats = await prisma.category.findMany({
orderBy: { name: "asc" },
});
reply.send(cats);
});
}

View File

@@ -0,0 +1,91 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { encrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
const prisma = new PrismaClient();
const subscribeBody = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
boardId: z.string().optional(),
postId: z.string().optional(),
});
const unsubscribeBody = z.object({
endpoint: z.string().url(),
});
export default async function pushRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof subscribeBody> }>(
"/push/subscribe",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const body = subscribeBody.parse(req.body);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
if (existing) {
await prisma.pushSubscription.update({
where: { id: existing.id },
data: {
boardId: body.boardId ?? null,
postId: body.postId ?? null,
},
});
reply.send({ ok: true, updated: true });
return;
}
await prisma.pushSubscription.create({
data: {
endpoint: encrypt(body.endpoint, masterKey),
endpointIdx,
keysP256dh: encrypt(body.keys.p256dh, masterKey),
keysAuth: encrypt(body.keys.auth, masterKey),
userId: req.user!.id,
boardId: body.boardId,
postId: body.postId,
},
});
reply.status(201).send({ ok: true });
}
);
app.delete<{ Body: z.infer<typeof unsubscribeBody> }>(
"/push/subscribe",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const body = unsubscribeBody.parse(req.body);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const deleted = await prisma.pushSubscription.deleteMany({
where: { endpointIdx, userId: req.user!.id },
});
if (deleted.count === 0) {
reply.status(404).send({ error: "Subscription not found" });
return;
}
reply.send({ ok: true });
}
);
app.get(
"/push/subscriptions",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const subs = await prisma.pushSubscription.findMany({
where: { userId: req.user!.id },
select: { id: true, boardId: true, postId: true, createdAt: true },
});
reply.send(subs);
}
);
}

View File

@@ -0,0 +1,70 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
const reactionBody = z.object({
emoji: z.string().min(1).max(8),
});
export default async function reactionRoutes(app: FastifyInstance) {
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
"/comments/:id/reactions",
{ preHandler: [app.requireUser] },
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 { emoji } = reactionBody.parse(req.body);
const existing = await prisma.reaction.findUnique({
where: {
commentId_userId_emoji: {
commentId: comment.id,
userId: req.user!.id,
emoji,
},
},
});
if (existing) {
await prisma.reaction.delete({ where: { id: existing.id } });
reply.send({ toggled: false });
} else {
await prisma.reaction.create({
data: {
emoji,
commentId: comment.id,
userId: req.user!.id,
},
});
reply.send({ toggled: true });
}
}
);
app.delete<{ Params: { id: string; emoji: string } }>(
"/comments/:id/reactions/:emoji",
{ preHandler: [app.requireUser] },
async (req, reply) => {
const deleted = await prisma.reaction.deleteMany({
where: {
commentId: req.params.id,
userId: req.user!.id,
emoji: req.params.emoji,
},
});
if (deleted.count === 0) {
reply.status(404).send({ error: "Reaction not found" });
return;
}
reply.send({ ok: true });
}
);
}

View File

@@ -0,0 +1,138 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
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(),
});
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] },
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 body = voteBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
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 },
});
} else {
await prisma.vote.create({
data: {
postId: post.id,
voterId: req.user!.id,
budgetPeriod: period,
},
});
}
await prisma.post.update({
where: { id: post.id },
data: { voteCount: { increment: 1 } },
});
await prisma.activityEvent.create({
data: {
type: "vote_cast",
boardId: board.id,
postId: post.id,
metadata: {},
},
});
reply.send({ ok: true, voteCount: post.voteCount + 1 });
}
);
app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/vote",
{ preHandler: [app.requireUser] },
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 vote = await prisma.vote.findUnique({
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
});
if (!vote) {
reply.status(404).send({ error: "No vote found" });
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 } },
});
reply.send({ ok: true });
}
);
app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/budget",
{ preHandler: [app.requireUser] },
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 remaining = await getRemainingBudget(req.user!.id, board.id);
const nextReset = getNextResetDate(board.voteBudgetReset);
reply.send({
total: board.voteBudget,
remaining,
resetSchedule: board.voteBudgetReset,
nextReset: nextReset.toISOString(),
});
}
);
}

View File

@@ -0,0 +1,93 @@
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import fastifyStatic from "@fastify/static";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import securityPlugin from "./middleware/security.js";
import authPlugin from "./middleware/auth.js";
import { loadPlugins } from "./plugins/loader.js";
import boardRoutes from "./routes/boards.js";
import postRoutes from "./routes/posts.js";
import voteRoutes from "./routes/votes.js";
import commentRoutes from "./routes/comments.js";
import reactionRoutes from "./routes/reactions.js";
import identityRoutes from "./routes/identity.js";
import passkeyRoutes from "./routes/passkey.js";
import feedRoutes from "./routes/feed.js";
import activityRoutes from "./routes/activity.js";
import pushRoutes from "./routes/push.js";
import privacyRoutes from "./routes/privacy.js";
import adminAuthRoutes from "./routes/admin/auth.js";
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";
export async function createServer() {
const app = Fastify({
logger: {
serializers: {
req(req) {
return {
method: req.method,
url: req.url,
};
},
},
},
});
await app.register(cookie, { secret: process.env.TOKEN_SECRET });
await app.register(cors, {
origin: true,
credentials: true,
});
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
});
await app.register(securityPlugin);
await app.register(authPlugin);
// api routes under /api/v1
await app.register(async (api) => {
await api.register(boardRoutes);
await api.register(postRoutes);
await api.register(voteRoutes);
await api.register(commentRoutes);
await api.register(reactionRoutes);
await api.register(identityRoutes);
await api.register(passkeyRoutes);
await api.register(feedRoutes);
await api.register(activityRoutes);
await api.register(pushRoutes);
await api.register(privacyRoutes);
await api.register(adminAuthRoutes);
await api.register(adminPostRoutes);
await api.register(adminBoardRoutes);
await api.register(adminCategoryRoutes);
await api.register(adminStatsRoutes);
}, { prefix: "/api/v1" });
// serve static frontend build in production
const webDist = resolve(process.cwd(), "../web/dist");
if (process.env.NODE_ENV === "production" && existsSync(webDist)) {
await app.register(fastifyStatic, {
root: webDist,
wildcard: false,
});
app.setNotFoundHandler((_req, reply) => {
reply.sendFile("index.html");
});
}
await loadPlugins(app);
return app;
}

View File

@@ -0,0 +1,21 @@
import { createChallenge, verifySolution } from "altcha-lib";
import { config } from "../config.js";
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({
hmacKey: config.ALTCHA_HMAC_KEY,
maxNumber,
expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000),
});
return challenge;
}
export async function verifyChallenge(payload: string): Promise<boolean> {
try {
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
return ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,30 @@
import { createCipheriv, createDecipheriv, createHmac, createHash, randomBytes } from "node:crypto";
const IV_LEN = 12;
const TAG_LEN = 16;
export function encrypt(plaintext: string, key: Buffer): string {
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted, tag]).toString("base64");
}
export function decrypt(encoded: string, key: Buffer): string {
const buf = Buffer.from(encoded, "base64");
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(buf.length - TAG_LEN);
const ciphertext = buf.subarray(IV_LEN, buf.length - TAG_LEN);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext) + decipher.final("utf8");
}
export function blindIndex(value: string, key: Buffer): string {
return createHmac("sha256", key).update(value.toLowerCase()).digest("hex");
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

View File

@@ -0,0 +1,71 @@
import webpush from "web-push";
import { PrismaClient } from "@prisma/client";
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,
config.VAPID_PUBLIC_KEY,
config.VAPID_PRIVATE_KEY
);
}
interface PushPayload {
title: string;
body: string;
url?: string;
tag?: string;
}
export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) {
try {
await webpush.sendNotification(
{
endpoint: decrypt(sub.endpoint, masterKey),
keys: {
p256dh: decrypt(sub.keysP256dh, masterKey),
auth: decrypt(sub.keysAuth, masterKey),
},
},
JSON.stringify(payload)
);
return true;
} catch (err: any) {
if (err.statusCode === 404 || err.statusCode === 410) {
return false;
}
throw err;
}
}
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 } } });
}
}
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 } } });
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}