diff --git a/docs/API.md b/docs/API.md index a0ec80d..71305cf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -39,19 +39,28 @@ und niemand merkte es, bis das Bild fehlt. ### Groß- und Kleinschreibung -Was über das Dashboard oder ein Werkzeug hochgeladen wird, kommt **klein** -an: `WEAPON_SMG.png` liegt als `weapon_smg.png`. Der Grund ist, dass ein Name -zu einer Adresse wird und Adressen sich nicht auf die Schreibweise eines -Dateisystems verlassen sollen. +**Die Schreibweise bleibt, und sie zählt.** `WEAPON_SMG.png` liegt als +`WEAPON_SMG.png`, und `/f/items/weapon_smg.png` ist dann ein 404. -Für ein Skript heißt das: **die Vorlage arbeitet mit Kleinbuchstaben.** +> **Berichtigt am 03.10.2026.** Hier stand bis dahin, alles komme **klein** +> an und eine Vorlage solle `item:lower()` rufen. Das gilt seit `a373b96` +> (22.08.2026) nicht mehr: ox_inventory fragt unter dem exakten Item-Namen, +> und dessen Waffen heißen `WEAPON_*`. Gemessen am 03.10.2026: +> `items/WEAPON_PISTOL.png` 200, `items/weapon_pistol.png` 404. Wer der alten +> Zeile folgte, baute Adressen, die ins Leere zeigen — und die +> Dashboard-Abfrage „Fehlende“ tat genau das und meldete jede Waffe als +> fehlend. + +Für ein Skript heißt das: **den Namen so einsetzen, wie er ist.** Klein +gemacht wird nur die Endung. ```lua -local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item:lower()) +local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item) ``` -Über `/api/upload` mit eigenem `X-Path` gilt das nicht — dort steht der Pfad -so, wie er geschickt wird. +Ersetzt werden beim Hochladen über das Fenster weiterhin Zeichen, die in einer +Adresse nichts verloren haben (Leerzeichen, Klammern, Umlaute). Über +`/api/upload` mit eigenem `X-Path` steht der Pfad so, wie er geschickt wird. --- @@ -385,6 +394,44 @@ Upload. --- +## POST /api/fehlende + +Dieselbe Frage wie im Dashboard unter **Fehlende**, aber mit Token: welche +dieser Namen haben **kein** Bild? Für „nur fehlende“ über einen ganzen +Katalog — die Kleidung des Fotostudios sind rund 8.000 Namen, das wären +8.000 Anfragen an `/api/exists` und hier zwei. + +```json +{ "muster": "clothing/{model}.png", "namen": ["m_c_11_base_84", "…"] } +``` + +`namen` darf auch ein Textblock sein (Zeilen, Kommas oder Semikolons). +Höchstens **5000 Namen** je Anfrage, mehr gibt einen 400 mit Text. + +```json +{ "muster": "clothing/{model}.png", "geprueft": 2, "da": 1, + "fehlt": [{ "name": "m_c_11_base_84", "pfad": "clothing/m_c_11_base_84.png" }], + "schreibweise": [], + "ueberzaehlig": ["…"], "ueberzaehligGesamt": 5337, + "unbrauchbar": [] } +``` + +- **Genau verglichen**, wie die öffentliche Adresse auch. Liegt etwas nur + unter einer anderen Schreibweise, steht es in `fehlt` **und** in + `schreibweise` (mit `liegtAls`) — es muss umbenannt werden, nicht neu + aufgenommen. +- `da` ist eine **Zahl**. Wer die einzelnen braucht: alle Namen ohne `fehlt` + und ohne `unbrauchbar`. +- **Keine Ordnerfessel**, wie bei `/api/exists`: die Antwort ist über `/f/` + ohnehin öffentlich. +- Endet nie auf eine Bildendung — die Proxy-Falle aus dem Abschnitt darüber + greift hier nicht. + +Die Rechnung steht **einmal**, in `server/src/fehlende.ts`; die Route des +Dashboards ruft dieselbe. + +--- + ## DELETE /api/media/<pfad> Braucht einen Token mit dem Recht **darf löschen**. diff --git a/server/src/fehlende.ts b/server/src/fehlende.ts new file mode 100644 index 0000000..9cc1739 --- /dev/null +++ b/server/src/fehlende.ts @@ -0,0 +1,142 @@ +/** Wozu fehlt ein Bild? -- die EINE Rechnung dafuer. + * + * Zwei Aufrufer: das Dashboard (`POST /api/dash/fehlende`, mit Sitzung) und + * das Fotostudio (`POST /api/fehlende`, mit Token). Bis zum 03.10.2026 gab es + * nur den ersten, und die Rechnung stand in dash.ts. Eine zweite Fassung + * daneben waere dieselbe Frage zweimal beantwortet -- und die zweite Antwort + * veraltet als erste. + * + * DIE SCHREIBWEISE ZAEHLT. Hier stand bis zum 03.10.2026, gesucht werde + * kleingeschrieben, "weil so abgelegt wird". Das stimmt seit a373b96 + * (22.08.2026, "Beim Hochladen bleibt die Schreibweise") nicht mehr. Gemessen + * am 03.10.2026 ueber /f/: + * + * items/WEAPON_PISTOL.png 200 + * items/weapon_pistol.png 404 + * + * Die Rechnung meldete also jede Waffe als fehlend, die da lag. Jetzt wird + * genau verglichen -- die oeffentliche Adresse unterscheidet ebenfalls, und + * was unter der erfragten Schreibweise nicht abrufbar ist, FEHLT fuer jeden, + * der genau diese Adresse baut. + * + * Liegt es unter einer ANDEREN Schreibweise, steht es zusaetzlich in + * `schreibweise` -- das ist kein Bild, das neu aufgenommen werden muss, + * sondern eines, das umbenannt werden will. Beides in einen Topf zu werfen + * hiesse, eine Viertelstunde Fotostudio fuer eine Sekunde Umbenennen. + */ +import { db } from './db.js' +import { PathError, checkPath } from './storage.js' + +export const FEHLENDE_HOECHSTENS = 5000 + +export type FehlendeAntwort = { + muster: string + geprueft: number + /** Wie viele genau so daliegen. Eine ZAHL, weil das Dashboard sie so + * liest -- wer einzelne braucht, rechnet: geprueft minus fehlt minus + * unbrauchbar. */ + da: number + fehlt: { name: string; pfad: string }[] + /** Fehlt unter der erfragten Schreibweise, liegt aber unter einer anderen. */ + schreibweise: { name: string; pfad: string; liegtAls: string }[] + ueberzaehlig: string[] + ueberzaehligGesamt: number + unbrauchbar: { name: string; grund: string }[] +} + +/** Wirft `FehlendeFehler` mit einem Satz fuer den Aufrufer -- beide Routen + * machen daraus einen 400 mit Text, keinen nackten 500. */ +export class FehlendeFehler extends Error {} + +export function fehlendeRechnen(musterRoh: unknown, namenRoh: unknown): FehlendeAntwort { + const muster = String(musterRoh ?? '').trim().replace(/^\/+/, '') + if (!muster) throw new FehlendeFehler('Kein Muster angegeben') + + const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? [] + if (platzhalter.length !== 1) { + throw new FehlendeFehler( + platzhalter.length === 0 + ? 'Im Muster fehlt der Platzhalter, z. B. vehicles/{model}.webp' + : `Genau ein Platzhalter, nicht ${platzhalter.length}. Sonst ist nicht klar, wofuer die Namen stehen.`, + ) + } + + // Die Namen kommen als Liste oder als Textblock aus einem Textfeld -- wer + // eine Spalte aus einer Tabelle kopiert, hat Zeilenumbrueche, wer aus einem + // Skript kommt, hat ein Array. + const roh = Array.isArray(namenRoh) ? namenRoh : String(namenRoh ?? '').split(/[\r\n,;]+/) + const namen = [...new Set(roh.map((n) => String(n).trim()).filter(Boolean))] + if (namen.length === 0) throw new FehlendeFehler('Keine Namen dabei') + if (namen.length > FEHLENDE_HOECHSTENS) { + throw new FehlendeFehler(`Hoechstens ${FEHLENDE_HOECHSTENS} Namen auf einmal`) + } + + const wo = platzhalter[0] as string + // Der Ordner ist der Teil vor dem Platzhalter -- daran erkennen wir, was + // "ueberzaehlig" ueberhaupt heisst. Ohne ihn waere jede Datei im Dienst + // ueberzaehlig, und die Antwort waere unbrauchbar. + const bisPlatzhalter = muster.slice(0, muster.indexOf(wo)) + const ordner = bisPlatzhalter.includes('/') + ? bisPlatzhalter.slice(0, bisPlatzhalter.lastIndexOf('/')) + : '' + + const genau = db.prepare('SELECT 1 FROM media WHERE path = ?') + const ohneSchreibweise = db.prepare( + 'SELECT path FROM media WHERE path = ? COLLATE NOCASE LIMIT 1', + ) + + const da: string[] = [] + const fehlt: FehlendeAntwort['fehlt'] = [] + const schreibweise: FehlendeAntwort['schreibweise'] = [] + const unbrauchbar: FehlendeAntwort['unbrauchbar'] = [] + + for (const name of namen) { + const pfad = muster.replace(wo, name) + let gueltig: string + try { + gueltig = checkPath(pfad) + } catch (err) { + unbrauchbar.push({ + name, + grund: err instanceof PathError ? err.message : 'unbrauchbar', + }) + continue + } + if (genau.get(gueltig)) { + da.push(gueltig) + continue + } + fehlt.push({ name, pfad: gueltig }) + const anders = ohneSchreibweise.get(gueltig) as { path: string } | undefined + if (anders) schreibweise.push({ name, pfad: gueltig, liegtAls: anders.path }) + } + + // Die Gegenrichtung: was liegt in diesem Ordner, ohne in der Liste zu + // stehen? Nur Dateien, die auf das Muster passen -- eine README im selben + // Ordner ist nicht ueberzaehlig, sie gehoert nur nicht dazu. + const endung = muster.slice(muster.indexOf(wo) + wo.length) + const gesucht = new Set(da) + const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`) + const imOrdner = ordner + ? (db + .prepare(`SELECT path FROM media WHERE path LIKE ? ESCAPE '\\'`) + .all(`${maskiert}/%`) as { path: string }[]) + : [] + const ueberzaehlig = imOrdner + .map((r) => r.path) + .filter((p) => p.endsWith(endung) && !gesucht.has(p)) + + return { + muster, + geprueft: namen.length, + da: da.length, + fehlt, + schreibweise, + // Gekappt, damit die Antwort nicht zur Datenhalde wird -- die Zahl + // steht daneben, damit die Kappung nicht als Vollstaendigkeit + // durchgeht. + ueberzaehlig: ueberzaehlig.slice(0, 500), + ueberzaehligGesamt: ueberzaehlig.length, + unbrauchbar, + } +} diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index f5c5085..3cf5ad5 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -10,6 +10,7 @@ import { unzipSync } from 'fflate' import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' +import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js' import { avatarUrl, db, @@ -2292,90 +2293,14 @@ dashRoutes.post('/media/ersetzen', async (c) => { */ dashRoutes.post('/fehlende', async (c) => { const body = await jsonBody<{ muster?: string; namen?: string[] | string }>(c) - if (!body?.muster?.trim()) return c.json({ error: 'Kein Muster angegeben' }, 400) - - const muster = body.muster.trim().replace(/^\/+/, '') - const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? [] - if (platzhalter.length !== 1) { - return c.json( - { - error: - platzhalter.length === 0 - ? 'Im Muster fehlt der Platzhalter, z. B. vehicles/{model}.webp' - : `Genau ein Platzhalter, nicht ${platzhalter.length}. Sonst ist nicht klar, wofuer die Namen stehen.`, - }, - 400, - ) + // Die Rechnung steht seit dem 03.10.2026 in fehlende.ts -- das Fotostudio + // stellt dieselbe Frage mit Token unter /api/fehlende. + try { + return c.json(fehlendeRechnen(body?.muster, body?.namen)) + } catch (err) { + if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400) + throw err } - - // Die Namen kommen als Liste oder als Textblock aus einem Textfeld -- wer - // eine Spalte aus einer Tabelle kopiert, hat Zeilenumbrueche, wer aus einem - // Skript kommt, hat ein Array. - const roh = Array.isArray(body.namen) - ? body.namen - : String(body.namen ?? '').split(/[\r\n,;]+/) - const namen = [...new Set(roh.map((n) => String(n).trim()).filter(Boolean))] - if (namen.length === 0) return c.json({ error: 'Keine Namen dabei' }, 400) - if (namen.length > 5000) return c.json({ error: 'Hoechstens 5000 Namen auf einmal' }, 400) - - const wo = platzhalter[0] as string - // Der Ordner ist der Teil vor dem Platzhalter -- daran erkennen wir, was - // "ueberzaehlig" ueberhaupt heisst. Ohne ihn waere jede Datei im Dienst - // ueberzaehlig, und die Antwort waere unbrauchbar. - const bisPlatzhalter = muster.slice(0, muster.indexOf(wo)) - const ordner = bisPlatzhalter.includes('/') - ? bisPlatzhalter.slice(0, bisPlatzhalter.lastIndexOf('/')) - : '' - - const vorhanden = db.prepare('SELECT 1 FROM media WHERE path = ?') - const fehlt: { name: string; pfad: string }[] = [] - const da: string[] = [] - const unbrauchbar: { name: string; grund: string }[] = [] - - for (const name of namen) { - // KLEINGESCHRIEBEN gesucht, weil so abgelegt wird. Wer "WEAPON_SMG" - // hereinreicht, meint weapon_smg.png -- und bekaeme sonst ein "fehlt", - // das gelogen waere. - const pfad = muster.replace(wo, name.toLowerCase()) - try { - const gueltig = checkPath(pfad) - if (vorhanden.get(gueltig)) da.push(gueltig) - else fehlt.push({ name, pfad: gueltig }) - } catch (err) { - unbrauchbar.push({ - name, - grund: err instanceof PathError ? err.message : 'unbrauchbar', - }) - } - } - - // Die Gegenrichtung: was liegt in diesem Ordner, ohne in der Liste zu - // stehen? Nur Dateien, die auf das Muster passen -- eine README im selben - // Ordner ist nicht ueberzaehlig, sie gehoert nur nicht dazu. - const endung = muster.slice(muster.indexOf(wo) + wo.length) - const gesucht = new Set(da) - const maskiert = ordner.replace(/[\\%_]/g, (z) => `\\${z}`) - const imOrdner = ordner - ? (db - .prepare(`SELECT path FROM media WHERE path LIKE ? ESCAPE '\\'`) - .all(`${maskiert}/%`) as { path: string }[]) - : [] - const ueberzaehlig = imOrdner - .map((r) => r.path) - .filter((pfad) => pfad.endsWith(endung) && !gesucht.has(pfad)) - - return c.json({ - muster, - geprueft: namen.length, - da: da.length, - fehlt, - // Gekappt, damit die Antwort nicht zur Datenhalde wird -- die Zahl - // steht daneben, damit die Kappung nicht als Vollstaendigkeit - // durchgeht. - ueberzaehlig: ueberzaehlig.slice(0, 500), - ueberzaehligGesamt: ueberzaehlig.length, - unbrauchbar, - }) }) dashRoutes.get('/export', (c) => { diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index b92b671..1d97e5d 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -22,6 +22,7 @@ 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' @@ -142,6 +143,7 @@ uploadRoutes.use('/media/*', requireToken) // eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen // soll. uploadRoutes.use('/zugang', requireToken) +uploadRoutes.use('/fehlende', requireToken) uploadRoutes.use('/exists', requireToken) uploadRoutes.use('/exists/*', requireToken) @@ -473,6 +475,35 @@ uploadRoutes.get('/zugang', (c) => { }) }) +/** 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/ + * (siehe unten) kann hier nicht zuschnappen. + */ +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)