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