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

+12 -1
View File
@@ -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,
+39
View File
@@ -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 = {
+6
View File
@@ -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)
+21 -5
View File
@@ -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'>
+5 -1
View File
@@ -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()
}
+193
View File
@@ -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',
})
})
+13 -2
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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)
+27
View File
@@ -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>