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>
462 lines
19 KiB
TypeScript
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}`)
|
|
})
|