// 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 // // 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 )') 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)