import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; // Streams a lesson material from the CDN with a forced attachment header and a // human-readable filename. Direct CDN links open the PDF inline in a new tab // (no Content-Disposition), which reads as "can't download" for users with a // popup blocker or on mobile. This route makes the browser save the file. const CONTENT_TYPES: Record = { pdf: "application/pdf", zip: "application/zip", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", }; function extFromUrl(url: string): string { const clean = url.split("?")[0]; const dot = clean.lastIndexOf("."); 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 }> } ) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } const { id } = await params; const file = await prisma.lessonFile.findUnique({ where: { id }, include: { lesson: { include: { module: { select: { courseId: true } } } } }, }); if (!file) { return NextResponse.json({ error: "not_found" }, { status: 404 }); } // Access: staff always; students must be enrolled in the file's course. const role = session.user.role; if (role !== "admin" && role !== "curator") { const enrollment = await prisma.courseEnrollment.findFirst({ where: { userId: session.user.id, courseId: file.lesson.module.courseId }, select: { userId: true }, }); if (!enrollment) { return NextResponse.json({ error: "forbidden" }, { status: 403 }); } } 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 }); } // 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] ?? "application/octet-stream"; // Build a readable filename: "." (avoid doubling the extension). let downloadName = file.name.trim(); if (ext && !downloadName.toLowerCase().endsWith(`.${ext}`)) { downloadName += `.${ext}`; } const asciiName = downloadName.replace(/[^\x20-\x7E]+/g, "_").replace(/"/g, ""); const disposition = `attachment; filename="${asciiName}"; ` + `filename*=UTF-8''${encodeURIComponent(downloadName)}`; const respHeaders: Record = { "Content-Type": contentType, "Content-Disposition": disposition, "Cache-Control": "private, no-store", }; const len = upstream.headers.get("content-length"); if (len) respHeaders["Content-Length"] = len; return new NextResponse(upstream.body, { status: 200, headers: respHeaders }); }