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"] +}