Files
d4rk_media/server/src/index.ts
T
D4rkst3randClaude Opus 5.5 a4e918be8a Sandbox auf ausgelieferte Dateien, die Skript tragen koennen (d4rk-rp-planung#254)
Ein SVG mit <script> lief direkt geoeffnet auf der Herkunft des Dashboards
(FILES_HOST leer) - mit dessen Sitzung. Jeder Upload-Token konnte es ablegen,
die Arten-Fessel "Bild" laesst SVG durch.

Jetzt: Content-Security-Policy "sandbox; default-src 'none'; ..." auf allem
ausser Rasterbild, Video und Ton (Positivliste, dateiKopf in storage.ts), auf
allen Wegen: /f/, /t/, Dateiwirt, /api/privat. Nicht auf Video und Bild,
weil die Sandbox ein direkt geoeffnetes Video anhaelt (readyState 0 statt 4).

Gemessen mit tools/csp-pruefen.mjs gegen einen Wegwerf-Container aus diesem
Stand, Edge kopflos: vorher lief das Skript (Gegenprobe --erwarte-lauf),
jetzt 8/8 - Skript laeuft nicht, SVG wird gezeichnet, PNG und MP4 wie vorher,
Teilantwort 206.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:57:08 +02:00

462 lines
19 KiB
TypeScript

// 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 { dateiKopf, 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'
import { privatRoutes } from './routes/privat.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<string, string> = {
'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',
// nosniff und die Sandbox gegen Skript-SVGs (#254) - storage.ts, dateiKopf.
...dateiKopf(typ),
}
// 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)
// Der private Bereich (d4rk-rp-planung#227): nur mit einem Token, der die
// Fessel "privat" traegt. VOR /api eingehaengt waere es dasselbe -- uploadRoutes
// kennt keinen Pfad /privat --, aber die eigene Zeile macht die Grenze sichtbar.
app.route('/api/privat', privatRoutes)
// Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/<schluessel> 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 <img>-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',
...dateiKopf('image/webp'),
})
})
// 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<string, string> = {
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}`)
})