Privater Bereich /api/privat fuer Behoerdenfotos (d4rk-rp-planung#227 H1)
Polizeifotos aus dem Spiel duerfen nicht unter /f/ liegen, wo jeder sie mit der Adresse bekommt. Neu: - eigener Ordner DATA_DIR/privat neben files/ (fuer /f/ und /t/ durch Bauart unerreichbar), eigene Tabelle privat_media, eigene Vorschauen - PUT/GET /api/privat/datei/<pfad>, ?vorschau=1; kein Ueberschreiben (409), kein Loeschweg; Cache-Control private, no-store - Token-Fessel "privat": ein privater Token kommt an keinen oeffentlichen Weg (upload, Fivemanage, Liste), ein oeffentlicher an keinen privaten - Dashboard: Kaestchen "nur privater Bereich" beim Anlegen und Bearbeiten, Marke in der Liste - checkPathUnter: dieselbe Pfadregel fuer beide Ablagen - tools/sichern.ps1 packt privat/ mit und zaehlt Dateien gegen Eintraege Geprueft mit tools/privat-pruefen.mjs gegen einen Wegwerf-Container des Abbilds: 21/21, mit Kontrollen (oeffentliches Bild MUSS unter /f/ kommen). Dashboard gemessen (kopfloser Edge): 7/7. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c8080fb2eb
commit
6630def7b2
14 files changed
+641
-15
No files matched your search
+57
@@ -463,6 +463,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
|
||||
|
||||
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. */
|
||||
const dataDir = resolve(optional('DATA_DIR', './data'))
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -68,6 +75,10 @@ export const config = {
|
||||
port: number('PORT', 8080),
|
||||
dataDir,
|
||||
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'),
|
||||
|
||||
publicUrl,
|
||||
|
||||
@@ -227,6 +227,33 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
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 }[]
|
||||
if (!eSpalten.some((s) => s.name === 'von')) {
|
||||
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
|
||||
@@ -352,6 +379,18 @@ export type Token = {
|
||||
arten: string
|
||||
/** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */
|
||||
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 = {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { fivemanageRoutes } from './routes/fivemanage.js'
|
||||
import { freigabeRoutes } from './routes/freigabe.js'
|
||||
import { statusRoutes } from './routes/status.js'
|
||||
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
||||
import { privatRoutes } from './routes/privat.js'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
@@ -232,6 +233,11 @@ app.route('/api', uploadRoutes)
|
||||
app.route('/api', fivemanageRoutes)
|
||||
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
|
||||
// sich vorlesen und abtippen.
|
||||
app.route('/api/s', freigabeRoutes)
|
||||
|
||||
@@ -2808,7 +2808,7 @@ dashRoutes.get('/tokens', (c) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`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`,
|
||||
)
|
||||
.all() as Omit<Token, 'hash'>[]
|
||||
@@ -2870,9 +2870,12 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
maxKante?: number | string
|
||||
arten?: string[]
|
||||
gueltigTage?: number | string
|
||||
privat?: boolean
|
||||
}>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
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)
|
||||
|
||||
@@ -2954,8 +2957,8 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at,
|
||||
max_bytes, quota_bytes, max_kante, arten, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
max_bytes, quota_bytes, max_kante, arten, expires_at, privat)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
name.trim(),
|
||||
@@ -2968,9 +2971,16 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
maxKante,
|
||||
arten,
|
||||
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 Datenbank, und auch wir koennen ihn nicht mehr zeigen.
|
||||
@@ -2982,6 +2992,7 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
maxBytes,
|
||||
arten,
|
||||
expiresAt,
|
||||
privat,
|
||||
token,
|
||||
})
|
||||
})
|
||||
@@ -3015,6 +3026,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
|
||||
maxKante?: number | string | null
|
||||
arten?: string[] | null
|
||||
gueltigTage?: number | string | null
|
||||
privat?: boolean
|
||||
}>(c)
|
||||
if (!body) return c.json(KEIN_JSON, 400)
|
||||
|
||||
@@ -3040,6 +3052,10 @@ dashRoutes.patch('/tokens/:id', async (c) => {
|
||||
}
|
||||
|
||||
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. ----------
|
||||
const zahl = (wert: unknown, was: string, deckel?: number) => {
|
||||
@@ -3108,7 +3124,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
|
||||
const neu = db
|
||||
.prepare(
|
||||
`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 = ?`,
|
||||
)
|
||||
.get(id) as Omit<Token, 'hash'>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { config } from '../config.js'
|
||||
import { meldeUpload } from '../meldung.js'
|
||||
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
||||
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
|
||||
import { kontingentUeberschritten } from './upload.js'
|
||||
import { PRIVAT_NUR_DORT, kontingentUeberschritten } from './upload.js'
|
||||
import {
|
||||
checkPath,
|
||||
kannBewegtVorschau,
|
||||
@@ -61,6 +61,10 @@ const wache = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
||||
if (token.expires_at && token.expires_at < now()) {
|
||||
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)
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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,
|
||||
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, und der Browser rät den Typ nicht.
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
})
|
||||
@@ -69,10 +69,21 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
||||
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)
|
||||
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.
|
||||
*
|
||||
* Die Meldung nennt Zahlen: "belegt 1,9 GB von 2 GB, diese Datei braucht 130
|
||||
@@ -126,7 +137,7 @@ function groesse(n: number): string {
|
||||
*
|
||||
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
|
||||
* Galerie nicht auseinanderlaufen koennen. */
|
||||
function artVon(mime: string): string {
|
||||
export function artVon(mime: string): string {
|
||||
const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||
if (m.startsWith('image/')) return 'bild'
|
||||
if (m.startsWith('video/')) return 'video'
|
||||
@@ -149,7 +160,7 @@ uploadRoutes.use('/exists', requireToken)
|
||||
uploadRoutes.use('/exists/*', requireToken)
|
||||
|
||||
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
|
||||
async function readBody(
|
||||
export async function readBody(
|
||||
c: Context<{ Variables: Vars }>,
|
||||
): Promise<{ data: Buffer; path?: string; filename?: string }> {
|
||||
const contentType = c.req.header('content-type') ?? ''
|
||||
|
||||
+36
-4
@@ -28,6 +28,14 @@ export class PathError extends Error {}
|
||||
|
||||
/** Prueft den Pfad und gibt ihn normalisiert zurueck. Wirft PathError. */
|
||||
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(/^\/+/, '')
|
||||
|
||||
if (!path) throw new PathError('Pfad ist leer')
|
||||
@@ -58,8 +66,8 @@ export function checkPath(raw: string): string {
|
||||
|
||||
// Guertel und Hosentraeger: selbst wenn oben je eine Luecke waere, muss
|
||||
// das Ergebnis unterhalb des Datenordners liegen.
|
||||
const absolute = resolve(config.filesDir, path)
|
||||
if (absolute !== config.filesDir && !absolute.startsWith(config.filesDir + sep)) {
|
||||
const absolute = resolve(basis, path)
|
||||
if (absolute !== basis && !absolute.startsWith(basis + sep)) {
|
||||
throw new PathError('Pfad zeigt aus dem Datenordner heraus')
|
||||
}
|
||||
|
||||
@@ -254,7 +262,19 @@ export async function vielleichtKleiner(
|
||||
* Bild durch ein halbes ersetzen — und halbe Bilder sehen im Handy aus wie
|
||||
* ein Fehler im Handy. */
|
||||
export async function writeFileAtomic(path: string, data: Buffer): Promise<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 })
|
||||
|
||||
const temp = `${target}.${process.pid}.${Date.now()}.tmp`
|
||||
@@ -295,7 +315,19 @@ export const kannVorschau = (mime: string) =>
|
||||
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
|
||||
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
|
||||
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 {
|
||||
// Der Import steht hier drin und nicht oben: sharp zieht eine native
|
||||
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
|
||||
|
||||
@@ -183,6 +183,8 @@ export type TokenRow = {
|
||||
/** Erlaubte Arten, mit Komma. Leer = alle. */
|
||||
arten: string
|
||||
expires_at: number | null
|
||||
/** 1 = nur privater Bereich (/api/privat), kein öffentlicher Upload. */
|
||||
privat: number
|
||||
abgelaufen: boolean
|
||||
/** Wieviel mit DIESEM Token abgelegt wurde. */
|
||||
dateien: number
|
||||
@@ -199,6 +201,7 @@ export type NewToken = {
|
||||
name: string
|
||||
prefix: string
|
||||
canDelete: boolean
|
||||
privat: boolean
|
||||
token: string
|
||||
}
|
||||
|
||||
@@ -584,6 +587,7 @@ export const api = {
|
||||
maxKante?: string | null
|
||||
arten?: string[] | null
|
||||
gueltigTage?: string | null
|
||||
privat?: boolean
|
||||
},
|
||||
) => request<TokenRow & { geaendert: string[] }>(`/api/dash/tokens/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -625,6 +629,7 @@ export const api = {
|
||||
quotaMb?: string
|
||||
arten?: string[]
|
||||
gueltigTage?: string
|
||||
privat?: boolean
|
||||
}) => post<NewToken>('/api/dash/tokens', daten),
|
||||
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export function TokenBearbeiten({
|
||||
const [name, setName] = useState(token.name)
|
||||
const [prefix, setPrefix] = useState(token.prefix)
|
||||
const [canDelete, setCanDelete] = useState(Boolean(token.can_delete))
|
||||
const [privat, setPrivat] = useState(Boolean(token.privat))
|
||||
const [maxMb, setMaxMb] = useState(
|
||||
token.max_bytes ? String(Math.round((token.max_bytes / 1048576) * 10) / 10) : '',
|
||||
)
|
||||
@@ -65,6 +66,7 @@ export function TokenBearbeiten({
|
||||
name: name.trim(),
|
||||
prefix,
|
||||
canDelete,
|
||||
privat,
|
||||
// Leer heißt ausdrücklich „keine Grenze" und nicht
|
||||
// „nicht angefasst" — sonst ließe sich eine einmal
|
||||
// gesetzte Grenze nie wieder loswerden.
|
||||
@@ -148,6 +150,22 @@ export function TokenBearbeiten({
|
||||
</span>
|
||||
</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>
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||
Erlaubte Arten (nichts angekreuzt = alle)
|
||||
|
||||
@@ -14,6 +14,7 @@ export function Tokens() {
|
||||
const [name, setName] = useState('')
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [darfLoeschen, setDarfLoeschen] = useState(false)
|
||||
const [privat, setPrivat] = useState(false)
|
||||
const [maxMb, setMaxMb] = useState('')
|
||||
const [quotaMb, setQuotaMb] = useState('')
|
||||
const [maxKante, setMaxKante] = useState('')
|
||||
@@ -60,6 +61,7 @@ export function Tokens() {
|
||||
maxKante: maxKante.trim(),
|
||||
arten,
|
||||
gueltigTage: gueltigTage.trim(),
|
||||
privat,
|
||||
}),
|
||||
(t) => `Token "${t.name}" angelegt.`,
|
||||
)
|
||||
@@ -69,6 +71,7 @@ export function Tokens() {
|
||||
setName('')
|
||||
setPrefix('')
|
||||
setDarfLoeschen(false)
|
||||
setPrivat(false)
|
||||
setMaxMb('')
|
||||
setGueltigTage('')
|
||||
setArten([])
|
||||
@@ -205,6 +208,24 @@ export function Tokens() {
|
||||
</span>
|
||||
</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>
|
||||
<Knopf
|
||||
art="haupt"
|
||||
@@ -274,6 +295,11 @@ export function Tokens() {
|
||||
: '— (überall)'}
|
||||
</td>
|
||||
<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 ? (
|
||||
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
|
||||
schreiben + löschen
|
||||
@@ -411,6 +437,7 @@ export function Tokens() {
|
||||
<p className="mt-3 text-xs text-schrift-leise">
|
||||
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
|
||||
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
|
||||
{frisch.privat ? ' · nur privater Bereich' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
# daran scheitert, dass nichts im Papierkorb liegt, waere absurd. Das Volume
|
||||
# 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 `
|
||||
-v "${Volume}:/data:ro" `
|
||||
-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 t = db.prepare('SELECT COUNT(*) AS n FROM tokens').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/privat -type f 2>/dev/null | wc -l
|
||||
"@) -replace "`r", ''
|
||||
|
||||
$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() })
|
||||
$zahlen = $zeilen[0] | ConvertFrom-Json
|
||||
$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"
|
||||
# -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) {
|
||||
Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege."
|
||||
|
||||
Reference in new issue
Block a user