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