diff --git a/ROADMAP.md b/ROADMAP.md index e2b640d..e10e825 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,16 +27,27 @@ kein zweiter Dienst, kein Passwort dazwischen, und eine Sicherung ist ein `cp -a` über ein Volume. Wenn daraus je Millionen Zeilen werden, wird `server/src/db.ts` ausgetauscht — die Aufrufer merken davon nichts. -**Zwei Hostnamen, ein Prozess.** +**Ein Name.** `fivecdn.d4rkst3r.de` und `fivemanage.d4rkst3r.de` fallen weg — +wir betreiben kein Fivemanage, also tragen unsere Adressen auch nicht dessen +Namen. Dashboard und API liegen an der Wurzel, die Dateien unter `/f/`: -| | | -|---|---| -| `fivecdn.d4rkst3r.de` | nur Dateien. Kein Dashboard, keine API, nichts anzumelden | -| `fivemanage.d4rkst3r.de` | Dashboard und API | +``` +https://media.d4rkst3r.de/ Dashboard und API +https://media.d4rkst3r.de/f/vehicles/adder.webp +``` -Das hält die öffentliche Adresse frei von Angriffsfläche und die URLs hübsch: -`https://fivecdn.d4rkst3r.de/vehicles/adder.webp`, ohne `/f/`-Präfix. Die -Vorlage im Handy bleibt damit schlicht `{model}.webp`. +Ein DNS-Eintrag, ein Host im Proxy. Aufgegeben ist damit eine Eigenschaft, die +zwei Namen mitbrächten: die öffentliche Adresse trägt jetzt auch das +Anmeldeformular. + +Zwei Namen kann der Dienst weiterhin — `FILES_HOST` setzen, dann gibt es unter +diesem Namen ausschließlich Dateien und die URLs kommen ohne `/f/` aus. Beides +ist gemessen. Der Wechsel ist billig: die Adressen stehen **nirgends in der +Datenbank**, sie werden bei jeder Antwort aus `PUBLIC_URL` gebaut. + +**Der Pfad bleibt in beiden Fällen der Schlüssel.** `vehicles/adder.webp` +bleibt `vehicles/adder.webp`, die Vorlage im Handy also `{model}.webp`. Nur der +Teil davor ändert sich, und der steht in einer Variablen. **Der Pfad ist der Schlüssel.** `X-Path: vehicles/adder.webp` legt die Datei genau dort ab. Vorhersagbare URLs sind für das Fotostudio die ganze @@ -100,13 +111,14 @@ cp server/.env.example server/.env npm --prefix server ci && npm --prefix server run dev ``` -> **Der Stolperstein beim lokalen Start:** ohne `FILES_HOST` nimmt der Dienst -> den Host aus `PUBLIC_URL` — lokal also `localhost`. Dann gilt *jede* Anfrage -> als Anfrage an den Dateiwirt, und dort gibt es absichtlich weder Dashboard -> noch API: `/health` antwortet 404, die Anmeldung 405 „hier gibt es nur -> Dateien". Nichts ist kaputt. In Produktion sind es zwei verschiedene Namen -> und der Standard stimmt; lokal gehört ein Name hin, den niemand aufruft. -> Steht samt Begründung in `server/.env.example`. +> **Der Stolperstein, den es nicht mehr gibt.** Anfangs war der Standard für +> `FILES_HOST` der Host aus `PUBLIC_URL`. Bei zwei Namen ist das richtig, bei +> einem tödlich: dann galt *jede* Anfrage als Anfrage an den Dateiwirt, +> `/health` gab 404 und die Anmeldung 405 „hier gibt es nur Dateien". Nichts +> war kaputt, und niemand kommt darauf. Seit der Entscheidung für einen Namen +> ist der Ein-Namen-Betrieb der Standard und der zweite Name die Ansage — +> lokal läuft es damit ohne Sonderbehandlung. Welche Betriebsart gilt, sagt +> der Dienst in der zweiten Startzeile. **Was der Test gefunden hat** — alles im Grundgerüst, alles repariert: @@ -150,6 +162,20 @@ HEAD, Bereichsanfragen und 416 · unter dem Dateihost gibt es weder API noch Nicht getestet: Docker, das Dashboard (gibt es noch nicht), und echte Last. +### ✅ Fertig — ein Name statt zwei + +`PUBLIC_URL` ist jetzt nur noch der Ursprung; das `/f` hängt der Dienst selbst +an. Wer es mitschriebe, bekäme Adressen mit `/f/f/` — und das fiele erst auf, +wenn das erste Bild fehlt. Prefix und Route werden deshalb an genau **einer** +Stelle entschieden (`config.filePrefix`), damit die zurückgegebene Adresse und +die Route, die sie ausliefert, nicht auseinanderlaufen können. + +Beide Betriebsarten sind durchgemessen: mit einem Namen kommt +`…/f/vehicles/adder.png` zurück und liefert die Datei; mit gesetztem +`FILES_HOST` kommt sie ohne Präfix, liegt unter dem Dateihost, ist am +Dashboard-Namen zusätzlich unter `/f/` erreichbar, und unter dem Dateihost gibt +es weiterhin keine API. + ### ⬜ Als Nächstes **2 · Dockerfile und Compose.** Ein Abbild, das Server und gebaute Oberfläche diff --git a/server/.env.example b/server/.env.example index fe92e88..595871f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -3,26 +3,32 @@ # Ohne PUBLIC_URL und ADMIN_PASSWORD startet der Dienst nicht — das ist # Absicht, siehe config.ts. -# Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL -# gebaut, die nach dem Upload zurueckkommt. Ohne abschliessenden Schraegstrich. -PUBLIC_URL=http://localhost:8080/f +# Unter welcher Adresse der Dienst oeffentlich steht. NUR der Ursprung, ohne +# Pfad und ohne abschliessenden Schraegstrich. +# +# Das /f/ haengt der Dienst selbst an. Wer es hier mitschreibt, bekommt +# Adressen mit /f/f/ — und das faellt erst auf, wenn das erste Bild fehlt. +# +# In Produktion also: PUBLIC_URL=https://media.d4rkst3r.de +PUBLIC_URL=http://localhost:8080 -# DER STOLPERSTEIN BEIM LOKALEN START. +# EIN NAME ODER ZWEI. # -# Ohne diese Zeile nimmt der Dienst den Host aus PUBLIC_URL — lokal also -# "localhost". Dann gilt jede Anfrage als Anfrage an den Dateiwirt, und dort -# gibt es absichtlich weder Dashboard noch API: /health antwortet 404, die -# Anmeldung 405 "hier gibt es nur Dateien". Nichts ist kaputt, der Dienst tut -# genau das, was er soll — nur eben unter dem einen Namen, den man lokal hat. +# Leer lassen (Standard) heisst EIN Name: Dashboard und API an der Wurzel, +# Dateien unter /f/. Die zurueckgegebene Adresse lautet dann +# https://media.d4rkst3r.de/f/vehicles/adder.webp. Ein DNS-Eintrag, ein Host +# im Proxy — dafuer traegt die oeffentliche Adresse auch das Anmeldeformular. # -# In Produktion sind es zwei verschiedene Namen und der Standard stimmt: -# PUBLIC_URL=https://fivecdn.d4rkst3r.de -# FILES_HOST=fivecdn.d4rkst3r.de (Dashboard: fivemanage.d4rkst3r.de) +# Wer hier einen Hostnamen eintraegt, bekommt ZWEI Namen: unter diesem gibt es +# dann ausschliesslich Dateien, kein Dashboard, keine API, nichts anzumelden. +# Die URLs kommen ohne /f/ aus, und der Name, den jeder Spieler kennt, hat +# keine Angriffsflaeche. Beide Namen muessen im Proxy auf denselben Port +# zeigen; PUBLIC_URL ist dann der Datei-Name: +# PUBLIC_URL=https://cdn.d4rkst3r.de +# FILES_HOST=cdn.d4rkst3r.de # -# Lokal deshalb ein Name, den niemand aufruft — dann bleibt localhost fuer -# Dashboard und API frei. Zum Testen des Dateiwirts: -# curl -H "Host: files.localhost" http://localhost:8080/vehicles/adder.webp -FILES_HOST=files.localhost +# Welche Betriebsart laeuft, sagt der Dienst beim Start. +FILES_HOST= # Wird beim ERSTEN Start angelegt. Ein spaeter geaendertes Passwort setzt das # bestehende nicht zurueck. diff --git a/server/src/config.ts b/server/src/config.ts index acc5e9c..e2541ae 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -40,11 +40,30 @@ for (const dir of [dataDir, filesDir]) { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) } -/** Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL +/** Unter welcher Adresse der Dienst oeffentlich steht. Daraus wird die URL * gebaut, die nach dem Upload zurueckkommt — und die im Handy in der Vorlage - * steht. Ohne abschliessenden Schraegstrich. */ + * steht. Nur der Ursprung, ohne Pfad und ohne abschliessenden Schraegstrich: + * https://media.d4rkst3r.de + * + * Das /f/ im Ein-Namen-Betrieb haengt der Dienst selbst an. Wer es hier + * mitschriebe, bekaeme Adressen mit /f/f/ — und die faende niemand, bis das + * erste Bild fehlt. */ const publicUrl = required('PUBLIC_URL').replace(/\/+$/, '') +/** Ein Name oder zwei? + * + * Gesetzt → ZWEI Namen: unter diesem Host gibt es ausschliesslich Dateien, + * kein Dashboard, keine API. Die oeffentliche Adresse hat damit + * keine Angriffsflaeche, und die URLs kommen ohne Praefix aus. + * Leer → EIN Name: Dashboard und API an der Wurzel, Dateien unter /f/. + * + * Frueher war der Standard der Host aus PUBLIC_URL. Das ist bei zwei Namen + * richtig und bei einem toedlich: dann gilt JEDE Anfrage als Anfrage an den + * Dateiwirt, /health antwortet 404 und die Anmeldung 405 "hier gibt es nur + * Dateien". Nichts ist kaputt, und niemand kommt darauf. Deshalb ist der + * Ein-Namen-Betrieb jetzt der Standard und der zweite Name die Ansage. */ +const filesHost = (process.env.FILES_HOST ?? '').trim().toLowerCase() + export const config = { port: number('PORT', 8080), dataDir, @@ -53,16 +72,14 @@ export const config = { publicUrl, - /** Der Hostname, unter dem NUR Dateien ausgeliefert werden. - * - * Trifft eine Anfrage unter diesem Namen ein, gibt es weder Dashboard - * noch API — nur GET auf Dateien. Das haelt die oeffentliche Adresse frei - * von jeder Angriffsflaeche und macht die URLs zugleich huebsch: - * https://fivecdn.d4rkst3r.de/vehicles/adder.webp statt eines /f/-Praefix. - * - * Leer lassen, wenn alles unter einem Namen laufen soll — dann liegen die - * Dateien unter /f/. */ - filesHost: optional('FILES_HOST', new URL(publicUrl).host), + /** Der Hostname, unter dem NUR Dateien ausgeliefert werden — oder leer, + * wenn alles unter einem Namen laeuft. Siehe oben. */ + filesHost, + + /** Was zwischen Adresse und Pfad steht. Bei zwei Namen nichts, bei einem + * "/f" — beides an genau EINER Stelle entschieden, damit die gebaute URL + * und die Route, die sie ausliefert, nicht auseinanderlaufen koennen. */ + filePrefix: filesHost ? '' : '/f', /** Wird beim Start angelegt, falls es noch keinen Benutzer gibt. */ adminPassword: required('ADMIN_PASSWORD'), diff --git a/server/src/index.ts b/server/src/index.ts index ea78364..3aad46c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,19 +1,23 @@ // Der Einstiegspunkt. // -// ZWEI HOSTNAMEN, EIN PROZESS. Kommt eine Anfrage unter FILES_HOST herein, -// gibt es nur Dateien — kein Dashboard, keine API, kein Anmeldeformular. Das -// hat zwei Gruende: +// EIN NAME ODER ZWEI, ein Prozess in beiden Faellen. Was gilt, entscheidet +// FILES_HOST, und der Start sagt es in einer Zeile. // -// 1. Die oeffentliche Adresse bleibt huebsch. Das Handy fragt -// https://fivecdn.d4rkst3r.de/vehicles/adder.webp ab und nicht -// irgendein /f/-Praefix — die Vorlage lautet weiterhin schlicht -// {model}.webp. -// 2. Der Name, der im Internet steht und den jeder Spieler kennt, hat -// ueberhaupt keine Angriffsflaeche. Es gibt dort nichts anzumelden. +// 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. // -// Unter dem anderen Namen laeuft das Dashboard samt API, und die Dateien sind -// dort zusaetzlich unter /f/ erreichbar — praktisch fuer die Galerie, wenn -// jemand den zweiten Hostnamen noch nicht eingerichtet hat. +// 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' @@ -96,6 +100,11 @@ async function serveFile(c: any, rawPath: string) { // ------------------------------------------------------------ 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() @@ -225,7 +234,15 @@ setInterval(pruneSessions, 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}`) - console.log(`[d4rk_media] Dateiwirt hoert auf Host "${config.filesHost}"`) + 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}`) }) diff --git a/server/src/storage.ts b/server/src/storage.ts index a68a961..2759e17 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -127,5 +127,9 @@ export async function fileExists(path: string): Promise { } } -/** Die oeffentliche Adresse einer Datei. */ -export const publicUrlFor = (path: string) => `${config.publicUrl}/${path}` +/** Die oeffentliche Adresse einer Datei. + * + * filePrefix ist bei zwei Namen leer und bei einem "/f" — so kommt hier + * dieselbe Adresse heraus, unter der die Datei auch tatsaechlich liegt. */ +export const publicUrlFor = (path: string) => + `${config.publicUrl}${config.filePrefix}/${path}`