Files
echoboard/packages/api/src/routes/posts.ts

759 lines
26 KiB
TypeScript

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<string, string> }>(
"/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<string, number>();
if (req.user) {
const votes = await prisma.vote.findMany({
where: { voterId: req.user.id, postId: { in: posts.map((p) => p.id) } },
select: { postId: true, weight: true },
});
for (const v of votes) userVotes.set(v.postId, v.weight);
}
const staleCutoff = board.staleDays > 0
? new Date(Date.now() - board.staleDays * 86400000)
: null;
reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
description: p.description,
status: p.status,
statusReason: p.statusReason,
category: p.category,
voteCount: p.voteCount,
viewCount: p.viewCount,
isPinned: p.isPinned,
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
commentCount: p._count.comments,
author: 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<string, number> = {
critical: 0, important: 0, nice_to_have: 0, minor: 0,
};
for (const row of importanceVotes) {
if (row.importance && row.importance in importanceCounts) {
importanceCounts[row.importance] = row._count.importance;
}
}
reply.send({
id: post.id,
type: post.type,
title: post.title,
description: post.description,
status: post.status,
statusReason: post.statusReason,
category: post.category,
voteCount: post.voteCount,
viewCount: post.viewCount,
isPinned: post.isPinned,
isEditLocked: post.isEditLocked,
isThreadLocked: post.isThreadLocked,
isVotingLocked: post.isVotingLocked,
onBehalfOf: post.onBehalfOf,
commentCount: post._count.comments,
voteTotal: post._count.votes,
author: cleanAuthor(post.author),
tags: post.tags.map((pt) => pt.tag),
attachments: post.attachments,
statusChanges: post.statusChanges.map((sc) => ({
id: sc.id,
fromStatus: sc.fromStatus,
toStatus: sc.toStatus,
reason: sc.reason,
createdAt: sc.createdAt,
})),
voted,
voteWeight,
userImportance,
importanceCounts,
editCount: post._count.editHistory,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/timeline",
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
const [statusChanges, comments] = await Promise.all([
prisma.statusChange.findMany({
where: { postId: post.id },
orderBy: { createdAt: "asc" },
take: 500,
}),
prisma.comment.findMany({
where: { postId: post.id },
include: {
author: { select: { id: true, displayName: true, avatarPath: true } },
adminUser: { select: { displayName: true, teamTitle: true } },
reactions: { select: { emoji: true, userId: true } },
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
_count: { select: { editHistory: true } },
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
adminUserId: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
adminUser: { select: { displayName: true } },
},
},
},
orderBy: { createdAt: "asc" },
take: 500,
}),
]);
const entries = [
...statusChanges.map((sc) => ({
id: sc.id,
type: "status_change" as const,
authorName: "System",
content: "",
oldStatus: sc.fromStatus,
newStatus: sc.toStatus,
reason: sc.reason ?? null,
createdAt: sc.createdAt,
isAdmin: true,
})),
...comments.map((c) => {
const emojiMap: Record<string, { count: number; userIds: string[] }> = {};
for (const r of c.reactions) {
if (!emojiMap[r.emoji]) emojiMap[r.emoji] = { count: 0, userIds: [] };
emojiMap[r.emoji].count++;
emojiMap[r.emoji].userIds.push(r.userId);
}
return {
id: c.id,
type: "comment" as const,
authorId: c.author?.id ?? null,
authorName: c.isAdmin ? (decryptName(c.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.author?.displayName ?? null) ?? `Anonymous #${(c.author?.id ?? "0000").slice(-4)}`),
authorTitle: c.isAdmin && c.adminUser?.teamTitle ? decryptName(c.adminUser.teamTitle) : null,
authorAvatarUrl: c.author?.avatarPath ? `/api/v1/avatars/${c.author.id}` : null,
content: c.body,
createdAt: c.createdAt,
isAdmin: c.isAdmin,
replyTo: c.replyTo ? {
id: c.replyTo.id,
body: c.replyTo.body.slice(0, 200),
isAdmin: c.replyTo.isAdmin,
authorName: c.replyTo.isAdmin ? (decryptName(c.replyTo.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.replyTo.author?.displayName ?? null) ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`),
} : null,
reactions: Object.entries(emojiMap).map(([emoji, data]) => ({
emoji,
count: data.count,
hasReacted: req.user ? data.userIds.includes(req.user.id) : false,
})),
attachments: c.attachments,
editCount: c._count.editHistory,
isEditLocked: c.isEditLocked,
};
}),
].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
reply.send({ entries, isCurrentAdmin: !!req.adminId });
}
);
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
"/boards/:boardSlug/posts",
{ preHandler: [app.requireUser], 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<typeof updatePostSchema> }>(
"/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();
}
);
}