Fehlende mit Token, und die Rechnung vergleicht die Schreibweise genau

POST /api/fehlende stellt dieselbe Frage wie das Dashboard (welche Namen
haben kein Bild?), mit Token statt Sitzung. Das Fotostudio soll "fehlt" so
beantworten, wie der Laden es sieht -- fuer die Kleidung sind das rund 8.000
Namen, mit /exists ebenso viele Anfragen, hier zwei.

Die Rechnung steht jetzt EINMAL in server/src/fehlende.ts; die Dashboard-Route
ruft sie auf. Die Antwortform bleibt (da als Zahl), dazu kommt `schreibweise`.

DABEI GEFUNDEN: die alte Rechnung schrieb jeden Namen klein, "weil so
abgelegt wird". Seit a373b96 (22.08.) stimmt das nicht mehr. Gemessen am
03.10. ueber /f/: items/WEAPON_PISTOL.png 200, items/weapon_pistol.png 404 --
die Abfrage meldete also jede Waffe als fehlend, die da lag. Jetzt wird genau
verglichen; liegt etwas nur unter anderer Schreibweise, steht es zusaetzlich
in `schreibweise` (umbenennen statt neu aufnehmen). docs/API.md sagte
dasselbe Falsche und riet zu item:lower() -- berichtigt.

Geprueft in einem eigenen Container gegen eine Kopie der Datenbank (per
SQLite-Sicherung gezogen, danach geloescht), mit Wegwerf-Token: 13/13 --
401 ohne Token, 400 mit Text bei kaputtem JSON, fehlendem Platzhalter, ohne
Namen und bei 5001 Namen; WEAPON_PISTOL da, weapon_pistol fehlt und steht in
schreibweise, ein erfundener Name fehlt ohne schreibweise. Ernstfall: 7.928
lokale Kleidungsbilder des Fotostudios, 2.591 davon liegen im Dienst (33 %,
eine Stichprobe ueber /f/ hatte 11 von 40 ergeben).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
D4rkst3randClaude Opus 5.5 committed 2026-10-03 10:01:54 +02:00
1 parent 73c9f310c0
commit 6677b559f2
4 files changed
+236 -91

No files matched your search

+55 -8
View File
@@ -39,19 +39,28 @@ und niemand merkte es, bis das Bild fehlt.
### Groß- und Kleinschreibung ### Groß- und Kleinschreibung
Was über das Dashboard oder ein Werkzeug hochgeladen wird, kommt **klein** **Die Schreibweise bleibt, und sie zählt.** `WEAPON_SMG.png` liegt als
an: `WEAPON_SMG.png` liegt als `weapon_smg.png`. Der Grund ist, dass ein Name `WEAPON_SMG.png`, und `/f/items/weapon_smg.png` ist dann ein 404.
zu einer Adresse wird und Adressen sich nicht auf die Schreibweise eines
Dateisystems verlassen sollen.
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 ```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 Ersetzt werden beim Hochladen über das Fenster weiterhin Zeichen, die in einer
so, wie er geschickt wird. 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/&lt;pfad&gt; ## DELETE /api/media/&lt;pfad&gt;
Braucht einen Token mit dem Recht **darf löschen**. Braucht einen Token mit dem Recht **darf löschen**.
+142
View File
@@ -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,
}
}
+8 -83
View File
@@ -10,6 +10,7 @@ import { unzipSync } from 'fflate'
import type { Context } from 'hono' import type { Context } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { config } from '../config.js' import { config } from '../config.js'
import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js'
import { import {
avatarUrl, avatarUrl,
db, db,
@@ -2292,90 +2293,14 @@ dashRoutes.post('/media/ersetzen', async (c) => {
*/ */
dashRoutes.post('/fehlende', async (c) => { dashRoutes.post('/fehlende', async (c) => {
const body = await jsonBody<{ muster?: string; namen?: string[] | string }>(c) const body = await jsonBody<{ muster?: string; namen?: string[] | string }>(c)
if (!body?.muster?.trim()) return c.json({ error: 'Kein Muster angegeben' }, 400) // Die Rechnung steht seit dem 03.10.2026 in fehlende.ts -- das Fotostudio
// stellt dieselbe Frage mit Token unter /api/fehlende.
const muster = body.muster.trim().replace(/^\/+/, '') try {
const platzhalter = muster.match(/\{[a-zA-Z]+\}/g) ?? [] return c.json(fehlendeRechnen(body?.muster, body?.namen))
if (platzhalter.length !== 1) { } catch (err) {
return c.json( if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400)
{ throw err
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 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) => { dashRoutes.get('/export', (c) => {
+31
View File
@@ -22,6 +22,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import type { Context, Next } from 'hono' import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader, tokenPrefixes } from '../auth.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 // eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen
// soll. // soll.
uploadRoutes.use('/zugang', requireToken) uploadRoutes.use('/zugang', requireToken)
uploadRoutes.use('/fehlende', requireToken)
uploadRoutes.use('/exists', requireToken) uploadRoutes.use('/exists', 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/<pfad>
* (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) => { uploadRoutes.get('/exists', (c) => {
const roh = c.req.query('pfad') ?? c.req.query('path') ?? '' const roh = c.req.query('pfad') ?? c.req.query('path') ?? ''
if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400) if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400)