security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -1,38 +1,163 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client";
|
||||
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 prisma = new PrismaClient();
|
||||
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(3).max(200),
|
||||
description: z.any(),
|
||||
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(3).max(200).optional(),
|
||||
description: z.any().optional(),
|
||||
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().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),
|
||||
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] },
|
||||
{ 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) {
|
||||
@@ -46,13 +171,29 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
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" };
|
||||
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 "trending": orderBy = { voteCount: "desc" }; break;
|
||||
case "updated": orderBy = { updatedAt: "desc" }; break;
|
||||
default: orderBy = { createdAt: "desc" };
|
||||
}
|
||||
|
||||
@@ -64,70 +205,266 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
take: q.limit,
|
||||
include: {
|
||||
_count: { select: { comments: true } },
|
||||
author: { select: { id: true, displayName: 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: p.author,
|
||||
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] },
|
||||
{ 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 } },
|
||||
_count: { select: { comments: true, votes: true } },
|
||||
adminResponses: {
|
||||
include: { admin: { select: { id: true, email: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
reply.send({ ...post, voted });
|
||||
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] },
|
||||
{ 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) {
|
||||
@@ -143,17 +480,59 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
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: body.title,
|
||||
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",
|
||||
@@ -163,16 +542,40 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201).send(post);
|
||||
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] },
|
||||
{ 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) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
@@ -181,26 +584,136 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
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 }),
|
||||
...(body.title !== undefined && { title: body.title.replace(INVISIBLE_RE, '') }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
...(body.category !== undefined && { category: body.category }),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
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] },
|
||||
{ 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) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
@@ -209,7 +722,36 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
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();
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user