Compare commits

..
6 Commits
Author SHA1 Message Date
D4rkst3randClaude Opus 5.5 f077ac155b Presigned URLs wie Fivemanage v3, einmalig und gefesselt (d4rk-rp-planung#239)
d4rk_phone gab den Token an jeden Client, weil der Dienst keinen Weg ohne
Token kannte. Jetzt wie Fivemanage (docs.fivemanage.com, gelesen 19.10.2026;
sky_phone nutzt denselben Weg):

  GET  /api/v3/file/presigned-url[?expiresAt=]   mit Token  -> data.presignedUrl
  POST /api/v3/file/presigned-url/<schluessel>   OHNE Token -> data.url

Strenger: jeder Schluessel einmal (auch bei Ablehnung verbraucht), hoechstens
15 Minuten, Fesseln des ausstellenden Tokens beim Hochladen frisch gelesen,
private Tokens bekommen keinen. Im Arbeitsspeicher (Betreiber 19.10.2026),
nur der Hash.

tools/presign-pruefen.mjs gegen einen Wegwerf-Container: 19/19, mit
Kontrollen (v3/file mit Token geht, ohne bleibt 401).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 21:08:21 +02:00
D4rkst3randClaude Opus 5.5 a4e918be8a Sandbox auf ausgelieferte Dateien, die Skript tragen koennen (d4rk-rp-planung#254)
Ein SVG mit <script> lief direkt geoeffnet auf der Herkunft des Dashboards
(FILES_HOST leer) - mit dessen Sitzung. Jeder Upload-Token konnte es ablegen,
die Arten-Fessel "Bild" laesst SVG durch.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:57:08 +02:00
D4rkst3randClaude Opus 5.5 6630def7b2 Privater Bereich /api/privat fuer Behoerdenfotos (d4rk-rp-planung#227 H1)
Polizeifotos aus dem Spiel duerfen nicht unter /f/ liegen, wo jeder sie mit
der Adresse bekommt. Neu:

- eigener Ordner DATA_DIR/privat neben files/ (fuer /f/ und /t/ durch
  Bauart unerreichbar), eigene Tabelle privat_media, eigene Vorschauen
- PUT/GET /api/privat/datei/<pfad>, ?vorschau=1; kein Ueberschreiben (409),
  kein Loeschweg; Cache-Control private, no-store
- Token-Fessel "privat": ein privater Token kommt an keinen oeffentlichen
  Weg (upload, Fivemanage, Liste), ein oeffentlicher an keinen privaten
- Dashboard: Kaestchen "nur privater Bereich" beim Anlegen und Bearbeiten,
  Marke in der Liste
- checkPathUnter: dieselbe Pfadregel fuer beide Ablagen
- tools/sichern.ps1 packt privat/ mit und zaehlt Dateien gegen Eintraege

Geprueft mit tools/privat-pruefen.mjs gegen einen Wegwerf-Container des
Abbilds: 21/21, mit Kontrollen (oeffentliches Bild MUSS unter /f/ kommen).
Dashboard gemessen (kopfloser Edge): 7/7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 21:36:16 +02:00
D4rkst3randClaude Opus 5.5 c8080fb2eb Liste eines Ordners, mit Token -- fuer Aufrufer, die die Namen nicht kennen
GET /api/liste?ordner=clothing nennt alle Pfade darin (mit Unterordnern).
/api/fehlende braucht die Namen, und die kennt beim Fotostudio nur der
Spiel-Client -- die Kleidungsnamen entstehen dort aus Natives. Statt 8.000
Namen ueber ein Netzereignis zum Server zu schicken, holt der Server die
Liste, und das Studio prueft selbst.

Schreibweise genau (substr statt LIKE, das in SQLite fuer ASCII
schreibweisenblind ist). Ohne Ordnerfessel wie /exists. Gekappt bei 100.000
mit `gekappt: true` daneben.

Geprueft gegen eine DB-Kopie im eigenen Container: 401 ohne Token, 400 ohne
ordner und mit ../, clothing 2591 Pfade und deckungsgleich mit /api/fehlende,
'Clothing' (gross) leer, items enthaelt items/WEAPON_PISTOL.png.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 10:06:54 +02:00
D4rkst3randClaude Opus 5.5 6677b559f2 Fehlende mit Token, und die Rechnung vergleicht die Schreibweise genau
POST /api/fehlende stellt dieselbe Frage wie das Dashboard (welche Namen
haben kein Bild?), mit Token statt Sitzung. Das Fotostudio soll "fehlt" so
beantworten, wie der Laden es sieht -- fuer die Kleidung sind das rund 8.000
Namen, mit /exists ebenso viele Anfragen, hier zwei.

Die Rechnung steht jetzt EINMAL in server/src/fehlende.ts; die Dashboard-Route
ruft sie auf. Die Antwortform bleibt (da als Zahl), dazu kommt `schreibweise`.

DABEI GEFUNDEN: die alte Rechnung schrieb jeden Namen klein, "weil so
abgelegt wird". Seit a373b96 (22.08.) stimmt das nicht mehr. Gemessen am
03.10. ueber /f/: items/WEAPON_PISTOL.png 200, items/weapon_pistol.png 404 --
die Abfrage meldete also jede Waffe als fehlend, die da lag. Jetzt wird genau
verglichen; liegt etwas nur unter anderer Schreibweise, steht es zusaetzlich
in `schreibweise` (umbenennen statt neu aufnehmen). docs/API.md sagte
dasselbe Falsche und riet zu item:lower() -- berichtigt.

Geprueft in einem eigenen Container gegen eine Kopie der Datenbank (per
SQLite-Sicherung gezogen, danach geloescht), mit Wegwerf-Token: 13/13 --
401 ohne Token, 400 mit Text bei kaputtem JSON, fehlendem Platzhalter, ohne
Namen und bei 5001 Namen; WEAPON_PISTOL da, weapon_pistol fehlt und steht in
schreibweise, ein erfundener Name fehlt ohne schreibweise. Ernstfall: 7.928
lokale Kleidungsbilder des Fotostudios, 2.591 davon liegen im Dienst (33 %,
eine Stichprobe ueber /f/ hatte 11 von 40 ergeben).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 10:01:54 +02:00
D4rkst3randClaude Opus 5.5 73c9f310c0 Zugang: was darf dieser Token -- lag seit dem 26.08. nur in der Arbeitskopie
GET /api/zugang nennt die Ordner, in die ein Token schreiben darf (oder die
vorhandenen, wenn er ueberall darf). Das Fotostudio ruft die Route fuer seine
Ordnerauswahl auf.

Gemessen am 03.10.2026: der laufende Container ist am 26.08. um 15:05 aus
dieser Arbeitskopie gebaut worden, in derselben Minute wie die letzte
Aenderung an upload.ts, und /api/zugang antwortet ohne Token mit 401 (ein
erfundener Weg mit 404). Die Route laeuft also -- sie stand nur nicht im Git.
Dieser Commit holt nach, was im Betrieb ist; am Code aendert er nichts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:55:37 +02:00
17 changed files with 1423 additions and 114 deletions

No files matched your search

+161 -10
View File
@@ -39,19 +39,28 @@ und niemand merkte es, bis das Bild fehlt.
### Groß- und Kleinschreibung ### Groß- und Kleinschreibung
Was über das Dashboard oder ein Werkzeug hochgeladen wird, kommt **klein** **Die Schreibweise bleibt, und sie zählt.** `WEAPON_SMG.png` liegt als
an: `WEAPON_SMG.png` liegt als `weapon_smg.png`. Der Grund ist, dass ein Name `WEAPON_SMG.png`, und `/f/items/weapon_smg.png` ist dann ein 404.
zu einer Adresse wird und Adressen sich nicht auf die Schreibweise eines
Dateisystems verlassen sollen.
Für ein Skript heißt das: **die Vorlage arbeitet mit Kleinbuchstaben.** > **Berichtigt am 03.10.2026.** Hier stand bis dahin, alles komme **klein**
> an und eine Vorlage solle `item:lower()` rufen. Das gilt seit `a373b96`
> (22.08.2026) nicht mehr: ox_inventory fragt unter dem exakten Item-Namen,
> und dessen Waffen heißen `WEAPON_*`. Gemessen am 03.10.2026:
> `items/WEAPON_PISTOL.png` 200, `items/weapon_pistol.png` 404. Wer der alten
> Zeile folgte, baute Adressen, die ins Leere zeigen — und die
> Dashboard-Abfrage „Fehlende“ tat genau das und meldete jede Waffe als
> fehlend.
Für ein Skript heißt das: **den Namen so einsetzen, wie er ist.** Klein
gemacht wird nur die Endung.
```lua ```lua
local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item:lower()) local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item)
``` ```
Über `/api/upload` mit eigenem `X-Path` gilt das nicht — dort steht der Pfad Ersetzt werden beim Hochladen über das Fenster weiterhin Zeichen, die in einer
so, wie er geschickt wird. Adresse nichts verloren haben (Leerzeichen, Klammern, Umlaute). Über
`/api/upload` mit eigenem `X-Path` steht der Pfad so, wie er geschickt wird.
--- ---
@@ -102,6 +111,8 @@ Dienstes** ein.
| `POST /api/video` | ✅ gleich | `video` | dito | | `POST /api/video` | ✅ gleich | `video` | dito |
| `POST /api/audio` | ✅ gleich | `audio` | dito | | `POST /api/audio` | ✅ gleich | `audio` | dito |
| `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` | | `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` |
| `GET /api/v3/file/presigned-url` | ✅ gleich, strenger | `?expiresAt=` (Unix-Sekunden) | `{ "status": "ok", "data": { "presignedUrl", "expiresAt" } }` |
| `POST /api/v3/file/presigned-url/<schlüssel>` | ✅ gleich, **ohne Token** | `file`, `path?`, `filename?` | wie `/api/v3/file` |
Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so
wie bei Fivemanage. `Bearer …` geht weiterhin auch. wie bei Fivemanage. `Bearer …` geht weiterhin auch.
@@ -127,8 +138,35 @@ Fivemanages ID auch hat.
**Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt **Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt
hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser
Dienst löscht nichts von selbst. Presigned URLs gibt es nicht: der Upload geht Dienst löscht nichts von selbst.
direkt hierher.
### Presigned URLs — wenn die Bytes vom Spieler kommen
Seit 19.10.2026 (d4rk-rp-planung#239). **Der Token gehört nie in den Browser
eines Spielers.** Muss ein Bild, Ton oder Video aus der NUI hochgehen, holt der
**Server** mit seinem Token eine Adresse, und nur die geht an den Client:
```lua
-- Server: Adresse holen (Token nackt im Kopf, wie bei Fivemanage)
PerformHttpRequest('https://media.d4rkst3r.de/api/v3/file/presigned-url', cb, 'GET', '',
{ Authorization = GetConvar('mein_token', '') })
-- cb: data.presignedUrl an den Client geben
```
```js
// NUI: dorthin hochladen, OHNE Authorization
const f = new FormData(); f.append('file', blob, 'foto.webp')
const r = await fetch(presignedUrl, { method: 'POST', body: f }) // -> data.url
```
**Strenger als Fivemanage, mit Absicht** (gemessen mit `tools/presign-pruefen.mjs`, 19/19):
- jede Adresse gilt **genau einmal**, auch ein Versuch, der an einer Fessel scheitert, verbraucht sie;
- höchstens **15 Minuten**, auch wenn `expiresAt` mehr verlangt; ohne Angabe 15 Minuten;
- es gelten **die Fesseln des ausstellenden Tokens**, beim Hochladen frisch gelesen — wird der Token gelöscht oder gefesselt, gilt das sofort auch für seine offenen Adressen;
- ein **privater** Token bekommt keine (403).
Die Schlüssel liegen im Arbeitsspeicher; ein Neustart des Dienstes verwirft offene.
> Beide Formen sind **aus echtem Code abgelesen**, nicht geraten: > Beide Formen sind **aus echtem Code abgelesen**, nicht geraten:
> [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und > [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und
@@ -385,6 +423,62 @@ Upload.
--- ---
## POST /api/fehlende
Dieselbe Frage wie im Dashboard unter **Fehlende**, aber mit Token: welche
dieser Namen haben **kein** Bild? Für „nur fehlende“ über einen ganzen
Katalog — die Kleidung des Fotostudios sind rund 8.000 Namen, das wären
8.000 Anfragen an `/api/exists` und hier zwei.
```json
{ "muster": "clothing/{model}.png", "namen": ["m_c_11_base_84", "…"] }
```
`namen` darf auch ein Textblock sein (Zeilen, Kommas oder Semikolons).
Höchstens **5000 Namen** je Anfrage, mehr gibt einen 400 mit Text.
```json
{ "muster": "clothing/{model}.png", "geprueft": 2, "da": 1,
"fehlt": [{ "name": "m_c_11_base_84", "pfad": "clothing/m_c_11_base_84.png" }],
"schreibweise": [],
"ueberzaehlig": ["…"], "ueberzaehligGesamt": 5337,
"unbrauchbar": [] }
```
- **Genau verglichen**, wie die öffentliche Adresse auch. Liegt etwas nur
unter einer anderen Schreibweise, steht es in `fehlt` **und** in
`schreibweise` (mit `liegtAls`) — es muss umbenannt werden, nicht neu
aufgenommen.
- `da` ist eine **Zahl**. Wer die einzelnen braucht: alle Namen ohne `fehlt`
und ohne `unbrauchbar`.
- **Keine Ordnerfessel**, wie bei `/api/exists`: die Antwort ist über `/f/`
ohnehin öffentlich.
- Endet nie auf eine Bildendung — die Proxy-Falle aus dem Abschnitt darüber
greift hier nicht.
Die Rechnung steht **einmal**, in `server/src/fehlende.ts`; die Route des
Dashboards ruft dieselbe.
---
## GET /api/liste?ordner=&lt;ordner&gt;
Was liegt in einem Ordner, mit Unterordnern. Für Aufrufer, die die Namen
**nicht** vorher kennen — das Fotostudio erfährt die Kleidungsnamen erst im
Spiel-Client und prüft sie dort gegen diese Liste.
```json
{ "ordner": "clothing", "gesamt": 2591, "gekappt": false,
"pfade": ["clothing/f_c_10_Female_Heist_0.png", "…"] }
```
- Mit Token, ohne Ordnerfessel (wie `/api/exists`).
- **Schreibweise genau**: `?ordner=Clothing` ist ein anderer Ordner.
- Höchstens 100 000 Pfade; darüber steht `gekappt: true`. Eine gekappte
Liste ist **keine** Auskunft über das, was dahinter liegt.
---
## DELETE /api/media/&lt;pfad&gt; ## DELETE /api/media/&lt;pfad&gt;
Braucht einen Token mit dem Recht **darf löschen**. Braucht einen Token mit dem Recht **darf löschen**.
@@ -398,6 +492,63 @@ Papierkorb. Die tägliche Sicherung hilft nur bis zum letzten Lauf.
--- ---
## Der private Bereich: /api/privat
Seit dem 16.10.2026 (d4rk-rp-planung#227). Er ist für Bilder, die **nicht
öffentlich** sein dürfen, z. B. Fotos der Behörden aus dem Spiel. Alles unter
`/f/` ist für jeden lesbar, der die Adresse kennt; hier nicht.
```
PUT /api/privat/datei/<pfad> ablegen
GET /api/privat/datei/<pfad> lesen
GET /api/privat/datei/<pfad>?vorschau=1 Vorschau, 320 px WebP (404, wenn keine)
```
**Nur mit einem Token, der die Fessel „nur privater Bereich“ trägt**
(Dashboard → Token, Spalte `privat`):
| Token | `/api/privat` | `/api/upload`, Fivemanage-Wege, Liste, exists |
|---|---|---|
| privat | ✅ | **403**: „Dieser Token gilt nur für den privaten Bereich …“ |
| öffentlich | **403** | ✅ |
Ein Polizeifoto kann so nicht aus Versehen öffentlich landen.
**Ablegen:** Der Rumpf ist roh oder Base64 (`X-Encoding: base64`, eine
Data-URL wird abgeschnitten). Die Art steht in `X-Mime`. Es gelten dieselben
Fesseln wie öffentlich: Präfix (auch beim **Lesen**), größte Datei,
Kontingent, Arten, Kantenlänge, Ablauf.
**Kein Überschreiben:** Derselbe Pfad ein zweites Mal gibt **409**. Es gibt
**keinen Löschweg**: ein Beweisfoto wird nicht ausgetauscht, der Verbraucher
storniert bei sich.
**Antwort beim Ablegen:**
```json
{ "path": "fotos/police/2026-10/1.webp", "size": 222118, "sha256": "…", "mime": "image/webp", "vorschau": true }
```
Die Antwort enthält **keine `url`**. Es gibt keine öffentliche Adresse.
**Lesen:** Die Antwort trägt `Cache-Control: private, no-store` und
`X-Content-Type-Options: nosniff`.
**Wo es liegt:**
- Dateien in `DATA_DIR/privat`, Einträge in der Tabelle `privat_media`.
- `/f/` und `/t/` lösen nur unter `files/` bzw. `thumbs/` auf und kommen nie
dorthin. Keine Liste, keine Freigabe und keine Galerie liest die Tabelle.
- Die Vorschauen liegen in `DATA_DIR/privat-vorschau`.
- `tools/sichern.ps1` packt `privat/` mit ein und zählt Dateien gegen
Einträge.
**Prüfung:** `node tools/privat-pruefen.mjs <bild.webp>` gegen einen
Wegwerf-Container des Abbilds `d4rk-media:h1-test`. Gemessen am 16.10.2026:
21/21, mit Kontrollen (ein öffentliches Bild MUSS unter `/f/` kommen).
---
## Die Wege des Dashboards ## Die Wege des Dashboards
Diese brauchen eine **Sitzung** und keinen Token — sie sind für die Oberfläche Diese brauchen eine **Sitzung** und keinen Token — sie sind für die Oberfläche
+12 -1
View File
@@ -36,7 +36,14 @@ function number(name: string, fallback: number): number {
* Sicherung aus genau einem `cp -a` besteht. */ * Sicherung aus genau einem `cp -a` besteht. */
const dataDir = resolve(optional('DATA_DIR', './data')) const dataDir = resolve(optional('DATA_DIR', './data'))
const filesDir = resolve(dataDir, 'files') const filesDir = resolve(dataDir, 'files')
for (const dir of [dataDir, filesDir]) { /** Der PRIVATE Bereich (d4rk-rp-planung#227, Fotos der Behoerden). Ein
* eigener Ordner NEBEN files/ und nicht darin: /f/ und /t/ loesen jeden Pfad
* unter filesDir auf (checkPath), ein Nachbarordner ist von dort aus also
* durch Bauart unerreichbar -- nicht durch eine Regel, die man vergessen
* kann. Gelesen wird nur ueber /api/privat mit einem Token, der die Fessel
* "privat" traegt. */
const privatDir = resolve(dataDir, 'privat')
for (const dir of [dataDir, filesDir, privatDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
} }
@@ -68,6 +75,10 @@ export const config = {
port: number('PORT', 8080), port: number('PORT', 8080),
dataDir, dataDir,
filesDir, filesDir,
privatDir,
/** Vorschaubilder des privaten Bereichs -- getrennt von /data/thumbs,
* denn /t/ liefert jene ohne Anmeldung aus. */
privatVorschauDir: resolve(dataDir, 'privat-vorschau'),
dbPath: resolve(dataDir, 'media.db'), dbPath: resolve(dataDir, 'media.db'),
publicUrl, publicUrl,
+39
View File
@@ -227,6 +227,33 @@ CREATE TABLE IF NOT EXISTS settings (
console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt') console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt')
} }
// Die Fessel "privat" (d4rk-rp-planung#227). 1 = dieser Token arbeitet NUR
// im privaten Bereich (/api/privat) und kommt an keinen oeffentlichen
// Upload-Weg -- so landet ein Polizeifoto nicht aus Versehen unter /f/.
// Und nur ein solcher Token liest private Dateien. 0 fuer jeden
// bestehenden Token: was heute geht, geht weiter.
if (!tSpalten.some((s) => s.name === 'privat')) {
db.exec('ALTER TABLE tokens ADD COLUMN privat INTEGER NOT NULL DEFAULT 0')
console.log('[db] Spalte tokens.privat angelegt')
}
// Die privaten Dateien. EIGENE Tabelle und nicht eine Spalte an media:
// media lesen Galerie, Liste, Freigaben, der Verwaisten-Sucher und das
// Dashboard an Dutzenden Stellen -- eine vergessene Bedingung dort waere
// ein Polizeifoto in einer oeffentlichen Liste. Hier kann es keine geben.
// KEIN Ueberschreiben und kein Loeschweg: ein Beweisfoto wird nicht
// ausgetauscht. Der Verbraucher (MDT) storniert bei sich.
db.exec(`
CREATE TABLE IF NOT EXISTS privat_media (
id INTEGER PRIMARY KEY,
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
)`)
const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[] const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[]
if (!eSpalten.some((s) => s.name === 'von')) { if (!eSpalten.some((s) => s.name === 'von')) {
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese // Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
@@ -352,6 +379,18 @@ export type Token = {
arten: string arten: string
/** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */ /** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */
expires_at: number | null expires_at: number | null
/** 1 = nur privater Bereich (/api/privat), sonst 0. */
privat: number
}
export type PrivatMedia = {
id: number
path: string
size: number
sha256: string
mime: string
token_id: number | null
created_at: number
} }
export type Media = { export type Media = {
+142
View File
@@ -0,0 +1,142 @@
/** Wozu fehlt ein Bild? -- die EINE Rechnung dafuer.
*
* Zwei Aufrufer: das Dashboard (`POST /api/dash/fehlende`, mit Sitzung) und
* das Fotostudio (`POST /api/fehlende`, mit Token). Bis zum 03.10.2026 gab es
* nur den ersten, und die Rechnung stand in dash.ts. Eine zweite Fassung
* daneben waere dieselbe Frage zweimal beantwortet -- und die zweite Antwort
* veraltet als erste.
*
* DIE SCHREIBWEISE ZAEHLT. Hier stand bis zum 03.10.2026, gesucht werde
* kleingeschrieben, "weil so abgelegt wird". Das stimmt seit a373b96
* (22.08.2026, "Beim Hochladen bleibt die Schreibweise") nicht mehr. Gemessen
* am 03.10.2026 ueber /f/:
*
* items/WEAPON_PISTOL.png 200
* items/weapon_pistol.png 404
*
* Die Rechnung meldete also jede Waffe als fehlend, die da lag. Jetzt wird
* genau verglichen -- die oeffentliche Adresse unterscheidet ebenfalls, und
* was unter der erfragten Schreibweise nicht abrufbar ist, FEHLT fuer jeden,
* der genau diese Adresse baut.
*
* Liegt es unter einer ANDEREN Schreibweise, steht es zusaetzlich in
* `schreibweise` -- das ist kein Bild, das neu aufgenommen werden muss,
* sondern eines, das umbenannt werden will. Beides in einen Topf zu werfen
* hiesse, eine Viertelstunde Fotostudio fuer eine Sekunde Umbenennen.
*/
import { db } from './db.js'
import { PathError, checkPath } from './storage.js'
export const FEHLENDE_HOECHSTENS = 5000
export type FehlendeAntwort = {
muster: string
geprueft: number
/** Wie viele genau so daliegen. Eine ZAHL, weil das Dashboard sie so
* liest -- wer einzelne braucht, rechnet: geprueft minus fehlt minus
* unbrauchbar. */
da: number
fehlt: { name: string; pfad: string }[]
/** Fehlt unter der erfragten Schreibweise, liegt aber unter einer anderen. */
schreibweise: { name: string; pfad: string; liegtAls: string }[]
ueberzaehlig: string[]
ueberzaehligGesamt: number
unbrauchbar: { name: string; grund: string }[]
}
/** Wirft `FehlendeFehler` mit einem Satz fuer den Aufrufer -- beide Routen
* machen daraus einen 400 mit Text, keinen nackten 500. */
export class FehlendeFehler extends Error {}
export function fehlendeRechnen(musterRoh: unknown, namenRoh: unknown): FehlendeAntwort {
const muster = String(musterRoh ?? '').trim().replace(/^\/+/, '')
if (!muster) throw new FehlendeFehler('Kein Muster angegeben')
const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? []
if (platzhalter.length !== 1) {
throw new FehlendeFehler(
platzhalter.length === 0
? 'Im Muster fehlt der Platzhalter, z. B. vehicles/{model}.webp'
: `Genau ein Platzhalter, nicht ${platzhalter.length}. Sonst ist nicht klar, wofuer die Namen stehen.`,
)
}
// Die Namen kommen als Liste oder als Textblock aus einem Textfeld -- wer
// eine Spalte aus einer Tabelle kopiert, hat Zeilenumbrueche, wer aus einem
// Skript kommt, hat ein Array.
const roh = Array.isArray(namenRoh) ? namenRoh : String(namenRoh ?? '').split(/[\r\n,;]+/)
const namen = [...new Set(roh.map((n) => String(n).trim()).filter(Boolean))]
if (namen.length === 0) throw new FehlendeFehler('Keine Namen dabei')
if (namen.length > FEHLENDE_HOECHSTENS) {
throw new FehlendeFehler(`Hoechstens ${FEHLENDE_HOECHSTENS} Namen auf einmal`)
}
const wo = platzhalter[0] as string
// Der Ordner ist der Teil vor dem Platzhalter -- daran erkennen wir, was
// "ueberzaehlig" ueberhaupt heisst. Ohne ihn waere jede Datei im Dienst
// ueberzaehlig, und die Antwort waere unbrauchbar.
const bisPlatzhalter = muster.slice(0, muster.indexOf(wo))
const ordner = bisPlatzhalter.includes('/')
? bisPlatzhalter.slice(0, bisPlatzhalter.lastIndexOf('/'))
: ''
const genau = db.prepare('SELECT 1 FROM media WHERE path = ?')
const ohneSchreibweise = db.prepare(
'SELECT path FROM media WHERE path = ? COLLATE NOCASE LIMIT 1',
)
const da: string[] = []
const fehlt: FehlendeAntwort['fehlt'] = []
const schreibweise: FehlendeAntwort['schreibweise'] = []
const unbrauchbar: FehlendeAntwort['unbrauchbar'] = []
for (const name of namen) {
const pfad = muster.replace(wo, name)
let gueltig: string
try {
gueltig = checkPath(pfad)
} catch (err) {
unbrauchbar.push({
name,
grund: err instanceof PathError ? err.message : 'unbrauchbar',
})
continue
}
if (genau.get(gueltig)) {
da.push(gueltig)
continue
}
fehlt.push({ name, pfad: gueltig })
const anders = ohneSchreibweise.get(gueltig) as { path: string } | undefined
if (anders) schreibweise.push({ name, pfad: gueltig, liegtAls: anders.path })
}
// Die Gegenrichtung: was liegt in diesem Ordner, ohne in der Liste zu
// stehen? Nur Dateien, die auf das Muster passen -- eine README im selben
// Ordner ist nicht ueberzaehlig, sie gehoert nur nicht dazu.
const endung = muster.slice(muster.indexOf(wo) + wo.length)
const gesucht = new Set(da)
const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
const imOrdner = ordner
? (db
.prepare(`SELECT path FROM media WHERE path LIKE ? ESCAPE '\\'`)
.all(`${maskiert}/%`) as { path: string }[])
: []
const ueberzaehlig = imOrdner
.map((r) => r.path)
.filter((p) => p.endsWith(endung) && !gesucht.has(p))
return {
muster,
geprueft: namen.length,
da: da.length,
fehlt,
schreibweise,
// Gekappt, damit die Antwort nicht zur Datenhalde wird -- die Zahl
// steht daneben, damit die Kappung nicht als Vollstaendigkeit
// durchgeht.
ueberzaehlig: ueberzaehlig.slice(0, 500),
ueberzaehligGesamt: ueberzaehlig.length,
unbrauchbar,
}
}
+10 -5
View File
@@ -30,12 +30,13 @@ import { config } from './config.js'
import { ensureAdmin } from './auth.js' import { ensureAdmin } from './auth.js'
import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js' import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js'
import { pruefePlatte, pruefeSicherung } from './meldung.js' import { pruefePlatte, pruefeSicherung } from './meldung.js'
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js' import { dateiKopf, absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
import { uploadRoutes } from './routes/upload.js' import { uploadRoutes } from './routes/upload.js'
import { fivemanageRoutes } from './routes/fivemanage.js' import { fivemanageRoutes } from './routes/fivemanage.js'
import { freigabeRoutes } from './routes/freigabe.js' import { freigabeRoutes } from './routes/freigabe.js'
import { statusRoutes } from './routes/status.js' import { statusRoutes } from './routes/status.js'
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js' import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
import { privatRoutes } from './routes/privat.js'
const app = new Hono() const app = new Hono()
@@ -151,9 +152,8 @@ async function serveFile(c: any, rawPath: string) {
ETag: etag, ETag: etag,
'Last-Modified': info.mtime.toUTCString(), 'Last-Modified': info.mtime.toUTCString(),
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
// Kein Verlassen auf den Dateinamen: der Browser soll nicht raten, // nosniff und die Sandbox gegen Skript-SVGs (#254) - storage.ts, dateiKopf.
// was er da vor sich hat. ...dateiKopf(typ),
'X-Content-Type-Options': 'nosniff',
} }
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung. // GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
@@ -232,6 +232,11 @@ app.route('/api', uploadRoutes)
app.route('/api', fivemanageRoutes) app.route('/api', fivemanageRoutes)
app.route('/api/dash', dashRoutes) app.route('/api/dash', dashRoutes)
// Der private Bereich (d4rk-rp-planung#227): nur mit einem Token, der die
// Fessel "privat" traegt. VOR /api eingehaengt waere es dasselbe -- uploadRoutes
// kennt keinen Pfad /privat --, aber die eigene Zeile macht die Grenze sichtbar.
app.route('/api/privat', privatRoutes)
// Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/<schluessel> laesst // Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/<schluessel> laesst
// sich vorlesen und abtippen. // sich vorlesen und abtippen.
app.route('/api/s', freigabeRoutes) app.route('/api/s', freigabeRoutes)
@@ -276,7 +281,7 @@ app.get('/t/*', async (c) => {
// Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen // Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen
// wird, und dann soll das Dashboard es sehen. // wird, und dann soll das Dashboard es sehen.
'Cache-Control': 'public, max-age=60', 'Cache-Control': 'public, max-age=60',
'X-Content-Type-Options': 'nosniff', ...dateiKopf('image/webp'),
}) })
}) })
+29 -88
View File
@@ -10,6 +10,7 @@ import { unzipSync } from 'fflate'
import type { Context } from 'hono' import type { Context } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { config } from '../config.js' import { config } from '../config.js'
import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js'
import { import {
avatarUrl, avatarUrl,
db, db,
@@ -2292,90 +2293,14 @@ dashRoutes.post('/media/ersetzen', async (c) => {
*/ */
dashRoutes.post('/fehlende', async (c) => { dashRoutes.post('/fehlende', async (c) => {
const body = await jsonBody<{ muster?: string; namen?: string[] | string }>(c) const body = await jsonBody<{ muster?: string; namen?: string[] | string }>(c)
if (!body?.muster?.trim()) return c.json({ error: 'Kein Muster angegeben' }, 400) // Die Rechnung steht seit dem 03.10.2026 in fehlende.ts -- das Fotostudio
// stellt dieselbe Frage mit Token unter /api/fehlende.
const muster = body.muster.trim().replace(/^\/+/, '') try {
const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? [] return c.json(fehlendeRechnen(body?.muster, body?.namen))
if (platzhalter.length !== 1) { } catch (err) {
return c.json( if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400)
{ throw err
error:
platzhalter.length === 0
? 'Im Muster fehlt der Platzhalter, z. B. vehicles/{model}.webp'
: `Genau ein Platzhalter, nicht ${platzhalter.length}. Sonst ist nicht klar, wofuer die Namen stehen.`,
},
400,
)
} }
// Die Namen kommen als Liste oder als Textblock aus einem Textfeld -- wer
// eine Spalte aus einer Tabelle kopiert, hat Zeilenumbrueche, wer aus einem
// Skript kommt, hat ein Array.
const roh = Array.isArray(body.namen)
? body.namen
: String(body.namen ?? '').split(/[\r\n,;]+/)
const namen = [...new Set(roh.map((n) => String(n).trim()).filter(Boolean))]
if (namen.length === 0) return c.json({ error: 'Keine Namen dabei' }, 400)
if (namen.length > 5000) return c.json({ error: 'Hoechstens 5000 Namen auf einmal' }, 400)
const wo = platzhalter[0] as string
// Der Ordner ist der Teil vor dem Platzhalter -- daran erkennen wir, was
// "ueberzaehlig" ueberhaupt heisst. Ohne ihn waere jede Datei im Dienst
// ueberzaehlig, und die Antwort waere unbrauchbar.
const bisPlatzhalter = muster.slice(0, muster.indexOf(wo))
const ordner = bisPlatzhalter.includes('/')
? bisPlatzhalter.slice(0, bisPlatzhalter.lastIndexOf('/'))
: ''
const vorhanden = db.prepare('SELECT 1 FROM media WHERE path = ?')
const fehlt: { name: string; pfad: string }[] = []
const da: string[] = []
const unbrauchbar: { name: string; grund: string }[] = []
for (const name of namen) {
// KLEINGESCHRIEBEN gesucht, weil so abgelegt wird. Wer "WEAPON_SMG"
// hereinreicht, meint weapon_smg.png -- und bekaeme sonst ein "fehlt",
// das gelogen waere.
const pfad = muster.replace(wo, name.toLowerCase())
try {
const gueltig = checkPath(pfad)
if (vorhanden.get(gueltig)) da.push(gueltig)
else fehlt.push({ name, pfad: gueltig })
} catch (err) {
unbrauchbar.push({
name,
grund: err instanceof PathError ? err.message : 'unbrauchbar',
})
}
}
// Die Gegenrichtung: was liegt in diesem Ordner, ohne in der Liste zu
// stehen? Nur Dateien, die auf das Muster passen -- eine README im selben
// Ordner ist nicht ueberzaehlig, sie gehoert nur nicht dazu.
const endung = muster.slice(muster.indexOf(wo) + wo.length)
const gesucht = new Set(da)
const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
const imOrdner = ordner
? (db
.prepare(`SELECT path FROM media WHERE path LIKE ? ESCAPE '\\'`)
.all(`${maskiert}/%`) as { path: string }[])
: []
const ueberzaehlig = imOrdner
.map((r) => r.path)
.filter((pfad) => pfad.endsWith(endung) && !gesucht.has(pfad))
return c.json({
muster,
geprueft: namen.length,
da: da.length,
fehlt,
// Gekappt, damit die Antwort nicht zur Datenhalde wird -- die Zahl
// steht daneben, damit die Kappung nicht als Vollstaendigkeit
// durchgeht.
ueberzaehlig: ueberzaehlig.slice(0, 500),
ueberzaehligGesamt: ueberzaehlig.length,
unbrauchbar,
})
}) })
dashRoutes.get('/export', (c) => { dashRoutes.get('/export', (c) => {
@@ -2883,7 +2808,7 @@ dashRoutes.get('/tokens', (c) => {
const rows = db const rows = db
.prepare( .prepare(
`SELECT id, name, prefix, can_delete, created_at, last_used_at, `SELECT id, name, prefix, can_delete, created_at, last_used_at,
max_bytes, quota_bytes, max_kante, arten, expires_at max_bytes, quota_bytes, max_kante, arten, expires_at, privat
FROM tokens ORDER BY created_at DESC`, FROM tokens ORDER BY created_at DESC`,
) )
.all() as Omit<Token, 'hash'>[] .all() as Omit<Token, 'hash'>[]
@@ -2945,9 +2870,12 @@ dashRoutes.post('/tokens', async (c) => {
maxKante?: number | string maxKante?: number | string
arten?: string[] arten?: string[]
gueltigTage?: number | string gueltigTage?: number | string
privat?: boolean
}>(c) }>(c)
if (!body) return c.json(KEIN_JSON, 400) if (!body) return c.json(KEIN_JSON, 400)
const { name, prefix, canDelete } = body const { name, prefix, canDelete } = body
// d4rk-rp-planung#227: nur privater Bereich, kein oeffentlicher Upload.
const privat = body.privat === true
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400) if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
@@ -3029,8 +2957,8 @@ dashRoutes.post('/tokens', async (c) => {
const info = db const info = db
.prepare( .prepare(
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at, `INSERT INTO tokens (name, hash, prefix, can_delete, created_at,
max_bytes, quota_bytes, max_kante, arten, expires_at) max_bytes, quota_bytes, max_kante, arten, expires_at, privat)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
name.trim(), name.trim(),
@@ -3043,9 +2971,16 @@ dashRoutes.post('/tokens', async (c) => {
maxKante, maxKante,
arten, arten,
expiresAt, expiresAt,
privat ? 1 : 0,
) )
logVerwaltung(c.get('user').username, 'token', 'angelegt', name.trim(), cleanPrefix || 'ueberall') logVerwaltung(
c.get('user').username,
'token',
'angelegt',
name.trim(),
`${cleanPrefix || 'ueberall'}${privat ? ', privat' : ''}`,
)
// Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in // Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in
// der Datenbank, und auch wir koennen ihn nicht mehr zeigen. // der Datenbank, und auch wir koennen ihn nicht mehr zeigen.
@@ -3057,6 +2992,7 @@ dashRoutes.post('/tokens', async (c) => {
maxBytes, maxBytes,
arten, arten,
expiresAt, expiresAt,
privat,
token, token,
}) })
}) })
@@ -3090,6 +3026,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
maxKante?: number | string | null maxKante?: number | string | null
arten?: string[] | null arten?: string[] | null
gueltigTage?: number | string | null gueltigTage?: number | string | null
privat?: boolean
}>(c) }>(c)
if (!body) return c.json(KEIN_JSON, 400) if (!body) return c.json(KEIN_JSON, 400)
@@ -3115,6 +3052,10 @@ dashRoutes.patch('/tokens/:id', async (c) => {
} }
if (body.canDelete !== undefined) feld.can_delete = body.canDelete ? 1 : 0 if (body.canDelete !== undefined) feld.can_delete = body.canDelete ? 1 : 0
// d4rk-rp-planung#227. Umschalten wirkt sofort in beide Richtungen: ein
// Token, der privat wird, kommt ab dem naechsten Aufruf an keinen
// oeffentlichen Weg mehr -- und umgekehrt nicht mehr an private Dateien.
if (body.privat !== undefined) feld.privat = body.privat ? 1 : 0
// -- Die Zahlen. null loescht die Grenze, eine Zahl setzt sie. ---------- // -- Die Zahlen. null loescht die Grenze, eine Zahl setzt sie. ----------
const zahl = (wert: unknown, was: string, deckel?: number) => { const zahl = (wert: unknown, was: string, deckel?: number) => {
@@ -3183,7 +3124,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
const neu = db const neu = db
.prepare( .prepare(
`SELECT id, name, prefix, can_delete, created_at, last_used_at, `SELECT id, name, prefix, can_delete, created_at, last_used_at,
max_bytes, quota_bytes, max_kante, arten, expires_at max_bytes, quota_bytes, max_kante, arten, expires_at, privat
FROM tokens WHERE id = ?`, FROM tokens WHERE id = ?`,
) )
.get(id) as Omit<Token, 'hash'> .get(id) as Omit<Token, 'hash'>
+89 -1
View File
@@ -27,11 +27,12 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import type { Context, Next } from 'hono' import type { Context, Next } from 'hono'
import { createHash, randomBytes } from 'node:crypto'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js' import { PRIVAT_NUR_DORT, kontingentUeberschritten } from './upload.js'
import { import {
checkPath, checkPath,
kannBewegtVorschau, kannBewegtVorschau,
@@ -61,6 +62,10 @@ const wache = async (c: Context<{ Variables: Vars }>, next: Next) => {
if (token.expires_at && token.expires_at < now()) { if (token.expires_at && token.expires_at < now()) {
return c.json({ status: 'error', message: 'Token ist abgelaufen' }, 401) return c.json({ status: 'error', message: 'Token ist abgelaufen' }, 401)
} }
// Wie in upload.ts: ein privater Token kommt an keinen oeffentlichen Weg.
if (token.privat) {
return c.json({ status: 'error', message: PRIVAT_NUR_DORT }, 403)
}
c.set('token', token) c.set('token', token)
await next() await next()
} }
@@ -345,3 +350,86 @@ fivemanageRoutes.post('/v3/file', async (c) => {
if (!r.ok) return r.antwort if (!r.ok) return r.antwort
return c.json({ status: 'ok', data: { id: r.id, url: r.url } }) return c.json({ status: 'ok', data: { id: r.id, url: r.url } })
}) })
// ------------------------------------------------------- Presigned URLs (v3)
//
// WOZU (d4rk-rp-planung#239). Ein Skript, dessen Bytes vom SPIELER kommen
// (Handy-Kamera, Galerie, Video), musste bisher den Token selbst an die NUI
// geben - und damit an jeden Client. Fivemanage loest das mit einer
// kurzlebigen Adresse: der Server holt sie MIT Token, der Client laedt OHNE
// Token dorthin hoch. Diesen Weg spricht der Dienst jetzt auch.
//
// NACHGELESEN (19.10.2026), nicht geraten:
// docs.fivemanage.com, api-reference/files/create-presigned-upload-url
// GET /api/v3/file/presigned-url, Authorization: <token>,
// optional ?expiresAt=<Unix-Sekunden>, Standard 15 Minuten
// -> { status: "ok", data: { presignedUrl } }
// .../upload-file-via-presigned-url
// POST /api/v3/file/presigned-url/<schluessel>, multipart, Feld file
// -> { status, data: { id, url } }
// sky_phone (source/server/media.lua, PhoneMediaCapture.vue) nutzt genau das.
//
// STRENGER ALS FIVEMANAGE, mit Absicht:
// - jeder Schluessel gilt EINMAL. Ob Fivemanages Adressen mehrfach gehen,
// sagt deren Doku nicht; hier ist es ausgeschlossen. Auch ein Versuch,
// der an einer Fessel scheitert, verbraucht ihn.
// - hoechstens 15 Minuten, auch wenn expiresAt mehr verlangt.
// - es gelten die Fesseln des AUSSTELLENDEN Tokens, frisch aus der
// Datenbank beim Hochladen gelesen - wer den Token inzwischen sperrt oder
// fesselt, sperrt damit auch seine offenen Schluessel.
//
// IM ARBEITSSPEICHER (Betreiber 19.10.2026): ein Neustart verwirft offene
// Schluessel. Sie gelten ohnehin nur Minuten, und so braucht es keine Tabelle.
// Gespeichert wird nur der HASH - der Schluessel selbst steht nur in der Adresse.
const SCHLUESSEL_HOECHSTENS_MS = 15 * 60_000
const offeneSchluessel = new Map<string, { tokenId: number; bis: number }>()
const hashVon = (s: string) => createHash('sha256').update(s).digest('hex')
fivemanageRoutes.use('/v3/file/presigned-url', wache)
fivemanageRoutes.get('/v3/file/presigned-url', (c) => {
const token = c.get('token')
const jetzt = now()
for (const [h, s] of offeneSchluessel) if (s.bis <= jetzt) offeneSchluessel.delete(h)
// expiresAt in Unix-SEKUNDEN wie bei Fivemanage; ohne Angabe 15 Minuten.
const roh = c.req.query('expiresAt')
let bis = jetzt + SCHLUESSEL_HOECHSTENS_MS
if (roh !== undefined) {
const s = Number(roh)
if (!Number.isFinite(s) || s * 1000 <= jetzt) {
return c.json({ status: 'error', message: 'expiresAt muss ein Zeitpunkt in der Zukunft sein (Unix-Sekunden)' }, 400)
}
bis = Math.min(s * 1000, jetzt + SCHLUESSEL_HOECHSTENS_MS)
}
const schluessel = randomBytes(32).toString('base64url')
offeneSchluessel.set(hashVon(schluessel), { tokenId: token.id, bis })
// Kein Eintrag im Verlauf: der zaehlt Dateien. Der Upload danach steht dort.
return c.json({
status: 'ok',
data: { presignedUrl: `${config.publicUrl}/api/v3/file/presigned-url/${schluessel}`, expiresAt: Math.floor(bis / 1000) },
})
})
fivemanageRoutes.post('/v3/file/presigned-url/:schluessel', async (c) => {
const h = hashVon(c.req.param('schluessel'))
const s = offeneSchluessel.get(h)
// ZUERST verbrauchen, dann pruefen: ein zweiter Versuch mit demselben
// Schluessel findet nichts mehr, egal wie der erste ausging.
offeneSchluessel.delete(h)
if (!s) return c.json({ status: 'error', message: 'Schluessel unbekannt oder schon benutzt' }, 401)
if (s.bis <= now()) return c.json({ status: 'error', message: 'Schluessel abgelaufen' }, 401)
const token = db.prepare('SELECT * FROM tokens WHERE id = ?').get(s.tokenId) as Token | undefined
if (!token) return c.json({ status: 'error', message: 'Der ausstellende Token gibt es nicht mehr' }, 401)
if (token.expires_at && token.expires_at < now()) {
return c.json({ status: 'error', message: 'Der ausstellende Token ist abgelaufen' }, 401)
}
if (token.privat) return c.json({ status: 'error', message: PRIVAT_NUR_DORT }, 403)
c.set('token', token)
const r = await ablegen(c)
if (!r.ok) return r.antwort
return c.json({ status: 'ok', data: { id: r.id, url: r.url } })
})
+194
View File
@@ -0,0 +1,194 @@
// Der PRIVATE Bereich (d4rk-rp-planung#227, docs/plan-diensthandy.md H1).
//
// WOFUER. Fotos, die Behoerden im Spiel machen. Unter /f/ ist jede Datei fuer
// jeden lesbar, der die Adresse kennt (routes/freigabe.ts sagt es selbst) --
// fuer ein Polizeifoto ist das falsch. Hier liest nur, wer einen Token mit der
// Fessel "privat" hat, und das ist auf unserer Seite allein das MDT-Backend.
// Es prueft die Rechte der Person, protokolliert das Oeffnen und reicht das
// Bild weiter. Der Token liegt nie in einem Browser und nie im Spiel.
//
// PUT /api/privat/datei/<pfad> ablegen (Rumpf roh, oder Base64
// mit X-Encoding: base64), X-Mime
// GET /api/privat/datei/<pfad> lesen
// GET /api/privat/datei/<pfad>?vorschau=1 320 px WebP, 404 wenn keine
//
// DREI GRENZEN DURCH BAUART, nicht durch Bedingungen:
// - eigener Ordner neben files/ (config.privatDir): /f/ und /t/ loesen nur
// unter files/ bzw. thumbs/ auf und kommen nie hierher;
// - eigene Tabelle privat_media: keine Liste, keine Freigabe, keine Galerie
// liest sie;
// - ein privater Token kommt an keinen oeffentlichen Upload-Weg (Wachen in
// upload.ts und fivemanage.ts), ein oeffentlicher an keinen privaten.
//
// KEIN UEBERSCHREIBEN, KEIN LOESCHEN. Ein Beweisfoto wird nicht ausgetauscht;
// derselbe Pfad ein zweites Mal ist 409. Stornieren macht der Verbraucher.
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { config } from '../config.js'
import { db, now, type PrivatMedia, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import {
PathError,
checkPathUnter,
dateiKopf,
kannVorschau,
mimeFor,
privatPfad,
privatVorschauPfad,
sha256,
vielleichtKleiner,
writePrivatAtomic,
writePrivatVorschau,
} from '../storage.js'
import { artVon, readBody } from './upload.js'
type Vars = { token: Token }
export const privatRoutes = new Hono<{ Variables: Vars }>()
/** Nur Tokens mit der Fessel "privat". Ein oeffentlicher Token bekommt 403 --
* nicht 404: er soll erfahren, dass er hier falsch ist, statt einen Tippfehler
* im Pfad zu suchen. */
const wache = async (c: Context<{ Variables: Vars }>, next: Next) => {
const token = tokenFromHeader(c.req.header('authorization'))
if (!token) return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
if (token.expires_at && token.expires_at < now()) {
return c.json(
{ error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.` },
401,
)
}
if (!token.privat) {
return c.json({ error: 'Dieser Token hat keinen Zugang zum privaten Bereich.' }, 403)
}
c.set('token', token)
await next()
}
privatRoutes.use('/datei/*', wache)
/** Den Pfad pruefen: dieselben Regeln wie oeffentlich, aber unter privatDir,
* und die Praefix-Fessel des Tokens gilt auch beim LESEN. */
function pfadAus(c: Context<{ Variables: Vars }>, token: Token): { path: string } | { antwort: Response } {
let path: string
try {
path = checkPathUnter(c.req.param('pfad') ?? '', config.privatDir)
} catch (err) {
if (err instanceof PathError) return { antwort: c.json({ error: err.message }, 400) }
throw err
}
if (!tokenAllows(token, path)) {
return { antwort: c.json({ error: `dieser Token darf nur unter ${fesselText(token)} arbeiten` }, 403) }
}
return { path }
}
privatRoutes.put('/datei/:pfad{.+}', async (c) => {
const token = c.get('token')
const p = pfadAus(c, token)
if ('antwort' in p) return p.antwort
const { path } = p
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
const angesagt = Number(c.req.header('content-length') ?? 0)
if (angesagt > grenze) return c.json({ error: `zu gross: ${angesagt} > ${grenze} Bytes` }, 413)
// Kein Ueberschreiben -- VOR dem Lesen des Rumpfes geprueft.
if (db.prepare('SELECT 1 FROM privat_media WHERE path = ?').get(path)) {
return c.json({ error: 'Pfad ist belegt - private Dateien werden nicht ersetzt', path }, 409)
}
let data: Buffer
try {
data = (await readBody(c)).data
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
}
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
if (data.length > grenze) return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413)
// Das Kontingent ueber das, was in DIESEM Bereich wirklich liegt.
if (token.quota_bytes) {
const belegt = (
db.prepare('SELECT COALESCE(SUM(size),0) AS b FROM privat_media WHERE token_id = ?').get(token.id) as {
b: number
}
).b
if (belegt + data.length > token.quota_bytes) {
return c.json({ error: `Kontingent erschoepft: belegt ${belegt} von ${token.quota_bytes} Bytes` }, 413)
}
}
const mime = c.req.header('x-mime') ?? mimeFor(path)
if (token.arten) {
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
const art = artVon(mime)
if (!erlaubt.includes(art)) {
return c.json({ error: `Dieser Token darf nur ${erlaubt.join(', ')} ablegen - das hier ist "${art}" (${mime}).` }, 403)
}
}
let daten = data
if (token.max_kante) daten = (await vielleichtKleiner(data, mime, token.max_kante)).data
const digest = sha256(daten)
try {
await writePrivatAtomic(path, daten)
} catch (err) {
console.error('[privat] Schreiben fehlgeschlagen:', err)
return c.json({ error: 'Schreiben fehlgeschlagen' }, 500)
}
const vorschau = kannVorschau(mime) ? await writePrivatVorschau(path, daten) : false
try {
db.prepare(
`INSERT INTO privat_media (path, size, sha256, mime, token_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(path, daten.length, digest, mime, token.id, now())
} catch (err) {
// Zwei gleichzeitige Aufrufe mit demselben Pfad: der zweite verliert
// hier am UNIQUE. Die Datei auf der Platte ist dann die des ersten
// ODER des zweiten -- deshalb laut, statt still 200.
console.error(`[privat] ${path}: Eintrag fehlgeschlagen:`, err)
return c.json({ error: 'Pfad ist belegt (gleichzeitiger Aufruf)', path }, 409)
}
// Eine Zeile ins Protokoll des Dienstes -- ohne Discord-Meldung und ohne
// Eintrag in events: der Verlauf im Dashboard verlinkt nach /f/.
console.log(`[privat] abgelegt ${path} (${daten.length} B, ${mime}) von Token "${token.name}"`)
return c.json({ path, size: daten.length, sha256: digest, mime, vorschau })
})
privatRoutes.get('/datei/:pfad{.+}', async (c) => {
const token = c.get('token')
const p = pfadAus(c, token)
if ('antwort' in p) return p.antwort
const { path } = p
// Die Tabelle ist die Wahrheit: was dort nicht steht, gibt es nicht --
// auch wenn eine Datei auf der Platte laege.
const row = db.prepare('SELECT * FROM privat_media WHERE path = ?').get(path) as PrivatMedia | undefined
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
const vorschau = c.req.query('vorschau') === '1'
const datei = vorschau ? privatVorschauPfad(path) : privatPfad(path)
let groesse: number
try {
groesse = (await stat(datei)).size
} catch {
if (!vorschau) console.error(`[privat] ${path}: Eintrag da, Datei fehlt`)
return c.json({ error: vorschau ? 'keine Vorschau' : 'Datei fehlt' }, 404)
}
return c.body(createReadStream(datei) as any, 200, {
'Content-Type': vorschau ? 'image/webp' : row.mime,
'Content-Length': String(groesse),
// Nie in einem geteilten Zwischenspeicher; nosniff und Sandbox (#254) aus storage.ts.
'Cache-Control': 'private, no-store',
...dateiKopf(vorschau ? 'image/webp' : row.mime),
})
})
+141 -3
View File
@@ -22,9 +22,10 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import type { Context, Next } from 'hono' import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader, tokenPrefixes } from '../auth.js'
import { import {
PathError, PathError,
checkPath, checkPath,
@@ -68,10 +69,21 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
401, 401,
) )
} }
// Ein PRIVATER Token kommt an keinen oeffentlichen Weg (d4rk-rp-planung#227):
// sonst laege ein Polizeifoto nach einem falschen Aufruf unter /f/, fuer
// jeden lesbar, der die Adresse kennt.
if (token.privat) {
return c.json({ error: PRIVAT_NUR_DORT }, 403)
}
c.set('token', token) c.set('token', token)
await next() await next()
} }
/** Die Antwort an einen privaten Token auf einem oeffentlichen Weg. Auch von
* fivemanage.ts benutzt -- EIN Satz fuer beide Wachen. */
export const PRIVAT_NUR_DORT =
'Dieser Token gilt nur fuer den privaten Bereich (/api/privat) und kann hier nicht hochladen.'
/** Prueft das Gesamtkontingent. Gibt einen SATZ zurueck, wenn es reisst. /** Prueft das Gesamtkontingent. Gibt einen SATZ zurueck, wenn es reisst.
* *
* Die Meldung nennt Zahlen: "belegt 1,9 GB von 2 GB, diese Datei braucht 130 * Die Meldung nennt Zahlen: "belegt 1,9 GB von 2 GB, diese Datei braucht 130
@@ -125,7 +137,7 @@ function groesse(n: number): string {
* *
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der * An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
* Galerie nicht auseinanderlaufen koennen. */ * Galerie nicht auseinanderlaufen koennen. */
function artVon(mime: string): string { export function artVon(mime: string): string {
const m = mime.split(';')[0]?.trim().toLowerCase() ?? '' const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
if (m.startsWith('image/')) return 'bild' if (m.startsWith('image/')) return 'bild'
if (m.startsWith('video/')) return 'video' if (m.startsWith('video/')) return 'video'
@@ -141,11 +153,14 @@ uploadRoutes.use('/media/*', requireToken)
// '/exists/*' trifft sie NICHT, weil dort kein Schraegstrich mehr folgt -- und // '/exists/*' trifft sie NICHT, weil dort kein Schraegstrich mehr folgt -- und
// eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen // eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen
// soll. // soll.
uploadRoutes.use('/zugang', requireToken)
uploadRoutes.use('/fehlende', requireToken)
uploadRoutes.use('/liste', requireToken)
uploadRoutes.use('/exists', requireToken) uploadRoutes.use('/exists', requireToken)
uploadRoutes.use('/exists/*', requireToken) uploadRoutes.use('/exists/*', requireToken)
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */ /** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
async function readBody( export async function readBody(
c: Context<{ Variables: Vars }>, c: Context<{ Variables: Vars }>,
): Promise<{ data: Buffer; path?: string; filename?: string }> { ): Promise<{ data: Buffer; path?: string; filename?: string }> {
const contentType = c.req.header('content-type') ?? '' const contentType = c.req.header('content-type') ?? ''
@@ -425,6 +440,129 @@ function existsAntwort(c: Context<{ Variables: Vars }>, roh: string) {
) )
} }
/** Was darf dieser Token eigentlich?
*
* WOZU. Das Fotostudio soll beim Einrichten eine AUSWAHL der Ordner zeigen
* statt eines Freitextfeldes -- und eine Auswahl, die etwas anbietet, das
* hinterher mit 403 abprallt, ist schlimmer als keine.
*
* Bisher erfuhr man die Fesseln nur, indem man dagegen lief: die
* 403-Meldung nennt sie. Das ist eine Auskunft ueber den eigenen Zugang, kein
* Geheimnis -- wer den Token hat, kennt seinen Umfang ohnehin, er muss ihn
* nur ausprobieren. Also lieber geradeheraus.
*
* Eine leere Liste heisst "ueberall", genau wie in der Datenbank. Das steht
* auch im Feld daneben, damit niemand "keine Ordner" liest.
*/
uploadRoutes.get('/zugang', (c) => {
const token = c.get('token') as Token
const fesseln = tokenPrefixes(token)
// WELCHE ORDNER ZUR AUSWAHL STEHEN.
//
// Ist der Token gefesselt, sind es genau seine -- eine Auswahl, die etwas
// anbietet, das hinterher mit 403 abprallt, waere schlimmer als keine.
//
// Ist er es NICHT, waere die Antwort "ueberall" fuer ein Auswahlfeld
// wertlos. Dann kommen die Ordner, die es wirklich gibt. Das ist keine
// Preisgabe: wer ueberall schreiben darf, kommt an jeden davon ohnehin --
// er muesste den Namen nur raten.
const vorhanden = (
db
.prepare(
`SELECT DISTINCT substr(path, 1, instr(path, '/') - 1) AS o
FROM media WHERE instr(path, '/') > 0 ORDER BY o`,
)
.all() as { o: string }[]
).map((z) => z.o)
return c.json({
// Woraus das Auswahlfeld waehlen darf.
ordner: fesseln.length > 0 ? fesseln : vorhanden,
// Und ob daneben noch etwas anderes ginge -- dann darf das Feld auch
// einen freien Namen zulassen.
ueberall: fesseln.length === 0,
darfLoeschen: token.can_delete === 1,
name: token.name,
})
})
/** Wozu fehlt ein Bild -- dieselbe Frage wie im Dashboard, mit Token.
*
* WOZU, ergaenzt am 03.10.2026. Das Fotostudio soll "fehlt" so beantworten,
* wie der Laden es sieht: liegt es HIER oder nicht. Mit /exists waeren das
* fuer die Kleidung rund 8.000 Anfragen; hier sind es zwei (hoechstens
* FEHLENDE_HOECHSTENS Namen je Anfrage).
*
* KEINE ORDNERFESSEL, genau wie bei /exists: was hier beantwortet wird, ist
* ueber /f/ ohnehin oeffentlich abrufbar. Der Token haelt nur Fremde davon ab,
* den Dienst mit Sammelabfragen zu beschaeftigen.
*
* Kein .webp/.png am Ende der Adresse -- die Proxy-Falle von /exists/<pfad>
* (siehe unten) kann hier nicht zuschnappen.
*/
/** Was liegt in einem Ordner -- die Liste, nicht die Frage nach Namen.
*
* WOZU, ergaenzt am 03.10.2026. /api/fehlende braucht die NAMEN, und die
* kennt beim Fotostudio nur der Spiel-Client: die Kleidungsnamen entstehen
* dort aus Natives, je Textur eines. Sie zum Server zu schicken hiesse rund
* 8.000 Namen ueber ein Netzereignis. Umgekehrt ist es eine Anfrage: der
* Dienst sagt, was liegt, und das Studio prueft jeden Namen selbst.
*
* SCHREIBWEISE GENAU, wie ueberall: `substr(...) = ?` und nicht LIKE --
* LIKE ist in SQLite fuer ASCII schreibweisenblind und naehme
* `Clothing/...` mit in `clothing/`.
*
* Mit Unterordnern (felgen/<radtyp>/<nummer> wird einer sein). Ohne
* Ordnerfessel, wie /exists: alles hier ist ueber /f/ ohnehin abrufbar.
*
* GEKAPPT bei LISTE_HOECHSTENS, und dann steht `gekappt: true` daneben --
* eine abgeschnittene Liste darf nicht als vollstaendige durchgehen, sonst
* meldet der Aufrufer alles dahinter als fehlend.
*/
const LISTE_HOECHSTENS = 100_000
uploadRoutes.get('/liste', (c) => {
const roh = (c.req.query('ordner') ?? '').trim().replace(/^\/+|\/+$/g, '')
if (!roh) return c.json({ error: 'Abfrage "ordner" fehlt, z. B. ?ordner=clothing' }, 400)
let ordner: string
try {
ordner = checkPath(roh)
} catch (err) {
return c.json({ error: err instanceof PathError ? err.message : 'Ordner unbrauchbar' }, 400)
}
const praefix = ordner + '/'
const gesamt = (
db
.prepare('SELECT count(*) AS n FROM media WHERE substr(path, 1, ?) = ?')
.get(praefix.length, praefix) as { n: number }
).n
const pfade = (
db
.prepare(
'SELECT path FROM media WHERE substr(path, 1, ?) = ? ORDER BY path LIMIT ?',
)
.all(praefix.length, praefix, LISTE_HOECHSTENS) as { path: string }[]
).map((z) => z.path)
return c.json({ ordner, gesamt, gekappt: pfade.length < gesamt, pfade })
})
uploadRoutes.post('/fehlende', async (c) => {
let body: { muster?: unknown; namen?: unknown } | null = null
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Kein gueltiges JSON' }, 400)
}
try {
return c.json(fehlendeRechnen(body?.muster, body?.namen))
} catch (err) {
if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400)
throw err
}
})
uploadRoutes.get('/exists', (c) => { uploadRoutes.get('/exists', (c) => {
const roh = c.req.query('pfad') ?? c.req.query('path') ?? '' const roh = c.req.query('pfad') ?? c.req.query('path') ?? ''
if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400) if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400)
+69 -4
View File
@@ -28,6 +28,14 @@ export class PathError extends Error {}
/** Prueft den Pfad und gibt ihn normalisiert zurueck. Wirft PathError. */ /** Prueft den Pfad und gibt ihn normalisiert zurueck. Wirft PathError. */
export function checkPath(raw: string): string { export function checkPath(raw: string): string {
return checkPathUnter(raw, config.filesDir)
}
/** Dieselbe Pruefung gegen einen anderen Wurzelordner -- fuer den privaten
* Bereich (config.privatDir). EINE Regel fuer beide Ablagen, damit sie nicht
* auseinanderlaufen; nur die Wurzel, unter der das Ergebnis liegen muss,
* ist verschieden. */
export function checkPathUnter(raw: string, basis: string): string {
const path = raw.trim().replace(/^\/+/, '') const path = raw.trim().replace(/^\/+/, '')
if (!path) throw new PathError('Pfad ist leer') if (!path) throw new PathError('Pfad ist leer')
@@ -58,14 +66,47 @@ export function checkPath(raw: string): string {
// Guertel und Hosentraeger: selbst wenn oben je eine Luecke waere, muss // Guertel und Hosentraeger: selbst wenn oben je eine Luecke waere, muss
// das Ergebnis unterhalb des Datenordners liegen. // das Ergebnis unterhalb des Datenordners liegen.
const absolute = resolve(config.filesDir, path) const absolute = resolve(basis, path)
if (absolute !== config.filesDir && !absolute.startsWith(config.filesDir + sep)) { if (absolute !== basis && !absolute.startsWith(basis + sep)) {
throw new PathError('Pfad zeigt aus dem Datenordner heraus') throw new PathError('Pfad zeigt aus dem Datenordner heraus')
} }
return path return path
} }
/**
* DIE KOPFZEILEN AUF JEDER AUSGELIEFERTEN DATEI (d4rk-rp-planung#254).
*
* Ein SVG kann <script> tragen, und es wird als image/svg+xml ausgeliefert
* -- direkt geoeffnet lief das Skript auf DIESER Herkunft, also da, wo das
* Dashboard mit seiner Sitzung wohnt (FILES_HOST ist leer). Gemessen
* 19.10.2026 mit tools/csp-pruefen.mjs: vorher lief es.
*
* `sandbox` schaltet Skripte im Dokument ab, auch im SVG. Fuer <img>, <video>
* und CSS-Hintergruende anderer Seiten aendert sich nichts: die Policy einer
* Antwort gilt nur, wenn sie SELBST das Dokument ist.
*
* NICHT AUF RASTERBILD, VIDEO UND TON: `sandbox` hielt ein direkt geoeffnetes
* Video an (readyState 0 statt 4, gemessen 19.10.2026 - auch mit media-src *,
* es liegt an der Sandbox selbst). Diese Arten fuehren ohnehin kein Skript aus,
* und nosniff verbietet dem Browser, sie umzudeuten. Die Sandbox bekommt
* deshalb ALLES ANDERE - eine Positivliste, keine Negativliste: eine neue Art,
* an die niemand gedacht hat, ist damit geschuetzt statt offen.
*
* EINE Stelle fuer alle Wege, die Bytes ausliefern (/f/, /t/, der Dateiwirt,
* /api/privat). Ein Weg ohne diese Kopfzeilen waere die Luecke von vorn.
*/
const OHNE_SANDBOX = /^(image\/(png|jpeg|gif|webp|avif)|video\/|audio\/)/i
const SANDBOX = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
/** Die Sicherheits-Kopfzeilen fuer eine Datei dieser Art (Content-Type). */
export function dateiKopf(typ: string): Record<string, string> {
return OHNE_SANDBOX.test(typ)
// Kein Verlassen auf den Dateinamen: der Browser soll nicht raten, was er vor sich hat.
? { 'X-Content-Type-Options': 'nosniff' }
: { 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': SANDBOX }
}
const MIME: Record<string, string> = { const MIME: Record<string, string> = {
webp: 'image/webp', webp: 'image/webp',
png: 'image/png', png: 'image/png',
@@ -254,7 +295,19 @@ export async function vielleichtKleiner(
* Bild durch ein halbes ersetzen — und halbe Bilder sehen im Handy aus wie * Bild durch ein halbes ersetzen — und halbe Bilder sehen im Handy aus wie
* ein Fehler im Handy. */ * ein Fehler im Handy. */
export async function writeFileAtomic(path: string, data: Buffer): Promise<void> { export async function writeFileAtomic(path: string, data: Buffer): Promise<void> {
const target = absolutePath(path) await schreibenNach(absolutePath(path), data)
}
/** Wo eine PRIVATE Datei liegt. Den Pfad vorher mit
* checkPathUnter(raw, config.privatDir) pruefen. */
export const privatPfad = (path: string) => join(config.privatDir, path)
/** Eine private Datei schreiben -- dieselbe Atomaritaet wie writeFileAtomic. */
export async function writePrivatAtomic(path: string, data: Buffer): Promise<void> {
await schreibenNach(privatPfad(path), data)
}
async function schreibenNach(target: string, data: Buffer): Promise<void> {
await mkdir(dirname(target), { recursive: true }) await mkdir(dirname(target), { recursive: true })
const temp = `${target}.${process.pid}.${Date.now()}.tmp` const temp = `${target}.${process.pid}.${Date.now()}.tmp`
@@ -295,7 +348,19 @@ export const kannVorschau = (mime: string) =>
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der * scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */ * Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
export async function writeThumb(path: string, data: Buffer): Promise<boolean> { export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
const ziel = thumbPath(path) return vorschauNach(thumbPath(path), data, path)
}
/** Die Vorschau eines PRIVATEN Bildes -- unter config.privatVorschauDir, das
* /t/ nicht ausliefert. Dieselbe Rechnung wie writeThumb. */
export const privatVorschauPfad = (path: string) => join(config.privatVorschauDir, `${path}.webp`)
export async function writePrivatVorschau(path: string, data: Buffer): Promise<boolean> {
return vorschauNach(privatVorschauPfad(path), data, `privat:${path}`)
}
/** Die eine Rechnung fuer beide Ablagen: auf THUMB_BREITE, als WebP, an `ziel`. */
async function vorschauNach(ziel: string, data: Buffer, path: string): Promise<boolean> {
try { try {
// Der Import steht hier drin und nicht oben: sharp zieht eine native // Der Import steht hier drin und nicht oben: sharp zieht eine native
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein // Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
+5
View File
@@ -183,6 +183,8 @@ export type TokenRow = {
/** Erlaubte Arten, mit Komma. Leer = alle. */ /** Erlaubte Arten, mit Komma. Leer = alle. */
arten: string arten: string
expires_at: number | null expires_at: number | null
/** 1 = nur privater Bereich (/api/privat), kein öffentlicher Upload. */
privat: number
abgelaufen: boolean abgelaufen: boolean
/** Wieviel mit DIESEM Token abgelegt wurde. */ /** Wieviel mit DIESEM Token abgelegt wurde. */
dateien: number dateien: number
@@ -199,6 +201,7 @@ export type NewToken = {
name: string name: string
prefix: string prefix: string
canDelete: boolean canDelete: boolean
privat: boolean
token: string token: string
} }
@@ -584,6 +587,7 @@ export const api = {
maxKante?: string | null maxKante?: string | null
arten?: string[] | null arten?: string[] | null
gueltigTage?: string | null gueltigTage?: string | null
privat?: boolean
}, },
) => request<TokenRow & { geaendert: string[] }>(`/api/dash/tokens/${id}`, { ) => request<TokenRow & { geaendert: string[] }>(`/api/dash/tokens/${id}`, {
method: 'PATCH', method: 'PATCH',
@@ -625,6 +629,7 @@ export const api = {
quotaMb?: string quotaMb?: string
arten?: string[] arten?: string[]
gueltigTage?: string gueltigTage?: string
privat?: boolean
}) => post<NewToken>('/api/dash/tokens', daten), }) => post<NewToken>('/api/dash/tokens', daten),
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`), deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
} }
@@ -36,6 +36,7 @@ export function TokenBearbeiten({
const [name, setName] = useState(token.name) const [name, setName] = useState(token.name)
const [prefix, setPrefix] = useState(token.prefix) const [prefix, setPrefix] = useState(token.prefix)
const [canDelete, setCanDelete] = useState(Boolean(token.can_delete)) const [canDelete, setCanDelete] = useState(Boolean(token.can_delete))
const [privat, setPrivat] = useState(Boolean(token.privat))
const [maxMb, setMaxMb] = useState( const [maxMb, setMaxMb] = useState(
token.max_bytes ? String(Math.round((token.max_bytes / 1048576) * 10) / 10) : '', token.max_bytes ? String(Math.round((token.max_bytes / 1048576) * 10) / 10) : '',
) )
@@ -65,6 +66,7 @@ export function TokenBearbeiten({
name: name.trim(), name: name.trim(),
prefix, prefix,
canDelete, canDelete,
privat,
// Leer heißt ausdrücklich „keine Grenze" und nicht // Leer heißt ausdrücklich „keine Grenze" und nicht
// „nicht angefasst" — sonst ließe sich eine einmal // „nicht angefasst" — sonst ließe sich eine einmal
// gesetzte Grenze nie wieder loswerden. // gesetzte Grenze nie wieder loswerden.
@@ -148,6 +150,22 @@ export function TokenBearbeiten({
</span> </span>
</label> </label>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={privat}
onChange={(e) => setPrivat(e.target.checked)}
className="mt-0.5 accent-akzent"
/>
<span>
nur <strong className="text-schrift">privater Bereich</strong>
<span className="block text-xs text-schrift-leise">
Wirkt sofort: angehakt kann der Token nicht mehr öffentlich
hochladen, abgehakt nicht mehr privat lesen.
</span>
</span>
</label>
<div> <div>
<span className="mb-1 block text-xs font-medium text-schrift-leise"> <span className="mb-1 block text-xs font-medium text-schrift-leise">
Erlaubte Arten (nichts angekreuzt = alle) Erlaubte Arten (nichts angekreuzt = alle)
+27
View File
@@ -14,6 +14,7 @@ export function Tokens() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [prefix, setPrefix] = useState('') const [prefix, setPrefix] = useState('')
const [darfLoeschen, setDarfLoeschen] = useState(false) const [darfLoeschen, setDarfLoeschen] = useState(false)
const [privat, setPrivat] = useState(false)
const [maxMb, setMaxMb] = useState('') const [maxMb, setMaxMb] = useState('')
const [quotaMb, setQuotaMb] = useState('') const [quotaMb, setQuotaMb] = useState('')
const [maxKante, setMaxKante] = useState('') const [maxKante, setMaxKante] = useState('')
@@ -60,6 +61,7 @@ export function Tokens() {
maxKante: maxKante.trim(), maxKante: maxKante.trim(),
arten, arten,
gueltigTage: gueltigTage.trim(), gueltigTage: gueltigTage.trim(),
privat,
}), }),
(t) => `Token "${t.name}" angelegt.`, (t) => `Token "${t.name}" angelegt.`,
) )
@@ -69,6 +71,7 @@ export function Tokens() {
setName('') setName('')
setPrefix('') setPrefix('')
setDarfLoeschen(false) setDarfLoeschen(false)
setPrivat(false)
setMaxMb('') setMaxMb('')
setGueltigTage('') setGueltigTage('')
setArten([]) setArten([])
@@ -205,6 +208,24 @@ export function Tokens() {
</span> </span>
</label> </label>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={privat}
onChange={(e) => setPrivat(e.target.checked)}
className="mt-0.5 accent-akzent"
/>
<span>
nur privater Bereich
<span className="block text-xs text-schrift-leise">
Für Fotos, die nicht öffentlich sein dürfen (Behörden). Der Token
legt nur unter <code className="font-mono">/api/privat</code> ab
und liest nur dort — nicht unter /f/, ohne Liste, ohne Freigabe.
Öffentlich hochladen kann er dann nicht.
</span>
</span>
</label>
<div> <div>
<Knopf <Knopf
art="haupt" art="haupt"
@@ -274,6 +295,11 @@ export function Tokens() {
: '— (überall)'} : '— (überall)'}
</td> </td>
<td className="py-2"> <td className="py-2">
{t.privat ? (
<span className="mr-1 rounded bg-akzent/15 px-1.5 py-0.5 text-xs text-schrift">
privat
</span>
) : null}
{t.can_delete ? ( {t.can_delete ? (
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn"> <span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
schreiben + löschen schreiben + löschen
@@ -411,6 +437,7 @@ export function Tokens() {
<p className="mt-3 text-xs text-schrift-leise"> <p className="mt-3 text-xs text-schrift-leise">
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '} Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'} · Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
{frisch.privat ? ' · nur privater Bereich' : ''}
</p> </p>
</div> </div>
+122
View File
@@ -0,0 +1,122 @@
// Prueft, dass ausgelieferte Dateien kein Skript ausfuehren (d4rk-rp-planung#254)
// und trotzdem noch als Bild und Video funktionieren.
//
// node tools/csp-pruefen.mjs <basis> <svg-pfad> <png-pfad> <mp4-pfad>
// node tools/csp-pruefen.mjs http://localhost:9188 test/skript.svg test/bild.png test/clip.mp4
//
// Gemessen wird im ECHTEN Browser (Edge kopflos, DevTools-Protokoll), nicht an
// der Kopfzeile allein: ob ein SVG-Skript laeuft, entscheidet der Browser.
// Das SVG setzt, wenn sein Skript laeuft, data-lief="ja" am Wurzelelement.
//
// GEGENPROBE: gegen einen Stand OHNE die Kopfzeile muss "svgSkriptLief" true
// sein - sonst sieht diese Pruefung nichts und ihr "false" hiesse nichts.
// Mit --erwarte-lauf prueft sie genau das (Rueckgabe 0, wenn das Skript LIEF).
//
// Das Edge-Profil: frisch je Lauf, aber mit der `Local State` einer Vorlage -
// ein fremdes Profil kostet sonst eine Fehlanmeldung am Windows-Konto, zehn
// davon sperren es (gemessen 12.10.2026, d4rk_mdt/frontend/tools/cdp.mjs,
// dort steht dieselbe Logik und warum).
import { spawn, spawnSync } from 'node:child_process'
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'
const VORLAGE = join(process.env.LOCALAPPDATA ?? tmpdir(), 'd4rk-messen', 'edge-vorlage', 'Local State')
const [basis, svg, png, mp4] = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const erwarteLauf = process.argv.includes('--erwarte-lauf')
if (!basis || !svg || !png || !mp4) {
console.error('Aufruf: node tools/csp-pruefen.mjs <basis> <svg-pfad> <png-pfad> <mp4-pfad> [--erwarte-lauf]')
process.exit(2)
}
const schlaf = (ms) => new Promise((r) => setTimeout(r, ms))
// 1. Die Kopfzeilen, ohne Browser.
const kopf = {}
for (const [art, pfad] of [['svg', svg], ['png', png], ['mp4', mp4]]) {
const r = await fetch(`${basis}/f/${pfad}`)
kopf[art] = { status: r.status, typ: r.headers.get('content-type'), csp: r.headers.get('content-security-policy') }
await r.arrayBuffer()
}
const teil = await fetch(`${basis}/f/${mp4}`, { headers: { Range: 'bytes=0-99' } })
kopf.mp4Teil = { status: teil.status, csp: teil.headers.get('content-security-policy') }
await teil.arrayBuffer()
// 2. Im Browser.
const profil = mkdtempSync(join(tmpdir(), 'media-csp-'))
try { copyFileSync(VORLAGE, join(profil, 'Local State')) } catch { /* noch keine Vorlage */ }
const port = 9398
const edge = spawn(EDGE, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
`--user-data-dir=${profil}`, `--remote-debugging-port=${port}`, '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' })
let ws = null
try {
let url = null
for (let i = 0; i < 60 && !url; i++) {
try { url = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find((t) => t.type === 'page')?.webSocketDebuggerUrl } catch { /* noch nicht oben */ }
if (!url) await schlaf(250)
}
if (!url) throw new Error('DevTools-Endpunkt kam nicht hoch')
ws = new WebSocket(url)
await new Promise((ok, weg) => { ws.onopen = ok; ws.onerror = () => weg(new Error('WebSocket zu')) })
let nr = 0
const offen = new Map()
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && offen.has(m.id)) { offen.get(m.id)(m); offen.delete(m.id) } }
// Jeder Aufruf mit Zeitgrenze: ohne sie blieb ein unbeantworteter Aufruf ewig
// stehen und der Lauf hing ohne jede Ausgabe (gemessen 19.10.2026).
const cdp = (method, params = {}) => new Promise((ok) => {
const id = ++nr
offen.set(id, ok)
ws.send(JSON.stringify({ id, method, params }))
setTimeout(() => { if (offen.has(id)) { offen.delete(id); ok({ zeit: true }) } }, 10000)
})
const werte = async (ausdruck) => {
const r = await cdp('Runtime.evaluate', { expression: ausdruck, returnByValue: true, awaitPromise: true })
if (r.zeit) return 'ZEIT'
if (r.result?.exceptionDetails) return 'FEHLER: ' + (r.result.exceptionDetails.exception?.description ?? r.result.exceptionDetails.text)
return r.result?.result?.value
}
const oeffne = async (pfad, ms) => { console.error(`oeffne ${pfad}`); await cdp('Page.navigate', { url: `${basis}/f/${pfad}` }); await schlaf(ms) }
await cdp('Page.enable'); await cdp('Runtime.enable')
await oeffne(svg, 1200)
const svgSkriptLief = (await werte(`document.documentElement.getAttribute('data-lief')`)) === 'ja'
const svgGezeichnet = (await werte(`!!document.querySelector('rect')`)) === true
await oeffne(png, 1000)
const pngBreite = await werte(`document.querySelector('img')?.naturalWidth ?? null`)
// OHNE Promise und Timer im Dokument gelesen: in einer Antwort mit `sandbox`
// laufen keine Skript-Timer - ein awaitPromise mit setTimeout kam dort nie
// zurueck (gemessen 19.10.2026, die erste Fassung hing daran). Gewartet wird
// hier auf der Node-Seite.
await oeffne(mp4, 3000)
const video = await werte(`(() => { const v = document.querySelector('video'); return v ? { da: true, breite: v.videoWidth, hoehe: v.videoHeight, bereit: v.readyState } : { da: false } })()`)
// Sandbox NUR auf dem, was Skript tragen kann (storage.ts, dateiKopf): auf
// PNG und MP4 haelt sie das direkt geoeffnete Video an (gemessen 19.10.2026).
const csp = (k) => typeof k?.csp === 'string' && k.csp.includes('sandbox')
const pruefungen = erwarteLauf
? { svgSkriptLief: svgSkriptLief === true }
: {
svgSkriptLiefNicht: svgSkriptLief === false,
svgGezeichnet,
svgCsp: csp(kopf.svg), pngOhneSandbox: !csp(kopf.png), mp4OhneSandbox: !csp(kopf.mp4) && !csp(kopf.mp4Teil),
pngBild: pngBreite === 1,
videoSpielt: video?.da === true && video.breite > 0,
mp4Teil206: kopf.mp4Teil.status === 206,
}
console.log(JSON.stringify({ kopf, svgSkriptLief, svgGezeichnet, pngBreite, video, pruefungen }, null, 1))
const rot = Object.entries(pruefungen).filter(([, v]) => !v).map(([k]) => k)
console.log(rot.length ? `ROT: ${rot.join(', ')}` : `csp-pruefen: ${Object.keys(pruefungen).length}/${Object.keys(pruefungen).length} ok${erwarteLauf ? ' (Gegenprobe: Skript lief)' : ''}`)
process.exitCode = rot.length ? 1 : 0
} finally {
try { ws?.close() } catch { /* zu */ }
spawnSync('taskkill', ['/T', '/F', '/PID', String(edge.pid)], { stdio: 'ignore' })
await schlaf(800)
try {
const inhalt = readFileSync(join(profil, 'Local State'), 'utf8'); JSON.parse(inhalt)
mkdirSync(dirname(VORLAGE), { recursive: true }); writeFileSync(VORLAGE, inhalt)
} catch { /* keine brauchbare Datei */ }
for (let i = 0; i < 20; i++) { try { rmSync(profil, { recursive: true, force: true }); break } catch { await schlaf(500) } }
}
+156
View File
@@ -0,0 +1,156 @@
// Prueft die PRESIGNED URLS (d4rk-rp-planung#239) gegen einen WEGWERF-Container
// des fertigen Abbilds -- nicht gegen den laufenden Dienst.
//
// docker build -t d4rk-media:presign-test server
// node tools/presign-pruefen.mjs
//
// Wie privat-pruefen.mjs: kein Volume, ein Port nur auf 127.0.0.1, ein
// Zufallspasswort, das nirgends ausgegeben wird; am Ende wird der Container
// entfernt, auch wenn eine Pruefung scheitert.
//
// DIE KONTROLLEN ZUERST: der gewoehnliche Weg /api/v3/file MIT Token muss
// gehen. Erst dann heisst "mit Schluessel abgelehnt" etwas.
import { execFileSync } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
const ABBILD = 'd4rk-media:presign-test'
const NAME = 'd4rk-media-presign-test'
const PORT = 9197
const BASIS = `http://127.0.0.1:${PORT}`
// Ein echtes 1x1-PNG; die Bytes muessen unveraendert zurueckkommen.
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
const sha = (b) => createHash('sha256').update(b).digest('hex')
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
const wegraeumen = () => { try { docker('rm', '-f', NAME) } catch { /* war nicht da */ } }
const schlaf = (ms) => new Promise((r) => setTimeout(r, ms))
const ergebnisse = []
function pruefe(id, was, ok, ist) {
ergebnisse.push({ id, was, ok, ist })
console.log(`${ok ? 'OK ' : 'FAIL'} ${id.padEnd(4)} ${was} [${ist}]`)
}
/** multipart wie die NUI eines Handys: Feld "file", optional path. */
const formular = (bytes, typ, name = 'foto.png', pfad) => {
const f = new FormData()
f.append('file', new Blob([bytes], { type: typ }), name)
if (pfad) f.append('path', pfad)
return f
}
async function main() {
wegraeumen()
const kennwort = randomBytes(18).toString('base64url')
docker('run', '-d', '--rm', '--name', NAME, '-p', `127.0.0.1:${PORT}:8080`,
'-e', `PUBLIC_URL=${BASIS}`, '-e', 'ADMIN_USERNAME=admin', '-e', `ADMIN_PASSWORD=${kennwort}`, ABBILD)
let bereit = false
for (let i = 0; i < 60 && !bereit; i++) {
try { bereit = (await fetch(`${BASIS}/health`)).ok } catch { /* noch nicht */ }
if (!bereit) await schlaf(500)
}
if (!bereit) { console.log('UNGUELTIG: der Testcontainer antwortet nicht auf /health'); return 2 }
const login = await fetch(`${BASIS}/api/dash/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: kennwort }),
})
const keks = (login.headers.get('set-cookie') ?? '').split(';')[0]
if (!login.ok || !keks) { console.log(`UNGUELTIG: Anmeldung am Dashboard fehlgeschlagen (${login.status})`); return 2 }
const dash = (pfad, init = {}) =>
fetch(`${BASIS}/api/dash${pfad}`, { ...init, headers: { 'Content-Type': 'application/json', Cookie: keks, ...(init.headers ?? {}) } })
const anlegen = async (daten) => (await dash('/tokens', { method: 'POST', body: JSON.stringify(daten) })).json()
// Wie der Handy-Token gefesselt werden soll: Praefix, nur Bild, 1 MB.
const tHandy = await anlegen({ name: 'presign handy', prefix: 'phone', canDelete: false, arten: ['bild'], maxMb: 1 })
const tPrivat = await anlegen({ name: 'presign privat', prefix: 'fotos', canDelete: false, privat: true })
if (!tHandy.token || !tPrivat.token) { console.log('UNGUELTIG: Tokens liessen sich nicht anlegen'); return 2 }
const H = { Authorization: tHandy.token } // nackt, wie Fivemanage und d4rk_phone
const holen = async (kopf = H, query = '') => fetch(`${BASIS}/api/v3/file/presigned-url${query}`, { headers: kopf })
const schluessel = async (query = '') => (await (await holen(H, query)).json()).data?.presignedUrl
// --- Kontrolle -------------------------------------------------------------
let r = await fetch(`${BASIS}/api/v3/file`, { method: 'POST', headers: H, body: formular(PNG, 'image/png') })
pruefe('K1', 'Kontrolle: /api/v3/file MIT Token laedt hoch', r.status === 200, r.status)
if (r.status !== 200) { console.log('UNGUELTIG: der gewoehnliche Weg geht nicht - die Ablehnungen saehen nichts'); return 2 }
r = await fetch(`${BASIS}/api/v3/file`, { method: 'POST', body: formular(PNG, 'image/png') })
pruefe('K2', 'Kontrolle: /api/v3/file OHNE Token bleibt 401 (Wache greift noch)', r.status === 401, r.status)
// --- Ausstellen --------------------------------------------------------------
r = await holen({})
pruefe('S1', 'Schluessel holen ohne Token: 401', r.status === 401, r.status)
r = await holen()
const antwort = r.status === 200 ? await r.json() : null
const url = antwort?.data?.presignedUrl
pruefe('S2', 'Schluessel holen mit Token: Fivemanage-Form data.presignedUrl',
r.status === 200 && antwort?.status === 'ok' && typeof url === 'string' && url.startsWith(`${BASIS}/api/v3/file/presigned-url/`), `${r.status} ${url ? 'url da' : 'url fehlt'}`)
const bisS = antwort?.data?.expiresAt
const jetztS = Date.now() / 1000
pruefe('S3', 'ohne expiresAt: 15 Minuten', typeof bisS === 'number' && Math.abs(bisS - (jetztS + 900)) < 5, `${bisS ? Math.round(bisS - jetztS) : '?'} s`)
// --- Hochladen OHNE Token ------------------------------------------------------
r = await fetch(url, { method: 'POST', body: formular(PNG, 'image/png') })
const hoch = r.status === 200 ? await r.json() : null
pruefe('U1', 'Hochladen mit Schluessel, OHNE Token', r.status === 200 && hoch?.status === 'ok' && !!hoch?.data?.url, `${r.status}`)
const zurueck = hoch?.data?.url ? await fetch(hoch.data.url) : null
const bytes = zurueck?.ok ? Buffer.from(await zurueck.arrayBuffer()) : null
pruefe('U2', 'die Datei liegt unter phone/ und kommt unveraendert zurueck',
!!hoch?.data?.url && hoch.data.url.includes('/f/phone/') && bytes && sha(bytes) === sha(PNG), hoch?.data?.url?.replace(BASIS, '') ?? 'keine url')
// --- Einmal ----------------------------------------------------------------------
r = await fetch(url, { method: 'POST', body: formular(PNG, 'image/png') })
pruefe('E1', 'derselbe Schluessel ein zweites Mal: 401', r.status === 401, r.status)
r = await fetch(`${BASIS}/api/v3/file/presigned-url/${randomBytes(32).toString('base64url')}`, { method: 'POST', body: formular(PNG, 'image/png') })
pruefe('E2', 'ausgedachter Schluessel: 401', r.status === 401, r.status)
// --- Ablauf ------------------------------------------------------------------------
r = await holen(H, `?expiresAt=${Math.floor(Date.now() / 1000) - 10}`)
pruefe('A1', 'expiresAt in der Vergangenheit: 400', r.status === 400, r.status)
const lang = await (await holen(H, `?expiresAt=${Math.floor(Date.now() / 1000) + 86400}`)).json()
pruefe('A2', 'expiresAt in einem Tag: auf 15 Minuten gedeckelt', typeof lang?.data?.expiresAt === 'number' && lang.data.expiresAt - Date.now() / 1000 <= 905, `${Math.round((lang?.data?.expiresAt ?? 0) - Date.now() / 1000)} s`)
const kurz = await schluessel(`?expiresAt=${Math.floor(Date.now() / 1000) + 2}`)
await schlaf(3500)
r = await fetch(kurz, { method: 'POST', body: formular(PNG, 'image/png') })
pruefe('A3', 'abgelaufener Schluessel: 401', r.status === 401, r.status)
// --- Fesseln des ausstellenden Tokens ------------------------------------------------
let s = await schluessel()
r = await fetch(s, { method: 'POST', body: formular(Buffer.from('ID3'), 'audio/mpeg', 'ton.mp3') })
pruefe('F1', 'Art-Fessel: Ton mit Bild-Token abgelehnt (403)', r.status === 403, r.status)
r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png') })
pruefe('F2', '... und der Schluessel ist damit verbraucht (401)', r.status === 401, r.status)
s = await schluessel()
r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png', 'x.png', 'items/fremd.png') })
pruefe('F3', 'Praefix-Fessel: Pfad ausserhalb phone/ abgelehnt (403)', r.status === 403, r.status)
s = await schluessel()
r = await fetch(s, { method: 'POST', body: formular(Buffer.alloc(1_300_000, 1), 'image/png', 'gross.png') })
pruefe('F4', 'Groessen-Fessel: 1,3 MB bei 1 MB abgelehnt (413)', r.status === 413, r.status)
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>')
s = await schluessel()
r = await fetch(s, { method: 'POST', body: formular(svg, 'image/svg+xml', 'x.svg') })
const svgUrl = r.status === 200 ? (await r.json()).data?.url : null
const svgKopf = svgUrl ? (await fetch(svgUrl)).headers.get('content-security-policy') ?? '' : ''
pruefe('F5', 'ein SVG (zaehlt als Bild) kommt mit Sandbox zurueck (#254)', !svgUrl || svgKopf.includes('sandbox'), svgUrl ? (svgKopf || 'keine CSP') : `nicht angenommen (${r.status})`)
// --- Token gesperrt, Schluessel offen ------------------------------------------------
s = await schluessel()
const liste = await (await dash('/tokens')).json()
const id = liste.items?.find((t) => t.name === 'presign handy')?.id
const weg = id ? await dash(`/tokens/${id}`, { method: 'DELETE' }) : null
r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png') })
pruefe('T1', 'Token geloescht: sein offener Schluessel geht nicht mehr (401)', weg?.ok === true && r.status === 401, `loeschen=${weg?.status} hoch=${r.status}`)
// --- Privater Token ----------------------------------------------------------------
r = await holen({ Authorization: tPrivat.token })
pruefe('P1', 'privater Token bekommt keinen Schluessel (403)', r.status === 403, r.status)
const fehl = ergebnisse.filter((e) => !e.ok).length
console.log(`\n${ergebnisse.length - fehl}/${ergebnisse.length} bestanden`)
return fehl ? 1 : 0
}
let code = 1
try { code = await main() } catch (err) { console.log(`FEHLER: ${err instanceof Error ? err.message : err}`) } finally { wegraeumen() }
process.exit(code)
+185
View File
@@ -0,0 +1,185 @@
// Prueft den PRIVATEN Bereich (d4rk-rp-planung#227) gegen einen
// WEGWERF-Container des fertigen Abbilds -- nicht gegen den laufenden Dienst.
//
// docker build -t d4rk-media:h1-test server
// node tools/privat-pruefen.mjs <bild.webp>
//
// Das Bild ist ein beliebiges echtes WebP (fuer die Vorschau). Ohne Bild:
// UNGUELTIG, nicht "bestanden".
//
// DER CONTAINER hat kein Volume, einen freien Port nur auf 127.0.0.1 und ein
// Zufallspasswort, das nirgends ausgegeben wird. Er wird am Ende entfernt,
// auch wenn eine Pruefung scheitert.
//
// DIE KONTROLLEN ZUERST: ein oeffentliches Bild MUSS unter /f/ kommen. Erst
// dann ist "privat unter /f/ = 404" eine Aussage und kein kaputter Dienst.
import { execFileSync } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, existsSync } from 'node:fs'
const ABBILD = 'd4rk-media:h1-test'
const NAME = 'd4rk-media-h1-test'
const PORT = 9199
const BASIS = `http://127.0.0.1:${PORT}`
const bildPfad = process.argv[2]
if (!bildPfad || !existsSync(bildPfad)) {
console.log('UNGUELTIG: kein Testbild angegeben (node tools/privat-pruefen.mjs <bild.webp>)')
process.exit(2)
}
const bild = readFileSync(bildPfad)
const sha = (b) => createHash('sha256').update(b).digest('hex')
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
const wegraeumen = () => {
try { docker('rm', '-f', NAME) } catch { /* war nicht da */ }
}
const ergebnisse = []
function pruefe(id, was, ok, ist) {
ergebnisse.push({ id, was, ok, ist })
console.log(`${ok ? 'OK ' : 'FAIL'} ${id.padEnd(4)} ${was} [${ist}]`)
}
async function main() {
wegraeumen()
const kennwort = randomBytes(18).toString('base64url')
docker(
'run', '-d', '--rm', '--name', NAME,
'-p', `127.0.0.1:${PORT}:8080`,
'-e', `PUBLIC_URL=${BASIS}`,
'-e', 'ADMIN_USERNAME=admin',
'-e', `ADMIN_PASSWORD=${kennwort}`,
ABBILD,
)
// Warten, bis er antwortet -- hoechstens 30 s.
let bereit = false
for (let i = 0; i < 60 && !bereit; i++) {
try { bereit = (await fetch(`${BASIS}/health`)).ok } catch { /* noch nicht */ }
if (!bereit) await new Promise((r) => setTimeout(r, 500))
}
if (!bereit) {
console.log('UNGUELTIG: der Testcontainer antwortet nicht auf /health')
return 2
}
// Anmelden wie das Dashboard (Cookie aus der Antwort).
const login = await fetch(`${BASIS}/api/dash/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: kennwort }),
})
const keks = (login.headers.get('set-cookie') ?? '').split(';')[0]
if (!login.ok || !keks) {
console.log(`UNGUELTIG: Anmeldung am Dashboard fehlgeschlagen (${login.status})`)
return 2
}
const dash = (pfad, init = {}) =>
fetch(`${BASIS}/api/dash${pfad}`, { ...init, headers: { 'Content-Type': 'application/json', Cookie: keks, ...(init.headers ?? {}) } })
// Zwei Tokens ueber den ECHTEN Weg des Dashboards.
const anlegen = async (daten) => (await dash('/tokens', { method: 'POST', body: JSON.stringify(daten) })).json()
const tPrivat = await anlegen({ name: 'h1 privat', prefix: 'fotos', canDelete: false, privat: true })
const tOeff = await anlegen({ name: 'h1 oeffentlich', prefix: 'test', canDelete: false })
if (!tPrivat.token || !tOeff.token) {
console.log('UNGUELTIG: Tokens liessen sich nicht anlegen')
return 2
}
const P = { Authorization: `Bearer ${tPrivat.token}` }
const O = { Authorization: `Bearer ${tOeff.token}` }
// --- Kontrollen ----------------------------------------------------------
let r = await fetch(`${BASIS}/api/upload`, {
method: 'POST', headers: { ...O, 'X-Path': 'test/kontrolle.webp', 'X-Mime': 'image/webp' }, body: bild,
})
pruefe('K1', 'oeffentlicher Upload mit oeffentlichem Token', r.status === 200, r.status)
r = await fetch(`${BASIS}/f/test/kontrolle.webp`)
pruefe('K2', 'oeffentliches Bild unter /f/ (Kontrolle: /f/ funktioniert)', r.status === 200, r.status)
if (r.status !== 200) {
console.log('UNGUELTIG: /f/ liefert nicht einmal oeffentliche Bilder - die 404-Pruefungen saehen nichts')
return 2
}
// --- Ablegen und Lesen ---------------------------------------------------
const PFAD = 'fotos/police/2026-10/1.webp'
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, {
method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild,
})
const abgelegt = r.status === 200 ? await r.json() : null
pruefe('P1', 'privat ablegen', r.status === 200 && abgelegt?.sha256 === sha(bild), `${r.status} vorschau=${abgelegt?.vorschau}`)
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, { headers: P })
const gelesen = r.status === 200 ? Buffer.from(await r.arrayBuffer()) : null
pruefe('P2', 'privat lesen, Bytes gleich', r.status === 200 && gelesen && sha(gelesen) === sha(bild), r.status)
const cc = r.headers.get('cache-control') ?? ''
pruefe('P3', 'Cache-Control private, no-store', cc.includes('private') && cc.includes('no-store'), cc || 'fehlt')
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}?vorschau=1`, { headers: P })
pruefe('P4', 'Vorschau aus WebP', r.status === 200 && r.headers.get('content-type') === 'image/webp', `${r.status} ${r.headers.get('content-type')}`)
// --- Der Kern: nicht oeffentlich ------------------------------------------
r = await fetch(`${BASIS}/f/${PFAD}`)
pruefe('P5', 'privates Bild NICHT unter /f/', r.status === 404, r.status)
r = await fetch(`${BASIS}/t/${PFAD}`)
pruefe('P6', 'private Vorschau NICHT unter /t/', r.status === 404, r.status)
r = await fetch(`${BASIS}/api/liste?ordner=fotos`, { headers: O })
const liste = await r.text()
pruefe('P7', 'keine Liste zeigt das private Bild', !liste.includes('1.webp'), `${r.status}`)
// --- Wer darf was ---------------------------------------------------------
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`)
pruefe('P8', 'ohne Token: 401', r.status === 401, r.status)
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, { headers: O })
pruefe('P9', 'oeffentlicher Token liest nicht privat: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/privat/datei/test/x.webp`, { method: 'PUT', headers: { ...O, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P10', 'oeffentlicher Token legt nicht privat ab: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/upload`, { method: 'POST', headers: { ...P, 'X-Path': 'fotos/oeffentlich.webp', 'X-Mime': 'image/webp' }, body: bild })
pruefe('P11', 'privater Token laedt nicht oeffentlich hoch: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/image`, { method: 'POST', headers: { ...P, 'Content-Type': 'image/webp' }, body: bild })
pruefe('P12', 'privater Token auch nicht ueber Fivemanage: 403', r.status === 403, r.status)
// --- Schutz ---------------------------------------------------------------
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, { method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P13', 'kein Ueberschreiben: 409', r.status === 409, r.status)
r = await fetch(`${BASIS}/api/privat/datei/andere/x.webp`, { method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P14', 'Praefix-Fessel gilt: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/privat/datei/fotos/%2E%2E/%2E%2E/x.webp`, { method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P15', '.. im Pfad: abgelehnt', r.status === 400 || r.status === 404, r.status)
r = await fetch(`${BASIS}/api/privat/datei/fotos/gibtesnicht.webp`, { headers: P })
pruefe('P16', 'unbekannter Pfad: 404', r.status === 404, r.status)
// --- Dashboard zeigt die Fessel ------------------------------------------
const liste2 = await (await dash('/tokens')).json()
const zeile = liste2.items?.find((t) => t.name === 'h1 privat')
pruefe('P17', 'Dashboard-Liste: privat = 1', zeile?.privat === 1, String(zeile?.privat))
// --- Sicherung: dieselbe tar-Zeile wie tools/sichern.ps1 ----------------
const ps1 = readFileSync(new URL('./sichern.ps1', import.meta.url), 'utf8')
const roh = ps1.split('\n').find((z) => z.startsWith('$packen = '))
// PowerShell-Escapes zurueck in Shell: `" -> ", `$ -> $, $name -> Testname.
const packen = roh
.slice('$packen = "'.length, roh.trimEnd().length - 1)
.replaceAll('`"', '"').replaceAll('`$', '$')
.replaceAll('/out/$name', '/tmp/h1.tar.gz')
// Im Container liegt keine .sicherung - eine leere Aufnahme reicht fuer tar.
docker('exec', NAME, 'sh', '-c', 'mkdir -p /data/.sicherung && touch /data/.sicherung/media.db')
docker('exec', NAME, 'sh', '-c', packen)
const inhalt = docker('exec', NAME, 'sh', '-c', 'tar tzf /tmp/h1.tar.gz')
pruefe('B1', 'Sicherung enthaelt privat/', inhalt.split('\n').some((z) => z.startsWith(`privat/${PFAD}`)), inhalt.includes('privat/') ? 'privat/ da' : 'privat/ fehlt')
pruefe('B2', 'Kontrolle: Sicherung enthaelt files/', inhalt.split('\n').some((z) => z.startsWith('files/test/kontrolle.webp')), 'files/')
const fehl = ergebnisse.filter((e) => !e.ok).length
console.log(`\n${ergebnisse.length - fehl}/${ergebnisse.length} bestanden`)
return fehl ? 1 : 0
}
let code = 1
try {
code = await main()
} catch (err) {
console.log(`FEHLER: ${err instanceof Error ? err.message : err}`)
} finally {
wegraeumen()
}
process.exit(code)
+24 -2
View File
@@ -175,7 +175,17 @@ Schritt 'Bilder und Datenbank packen'
# Ordner gar nicht, und tar braeche mit "Cannot stat" ab. Eine Sicherung, die # Ordner gar nicht, und tar braeche mit "Cannot stat" ab. Eine Sicherung, die
# daran scheitert, dass nichts im Papierkorb liegt, waere absurd. Das Volume # daran scheitert, dass nichts im Papierkorb liegt, waere absurd. Das Volume
# haengt NUR LESEND -- ein `mkdir` als Ausweg ginge hier also ohnehin nicht. # haengt NUR LESEND -- ein `mkdir` als Ausweg ginge hier also ohnehin nicht.
$packen = "if [ -d /data/papierkorb ]; then tar czf '/out/$name' -C /data files -C /data/.sicherung media.db -C /data papierkorb; else tar czf '/out/$name' -C /data files -C /data/.sicherung media.db; fi" #
# DER PRIVATE BEREICH MUSS EBENSO MIT (d4rk-rp-planung#227): seine Eintraege
# stehen in media.db (privat_media), die Dateien unter /data/privat. Fehlte der
# Ordner im Archiv, saehe eine Wiederherstellung vollstaendig aus und haette
# jedes Behoerdenfoto verloren. Die Vorschauen (/data/privat-vorschau) bleiben
# draussen wie /data/thumbs.
#
# Beide Ordner sind wahlweise, deshalb die Liste `extra` und `${extra:+...}`:
# ist sie leer, steht hinter media.db gar nichts mehr. Die Dollarzeichen der
# Shell tragen einen Backtick, sonst setzt PowerShell sie selbst ein.
$packen = "extra=''; if [ -d /data/papierkorb ]; then extra=`"`$extra papierkorb`"; fi; if [ -d /data/privat ]; then extra=`"`$extra privat`"; fi; tar czf '/out/$name' -C /data files -C /data/.sicherung media.db `${extra:+-C /data `$extra}"
docker run --rm ` docker run --rm `
-v "${Volume}:/data:ro" ` -v "${Volume}:/data:ro" `
-v "${Ziel}:/out" ` -v "${Ziel}:/out" `
@@ -209,9 +219,12 @@ const db = require('better-sqlite3')('/tmp/p/media.db', { readonly: true, fileMu
const m = db.prepare('SELECT COUNT(*) AS n FROM media').get().n; const m = db.prepare('SELECT COUNT(*) AS n FROM media').get().n;
const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n; const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n;
const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
console.log(JSON.stringify({ media: m, tokens: t, users: u })); let p = -1;
try { p = db.prepare('SELECT COUNT(*) AS n FROM privat_media').get().n; } catch (e) {}
console.log(JSON.stringify({ media: m, tokens: t, users: u, privat: p }));
" "
find /tmp/p/files -type f | wc -l find /tmp/p/files -type f | wc -l
find /tmp/p/privat -type f 2>/dev/null | wc -l
"@) -replace "`r", '' "@) -replace "`r", ''
$pruefung = docker run --rm -v "${Ziel}:/in:ro" --entrypoint sh $Abbild -c $skript $pruefung = docker run --rm -v "${Ziel}:/in:ro" --entrypoint sh $Abbild -c $skript
@@ -220,8 +233,17 @@ if ($LASTEXITCODE -ne 0) { Schlecht "Pruefung fehlgeschlagen: $pruefung"; exit 1
$zeilen = @($pruefung -split "`n" | Where-Object { $_.Trim() }) $zeilen = @($pruefung -split "`n" | Where-Object { $_.Trim() })
$zahlen = $zeilen[0] | ConvertFrom-Json $zahlen = $zeilen[0] | ConvertFrom-Json
$dateien = [int]$zeilen[1].Trim() $dateien = [int]$zeilen[1].Trim()
$privatDateien = [int]$zeilen[2].Trim()
Gut "in der Sicherung: $($zahlen.media) Medieneintraege, $dateien Dateien, $($zahlen.tokens) Token, $($zahlen.users) Benutzer" Gut "in der Sicherung: $($zahlen.media) Medieneintraege, $dateien Dateien, $($zahlen.tokens) Token, $($zahlen.users) Benutzer"
# -1 heisst: die Tabelle gibt es in dieser Sicherung noch nicht (aelterer Stand).
if ($zahlen.privat -ge 0) {
Gut "privater Bereich: $($zahlen.privat) Eintraege, $privatDateien Dateien"
if ($privatDateien -ne $zahlen.privat) {
Schlecht "PASST NICHT: $privatDateien private Dateien zu $($zahlen.privat) Eintraegen - Behoerdenfotos fehlen in der Sicherung."
exit 1
}
}
if ($erwartet -ge 0 -and $zahlen.media -ne $erwartet) { if ($erwartet -ge 0 -and $zahlen.media -ne $erwartet) {
Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege." Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege."