From e5ba94cb336f1692178a79e6464266475681d2ed Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 19 May 2026 13:56:31 +0500 Subject: [PATCH] Fix security, transaction, and badge issues from final review - Validate file URLs against S3 prefix in messages route (Fix 1) - Guard attachment hrefs with https:// check in QuestionThread and QuestionSplitView (Fix 2) - Wrap message create + updatedAt bump in prisma.$transaction (Fix 3) - Add questionsBadge count query to curator layout for admin branch (Fix 4) - Fire-and-forget email sends with void Promise.all (Fix 5) - Wrap req.json() calls in try/catch returning 400 on parse failure (Fix 6) Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/questions/[id]/messages/route.ts | 55 ++++++++++++------- src/app/api/questions/route.ts | 9 ++- src/app/curator/layout.tsx | 13 ++++- .../questions/QuestionSplitView.tsx | 2 +- src/components/questions/QuestionThread.tsx | 2 +- 5 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/app/api/questions/[id]/messages/route.ts b/src/app/api/questions/[id]/messages/route.ts index 4c95ad8..3dd8f64 100644 --- a/src/app/api/questions/[id]/messages/route.ts +++ b/src/app/api/questions/[id]/messages/route.ts @@ -10,6 +10,13 @@ interface FileAttachment { size: number; } +function buildS3Prefix(): string { + const endpoint = process.env.S3_ENDPOINT ?? ""; + const bucket = process.env.S3_BUCKET ?? ""; + // e.g. https://fsn1.your-objectstorage.com/lms-uploads/ + return `${endpoint}/${bucket}/`; +} + export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -33,41 +40,49 @@ export async function POST( return NextResponse.json({ error: "Question is closed" }, { status: 409 }); } - const body = await req.json(); + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } const { text, files } = body as { text: string; files?: FileAttachment[] }; if (!text?.trim()) { return NextResponse.json({ error: "text is required" }, { status: 400 }); } + const s3Prefix = buildS3Prefix(); const safeFiles = files ?.filter( (f) => typeof f.name === "string" && typeof f.url === "string" && + f.url.startsWith("https://") && + f.url.startsWith(s3Prefix) && typeof f.size === "number" ) .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); - const message = await prisma.studentQuestionMessage.create({ - data: { - questionId: id, - authorId: session.user.id, - text: text.trim(), - files: safeFiles?.length ? (safeFiles as object[]) : undefined, - }, - include: { author: { select: { id: true, name: true, role: true } } }, - }); + const [msg] = await prisma.$transaction([ + prisma.studentQuestionMessage.create({ + data: { + questionId: id, + authorId: session.user.id, + text: text.trim(), + files: safeFiles?.length ? (safeFiles as object[]) : undefined, + }, + include: { author: { select: { id: true, name: true, role: true } } }, + }), + prisma.studentQuestion.update({ + where: { id }, + data: { updatedAt: new Date() }, + }), + ]); - // Touch question.updatedAt - await prisma.studentQuestion.update({ - where: { id }, - data: { updatedAt: new Date() }, - }); - - // Send notifications + // Send notifications (fire-and-forget, outside transaction) if (isStaff) { - await sendQuestionReplyEmail( + void sendQuestionReplyEmail( question.user.email, question.user.name, question.title, @@ -78,12 +93,12 @@ export async function POST( where: { role: { in: ["admin", "curator"] } }, select: { email: true, name: true }, }); - await Promise.all( + void Promise.all( staff.map((s) => sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title) ) ); } - return NextResponse.json(message, { status: 201 }); + return NextResponse.json(msg, { status: 201 }); } diff --git a/src/app/api/questions/route.ts b/src/app/api/questions/route.ts index b176fca..38384bf 100644 --- a/src/app/api/questions/route.ts +++ b/src/app/api/questions/route.ts @@ -53,7 +53,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const body = await req.json(); + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } const { title, text, courseId } = body as { title: string; text: string; @@ -82,7 +87,7 @@ export async function POST(req: NextRequest) { where: { role: { in: ["admin", "curator"] } }, select: { email: true, name: true }, }); - await Promise.all( + void Promise.all( staff.map((s) => sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim()) ) diff --git a/src/app/curator/layout.tsx b/src/app/curator/layout.tsx index 05facde..4bdde0d 100644 --- a/src/app/curator/layout.tsx +++ b/src/app/curator/layout.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { LogoutButton } from "@/components/layout/logout-button"; import { AdminShell } from "@/components/admin/admin-shell"; import { getSetting } from "@/lib/settings"; +import { prisma } from "@/lib/prisma"; export default async function CuratorLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); @@ -19,7 +20,17 @@ export default async function CuratorLayout({ children }: { children: React.Reac // Admin uses the admin shell with sidebar if (session.user.role === "admin") { - return {children}; + const questionsBadge = await prisma.studentQuestion.count({ + where: { + messages: { + some: { + isRead: false, + author: { role: "student" }, + }, + }, + }, + }); + return {children}; } return ( diff --git a/src/components/questions/QuestionSplitView.tsx b/src/components/questions/QuestionSplitView.tsx index a52adc5..492e5ee 100644 --- a/src/components/questions/QuestionSplitView.tsx +++ b/src/components/questions/QuestionSplitView.tsx @@ -295,7 +295,7 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string }) {msg.files.map((f) => ( (