From 3ddab937e7646f5710f4172c3c507a97825da6b0 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Tue, 11 Aug 2026 18:29:33 +0200 Subject: [PATCH] feat: hochladen aus dem Dashboard -- Bilder, Videos, Ton, PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/dash/upload, eigener Weg neben /api/upload. Der dortige haengt an einem Bearer-Token; wer im Dashboard sitzt, hat eine SITZUNG und keinen Token, und einen Token nur zum Hochladen anzulegen waere ein Geheimnis mehr, das jemand verlieren kann. Mehrere Dateien auf einmal, und JEDE bekommt ihr eigenes Ergebnis mit Pfad, Groesse und Grund im Fehlerfall. Ein "3 von 5 hochgeladen" ohne die Namen der zwei anderen ist genau die Sorte Rueckmeldung, wegen der wir hier neu bauen. Eine kaputte Datei bricht die anderen nicht ab. parseBody({ all: true }) ist noetig: ohne das liefert Hono bei mehreren Feldern gleichen Namens nur das LETZTE, und aus fuenf Dateien wuerde eine. DATEINAMEN WERDEN ZURECHTGEBOGEN, und zwar sichtbar. checkPath laesst "Mein Video (final) 2.MP4" mit Recht nicht durch, also macht safeFilename daraus "mein-video-final-2.mp4" -- Umlaute werden umschrieben und nicht weggeworfen ("Grüße" -> "gruesse", nicht "gr-e"). Die Ergebnisliste zeigt den neuen Pfad neben dem alten Namen. BEIM MESSEN GEFUNDEN: bleibt vom Namen nichts uebrig -- "ЖУРНАЛ.txt", " .png", "★★★.txt" --, hiessen bisher ALLE "datei.txt", und die zweite Datei ueberschrieb die erste stillschweigend. Jetzt haengen die ersten acht Stellen des SHA-256 daran: gleicher Inhalt ergibt denselben Namen, verschiedener Inhalt verschiedene. Nachgemessen mit drei solchen Namen. Der Fortschrittsbalken laeuft ueber XMLHttpRequest und nicht fetch: nur der kennt upload.onprogress, und bei einem 40-MB-Video ist ein Balken der Unterschied zwischen "es tut sich was" und "ist es abgestuerzt?". Co-Authored-By: Claude Opus 5 --- server/src/routes/dash.ts | 136 ++++++++++++++++++ server/src/storage.ts | 47 +++++++ server/ui/src/api.ts | 10 ++ server/ui/src/components/Hochladen.tsx | 185 +++++++++++++++++++++++++ server/ui/src/pages/Galerie.tsx | 9 ++ 5 files changed, 387 insertions(+) create mode 100644 server/ui/src/components/Hochladen.tsx diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 0c5d9a8..b04ad35 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -26,8 +26,12 @@ import { checkPath, deleteFile, kannVorschau, + mimeFor, publicUrlFor, + safeFilename, + sha256, thumbPath, + writeFileAtomic, writeThumb, PathError, } from '../storage.js' @@ -362,6 +366,138 @@ dashRoutes.post('/media/delete', async (c) => { return c.json({ deleted, count: deleted.length }) }) +/** Hochladen aus dem Dashboard. + * + * Ein eigener Weg neben /api/upload, und zwar aus einem Grund: der dortige + * haengt an einem Bearer-Token. Wer im Dashboard sitzt, hat eine SITZUNG und + * keinen Token — und einen Token nur zum Hochladen anzulegen waere ein + * Geheimnis mehr, das jemand verlieren kann. + * + * Mehrere Dateien auf einmal, und JEDE bekommt ihr eigenes Ergebnis. Ein + * "3 von 5 hochgeladen" ohne die Namen der zwei anderen ist genau die Sorte + * Rueckmeldung, wegen der wir hier neu bauen. */ +dashRoutes.post('/upload', async (c) => { + const contentType = c.req.header('content-type') ?? '' + if (!contentType.includes('multipart/form-data')) { + return c.json({ error: 'Erwartet wird multipart/form-data' }, 400) + } + + // { all: true } ist noetig: ohne das liefert Hono bei mehreren Feldern + // gleichen Namens nur das letzte, und aus fuenf Dateien wuerde eine. + const form = await c.req.parseBody({ all: true }) + + const roheDateien = form['file'] + const dateien = (Array.isArray(roheDateien) ? roheDateien : [roheDateien]).filter( + (f): f is File => f instanceof File, + ) + if (dateien.length === 0) return c.json({ error: 'Keine Datei dabei' }, 400) + + const ordnerRoh = typeof form['folder'] === 'string' ? form['folder'].trim() : '' + let ordner = '' + if (ordnerRoh) { + try { + ordner = checkPath(ordnerRoh.replace(/\/+$/, '')) + } catch (err) { + if (err instanceof PathError) { + return c.json({ error: `Ordner: ${err.message}` }, 400) + } + throw err + } + } + + const ueberschreiben = form['overwrite'] !== 'false' + const ergebnisse: { + name: string + ok: boolean + path?: string + url?: string + size?: number + replaced?: boolean + error?: string + }[] = [] + + for (const datei of dateien) { + const name = datei.name || 'datei' + try { + if (datei.size > config.maxUploadBytes) { + ergebnisse.push({ + name, + ok: false, + error: `zu gross (${datei.size} > ${config.maxUploadBytes} Bytes)`, + }) + continue + } + + const data = Buffer.from(await datei.arrayBuffer()) + if (data.length === 0) { + ergebnisse.push({ name, ok: false, error: 'leere Datei' }) + continue + } + + const digest = sha256(data) + // Der Hash als Rettungsanker fuer Namen, von denen nichts uebrig + // bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei + // verschiedene Dateien beide "datei.txt", und die zweite + // ueberschriebe die erste still. Beim Messen genau so passiert. + const dateiname = safeFilename(name, digest.slice(0, 8)) + const path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname) + + const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as + | Media + | undefined + if (vorhanden && !ueberschreiben) { + ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' }) + continue + } + + const mime = datei.type || mimeFor(path) + await writeFileAtomic(path, data) + if (kannVorschau(mime)) await writeThumb(path, data) + + const zeit = now() + if (vorhanden) { + db.prepare( + `UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ? + WHERE id = ?`, + ).run(data.length, digest, mime, zeit, vorhanden.id) + } else { + db.prepare( + `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, ?, ?)`, + ).run(path, data.length, digest, mime, zeit, zeit) + } + + ergebnisse.push({ + name, + ok: true, + path, + url: publicUrlFor(path), + size: data.length, + replaced: Boolean(vorhanden), + }) + } catch (err) { + // Eine kaputte Datei bricht die anderen NICHT ab. Wer fuenf Dateien + // zieht und bei der zweiten alles verliert, zieht sie danach + // einzeln — und das ist unsere Schuld, nicht seine. + ergebnisse.push({ + name, + ok: false, + error: + err instanceof PathError + ? err.message + : err instanceof Error + ? err.message + : 'unbekannter Fehler', + }) + } + } + + const gut = ergebnisse.filter((e) => e.ok).length + // 207 waere formal richtiger, aber jede Zwischenschicht behandelt es wie + // einen Sonderfall. 200 mit ehrlichen Zahlen im Rumpf ist hier nuetzlicher. + return c.json({ hochgeladen: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') diff --git a/server/src/storage.ts b/server/src/storage.ts index 8a9277f..c31fab2 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -88,6 +88,53 @@ export function mimeFor(path: string): string { return MIME[ext] ?? 'application/octet-stream' } +/** Aus einem Dateinamen von der Platte einen zulaessigen Pfadteil machen. + * + * Der Weg vom Dashboard ist ein anderer als der vom Skript: dort schickt + * jemand `X-Path` und weiss genau, was er will. Hier zieht jemand + * "Mein Video (final) 2.MP4" ins Fenster, und `checkPath` wuerde das mit + * Recht ablehnen — Klammern, Leerzeichen, alles nicht erlaubt. + * + * Deshalb wird der Name hier zurechtgebogen, und zwar VORHERSAGBAR: + * Kleinbuchstaben, alles Fremde wird zum Strich, Striche werden + * zusammengefasst. Aus obigem wird "mein-video-final-2.mp4". Wer den + * Dateinamen exakt behalten will, benennt ihn vorher um — das ist ehrlicher, + * als so zu tun, als koennte jeder Name eine URL werden. + * + * `eindeutig` ist der Rettungsanker fuer den Fall, dass vom Namen NICHTS + * uebrig bleibt — bei "ЖУРНАЛ.txt" oder " .png". Ohne ihn hiessen beide + * "datei.txt" bzw. "datei.png", und die zweite Datei ueberschriebe die erste + * stillschweigend. Genau das ist beim Messen passiert. Ueblicherweise gibt + * der Aufrufer die ersten Stellen des SHA-256 mit: gleicher Inhalt ergibt + * denselben Namen, verschiedener Inhalt verschiedene. */ +export function safeFilename(name: string, eindeutig?: string): string { + const punkt = name.lastIndexOf('.') + const roh = punkt > 0 ? name.slice(0, punkt) : name + const endung = punkt > 0 ? name.slice(punkt + 1) : '' + + const zaehmen = (s: string) => + s + .toLowerCase() + // Umlaute NICHT einfach wegwerfen: "grün" wuerde sonst zu "grn". + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[^a-z0-9]+/, '') + .replace(/[-.]+$/, '') + + const stamm = zaehmen(roh) + const ende = zaehmen(endung) + + // Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das + // kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und + // sieht im Ergebnis, was daraus geworden ist. + const basis = stamm || (eindeutig ? `datei-${eindeutig}` : 'datei') + return ende ? `${basis}.${ende}` : basis +} + export function extensionFor(mime: string): string { const base = mime.split(';')[0]?.trim().toLowerCase() for (const [ext, value] of Object.entries(MIME)) { diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index d896226..2d1e38f 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -147,6 +147,16 @@ export type Folders = { wurzel: { files: number; bytes: number } } +export type UploadErgebnis = { + name: string + ok: boolean + path?: string + url?: string + size?: number + replaced?: boolean + error?: string +} + export type ThumbLauf = { erzeugt: number vorhanden: number diff --git a/server/ui/src/components/Hochladen.tsx b/server/ui/src/components/Hochladen.tsx new file mode 100644 index 0000000..a0ed11f --- /dev/null +++ b/server/ui/src/components/Hochladen.tsx @@ -0,0 +1,185 @@ +import { useRef, useState } from 'react' +import { CheckCircle2, Upload, X, XCircle } from 'lucide-react' +import type { UploadErgebnis } from '../api' +import { useStore } from '../store' +import { bytes } from '../format' +import { Knopf } from './Bausteine' + +/** Hochladen aus dem Dashboard. + * + * Bilder, Videos, Ton, PDF — was der Dienst kennt, steht in der MIME-Tabelle + * in storage.ts. Alles andere landet als application/octet-stream und wird + * heruntergeladen statt angezeigt; das ist kein Fehler, sondern die sichere + * Voreinstellung. + * + * JEDE Datei bekommt ihre eigene Zeile mit Ergebnis. "3 von 5 hochgeladen" + * ohne die Namen der zwei anderen ist genau die Sorte Rückmeldung, wegen der + * wir hier neu bauen. */ +export function Hochladen({ ordner, fertig }: { ordner: string; fertig: () => void }) { + const note = useStore((s) => s.note) + const [ueber, setUeber] = useState(false) + const [laeuft, setLaeuft] = useState(false) + const [fortschritt, setFortschritt] = useState(0) + const [ergebnisse, setErgebnisse] = useState(null) + const feld = useRef(null) + + async function schicken(dateien: FileList | File[]) { + const liste = [...dateien] + if (liste.length === 0) return + + setLaeuft(true) + setFortschritt(0) + setErgebnisse(null) + + const form = new FormData() + for (const d of liste) form.append('file', d) + if (ordner) form.append('folder', ordner) + + try { + // XMLHttpRequest und nicht fetch: nur der kennt upload.onprogress. + // Bei einem 40-MB-Video ist ein Balken der Unterschied zwischen + // "es tut sich was" und "ist es abgestürzt?". + const antwort = await new Promise<{ status: number; text: string }>( + (auf, ab) => { + const x = new XMLHttpRequest() + x.open('POST', '/api/dash/upload') + x.withCredentials = true + x.upload.onprogress = (e) => { + if (e.lengthComputable) setFortschritt(e.loaded / e.total) + } + x.onload = () => auf({ status: x.status, text: x.responseText }) + x.onerror = () => ab(new Error('Die Verbindung brach ab')) + x.send(form) + }, + ) + + const daten = JSON.parse(antwort.text) + if (antwort.status !== 200) { + throw new Error(daten?.error ?? `HTTP ${antwort.status}`) + } + + setErgebnisse(daten.ergebnisse) + if (daten.fehlgeschlagen === 0) { + note('ok', `${daten.hochgeladen} hochgeladen.`) + } else { + note( + 'fehler', + `${daten.hochgeladen} hochgeladen, ${daten.fehlgeschlagen} nicht — siehe Liste.`, + ) + } + if (daten.hochgeladen > 0) fertig() + } catch (err) { + note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler') + } finally { + setLaeuft(false) + if (feld.current) feld.current.value = '' + } + } + + return ( +
+
{ + e.preventDefault() + setUeber(true) + }} + onDragLeave={() => setUeber(false)} + onDrop={(e) => { + e.preventDefault() + setUeber(false) + void schicken(e.dataTransfer.files) + }} + onClick={() => feld.current?.click()} + className={`flex cursor-pointer flex-col items-center gap-1 rounded-lg border + border-dashed px-4 py-5 text-center text-sm transition-colors ${ + ueber + ? 'border-akzent bg-akzent/10 text-schrift' + : 'border-rand text-schrift-leise hover:border-akzent/50 hover:text-schrift' + }`} + > + + + Dateien hierher ziehen oder auswählen + + + Ziel:{' '} + {ordner ? `${ordner}/` : '(Wurzel)'} · + Bilder, Videos, Ton, PDF + +
+ + e.target.files && void schicken(e.target.files)} + /> + + {laeuft && ( +
+
+
+ )} + + {ergebnisse && ( +
+
+ Ergebnis + +
+
    + {ergebnisse.map((e, i) => ( +
  • + {e.ok ? ( + + ) : ( + + )} + + {e.path ?? e.name} + {e.ok ? ( + + {' '} + — {bytes(e.size ?? 0)} + {e.replaced && ' (ersetzt)'} + {/* Der zurechtgebogene Name wird + GEZEIGT: wer "Mein Video.MP4" + zieht, soll sehen, dass daraus + "mein-video.mp4" wurde. */} + {e.path && !e.path.endsWith(e.name) && ( + + {' '} + · aus „{e.name}" + + )} + + ) : ( + — {e.error} + )} + +
  • + ))} +
+
+ )} + + {ergebnisse === null && !laeuft && ( + feld.current?.click()} + kind="Dateien wählen" + /> + )} +
+ ) +} diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index f30de9e..c25cad9 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -17,6 +17,7 @@ import { run, useStore } from '../store' import { bytes, istBild, zeit } from '../format' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Ordner } from '../components/Ordner' +import { Hochladen } from '../components/Hochladen' const PRO_SEITE = 60 @@ -215,6 +216,14 @@ export function Galerie() { )}
+ { + void laden() + void ordnerLaden() + }} + /> + {/* Das Raster */} {items.length === 0 && !laedt ? (