From e36db8c0153fefb4f4ec9ee3c27e0b6bd3b7f99e Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Tue, 11 Aug 2026 15:20:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20eigener=20Medien-Dienst=20statt=20Fivem?= =?UTF-8?q?anage=20Lite=20=E2=80=94=20Server-Grundgeruest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Container statt vier: Hono, SQLite, Dateien auf einer Platte. Kein PostgreSQL, kein MinIO, kein ClickHouse. Das ist nicht die kleine Loesung, sondern die, die man nicht pflegt — eine Sicherung ist ein cp -a. Der Ausloeser war nicht der Funktionsumfang von Lite, sondern dessen Zustand. web/src/utils/http-util.ts wirft jeden Fehler als new Error(...), die Hooks pruefen auf "instanceof ApiError" — damit wird jeder Fehlschlag lautlos verschluckt. "Create organization" meldet weder Erfolg noch Misserfolg; das Ergebnis waren 19 gleichnamige Organisationen, die sich auch nicht loeschen lassen, weil die Route fehlt. Von allem, was Lite darueber hinaus kann, haben wir nichts gebraucht. Zwei Hostnamen, ein Prozess: unter FILES_HOST gibt es ausschliesslich Dateien — kein Dashboard, keine API, nichts anzumelden. Das haelt die oeffentliche Adresse frei von Angriffsflaeche und die URLs huebsch, ohne /f/-Praefix. Die Vorlage im Handy bleibt {model}.webp. Der Pfad ist der Schluessel: X-Path legt die Datei genau dort ab, wo der Aufrufer sie erwartet. Bei Lite haetten wir die zurueckgegebenen Adressen in einer urls.json mitschleppen muessen — vorhersagbare URLs sind fuer das Fotostudio die ganze Voraussetzung. storage.ts bereinigt Pfade NICHT, es lehnt sie ab. Ein zurechtgebogener Pfad legt die Datei unter einem anderen Namen ab als dem erwarteten, und das faellt erst auf, wenn das Bild fehlt. Typprueft. Noch nicht gestartet, noch nicht getestet — das ist der naechste Schritt und steht in ROADMAP.md. Co-Authored-By: Claude Opus 5 --- .gitignore | 7 + ROADMAP.md | 156 ++++++ server/package-lock.json | 1052 +++++++++++++++++++++++++++++++++++ server/package.json | 22 + server/src/auth.ts | 123 ++++ server/src/config.ts | 82 +++ server/src/db.ts | 101 ++++ server/src/index.ts | 168 ++++++ server/src/routes/dash.ts | 247 ++++++++ server/src/routes/upload.ts | 225 ++++++++ server/src/storage.ts | 131 +++++ server/tsconfig.json | 19 + 12 files changed, 2333 insertions(+) create mode 100644 ROADMAP.md create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/auth.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db.ts create mode 100644 server/src/index.ts create mode 100644 server/src/routes/dash.ts create mode 100644 server/src/routes/upload.ts create mode 100644 server/src/storage.ts create mode 100644 server/tsconfig.json diff --git a/.gitignore b/.gitignore index 24d9d01..9a50ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ # Die echten Geheimnisse. Nur .env.example gehoert ins Repo. .env + +# Node +node_modules/ +dist/ + +# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo. +data/ diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..0a7bbb7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,156 @@ +# d4rk_media — Roadmap + +Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen, +ausliefern, in einem Dashboard verwalten. + +**Warum nicht Fivemanage Lite.** Der Stack lief am Ende — PostgreSQL, MinIO, +Dashboard, alles grün. Was an ihm nicht zu reparieren war, ist die Qualität +der Oberfläche: `http-util.ts` wirft jeden Fehler als `new Error(…)`, die +Hooks prüfen auf `instanceof ApiError`, und damit wird **jeder** Fehlschlag +lautlos verschluckt. `Create organization` gibt weder Erfolg noch Misserfolg +zurück — das Ergebnis waren 19 gleichnamige Organisationen, weil ein Knopf +ohne Rückmeldung wie ein kaputter Knopf aussieht. Löschen geht nicht, die +Route existiert nicht. Das ist kein Bedienfehler und keine fehlende Funktion, +das ist 0.1.0-beta. + +Dazu kam: von dem, was Lite über eine Dateiablage hinaus kann — Mandanten, +strukturiertes Logging, ClickHouse — haben wir nichts gebraucht und alles +mitgeschleppt. + +--- + +## Der Zuschnitt + +**Ein Container.** SQLite statt PostgreSQL, Dateien auf einer Platte statt +MinIO. Das ist nicht die kleine Lösung, sondern die, die man nicht pflegt: +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.** + +| | | +|---|---| +| `fivecdn.d4rkst3r.de` | nur Dateien. Kein Dashboard, keine API, nichts anzumelden | +| `fivemanage.d4rkst3r.de` | Dashboard und API | + +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`. + +**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 +Voraussetzung — bei Lite hätten wir die zurückgegebenen Adressen in einer +`urls.json` mitschleppen müssen. + +**Nicht nachgebaut wird das Logging.** Genau dafür schleppt Fivemanage +ClickHouse mit, und genau da ist der Wartungsaufwand echt. Wenn Logs kommen +sollen, ist das ein eigenes Projekt mit eigener Entscheidung. + +--- + +## Stand + +### ✅ Fertig — Server-Grundgerüst + +``` +server/src/config.ts Einstellungen, alle aus der Umgebung +server/src/db.ts SQLite: users, sessions, tokens, media +server/src/auth.ts scrypt, Sitzungen, Token-Hashes +server/src/storage.ts Pfadprüfung, atomares Schreiben, MIME +server/src/routes/upload.ts die API für Skripte +server/src/routes/dash.ts die API fürs Dashboard +server/src/index.ts Host-Weiche, Dateiauslieferung, Start +``` + +Typprüfung läuft durch. **Noch nicht gestartet und nicht getestet.** + +Die API für Skripte: + +``` +POST /api/upload Bearer-Token + X-Path vehicles/adder.webp (optional) + X-Encoding base64 (für Lua) + X-Overwrite false (Standard: ja) + → { url, path, size, sha256, mime, replaced } + +DELETE /api/media/ Token braucht can_delete +GET /api/exists/ für "nur fehlende" im Serienlauf +``` + +Drei Rumpfformen — `multipart/form-data`, Base64, rohe Bytes. Base64 ist für +FiveM: `PerformHttpRequest` schickt einen Lua-String, und im Fotostudio kommt +das Bild ohnehin als Data-URL aus dem NUI. + +Die API fürs Dashboard: Anmeldung, Passwortwechsel, Medienliste mit Suche und +Paginierung, Einzel- und Sammellöschung, Statistik nach Ordnern, +Token-Verwaltung. + +### ⬜ Als Nächstes + +**1 · Smoke-Test.** Server lokal starten, Token anlegen, ein Bild hoch- und +wieder abrufen. Vor Docker, vor allem anderen. + +**2 · Dockerfile und Compose.** Ein Abbild, das Server und gebaute Oberfläche +ausliefert; ein Volume für `data/`; ein Port für NPM. In Portainer aus diesem +Repo deploybar — **`Compose path` nimmt EINE Datei**, Ergänzungen werden +stillschweigend ignoriert. Das hat uns bei Lite einen Nachmittag gekostet. + +**3 · `server/upload.lua` im Fotostudio.** `Upload.put(pfad, bytes) → url`. +Token in `config.upload.lua`, server-only, gitignored, mit committetem +`.example` — nie im NUI, dort hat es jeder Spieler im Speicher. An den +Serienlauf hängen, damit freigestellte Bilder nach der Freigabe sofort +hochgehen. + +Nach diesem Schritt ist das erreicht, worum es ursprünglich ging. + +**4 · Dashboard.** React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), +Lucide, Zustand. **Kein Konsta** — das ist für die Handy-Oberfläche richtig, +für ein Dashboard am Monitor nicht. + +Anmeldung · Galerie mit Vorschau, Suche, Mehrfachauswahl · Löschen · +Speicherverbrauch nach Ordnern · Token-Verwaltung, Klartext genau einmal. + +> **Die eine Regel:** jeder Knopf gibt Rückmeldung, auch der erfolgreiche. +> Das ist der Fehler, an dem wir bei Fivemanage einen Nachmittag verloren +> haben, und er ist billig zu vermeiden. + +**5 · Umzug.** Root-`docker-compose.yml` wird unsere; die Fivemanage-Dateien +wandern nach `legacy/`. Erst wenn unserer trägt — bis dahin bleibt der +Lite-Stack als Vergleichsmaßstab laufen. + +### ⬜ Später, wenn es sich lohnt + +- Vorschaubilder serverseitig (spart dem Dashboard das Laden von 900 Vollbildern) +- Namensräume je Ressource, mit eigenem Token und eigener Statistik +- Spieler-Metadaten an einem Upload (wer, wann, welcher Server) +- Aufräumregeln: was älter als X ist und zu keinem Datensatz gehört +- Mehrere Benutzer statt nur `admin` + +--- + +## Offene Entscheidungen + +- **Sicherung.** Ein Volume mit Bildern und `media.db`. Restic gegen die + Nextcloud? Oder reicht ein `docker cp` vor größeren Änderungen? +- **Bilder aus dem Spiel** (Screenshots, Clips von Spielern) — dafür brauchte + es Größenbegrenzungen je Token und vermutlich eine Warteschlange. Erst + planen, wenn es ansteht. + +--- + +## Was aus dem Fivemanage-Anlauf hängen bleibt + +Steht ausführlich im README, hier nur die Kurzfassung — es sind alles Dinge, +die uns wieder begegnen: + +1. **Portainer nimmt unter `Compose path` eine Datei.** Ergänzungen werden + stillschweigend übergangen: alle Container gesund, alles 502. +2. **Host-Ports an `0.0.0.0` binden, nicht an `127.0.0.1`.** NPM liegt im + Standard-Bridge-Netz und kommt über die Docker-Brücke, nicht über Loopback. + Zu macht der Port die Firewall. +3. **Belegt auf diesem Server:** 80, 81, 443, 2224, 3000, 3080, 3478, 8000, + 8080, 8090, 9000, 9443, 11000. Unsere: 9100 und 9101. +4. **`9101:8080` ist keine Kollision.** Nur die linke Zahl muss einmalig sein. +5. **Ein Dienst, der ohne Konfiguration hochfährt, kostet mehr Zeit als + einer, der sich weigert.** Deshalb bricht `config.ts` beim Start ab. diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..1b56466 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1052 @@ +{ + "name": "d4rk-media-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "d4rk-media-server", + "version": "0.1.0", + "dependencies": { + "@hono/node-server": "^1.13.7", + "better-sqlite3": "^11.7.0", + "hono": "^4.6.14" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..1d8a8f7 --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "d4rk-media-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "better-sqlite3": "^11.7.0", + "hono": "^4.6.14" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..475d909 --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,123 @@ +// Anmeldung, Sitzungen und Tokens. +// +// Ohne Bibliothek, aber nicht selbstgestrickt: scrypt und timingSafeEqual +// kommen aus node:crypto. Was hier selbst geschrieben ist, ist die Verdrahtung +// — nicht die Kryptografie. + +import { + randomBytes, + scryptSync, + timingSafeEqual, + createHash, +} from 'node:crypto' +import { db, now, type Token, type User } from './db.js' +import { config } from './config.js' + +const SCRYPT_N = 16384 // ~100 ms je Pruefung, spuerbar fuer einen Angreifer +const KEY_LENGTH = 64 + +/** Format: scrypt$$$. Das Verfahren steht im String, + * damit spaetere Parameter alte Passwoerter nicht ungueltig machen. */ +export function hashPassword(password: string): string { + const salt = randomBytes(16) + const key = scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_N }) + return `scrypt$${SCRYPT_N}$${salt.toString('hex')}$${key.toString('hex')}` +} + +export function verifyPassword(password: string, stored: string): boolean { + const [scheme, n, saltHex, keyHex] = stored.split('$') + if (scheme !== 'scrypt' || !n || !saltHex || !keyHex) return false + + const key = scryptSync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH, { + N: Number(n), + }) + const expected = Buffer.from(keyHex, 'hex') + // Laengen muessen vor timingSafeEqual stimmen, sonst wirft es. + if (key.length !== expected.length) return false + return timingSafeEqual(key, expected) +} + +// ---------------------------------------------------------------- Sitzungen + +export const SESSION_COOKIE = 'sid' + +export function createSession(userId: number): { id: string; expiresAt: number } { + const id = randomBytes(32).toString('base64url') + const expiresAt = now() + config.sessionDays * 86_400_000 + db.prepare( + 'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)', + ).run(id, userId, now(), expiresAt) + return { id, expiresAt } +} + +export function userForSession(sessionId: string | undefined): User | null { + if (!sessionId) return null + const row = db + .prepare( + `SELECT u.* FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id = ? AND s.expires_at > ?`, + ) + .get(sessionId, now()) as User | undefined + return row ?? null +} + +export function destroySession(sessionId: string) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId) +} + +// ------------------------------------------------------------------- Tokens + +/** Der Klartext-Token. Traegt ein Praefix, damit er in einem Log oder einer + * Konfigurationsdatei sofort als solcher zu erkennen ist. */ +export function generateToken(): string { + return `d4rk_${randomBytes(24).toString('base64url')}` +} + +export function tokenHash(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +/** Den Token zu einem Authorization-Kopf finden. + * + * Gesucht wird ueber den HASH, nicht ueber den Klartext — in der Datenbank + * steht nichts, womit jemand hochladen koennte, der sie in die Haende + * bekommt. */ +export function tokenFromHeader(header: string | undefined): Token | null { + if (!header) return null + const match = /^Bearer\s+(.+)$/i.exec(header.trim()) + if (!match?.[1]) return null + + const row = db + .prepare('SELECT * FROM tokens WHERE hash = ?') + .get(tokenHash(match[1])) as Token | undefined + if (!row) return null + + db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id) + return row +} + +/** Darf dieser Token auf diesen Pfad schreiben? */ +export function tokenAllows(token: Token, path: string): boolean { + if (!token.prefix) return true + const prefix = token.prefix.replace(/\/+$/, '') + '/' + return path.startsWith(prefix) +} + +// -------------------------------------------------------------------- Start + +/** Den ersten Benutzer anlegen, falls noch keiner da ist. + * + * Nur DANN — ein geaendertes ADMIN_PASSWORD setzt ein bestehendes Passwort + * nicht zurueck. Wer sich ausgesperrt hat, loescht die Zeile in der Datenbank + * und startet neu. */ +export function ensureAdmin() { + const count = db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number } + if (count.n > 0) return + + db.prepare( + 'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', + ).run(config.adminUsername, hashPassword(config.adminPassword), now()) + + console.log(`[auth] Benutzer "${config.adminUsername}" angelegt.`) +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..acc5e9c --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,82 @@ +// Alle Einstellungen an einer Stelle, alle aus der Umgebung. +// +// Was fehlt, faellt beim START auf und nicht beim ersten Upload. Das ist +// Absicht: ein Dienst, der bereitwillig hochfaehrt und erst Stunden spaeter +// merkt, dass ihm die halbe Konfiguration fehlt, kostet mehr Zeit als einer, +// der sich sofort weigert. + +import { existsSync, mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +function required(name: string): string { + const value = process.env[name] + if (!value) { + console.error(`[config] ${name} fehlt. Ohne die Variable startet der Dienst nicht.`) + process.exit(1) + } + return value +} + +function optional(name: string, fallback: string): string { + return process.env[name] || fallback +} + +function number(name: string, fallback: number): number { + const raw = process.env[name] + if (!raw) return fallback + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + console.error(`[config] ${name}="${raw}" ist keine sinnvolle Zahl.`) + process.exit(1) + } + return parsed +} + +/** Wohin die Dateien und die Datenbank kommen. EIN Ordner, damit eine + * Sicherung aus genau einem `cp -a` besteht. */ +const dataDir = resolve(optional('DATA_DIR', './data')) +const filesDir = resolve(dataDir, 'files') +for (const dir of [dataDir, filesDir]) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) +} + +/** Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL + * gebaut, die nach dem Upload zurueckkommt — und die im Handy in der Vorlage + * steht. Ohne abschliessenden Schraegstrich. */ +const publicUrl = required('PUBLIC_URL').replace(/\/+$/, '') + +export const config = { + port: number('PORT', 8080), + dataDir, + filesDir, + dbPath: resolve(dataDir, 'media.db'), + + 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), + + /** Wird beim Start angelegt, falls es noch keinen Benutzer gibt. */ + adminPassword: required('ADMIN_PASSWORD'), + adminUsername: optional('ADMIN_USERNAME', 'admin'), + + /** Groesste erlaubte Datei. Ein freigestelltes Fahrzeugbild wiegt rund + * 200 KB; 64 MB lassen Luft fuer Videos, ohne dass ein Fehlgriff die + * Platte fuellt. */ + maxUploadBytes: number('MAX_UPLOAD_MB', 64) * 1024 * 1024, + + /** Wie lange eine Anmeldung im Dashboard haelt. */ + sessionDays: number('SESSION_DAYS', 30), + + dev: process.env.NODE_ENV !== 'production', +} as const + +export type Config = typeof config diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..f8cf9ff --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,101 @@ +// Die Ablage: SQLite, eine Datei neben den Bildern. +// +// Warum nicht PostgreSQL: weil hier eine Handvoll Tabellen liegt, auf die ein +// Server schreibt. SQLite ist dafuer nicht die kleine Loesung, sondern die +// angemessene — sie kostet keinen Container, keine Verbindung, kein Passwort, +// und eine Sicherung ist ein Dateikopie. Wenn daraus je Millionen Zeilen +// werden, tauschen wir dieses Modul aus; die Aufrufer sehen davon nichts. + +import Database from 'better-sqlite3' +import { config } from './config.js' + +export const db = new Database(config.dbPath) + +// WAL: Lesen blockiert Schreiben nicht. Bei einem Dashboard, das waehrend +// eines laufenden Serienuploads die Galerie zeigt, ist genau das der Fall. +db.pragma('journal_mode = WAL') +db.pragma('foreign_keys = ON') +db.pragma('busy_timeout = 5000') + +db.exec(` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + -- Nur der Hash. Der Token selbst wird genau einmal gezeigt und ist danach + -- auch fuer uns nicht mehr lesbar — wer ihn verliert, legt einen neuen an. + hash TEXT NOT NULL UNIQUE, + -- Optionale Fessel: dieser Token darf nur unterhalb dieses Praefix + -- schreiben. Leer heisst ueberall. + prefix TEXT NOT NULL DEFAULT '', + can_delete INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_used_at INTEGER +); + +CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY, + -- Der Pfad IST der Schluessel. Fuer das Fotostudio ist das der ganze + -- Punkt: vehicles/adder.webp bleibt vehicles/adder.webp, damit die + -- Vorlage im Handy {model}.webp lauten kann und niemand eine + -- Uebersetzungstabelle mitschleppen muss. + path TEXT NOT NULL UNIQUE, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + mime TEXT NOT NULL, + token_id INTEGER REFERENCES tokens(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC); +`) + +export type User = { + id: number + username: string + password_hash: string + created_at: number +} + +export type Token = { + id: number + name: string + hash: string + prefix: string + can_delete: number + created_at: number + last_used_at: number | null +} + +export type Media = { + id: number + path: string + size: number + sha256: string + mime: string + token_id: number | null + created_at: number + updated_at: number +} + +export const now = () => Date.now() + +/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich — + * sonst waechst die Tabelle ewig, und niemand merkt es. */ +export function pruneSessions() { + db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now()) +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..0ca9350 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,168 @@ +// 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: +// +// 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. +// +// 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. + +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } 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 { pruneSessions } from './db.js' +import { absolutePath, checkPath, mimeFor } from './storage.js' +import { uploadRoutes } from './routes/upload.js' +import { dashRoutes } from './routes/dash.js' + +const app = new Hono() + +if (config.dev) app.use('*', logger()) + +// -------------------------------------------------------- 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) + } + + const 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) + } + + const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"` + const headers: Record = { + 'Content-Type': mimeFor(path), + 'Cache-Control': 'public, max-age=300', + 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', + } + + 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) => { + 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 + +app.get('/health', (c) => c.json({ ok: true })) + +app.route('/api', uploadRoutes) +app.route('/api/dash', dashRoutes) + +app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3))) + +// 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 = fileURLToPath(new URL('../web/', import.meta.url)) +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 + try { + const target = join(webDir, candidate.replace(/^\//, '')) + const info = await stat(target) + if (info.isFile()) { + return c.body(createReadStream(target) as any, 200, { + 'Content-Type': mimeFor(candidate), + 'Cache-Control': candidate.includes('/assets/') + ? 'public, max-age=31536000, immutable' + : 'no-cache', + }) + } + } catch { + /* faellt unten auf index.html */ + } + + try { + return c.body(createReadStream(join(webDir, 'index.html')) as any, 200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', + }) + } catch { + 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() + +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] Daten in ${config.dataDir}`) +}) diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts new file mode 100644 index 0000000..8c3423a --- /dev/null +++ b/server/src/routes/dash.ts @@ -0,0 +1,247 @@ +// 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 { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { config } from '../config.js' +import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js' +import { + SESSION_COOKIE, + createSession, + destroySession, + generateToken, + hashPassword, + tokenHash, + userForSession, + verifyPassword, +} from '../auth.js' +import { checkPath, deleteFile, publicUrlFor, PathError } from '../storage.js' + +type Vars = { user: User } + +export const dashRoutes = new Hono<{ Variables: Vars }>() + +// ---------------------------------------------------------------- Anmeldung + +dashRoutes.post('/auth/login', async (c) => { + const { username, password } = await c.req.json<{ + username?: string + password?: string + }>() + + 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)) { + return c.json({ error: 'Benutzername oder Passwort stimmt nicht' }, 401) + } + + 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: { id: user.id, username: user.username } }) +}) + +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 }) +}) + +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: { id: user.id, username: user.username } }) +}) + +// 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 { current, next: fresh } = await c.req.json<{ + current?: string + next?: string + }>() + 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 + +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) + + const where = query ? 'WHERE path LIKE ?' : '' + const params = query ? [`%${query}%`] : [] + + 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 updated_at 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) })), + }) +}) + +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 deleteFile(row.path) + db.prepare('DELETE FROM media WHERE id = ?').run(id) + return c.json({ deleted: row.path }) +}) + +/** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */ +dashRoutes.post('/media/delete', async (c) => { + const { ids } = await c.req.json<{ ids?: number[] }>() + if (!Array.isArray(ids) || ids.length === 0) { + return c.json({ error: 'keine Auswahl' }, 400) + } + + const select = db.prepare('SELECT * FROM media WHERE id = ?') + const remove = db.prepare('DELETE FROM media WHERE id = ?') + const deleted: string[] = [] + + for (const id of ids) { + const row = select.get(id) as Media | undefined + if (!row) continue + await deleteFile(row.path) + remove.run(id) + deleted.push(row.path) + } + + return c.json({ deleted, count: deleted.length }) +}) + +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 + +dashRoutes.get('/tokens', (c) => { + const rows = db + .prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC') + .all() as Omit[] + return c.json({ items: rows }) +}) + +dashRoutes.post('/tokens', async (c) => { + const { name, prefix, canDelete } = await c.req.json<{ + name?: string + prefix?: string + canDelete?: boolean + }>() + + if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) + + // 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) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now()) + + // 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), + 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 }) +}) + +dashRoutes.post('/maintenance/prune-sessions', (c) => { + pruneSessions() + return c.json({ ok: true }) +}) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts new file mode 100644 index 0000000..2363f64 --- /dev/null +++ b/server/src/routes/upload.ts @@ -0,0 +1,225 @@ +// Die Schnittstelle fuer Skripte: hochladen und wieder loeschen. +// +// Das ist der Teil, den der FiveM-Server anspricht — deshalb ist er stur +// einfach gehalten. Ein Aufruf, ein Token, eine Antwort mit der fertigen URL. +// +// POST /api/upload +// Authorization: Bearer d4rk_... +// X-Path: vehicles/adder.webp +// X-Encoding: base64 +// +// +// DREI FORMEN FUER DEN RUMPF, und der Grund fuer die dritte ist FiveM: +// +// multipart/form-data Feld "file" — so laedt das Dashboard hoch +// X-Encoding: base64 Base64-Text — so laedt Lua hoch +// sonst rohe Bytes +// +// PerformHttpRequest schickt einen Lua-String; rohe Bytes ueberstehen den Weg +// nicht zuverlaessig. Im Fotostudio kommt das Bild ohnehin als Data-URL aus +// dem NUI, ist also schon Base64 — der Umweg ist damit keiner. + +import { Hono } from 'hono' +import type { Context } from 'hono' +import { config } from '../config.js' +import { db, now, type Media, type Token } from '../db.js' +import { tokenAllows, tokenFromHeader } from '../auth.js' +import { + PathError, + checkPath, + deleteFile, + extensionFor, + fileExists, + mimeFor, + publicUrlFor, + sha256, + writeFileAtomic, +} from '../storage.js' + +type Vars = { token: Token } + +export const uploadRoutes = new Hono<{ Variables: Vars }>() + +uploadRoutes.use('*', async (c, next) => { + const token = tokenFromHeader(c.req.header('authorization')) + if (!token) { + return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401) + } + c.set('token', token) + await next() +}) + +/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */ +async function readBody( + c: Context<{ Variables: Vars }>, +): Promise<{ data: Buffer; path?: string; filename?: string }> { + const contentType = c.req.header('content-type') ?? '' + + if (contentType.includes('multipart/form-data')) { + const form = await c.req.parseBody() + const file = form['file'] + if (!(file instanceof File)) { + throw new Error('Feld "file" fehlt im Formular') + } + return { + data: Buffer.from(await file.arrayBuffer()), + path: typeof form['path'] === 'string' ? form['path'] : undefined, + filename: file.name, + } + } + + const raw = Buffer.from(await c.req.arrayBuffer()) + + if (/base64/i.test(c.req.header('x-encoding') ?? '')) { + // Data-URLs kommen mit Vorspann ("data:image/webp;base64,"). Den + // abzuschneiden ist freundlicher, als den Aufrufer dazu zu zwingen. + const text = raw.toString('utf8').replace(/^data:[^,]*,/, '') + return { data: Buffer.from(text, 'base64') } + } + + return { data: raw } +} + +uploadRoutes.post('/upload', async (c) => { + const token = c.get('token') + + // Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload + // abgewiesen wird, BEVOR er im Speicher liegt. + const announced = Number(c.req.header('content-length') ?? 0) + if (announced > config.maxUploadBytes) { + return c.json( + { error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` }, + 413, + ) + } + + let body: Awaited> + try { + body = await readBody(c) + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400) + } + + const { data } = body + if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400) + if (data.length > config.maxUploadBytes) { + return c.json( + { error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` }, + 413, + ) + } + + const digest = sha256(data) + + // Der Pfad: ausdruecklich gewuenscht, oder aus dem Hash gebaut. Der + // gewuenschte hat Vorrang und wird NICHT zurechtgebogen. + const wanted = c.req.header('x-path') ?? body.path ?? body.filename + let path: string + try { + path = wanted + ? checkPath(wanted) + : checkPath( + `uploads/${digest.slice(0, 2)}/${digest}.${extensionFor( + c.req.header('x-mime') ?? 'application/octet-stream', + )}`, + ) + } catch (err) { + if (err instanceof PathError) return c.json({ error: err.message }, 400) + throw err + } + + if (!tokenAllows(token, path)) { + return c.json( + { error: `dieser Token darf nur unter "${token.prefix}/" schreiben` }, + 403, + ) + } + + const existing = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as + | Media + | undefined + + // Ueberschreiben ist der Normalfall: ein neu aufgenommenes Fahrzeugbild + // soll das alte ersetzen, sonst braeuchte jeder zweite Lauf einen anderen + // Namen. Wer das nicht will, sagt es. + const overwrite = (c.req.header('x-overwrite') ?? 'true').toLowerCase() !== 'false' + if (existing && !overwrite) { + return c.json({ error: 'Pfad ist belegt', path, url: publicUrlFor(path) }, 409) + } + + const mime = c.req.header('x-mime') ?? mimeFor(path) + + try { + await writeFileAtomic(path, data) + } catch (err) { + console.error('[upload] Schreiben fehlgeschlagen:', err) + return c.json({ error: 'Schreiben fehlgeschlagen' }, 500) + } + + const timestamp = now() + if (existing) { + db.prepare( + `UPDATE media SET size = ?, sha256 = ?, mime = ?, token_id = ?, updated_at = ? + WHERE id = ?`, + ).run(data.length, digest, mime, token.id, timestamp, existing.id) + } else { + db.prepare( + `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(path, data.length, digest, mime, token.id, timestamp, timestamp) + } + + return c.json({ + url: publicUrlFor(path), + path, + size: data.length, + sha256: digest, + mime, + replaced: Boolean(existing), + }) +}) + +/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID. */ +uploadRoutes.delete('/media/*', async (c) => { + const token = c.get('token') + if (!token.can_delete) { + return c.json({ error: 'dieser Token darf nicht loeschen' }, 403) + } + + let path: string + try { + path = checkPath(decodeURIComponent(c.req.path.replace(/^\/media\//, ''))) + } catch (err) { + if (err instanceof PathError) return c.json({ error: err.message }, 400) + throw err + } + + if (!tokenAllows(token, path)) { + return c.json({ error: 'ausserhalb des erlaubten Bereichs' }, 403) + } + if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404) + + await deleteFile(path) + db.prepare('DELETE FROM media WHERE path = ?').run(path) + return c.json({ deleted: path }) +}) + +/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende" + * beantworten kann, ohne 900 Bilder hochzuladen. */ +uploadRoutes.get('/exists/*', (c) => { + let path: string + try { + path = checkPath(decodeURIComponent(c.req.path.replace(/^\/exists\//, ''))) + } catch { + return c.json({ exists: false }, 200) + } + const row = db.prepare('SELECT size, sha256, updated_at FROM media WHERE path = ?').get( + path, + ) as Pick | undefined + + return c.json( + row + ? { exists: true, path, url: publicUrlFor(path), ...row } + : { exists: false, path }, + ) +}) diff --git a/server/src/storage.ts b/server/src/storage.ts new file mode 100644 index 0000000..a68a961 --- /dev/null +++ b/server/src/storage.ts @@ -0,0 +1,131 @@ +// Dateien auf der Platte. +// +// Der Pfad kommt vom Aufrufer — beim Fotostudio ist das genau der Punkt, +// vehicles/adder.webp soll vehicles/adder.webp bleiben. Damit ist er auch die +// gefaehrlichste Eingabe, die dieser Dienst kennt: ein durchgelassenes ".." +// schreibt irgendwohin auf dem Wirt. +// +// Deshalb wird hier nicht bereinigt, sondern ABGELEHNT. Ein Pfad, der nicht +// den Regeln entspricht, wird nicht zurechtgebogen — sonst landet eine Datei +// unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand +// merkt es, bis das Bild fehlt. + +import { createHash } from 'node:crypto' +import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' +import { config } from './config.js' + +/** Ein Segment: beginnt mit Buchstabe oder Ziffer, danach auch Punkt, Strich + * und Unterstrich. Damit sind "..", versteckte Dateien und alles mit + * Schraegstrich, Backslash oder Steuerzeichen von vornherein draussen. */ +const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ + +export const MAX_PATH_LENGTH = 200 +export const MAX_DEPTH = 8 + +export class PathError extends Error {} + +/** Prueft den Pfad und gibt ihn normalisiert zurueck. Wirft PathError. */ +export function checkPath(raw: string): string { + const path = raw.trim().replace(/^\/+/, '') + + if (!path) throw new PathError('Pfad ist leer') + if (path.length > MAX_PATH_LENGTH) { + throw new PathError(`Pfad laenger als ${MAX_PATH_LENGTH} Zeichen`) + } + + const segments = path.split('/') + if (segments.length > MAX_DEPTH) { + throw new PathError(`mehr als ${MAX_DEPTH} Ebenen`) + } + + for (const segment of segments) { + if (!SEGMENT.test(segment)) { + throw new PathError( + `"${segment}" ist als Pfadteil nicht erlaubt ` + + '(Buchstaben, Ziffern, Punkt, Strich, Unterstrich; ' + + 'nicht mit Punkt beginnend)', + ) + } + } + + // Guertel und Hosentraeger: selbst wenn oben je eine Luecke waere, muss + // das Ergebnis unterhalb des Datenordners liegen. + const absolute = resolve(config.filesDir, path) + if (absolute !== config.filesDir && !absolute.startsWith(config.filesDir + sep)) { + throw new PathError('Pfad zeigt aus dem Datenordner heraus') + } + + return path +} + +const MIME: Record = { + webp: 'image/webp', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + avif: 'image/avif', + mp4: 'video/mp4', + webm: 'video/webm', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + wav: 'audio/wav', + pdf: 'application/pdf', + json: 'application/json', + txt: 'text/plain; charset=utf-8', +} + +export function mimeFor(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? '' + return MIME[ext] ?? 'application/octet-stream' +} + +export function extensionFor(mime: string): string { + const base = mime.split(';')[0]?.trim().toLowerCase() + for (const [ext, value] of Object.entries(MIME)) { + if (value.split(';')[0] === base) return ext + } + return 'bin' +} + +export const sha256 = (data: Buffer) => createHash('sha256').update(data).digest('hex') + +export const absolutePath = (path: string) => join(config.filesDir, path) + +/** Schreibt die Datei. + * + * Erst daneben, dann umbenennen: ein Ueberschreiben ist damit entweder ganz + * passiert oder gar nicht. Sonst koennte ein abgebrochener Upload das alte + * Bild durch ein halbes ersetzen — und halbe Bilder sehen im Handy aus wie + * ein Fehler im Handy. */ +export async function writeFileAtomic(path: string, data: Buffer): Promise { + const target = absolutePath(path) + await mkdir(dirname(target), { recursive: true }) + + const temp = `${target}.${process.pid}.${Date.now()}.tmp` + try { + await writeFile(temp, data) + await rename(temp, target) + } catch (err) { + await rm(temp, { force: true }).catch(() => {}) + throw err + } +} + +export async function deleteFile(path: string): Promise { + await rm(absolutePath(path), { force: true }) +} + +export async function fileExists(path: string): Promise { + try { + await stat(absolutePath(path)) + return true + } catch { + return false + } +} + +/** Die oeffentliche Adresse einer Datei. */ +export const publicUrlFor = (path: string) => `${config.publicUrl}/${path}` diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..6df3ef8 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}