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:
86
packages/api/src/cli/create-admin.ts
Normal file
86
packages/api/src/cli/create-admin.ts
Normal 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);
|
||||
});
|
||||
41
packages/api/src/config.ts
Normal file
41
packages/api/src/config.ts
Normal 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");
|
||||
60
packages/api/src/cron/index.ts
Normal file
60
packages/api/src/cron/index.ts
Normal 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
19
packages/api/src/index.ts
Normal 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();
|
||||
74
packages/api/src/lib/budget.ts
Normal file
74
packages/api/src/lib/budget.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
packages/api/src/middleware/auth.ts
Normal file
102
packages/api/src/middleware/auth.ts
Normal 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" });
|
||||
28
packages/api/src/middleware/security.ts
Normal file
28
packages/api/src/middleware/security.ts
Normal 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" });
|
||||
32
packages/api/src/plugins/loader.ts
Normal file
32
packages/api/src/plugins/loader.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/api/src/plugins/types.ts
Normal file
17
packages/api/src/plugins/types.ts
Normal 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[];
|
||||
}
|
||||
49
packages/api/src/routes/activity.ts
Normal file
49
packages/api/src/routes/activity.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
42
packages/api/src/routes/admin/auth.ts
Normal file
42
packages/api/src/routes/admin/auth.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
112
packages/api/src/routes/admin/boards.ts
Normal file
112
packages/api/src/routes/admin/boards.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
46
packages/api/src/routes/admin/categories.ts
Normal file
46
packages/api/src/routes/admin/categories.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
173
packages/api/src/routes/admin/posts.ts
Normal file
173
packages/api/src/routes/admin/posts.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
86
packages/api/src/routes/admin/stats.ts
Normal file
86
packages/api/src/routes/admin/stats.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
64
packages/api/src/routes/boards.ts
Normal file
64
packages/api/src/routes/boards.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
150
packages/api/src/routes/comments.ts
Normal file
150
packages/api/src/routes/comments.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
69
packages/api/src/routes/feed.ts
Normal file
69
packages/api/src/routes/feed.ts
Normal 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 }));
|
||||
});
|
||||
}
|
||||
139
packages/api/src/routes/identity.ts
Normal file
139
packages/api/src/routes/identity.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
247
packages/api/src/routes/passkey.ts
Normal file
247
packages/api/src/routes/passkey.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
216
packages/api/src/routes/posts.ts
Normal file
216
packages/api/src/routes/posts.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
50
packages/api/src/routes/privacy.ts
Normal file
50
packages/api/src/routes/privacy.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
91
packages/api/src/routes/push.ts
Normal file
91
packages/api/src/routes/push.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
70
packages/api/src/routes/reactions.ts
Normal file
70
packages/api/src/routes/reactions.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
138
packages/api/src/routes/votes.ts
Normal file
138
packages/api/src/routes/votes.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
93
packages/api/src/server.ts
Normal file
93
packages/api/src/server.ts
Normal 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;
|
||||
}
|
||||
21
packages/api/src/services/altcha.ts
Normal file
21
packages/api/src/services/altcha.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
packages/api/src/services/encryption.ts
Normal file
30
packages/api/src/services/encryption.ts
Normal 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");
|
||||
}
|
||||
71
packages/api/src/services/push.ts
Normal file
71
packages/api/src/services/push.ts
Normal 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 } } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user