Fix API routes: closed-question guard, file validation, files sanitization, follow-up email

- Add CLOSED status guard in messages POST (returns 409)
- Add extension allowlist check in upload route + text/x-markdown MIME type
- Sanitize files JSON array before DB write
- Add sendQuestionFollowUpEmail helper and use it for student follow-up replies
- Scope email field to staff only in questions list query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:28:08 +05:00
parent f2946db57a
commit a9e6272d2d
4 changed files with 47 additions and 5 deletions
+15 -3
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendQuestionCreatedEmail, sendQuestionReplyEmail } from "@/lib/email";
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
interface FileAttachment {
name: string;
@@ -29,6 +29,9 @@ export async function POST(
if (!isStaff && question.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (question.status === "CLOSED") {
return NextResponse.json({ error: "Question is closed" }, { status: 409 });
}
const body = await req.json();
const { text, files } = body as { text: string; files?: FileAttachment[] };
@@ -37,12 +40,21 @@ export async function POST(
return NextResponse.json({ error: "text is required" }, { status: 400 });
}
const safeFiles = files
?.filter(
(f) =>
typeof f.name === "string" &&
typeof f.url === "string" &&
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: files?.length ? (files as object[]) : undefined,
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
},
include: { author: { select: { id: true, name: true, role: true } } },
});
@@ -68,7 +80,7 @@ export async function POST(
});
await Promise.all(
staff.map((s) =>
sendQuestionCreatedEmail(s.email, s.name, session.user.name, question.title)
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
)
);
}
+5 -1
View File
@@ -10,10 +10,14 @@ export async function GET() {
const isStaff = session.user.role === "admin" || session.user.role === "curator";
const userSelect = isStaff
? { id: true as const, name: true as const, email: true as const }
: { id: true as const, name: true as const };
const questions = await prisma.studentQuestion.findMany({
where: isStaff ? undefined : { userId: session.user.id },
include: {
user: { select: { id: true, name: true, email: true } },
user: { select: userSelect },
course: { select: { id: true, title: true } },
_count: {
select: {
+6 -1
View File
@@ -6,7 +6,7 @@ import { randomUUID } from "crypto";
const ALLOWED_TYPES = new Set([
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "text/markdown", "text/plain",
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
]);
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
@@ -30,6 +30,11 @@ export async function POST(req: NextRequest) {
}
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
if (!ALLOWED_EXTS.has(ext)) {
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
}
const key = `questions/tmp/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
+21
View File
@@ -271,6 +271,27 @@ export async function sendQuestionReplyEmail(
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
}
export async function sendQuestionFollowUpEmail(
to: string,
recipientName: string,
studentName: string,
questionTitle: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Новый ответ от студента — ${questionTitle}`,
html: base(`
<p ${p}>Привет, ${recipientName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> добавил сообщение в вопрос:</p>
${quote(questionTitle)}
<p ${pLast}>Откройте панель, чтобы ответить:</p>
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
`, school),
}).catch((e) => console.error("[email] sendQuestionFollowUpEmail:", e));
}
export async function sendHomeworkUpdatedEmail(
to: string,
recipientName: string,