From a9e6272d2d74b3c5fb319b8272826b9bc3e7fe7c Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 19 May 2026 13:28:08 +0500 Subject: [PATCH] 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 --- src/app/api/questions/[id]/messages/route.ts | 18 ++++++++++++++--- src/app/api/questions/route.ts | 6 +++++- src/app/api/student/question-upload/route.ts | 7 ++++++- src/lib/email.ts | 21 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/app/api/questions/[id]/messages/route.ts b/src/app/api/questions/[id]/messages/route.ts index 58c0bbb..4c95ad8 100644 --- a/src/app/api/questions/[id]/messages/route.ts +++ b/src/app/api/questions/[id]/messages/route.ts @@ -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) ) ); } diff --git a/src/app/api/questions/route.ts b/src/app/api/questions/route.ts index 0c88d8e..b176fca 100644 --- a/src/app/api/questions/route.ts +++ b/src/app/api/questions/route.ts @@ -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: { diff --git a/src/app/api/student/question-upload/route.ts b/src/app/api/student/question-upload/route.ts index 7a55c65..aafe1ea 100644 --- a/src/app/api/student/question-upload/route.ts +++ b/src/app/api/student/question-upload/route.ts @@ -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); diff --git a/src/lib/email.ts b/src/lib/email.ts index c31a5a0..47cd196 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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(` +

Привет, ${recipientName}!

+

Студент ${studentName} добавил сообщение в вопрос:

+ ${quote(questionTitle)} +

Откройте панель, чтобы ответить:

+ ${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")} + `, school), + }).catch((e) => console.error("[email] sendQuestionFollowUpEmail:", e)); +} + export async function sendHomeworkUpdatedEmail( to: string, recipientName: string,