From 98cc023709f3a7aa87684a42cc5f8e2726f6a1b2 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 8 Jul 2026 19:28:26 +0500 Subject: [PATCH] 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. --- .../[slug]/lessons/[lessonId]/page.tsx | 5 +- .../api/lesson-files/[id]/download/route.ts | 92 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/app/api/lesson-files/[id]/download/route.ts diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index 34ff983..ec0ccd8 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -149,9 +149,8 @@ export default async function LessonPage({ params }: Props) { {lesson.files.map((file) => ( diff --git a/src/app/api/lesson-files/[id]/download/route.ts b/src/app/api/lesson-files/[id]/download/route.ts new file mode 100644 index 0000000..f1e1070 --- /dev/null +++ b/src/app/api/lesson-files/[id]/download/route.ts @@ -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 = { + 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: "." (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 }); +}