Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/app/_components/media/media-shared.ts
Original file line number Diff line number Diff line change
@@ -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()}`;
}
124 changes: 124 additions & 0 deletions src/app/_components/media/use-media-download.ts
Original file line number Diff line number Diff line change
@@ -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<MediaItem, "url" | "name" | "extension">;

/** 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<string>, 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<string>();
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 };
}
8 changes: 6 additions & 2 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -35,7 +39,7 @@ const validTypesByFolder: Record<string, string[]> = {
"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": [
Expand All @@ -54,7 +58,7 @@ const validTypesByFolder: Record<string, string[]> = {
const maxSizeByFolder: Record<string, number> = {
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,
Expand Down
36 changes: 36 additions & 0 deletions src/app/api/uploads/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 });
Expand Down
53 changes: 40 additions & 13 deletions src/app/dashboard/media/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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;
}

Expand All @@ -334,19 +344,23 @@ export default function DashboardMediaPage() {
body: formData,
});

if (!response.ok) {
throw new Error("Upload fehlgeschlagen");
}

const data = (await response.json()) as {
url: string;
filename: string;
size: number;
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,
Expand Down Expand Up @@ -587,6 +601,18 @@ export default function DashboardMediaPage() {

{/* Actions Overlay: always visible on touch/mobile, hover on desktop */}
<div className="absolute right-2 bottom-14 flex gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
{/* Download der Originaldatei */}
<Button
onClick={(e) => {
e.stopPropagation();
downloadOne(media);
}}
variant="secondary"
size="icon"
title="Herunterladen"
>
<DownloadIcon className="h-4 w-4" />
</Button>
{/* Re-crop (real crop) for images */}
{media.mimeType.startsWith("image/") && isReviewer && (
<Button
Expand Down Expand Up @@ -697,9 +723,12 @@ export default function DashboardMediaPage() {
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
accept="image/*,video/*,audio/*,application/pdf"
accept={MEDIA_UPLOAD_ACCEPT}
className="dark:bg-dark-background dark:border-dark-border dark:text-dark-text w-full rounded-lg border border-gray-300 px-4 py-2"
/>
<p className="dark:text-dark-muted mt-1 text-xs text-gray-500">
{MEDIA_UPLOAD_EXTENSIONS_LABEL}, bis {MEDIA_UPLOAD_MAX_LABEL}
</p>
{isUploading && (
<p className="mt-1 text-sm text-gray-500">Lädt hoch...</p>
)}
Expand Down Expand Up @@ -830,14 +859,12 @@ export default function DashboardMediaPage() {
<p className="dark:text-dark-text mt-4 text-lg font-medium">
{previewItem.name}
</p>
<a
href={previewItem.url}
target="_blank"
rel="noopener noreferrer"
<button
onClick={() => downloadOne(previewItem)}
className="text-primary mt-2 inline-block hover:underline"
>
Herunterladen
</a>
</button>
</div>
)}

Expand Down
30 changes: 30 additions & 0 deletions src/lib/media-upload.ts
Original file line number Diff line number Diff line change
@@ -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);
}