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:
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user