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