Files
d4rk_media/server/src/routes/upload.ts
T
D4rkst3randClaude Opus 5.5 6630def7b2 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>
2026-10-16 21:36:16 +02:00

573 lines
22 KiB
TypeScript

// Die Schnittstelle fuer Skripte: hochladen und wieder loeschen.
//
// Das ist der Teil, den der FiveM-Server anspricht — deshalb ist er stur
// einfach gehalten. Ein Aufruf, ein Token, eine Antwort mit der fertigen URL.
//
// POST /api/upload
// Authorization: Bearer d4rk_...
// X-Path: vehicles/adder.webp
// X-Encoding: base64
// <Rumpf>
//
// DREI FORMEN FUER DEN RUMPF, und der Grund fuer die dritte ist FiveM:
//
// multipart/form-data Feld "file" — so laedt das Dashboard hoch
// X-Encoding: base64 Base64-Text — so laedt Lua hoch
// sonst rohe Bytes
//
// PerformHttpRequest schickt einen Lua-String; rohe Bytes ueberstehen den Weg
// nicht zuverlaessig. Im Fotostudio kommt das Bild ohnehin als Data-URL aus
// dem NUI, ist also schon Base64 — der Umweg ist damit keiner.
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { config } from '../config.js'
import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader, tokenPrefixes } from '../auth.js'
import {
PathError,
checkPath,
deleteFile,
extensionFor,
fileExists,
kannBewegtVorschau,
vielleichtKleiner,
kannVorschau,
lohntWebp,
mimeFor,
publicUrlFor,
sha256,
writeFileAtomic,
writeBewegtThumb,
writeThumb,
writeWebp,
} from '../storage.js'
type Vars = { token: Token }
export const uploadRoutes = new Hono<{ Variables: Vars }>()
// Die Token-Pruefung haengt an den drei eigenen Pfaden und NICHT an '*'.
//
// Mit '*' galt sie fuer alles unterhalb des Einhaengepunkts — auch fuer
// /api/dash/*, das daneben liegt. Das Dashboard bekam dann "Token fehlt oder
// ist unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann.
const requireToken = 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)
}
// Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es:
// sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token.
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,
)
}
// 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
* KB" beantwortet die Frage, die man danach hat. Ein blosses "Kontingent
* erschoepft" beantwortet sie nicht.
*
* Ein ERSETZEN zaehlt hier grosszuegig als voller Neuzugang: der genaue Wert
* waere (neu - alt), aber dafuer muesste hier schon feststehen, unter welchem
* Pfad die Datei landet -- und das steht erst nach dem Lesen des Rumpfes fest.
* Die Ungenauigkeit geht zugunsten der Grenze, nicht darueber hinaus. */
export function kontingentUeberschritten(token: Token, kommt: number): string | null {
if (!token.quota_bytes) return null
const belegt = (
db
.prepare('SELECT COALESCE(SUM(size),0) AS b FROM media WHERE token_id = ?')
.get(token.id) as { b: number }
).b
if (belegt + kommt <= token.quota_bytes) return null
return (
`Kontingent erschoepft: dieser Token haelt ${groesse(belegt)} von ` +
`${groesse(token.quota_bytes)}, und diese Datei braucht ${groesse(kommt)}.`
)
}
/** Bytes lesbar -- mit MITWACHSENDER Einheit.
*
* Erst stand hier ein festes "MB", und die Meldung lautete daraufhin
* "haelt 0.0 MB von 0.0 MB, und diese Datei braucht 0.0 MB". Formal richtig,
* praktisch eine Meldung, die genau die Frage nicht beantwortet, wegen der man
* sie liest. Bei einem Kontingent von 20 KB muss dort KB stehen.
*
* 1024 und nicht 1000: dieselbe Rechnung wie in der Oberflaeche, sonst nennt
* der Dienst eine andere Zahl als das Dashboard daneben. */
function groesse(n: number): string {
if (n < 1024) return `${n} B`
const einheiten = ['KB', 'MB', 'GB', 'TB']
let wert = n / 1024
let i = 0
while (wert >= 1024 && i < einheiten.length - 1) {
wert /= 1024
i++
}
// Eine Nachkommastelle nur, wo sie etwas sagt: "1,5 GB" ja, "512,0 KB" nein.
return `${wert < 10 ? wert.toFixed(1) : Math.round(wert)} ${einheiten[i]}`
}
/** Welche Medienart ist das? Dieselben Namen wie im Dashboard.
*
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
* Galerie nicht auseinanderlaufen koennen. */
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'
if (m.startsWith('audio/')) return 'ton'
if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json'))
return 'dokument'
return 'andere'
}
uploadRoutes.use('/upload', requireToken)
uploadRoutes.use('/media/*', requireToken)
// BEIDE Formen hinter der Wache. Die Abfrageform steht als eigener Eintrag da:
// '/exists/*' trifft sie NICHT, weil dort kein Schraegstrich mehr folgt -- und
// eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen
// soll.
uploadRoutes.use('/zugang', requireToken)
uploadRoutes.use('/fehlende', requireToken)
uploadRoutes.use('/liste', requireToken)
uploadRoutes.use('/exists', requireToken)
uploadRoutes.use('/exists/*', requireToken)
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
export async function readBody(
c: Context<{ Variables: Vars }>,
): Promise<{ data: Buffer; path?: string; filename?: string }> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('multipart/form-data')) {
const form = await c.req.parseBody()
const file = form['file']
if (!(file instanceof File)) {
throw new Error('Feld "file" fehlt im Formular')
}
return {
data: Buffer.from(await file.arrayBuffer()),
path: typeof form['path'] === 'string' ? form['path'] : undefined,
filename: file.name,
}
}
const raw = Buffer.from(await c.req.arrayBuffer())
if (/base64/i.test(c.req.header('x-encoding') ?? '')) {
// Data-URLs kommen mit Vorspann ("data:image/webp;base64,"). Den
// abzuschneiden ist freundlicher, als den Aufrufer dazu zu zwingen.
const text = raw.toString('utf8').replace(/^data:[^,]*,/, '')
return { data: Buffer.from(text, 'base64') }
}
return { data: raw }
}
uploadRoutes.post('/upload', async (c) => {
const token = c.get('token')
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
// abgewiesen wird, BEVOR er im Speicher liegt.
// Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses
// Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB.
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
const announced = Number(c.req.header('content-length') ?? 0)
if (announced > grenze) {
return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413)
}
// DAS GESAMTKONTINGENT, geprueft BEVOR der Rumpf im Speicher liegt.
//
// max_bytes daneben gilt je Datei -- ein Token mit "hoechstens 2 MB je
// Bild" konnte die Platte trotzdem fuellen, es brauchte nur genug Bilder.
// Gezaehlt wird ueber media.token_id, also ueber das, was WIRKLICH liegt,
// und nicht ueber einen mitlaufenden Zaehler, der auseinanderlaufen kann.
const zuViel = kontingentUeberschritten(token, announced)
if (zuViel) return c.json({ error: zuViel }, 413)
let body: Awaited<ReturnType<typeof readBody>>
try {
body = await readBody(c)
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
}
const { data } = body
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)
}
// Noch einmal mit der WIRKLICHEN Groesse: content-length ist eine Ansage
// des Aufrufers, keine Tatsache.
const zuViel2 = kontingentUeberschritten(token, data.length)
if (zuViel2) return c.json({ error: zuViel2 }, 413)
const digest = sha256(data)
// Der Pfad: ausdruecklich gewuenscht, oder aus dem Hash gebaut. Der
// gewuenschte hat Vorrang und wird NICHT zurechtgebogen.
const wanted = c.req.header('x-path') ?? body.path ?? body.filename
let path: string
try {
path = wanted
? checkPath(wanted)
: checkPath(
`uploads/${digest.slice(0, 2)}/${digest}.${extensionFor(
c.req.header('x-mime') ?? 'application/octet-stream',
)}`,
)
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
if (!tokenAllows(token, path)) {
return c.json(
{ error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
403,
)
}
const existing = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
| Media
| undefined
// Ueberschreiben ist der Normalfall: ein neu aufgenommenes Fahrzeugbild
// soll das alte ersetzen, sonst braeuchte jeder zweite Lauf einen anderen
// Namen. Wer das nicht will, sagt es.
const overwrite = (c.req.header('x-overwrite') ?? 'true').toLowerCase() !== 'false'
if (existing && !overwrite) {
return c.json({ error: 'Pfad ist belegt', path, url: publicUrlFor(path) }, 409)
}
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(', ')} hochladen — das hier ist "${art}" (${mime}).`,
},
403,
)
}
}
// VOR dem Schreiben verkleinern, wenn der Token es verlangt. Danach waere
// es zu spaet: dann laege das grosse Bild schon auf der Platte, und der
// Hash im Datensatz gehoerte zu einer anderen Datei als der abgelegten.
let daten = data
let verkleinert: false | { von: string; auf: string } = false
if (token.max_kante) {
const r = await vielleichtKleiner(data, mime, token.max_kante)
daten = r.data
verkleinert = r.verkleinert
}
// DER HASH GEHOERT ZU DEM, WAS WIRKLICH LIEGT. Weiter oben wird er aus dem
// Original gerechnet -- der braucht ihn fuer den Ersatzpfad, wenn kein
// X-Path kam. Wird das Bild verkleinert, ist das eine ANDERE Datei, und ein
// Datensatz mit dem Hash des Originals waere schlicht falsch: der
// Verwaisten-Sucher und jede spaetere Gegenprobe verglichen dann gegen
// etwas, das nirgends liegt.
const digestAbgelegt = verkleinert ? sha256(daten) : digest
try {
await writeFileAtomic(path, daten)
} catch (err) {
console.error('[upload] Schreiben fehlgeschlagen:', err)
return c.json({ error: 'Schreiben fehlgeschlagen' }, 500)
}
// Die Vorschau gleich mit. Bewusst NACH dem eigentlichen Schreiben und mit
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
if (kannVorschau(mime)) await writeThumb(path, daten)
if (lohntWebp(mime)) await writeWebp(path, daten)
// Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und
// deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge faellt
// beim selben Blick in die Datei mit ab.
let dauer: number | null = null
if (kannBewegtVorschau(mime)) {
dauer = (await writeBewegtThumb(path, mime)).dauer
}
const timestamp = now()
if (existing) {
db.prepare(
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ?
WHERE id = ?`,
).run(daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, existing.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(path, daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, timestamp)
}
logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name)
meldeUpload(path, daten.length, token.name)
return c.json({
url: publicUrlFor(path),
path,
size: daten.length,
sha256: digestAbgelegt,
mime,
replaced: Boolean(existing),
// Nur dann, wenn wirklich gerechnet wurde -- ein Feld, das immer
// dasteht, sagt nichts.
...(verkleinert ? { verkleinert } : {}),
})
})
/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID.
*
* `:pfad{.+}` und nicht `*`: ein Sternchen wird von Hono nicht als Parameter
* erfasst, und `c.req.path` traegt den Einhaengepunkt mit — aus
* DELETE /api/media/vehicles/adder.png wurde damit der Pfad
* "api/media/vehicles/adder.png". Der Parameter kommt bereits dekodiert,
* ein zweites decodeURIComponent waere eine Dekodierung zu viel. */
uploadRoutes.delete('/media/:pfad{.+}', async (c) => {
const token = c.get('token')
if (!token.can_delete) {
return c.json({ error: 'dieser Token darf nicht loeschen' }, 403)
}
let path: string
try {
path = checkPath(c.req.param('pfad'))
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
if (!tokenAllows(token, path)) {
return c.json({ error: 'ausserhalb des erlaubten Bereichs' }, 403)
}
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
// Die Groesse VOR dem Loeschen holen: danach steht sie nirgends mehr, und
// ein Verlaufseintrag ohne Groesse beantwortet die Frage "wie viel Platz
// wurde da frei" nicht.
const zeile = db.prepare('SELECT size FROM media WHERE path = ?').get(path) as
| { size: number }
| undefined
await deleteFile(path)
db.prepare('DELETE FROM media WHERE path = ?').run(path)
logEvent('delete', path, zeile?.size ?? null, 'token', token.name)
return c.json({ deleted: path })
})
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
* beantworten kann, ohne 900 Bilder hochzuladen.
*
* ZWEI FORMEN, UND DIE ZWEITE IST DIE WICHTIGE:
*
* GET /api/exists/vehicles/adder.webp die alte, mit Pfad
* GET /api/exists?pfad=vehicles/adder.webp die neue, mit Abfrage
*
* Der Grund ist NACHGEMESSEN und unangenehm. Nginx Proxy Manager bringt eine
* `assets.conf` mit, deren Regel auf JEDE URL greift, die auf .webp, .png, .js
* ... endet:
*
* location ~* ^.*\.(css|js|jpe?g|gif|png|webp|...)$ {
* proxy_cache public-cache;
* proxy_ignore_headers Set-Cookie Cache-Control Expires ...;
* proxy_cache_valid any 30m;
* }
*
* Die ERSTE Form endet auf ".webp" und faellt damit hinein. Der Proxy legt die
* Antwort weg -- und liefert sie danach an JEDEN aus, auch ohne Token.
* Gemessen: 200 mit vollstaendiger Auskunft, Authorization-Kopf gar nicht
* gesetzt. Unser eigenes `Cache-Control` hilft nicht, `proxy_ignore_headers`
* wirft es weg.
*
* Ein `location ^~ /api/`-Block im Proxy schliesst das -- aber eine
* Zugangspruefung, die an einer Zeile in einer fremden Oberflaeche haengt, ist
* keine. Die ZWEITE Form endet nie auf eine Bildendung (die Abfrage zaehlt
* fuer die Regel nicht mit) und ist deshalb von sich aus dicht.
*
* Die alte Form bleibt, weil Skripte sie benutzen -- aber die Dokumentation
* nennt nur noch die neue. */
function existsAntwort(c: Context<{ Variables: Vars }>, roh: string) {
let path: string
try {
path = checkPath(roh)
} catch {
return c.json({ exists: false }, 200)
}
const row = db.prepare('SELECT size, sha256, updated_at FROM media WHERE path = ?').get(
path,
) as Pick<Media, 'size' | 'sha256' | 'updated_at'> | undefined
return c.json(
row
? { exists: true, path, url: publicUrlFor(path), ...row }
: { exists: false, path },
)
}
/** Was darf dieser Token eigentlich?
*
* WOZU. Das Fotostudio soll beim Einrichten eine AUSWAHL der Ordner zeigen
* statt eines Freitextfeldes -- und eine Auswahl, die etwas anbietet, das
* hinterher mit 403 abprallt, ist schlimmer als keine.
*
* Bisher erfuhr man die Fesseln nur, indem man dagegen lief: die
* 403-Meldung nennt sie. Das ist eine Auskunft ueber den eigenen Zugang, kein
* Geheimnis -- wer den Token hat, kennt seinen Umfang ohnehin, er muss ihn
* nur ausprobieren. Also lieber geradeheraus.
*
* Eine leere Liste heisst "ueberall", genau wie in der Datenbank. Das steht
* auch im Feld daneben, damit niemand "keine Ordner" liest.
*/
uploadRoutes.get('/zugang', (c) => {
const token = c.get('token') as Token
const fesseln = tokenPrefixes(token)
// WELCHE ORDNER ZUR AUSWAHL STEHEN.
//
// Ist der Token gefesselt, sind es genau seine -- eine Auswahl, die etwas
// anbietet, das hinterher mit 403 abprallt, waere schlimmer als keine.
//
// Ist er es NICHT, waere die Antwort "ueberall" fuer ein Auswahlfeld
// wertlos. Dann kommen die Ordner, die es wirklich gibt. Das ist keine
// Preisgabe: wer ueberall schreiben darf, kommt an jeden davon ohnehin --
// er muesste den Namen nur raten.
const vorhanden = (
db
.prepare(
`SELECT DISTINCT substr(path, 1, instr(path, '/') - 1) AS o
FROM media WHERE instr(path, '/') > 0 ORDER BY o`,
)
.all() as { o: string }[]
).map((z) => z.o)
return c.json({
// Woraus das Auswahlfeld waehlen darf.
ordner: fesseln.length > 0 ? fesseln : vorhanden,
// Und ob daneben noch etwas anderes ginge -- dann darf das Feld auch
// einen freien Namen zulassen.
ueberall: fesseln.length === 0,
darfLoeschen: token.can_delete === 1,
name: token.name,
})
})
/** Wozu fehlt ein Bild -- dieselbe Frage wie im Dashboard, mit Token.
*
* WOZU, ergaenzt am 03.10.2026. Das Fotostudio soll "fehlt" so beantworten,
* wie der Laden es sieht: liegt es HIER oder nicht. Mit /exists waeren das
* fuer die Kleidung rund 8.000 Anfragen; hier sind es zwei (hoechstens
* FEHLENDE_HOECHSTENS Namen je Anfrage).
*
* KEINE ORDNERFESSEL, genau wie bei /exists: was hier beantwortet wird, ist
* ueber /f/ ohnehin oeffentlich abrufbar. Der Token haelt nur Fremde davon ab,
* den Dienst mit Sammelabfragen zu beschaeftigen.
*
* Kein .webp/.png am Ende der Adresse -- die Proxy-Falle von /exists/<pfad>
* (siehe unten) kann hier nicht zuschnappen.
*/
/** Was liegt in einem Ordner -- die Liste, nicht die Frage nach Namen.
*
* WOZU, ergaenzt am 03.10.2026. /api/fehlende braucht die NAMEN, und die
* kennt beim Fotostudio nur der Spiel-Client: die Kleidungsnamen entstehen
* dort aus Natives, je Textur eines. Sie zum Server zu schicken hiesse rund
* 8.000 Namen ueber ein Netzereignis. Umgekehrt ist es eine Anfrage: der
* Dienst sagt, was liegt, und das Studio prueft jeden Namen selbst.
*
* SCHREIBWEISE GENAU, wie ueberall: `substr(...) = ?` und nicht LIKE --
* LIKE ist in SQLite fuer ASCII schreibweisenblind und naehme
* `Clothing/...` mit in `clothing/`.
*
* Mit Unterordnern (felgen/<radtyp>/<nummer> wird einer sein). Ohne
* Ordnerfessel, wie /exists: alles hier ist ueber /f/ ohnehin abrufbar.
*
* GEKAPPT bei LISTE_HOECHSTENS, und dann steht `gekappt: true` daneben --
* eine abgeschnittene Liste darf nicht als vollstaendige durchgehen, sonst
* meldet der Aufrufer alles dahinter als fehlend.
*/
const LISTE_HOECHSTENS = 100_000
uploadRoutes.get('/liste', (c) => {
const roh = (c.req.query('ordner') ?? '').trim().replace(/^\/+|\/+$/g, '')
if (!roh) return c.json({ error: 'Abfrage "ordner" fehlt, z. B. ?ordner=clothing' }, 400)
let ordner: string
try {
ordner = checkPath(roh)
} catch (err) {
return c.json({ error: err instanceof PathError ? err.message : 'Ordner unbrauchbar' }, 400)
}
const praefix = ordner + '/'
const gesamt = (
db
.prepare('SELECT count(*) AS n FROM media WHERE substr(path, 1, ?) = ?')
.get(praefix.length, praefix) as { n: number }
).n
const pfade = (
db
.prepare(
'SELECT path FROM media WHERE substr(path, 1, ?) = ? ORDER BY path LIMIT ?',
)
.all(praefix.length, praefix, LISTE_HOECHSTENS) as { path: string }[]
).map((z) => z.path)
return c.json({ ordner, gesamt, gekappt: pfade.length < gesamt, pfade })
})
uploadRoutes.post('/fehlende', async (c) => {
let body: { muster?: unknown; namen?: unknown } | null = null
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Kein gueltiges JSON' }, 400)
}
try {
return c.json(fehlendeRechnen(body?.muster, body?.namen))
} catch (err) {
if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400)
throw err
}
})
uploadRoutes.get('/exists', (c) => {
const roh = c.req.query('pfad') ?? c.req.query('path') ?? ''
if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400)
return existsAntwort(c, roh)
})
uploadRoutes.get('/exists/:pfad{.+}', (c) => existsAntwort(c, c.req.param('pfad')))