diff --git a/src/app/api/questions/[id]/messages/route.ts b/src/app/api/questions/[id]/messages/route.ts index 3dd8f64..0494034 100644 --- a/src/app/api/questions/[id]/messages/route.ts +++ b/src/app/api/questions/[id]/messages/route.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email"; +import { isAllowedPublicUrl } from "@/lib/s3"; interface FileAttachment { name: string; @@ -10,13 +11,6 @@ interface FileAttachment { 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( req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -52,14 +46,11 @@ export async function POST( return NextResponse.json({ error: "text is required" }, { status: 400 }); } - const s3Prefix = buildS3Prefix(); const safeFiles = files ?.filter( (f) => typeof f.name === "string" && - typeof f.url === "string" && - f.url.startsWith("https://") && - f.url.startsWith(s3Prefix) && + isAllowedPublicUrl(f.url) && typeof f.size === "number" ) .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); diff --git a/src/app/api/questions/route.ts b/src/app/api/questions/route.ts index ec0c448..abc5648 100644 --- a/src/app/api/questions/route.ts +++ b/src/app/api/questions/route.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { sendQuestionCreatedEmail } from "@/lib/email"; +import { isAllowedPublicUrl } from "@/lib/s3"; interface FileAttachment { name: string; @@ -10,12 +11,6 @@ interface FileAttachment { size: number; } -function buildS3Prefix(): string { - const endpoint = process.env.S3_ENDPOINT ?? ""; - const bucket = process.env.S3_BUCKET ?? ""; - return `${endpoint}/${bucket}/`; -} - export async function GET() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -82,14 +77,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "title and text are required" }, { status: 400 }); } - const s3Prefix = buildS3Prefix(); const safeFiles = files ?.filter( (f) => typeof f.name === "string" && - typeof f.url === "string" && - f.url.startsWith("https://") && - f.url.startsWith(s3Prefix) && + isAllowedPublicUrl(f.url) && typeof f.size === "number" ) .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 92caa39..25450f9 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -19,6 +19,21 @@ export function getPublicUrl(key: string): string { return `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`; } +/** + * Валидна ли публичная ссылка на наш загруженный файл. Принимает И CDN-домен + * (`S3_CDN_URL`, напр. files.second-brain.ru), И прямой S3-эндпоинт — иначе + * после включения CDN ссылки от uploadFile (getPublicUrl) перестают проходить + * валидацию (вложения молча отбрасываются). Нужна, чтобы клиент не подсунул + * произвольный URL — только наши бакет/CDN. + */ +export function isAllowedPublicUrl(url: string): boolean { + if (typeof url !== "string" || !url.startsWith("https://")) return false; + const prefixes: string[] = []; + if (process.env.S3_CDN_URL) prefixes.push(`${process.env.S3_CDN_URL}/`); + if (process.env.S3_ENDPOINT) prefixes.push(`${process.env.S3_ENDPOINT}/${BUCKET}/`); + return prefixes.some((p) => url.startsWith(p)); +} + /** * Заголовок Content-Disposition для принудительного скачивания с человеческим * именем. RFC 5987: ASCII-фолбэк + UTF-8 (кириллица percent-encoded).