diff --git a/docs/API.md b/docs/API.md index 24f633f..1a81017 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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/ ablegen +GET /api/privat/datei/ lesen +GET /api/privat/datei/?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 ` 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 diff --git a/server/src/config.ts b/server/src/config.ts index e2541ae..f628f4f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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, diff --git a/server/src/db.ts b/server/src/db.ts index e92078b..b4d5756 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -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 = { diff --git a/server/src/index.ts b/server/src/index.ts index 98c0ebe..ea463c8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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/ laesst // sich vorlesen und abtippen. app.route('/api/s', freigabeRoutes) diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 3cf5ad5..3df3a77 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -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[] @@ -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 diff --git a/server/src/routes/fivemanage.ts b/server/src/routes/fivemanage.ts index 73ce667..ff34db6 100644 --- a/server/src/routes/fivemanage.ts +++ b/server/src/routes/fivemanage.ts @@ -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() } diff --git a/server/src/routes/privat.ts b/server/src/routes/privat.ts new file mode 100644 index 0000000..e282329 --- /dev/null +++ b/server/src/routes/privat.ts @@ -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/ ablegen (Rumpf roh, oder Base64 +// mit X-Encoding: base64), X-Mime +// GET /api/privat/datei/ lesen +// GET /api/privat/datei/?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', + }) +}) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index cb510ed..2c27c38 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -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') ?? '' diff --git a/server/src/storage.ts b/server/src/storage.ts index 5287dbc..cde8518 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -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 { - 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 { + await schreibenNach(privatPfad(path), data) +} + +async function schreibenNach(target: string, data: Buffer): Promise { 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 { - 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 { + 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 { try { // Der Import steht hier drin und nicht oben: sharp zieht eine native // Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 5336f6c..6c9e3d0 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -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(`/api/dash/tokens/${id}`, { method: 'PATCH', @@ -625,6 +629,7 @@ export const api = { quotaMb?: string arten?: string[] gueltigTage?: string + privat?: boolean }) => post('/api/dash/tokens', daten), deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`), } diff --git a/server/ui/src/components/TokenBearbeiten.tsx b/server/ui/src/components/TokenBearbeiten.tsx index ffa4ab4..bec58df 100644 --- a/server/ui/src/components/TokenBearbeiten.tsx +++ b/server/ui/src/components/TokenBearbeiten.tsx @@ -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({ + +
Erlaubte Arten (nichts angekreuzt = alle) diff --git a/server/ui/src/pages/Tokens.tsx b/server/ui/src/pages/Tokens.tsx index 701c38f..1dd304a 100644 --- a/server/ui/src/pages/Tokens.tsx +++ b/server/ui/src/pages/Tokens.tsx @@ -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() { + +
+ {t.privat ? ( + + privat + + ) : null} {t.can_delete ? ( schreiben + löschen @@ -411,6 +437,7 @@ export function Tokens() {

Präfix: {frisch.prefix || '—'}{' '} · Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'} + {frisch.privat ? ' · nur privater Bereich' : ''}

diff --git a/tools/privat-pruefen.mjs b/tools/privat-pruefen.mjs new file mode 100644 index 0000000..e101eae --- /dev/null +++ b/tools/privat-pruefen.mjs @@ -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 +// +// 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) diff --git a/tools/sichern.ps1 b/tools/sichern.ps1 index 9b8745f..e5ac401 100644 --- a/tools/sichern.ps1 +++ b/tools/sichern.ps1 @@ -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."