"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { isAllowed } from "@/lib/services/moderation"; // website_article_comments.comment is VARCHAR(255); keep the write within bounds. const COMMENT_MAX = 255; type CommentOutcome = | "posted" | "empty" | "invalid" | "moderated" | "ratelimit" | "not_found" | "error"; function commentRedirect(slug: string, outcome: CommentOutcome): never { const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news"; if (outcome === "posted") redirect(`${path}?comment=posted`); redirect(`${path}?error=${outcome}`); } function isNextRedirect(e: unknown): boolean { return ( !!e && typeof e === "object" && "digest" in e && typeof (e as { digest?: unknown }).digest === "string" && (e as { digest: string }).digest.startsWith("NEXT_REDIRECT") ); } /** * Post a comment on a news article as the SIGNED-IN user. The author id is read * from the session (re-fetched via auth()), never from the submitted FormData, * so a crafted form cannot post as another account. The articleId comes from the * form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT). * * Errors redirect back with a machine-readable ?error= code; success redirects * with ?comment=posted. */ export async function postComment(formData: FormData): Promise { const slugHint = String(formData.get("slug") ?? "") .normalize("NFC") .trim(); let outcome: CommentOutcome = "error"; let slug = slugHint; try { const session = await auth(); if (!session?.user?.id) { redirect("/login"); } const userId = Number(session.user.id); if (!Number.isFinite(userId) || userId <= 0) { redirect("/login"); } await clientIp(); if (!(await rateLimit(`comment:${userId}`, 5, 30_000)).ok) { outcome = "ratelimit"; } else { const comment = String(formData.get("comment") ?? "") .normalize("NFC") .trim() .slice(0, COMMENT_MAX); if (!comment) { outcome = "empty"; } else if (!(await isAllowed(comment)).ok) { outcome = "moderated"; } else { const articleIdRaw = String(formData.get("articleId") ?? "") .normalize("NFC") .trim(); if (!/^\d+$/.test(articleIdRaw)) { outcome = "invalid"; } else { const articleId = BigInt(articleIdRaw); const article = await prisma.websiteArticles.findUnique({ where: { id: articleId }, select: { slug: true }, }); if (!article) { outcome = "not_found"; } else { slug = article.slug; const now = new Date(); await prisma.websiteArticleComments.create({ data: { articleId, userId, comment, createdAt: now, updatedAt: now, }, }); outcome = "posted"; } } } } } catch (e) { if (isNextRedirect(e)) throw e; outcome = "error"; } if (slug) revalidatePath(`/news/${slug}`); commentRedirect(slug, outcome); }