feat: hochladen aus dem Dashboard -- Bilder, Videos, Ton, PDF

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:29:33 +02:00
co-authored by Claude Opus 5
parent f7086c231c
commit 3ddab937e7
5 changed files with 387 additions and 0 deletions
+136
View File
@@ -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')