Force lesson materials to download as attachment with readable filename
Direct CDN links opened PDFs inline (no Content-Disposition), which reads as 'can't download' behind popup blockers / on mobile. New proxy route streams the file with attachment + human name; lesson link points to it.
This commit is contained in:
@@ -149,9 +149,8 @@ export default async function LessonPage({ params }: Props) {
|
||||
{lesson.files.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/api/lesson-files/${file.id}/download`}
|
||||
download={file.name}
|
||||
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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() : "";
|
||||
}
|
||||
|
||||
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 upstream = await fetch(file.url);
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return NextResponse.json({ error: "file_unavailable" }, { status: 502 });
|
||||
}
|
||||
|
||||
const ext = extFromUrl(file.url);
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user