759 lines
26 KiB
TypeScript
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 { firePluginEvent } 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 },
|
|
},
|
|
});
|
|
|
|
firePluginEvent("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();
|
|
}
|
|
);
|
|
}
|