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:
D4rkst3randClaude Opus 5.5 committed 2026-10-16 21:36:16 +02:00
1 parent c8080fb2eb
commit 6630def7b2
14 files changed
+641 -15

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
// Prueft den PRIVATEN Bereich (d4rk-rp-planung#227) gegen einen
// WEGWERF-Container des fertigen Abbilds -- nicht gegen den laufenden Dienst.
//
// docker build -t d4rk-media:h1-test server
// node tools/privat-pruefen.mjs <bild.webp>
//
// Das Bild ist ein beliebiges echtes WebP (fuer die Vorschau). Ohne Bild:
// UNGUELTIG, nicht "bestanden".
//
// DER CONTAINER hat kein Volume, einen freien Port nur auf 127.0.0.1 und ein
// Zufallspasswort, das nirgends ausgegeben wird. Er wird am Ende entfernt,
// auch wenn eine Pruefung scheitert.
//
// DIE KONTROLLEN ZUERST: ein oeffentliches Bild MUSS unter /f/ kommen. Erst
// dann ist "privat unter /f/ = 404" eine Aussage und kein kaputter Dienst.
import { execFileSync } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, existsSync } from 'node:fs'
const ABBILD = 'd4rk-media:h1-test'
const NAME = 'd4rk-media-h1-test'
const PORT = 9199
const BASIS = `http://127.0.0.1:${PORT}`
const bildPfad = process.argv[2]
if (!bildPfad || !existsSync(bildPfad)) {
console.log('UNGUELTIG: kein Testbild angegeben (node tools/privat-pruefen.mjs <bild.webp>)')
process.exit(2)
}
const bild = readFileSync(bildPfad)
const sha = (b) => createHash('sha256').update(b).digest('hex')
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
const wegraeumen = () => {
try { docker('rm', '-f', NAME) } catch { /* war nicht da */ }
}
const ergebnisse = []
function pruefe(id, was, ok, ist) {
ergebnisse.push({ id, was, ok, ist })
console.log(`${ok ? 'OK ' : 'FAIL'} ${id.padEnd(4)} ${was} [${ist}]`)
}
async function main() {
wegraeumen()
const kennwort = randomBytes(18).toString('base64url')
docker(
'run', '-d', '--rm', '--name', NAME,
'-p', `127.0.0.1:${PORT}:8080`,
'-e', `PUBLIC_URL=${BASIS}`,
'-e', 'ADMIN_USERNAME=admin',
'-e', `ADMIN_PASSWORD=${kennwort}`,
ABBILD,
)
// Warten, bis er antwortet -- hoechstens 30 s.
let bereit = false
for (let i = 0; i < 60 && !bereit; i++) {
try { bereit = (await fetch(`${BASIS}/health`)).ok } catch { /* noch nicht */ }
if (!bereit) await new Promise((r) => setTimeout(r, 500))
}
if (!bereit) {
console.log('UNGUELTIG: der Testcontainer antwortet nicht auf /health')
return 2
}
// Anmelden wie das Dashboard (Cookie aus der Antwort).
const login = await fetch(`${BASIS}/api/dash/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: kennwort }),
})
const keks = (login.headers.get('set-cookie') ?? '').split(';')[0]
if (!login.ok || !keks) {
console.log(`UNGUELTIG: Anmeldung am Dashboard fehlgeschlagen (${login.status})`)
return 2
}
const dash = (pfad, init = {}) =>
fetch(`${BASIS}/api/dash${pfad}`, { ...init, headers: { 'Content-Type': 'application/json', Cookie: keks, ...(init.headers ?? {}) } })
// Zwei Tokens ueber den ECHTEN Weg des Dashboards.
const anlegen = async (daten) => (await dash('/tokens', { method: 'POST', body: JSON.stringify(daten) })).json()
const tPrivat = await anlegen({ name: 'h1 privat', prefix: 'fotos', canDelete: false, privat: true })
const tOeff = await anlegen({ name: 'h1 oeffentlich', prefix: 'test', canDelete: false })
if (!tPrivat.token || !tOeff.token) {
console.log('UNGUELTIG: Tokens liessen sich nicht anlegen')
return 2
}
const P = { Authorization: `Bearer ${tPrivat.token}` }
const O = { Authorization: `Bearer ${tOeff.token}` }
// --- Kontrollen ----------------------------------------------------------
let r = await fetch(`${BASIS}/api/upload`, {
method: 'POST', headers: { ...O, 'X-Path': 'test/kontrolle.webp', 'X-Mime': 'image/webp' }, body: bild,
})
pruefe('K1', 'oeffentlicher Upload mit oeffentlichem Token', r.status === 200, r.status)
r = await fetch(`${BASIS}/f/test/kontrolle.webp`)
pruefe('K2', 'oeffentliches Bild unter /f/ (Kontrolle: /f/ funktioniert)', r.status === 200, r.status)
if (r.status !== 200) {
console.log('UNGUELTIG: /f/ liefert nicht einmal oeffentliche Bilder - die 404-Pruefungen saehen nichts')
return 2
}
// --- Ablegen und Lesen ---------------------------------------------------
const PFAD = 'fotos/police/2026-10/1.webp'
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, {
method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild,
})
const abgelegt = r.status === 200 ? await r.json() : null
pruefe('P1', 'privat ablegen', r.status === 200 && abgelegt?.sha256 === sha(bild), `${r.status} vorschau=${abgelegt?.vorschau}`)
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, { headers: P })
const gelesen = r.status === 200 ? Buffer.from(await r.arrayBuffer()) : null
pruefe('P2', 'privat lesen, Bytes gleich', r.status === 200 && gelesen && sha(gelesen) === sha(bild), r.status)
const cc = r.headers.get('cache-control') ?? ''
pruefe('P3', 'Cache-Control private, no-store', cc.includes('private') && cc.includes('no-store'), cc || 'fehlt')
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}?vorschau=1`, { headers: P })
pruefe('P4', 'Vorschau aus WebP', r.status === 200 && r.headers.get('content-type') === 'image/webp', `${r.status} ${r.headers.get('content-type')}`)
// --- Der Kern: nicht oeffentlich ------------------------------------------
r = await fetch(`${BASIS}/f/${PFAD}`)
pruefe('P5', 'privates Bild NICHT unter /f/', r.status === 404, r.status)
r = await fetch(`${BASIS}/t/${PFAD}`)
pruefe('P6', 'private Vorschau NICHT unter /t/', r.status === 404, r.status)
r = await fetch(`${BASIS}/api/liste?ordner=fotos`, { headers: O })
const liste = await r.text()
pruefe('P7', 'keine Liste zeigt das private Bild', !liste.includes('1.webp'), `${r.status}`)
// --- Wer darf was ---------------------------------------------------------
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`)
pruefe('P8', 'ohne Token: 401', r.status === 401, r.status)
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, { headers: O })
pruefe('P9', 'oeffentlicher Token liest nicht privat: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/privat/datei/test/x.webp`, { method: 'PUT', headers: { ...O, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P10', 'oeffentlicher Token legt nicht privat ab: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/upload`, { method: 'POST', headers: { ...P, 'X-Path': 'fotos/oeffentlich.webp', 'X-Mime': 'image/webp' }, body: bild })
pruefe('P11', 'privater Token laedt nicht oeffentlich hoch: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/image`, { method: 'POST', headers: { ...P, 'Content-Type': 'image/webp' }, body: bild })
pruefe('P12', 'privater Token auch nicht ueber Fivemanage: 403', r.status === 403, r.status)
// --- Schutz ---------------------------------------------------------------
r = await fetch(`${BASIS}/api/privat/datei/${PFAD}`, { method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P13', 'kein Ueberschreiben: 409', r.status === 409, r.status)
r = await fetch(`${BASIS}/api/privat/datei/andere/x.webp`, { method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P14', 'Praefix-Fessel gilt: 403', r.status === 403, r.status)
r = await fetch(`${BASIS}/api/privat/datei/fotos/%2E%2E/%2E%2E/x.webp`, { method: 'PUT', headers: { ...P, 'X-Mime': 'image/webp' }, body: bild })
pruefe('P15', '.. im Pfad: abgelehnt', r.status === 400 || r.status === 404, r.status)
r = await fetch(`${BASIS}/api/privat/datei/fotos/gibtesnicht.webp`, { headers: P })
pruefe('P16', 'unbekannter Pfad: 404', r.status === 404, r.status)
// --- Dashboard zeigt die Fessel ------------------------------------------
const liste2 = await (await dash('/tokens')).json()
const zeile = liste2.items?.find((t) => t.name === 'h1 privat')
pruefe('P17', 'Dashboard-Liste: privat = 1', zeile?.privat === 1, String(zeile?.privat))
// --- Sicherung: dieselbe tar-Zeile wie tools/sichern.ps1 ----------------
const ps1 = readFileSync(new URL('./sichern.ps1', import.meta.url), 'utf8')
const roh = ps1.split('\n').find((z) => z.startsWith('$packen = '))
// PowerShell-Escapes zurueck in Shell: `" -> ", `$ -> $, $name -> Testname.
const packen = roh
.slice('$packen = "'.length, roh.trimEnd().length - 1)
.replaceAll('`"', '"').replaceAll('`$', '$')
.replaceAll('/out/$name', '/tmp/h1.tar.gz')
// Im Container liegt keine .sicherung - eine leere Aufnahme reicht fuer tar.
docker('exec', NAME, 'sh', '-c', 'mkdir -p /data/.sicherung && touch /data/.sicherung/media.db')
docker('exec', NAME, 'sh', '-c', packen)
const inhalt = docker('exec', NAME, 'sh', '-c', 'tar tzf /tmp/h1.tar.gz')
pruefe('B1', 'Sicherung enthaelt privat/', inhalt.split('\n').some((z) => z.startsWith(`privat/${PFAD}`)), inhalt.includes('privat/') ? 'privat/ da' : 'privat/ fehlt')
pruefe('B2', 'Kontrolle: Sicherung enthaelt files/', inhalt.split('\n').some((z) => z.startsWith('files/test/kontrolle.webp')), 'files/')
const fehl = ergebnisse.filter((e) => !e.ok).length
console.log(`\n${ergebnisse.length - fehl}/${ergebnisse.length} bestanden`)
return fehl ? 1 : 0
}
let code = 1
try {
code = await main()
} catch (err) {
console.log(`FEHLER: ${err instanceof Error ? err.message : err}`)
} finally {
wegraeumen()
}
process.exit(code)