// Die Schnittstelle fuer das Dashboard. // // Eine Regel zieht sich durch alles hier: JEDE Antwort sagt, was passiert ist // — auch die erfolgreiche. Bei Fivemanage hat genau das gefehlt, und das // Ergebnis waren neunzehn gleichnamige Organisationen, weil ein Knopf ohne // Rueckmeldung wie ein kaputter Knopf aussieht. import { Hono } from 'hono' import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js' import { SESSION_COOKIE, createSession, destroySession, generateToken, hashPassword, tokenHash, userForSession, verifyPassword, } from '../auth.js' import { readFile, stat } from 'node:fs/promises' import { absolutePath, checkPath, deleteFile, kannVorschau, mimeFor, publicUrlFor, safeFilename, sha256, thumbPath, writeFileAtomic, writeThumb, PathError, } from '../storage.js' type Vars = { user: User } export const dashRoutes = new Hono<{ Variables: Vars }>() /** Den JSON-Rumpf lesen — oder null, wenn keiner ankam. * * c.req.json() wirft bei kaputtem JSON, und Hono macht daraus einen nackten * "Internal Server Error" ohne ein Wort dazu. Genau diese Sorte Antwort ist * der Grund, warum wir hier neu bauen. */ async function jsonBody(c: Context): Promise { try { return await c.req.json() } catch { return null } } const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const // ------------------------------------------------------------ Die Bremse // // Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch // teuer, aber teuer ist nicht selten: ohne Bremse darf jemand beliebig oft // raten, und irgendwann ist "sommer2024" dran. // // Bewusst im Speicher und nicht in der Datenbank: die Sperre soll einen // Neustart NICHT ueberleben. Wer sich selbst ausgesperrt hat, startet den // Container neu und kommt rein — bei einem Dienst, den eine Person betreibt, // ist das die richtige Notbremse. const FREI_VERSUCHE = 5 const GRUNDSPERRE_MS = 30_000 const HOECHSTSPERRE_MS = 15 * 60_000 type Versuche = { fehl: number; gesperrtBis: number } const versuche = new Map() /** Wer da klopft. * * X-Real-IP zuerst: NPM setzt den Kopf mit `$remote_addr` und UEBERSCHREIBT * damit, was der Aufrufer selbst hineinschreibt. Das gilt aber nur fuer den * Weg durch den Proxy — wer den Container direkt auf seinem Port erreicht, * kann sich jede Adresse ausdenken. Genau deshalb gehoert dieser Port hinter * die Firewall, und das steht so in der ROADMAP. */ function woher(c: Context): string { const real = c.req.header('x-real-ip')?.trim() if (real) return real const weiter = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() if (weiter) return weiter // @hono/node-server reicht die Node-Anfrage durch; ohne Proxy ist das die // einzige Quelle, die nicht vom Aufrufer stammt. const socket = (c.env as any)?.incoming?.socket?.remoteAddress return typeof socket === 'string' && socket ? socket : 'unbekannt' } /** Wie lange noch gesperrt, in Sekunden — oder 0. */ function gesperrt(key: string): number { const eintrag = versuche.get(key) if (!eintrag) return 0 const rest = eintrag.gesperrtBis - Date.now() return rest > 0 ? Math.ceil(rest / 1000) : 0 } function fehlversuch(key: string): void { const eintrag = versuche.get(key) ?? { fehl: 0, gesperrtBis: 0 } eintrag.fehl += 1 if (eintrag.fehl > FREI_VERSUCHE) { // Verdopplung ab dem sechsten Fehlversuch, gedeckelt. Aus 30 Sekunden // werden schnell Minuten — fuer einen Tippfehler unmerklich, fuer eine // Liste mit Millionen Passwoertern das Ende. const stufe = eintrag.fehl - FREI_VERSUCHE - 1 const dauer = Math.min(GRUNDSPERRE_MS * 2 ** stufe, HOECHSTSPERRE_MS) eintrag.gesperrtBis = Date.now() + dauer } versuche.set(key, eintrag) } const geschafft = (key: string) => versuche.delete(key) // Aufraeumen, damit die Karte nicht endlos waechst. Stuendlich reicht: was // laenger als eine Stunde nicht mehr geklopft hat, ist kein laufender Angriff. setInterval( () => { const jetzt = Date.now() for (const [key, eintrag] of versuche) { if (eintrag.gesperrtBis < jetzt - 3600_000) versuche.delete(key) } }, 3600_000, ).unref() // ---------------------------------------------------------------- Anmeldung dashRoutes.post('/auth/login', async (c) => { const key = woher(c) // Erst die Sperre, dann alles andere: ein gesperrter Versuch soll nicht // einmal scrypt kosten. const rest = gesperrt(key) if (rest > 0) { return c.json( { error: `Zu viele Fehlversuche. Noch ${rest} Sekunden warten.`, }, 429, { 'Retry-After': String(rest) }, ) } const body = await jsonBody<{ username?: string; password?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { username, password } = body // Ein leeres Formular ist ein Bedienfehler und kein Rateversuch — der // zaehlt nicht mit. if (!username || !password) { return c.json({ error: 'Benutzername und Passwort noetig' }, 400) } const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as | User | undefined // Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches // Passwort": alles andere verraet, welche Namen es gibt. if (!user || !verifyPassword(password, user.password_hash)) { fehlversuch(key) const jetztRest = gesperrt(key) return c.json( { error: jetztRest ? `Benutzername oder Passwort stimmt nicht. Zu viele Fehlversuche — noch ${jetztRest} Sekunden warten.` : 'Benutzername oder Passwort stimmt nicht', }, jetztRest ? 429 : 401, ) } geschafft(key) const session = createSession(user.id) setCookie(c, SESSION_COOKIE, session.id, { httpOnly: true, secure: !config.dev, sameSite: 'Lax', path: '/', expires: new Date(session.expiresAt), }) return c.json({ user: { id: user.id, username: user.username } }) }) dashRoutes.post('/auth/logout', (c) => { const sid = getCookie(c, SESSION_COOKIE) if (sid) destroySession(sid) deleteCookie(c, SESSION_COOKIE, { path: '/' }) return c.json({ ok: true }) }) dashRoutes.get('/auth/me', (c) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) return c.json({ user: { id: user.id, username: user.username } }) }) // Ab hier gilt: angemeldet oder nichts. dashRoutes.use('*', async (c, next) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) c.set('user', user) await next() }) dashRoutes.post('/auth/password', async (c) => { const body = await jsonBody<{ current?: string; next?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { current, next: fresh } = body const user = c.get('user') if (!current || !fresh) return c.json({ error: 'beide Passwoerter noetig' }, 400) if (fresh.length < 8) return c.json({ error: 'mindestens 8 Zeichen' }, 400) if (!verifyPassword(current, user.password_hash)) { return c.json({ error: 'aktuelles Passwort stimmt nicht' }, 401) } db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run( hashPassword(fresh), user.id, ) // Alle anderen Sitzungen beenden — ein Passwortwechsel, der alte // Anmeldungen weiterlaufen laesst, ist keiner. db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run( user.id, getCookie(c, SESSION_COOKIE), ) return c.json({ ok: true }) }) // ------------------------------------------------------------------- Medien dashRoutes.get('/media', (c) => { const query = c.req.query('query')?.trim() ?? '' const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) // Der Ordner ist ein EIGENER Filter und nicht bloss eine Suche nach // "vehicles/": die Suche findet mit LIKE %…% auch "alt/vehicles/adder.webp", // und dann zeigt der Ordner Dinge, die nicht darin liegen. Hier wird der // Anfang verankert. // // Die Sonderzeichen von LIKE werden maskiert: ein Ordner mit _ im Namen // (etwa "fotos_alt") wuerde sonst auch "fotosXalt" treffen — _ steht in // LIKE fuer ein beliebiges Zeichen. const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`) const bedingungen: string[] = [] const params: unknown[] = [] if (query) { bedingungen.push(`path LIKE ? ESCAPE '\\'`) params.push(`%${maskiert(query)}%`) } if (folder) { bedingungen.push(`path LIKE ? ESCAPE '\\'`) params.push(`${maskiert(folder)}/%`) } const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as { n: number } const rows = db .prepare( `SELECT * FROM media ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`, ) .all(...params, limit, offset) as Media[] return c.json({ total: total.n, limit, offset, items: rows.map((row) => ({ ...row, url: publicUrlFor(row.path) })), }) }) /** Alle Ordner, auf allen Ebenen, mit Anzahl und Groesse. * * In JavaScript zusammengezaehlt und nicht in SQL: fuer eine Baumansicht * braucht es JEDE Ebene ("vehicles" und "vehicles/showroom"), und das waere * in SQLite eine rekursive Abfrage, die niemand mehr liest. Bei ein paar * tausend Zeilen ist die Schleife hier schneller getippt und schneller * verstanden — und wenn es je Millionen werden, faellt es beim Messen auf und * nicht vorher. * * Die Zahlen sind KUMULATIV: "vehicles" enthaelt, was in "vehicles/showroom" * liegt. Alles andere ueberrascht in einer Baumansicht. */ dashRoutes.get('/folders', (c) => { const rows = db.prepare('SELECT path, size FROM media').all() as { path: string size: number }[] const ordner = new Map() for (const row of rows) { const teile = row.path.split('/') // Das letzte Stueck ist der Dateiname und kein Ordner. for (let i = 1; i < teile.length; i++) { const pfad = teile.slice(0, i).join('/') const eintrag = ordner.get(pfad) ?? { files: 0, bytes: 0, tiefe: i - 1 } eintrag.files += 1 eintrag.bytes += row.size ordner.set(pfad, eintrag) } } const items = [...ordner.entries()] .map(([folder, wert]) => ({ folder, ...wert })) // Alphabetisch und nicht nach Groesse: eine Baumansicht, deren // Reihenfolge sich beim naechsten Upload aendert, ist keine. .sort((a, b) => a.folder.localeCompare(b.folder, 'de')) // Dateien direkt an der Wurzel haben keinen Ordner und wuerden sonst // unsichtbar. Sie stehen im Dashboard unter "Alle". const wurzel = rows.filter((r) => !r.path.includes('/')) return c.json({ items, gesamt: { files: rows.length, bytes: rows.reduce((s, r) => s + r.size, 0) }, wurzel: { files: wurzel.length, bytes: wurzel.reduce((s, r) => s + r.size, 0) }, }) }) dashRoutes.delete('/media/:id', async (c) => { const id = Number(c.req.param('id')) const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined if (!row) return c.json({ error: 'nicht gefunden' }, 404) await deleteFile(row.path) db.prepare('DELETE FROM media WHERE id = ?').run(id) return c.json({ deleted: row.path }) }) /** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */ dashRoutes.post('/media/delete', async (c) => { const body = await jsonBody<{ ids?: number[] }>(c) if (!body) return c.json(KEIN_JSON, 400) const { ids } = body if (!Array.isArray(ids) || ids.length === 0) { return c.json({ error: 'keine Auswahl' }, 400) } const select = db.prepare('SELECT * FROM media WHERE id = ?') const remove = db.prepare('DELETE FROM media WHERE id = ?') const deleted: string[] = [] for (const id of ids) { const row = select.get(id) as Media | undefined if (!row) continue await deleteFile(row.path) remove.run(id) deleted.push(row.path) } 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') .get() as { files: number; bytes: number } const tokens = db.prepare('SELECT COUNT(*) AS n FROM tokens').get() as { n: number } // Die groessten Ordner — die erste Frage bei "wo ist mein Platz hin". const folders = db .prepare( `SELECT CASE WHEN instr(path, '/') > 0 THEN substr(path, 1, instr(path, '/') - 1) ELSE '(Wurzel)' END AS folder, COUNT(*) AS files, SUM(size) AS bytes FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 10`, ) .all() as { folder: string; files: number; bytes: number }[] return c.json({ ...media, tokens: tokens.n, folders, publicUrl: config.publicUrl }) }) // ------------------------------------------------------------------- Tokens dashRoutes.get('/tokens', (c) => { const rows = db .prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC') .all() as Omit[] return c.json({ items: rows }) }) dashRoutes.post('/tokens', async (c) => { const body = await jsonBody<{ name?: string prefix?: string canDelete?: boolean }>(c) if (!body) return c.json(KEIN_JSON, 400) const { name, prefix, canDelete } = body if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) // Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft. let cleanPrefix = '' if (prefix?.trim()) { try { cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, '')) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } } const token = generateToken() const info = db .prepare( `INSERT INTO tokens (name, hash, prefix, can_delete, created_at) VALUES (?, ?, ?, ?, ?)`, ) .run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now()) // Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in // der Datenbank, und auch wir koennen ihn nicht mehr zeigen. return c.json({ id: Number(info.lastInsertRowid), name: name.trim(), prefix: cleanPrefix, canDelete: Boolean(canDelete), token, }) }) dashRoutes.delete('/tokens/:id', (c) => { const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id'))) if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404) return c.json({ ok: true }) }) dashRoutes.post('/maintenance/prune-sessions', (c) => { pruneSessions() return c.json({ ok: true }) }) /** Fehlende Vorschaubilder nachziehen. * * Braucht es, weil die Vorschauen erst spaeter dazukamen: was vorher * hochgeladen wurde, hat keine. Und als Reparatur, falls eine je fehlschlaegt. * * Das Ergebnis nennt Zahlen und nicht nur "ok" — ein Knopf, der nach zwanzig * Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der * Frage, ob er ueberhaupt etwas getan hat. */ dashRoutes.post('/maintenance/thumbs', async (c) => { const rows = db.prepare('SELECT path, mime FROM media').all() as Pick< Media, 'path' | 'mime' >[] let erzeugt = 0 let vorhanden = 0 let uebersprungen = 0 let fehler = 0 for (const row of rows) { if (!kannVorschau(row.mime)) { uebersprungen++ continue } try { await stat(thumbPath(row.path)) vorhanden++ continue } catch { /* gibt es noch nicht — gleich */ } try { const data = await readFile(absolutePath(row.path)) if (await writeThumb(row.path, data)) erzeugt++ else fehler++ } catch { // Ein Datensatz ohne Datei. Kommt vor, wenn jemand im Volume // aufgeraeumt hat; hier ist es ein Zaehler und kein Absturz. fehler++ } } return c.json({ erzeugt, vorhanden, uebersprungen, fehler, gesamt: rows.length }) })