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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user