// Der Einstiegspunkt. // // EIN NAME ODER ZWEI, ein Prozess in beiden Faellen. Was gilt, entscheidet // FILES_HOST, und der Start sagt es in einer Zeile. // // EIN NAME (Standard). Dashboard und API an der Wurzel, Dateien unter /f/: // https://media.d4rkst3r.de/f/vehicles/adder.webp // Ein DNS-Eintrag, ein Host im Proxy. Dafuer traegt die oeffentliche Adresse // auch das Anmeldeformular. // // ZWEI NAMEN. Unter FILES_HOST gibt es dann ausschliesslich Dateien — kein // Dashboard, keine API, nichts anzumelden: // https://cdn.example.de/vehicles/adder.webp // Das haelt den Namen, den jeder Spieler kennt, ohne Angriffsflaeche und // spart das Praefix. Unter dem anderen Namen laeuft das Dashboard, und die // Dateien sind dort zusaetzlich unter /f/ erreichbar. // // In beiden Faellen gilt: DER PFAD IST DER SCHLUESSEL. vehicles/adder.webp // bleibt vehicles/adder.webp, damit die Vorlage im Handy {model}.webp lauten // kann. Nur der Teil davor aendert sich — und der steht in PUBLIC_URL. import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join, resolve, sep } from 'node:path' import { serve } from '@hono/node-server' import { Hono } from 'hono' import { logger } from 'hono/logger' import { config } from './config.js' import { ensureAdmin } from './auth.js' import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js' import { pruefePlatte, pruefeSicherung } from './meldung.js' import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js' import { uploadRoutes } from './routes/upload.js' import { fivemanageRoutes } from './routes/fivemanage.js' import { freigabeRoutes } from './routes/freigabe.js' import { statusRoutes } from './routes/status.js' import { dashRoutes, papierkorbAusleeren } from './routes/dash.js' const app = new Hono() if (config.dev) app.use('*', logger()) // Ein unerwarteter Fehler bekommt einen TEXT, keinen nackten 500. // // Genau das ist der Fehler, wegen dem dieses Projekt existiert: bei Fivemanage // endete jeder Fehlschlag als "Internal Server Error" ohne ein Wort dazu. Wir // haben das an den bekannten Stellen einzeln abgefangen -- und prompt hat ein // Tippfehler in einer SQL-Zeile beim Export genau diese nackte Antwort // erzeugt. Eine Auffanglinie an EINER Stelle schliesst die Luecke fuer alles, // woran heute niemand denkt. // // Die Meldung geht mit hinaus. Das ist eine bewusste Abwaegung: dieses // Dashboard hat einen Benutzerkreis von einer Person, und ein Fehler, den man // nicht lesen kann, kostet mehr als er verbirgt. Die Spur bleibt zusaetzlich // in der Konsole. app.onError((err, c) => { console.error('[fehler]', c.req.method, c.req.path, err) const text = err instanceof Error ? err.message : String(err) if (c.req.path.startsWith('/api/')) { return c.json({ error: text }, 500) } return c.text(`Serverfehler: ${text}`, 500) }) // Ein Fehler AUSSERHALB jedes try/catch darf den Dienst nicht umbringen. // // Genau das ist beim Uebernehmen von 3670 Dateien passiert: undici warf // "ReadableStream is already closed" aus einem Microtask -- also an einer // Stelle, an der kein try/catch der Welt steht. Node beendet den Prozess // daraufhin, Docker startet ihn neu, die naechste Anfrage kippt ihn wieder: // eine Neustartschleife, und nach aussen 502. // // DIE ABWAEGUNG, ausgesprochen: einen unerwarteten Fehler zu ueberleben ist // nicht in jedem Fall richtig -- der Prozess KANN danach in einem kaputten // Zustand sein. Hier ueberwiegt trotzdem das Weiterlaufen: dieser Dienst haelt // keinen Zustand im Speicher, der verderben koennte (alles steht in SQLite und // auf der Platte), und ein Dienst, der wegen EINER Anfrage fuer alle weg ist, // ist der schlechtere Tausch. // // Laut wird es trotzdem: die Spur steht vollstaendig in der Konsole. process.on('uncaughtException', (err) => { console.error('[unerwartet] uncaughtException — der Dienst laeuft weiter:', err) }) process.on('unhandledRejection', (grund) => { console.error('[unerwartet] unhandledRejection — der Dienst laeuft weiter:', grund) }) // -------------------------------------------------------- Dateien ausliefern /** Liefert eine Datei aus, mit ETag und Bereichsanfragen. * * Bereichsanfragen sind kein Luxus: ohne sie kann ein Browser in einem Video * nicht springen, und ein abgebrochener Download faengt von vorn an. */ async function serveFile(c: any, rawPath: string) { let path: string try { path = checkPath(decodeURIComponent(rawPath)) } catch { return c.text('nicht gefunden', 404) } let absolute = absolutePath(path) let info try { info = await stat(absolute) if (!info.isFile()) return c.text('nicht gefunden', 404) } catch { return c.text('nicht gefunden', 404) } // DIE SPARSAME FASSUNG, unter DERSELBEN Adresse. // // Der Browser sagt mit `Accept`, was er versteht. Versteht er WebP und // liegt eine Fassung daneben, bekommt er sie. Die Adresse bleibt // items/foo.png, und kein Skript merkt etwas davon. Lua, curl und alles // ohne diese Ansage bekommen unveraendert das Original. // // Der Vergleich ist absichtlich stumpf: `Accept` traegt Gewichte, und ein // "image/webp;q=0" hiesse eigentlich "bitte nicht". Das schickt in der // Praxis niemand -- und faellt jemand doch darauf herein, bekommt er ein // gueltiges Bild, nur in einem anderen Format. Der Preis eines Fehlers ist // hier klein genug, dass die einfache Pruefung die richtige ist. let typ = mimeFor(path) let sparsam = false if (lohntWebp(typ) && (c.req.header('accept') ?? '').includes('image/webp')) { try { const wInfo = await stat(webpPfad(path)) if (wInfo.isFile()) { absolute = webpPfad(path) info = wInfo typ = 'image/webp' sparsam = true } } catch { /* keine sparsame Fassung -- dann eben das Original */ } } // Das Kennzeichen gehoert IN den ETag: sonst traegt das Original und die // sparsame Fassung dieselbe Marke, sobald beide zufaellig gleich gross und // gleich alt sind, und ein Aufrufer bekaeme auf seinen 304 hin das falsche // Bild aus seinem eigenen Zwischenspeicher. const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}${sparsam ? '-w' : ''}"` const headers: Record = { 'Content-Type': typ, 'Cache-Control': 'public, max-age=300', // OHNE DIESE ZEILE legt ein Zwischenspeicher die WebP-Fassung fuer ALLE // ab -- auch fuer die, die sie nicht lesen koennen. Vary: 'Accept', ETag: etag, 'Last-Modified': info.mtime.toUTCString(), 'Accept-Ranges': 'bytes', // Kein Verlassen auf den Dateinamen: der Browser soll nicht raten, // was er da vor sich hat. 'X-Content-Type-Options': 'nosniff', } // GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung. // // Auch der 304 zaehlt: der Aufrufer WOLLTE die Datei, er hatte sie nur // schon. Fuer die Frage "braucht das noch jemand" ist das dasselbe. // HEAD zaehlt nicht -- das fragt nach der Datei, nicht nach dem Inhalt. if (c.req.method !== 'HEAD') zaehleAbruf(path) if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers) if (c.req.method === 'HEAD') { return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) }) } const range = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '') if (range) { const start = range[1] ? Number(range[1]) : 0 const end = range[2] ? Number(range[2]) : info.size - 1 if (start >= info.size || end >= info.size || start > end) { return c.body(null, 416, { 'Content-Range': `bytes */${info.size}` }) } return c.body(createReadStream(absolute, { start, end }) as any, 206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${info.size}`, 'Content-Length': String(end - start + 1), }) } return c.body(createReadStream(absolute) as any, 200, { ...headers, 'Content-Length': String(info.size), }) } // ------------------------------------------------------------ Der Dateiwirt app.use('*', async (c, next) => { // Kein zweiter Name gesetzt: dann gibt es hier nichts zu entscheiden. // Ohne diese Zeile wuerde im Ein-Namen-Betrieb jede Anfrage als Anfrage an // den Dateiwirt gelten und die eigene API verschwinden. if (!config.filesHost) return next() const host = (c.req.header('host') ?? '').split(':')[0]?.toLowerCase() if (host !== config.filesHost.split(':')[0]?.toLowerCase()) return next() if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { return c.text('hier gibt es nur Dateien', 405) } if (c.req.path === '/') return c.text('d4rk_media', 200) return serveFile(c, c.req.path.slice(1)) }) // ------------------------------------------------------- Dashboard und API // Der HEALTHCHECK des Containers haengt hier dran, und deshalb bleibt es // billig: eine schwere Pruefung, die bei einer langsamen Platte einmal // ausfaellt, liesse Docker den Container neu starten -- also genau dann, wenn // er unter Last steht. // // Was diese Zeile beweist, ist genau eines: der Prozess nimmt Anfragen an. // Fuer eine STATUSSEITE ist das zu duenn -- dafuer gibt es /status daneben, // das wirklich nachsieht. app.get('/health', (c) => c.json({ ok: true })) // Fuer die Statusseite. Oeffentlich wie /health, aber es prueft Datenbank, // Platte, Ausliefern und die Sicherung -- und antwortet mit 503, wenn der // Dienst sein Geschaeft nicht mehr tun kann. app.route('/status', statusRoutes) app.route('/api', uploadRoutes) // NACH uploadRoutes: unsere eigenen Wege haben Vorrang, falls je ein Name // zusammenfaellt. Derzeit tut er das nicht (/upload, /exists, /media gegen // /image, /video, /audio, /file, /v3/file), aber die Reihenfolge sagt, was // gelten soll, wenn es einmal so kommt. app.route('/api', fivemanageRoutes) app.route('/api/dash', dashRoutes) // Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/ laesst // sich vorlesen und abtippen. app.route('/api/s', freigabeRoutes) app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3))) /** Die Vorschaubilder, unter /t/. * * Absichtlich NICHT unter dem Dateihost und absichtlich ohne Anmeldung: die * Bilder selbst sind ohnehin oeffentlich, und das Dashboard laedt die * Vorschauen mit gewoehnlichen -Marken — mit Anmeldung braeuchte es * dafuer einen Umweg ueber fetch und Blob-Adressen. * * Fehlt eine Vorschau, kommt 404 und das Dashboard nimmt das Vollbild. Kein * Erzeugen auf Zuruf: eine Route, die bei jeder Anfrage rechnen kann, ist * eine Route, die sich mit 900 gleichzeitigen Anfragen umbringen laesst. */ app.get('/t/*', async (c) => { const roh = c.req.path.slice(3) let path: string try { path = checkPath(decodeURIComponent(roh)) } catch { return c.text('nicht gefunden', 404) } const absolute = thumbPath(path) let info try { info = await stat(absolute) if (!info.isFile()) return c.text('nicht gefunden', 404) } catch { return c.text('nicht gefunden', 404) } const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"` if (c.req.header('if-none-match') === etag) return c.body(null, 304) return c.body(createReadStream(absolute) as any, 200, { 'Content-Type': 'image/webp', 'Content-Length': String(info.size), ETag: etag, // Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen // wird, und dann soll das Dashboard es sehen. 'Cache-Control': 'public, max-age=60', 'X-Content-Type-Options': 'nosniff', }) }) // Die gebaute Oberflaeche. Alles, was keine API ist und keine Datei, bekommt // index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router. // fileURLToPath und nicht .pathname: unter Windows liefert letzteres // "/C:/..." und jedes stat() darauf schlaegt fehl. const webDir = resolve(fileURLToPath(new URL('../web/', import.meta.url))) /** Der absolute Pfad zu einer Datei der Oberflaeche — oder null, wenn er aus * dem Ordner herauszeigt. * * Dieselbe Guertel-und-Hosentraeger-Regel wie in storage.ts, und aus demselben * Grund: der Pfad kommt vom Aufrufer. Ohne die Pruefung hat * `GET /..%5Cpackage.json` unter Windows die Datei ausgeliefert — Hono reicht * %5C unveraendert durch, und path.join behandelt den Backslash dort als * Trenner. Unter Linux traegt derselbe Angriff nicht, aber das ist Glueck und * keine Abwehr. */ function webFile(candidate: string): string | null { const target = resolve(webDir, decodeSafely(candidate).replace(/^[/\\]+/, '')) if (target !== webDir && !target.startsWith(webDir + sep)) return null return target } /** %5C und Konsorten aufloesen, bevor geprueft wird — sonst prueft die * Eindaemmung eine andere Zeichenkette als die, die spaeter im Dateisystem * landet. Ein kaputtes Prozentzeichen ist kein Grund abzustuerzen. */ function decodeSafely(raw: string): string { try { return decodeURIComponent(raw) } catch { return raw } } /** Die Dateitypen der Oberflaeche — bewusst eine EIGENE Tabelle und nicht die * aus storage.ts. * * Dort fehlen html, css und js mit gutem Grund: eine hochgeladene .html wird * unter dem Dateihost als application/octet-stream ausgeliefert und damit * heruntergeladen statt ausgefuehrt. Stuende html in der geteilten Tabelle, * koennte jeder mit einem Upload-Token eine Seite unter fivecdn.d4rkst3r.de * veroeffentlichen. Die gebaute Oberflaeche liegt dagegen im Abbild und kommt * von uns — hier ist der richtige Typ noetig, sonst laedt der Browser weder * Stylesheet noch Modul. */ const WEB_MIME: Record = { html: 'text/html; charset=utf-8', // webmanifest steht hier und nicht in der geteilten Tabelle: es ist eine // Datei der Oberflaeche. Ohne den Eintrag kaeme sie als // application/octet-stream, und der Browser wuerde sie verwerfen. webmanifest: 'application/manifest+json; charset=utf-8', css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', map: 'application/json; charset=utf-8', ico: 'image/x-icon', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', } const webMimeFor = (path: string) => WEB_MIME[path.split('.').pop()?.toLowerCase() ?? ''] ?? mimeFor(path) app.get('*', async (c) => { if (c.req.path.startsWith('/api/')) return c.json({ error: 'unbekannt' }, 404) const candidate = c.req.path === '/' ? '/index.html' : c.req.path const target = webFile(candidate) if (target) { try { const info = await stat(target) if (info.isFile()) { return c.body(createReadStream(target) as any, 200, { 'Content-Type': webMimeFor(candidate), 'Cache-Control': candidate.includes('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache', }) } } catch { /* faellt unten auf index.html */ } } // Erst nachsehen, dann streamen. createReadStream meldet eine fehlende // Datei ASYNCHRON ueber ein 'error'-Ereignis — ein try/catch darum faengt // nichts. Vorher stand hier genau das, und das Ergebnis war bei ungebauter // Oberflaeche ein leerer 200 samt ENOENT im Log statt der Auskunft unten. const index = join(webDir, 'index.html') try { if ((await stat(index)).isFile()) { return c.body(createReadStream(index) as any, 200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', }) } } catch { /* faellt auf die Auskunft unten */ } return c.text( 'Die Oberflaeche ist nicht gebaut. Der Dienst laeuft trotzdem — ' + 'die API steht unter /api.', 200, ) }) // --------------------------------------------------------------------- Start ensureAdmin() pruneSessions() setInterval(pruneSessions, 3600_000).unref() // UND DIE EREIGNISSE, taeglich. Das stand bisher NUR an einem Wartungsweg // (/maintenance/prune-sessions), den nicht einmal die Oberflaeche anbietet -- // die dokumentierten 180 Tage Aufbewahrung griffen damit nie, und die Tabelle // wuchs fuer immer. Nach einem einzigen Tag mit Umzug und Serienlauf standen // dort 5355 Zeilen. // // Nicht stuendlich wie die Sitzungen: ein DELETE ueber eine Zeitspanne von // einem halben Jahr hat es nicht eilig, und 24 Stunden Verzoegerung beim // Wegraeumen fallen niemandem auf. setTimeout(() => { pruneEvents() pruneVerwaltung() }, 120_000).unref() setInterval(() => { pruneEvents() pruneVerwaltung() }, 86_400_000).unref() // Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst // soll erst stehen) und danach stuendlich. // // DAS IST DER PUNKT DER GANZEN MELDUNGSFUNKTION: eine Sicherung, die aufhoert // zu laufen, faellt sonst erst auf, wenn man sie braucht. setTimeout(() => void pruefeSicherung(), 60_000).unref() setInterval(() => void pruefeSicherung(), 3600_000).unref() // Und der Platz auf der Platte. Derselbe Takt und derselbe Grund: eine volle // Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und // nichts mehr gesichert wird. setTimeout(() => void pruefePlatte(), 75_000).unref() setInterval(() => void pruefePlatte(), 3600_000).unref() // Die gezaehlten Abrufe wegschreiben. Alle 30 Sekunden reicht: wer wissen // will, ob eine Datei benutzt wird, braucht keine Genauigkeit auf die Sekunde. setInterval(schreibeAbrufe, 30_000).unref() // Und beim Herunterfahren, damit die letzten nicht verlorengehen. for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, () => { schreibeAbrufe() process.exit(0) }) } // Den Papierkorb ausleeren, was ueberfaellig ist. Beim Start mit Verzoegerung, // danach stuendlich. setTimeout(() => void papierkorbAusleeren(), 90_000).unref() setInterval(() => void papierkorbAusleeren(), 3600_000).unref() serve({ fetch: app.fetch, port: config.port }, (info) => { console.log(`[d4rk_media] Port ${info.port}`) console.log( `[d4rk_media] Dateien oeffentlich unter ${config.publicUrl}${config.filePrefix}/`, ) // Welche Betriebsart laeuft, steht beim Start da und nicht im Quelltext. // Wer sich wundert, warum das Dashboard nicht aufgeht, liest hier zuerst. console.log( config.filesHost ? `[d4rk_media] Zwei Namen: Dateiwirt hoert auf "${config.filesHost}", Dashboard und API unter jedem anderen` : '[d4rk_media] Ein Name: Dashboard und API an der Wurzel, Dateien unter /f/', ) console.log(`[d4rk_media] Daten in ${config.dataDir}`) })