Force download for lesson materials (Content-Disposition: attachment)

Lesson files were served inline (no Content-Disposition) → browsers
opened PDFs in a new tab instead of downloading; flaky across
browsers/providers. Set Content-Disposition: attachment with the
human-readable filename (RFC 5987 for Cyrillic) on upload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:06:51 +05:00
parent a7abf454d9
commit 5d4630519c
2 changed files with 17 additions and 3 deletions
+5 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { uploadFile, deleteFile } from "@/lib/s3"; import { uploadFile, deleteFile, attachmentDisposition } from "@/lib/s3";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
async function requireAdmin() { async function requireAdmin() {
@@ -31,7 +31,10 @@ export async function POST(req: NextRequest) {
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`; const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type); // Материалы урока отдаём как attachment (надёжное скачивание во всех браузерах)
// с человеческим именем из name (а не UUID).
const downloadName = name.toLowerCase().endsWith(`.${ext.toLowerCase()}`) ? name : `${name}.${ext}`;
const url = await uploadFile(key, buffer, file.type, attachmentDisposition(downloadName));
if (existing) { if (existing) {
const oldKey = existing.url.split(`/${process.env.S3_BUCKET}/`)[1]; const oldKey = existing.url.split(`/${process.env.S3_BUCKET}/`)[1];
+12 -1
View File
@@ -17,10 +17,20 @@ export function getPublicUrl(key: string): string {
return `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`; return `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`;
} }
/**
* Заголовок Content-Disposition для принудительного скачивания с человеческим
* именем. RFC 5987: ASCII-фолбэк + UTF-8 (кириллица percent-encoded).
*/
export function attachmentDisposition(filename: string): string {
const ascii = filename.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "'");
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}
export async function uploadFile( export async function uploadFile(
key: string, key: string,
body: Buffer | Uint8Array, body: Buffer | Uint8Array,
contentType: string contentType: string,
contentDisposition?: string
): Promise<string> { ): Promise<string> {
await s3.send( await s3.send(
new PutObjectCommand({ new PutObjectCommand({
@@ -28,6 +38,7 @@ export async function uploadFile(
Key: key, Key: key,
Body: body, Body: body,
ContentType: contentType, ContentType: contentType,
...(contentDisposition ? { ContentDisposition: contentDisposition } : {}),
}) })
); );
return getPublicUrl(key); return getPublicUrl(key);