ZIP/DOCX etc. download natively — no need to force attachment; and the 157 MB Second Brain Vault archive must stream from Caddy with Range, not through the app. Proxy attachment stays for small inline types (PDF/images).
118 lines
4.3 KiB
TypeScript
118 lines
4.3 KiB
TypeScript
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<string, string> = {
|
|
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: "<name>.<ext>" (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<string, string> = {
|
|
"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 });
|
|
}
|