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 { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendQuestionCreatedEmail, sendQuestionReplyEmail } from "@/lib/email";
|
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
|
||||||
|
|
||||||
interface FileAttachment {
|
interface FileAttachment {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,6 +29,9 @@ export async function POST(
|
|||||||
if (!isStaff && question.userId !== session.user.id) {
|
if (!isStaff && question.userId !== session.user.id) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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 body = await req.json();
|
||||||
const { text, files } = body as { text: string; files?: FileAttachment[] };
|
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 });
|
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({
|
const message = await prisma.studentQuestionMessage.create({
|
||||||
data: {
|
data: {
|
||||||
questionId: id,
|
questionId: id,
|
||||||
authorId: session.user.id,
|
authorId: session.user.id,
|
||||||
text: text.trim(),
|
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 } } },
|
include: { author: { select: { id: true, name: true, role: true } } },
|
||||||
});
|
});
|
||||||
@@ -68,7 +80,7 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
staff.map((s) =>
|
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 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({
|
const questions = await prisma.studentQuestion.findMany({
|
||||||
where: isStaff ? undefined : { userId: session.user.id },
|
where: isStaff ? undefined : { userId: session.user.id },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: userSelect },
|
||||||
course: { select: { id: true, title: true } },
|
course: { select: { id: true, title: true } },
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { randomUUID } from "crypto";
|
|||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
"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
|
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 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 key = `questions/tmp/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const url = await uploadFile(key, buffer, file.type);
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|||||||
@@ -271,6 +271,27 @@ export async function sendQuestionReplyEmail(
|
|||||||
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
}).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(
|
export async function sendHomeworkUpdatedEmail(
|
||||||
to: string,
|
to: string,
|
||||||
recipientName: string,
|
recipientName: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user