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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:56:31 +05:00
parent 12e1785ff2
commit e5ba94cb33
5 changed files with 56 additions and 25 deletions
+26 -11
View File
@@ -10,6 +10,13 @@ interface FileAttachment {
size: number; 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( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@@ -33,23 +40,32 @@ export async function POST(
return NextResponse.json({ error: "Question is closed" }, { status: 409 }); 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[] }; const { text, files } = body as { text: string; files?: FileAttachment[] };
if (!text?.trim()) { if (!text?.trim()) {
return NextResponse.json({ error: "text is required" }, { status: 400 }); return NextResponse.json({ error: "text is required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files const safeFiles = files
?.filter( ?.filter(
(f) => (f) =>
typeof f.name === "string" && typeof f.name === "string" &&
typeof f.url === "string" && typeof f.url === "string" &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number" typeof f.size === "number"
) )
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
const message = await prisma.studentQuestionMessage.create({ const [msg] = await prisma.$transaction([
prisma.studentQuestionMessage.create({
data: { data: {
questionId: id, questionId: id,
authorId: session.user.id, authorId: session.user.id,
@@ -57,17 +73,16 @@ export async function POST(
files: safeFiles?.length ? (safeFiles as object[]) : undefined, files: safeFiles?.length ? (safeFiles as object[]) : undefined,
}, },
include: { author: { select: { id: true, name: true, role: true } } }, include: { author: { select: { id: true, name: true, role: true } } },
}); }),
prisma.studentQuestion.update({
// Touch question.updatedAt
await prisma.studentQuestion.update({
where: { id }, where: { id },
data: { updatedAt: new Date() }, data: { updatedAt: new Date() },
}); }),
]);
// Send notifications // Send notifications (fire-and-forget, outside transaction)
if (isStaff) { if (isStaff) {
await sendQuestionReplyEmail( void sendQuestionReplyEmail(
question.user.email, question.user.email,
question.user.name, question.user.name,
question.title, question.title,
@@ -78,12 +93,12 @@ export async function POST(
where: { role: { in: ["admin", "curator"] } }, where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true }, select: { email: true, name: true },
}); });
await Promise.all( void Promise.all(
staff.map((s) => staff.map((s) =>
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title) sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
) )
); );
} }
return NextResponse.json(message, { status: 201 }); return NextResponse.json(msg, { status: 201 });
} }
+7 -2
View File
@@ -53,7 +53,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 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 { const { title, text, courseId } = body as {
title: string; title: string;
text: string; text: string;
@@ -82,7 +87,7 @@ export async function POST(req: NextRequest) {
where: { role: { in: ["admin", "curator"] } }, where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true }, select: { email: true, name: true },
}); });
await Promise.all( void Promise.all(
staff.map((s) => staff.map((s) =>
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim()) sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
) )
+12 -1
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button"; import { LogoutButton } from "@/components/layout/logout-button";
import { AdminShell } from "@/components/admin/admin-shell"; import { AdminShell } from "@/components/admin/admin-shell";
import { getSetting } from "@/lib/settings"; import { getSetting } from "@/lib/settings";
import { prisma } from "@/lib/prisma";
export default async function CuratorLayout({ children }: { children: React.ReactNode }) { export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); 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 // Admin uses the admin shell with sidebar
if (session.user.role === "admin") { if (session.user.role === "admin") {
return <AdminShell userName={session.user.name}>{children}</AdminShell>; const questionsBadge = await prisma.studentQuestion.count({
where: {
messages: {
some: {
isRead: false,
author: { role: "student" },
},
},
},
});
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
} }
return ( return (
@@ -295,7 +295,7 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
{msg.files.map((f) => ( {msg.files.map((f) => (
<a <a
key={f.url} key={f.url}
href={f.url} href={f.url.startsWith("https://") ? f.url : "#"}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm" className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
+1 -1
View File
@@ -146,7 +146,7 @@ export function QuestionThread({
{msg.files.map((f, i) => ( {msg.files.map((f, i) => (
<a <a
key={i} key={i}
href={f.url} href={f.url.startsWith("https://") ? f.url : "#"}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm" className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"