diff --git a/src/app/api/lesson-files/[id]/download/route.ts b/src/app/api/lesson-files/[id]/download/route.ts index f1e1070..da73889 100644 --- a/src/app/api/lesson-files/[id]/download/route.ts +++ b/src/app/api/lesson-files/[id]/download/route.ts @@ -29,6 +29,17 @@ function extFromUrl(url: string): string { return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : ""; } +// Types a browser renders inline (so the direct link "opens in a tab" instead of +// downloading). Only these need proxying with a forced attachment header. ZIP, +// DOCX, etc. already download natively → redirect straight to the CDN. +const INLINE_EXT = new Set([ + "pdf", "png", "jpg", "jpeg", "webp", "gif", "svg", "avif", + "txt", "htm", "html", "mp4", "mov", "webm", "mp3", "wav", +]); +// Above this, don't proxy through the app — redirect so the file streams from +// the CDN/Caddy with Range support (e.g. the 157 MB Second Brain Vault archive). +const PROXY_MAX_BYTES = 50 * 1024 * 1024; + export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -59,12 +70,26 @@ export async function GET( } } + const ext = extFromUrl(file.url); + + // Non-inline types download natively — send the browser straight to the CDN + // (native download, Range/resume, no app load). Auth is already enforced above. + if (!INLINE_EXT.has(ext)) { + return NextResponse.redirect(file.url, 302); + } + const upstream = await fetch(file.url); if (!upstream.ok || !upstream.body) { return NextResponse.json({ error: "file_unavailable" }, { status: 502 }); } - const ext = extFromUrl(file.url); + // Guard: a very large inline file still shouldn't stream through the app. + const upstreamLen = Number(upstream.headers.get("content-length") ?? "0"); + if (upstreamLen > PROXY_MAX_BYTES) { + await upstream.body.cancel(); + return NextResponse.redirect(file.url, 302); + } + const contentType = upstream.headers.get("content-type") ?? CONTENT_TYPES[ext] ??