Compare commits
6
Commits
06285a3a53
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f077ac155b | ||
|
|
a4e918be8a | ||
|
|
6630def7b2 | ||
|
|
c8080fb2eb | ||
|
|
6677b559f2 | ||
|
|
73c9f310c0 |
No files matched your search
+161
-10
@@ -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=<ordner>
|
||||||
|
|
||||||
|
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/<pfad>
|
## DELETE /api/media/<pfad>
|
||||||
|
|
||||||
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
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+27
-86
@@ -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(/^\/+/, '')
|
|
||||||
const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? []
|
|
||||||
if (platzhalter.length !== 1) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
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 {
|
try {
|
||||||
const gueltig = checkPath(pfad)
|
return c.json(fehlendeRechnen(body?.muster, body?.namen))
|
||||||
if (vorhanden.get(gueltig)) da.push(gueltig)
|
|
||||||
else fehlt.push({ name, pfad: gueltig })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unbrauchbar.push({
|
if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400)
|
||||||
name,
|
throw err
|
||||||
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'>
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
})
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) } }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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."
|
||||||
|
|||||||
Reference in new issue
Block a user