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>
186 lines
9.6 KiB
JavaScript
186 lines
9.6 KiB
JavaScript
// 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)
|