// 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 { unzipSync } from 'fflate' import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' import { avatarUrl, db, logEvent, now, pruneEvents, pruneSessions, type Media, type Freigabe, type MediaEvent, type PapierkorbEintrag, type Token, type User, } from '../db.js' import { SESSION_COOKIE, createSession, destroySession, generateToken, hashPassword, tokenHash, userForSession, verifyPassword, } from '../auth.js' import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' import { join } from 'node:path' import { DISCORD_KEYS, SICHERUNG_KEYS, ausDatenbank, discordConfig, getSetting, setSetting, setzeVorlagen, sicherungConfig, vorlagen, type Vorlage, } from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js' import { neuerSchluessel } from './freigabe.js' import { absolutePath, checkPath, ausDemPapierkorb, deleteFile, inDenPapierkorb, kannBewegtVorschau, kannVorschau, lohntWebp, mimeFor, moveFile, moveFolder, publicUrlFor, safeFilename, sha256, papierkorbPfad, thumbPath, webpPfad, writeBewegtThumb, writeFileAtomic, writeThumb, writeWebp, 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 /** Wie lange Geloeschtes im Papierkorb bleibt. 30 Tage: lang genug, dass ein * Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */ const PAPIERKORB_TAGE = 30 // ------------------------------------------------------------ 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: zeigen(user) }) }) 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 }) }) /** Wer gerade angemeldet ist — samt Bild, wenn es eines gibt. * * Die Adresse wird HIER gebaut und nicht in der Oberflaeche: Discords * URL-Schema (gif bei bewegten Bildern, Ersatzbild aus der ID gerechnet) * gehoert an eine Stelle und nicht in jede Ansicht, die ein Bild zeigt. */ const zeigen = (user: User) => ({ id: user.id, username: user.username, discord: Boolean(user.discord_id), discordName: user.discord_name, avatar: avatarUrl(user.discord_id, user.discord_avatar, 64), }) 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: zeigen(user) }) }) /** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist. * * Nur ein Ja/Nein: ob der Discord-Knopf ueberhaupt sinnvoll ist. Weder * Client-ID noch Server-ID gehen hier heraus — die braucht das Formular * nicht, und was nicht herausgeht, kann auch nicht abgeschrieben werden. */ dashRoutes.get('/auth/config', (c) => c.json({ discord: discordConfig().bereit })) // Der Discord-Weg selbst. MUSS vor der Wache stehen: wer sich anmelden will, // ist noch nicht angemeldet. dashRoutes.route('/auth', discordRoutes) // 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 /** Die Medienarten, wie sie im Dashboard heissen -- und was sie in SQL sind. * * An EINER Stelle, weil zwei Stellen zwangslaeufig auseinanderlaufen: die * Liste zeigte dann etwas anderes als der Zaehler daneben behauptet. * * "andere" ist bewusst die Verneinung der uebrigen und keine eigene Liste: * sonst faellt ein neuer Typ (z. B. application/zip) durch alle Filter und * ist nirgends zu finden. */ const ARTEN: Record = { bild: "mime LIKE 'image/%'", video: "mime LIKE 'video/%'", ton: "mime LIKE 'audio/%'", dokument: "(mime = 'application/pdf' OR mime LIKE 'text/%' OR mime LIKE 'application/json%')", andere: "(mime NOT LIKE 'image/%' AND mime NOT LIKE 'video/%' AND mime NOT LIKE 'audio/%' " + "AND mime <> 'application/pdf' AND mime NOT LIKE 'text/%' AND mime NOT LIKE 'application/json%')", } 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)}/%`) } // Die Art kommt aus der Adresszeile und geht in SQL -- also gegen die // feste Tabelle getauscht und niemals durchgereicht. const art = c.req.query('art') ?? '' if (ARTEN[art]) bedingungen.push(ARTEN[art]) const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' // Die Sortierung kommt vom Aufrufer und geht in SQL -- also NIEMALS // durchgereicht, sondern gegen eine feste Liste getauscht. Ein Feldname // aus einer Adresszeile ist die aelteste Einladung ueberhaupt. const SPALTEN: Record = { geaendert: 'updated_at', angelegt: 'created_at', pfad: 'path', groesse: 'size', // Gezaehlt wurde laengst, sortieren konnte man danach nicht -- eine // Zahl, die man nicht ordnen kann, beantwortet die Frage "was wird // eigentlich gebraucht" nur fuer die zehn im Speicherbericht. abrufe: 'abrufe', geholt: 'zuletzt_abgerufen', } const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at' const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC' // Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung // sortiert: sonst stuende "Adder" vor "adder" und dazwischen nichts. const nachSpalte = sortSpalte === 'path' ? 'path COLLATE NOCASE' : sortSpalte 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 ${nachSpalte} ${richtung}, id 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. */ /** Wie viele Dateien es je Art gibt -- fuer die Filterknoepfe. * * Mit den gerade geltenden Einschraenkungen (Ordner, Suche), sonst stuende an * einem Knopf eine Zahl, die nach dem Klick nicht stimmt. */ dashRoutes.get('/arten', (c) => { const query = c.req.query('query')?.trim() ?? '' const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`) const grund: string[] = [] const params: unknown[] = [] if (query) { grund.push(`path LIKE ? ESCAPE '\\'`) params.push(`%${maskiert(query)}%`) } if (folder) { grund.push(`path LIKE ? ESCAPE '\\'`) params.push(`${maskiert(folder)}/%`) } const zaehle = (zusatz?: string) => { const alle = zusatz ? [...grund, zusatz] : grund const where = alle.length ? `WHERE ${alle.join(' AND ')}` : '' return ( db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as { n: number } ).n } const out: Record = { alle: zaehle() } for (const [name, bedingung] of Object.entries(ARTEN)) out[name] = zaehle(bedingung) return c.json(out) }) dashRoutes.get('/folders', async (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) } } // Leere Ordner gibt es nur auf der Platte -- sie stehen in keiner Tabelle, // weil dort nur Dateien stehen. Wer einen anlegt, um etwas hineinzulegen, // soll ihn trotzdem sehen. async function verzeichnisse(rel: string, tiefe: number) { let eintraege try { eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true }) } catch { return } for (const e of eintraege) { if (!e.isDirectory()) continue const p = rel ? `${rel}/${e.name}` : e.name if (!ordner.has(p)) ordner.set(p, { files: 0, bytes: 0, tiefe }) await verzeichnisse(p, tiefe + 1) } } await verzeichnisse('', 0) 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) }, }) }) /** Nur die IDs zu einer Auswahl — fuer "alle Treffer waehlen". * * Ohne das muesste jemand fuenfzehn Seiten durchblaettern und auf jeder * "Seite waehlen" druecken, um einen Ordner mit 900 Bildern zu leeren. * * Gedeckelt auf 5000: das ist mehr, als hier je auf einmal ausgewaehlt wird, * und verhindert, dass eine Antwort in die Megabyte laeuft. Wer daran stoesst, * bekommt es GESAGT (gekappt: true) statt still eine halbe Auswahl. */ dashRoutes.get('/media/ids', (c) => { const query = c.req.query('query')?.trim() ?? '' const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' const maskiert = (t: string) => t.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)}/%`) } // MUSS dieselbe Einschraenkung haben wie die Liste: sonst waehlt "alle // waehlen" mehr aus, als der Filter zeigt -- und geloescht wird dann // Unsichtbares. const art = c.req.query('art') ?? '' if (ARTEN[art]) bedingungen.push(ARTEN[art]) const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' const DECKEL = 5000 const rows = db .prepare(`SELECT id FROM media ${where} ORDER BY updated_at DESC LIMIT ?`) .all(...params, DECKEL + 1) as { id: number }[] return c.json({ ids: rows.slice(0, DECKEL).map((r) => r.id), gekappt: rows.length > DECKEL, }) }) 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 inPapierkorb(row, c.get('user').username) 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 deleted: string[] = [] for (const id of ids) { const row = select.get(id) as Media | undefined if (!row) continue await inPapierkorb(row, c.get('user').username) deleted.push(row.path) } return c.json({ deleted, count: deleted.length }) }) // ------------------------------------------------------------------- Archive // // WOZU. 3670 Item-Bilder einzeln ins Fenster zu ziehen ist keine Bedienung, // das ist eine Strafe. Ein ZIP ist die Form, in der solche Sammlungen ohnehin // unterwegs sind -- und es bringt die ORDNER MIT, was der wichtigere Teil ist: // items/foo.png im Archiv landet unter items/foo.png im Dienst. // // fflate und nicht adm-zip: reines JavaScript, keine native Bibliothek. Dieses // Projekt hat schon einmal einen halben Abend an einer ABI-Nummer verloren // (better-sqlite3 gegen Node 24), und das soll sich nicht wiederholen. /** Wie viele Eintraege ein Archiv hoechstens haben darf. */ const ZIP_MAX_EINTRAEGE = 5000 /** Und wieviel es ENTPACKT hoechstens ergeben darf. * * Das ist die Zip-Bombe: ein Archiv von 40 KB kann zu Gigabyte aufgehen, und * entpackt wird in den Arbeitsspeicher. Geprueft wird deshalb VOR dem * Entpacken -- die Groesse des Originals steht im Kopf jedes Eintrags, und * fflate laesst uns darauf filtern. Eine Pruefung danach waere keine. */ const ZIP_MAX_GESAMT = 256 * 1024 * 1024 const istArchiv = (name: string, typ: string) => /\.zip$/i.test(name) || /^application\/(x-)?zip(-compressed)?$/i.test(typ.split(';')[0] ?? '') /** Einen Pfad aus dem Archiv in einen zulaessigen Pfad verwandeln. * * Jedes Segment einzeln durch dieselbe Zaehmung wie ein Dateiname aus dem * Fenster. Gibt null zurueck, wenn nichts Brauchbares uebrig bleibt. * * ".." FLIEGT RAUS, und zwar hier schon: ein Archiv ist eine Datei von * draussen, und "../../etc/passwd" als Eintragsname ist ein Angriff mit * eigenem Namen (Zip Slip). checkPath faengt es zwar auch, aber eine Abwehr, * die nur an EINER Stelle steht, ist eine Abwehr, die beim Umbauen verschwindet. */ function archivPfad(roh: string): string | null { const teile = roh .replace(/\\/g, '/') .split('/') .filter((t) => t && t !== '.') if (teile.some((t) => t === '..')) return null const sauber = teile.map((t) => safeFilename(t)).filter(Boolean) return sauber.length ? sauber.join('/') : null } /** Ein Archiv auspacken. Was uebersprungen wird, steht mit Grund im Ergebnis -- * ein stilles Weglassen waere hier das Schlimmste: der Aufrufer glaubt, seine * 900 Bilder seien da, und merkt bei Bild 314, dass sie es nicht sind. */ function auspacken( data: Buffer, ergebnisse: { name: string; ok: boolean; error?: string }[], ): [string, Uint8Array][] { let gesamt = 0 let anzahl = 0 const inhalt = unzipSync(data, { filter: (eintrag) => { // Ordnereintraege und der Beifang, den macOS in jedes Archiv legt. if (eintrag.name.endsWith('/')) return false if (/(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$)/i.test(eintrag.name)) return false if (eintrag.originalSize === 0) return false if (++anzahl > ZIP_MAX_EINTRAEGE) return false gesamt += eintrag.originalSize return gesamt <= ZIP_MAX_GESAMT }, }) if (anzahl > ZIP_MAX_EINTRAEGE) { ergebnisse.push({ name: 'Archiv', ok: false, error: `mehr als ${ZIP_MAX_EINTRAEGE} Eintraege — der Rest wurde nicht ausgepackt`, }) } if (gesamt > ZIP_MAX_GESAMT) { ergebnisse.push({ name: 'Archiv', ok: false, error: `ergibt entpackt mehr als ${Math.round(ZIP_MAX_GESAMT / 1024 / 1024)} MB ` + '— der Rest wurde nicht ausgepackt', }) } const raus: [string, Uint8Array][] = [] for (const [roh, bytes] of Object.entries(inhalt)) { const pfad = archivPfad(roh) if (!pfad) { ergebnisse.push({ name: roh, ok: false, error: 'unbrauchbarer Name im Archiv' }) continue } raus.push([pfad, bytes]) } return raus } /** Hochladen aus dem Dashboard. * * Ein ZIP wird dabei AUSGEPACKT statt abgelegt: es ist so gut wie nie das * Archiv, das jemand oeffentlich haben will, sondern sein Inhalt. Die Ordner * im Archiv bleiben erhalten und haengen sich hinter den gewaehlten Zielordner. * * 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) } // Das Formular lesen -- und dabei eine Strenge ausbuegeln, die uns fast // eine Anbindung gekostet haette. // // Manche Aufrufer setzen die Grenzmarkierung in ANFUEHRUNGSZEICHEN: // // multipart/form-data; boundary="3c5a305b-cb7d-..." // // Nodes Parser lehnt das ab, obwohl RFC 2046 es ausdruecklich erlaubt. // Also nehmen wir sie weg und lesen selbst. // // ACHTUNG, EINE HALBE WAHRHEIT WAERE HIER TEURER ALS KEINE: das allein // rettet PowerShells `Invoke-RestMethod -Form` NICHT. Mitgeschnitten hat // dessen Rumpf naemlich noch eine zweite Abweichung: // // Content-Disposition: form-data; name=file; filename=a.png // // RFC 7578 verlangt name="file" MIT Anfuehrungszeichen, .NET laesst sie // weg. Das steht im RUMPF, nicht im Kopf -- dafuer braeuchte es einen // eigenen Multipart-Parser, und der waere fuer diesen Dienst // unverhaeltnismaessig. Werkzeuge auf dieser Maschine nehmen deshalb // `curl.exe -F` (siehe tools/uebernehmen.ps1); curl setzt beides korrekt. let dateien: File[] let ordnerRoh = '' let ueberschreiben = true try { const typ = c.req.header('content-type') ?? '' if (/boundary="/.test(typ)) { const gerade = typ.replace(/boundary="([^"]+)"/, 'boundary=$1') const rumpf = await c.req.arrayBuffer() const form = await new Request('http://formular.invalid/', { method: 'POST', headers: { 'content-type': gerade }, body: rumpf, }).formData() dateien = form.getAll('file').filter((f) => typeof f !== 'string') as unknown as File[] ordnerRoh = ((form.get('folder') as string | null) ?? '').trim() ueberschreiben = form.get('overwrite') !== 'false' } else { // DER NORMALFALL GEHT UEBER HONO, und das ist kein Geschmack: // c.req.raw.formData() direkt aufzurufen nimmt Hono den Rumpf weg, // den es selbst verwaltet. Beim Aufraeumen der Antwort schliesst // dann jemand einen Strom, der schon zu ist -- "ReadableStream is // already closed", eine Ausnahme AUSSERHALB jedes try/catch, und // der Prozess ist weg. // // Genau das ist beim Uebernehmen von 3670 Dateien passiert: der // Dienst lief in eine Neustartschleife, und NPM meldete 502. Beim // Test mit 57 Dateien war es nicht aufgefallen. const roh = await c.req.parseBody({ all: true }) const feld = roh['file'] dateien = (Array.isArray(feld) ? feld : [feld]).filter( (f) => f && typeof f !== 'string', ) as unknown as File[] ordnerRoh = typeof roh['folder'] === 'string' ? roh['folder'].trim() : '' ueberschreiben = roh['overwrite'] !== 'false' } } catch (err) { // Ein kaputter Rumpf ergab bisher einen 500, aufgefangen nur von der // Auffanglinie. Wer ein Formular falsch zusammenbaut, soll 400 samt // Grund bekommen und nicht "Serverfehler". return c.json( { error: `Der Formular-Rumpf liess sich nicht lesen: ${err instanceof Error ? err.message : 'unbekannt'}`, }, 400, ) } if (dateien.length === 0) return c.json({ error: 'Keine Datei dabei' }, 400) 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 ergebnisse: { name: string ok: boolean path?: string url?: string size?: number replaced?: boolean error?: string }[] = [] /** Eine Datei ablegen -- EIN Weg fuer alles. * * Ob der Inhalt aus einem Formularteil kam oder aus einem Archiv, macht ab * hier keinen Unterschied mehr: gleiche Namensregeln, gleiche Pruefung auf * Belegtsein, gleiche Vorschau, gleicher Eintrag im Verlauf. Ein zweiter * Weg fuer ZIP-Inhalte waere ein zweiter Weg, der beim naechsten Mal * auseinanderlaeuft. * * `zielpfad` ist gesetzt, wenn der Name schon ein Pfad ist -- bei * Archiveintraegen naemlich, die ihre Ordner mitbringen. */ async function ablegen( name: string, data: Buffer, gemeldeterTyp: string, zielpfad?: string, ): Promise { if (data.length === 0) { ergebnisse.push({ name, ok: false, error: 'leere Datei' }) return } if (data.length > config.maxUploadBytes) { ergebnisse.push({ name, ok: false, error: `zu gross (${data.length} > ${config.maxUploadBytes} Bytes)`, }) return } 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 = zielpfad ?? 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' }) return } // Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein // Achselzucken. Ein Formularteil ohne erkannte Art traegt // "application/octet-stream", und das ist weniger wert als unsere // Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und // handbuch.pdf allesamt unter "Anderes". Aus einem Archiv kommt gar // keine Angabe -- dort entscheidet die Endung ohnehin allein. const gemeldet = (gemeldeterTyp || '').split(';')[0]?.trim().toLowerCase() ?? '' const mime = gemeldet && gemeldet !== 'application/octet-stream' ? gemeldeterTyp : mimeFor(path) await writeFileAtomic(path, data) if (kannVorschau(mime)) await writeThumb(path, data) if (lohntWebp(mime)) await writeWebp(path, data) // Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und // deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge // faellt beim selben Blick in die Datei mit ab. let dauer: number | null = null if (kannBewegtVorschau(mime)) { dauer = (await writeBewegtThumb(path, mime)).dauer } const zeit = now() if (vorhanden) { db.prepare( `UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, updated_at = ? WHERE id = ?`, ).run(data.length, digest, mime, dauer, zeit, vorhanden.id) } else { db.prepare( `INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, ).run(path, data.length, digest, mime, dauer, zeit, zeit) } logEvent( vorhanden ? 'replace' : 'upload', path, data.length, 'user', c.get('user').username, ) ergebnisse.push({ name, ok: true, path, url: publicUrlFor(path), size: data.length, replaced: Boolean(vorhanden), }) } 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 (istArchiv(name, datei.type)) { for (const [eintrag, inhalt] of auspacken(data, ergebnisse)) { // EIGENES try JE EINTRAG. Sonst reisst ein einziger // unbrauchbarer Name -- zu tief, zu lang -- die restlichen // tausend mit, und der Aufrufer sieht eine Fehlermeldung // statt neunhundertneunundneunzig Erfolgen. try { await ablegen(`${name} → ${eintrag}`, Buffer.from(inhalt), '', eintrag) } catch (err) { ergebnisse.push({ name: `${name} → ${eintrag}`, ok: false, error: err instanceof Error ? err.message : 'unbekannter Fehler', }) } } continue } await ablegen(name, data, datei.type) } 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 }) }) /** Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht. * * Nur lesen, nie schreiben — geschrieben wird an den Stellen, an denen es * passiert. Ein Verlauf, den man von aussen fuellen kann, ist keiner. */ dashRoutes.get('/events', (c) => { const limit = Math.min(Number(c.req.query('limit') ?? 50) || 50, 500) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) const kind = c.req.query('kind')?.trim() ?? '' const actor = c.req.query('actor')?.trim() ?? '' const query = c.req.query('query')?.trim() ?? '' const bedingungen: string[] = [] const params: unknown[] = [] if (kind && ['upload', 'replace', 'delete'].includes(kind)) { bedingungen.push('kind = ?') params.push(kind) } if (actor) { bedingungen.push('actor = ?') params.push(actor) } if (query) { // Dieselbe Maskierung wie bei der Mediensuche: ein Pfad mit _ darin // wuerde sonst auch auf ein beliebiges Zeichen passen. bedingungen.push(`path LIKE ? ESCAPE '\\'`) params.push(`%${query.replace(/[\\%_]/g, (z) => `\\${z}`)}%`) } const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' const total = db.prepare(`SELECT COUNT(*) AS n FROM events ${where}`).get(...params) as { n: number } const items = db .prepare(`SELECT * FROM events ${where} ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`) .all(...params, limit, offset) as MediaEvent[] // Wer ueberhaupt vorkommt -- damit die Oberflaeche einen Filter anbieten // kann, ohne alle Eintraege durchzublaettern. const roheAkteure = db .prepare( `SELECT e.actor, e.actor_kind, COUNT(*) AS n, MAX(e.at) AS zuletzt, u.discord_id, u.discord_avatar FROM events e LEFT JOIN users u ON u.username = e.actor AND e.actor_kind = 'user' GROUP BY e.actor, e.actor_kind ORDER BY n DESC`, ) .all() as { actor: string actor_kind: string n: number zuletzt: number discord_id: string | null discord_avatar: string | null }[] // Das Bild nur fuer Menschen. Ein Token hat kein Gesicht, und ein // Platzhalterbild daneben waere eine Behauptung. const akteure = roheAkteure.map((a) => ({ actor: a.actor, actor_kind: a.actor_kind, n: a.n, zuletzt: a.zuletzt, avatar: avatarUrl(a.discord_id, a.discord_avatar, 32), })) // Ab wann aufgezeichnet wird. Alles davor steht nur in der Galerie, und // das gehoert dazugesagt statt verschwiegen. const seit = db.prepare('SELECT MIN(at) AS a FROM events').get() as { a: number | null } return c.json({ total: total.n, limit, offset, items, akteure, seit: seit.a }) }) // ------------------------------------------------------------- Einstellungen /** Die Einstellungen zum Anzeigen. * * DAS GEHEIMNIS GEHT NIE HERAUS — auch nicht an eine angemeldete Sitzung. * Zurueck kommt nur, OB eines hinterlegt ist. Ein Formular, das den * Client-Schluessel im Klartext zurueckschickt, streut ihn in jeden * Browser-Cache und jedes Bildschirmfoto. */ dashRoutes.get('/settings', (c) => { const d = discordConfig() return c.json({ discord: { clientId: d.clientId, guildId: d.guildId, roleIds: d.roleIds, hatGeheimnis: Boolean(d.clientSecret), bereit: d.bereit, // Die Stelle, an der der erste Anlauf fast immer scheitert. redirectUri: redirectUri(), ausDerUmgebung: { clientId: Boolean(d.clientId) && !ausDatenbank(DISCORD_KEYS.clientId), clientSecret: Boolean(d.clientSecret) && !ausDatenbank(DISCORD_KEYS.clientSecret), guildId: Boolean(d.guildId) && !ausDatenbank(DISCORD_KEYS.guildId), roleIds: d.roleIds.length > 0 && !ausDatenbank(DISCORD_KEYS.roleIds) && !ausDatenbank(DISCORD_KEYS.roleIdAlt), }, }, vorlagen: vorlagen(), meldungen: meldungConfig(), sicherung: (() => { const sc = sicherungConfig() let letzte: unknown = null try { const roh = getSetting(SICHERUNG_KEYS.letzte) letzte = roh ? JSON.parse(roh) : null } catch { // Ein kaputter Eintrag ist kein Grund, die ganze Seite // scheitern zu lassen -- dann steht dort eben "unbekannt". letzte = null } return { ...sc, letzte } })(), }) }) dashRoutes.post('/settings', async (c) => { const body = await jsonBody<{ discord?: { clientId?: string clientSecret?: string guildId?: string roleIds?: string | string[] } vorlagen?: { name?: string; muster?: string }[] meldungen?: { webhook?: string; anlaesse?: string[] } sicherung?: { basis?: string benutzer?: string passwort?: string ordner?: string behalten?: number | string } }>(c) if (!body) return c.json(KEIN_JSON, 400) // -- Meldungen ---------------------------------------------------------- if (body.meldungen) { const m = body.meldungen if (m.webhook !== undefined) { const v = m.webhook.trim() if (v === '-') setSetting(MELDUNG_KEYS.webhook, '') else if (v) { // Nur echte Discord-Webhooks. Wer hier versehentlich eine // andere Adresse eintraegt, schickt seine Meldungen sonst // irgendwohin. if (!/^https:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\//.test(v)) { return c.json( { error: 'Das sieht nicht nach einer Discord-Webhook-Adresse aus.' }, 400, ) } setSetting(MELDUNG_KEYS.webhook, v) } } if (Array.isArray(m.anlaesse)) { const erlaubt = ['sicherung', 'verwaiste'] setSetting( MELDUNG_KEYS.anlaesse, m.anlaesse.filter((a) => erlaubt.includes(a)).join(','), ) } if (!body.discord && !body.sicherung && !body.vorlagen) { return c.json({ ok: true, meldungen: meldungConfig() }) } } // -- Adressvorlagen ----------------------------------------------------- if (body.vorlagen) { if (!Array.isArray(body.vorlagen)) { return c.json({ error: 'Vorlagen muessen eine Liste sein' }, 400) } if (body.vorlagen.length > 20) { return c.json({ error: 'Hoechstens 20 Vorlagen' }, 400) } const sauber: Vorlage[] = [] for (const v of body.vorlagen) { const name = String(v?.name ?? '').trim().slice(0, 40) const muster = String(v?.muster ?? '').trim().replace(/^\/+/, '') if (!name || !muster) continue // Das Muster muss einen Platzhalter haben -- sonst ist es keine // Vorlage, sondern eine einzelne Adresse. if (!/\{[a-z]+\}/.test(muster)) { return c.json( { error: `"${name}": im Muster fehlt ein Platzhalter, z. B. {model}` }, 400, ) } // Und es muss ein gueltiger Pfad WERDEN koennen. Geprueft wird mit // eingesetztem Platzhalter, sonst scheitert checkPath an der // geschweiften Klammer. try { checkPath(muster.replace(/\{[a-z]+\}/g, 'x')) } catch (err) { return c.json( { error: `"${name}": ${err instanceof PathError ? err.message : 'ungueltiges Muster'}`, }, 400, ) } sauber.push({ name, muster }) } setzeVorlagen(sauber) if (!body.discord && !body.sicherung) { return c.json({ ok: true, vorlagen: sauber }) } } // -- Sicherung ---------------------------------------------------------- if (body.sicherung) { const sic = body.sicherung if (sic.basis !== undefined) { const v = sic.basis.trim().replace(/\/+$/, '') if (v && !/^https?:\/\//.test(v)) { return c.json({ error: 'Adresse muss mit http:// oder https:// anfangen' }, 400) } setSetting(SICHERUNG_KEYS.basis, v) } if (sic.benutzer !== undefined) setSetting(SICHERUNG_KEYS.benutzer, sic.benutzer.trim()) if (sic.ordner !== undefined) { // Kein fuehrender Schraegstrich: der Pfad haengt hinter der // WebDAV-Wurzel, und "//Sicherungen" ergibt dort einen Ordner // ohne Namen. setSetting(SICHERUNG_KEYS.ordner, sic.ordner.trim().replace(/^\/+|\/+$/g, '')) } if (sic.behalten !== undefined) { const n = Number(sic.behalten) if (!Number.isFinite(n) || n < 1 || n > 365) { return c.json({ error: 'Behalten: zwischen 1 und 365' }, 400) } setSetting(SICHERUNG_KEYS.behalten, String(Math.round(n))) } // Wie beim Discord-Geheimnis: leer heisst unveraendert, "-" entfernt. if (sic.passwort !== undefined) { const v = sic.passwort.trim() if (v === '-') setSetting(SICHERUNG_KEYS.passwort, '') else if (v) setSetting(SICHERUNG_KEYS.passwort, v) } if (!body.discord) { const jetzt = sicherungConfig() return c.json({ ok: true, bereit: jetzt.bereit, fehlt: [ !jetzt.basis && 'Adresse', !jetzt.benutzer && 'Benutzername', !jetzt.hatPasswort && 'App-Passwort', ].filter(Boolean), }) } } const d = body.discord if (!d) return c.json({ error: 'nichts zu speichern' }, 400) // Die IDs von Discord sind Snowflakes: 17 bis 20 Ziffern. Wer eine // Rollen-ID mit Leerzeichen oder einen halben Link einfuegt, soll es HIER // erfahren und nicht beim ersten Anmeldeversuch. const istId = (v: string) => /^\d{17,20}$/.test(v) if (d.clientId !== undefined) { const v = d.clientId.trim() if (v && !istId(v)) return c.json({ error: 'Client-ID: 17 bis 20 Ziffern' }, 400) setSetting(DISCORD_KEYS.clientId, v) } if (d.guildId !== undefined) { const v = d.guildId.trim() if (v && !istId(v)) return c.json({ error: 'Server-ID: 17 bis 20 Ziffern' }, 400) setSetting(DISCORD_KEYS.guildId, v) } if (d.roleIds !== undefined) { // Komma, Semikolon, Leerzeichen, Zeilenumbruch — alles trennt. Wer // drei IDs untereinander einfuegt, soll sie nicht erst zu einer Zeile // zusammenschieben muessen. const liste = (Array.isArray(d.roleIds) ? d.roleIds.join(',') : d.roleIds) .split(/[\s,;]+/) .map((t) => t.trim()) .filter(Boolean) // JEDE einzeln pruefen und die schlechte BEIM NAMEN nennen. "Rollen-ID // ungueltig" bei fuenf Eintraegen ist eine Suchaufgabe, keine Meldung. for (const eintrag of liste) { if (!istId(eintrag)) { return c.json( { error: `"${eintrag}" ist keine Rollen-ID (17 bis 20 Ziffern)` }, 400, ) } } const eindeutig = [...new Set(liste)] setSetting(DISCORD_KEYS.roleIds, eindeutig.join(',')) // Den alten Einzelwert raeumen wir weg, sobald die Liste gepflegt // wird: zwei Quellen fuer dieselbe Frage laufen sonst auseinander. setSetting(DISCORD_KEYS.roleIdAlt, '') } // Ein leeres Feld heisst "unveraendert lassen", nicht "loeschen" — sonst // wuerde jedes Speichern das Geheimnis wegwerfen, weil es nie im Formular // steht. Zum Entfernen gibt es den eigenen Wert '-'. if (d.clientSecret !== undefined) { const v = d.clientSecret.trim() if (v === '-') setSetting(DISCORD_KEYS.clientSecret, '') else if (v) setSetting(DISCORD_KEYS.clientSecret, v) } const jetzt = discordConfig() return c.json({ ok: true, bereit: jetzt.bereit, fehlt: [ !jetzt.clientId && 'Client-ID', !jetzt.clientSecret && 'Client-Geheimnis', !jetzt.guildId && 'Server-ID', jetzt.roleIds.length === 0 && 'mindestens eine Rollen-ID', ].filter(Boolean), }) }) /** Der Fuehler fuer die automatische Auffrischung. * * Drei Zahlen, drei Indexzugriffe -- billig genug, um ihn alle paar Sekunden * zu fragen. Die Oberflaeche laedt erst dann RICHTIG nach, wenn sich hier * etwas geaendert hat. * * Warum nicht Server-Sent Events: die waeren schoener, brauchen hinter NPM * aber zusaetzlich `proxy_buffering off` -- also einen weiteren Handgriff im * Proxy, der vergessen werden kann und dann zu einer Verbindung fuehrt, die * scheinbar haengt. Ein Zaehler, den man abfragt, hat diese Fussangel nicht. * * `neuest` ist noetig NEBEN `medien`: wird eine Datei ersetzt, bleibt die * Anzahl gleich und nur der Zeitstempel wandert. Ohne ihn saehe man das neue * Bild nicht. */ dashRoutes.get('/stand', (c) => { const m = db .prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), 0) AS neuest FROM media') .get() as { n: number; neuest: number } const e = db.prepare('SELECT COALESCE(MAX(id), 0) AS letzte FROM events').get() as { letzte: number } return c.json({ medien: m.n, neuest: m.neuest, ereignis: e.letzte }) }) /** Alles fuer die Startseite in EINER Antwort. * * Vier Einzelabfragen beim Oeffnen waeren vier Runden ueber den Proxy, nur um * eine Uebersicht zu zeigen. Und sie koennten auseinanderlaufen: die Zahl der * Dateien aus der einen und die letzten Ereignisse aus der naechsten Antwort * sind zwei verschiedene Zeitpunkte. */ dashRoutes.get('/uebersicht', (c) => { const m = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') .get() as { files: number; bytes: number } const heute = now() - 86_400_000 const woche = now() - 7 * 86_400_000 const neu = db.prepare('SELECT COUNT(*) AS n FROM events WHERE kind = ? AND at > ?') const heuteHoch = (neu.get('upload', heute) as { n: number }).n const wocheHoch = (neu.get('upload', woche) as { n: number }).n const letzte = db .prepare('SELECT * FROM events ORDER BY at DESC, id DESC LIMIT 8') .all() as MediaEvent[] // Die letzten Bilder fuer eine Streifenvorschau. Nur Bilder: ein PDF als // graue Kachel in einer Uebersicht ist Fuellmaterial. const bilder = ( db .prepare( `SELECT * FROM media WHERE mime LIKE 'image/%' ORDER BY updated_at DESC LIMIT 12`, ) .all() as Media[] ).map((row) => ({ ...row, url: publicUrlFor(row.path) })) const ordner = 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 5`, ) .all() as { folder: string; files: number; bytes: number }[] const tokens = db .prepare('SELECT id, name, prefix, can_delete, last_used_at FROM tokens ORDER BY last_used_at DESC') .all() as Pick[] return c.json({ files: m.files, bytes: m.bytes, heuteHoch, wocheHoch, publicUrl: config.publicUrl, filePrefix: config.filePrefix, letzte, bilder, ordner, tokens, discord: discordConfig().bereit, vorlagen: vorlagen(), }) }) /** Dateien in einen anderen Ordner legen. * * ACHTUNG, UND DIE OBERFLAECHE SAGT ES AUCH: der Pfad IST die oeffentliche * Adresse. Wer ein Bild verschiebt, macht jede Stelle ungueltig, die die alte * URL benutzt -- im Handy, in einer Vorlage, in einem Zwischenspeicher. Das * ist kein Fehler dieser Funktion, sondern der Preis vorhersagbarer Adressen. * * Jede Datei bekommt ihr eigenes Ergebnis, und eine Kollision bricht die * anderen nicht ab. */ dashRoutes.post('/media/move', async (c) => { const body = await jsonBody<{ ids?: number[]; ziel?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) if (!Array.isArray(body.ids) || body.ids.length === 0) { return c.json({ error: 'keine Auswahl' }, 400) } let ziel = '' if (body.ziel && body.ziel.trim()) { try { ziel = checkPath(body.ziel.trim().replace(/^\/+|\/+$/g, '')) } catch (err) { if (err instanceof PathError) return c.json({ error: `Ziel: ${err.message}` }, 400) throw err } } const wer = c.get('user').username const ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] = [] for (const id of body.ids) { const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined if (!row) { ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' }) continue } const name = row.path.split('/').pop() as string const neu = ziel ? `${ziel}/${name}` : name if (neu === row.path) { ergebnisse.push({ path: row.path, ok: false, error: 'liegt schon dort' }) continue } const belegt = db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu) if (belegt) { // Bewusst KEIN stilles Ueberschreiben: hier wuerde eine fremde // Datei verschwinden, und der Verlauf zeigte nur einen Umzug. ergebnisse.push({ path: row.path, ok: false, error: `${neu} ist belegt` }) continue } try { await moveFile(row.path, neu) db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run( neu, now(), row.id, ) logEvent('move', neu, row.size, 'user', wer, row.path) ergebnisse.push({ path: row.path, ok: true, neu }) } catch (err) { ergebnisse.push({ path: row.path, ok: false, error: err instanceof Error ? err.message : 'unbekannt', }) } } const gut = ergebnisse.filter((e) => e.ok).length return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse }) }) /** Eine Datei umbenennen. * * Verschieben gab es, Umbenennen nicht -- und das ist genau derselbe Vorgang * mit einem anderen letzten Stueck. Getrennt gehalten wird es trotzdem, weil * die WARNUNG eine andere ist: beim Verschieben wandert eine Datei, beim * Umbenennen aendert sich IHRE ADRESSE, und die steht womoeglich in einem * Skript, das wir nicht sehen. * * DESHALB SAGT DIE ANTWORT, WAS DIE ALTE ADRESSE WAR und wie oft sie geholt * wurde. Eine Datei mit 4000 Abrufen umzubenennen ist etwas anderes als eine * mit null -- und der Dienst ist die einzige Stelle, die das weiss. * * Nur der NAME wird geaendert, nicht der Ordner. Wer beides will, benennt um * und verschiebt; zwei sichtbare Schritte sind hier besser als einer, der * beides kann und dabei unklar wird. */ dashRoutes.post('/media/umbenennen', async (c) => { const body = await jsonBody<{ id?: number; name?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) if (!body.id || !body.name?.trim()) return c.json({ error: 'id und name noetig' }, 400) const row = db.prepare('SELECT * FROM media WHERE id = ?').get(body.id) as Media | undefined if (!row) return c.json({ error: 'nicht gefunden' }, 404) // Der Name kommt aus einem Formular und wird NICHT gezaehmt, sondern // geprueft: wer hier umbenennt, weiss, welche Adresse er will. Ein // stillschweigend zurechtgebogener Name waere schlimmer als eine // Fehlermeldung -- man merkte es erst, wenn das Bild fehlt. const name = body.name.trim() if (name.includes('/')) { return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten — zum Verschieben gibt es "Verschieben".' }, 400) } const ordner = row.path.split('/').slice(0, -1).join('/') let neu: string try { neu = checkPath(ordner ? `${ordner}/${name}` : name) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } if (neu === row.path) return c.json({ error: 'heisst schon so' }, 400) if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)) { return c.json({ error: `${neu} ist belegt` }, 409) } await moveFile(row.path, neu) db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(neu, now(), row.id) logEvent('move', neu, row.size, 'user', c.get('user').username, row.path) return c.json({ alt: row.path, neu, url: publicUrlFor(neu), abrufe: row.abrufe }) }) /** Einen Ordner umbenennen -- mit allem, was darin liegt. * * DAS IST DER GROSSE HEBEL und deshalb der vorsichtigere Weg: hier aendern * sich auf einen Schlag hunderte Adressen. "items" nach "gegenstaende" zu * benennen macht 3576 URLs ungueltig, und keine davon meldet sich. * * Die Datenbank wird in EINER Transaktion umgeschrieben. Nicht aus Eile: wenn * auf halbem Weg etwas schiefgeht, zeigt sonst die Haelfte der Eintraege auf * einen Ordner, den es nicht mehr gibt. Das Verschieben auf der Platte ist ein * einziges `rename` und damit ohnehin unteilbar. */ dashRoutes.post('/folders/umbenennen', async (c) => { const body = await jsonBody<{ pfad?: string; name?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) if (!body.pfad?.trim() || !body.name?.trim()) { return c.json({ error: 'pfad und name noetig' }, 400) } const name = body.name.trim() if (name.includes('/')) { return c.json({ error: 'Der Name darf keinen Schraegstrich enthalten' }, 400) } let alt: string let neu: string try { alt = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, '')) const oben = alt.split('/').slice(0, -1).join('/') neu = checkPath(oben ? `${oben}/${name}` : name) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } if (neu === alt) return c.json({ error: 'heisst schon so' }, 400) // Der Zielordner darf nicht schon belegt sein -- weder in der Datenbank // noch auf der Platte. Sonst schoebe `rename` den einen IN den anderen // hinein, was etwas voellig anderes ist als umbenennen. const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`) const drin = db .prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`) .get(`${maskiert(neu)}/%`) as { n: number } if (drin.n > 0) return c.json({ error: `"${neu}" gibt es schon (${drin.n} Dateien)` }, 409) try { await stat(join(config.filesDir, neu)) return c.json({ error: `"${neu}" gibt es schon` }, 409) } catch { /* frei — weiter */ } const betroffen = db .prepare(`SELECT id, path, size FROM media WHERE path LIKE ? ESCAPE '\\'`) .all(`${maskiert(alt)}/%`) as { id: number; path: string; size: number }[] try { await moveFolder(alt, neu) } catch (err) { return c.json( { error: `Verschieben fehlgeschlagen: ${err instanceof Error ? err.message : 'unbekannt'}` }, 500, ) } const wer = c.get('user').username const zeit = now() const schreiben = db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?') db.transaction(() => { for (const r of betroffen) { schreiben.run(neu + r.path.slice(alt.length), zeit, r.id) } })() // EIN Eintrag im Verlauf und nicht 3576. Wer nachliest, was passiert ist, // will "items -> gegenstaende" lesen und nicht dreitausend Zeilen davon. logEvent('move', neu, 0, 'user', wer, alt) return c.json({ alt, neu, dateien: betroffen.length }) }) /** Einen leeren Ordner anlegen. * * Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor * dem letzten Schraegstrich, mehr nicht. Ein leerer Ordner ist deshalb ein * echtes Verzeichnis auf der Platte und in keiner Tabelle -- /folders liest * beides und fuehrt es zusammen. */ dashRoutes.post('/folders', async (c) => { const body = await jsonBody<{ pfad?: string }>(c) if (!body?.pfad?.trim()) return c.json({ error: 'kein Pfad' }, 400) let pfad: string try { pfad = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, '')) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } await mkdir(join(config.filesDir, pfad), { recursive: true }) return c.json({ ok: true, pfad }) }) /** Einen Ordner entfernen -- nur wenn er leer ist. * * Ein Ordner mit Inhalt wird NICHT mitgeloescht: das waere ein Knopf, der * hunderte Dateien wegraeumt, ohne sie zu zeigen. Wer sie loeschen will, * waehlt sie in der Galerie aus und sieht dabei, was verschwindet. */ dashRoutes.delete('/folders/:pfad{.+}', async (c) => { let pfad: string try { pfad = checkPath(c.req.param('pfad')) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } const drin = db .prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`) .get(`${pfad.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`) as { n: number } if (drin.n > 0) { return c.json({ error: `Der Ordner enthaelt noch ${drin.n} Dateien.` }, 409) } try { // rmdir und nicht rm: es scheitert VON SELBST, wenn der Ordner nicht // leer ist -- und genau das ist hier erwuenscht. `rm` ohne recursive // wirft dagegen EISDIR und sagt damit etwas ganz anderes, als der // Fall ist (einmal darauf hereingefallen). await rmdir(join(config.filesDir, pfad)) } catch (err) { return c.json( { error: err instanceof Error ? err.message : 'liess sich nicht entfernen' }, 400, ) } // Das Abgeleitete desselben Ordners hinterher, falls es da ist. HIER mit // recursive: anders als oben geht es nicht um eine Sicherung gegen // versehentliches Loeschen -- der Ordner mit den Originalen ist zu diesem // Zeitpunkt schon weg, und was hier noch liegt, ist nachrechenbar. for (const ordner of ['thumbs', 'webp']) { await rm(join(config.dataDir, ordner, pfad), { recursive: true, force: true }).catch( () => {}, ) } return c.json({ ok: true }) }) /** Die Medienliste zum Mitnehmen. * * Wofuer: ein Skript, das die Adressen kennen muss, ohne die API zu fragen -- * eine Vorlage im Handy, eine Liste zum Abgleichen, ein Datensatz fuer eine * andere Resource. Drei Formen, weil drei verschiedene Werkzeuge sie lesen. * * Die Lua-Form ist KEINE Spielerei: eine Datei, die man in eine Resource legt * und mit `local urls = ...` einliest, spart genau den Zwischenschritt, an dem * sonst jemand von Hand abtippt. * * Ohne Deckel: wer 900 Zeilen exportiert, will 900 Zeilen. Bei 228 Eintraegen * sind das 40 KB. */ dashRoutes.get('/export', (c) => { const form = c.req.query('format') ?? 'json' const ordner = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' const where = ordner ? `WHERE path LIKE ? ESCAPE '\\'` : '' const params = ordner ? [`${ordner.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`] : [] const rows = ( db.prepare(`SELECT * FROM media ${where} ORDER BY path COLLATE NOCASE`).all(...params) as Media[] ).map((row) => ({ ...row, url: publicUrlFor(row.path) })) const stempel = new Date().toISOString().slice(0, 10) if (form === 'csv') { // Semikolon: Excel in deutscher Einstellung nimmt sonst alles in eine // Spalte, und dann ist die Datei fuer den haeufigsten Leser wertlos. const kopf = 'pfad;url;groesse;typ;sha256;geaendert' const zeilen = rows.map((r) => [r.path, r.url, r.size, r.mime, r.sha256, new Date(r.updated_at).toISOString()] .map((f) => `"${String(f).replace(/"/g, '""')}"`) .join(';'), ) // CRLF, weil Excel es so erwartet — mit blossem \n steht die halbe // Datei in einer Zelle. return c.body([kopf, ...zeilen].join('\r\n'), 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.csv"`, }) } if (form === 'lua') { // Der Dateiname ohne Endung als Schluessel: genau danach sucht ein // Skript ("adder"), nicht nach "vehicles/adder.webp". const eintraege = rows.map((r) => { const name = (r.path.split('/').pop() ?? r.path).replace(/\.[^.]+$/, '') // "\\'" und nicht "\'": in JavaScript ist Letzteres schlicht ein // Apostroph, der Backslash faellt weg — und ein Modellname mit // Apostroph zerlegte damit die erzeugte Lua-Datei. return ` ['${name.replace(/'/g, "\\'")}'] = '${r.url}',` }) const text = [ '-- Erzeugt von d4rk_media am ' + new Date().toISOString(), '-- ' + rows.length + ' Eintraege' + (ordner ? ` aus ${ordner}/` : ''), '--', '-- Verwendung: local urls = require(...) bzw. in einer Resource', '-- als shared_script einbinden und Urls. lesen.', 'Urls = {', ...eintraege, '}', '', ].join('\n') return c.body(text, 200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.lua"`, }) } return c.body(JSON.stringify({ erzeugt: now(), anzahl: rows.length, items: rows }, null, 2), 200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.json"`, }) }) /** Speicher nach Medienart, groesste Dateien, und was fuer eine spaetere * Aufbewahrungsregel wichtig waere. * * ERST SEHEN, DANN ENTSCHEIDEN. Automatisches Loeschen zu bauen, bevor man * weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route * loescht nichts -- sie zeigt. */ /** Eine Datei in den Papierkorb legen: Datei verschieben, Datensatz umziehen. * * An EINER Stelle, weil es zwei Loeschwege gibt (einzeln und im Block) und * die beim naechsten Mal sonst auseinanderlaufen. */ async function inPapierkorb(row: Media, wer: string): Promise { const eintrag = db .prepare( `INSERT INTO papierkorb (path, size, sha256, mime, geloescht, von, ablage) VALUES (?, ?, ?, ?, ?, ?, '')`, ) .run(row.path, row.size, row.sha256, row.mime, now(), wer) const id = Number(eintrag.lastInsertRowid) try { const ablage = await inDenPapierkorb(row.path, id) db.prepare('UPDATE papierkorb SET ablage = ? WHERE id = ?').run(ablage, id) } catch (err) { // Konnte die Datei nicht verschoben werden, darf der Datensatz nicht // stehen bleiben -- sonst zeigt der Papierkorb etwas an, das sich nicht // zurueckholen laesst. db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id) throw err } db.prepare('DELETE FROM media WHERE id = ?').run(row.id) logEvent('delete', row.path, row.size, 'user', wer) } /** Was zu lange im Papierkorb liegt, ist endgueltig weg. * * Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag * aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */ export async function papierkorbAusleeren(): Promise { const alt = db .prepare('SELECT * FROM papierkorb WHERE geloescht < ?') .all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[] for (const e of alt) { await rm(papierkorbPfad(e.ablage), { force: true }) db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id) } if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege endgueltig entfernt`) return alt.length } dashRoutes.get('/papierkorb', (c) => { const items = db .prepare('SELECT * FROM papierkorb ORDER BY geloescht DESC LIMIT 500') .all() as PapierkorbEintrag[] const summe = db .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM papierkorb') .get() as { n: number; bytes: number } return c.json({ items, ...summe, haltbarTage: PAPIERKORB_TAGE }) }) /** Zurueck an ihren Platz. * * Ist der Pfad inzwischen wieder belegt, wird NICHT ueberschrieben: dann * verschwaende eine Datei, die jemand nach dem Loeschen bewusst dort abgelegt * hat. */ dashRoutes.post('/papierkorb/zurueck', async (c) => { const body = await jsonBody<{ ids?: number[] }>(c) if (!Array.isArray(body?.ids) || body.ids.length === 0) { return c.json({ error: 'keine Auswahl' }, 400) } const wer = c.get('user').username const ergebnisse: { path: string; ok: boolean; error?: string }[] = [] for (const id of body.ids) { const e = db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id) as | PapierkorbEintrag | undefined if (!e) { ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' }) continue } if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(e.path)) { ergebnisse.push({ path: e.path, ok: false, error: 'Pfad ist wieder belegt' }) continue } try { await ausDemPapierkorb(e.ablage, e.path) const zeit = now() db.prepare( `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, ?, ?)`, ).run(e.path, e.size, e.sha256, e.mime, zeit, zeit) db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id) // Vorschau und sparsame Fassung wurden beim Loeschen weggeworfen -- // beides ist abgeleitet. Jetzt neu rechnen, sonst zeigt die Galerie // ein Loch und die Datei kommt wieder in voller Groesse heraus. if (kannVorschau(e.mime) || lohntWebp(e.mime)) { try { const daten = await readFile(absolutePath(e.path)) if (kannVorschau(e.mime)) await writeThumb(e.path, daten) if (lohntWebp(e.mime)) await writeWebp(e.path, daten) } catch { /* die Galerie faellt aufs Vollbild zurueck */ } } logEvent('upload', e.path, e.size, 'user', wer) ergebnisse.push({ path: e.path, ok: true }) } catch (err) { ergebnisse.push({ path: e.path, ok: false, error: err instanceof Error ? err.message : 'unbekannt', }) } } const gut = ergebnisse.filter((e) => e.ok).length return c.json({ zurueck: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse }) }) /** Endgueltig weg. Hier gibt es kein Zurueck mehr, und die Oberflaeche sagt * das auch. */ dashRoutes.post('/papierkorb/leeren', async (c) => { const body = await jsonBody<{ ids?: number[] }>(c) const eintraege = ( Array.isArray(body?.ids) && body.ids.length > 0 ? body.ids .map((id) => db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id)) .filter(Boolean) : db.prepare('SELECT * FROM papierkorb').all() ) as PapierkorbEintrag[] let weg = 0 for (const e of eintraege) { await rm(papierkorbPfad(e.ablage), { force: true }) db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id) weg++ } return c.json({ geleert: weg }) }) // ------------------------------------------------------------- Freigaben // // Was hier verwaltet wird, ist die LISTE eines Ordners -- die Dateien selbst // sind ohnehin oeffentlich. Das steht auch in der Oberflaeche, damit niemand // glaubt, eine Freigabe zu widerrufen mache seine Bilder wieder privat. dashRoutes.get('/freigaben', (c) => { const items = ( db.prepare('SELECT * FROM freigaben ORDER BY created_at DESC').all() as Freigabe[] ).map((f) => ({ ...f, url: `${config.publicUrl}/s/${f.schluessel}` })) return c.json({ items }) }) dashRoutes.post('/freigaben', async (c) => { const body = await jsonBody<{ ordner?: string; titel?: string }>(c) if (!body?.ordner?.trim()) return c.json({ error: 'kein Ordner' }, 400) let ordner: string try { ordner = checkPath(body.ordner.trim().replace(/^\/+|\/+$/g, '')) } catch (err) { if (err instanceof PathError) return c.json({ error: err.message }, 400) throw err } // Einen Link auf einen Ordner anzulegen, den es nicht gibt, ergibt eine // leere Seite und die Frage, woran es liegt. Lieber hier ablehnen. const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`) const drin = db .prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`) .get(`${maskiert}/%`) as { n: number } if (drin.n === 0) return c.json({ error: `In "${ordner}" liegt nichts.` }, 400) const schluessel = neuerSchluessel() const eintrag = db .prepare( `INSERT INTO freigaben (schluessel, ordner, titel, created_at) VALUES (?, ?, ?, ?)`, ) .run(schluessel, ordner, (body.titel ?? '').trim(), now()) logEvent('upload', ordner, 0, 'user', c.get('user').username) return c.json({ id: Number(eintrag.lastInsertRowid), schluessel, ordner, dateien: drin.n, url: `${config.publicUrl}/s/${schluessel}`, }) }) dashRoutes.delete('/freigaben/:id', (c) => { const r = db.prepare('DELETE FROM freigaben WHERE id = ?').run(Number(c.req.param('id'))) if (r.changes === 0) return c.json({ error: 'nicht gefunden' }, 404) return c.json({ ok: true }) }) dashRoutes.get('/speicher', (c) => { const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => { const z = db .prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM media WHERE ${bedingung}`) .get() as { n: number; bytes: number } return { art: name, files: z.n, bytes: z.bytes } }) const groesste = ( db.prepare('SELECT * FROM media ORDER BY size DESC LIMIT 10').all() as Media[] ).map((r) => ({ ...r, url: publicUrlFor(r.path) })) const aelteste = ( db.prepare('SELECT * FROM media ORDER BY created_at ASC LIMIT 10').all() as Media[] ).map((r) => ({ ...r, url: publicUrlFor(r.path) })) // Derselbe Inhalt unter zwei Pfaden. Kein Fehler -- aber jedes Doppel ist // Platz, den niemand gewaehlt hat. const doppelte = db .prepare( `SELECT sha256, COUNT(*) AS n, SUM(size) AS bytes, GROUP_CONCAT(path, '|') AS pfade FROM media GROUP BY sha256 HAVING n > 1 ORDER BY bytes DESC LIMIT 20`, ) .all() as { sha256: string; n: number; bytes: number; pfade: string }[] const verschwendet = doppelte.reduce((s, d) => s + (d.bytes / d.n) * (d.n - 1), 0) // Wie alt der Bestand ist, in Stufen. Das ist die Zahl, die eine // Aufbewahrungsregel braucht: "aelter als 90 Tage" ist eine Behauptung, // solange niemand weiss, wie viel das waere. const stufen = [7, 30, 90, 365].map((tage) => { const z = db .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE created_at < ?') .get(now() - tage * 86_400_000) as { n: number; bytes: number } return { tage, files: z.n, bytes: z.bytes } }) // Die Frage, die der Bericht bisher nicht beantworten konnte. const nieGeholt = db .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE abrufe = 0') .get() as { n: number; bytes: number } const beliebteste = ( db.prepare('SELECT * FROM media WHERE abrufe > 0 ORDER BY abrufe DESC LIMIT 10').all() as Media[] ).map((r) => ({ ...r, url: publicUrlFor(r.path) })) return c.json({ nachArt, groesste, aelteste, doppelte, verschwendet, stufen, nieGeholt, beliebteste, }) }) 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 /** Was mit einem Token abgelegt wurde. * * `media.token_id` wird seit dem ersten Tag geschrieben und war bis hierhin * NIRGENDS zu sehen. Dabei ist es die Auskunft, die vor dem Loeschen eines * Tokens zaehlt: "dieser Token haelt 3576 Dateien" ist etwas anderes als * "dieser Token ist ungenutzt". * * Der Dashboard-Upload traegt bewusst KEINEN Token (dort gibt es eine * Sitzung), diese Dateien stehen deshalb unter `ohneToken`. */ function tokenNutzung(): Map { const zeilen = db .prepare( `SELECT token_id, COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes, MAX(created_at) AS letzte FROM media WHERE token_id IS NOT NULL GROUP BY token_id`, ) .all() as { token_id: number; dateien: number; bytes: number; letzte: number }[] return new Map(zeilen.map((z) => [z.token_id, z])) } dashRoutes.get('/tokens', (c) => { const rows = db .prepare( `SELECT id, name, prefix, can_delete, created_at, last_used_at, max_bytes, arten, expires_at FROM tokens ORDER BY created_at DESC`, ) .all() as Omit[] const nutzung = tokenNutzung() const ohneToken = db .prepare( 'SELECT COUNT(*) AS dateien, COALESCE(SUM(size),0) AS bytes FROM media WHERE token_id IS NULL', ) .get() as { dateien: number; bytes: number } return c.json({ items: rows.map((r) => ({ ...r, // Abgelaufen ist eine Eigenschaft, keine Rechnung fuer die // Oberflaeche: sonst steht in zwei Ansichten zweimal dieselbe // Bedingung, und eine davon ist irgendwann falsch. abgelaufen: Boolean(r.expires_at && r.expires_at < now()), dateien: nutzung.get(r.id)?.dateien ?? 0, bytes: nutzung.get(r.id)?.bytes ?? 0, // `last_used_at` sagt, wann zuletzt jemand mit diesem Token // ANGEKLOPFT hat -- auch bei einem Loeschversuch oder einem // Fehlschlag. Das hier sagt, wann zuletzt etwas dabei HERAUSKAM. zuletztAbgelegt: nutzung.get(r.id)?.letzte ?? null, })), ohneToken, }) }) dashRoutes.post('/tokens', async (c) => { const body = await jsonBody<{ name?: string prefix?: string canDelete?: boolean maxMb?: number | string arten?: string[] gueltigTage?: number | string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { name, prefix, canDelete } = body if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) // -- Die drei Grenzen -------------------------------------------------- let maxBytes: number | null = null if (body.maxMb !== undefined && String(body.maxMb).trim() !== '') { const mb = Number(body.maxMb) if (!Number.isFinite(mb) || mb <= 0) { return c.json({ error: 'Groessengrenze: eine Zahl groesser als 0' }, 400) } // Groesser als die Grenze des Dienstes zu erlauben waere eine // Behauptung: durchgelassen wird ohnehin die schaerfere. if (mb * 1024 * 1024 > config.maxUploadBytes) { return c.json( { error: `Der Dienst nimmt hoechstens ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB — mehr kann auch dieser Token nicht.`, }, 400, ) } maxBytes = Math.round(mb * 1024 * 1024) } const ERLAUBTE_ARTEN = ['bild', 'video', 'ton', 'dokument', 'andere'] let arten = '' if (Array.isArray(body.arten) && body.arten.length > 0) { const falsch = body.arten.filter((a) => !ERLAUBTE_ARTEN.includes(a)) if (falsch.length) return c.json({ error: `Unbekannte Art: ${falsch.join(', ')}` }, 400) // Alle fuenf gewaehlt heisst dasselbe wie keine Einschraenkung -- dann // steht in der Datenbank auch nichts, sonst muesste jede spaetere // Erweiterung der Artenliste alte Token nachziehen. arten = body.arten.length === ERLAUBTE_ARTEN.length ? '' : body.arten.join(',') } let expiresAt: number | null = null if (body.gueltigTage !== undefined && String(body.gueltigTage).trim() !== '') { const tage = Number(body.gueltigTage) if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) { return c.json({ error: 'Gueltigkeit: 1 bis 3650 Tage' }, 400) } expiresAt = now() + Math.round(tage) * 86_400_000 } // 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, max_bytes, arten, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now(), maxBytes, arten, expiresAt, ) // 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), maxBytes, arten, expiresAt, 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 }) }) /** Eine Testnachricht schicken. * * Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall * ausprobiert, ist ein Webhook, der dann nicht funktioniert. */ dashRoutes.post('/maintenance/melden', async (c) => { // Mit echten Zahlen statt "Hallo Welt": so sieht man nicht nur, DASS etwas // ankommt, sondern auch, ob es lesbar ist. const b = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size),0) AS bytes FROM media') .get() as { files: number; bytes: number } const ok = await testMeldung(c.get('user').username, b) if (!ok) { return c.json( { error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' }, 400, ) } return c.json({ ok: true }) }) dashRoutes.post('/maintenance/prune-sessions', (c) => { pruneSessions() pruneEvents() return c.json({ ok: true }) }) /** Was nicht zusammenpasst: Dateien ohne Datensatz, Datensaetze ohne Datei, * Vorschauen ohne Bild. * * ERST ZAEHLEN, DANN FRAGEN. Diese Route veraendert NICHTS -- sie sagt nur, * was sie sieht, mit Beispielen. Ein Knopf, der ungefragt aufraeumt, ist bei * einer Ablage ohne Papierkorb der falsche Knopf. * * Wie es dazu kommt: jemand loescht im Volume von Hand, ein Upload bricht * zwischen Datei und Datensatz ab, oder ein Bild wird ausserhalb des Dienstes * hineinkopiert. Das Sicherungsskript zaehlt beides schon gegeneinander und * meldet die Abweichung -- reparieren konnte es sie bisher nicht. */ async function verwaisteSuchen() { const rows = db.prepare('SELECT id, path, size FROM media').all() as { id: number path: string size: number }[] const bekannt = new Map(rows.map((r) => [r.path, r])) // Alles, was wirklich auf der Platte liegt. const aufPlatte: string[] = [] async function durchlaufen(rel: string) { let eintraege try { eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true }) } catch { return } for (const e of eintraege) { const p = rel ? `${rel}/${e.name}` : e.name if (e.isDirectory()) await durchlaufen(p) else if (e.isFile()) aufPlatte.push(p) } } await durchlaufen('') const dateiOhneDatensatz = aufPlatte.filter((p) => !bekannt.has(p)) const datensatzOhneDatei: typeof rows = [] for (const r of rows) { try { await stat(absolutePath(r.path)) } catch { datensatzOhneDatei.push(r) } } // Abgeleitetes ohne Bild -- Vorschauen UND sparsame Fassungen. Beide liegen // im selben Baum wie das Original mit ".webp" hintendran, deshalb wird die // Endung hier abgeschnitten, um zurueckzurechnen, und deshalb reicht EINE // Schleife fuer beide Ordner. const abgeleitetOhneBild: string[] = [] async function abgeleitetDurchlaufen(ordner: string, rel: string) { let eintraege try { eintraege = await readdir(join(config.dataDir, ordner, rel), { withFileTypes: true, }) } catch { return } for (const e of eintraege) { const p = rel ? `${rel}/${e.name}` : e.name if (e.isDirectory()) await abgeleitetDurchlaufen(ordner, p) else if (e.isFile() && !bekannt.has(p.replace(/\.webp$/, ''))) { // MIT Ordner davor: der Aufraeumer muss wissen, welche der // beiden Dateien er wegwerfen soll -- der Pfad allein ist in // thumbs/ und webp/ derselbe. abgeleitetOhneBild.push(`${ordner}/${p}`) } } } await abgeleitetDurchlaufen('thumbs', '') await abgeleitetDurchlaufen('webp', '') return { rows, dateiOhneDatensatz, datensatzOhneDatei, abgeleitetOhneBild } } dashRoutes.get('/maintenance/orphans', async (c) => { const f = await verwaisteSuchen() return c.json({ dateiOhneDatensatz: { anzahl: f.dateiOhneDatensatz.length, beispiele: f.dateiOhneDatensatz.slice(0, 20), }, datensatzOhneDatei: { anzahl: f.datensatzOhneDatei.length, beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path), }, abgeleitetOhneBild: { anzahl: f.abgeleitetOhneBild.length, beispiele: f.abgeleitetOhneBild.slice(0, 20), }, }) }) /** Aufraeumen -- in zwei Richtungen, und die schonende ist die Voreinstellung. * * 'aufnehmen' Dateien ohne Datensatz kommen in die Datenbank. Sie sind da, * sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist * fast immer das Richtige und loescht NICHTS. * 'aufraeumen' Datensaetze ohne Datei und verwaistes Abgeleitetes fliegen * raus. Hier geht etwas weg, aber nur Verweise auf Dateien, die * es nicht mehr gibt, und Bilder, die sich neu rechnen lassen -- * deshalb geht das hier AN DER PAPIERKORB VORBEI. Ein Papierkorb * voller Vorschaubilder waere keine Sicherheit, nur Ballast. */ dashRoutes.post('/maintenance/orphans', async (c) => { const body = await jsonBody<{ aktion?: string }>(c) const aktion = body?.aktion ?? 'aufnehmen' const f = await verwaisteSuchen() const wer = c.get('user').username if (aktion === 'aufnehmen') { let aufgenommen = 0 for (const pfad of f.dateiOhneDatensatz) { try { const daten = await readFile(absolutePath(pfad)) const zeit = now() db.prepare( `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, ?, ?)`, ).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit) if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten) if (lohntWebp(mimeFor(pfad))) await writeWebp(pfad, daten) logEvent('upload', pfad, daten.length, 'user', wer) aufgenommen++ } catch (err) { console.error('[verwaiste] ' + pfad, err) } } return c.json({ aktion, aufgenommen }) } if (aktion === 'aufraeumen') { let datensaetze = 0 for (const r of f.datensatzOhneDatei) { db.prepare('DELETE FROM media WHERE id = ?').run(r.id) logEvent('delete', r.path, r.size, 'user', wer) datensaetze++ } let vorschauen = 0 for (const p of f.abgeleitetOhneBild) { await rm(join(config.dataDir, p), { force: true }) vorschauen++ } return c.json({ aktion, datensaetze, vorschauen }) } return c.json({ error: 'unbekannte Aktion — "aufnehmen" oder "aufraeumen"' }, 400) }) /** 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. */ const THUMB_STAPEL = 300 dashRoutes.post('/maintenance/thumbs', async (c) => { const rows = db.prepare('SELECT id, path, mime FROM media').all() as Pick< Media, 'id' | 'path' | 'mime' >[] let erzeugt = 0 let vorhanden = 0 let uebersprungen = 0 let fehler = 0 let offen = 0 /** Ton ohne eingebettetes Titelbild. Kein Fehler — der Normalfall. */ let ohneBild = 0 for (const row of rows) { const bewegt = kannBewegtVorschau(row.mime) if (!kannVorschau(row.mime) && !bewegt) { uebersprungen++ continue } try { await stat(thumbPath(row.path)) vorhanden++ continue } catch { /* gibt es noch nicht — gleich */ } // Stapelweise, aus demselben Grund wie unten bei den sparsamen // Fassungen: nach 90 Sekunden gibt der Proxy auf, und ein 504 auf einen // Knopf, dessen Arbeit weiterlaeuft, ist eine Luege. // // Ein Standbild aus einem Video kostet DEUTLICH mehr als eines aus // einem PNG -- ffmpeg muss dafuer einen Container oeffnen und // hineinspringen. Es zaehlt hier trotzdem als eins; der Stapel ist mit // 300 klein genug, dass auch 300 Videos in die neunzig Sekunden passen. if (erzeugt + fehler + ohneBild >= THUMB_STAPEL) { offen++ continue } try { if (bewegt) { const { ergebnis, dauer } = await writeBewegtThumb(row.path, row.mime) if (ergebnis === 'ok') erzeugt++ else if (ergebnis === 'keinBild') ohneBild++ else fehler++ // Die Laenge faellt beim selben Blick in die Datei mit ab -- // auch dann, wenn es kein Titelbild gab. db.prepare('UPDATE media SET dauer = ? WHERE id = ?').run(dauer, row.id) } else { 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, ohneBild, gesamt: rows.length, offen, fertig: offen === 0, }) }) /** Die sparsamen Fassungen nachziehen. * * Dasselbe Muster wie bei den Vorschauen und aus demselben Grund: die * uebernommenen Bilder liegen laengst da, die Umkodierung kam spaeter. Ohne * diesen Knopf haetten nur neue Uploads eine sparsame Fassung — und genau die * 3670 Item-Bilder, um die es geht, keine. * * DAS ERGEBNIS NENNT BYTES, nicht nur Stueckzahlen. "1240 erzeugt" beantwortet * die Frage nicht, die man beim Druecken hatte; "spart 118 MB" schon. Dass * einige NICHT lohnen, steht als eigene Zahl da und ist kein Fehler: bei * kleinen PNG faellt WebP groesser aus, und dann bleibt es beim Original. * * UND ES LAEUFT IN STAPELN -- das ist nicht Vorsicht, das ist nachgemessen. * Der erste Lauf ueber 3861 Dateien brauchte weit ueber neunzig Sekunden, und * bei genau neunzig gibt Nginx Proxy Manager auf: `proxy_read_timeout`. Der * Aufrufer sah einen 504, die Arbeit lief unbemerkt weiter, und der Knopf log * damit den Anwender an. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer * als einer ohne Rueckmeldung. * * Also: hoechstens WEBP_STAPEL Stueck je Aufruf, der Rest wird gezaehlt und * als `offen` gemeldet. Die Oberflaeche ruft erneut auf, solange `fertig` * falsch ist -- und kann dabei endlich sagen, wie weit sie ist. */ const WEBP_STAPEL = 300 dashRoutes.post('/maintenance/webp', async (c) => { const rows = db.prepare('SELECT path, mime, size FROM media').all() as Pick< Media, 'path' | 'mime' | 'size' >[] let erzeugt = 0 let vorhanden = 0 let uebersprungen = 0 let lohntNicht = 0 let fehler = 0 let vorher = 0 let nachher = 0 let offen = 0 for (const row of rows) { if (!lohntWebp(row.mime)) { uebersprungen++ continue } try { const info = await stat(webpPfad(row.path)) vorhanden++ vorher += row.size nachher += info.size continue } catch { /* gibt es noch nicht — gleich */ } // DER STAPEL IST VOLL: ab hier wird nur noch GEZAEHLT, nicht mehr // gerechnet. Das kostet ein stat() je Datei und liefert dafuer eine // ehrliche Restzahl -- besser als ein "fertig?", das raten muss. if (erzeugt + lohntNicht + fehler >= WEBP_STAPEL) { offen++ continue } try { const data = await readFile(absolutePath(row.path)) const groesse = await writeWebp(row.path, data) if (groesse === null) { lohntNicht++ } else { erzeugt++ vorher += row.size nachher += groesse } } catch { // Ein Datensatz ohne Datei — wie oben ein Zaehler, kein Absturz. fehler++ } } return c.json({ erzeugt, vorhanden, uebersprungen, lohntNicht, fehler, gesamt: rows.length, offen, fertig: offen === 0, vorher, nachher, gespart: vorher - nachher, }) })