Accept CDN URLs for question attachments

Attachments uploaded via question-upload get a CDN URL
(files.second-brain.ru via S3_CDN_URL), but the validator only accepted
the direct S3 endpoint prefix — so every attachment was silently dropped
(message text saved, file lost). Add isAllowedPublicUrl (CDN + direct S3)
in lib/s3 and use it in both question routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 15:11:36 +05:00
parent 2436f77de5
commit 9fbb7aea6c
3 changed files with 19 additions and 21 deletions
+2 -11
View File
@@ -3,6 +3,7 @@ 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 { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email"; import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
import { isAllowedPublicUrl } from "@/lib/s3";
interface FileAttachment { interface FileAttachment {
name: string; name: string;
@@ -10,13 +11,6 @@ interface FileAttachment {
size: number; 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( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@@ -52,14 +46,11 @@ export async function POST(
return NextResponse.json({ error: "text is required" }, { status: 400 }); return NextResponse.json({ error: "text is required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files const safeFiles = files
?.filter( ?.filter(
(f) => (f) =>
typeof f.name === "string" && typeof f.name === "string" &&
typeof f.url === "string" && isAllowedPublicUrl(f.url) &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number" typeof f.size === "number"
) )
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
+2 -10
View File
@@ -3,6 +3,7 @@ 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 } from "@/lib/email"; import { sendQuestionCreatedEmail } from "@/lib/email";
import { isAllowedPublicUrl } from "@/lib/s3";
interface FileAttachment { interface FileAttachment {
name: string; name: string;
@@ -10,12 +11,6 @@ interface FileAttachment {
size: number; size: number;
} }
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
return `${endpoint}/${bucket}/`;
}
export async function GET() { export async function GET() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 }); return NextResponse.json({ error: "title and text are required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files const safeFiles = files
?.filter( ?.filter(
(f) => (f) =>
typeof f.name === "string" && typeof f.name === "string" &&
typeof f.url === "string" && isAllowedPublicUrl(f.url) &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number" typeof f.size === "number"
) )
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
+15
View File
@@ -19,6 +19,21 @@ export function getPublicUrl(key: string): string {
return `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`; 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 для принудительного скачивания с человеческим * Заголовок Content-Disposition для принудительного скачивания с человеческим
* именем. RFC 5987: ASCII-фолбэк + UTF-8 (кириллица percent-encoded). * именем. RFC 5987: ASCII-фолбэк + UTF-8 (кириллица percent-encoded).