From 282452680ce2fd040c3e4e85b650be951e048051 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Fri, 11 Sep 2026 16:39:20 +0200 Subject: [PATCH] feat(medien): Dateien herunterladen, Upload-Grenzen vereinheitlichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus der Übersicht ließ sich eine Datei bisher nur über Rechtsklick sichern, und zwar unter ihrem Speichernamen ("bild-DFbip-176….jpg"). Kachel und Vorschau haben jetzt einen Download-Knopf. Dafür versteht die Upload-Route `?download=1`: sie setzt dann `Content-Disposition: attachment` samt lesbarem Dateinamen aus `?name=`. Ohne den Parameter bleibt alles wie bisher, damit Bilder weiter im `` landen. Der Wunschname ist Nutzereingabe und wird über eine Positivliste gefiltert; Pfadtrenner und Steuerzeichen fallen weg, Umlaute und Klammern bleiben. Außerdem standen die Upload-Grenzen doppelt im Code und wichen voneinander ab: das Formular bot Video, Audio und PDF an und ließ 50 MB durch, die Route nimmt Bilder bis 10 MB. Alles dazwischen endete in einem nackten "Upload fehlgeschlagen", weil die Antwort der Route verworfen wurde. Die Grenzen liegen jetzt einmal in `lib/media-upload.ts`, Formular und Route lesen sie beide, und die Begründung der Route erreicht die Oberfläche. --- src/app/_components/media/media-shared.ts | 32 +++++ .../_components/media/use-media-download.ts | 124 ++++++++++++++++++ src/app/api/upload/route.ts | 8 +- src/app/api/uploads/[...path]/route.ts | 36 +++++ src/app/dashboard/media/page.tsx | 53 ++++++-- src/lib/media-upload.ts | 30 +++++ 6 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 src/app/_components/media/media-shared.ts create mode 100644 src/app/_components/media/use-media-download.ts create mode 100644 src/lib/media-upload.ts diff --git a/src/app/_components/media/media-shared.ts b/src/app/_components/media/media-shared.ts new file mode 100644 index 00000000..d6f5b816 --- /dev/null +++ b/src/app/_components/media/media-shared.ts @@ -0,0 +1,32 @@ +import type { RouterOutputs } from "@/trpc/react"; + +export type MediaItem = RouterOutputs["media"]["getAll"]["media"][number]; + +/** + * Der Name, unter dem eine Datei im Downloads-Ordner landen soll: der gepflegte + * Medienname plus die Endung der tatsächlichen Datei. Auf der Platte steht der + * entstellte Speichername ("bild-DFbip-176….jpg"), den niemand wiederfindet. + */ +export function downloadFileName(item: { + name: string; + extension: string; +}): string { + const base = item.name.replace(/\.[^/.]+$/, "").trim() || "download"; + return item.extension ? `${base}.${item.extension}` : base; +} + +/** + * Download-URL. Die Route liefert Bilder sonst zur Anzeige aus; `?download=1` + * setzt `Content-Disposition: attachment`, `name` den lesbaren Dateinamen. + */ +export function downloadUrl(item: { + url: string; + name: string; + extension: string; +}): string { + const params = new URLSearchParams({ + download: "1", + name: downloadFileName(item), + }); + return `${item.url}?${params.toString()}`; +} diff --git a/src/app/_components/media/use-media-download.ts b/src/app/_components/media/use-media-download.ts new file mode 100644 index 00000000..10e17244 --- /dev/null +++ b/src/app/_components/media/use-media-download.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useToast } from "@/app/_components/ui/toast"; +import { downloadFileName, downloadUrl, type MediaItem } from "./media-shared"; + +type DownloadableMedia = Pick; + +/** Einen Blob im Browser als Datei speichern. */ +function saveBlob(blob: Blob, filename: string) { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +} + +/** + * Damit in einem ZIP nicht zwei Einträge „Probe.jpg“ heißen und einander + * überschreiben — Medien dürfen denselben Namen tragen, Dateien im Archiv nicht. + */ +function uniqueName(taken: Set, wanted: string): string { + if (!taken.has(wanted)) { + taken.add(wanted); + return wanted; + } + const dot = wanted.lastIndexOf("."); + const base = dot > 0 ? wanted.slice(0, dot) : wanted; + const extension = dot > 0 ? wanted.slice(dot) : ""; + let counter = 2; + while (taken.has(`${base} (${counter})${extension}`)) counter += 1; + const name = `${base} (${counter})${extension}`; + taken.add(name); + return name; +} + +/** + * Herunterladen einzelner Medien und ganzer Auswahlen. + * + * Eine Datei geht direkt über die Upload-Route — kein Umweg über den + * Arbeitsspeicher. Mehrere werden im Browser zu einem ZIP gepackt: JSZip liegt + * ohnehin im Bündel (Social-Media-Export), und der Server muss so nicht 200 + * Bilder gleichzeitig im RAM halten. + */ +export function useMediaDownload() { + const toast = useToast(); + const [isBundling, setIsBundling] = useState(false); + const [bundleProgress, setBundleProgress] = useState(0); + + const downloadOne = useCallback((item: DownloadableMedia) => { + const anchor = document.createElement("a"); + anchor.href = downloadUrl(item); + anchor.download = downloadFileName(item); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + }, []); + + const downloadMany = useCallback( + async (items: DownloadableMedia[], zipName = "medien") => { + if (items.length === 0) return; + if (items.length === 1) { + downloadOne(items[0]!); + return; + } + + setIsBundling(true); + setBundleProgress(0); + try { + // Erst beim Klick geladen: JSZip ist ~100 kB, die niemand mitschleppen + // soll, der die Seite nur anschaut. + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + const taken = new Set(); + const failed: string[] = []; + + for (const [index, item] of items.entries()) { + try { + const response = await fetch(downloadUrl(item)); + if (!response.ok) throw new Error(String(response.status)); + zip.file( + uniqueName(taken, downloadFileName(item)), + await response.blob(), + ); + } catch { + failed.push(item.name); + } + setBundleProgress(Math.round(((index + 1) / items.length) * 100)); + } + + if (taken.size === 0) { + toast.error("Keine der ausgewählten Dateien konnte geladen werden."); + return; + } + + const blob = await zip.generateAsync({ type: "blob" }); + saveBlob(blob, `${zipName}.zip`); + + if (failed.length > 0) { + toast.warning( + `${taken.size} von ${items.length} Dateien heruntergeladen. Nicht gefunden: ${failed + .slice(0, 3) + .join(", ")}${failed.length > 3 ? " …" : ""}`, + ); + } else { + toast.success(`${taken.size} Dateien als ZIP heruntergeladen`); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Download fehlgeschlagen", + ); + } finally { + setIsBundling(false); + setBundleProgress(0); + } + }, + [downloadOne, toast], + ); + + return { downloadOne, downloadMany, isBundling, bundleProgress }; +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index d61f329b..0dd21d71 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -3,6 +3,10 @@ import { writeFile, mkdir } from "fs/promises"; import { join } from "path"; import { auth } from "@/server/better-auth"; import { UPLOADS_ROOT } from "@/server/utils/uploads-dir"; +import { + MEDIA_UPLOAD_MAX_BYTES, + MEDIA_UPLOAD_MIME_TYPES, +} from "@/lib/media-upload"; import { createLogger } from "@/server/utils/logger"; @@ -35,7 +39,7 @@ const validTypesByFolder: Record = { "audio/wav", "audio/ogg", ], - media: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"], + media: [...MEDIA_UPLOAD_MIME_TYPES], // Attachments for course mails. No audio: these travel inside the message, // where a 30 MB recording would just bounce off the recipients' mailboxes. "course-mail": [ @@ -54,7 +58,7 @@ const validTypesByFolder: Record = { const maxSizeByFolder: Record = { profiles: 5 * 1024 * 1024, downloads: 50 * 1024 * 1024, - media: 10 * 1024 * 1024, + media: MEDIA_UPLOAD_MAX_BYTES, // Per file; the send mutation additionally caps the combined size, since // mail servers reject the whole message once it grows past ~25 MB. "course-mail": 10 * 1024 * 1024, diff --git a/src/app/api/uploads/[...path]/route.ts b/src/app/api/uploads/[...path]/route.ts index ede8a414..a201d3c2 100644 --- a/src/app/api/uploads/[...path]/route.ts +++ b/src/app/api/uploads/[...path]/route.ts @@ -39,6 +39,33 @@ function getMimeType(filename: string): string { return mimeTypes[ext] ?? "application/octet-stream"; } +/** + * Dateiname für `Content-Disposition`. Der Wunschname kommt aus der Query und + * ist damit Nutzereingabe: Pfadtrenner und Steuerzeichen fliegen raus, und die + * Endung der tatsächlichen Datei wird angehängt, damit ein umbenanntes Bild + * nicht als endungsloser Brocken im Downloads-Ordner landet. + */ +function downloadFilename(requested: string | null, filePath: string): string { + const storedName = filePath.split("/").pop() ?? "download"; + const extension = storedName.includes(".") + ? `.${storedName.split(".").pop()!.toLowerCase()}` + : ""; + + // Positivliste statt Sperrliste: was kein Buchstabe, keine Ziffer und + // kein harmloses Satzzeichen ist, wird zum Leerzeichen. Das erwischt + // Pfadtrenner und Steuerzeichen gleichermaßen, ohne Umlaute zu opfern. + const cleaned = (requested ?? "") + .replace(/[^\p{L}\p{N} ._\-()+&,']/gu, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); + + if (!cleaned) return storedName; + return cleaned.toLowerCase().endsWith(extension) + ? cleaned + : `${cleaned}${extension}`; +} + /** * Enforce the visibility rules stored in the database before streaming a * file. Mirrors the metadata rules of the materials/media routers: @@ -145,6 +172,15 @@ export async function GET( // the browser might interpret (svg/html/xml → XSS on this origin). if (mimeType === "application/octet-stream") { headers["Content-Disposition"] = "attachment"; + } else if (request.nextUrl.searchParams.has("download")) { + // `?download=1` macht aus der Vorschau-URL einen echten Download. Der + // Name kommt als Parameter mit, weil auf der Platte der entstellte + // Speichername steht ("bild-DFbip-176…jpg") und niemand den im + // Downloads-Ordner wiederfindet. + headers["Content-Disposition"] = + `attachment; filename*=UTF-8''${encodeURIComponent( + downloadFilename(request.nextUrl.searchParams.get("name"), filePath), + )}`; } return new NextResponse(webStream, { headers }); diff --git a/src/app/dashboard/media/page.tsx b/src/app/dashboard/media/page.tsx index a1e896b8..73f88627 100644 --- a/src/app/dashboard/media/page.tsx +++ b/src/app/dashboard/media/page.tsx @@ -10,12 +10,19 @@ import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; import { ContentStatus } from "~/generated/prisma/enums"; import { useToast } from "@/app/_components/ui/toast"; -import { CheckIcon, ImageIcon, PlusIcon } from "lucide-react"; +import { CheckIcon, DownloadIcon, ImageIcon, PlusIcon } from "lucide-react"; import { CropIcon, EditIcon, XIcon } from "lucide-react"; import { TrashIcon } from "lucide-react"; import ImageCropEditor from "@/app/_components/posts/image-crop-editor"; import { Button, Input, Label, Select } from "@/app/_components/ui"; import { formatMediaTags, splitMediaTags } from "@/lib/media-tags"; +import { useMediaDownload } from "@/app/_components/media/use-media-download"; +import { + MEDIA_UPLOAD_ACCEPT, + MEDIA_UPLOAD_EXTENSIONS_LABEL, + MEDIA_UPLOAD_MAX_BYTES, + MEDIA_UPLOAD_MAX_LABEL, +} from "@/lib/media-upload"; import { ScrollableModal, ScrollableModalCard, @@ -71,6 +78,7 @@ export default function DashboardMediaPage() { const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); const toast = useToast(); + const { downloadOne } = useMediaDownload(); const [search, setSearch] = useState(""); const [mimeTypeFilter, setMimeTypeFilter] = useState(""); @@ -316,8 +324,10 @@ export default function DashboardMediaPage() { const file = e.target.files?.[0]; if (!file) return; - if (file.size > 50 * 1024 * 1024) { - setUploadError("Die Datei ist zu groß. Maximal 50MB erlaubt."); + if (file.size > MEDIA_UPLOAD_MAX_BYTES) { + setUploadError( + `Die Datei ist zu groß. Maximal ${MEDIA_UPLOAD_MAX_LABEL} erlaubt.`, + ); return; } @@ -334,10 +344,6 @@ export default function DashboardMediaPage() { body: formData, }); - if (!response.ok) { - throw new Error("Upload fehlgeschlagen"); - } - const data = (await response.json()) as { url: string; filename: string; @@ -345,8 +351,16 @@ export default function DashboardMediaPage() { mimeType: string; path: string; extension: string; + error?: string; }; + if (!response.ok) { + // Die Route sagt genau, was nicht stimmt (Typ, Größe, Inhalt). Diese + // Meldung ging bisher verloren und wurde zu einem pauschalen + // "Upload fehlgeschlagen". + throw new Error(data.error ?? "Upload fehlgeschlagen"); + } + setUploadedFile({ url: data.url, filename: data.filename, @@ -587,6 +601,18 @@ export default function DashboardMediaPage() { {/* Actions Overlay: always visible on touch/mobile, hover on desktop */}
+ {/* Download der Originaldatei */} + {/* Re-crop (real crop) for images */} {media.mimeType.startsWith("image/") && isReviewer && (
)} diff --git a/src/lib/media-upload.ts b/src/lib/media-upload.ts new file mode 100644 index 00000000..b24e4f05 --- /dev/null +++ b/src/lib/media-upload.ts @@ -0,0 +1,30 @@ +/** + * Was die Medienverwaltung annimmt — einmal notiert, von der Upload-Route und + * vom Dialog gelesen. + * + * Vorher standen die Grenzen doppelt im Code und wichen voneinander ab: das + * Formular bot Video, Audio und PDF an und ließ 50 MB durch, die Route nahm + * Bilder bis 10 MB. Alles dazwischen endete in einem nackten + * „Upload fehlgeschlagen“. + */ +export const MEDIA_UPLOAD_MIME_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/gif", +] as const; + +/** Wert für `accept` am Datei-Feld. */ +export const MEDIA_UPLOAD_ACCEPT = MEDIA_UPLOAD_MIME_TYPES.join(","); + +export const MEDIA_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; + +export const MEDIA_UPLOAD_MAX_LABEL = "10 MB"; + +/** Für Fehlermeldungen: „JPG, PNG, WebP oder GIF“. */ +export const MEDIA_UPLOAD_EXTENSIONS_LABEL = "JPG, PNG, WebP oder GIF"; + +export function isAllowedMediaUpload(type: string): boolean { + return (MEDIA_UPLOAD_MIME_TYPES as readonly string[]).includes(type); +}