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>
573 lines
22 KiB
TypeScript
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')))
|