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 ? (