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