Compare commits

...
8 Commits
Author SHA1 Message Date
D4rkst3randClaude Opus 5 06285a3a53 Ein Token darf jetzt mehrere Ordner -- statt einem oder allen
Das Fotostudio schreibt nach vehicles/ UND items/, der Token war aber auf
"vehicles" gefesselt. Damit blieb nur die Wahl zwischen "einer davon" und
"darf ueberall": das eine lehnt die Haelfte der Uploads mit 403 ab, das andere
erlaubt auch sicherungen/.

Gemessen und dabei aufgefallen: config.upload.lua setzt kein clothingPath,
also gilt die Vorgabe items/{model}.png -- und die durfte dieser Token nicht.
Kleidungs-Uploads aus dem Studio sind also stillschweigend an 403 gescheitert.

Die Spalte bleibt ein Textfeld, mehrere Werte stehen durch Komma darin. Damit
gilt JEDER bestehende Token unveraendert weiter: ein Wert ohne Komma ist eine
Liste mit einem Eintrag. Kein Migrationsschritt, kein Sonderfall.

Geprueft mit acht Faellen, darunter zwei, die scheitern MUESSEN: items/ unter
der Fessel "vehicles" (403) und "vehiclesXYZ/a.png" unter "vehicles" -- das
angehaengte / war schon vorher da und bleibt, sonst wuerde ein Praefix seinen
eigenen Namensvetter durchlassen.

Die 403-Meldung nennt jetzt alle erlaubten Ordner. Vorher haette sie bei
mehreren Fesseln "vehicles,items/" geschrieben -- einen Ordner, den es nicht
gibt. Wer abgewiesen wird, soll lesen koennen, wohin er darf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:57:17 +02:00
D4rkst3randClaude Opus 5 c9e5bb3c31 Verschieben hatte denselben Fehler -- und Ordner entfernen gab es nur im Dienst
VERSCHIEBEN: derselbe Griff wie beim Loeschen, dieselbe Wirkung.

    onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))}

`items` ist die offene Seite; von 3565 gewaehlten Bildern wanderten 60. Ich
hatte gestern nur das Loeschen repariert und diese Stelle nicht gesucht --
zwei Knoepfe mit demselben Muster, und ich habe einen davon uebersehen.

Jetzt haelt der Auftrag auch hier `ids` (alle) und `namen` (die benennbaren)
getrennt, und verschoben wird in Haeppchen zu 200 mit Fortschritt im Knopf.
Was nicht ging, wird benannt statt verschwiegen.

ORDNER ENTFERNEN: die Route gab es (`DELETE /folders/:pfad`), die
Schnittstelle auch (`ordnerEntfernen` in api.ts) -- nur wurde sie NIRGENDS
aufgerufen. Eine Funktion ohne Knopf ist fuer den, der sie sucht, nicht
vorhanden; dieselbe Lehre steht schon ueber "Neuer Ordner" in derselben Datei.

Der Knopf sitzt jetzt neben dem Umbenennen, erst beim Darueberfahren sichtbar.
Die REGEL bleibt beim Dienst: er lehnt einen Ordner mit Inhalt ab und sagt,
wieviele Dateien darin liegen. Sie in der Oberflaeche zu wiederholen hiesse,
sie zweimal zu pflegen -- und die zweite Fassung ist irgendwann die falsche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:44:38 +02:00
D4rkst3randClaude Opus 5 8a17b8b974 Loeschen loeschte nur die sichtbare Seite
Gemeldet: alle 3565 Bilder eines Ordners gewaehlt, auf "3565 loeschen"
gedrueckt -- weg waren 60. Sechzig ist die Seitengroesse, und darin steckt der
ganze Fehler:

    onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))}

`items` ist die OFFENE SEITE. Der Knopf zaehlte die Auswahl, geloescht wurde
die Schnittmenge mit dem gerade Sichtbaren. Die Absicht dahinter war gut --
der Dialog will die Pfade zeigen, und die kennt er nur fuer geladene Eintraege
-- aber das Ergebnis war eine Zahl, die etwas anderes versprach als sie tat.

Jetzt haelt der Auftrag beides getrennt: `ids` sind ALLE Gewaehlten, `namen`
nur die benennbaren. Der Dialog zeigt bis zu zwoelf Pfade und sagt "und N
weitere" -- ohne eine Vollstaendigkeit vorzutaeuschen, die er nicht hat.

IN HAEPPCHEN ZU 200. Der Dienst verschiebt jede Datei einzeln; 3565 in einer
Anfrage laufen minutenlang und fallen beim Proxy in die Zeitgrenze -- dann
waere die Haelfte weg und die Oberflaeche meldete einen Fehler. Nach jeder
Runde steht im Knopf, wie weit es ist: bei etwas, das eine Minute dauert, ist
Stillstand von Haengen nicht zu unterscheiden. Bricht eine Runde ab, wird
abgebrochen und die Zahl genannt, die schon durch ist.

UND DER WARNTEXT WAR FALSCH. Dort stand "Es gibt keine Sicherung, aus der sie
zurueckkommt" -- aus der Zeit vor dem Papierkorb. Nachgesehen: `inPapierkorb`
VERSCHIEBT die Datei und nimmt den Eintrag zurueck, wenn das Verschieben
scheitert; 30 Tage Aufbewahrung. Eine Warnung, die schlimmer klingt als die
Lage, haelt vom Aufraeumen ab und ist damit so schaedlich wie eine zu harmlose.
Der Knopf heisst jetzt "In den Papierkorb".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:55:26 +02:00
D4rkst3r a99e792194 gitignore: die Arbeitsliste 2026-08-22 23:48:50 +02:00
D4rkst3randClaude Opus 5 a7e9332bad Die Arbeitsliste gehoert nicht ins Repo
fehlende-item-bilder.txt war eine Momentaufnahme fuer den Betreiber und ist
seit der Umbenennung ohnehin ueberholt. Die Datei bleibt lokal liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:48:50 +02:00
D4rkst3randClaude Opus 5 a373b962d0 Beim Hochladen bleibt die Schreibweise -- sie war eine stille Verstuemmelung
Der Betreiber setzte in FiveM `inventory:imagepath` auf das Portal, und die
meisten Item-Icons blieben leer. Gemessen:

    WEAPON_COMBATPISTOL.png  -> 404
    weapon_combatpistol.png  -> 200

ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab; seine Waffen
heissen WEAPON_*. Der Dienst laeuft auf Linux, dort ist die Schreibweise ein
Unterschied. Zur Gegenprobe die mitgelieferten Bilder von ox_inventory selbst:
113 von 198 sind grossgeschrieben.

Die Ursache lag hier: `zaehmen()` machte beim Hochladen per Fenster jeden
Namen klein -- "vorhersagbar". Fuer die ZEICHEN war das richtig (Leerzeichen,
Klammern, Umlaute haben in einer Adresse nichts verloren), fuer die
SCHREIBWEISE war es nie noetig: checkPath erlaubt [A-Za-z0-9] seit jeher, und
in einer URL ist ein Grossbuchstabe kein Problem.

Also bleibt die Schreibweise jetzt stehen. Umlaute werden weiter ersetzt, nun
in beiden Formen -- sonst wuerde aus "Gruen.png" ein "gr-n.png", dieselbe
stille Verstuemmelung eine Ebene tiefer. Die ENDUNG bleibt klein: daran haengt
nur der Dateityp, und ".PNG" waere eine Adresse, die niemand so tippt.

Dazu 109 vorhandene Bilder umbenannt (ueber moveFile, damit Vorschau und WebP
mitwandern; jede Umbenennung steht im Ereignisprotokoll). Vorher: 0 Kollisionen
im Trockenlauf. Nachher gemessen: WEAPON_COMBATPISTOL 200, und 156 von 226
Items haben ein exakt passendes Bild statt 47.

EIGENER FEHLER, DER DAS VERZOEGERT HAT: mein erster Abgleich verglich beide
Seiten kleingeschrieben und hat damit genau den Unterschied wegnormalisiert,
auf den es ankam -- ich meldete 56 leere Felder statt 174. Eine Pruefung, die
das Falsche misst, ist schlimmer als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:48:36 +02:00
D4rkst3randClaude Opus 5 0f0097ba3c Die Freigabeseite zaehlte ihre eigene Kappung als Gesamtzahl
Gemeldet: die Uebersichtsseite zeigt 2000 Bilder, die Galerie 3685. Gemessen
am Ordner "items": es sind WIRKLICH 3685. Es fehlt keine Datei -- die Seite
zaehlte falsch.

Der Grund stand in zwei Zeilen nebeneinander:

    ... ORDER BY path COLLATE NOCASE LIMIT 2000
    anzahl: zeilen.length,

Die Zahl war also nicht die Anzahl der Dateien, sondern die Anzahl der
geholten Zeilen -- und damit machte die Seite aus ihrer eigenen Kappung eine
Aussage ueber den Bestand. Genau der Fall aus CLAUDE.md: eine Auskunft, die
nicht alles sehen kann, meldet nicht die halbe Zahl als ganze.

Jetzt: COUNT(*) getrennt fuer die wahre Zahl, die Grenze bleibt (20000, damit
eine Freigabe auf einen riesigen Ordner den Dienst nicht erschlaegt) und wird
als `gezeigt` mit ausgeliefert. Sind beide gleich, ist die Liste vollstaendig.

DABEI EINEN EIGENEN FEHLER GEMACHT UND ER IST LEHRREICH: die neue Abfrage bekam
EINEN Backslash im ESCAPE statt zwei, und SQLite antwortete mit "ESCAPE
expression must be a single character". Meine Gegenprobe mit grep suchte nach
derselben falschen Fassung und meldete deshalb "passt". Erst `cat -v` neben der
funktionierenden Zeile hat es gezeigt -- eine Pruefung, die das Falsche misst,
ist schlimmer als keine.

Gemessen nach dem Ausrollen: anzahl 3685, gezeigt 3685, items 3685.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:11:28 +02:00
D4rkst3randClaude Opus 5 ab7605a6d2 Der Waechter kennt jetzt die Verwaltungszugaenge von Spieleservern
Der Betreiber hat es angemahnt, waehrend nebenan am Spiele-Panel gearbeitet
wurde: "vergiss nicht die ports an den waechter weiter zu geben."

Nachgesehen und gemessen -- die offenen Ports des Panels standen bereits in
der Basis (25566, 26014), keine Abweichung. Dabei ist etwas anderes
aufgefallen.

AUF 10011 LAUSCHT ts3server.exe AUF ALLEN ADRESSEN, aus C:\Server\TS3, seit dem
09.07.2026. Und eine handangelegte Regel laesst herein:

    Regelname:   TS3_10011 (TCP)
    Richtung:    Eingehend
    Profile:     Domaene, Privat, Oeffentlich
    Remote-IP:   Beliebig
    Aktion:      Zulassen

Das ist kein Spielport. Ueber ServerQuery verwaltet man einen TeamSpeak-Server
komplett -- Rechte, Konten, Kennwoerter. Im Panel nebenan ist derselbe Port
deshalb ausdruecklich an 127.0.0.1 gebunden und bekommt KEINE Firewallregel.

DER WAECHTER SCHWIEG DAZU, weil 10011 beim Uebernehmen des Standes schon
dastand -- also genau der Fall, fuer den die HEIKEL-Liste gedacht ist: "Port
3000 war auch schon immer so."

Dazu drei weitere aus derselben Familie, bevor sie hier auftauchen: Minecraft
RCON (25575), Rust RCON (28016) und die Palworld-REST-API (8212, kann kicken
und bannen). Im Panel stehen alle drei auf `veroeffentlichen: 'lokal'`; wenn
einer von ihnen je auf allen Adressen lauscht, ist etwas an einem Server von
Hand eingerichtet worden.

Gemessen nach der Aenderung (-Still): der Waechter meldet 10011 jetzt neben
RPC, SMB, MySQL, RDP und WinRM. Was er weiterhin NICHT sieht, ist UDP -- und
die meisten Spielports sind UDP. Das steht als naechstes an.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:03:12 +02:00
13 changed files with 369 additions and 76 deletions
+1
View File
@@ -13,3 +13,4 @@ server/web/
# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo. # Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo.
data/ data/
.waechter-basis.json .waechter-basis.json
fehlende-item-bilder.txt
+1 -1
View File
@@ -1,4 +1,4 @@
# Arbeitsweise in diesem Projekt c# Arbeitsweise in diesem Projekt
## Prüfen statt raten ## Prüfen statt raten
+37 -3
View File
@@ -147,10 +147,44 @@ export function tokenFromHeader(header: string | undefined): Token | null {
} }
/** Darf dieser Token auf diesen Pfad schreiben? */ /** Darf dieser Token auf diesen Pfad schreiben? */
/**
* Die Fesseln eines Tokens als Liste.
*
* MEHRERE STATT EINER, seit dem 23.08.2026. Das Fotostudio schreibt nach
* `vehicles/` UND `items/`, und mit einer einzigen Fessel blieb nur die Wahl
* zwischen "einer davon" und "darf ueberall". Beides war falsch: das eine
* lehnt die Haelfte der Uploads mit 403 ab, das andere erlaubt auch
* `sicherungen/`.
*
* Die Spalte bleibt, was sie war -- ein Textfeld. Mehrere Werte stehen durch
* Komma getrennt darin. Damit gilt jeder BESTEHENDE Token unveraendert
* weiter: ein Wert ohne Komma ist eine Liste mit einem Eintrag.
*/
export function tokenPrefixes(token: Pick<Token, 'prefix'>): string[] {
return (token.prefix ?? '')
.split(',')
.map((p) => p.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
}
/** Die Fesseln als Satzstueck fuer eine Fehlermeldung.
*
* Aus der rohen Spalte wuerde sonst `"vehicles,items/"` -- ein Ordner, den es
* nicht gibt. Wer eine 403 bekommt, soll lesen koennen, wohin er DARF. */
export function fesselText(token: Pick<Token, 'prefix'>): string {
const f = tokenPrefixes(token)
if (f.length === 0) return 'ueberall'
if (f.length === 1) return `"${f[0]}/"`
return f.map((p) => `"${p}/"`).join(' oder ')
}
export function tokenAllows(token: Token, path: string): boolean { export function tokenAllows(token: Token, path: string): boolean {
if (!token.prefix) return true const fesseln = tokenPrefixes(token)
const prefix = token.prefix.replace(/\/+$/, '') + '/' // Keine Fessel heisst ueberall -- so war es, so bleibt es.
return path.startsWith(prefix) if (fesseln.length === 0) return true
// Das `/` gehoert dazu: ohne es wuerde "vehicles" auch "vehiclesXYZ/a.png"
// durchlassen. Stand schon vorher so da und bleibt.
return fesseln.some((p) => path.startsWith(p + '/'))
} }
// -------------------------------------------------------------------- Start // -------------------------------------------------------------------- Start
+24 -2
View File
@@ -2913,6 +2913,28 @@ dashRoutes.get('/tokens', (c) => {
}) })
}) })
/** Aus der Eingabe eine geprueste Fesselliste machen.
*
* Mehrere Ordner werden durch Komma getrennt. JEDER wird einzeln nach den
* Pfadregeln geprueft -- eine Liste, bei der ein Eintrag durchrutscht, ist
* keine Pruefung. Doppelte fallen weg, damit die Meldung spaeter nicht
* denselben Ordner zweimal nennt.
*
* Leer bleibt leer, und das heisst weiterhin "darf ueberall".
*/
function fesselnPruefen(roh: string): string {
const teile = roh
.split(',')
.map((t) => t.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
const raus: string[] = []
for (const t of teile) {
const sauber = checkPath(t)
if (!raus.includes(sauber)) raus.push(sauber)
}
return raus.join(',')
}
dashRoutes.post('/tokens', async (c) => { dashRoutes.post('/tokens', async (c) => {
const body = await jsonBody<{ const body = await jsonBody<{
name?: string name?: string
@@ -2996,7 +3018,7 @@ dashRoutes.post('/tokens', async (c) => {
let cleanPrefix = '' let cleanPrefix = ''
if (prefix?.trim()) { if (prefix?.trim()) {
try { try {
cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, '')) cleanPrefix = fesselnPruefen(prefix)
} catch (err) { } catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400) if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err throw err
@@ -3082,7 +3104,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '') const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '')
if (roh) { if (roh) {
try { try {
feld.prefix = checkPath(roh) feld.prefix = fesselnPruefen(roh)
} catch (err) { } catch (err) {
if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400) if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400)
throw err throw err
+2 -2
View File
@@ -30,7 +30,7 @@ import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.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 { tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js' import { kontingentUeberschritten } from './upload.js'
import { import {
checkPath, checkPath,
@@ -252,7 +252,7 @@ async function ablegen(
antwort: c.json( antwort: c.json(
{ {
status: 'error', status: 'error',
message: `dieser Token darf nur unter "${token.prefix}/" schreiben`, message: `dieser Token darf nur unter ${fesselText(token)} schreiben`,
}, },
403, 403,
), ),
+34 -3
View File
@@ -35,6 +35,13 @@ export function neuerSchluessel(): string {
return Array.from(roh, (b) => ZEICHEN[b % ZEICHEN.length]).join('') return Array.from(roh, (b) => ZEICHEN[b % ZEICHEN.length]).join('')
} }
/** Wieviele Dateien liegen unter diesem Ordner -- die WAHRE Zahl.
*
* Dieselbe Bedingung wie die Liste darunter, nur ohne Grenze. Zwei Abfragen
* statt einer, weil `zeilen.length` nach einem `LIMIT` eben nicht die Anzahl
* ist, sondern die Anzahl der geholten Zeilen. */
const GEZAEHLT = `SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`
/** Die Liste eines freigegebenen Ordners. /** Die Liste eines freigegebenen Ordners.
* *
* OHNE ANMELDUNG, und deshalb wird hier genau abgemessen, was hinausgeht: * OHNE ANMELDUNG, und deshalb wird hier genau abgemessen, was hinausgeht:
@@ -56,12 +63,32 @@ freigabeRoutes.get('/:schluessel', (c) => {
) )
const maskiert = eintrag.ordner.replace(/[\\%_]/g, (z) => `\\${z}`) const maskiert = eintrag.ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
// WIEVIELE ES WIRKLICH SIND -- getrennt gezaehlt, nicht aus der Liste
// abgeleitet.
//
// GEMESSEN am 22.08.2026: der Ordner "items" enthaelt 3685 Dateien, die
// Freigabeseite meldete 2000. Der Grund stand eine Zeile weiter unten --
// `LIMIT 2000` -- und `anzahl: zeilen.length` machte aus der Kappung eine
// Aussage: die Seite behauptete eine Vollstaendigkeit, die sie nicht hatte.
//
// Genau der Fall aus CLAUDE.md: eine Auskunft, die nicht alles sehen kann,
// meldet nicht die halbe Zahl als ganze, sondern sagt, dass sie kappt.
const gesamt = (
db
.prepare(GEZAEHLT)
.get(`${maskiert}/%`) as { n: number }
).n
// Die Grenze bleibt -- eine Freigabe auf einen Ordner mit 200000 Dateien
// soll den Dienst nicht in die Knie zwingen. Sie ist nur nicht mehr
// stillschweigend: was darueber liegt, wird unten benannt.
const GRENZE = 20000
const zeilen = db const zeilen = db
.prepare( .prepare(
`SELECT path, size, mime, dauer FROM media `SELECT path, size, mime, dauer FROM media
WHERE path LIKE ? ESCAPE '\\' ORDER BY path COLLATE NOCASE LIMIT 2000`, WHERE path LIKE ? ESCAPE '\\' ORDER BY path COLLATE NOCASE LIMIT ?`,
) )
.all(`${maskiert}/%`) as Pick<Media, 'path' | 'size' | 'mime' | 'dauer'>[] .all(`${maskiert}/%`, GRENZE) as Pick<Media, 'path' | 'size' | 'mime' | 'dauer'>[]
return c.json({ return c.json({
// Auch hier der Name aus derselben Einstellung -- die Freigabeseite ist // Auch hier der Name aus derselben Einstellung -- die Freigabeseite ist
@@ -72,7 +99,11 @@ freigabeRoutes.get('/:schluessel', (c) => {
// Der Praefix, den die Oberflaeche abschneidet -- damit dort // Der Praefix, den die Oberflaeche abschneidet -- damit dort
// "adder.webp" steht und nicht "vehicles/adder.webp". Wer den ganzen // "adder.webp" steht und nicht "vehicles/adder.webp". Wer den ganzen
// Pfad sehen will, sieht ihn in der Adresse. // Pfad sehen will, sieht ihn in der Adresse.
anzahl: zeilen.length, // Die WIRKLICHE Zahl, nicht die der ausgelieferten Zeilen.
anzahl: gesamt,
// Und wieviele davon hier stehen. Sind beide gleich, ist die Liste
// vollstaendig; sonst weiss die Oberflaeche, dass sie es sagen muss.
gezeigt: zeilen.length,
items: zeilen.map((r) => ({ items: zeilen.map((r) => ({
name: r.path.slice(eintrag.ordner.length + 1), name: r.path.slice(eintrag.ordner.length + 1),
size: r.size, size: r.size,
+2 -2
View File
@@ -24,7 +24,7 @@ import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.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 { tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { import {
PathError, PathError,
checkPath, checkPath,
@@ -236,7 +236,7 @@ uploadRoutes.post('/upload', async (c) => {
if (!tokenAllows(token, path)) { if (!tokenAllows(token, path)) {
return c.json( return c.json(
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` }, { error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
403, 403,
) )
} }
+31 -5
View File
@@ -135,21 +135,47 @@ export function safeFilename(name: string, eindeutig?: string): string {
const roh = punkt > 0 ? name.slice(0, punkt) : name const roh = punkt > 0 ? name.slice(0, punkt) : name
const endung = punkt > 0 ? name.slice(punkt + 1) : '' const endung = punkt > 0 ? name.slice(punkt + 1) : ''
// DIE SCHREIBWEISE BLEIBT, seit dem 22.08.2026.
//
// Hier stand `.toLowerCase()` mit der Begruendung "vorhersagbar". Das war
// fuer die Zeichen richtig und fuer die GROSSSCHREIBUNG falsch, und es hat
// etwas kaputtgemacht, das niemand mit dem Hochladen in Verbindung brachte:
//
// ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab. Seine
// Waffen heissen WEAPON_COMBATPISTOL. Der Ordner lag auf einem
// Linux-Wirt, dort ist Gross- und Kleinschreibung ein Unterschied --
// also 404, also leeres Feld im Inventar. Gemessen am 22.08.2026:
// 109 von 226 Items betroffen, und ox_inventory liefert seine eigenen
// Bilder genau deshalb GROSS aus (113 von 198).
//
// Der Grund fuer das Zaehmen bleibt richtig -- Leerzeichen, Klammern und
// Umlaute haben in einer Adresse nichts verloren. Nur die Schreibweise
// gehoerte nie dazu: `checkPath` erlaubt `[A-Za-z0-9]` seit jeher, und in
// einer URL ist ein Grossbuchstabe kein Problem.
//
// Umlaute werden weiterhin ersetzt, jetzt in beiden Schreibweisen -- sonst
// wuerde aus "Gruen.png" ein "gr-n.png", und das waere dieselbe stille
// Verstuemmelung eine Ebene tiefer.
const zaehmen = (s: string) => const zaehmen = (s: string) =>
s s
.toLowerCase()
// Umlaute NICHT einfach wegwerfen: "grün" wuerde sonst zu "grn".
.replace(/ä/g, 'ae') .replace(/ä/g, 'ae')
.replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe') .replace(/ö/g, 'oe')
.replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue') .replace(/ü/g, 'ue')
.replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss') .replace(/ß/g, 'ss')
.replace(/[^a-z0-9._-]+/g, '-') .replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-{2,}/g, '-') .replace(/-{2,}/g, '-')
.replace(/^[^a-z0-9]+/, '') .replace(/^[^A-Za-z0-9]+/, '')
.replace(/[-.]+$/, '') .replace(/[-.]+$/, '')
const stamm = zaehmen(roh) const stamm = zaehmen(roh)
const ende = zaehmen(endung) // DIE ENDUNG BLEIBT KLEIN. Am Stamm haengt die Bedeutung -- an der Endung
// haengt nur der Dateityp, und die schreibt alle Welt klein. Wer
// "Bild.PNG" hochlaedt, will kein zweites Format, sondern ein PNG; ein
// ".PNG" waere eine Adresse, die niemand so tippt.
const ende = zaehmen(endung).toLowerCase()
// Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das // Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das
// kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und // kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und
+42 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, X } from 'lucide-react' import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, Trash2, X } from 'lucide-react'
import { api, type Folders } from '../api' import { api, type Folders } from '../api'
import { bytes } from '../format' import { bytes } from '../format'
import { run } from '../store' import { run } from '../store'
@@ -22,6 +22,7 @@ export function Ordner({
onWahl, onWahl,
onAngelegt, onAngelegt,
onUmbenennen, onUmbenennen,
onEntfernt,
}: { }: {
daten: Folders | null daten: Folders | null
gewaehlt: string gewaehlt: string
@@ -29,12 +30,28 @@ export function Ordner({
/** Damit die Liste den neuen Ordner auch zeigt. */ /** Damit die Liste den neuen Ordner auch zeigt. */
onAngelegt: () => void onAngelegt: () => void
onUmbenennen: (pfad: string) => void onUmbenennen: (pfad: string) => void
/** Nach dem Entfernen muss die Liste neu geladen werden -- sonst steht
* dort ein Ordner, den es nicht mehr gibt. */
onEntfernt: () => void
}) { }) {
const [neu, setNeu] = useState<string | null>(null) const [neu, setNeu] = useState<string | null>(null)
const [legtAn, setLegtAn] = useState(false) const [legtAn, setLegtAn] = useState(false)
if (!daten) return null if (!daten) return null
async function entfernen(pfad: string) {
// Nachfragen, und dabei sagen, was NICHT passiert: der Ordner geht,
// die Dateien darin gehen nicht -- der Dienst laesst ihn ohnehin nur
// leer entfernen.
if (!confirm(`Ordner "${pfad}" entfernen?
Er muss leer sein; Dateien darin werden nicht angetastet.`)) {
return
}
const r = await run(() => api.ordnerEntfernen(pfad), `Ordner ${pfad} entfernt.`)
if (r) onEntfernt()
}
async function anlegen() { async function anlegen() {
const name = (neu ?? '').trim() const name = (neu ?? '').trim()
if (!name) return if (!name) return
@@ -96,6 +113,30 @@ export function Ordner({
</button> </button>
)} )}
{/* ORDNER ENTFERNEN. Den Weg gab es im Dienst und in `api.ts`
laengst -- es fehlte allein der Knopf, und damit war die
Funktion fuer den, der sie sucht, nicht vorhanden. Dieselbe
Lehre wie beim "Neuer Ordner" ein paar Zeilen weiter oben.
DIE PRUEFUNG BLEIBT BEIM DIENST: er lehnt einen Ordner mit
Inhalt ab und sagt, wieviele Dateien darin liegen. Diese Regel
hier zu wiederholen hiesse, sie zweimal zu pflegen -- und die
zweite Fassung ist irgendwann die falsche. */}
{pfad !== undefined && (
<button
onClick={(e) => {
e.stopPropagation()
void entfernen(pfad)
}}
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
group-hover:opacity-100 hover:text-schlecht focus-visible:opacity-100"
aria-label={`${pfad} entfernen`}
title="Ordner entfernen (muss leer sein)"
>
<Trash2 size={12} />
</button>
)}
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise"> <span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
{anzahl} {anzahl}
</span> </span>
+60 -20
View File
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { AlertTriangle, FolderInput, Plus } from 'lucide-react' import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
import { api, type Folders, type MediaItem } from '../api' import { api, type Folders, type MediaItem } from '../api'
import { run } from '../store' import { run, useStore } from '../store'
import { Feld, Knopf } from './Bausteine' import { Feld, Knopf } from './Bausteine'
/** Dateien in einen anderen Ordner legen. /** Dateien in einen anderen Ordner legen.
@@ -12,12 +12,17 @@ import { Feld, Knopf } from './Bausteine'
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer * Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */ * Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
export function Verschieben({ export function Verschieben({
dateien, ids,
namen,
ordner, ordner,
onFertig, onFertig,
onAbbruch, onAbbruch,
}: { }: {
dateien: MediaItem[] /** ALLE Gewaehlten. */
ids: number[]
/** Nur die, deren Pfad bekannt ist -- die der offenen Seite. Fuer die
* Anzeige, nicht fuer die Handlung. */
namen: MediaItem[]
ordner: Folders | null ordner: Folders | null
onFertig: () => void onFertig: () => void
onAbbruch: () => void onAbbruch: () => void
@@ -25,27 +30,56 @@ export function Verschieben({
const [ziel, setZiel] = useState('') const [ziel, setZiel] = useState('')
const [neuerOrdner, setNeuerOrdner] = useState('') const [neuerOrdner, setNeuerOrdner] = useState('')
const [laeuft, setLaeuft] = useState(false) const [laeuft, setLaeuft] = useState(false)
const [fortschritt, setFortschritt] = useState(0)
const beispiel = dateien[0] const beispiel = namen[0]
const name = beispiel?.path.split('/').pop() ?? '' const name = beispiel?.path.split('/').pop() ?? ''
const neuerPfad = ziel ? `${ziel}/${name}` : name const neuerPfad = ziel ? `${ziel}/${name}` : name
async function verschieben() { async function verschieben() {
setLaeuft(true) setLaeuft(true)
const r = await run( const { note } = useStore.getState()
() => api.verschieben(dateien.map((d) => d.id), ziel),
(x) => // IN HAEPPCHEN, wie beim Loeschen: der Dienst verschiebt jede Datei
x.fehlgeschlagen === 0 // einzeln, und tausende in einer Anfrage laufen in die Zeitgrenze des
? `${x.verschoben} verschoben.` // Proxys. 200 je Runde, und der Knopf sagt, wie weit es ist.
: `${x.verschoben} verschoben, ${x.fehlgeschlagen} nicht: ` + const PORTION = 200
x.ergebnisse let bewegt = 0
.filter((e) => !e.ok) let daneben: string[] = []
.slice(0, 3) let abbruch: string | null = null
.map((e) => `${e.path} (${e.error})`)
.join('; '), for (let i = 0; i < ids.length; i += PORTION) {
try {
const x = await api.verschieben(ids.slice(i, i + PORTION), ziel)
bewegt += x.verschoben
daneben.push(
...x.ergebnisse.filter((e) => !e.ok).map((e) => `${e.path} (${e.error})`),
) )
} catch (e) {
abbruch = e instanceof Error ? e.message : 'Unbekannt'
break
}
setFortschritt(bewegt)
}
setLaeuft(false) setLaeuft(false)
if (r) onFertig() if (abbruch) {
note('fehler', `${bewegt} von ${ids.length} verschoben, dann brach es ab: ${abbruch}`)
return
}
if (daneben.length === 0) {
note('ok', `${bewegt} verschoben.`)
} else {
// Was NICHT ging, wird benannt -- ein "verschoben" ueber einer
// halben Bewegung waere die Sorte Rueckmeldung, die hier nicht
// vorkommt.
note(
'hinweis',
`${bewegt} verschoben, ${daneben.length} nicht: ${daneben.slice(0, 3).join('; ')}` +
(daneben.length > 3 ? ` … und ${daneben.length - 3} weitere` : ''),
)
}
onFertig()
} }
async function ordnerAnlegen() { async function ordnerAnlegen() {
@@ -69,7 +103,7 @@ export function Verschieben({
> >
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold"> <h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
<FolderInput size={16} className="text-akzent" /> <FolderInput size={16} className="text-akzent" />
{dateien.length === 1 ? 'Datei verschieben' : `${dateien.length} Dateien verschieben`} {ids.length === 1 ? 'Datei verschieben' : `${ids.length} Dateien verschieben`}
</h3> </h3>
<div className="flex flex-col gap-3 px-4 py-4"> <div className="flex flex-col gap-3 px-4 py-4">
@@ -126,9 +160,9 @@ export function Verschieben({
<div className="rounded border border-rand bg-grund p-2 font-mono text-xs"> <div className="rounded border border-rand bg-grund p-2 font-mono text-xs">
<div className="text-schrift-leise line-through">{beispiel.path}</div> <div className="text-schrift-leise line-through">{beispiel.path}</div>
<div className="text-gut">{neuerPfad}</div> <div className="text-gut">{neuerPfad}</div>
{dateien.length > 1 && ( {ids.length > 1 && (
<div className="mt-1 text-schrift-leise"> <div className="mt-1 text-schrift-leise">
und {dateien.length - 1} weitere und {ids.length - 1} weitere
</div> </div>
)} )}
</div> </div>
@@ -141,7 +175,13 @@ export function Verschieben({
art="haupt" art="haupt"
wartet={laeuft} wartet={laeuft}
onClick={() => void verschieben()} onClick={() => void verschieben()}
kind="Verschieben" kind={
// Bei tausenden Dateien dauert es; Stillstand ist
// sonst von Haengen nicht zu unterscheiden.
laeuft && fortschritt > 0
? `${fortschritt} von ${ids.length}`
: 'Verschieben'
}
/> />
</div> </div>
</div> </div>
+107 -31
View File
@@ -60,12 +60,24 @@ export function Galerie() {
const [daten, setDaten] = useState<MediaPage | null>(null) const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true) const [laedt, setLaedt] = useState(true)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set()) const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [loeschen, setLoeschen] = useState<MediaItem[] | null>(null) /** Was geloescht werden soll.
*
* `ids` sind ALLE Gewaehlten, `namen` nur die, deren Pfad wir kennen --
* also die der offenen Seite. Vorher war das eine einzige Liste, und
* genau daran ist es gescheitert: der Knopf zaehlte die Auswahl, geloescht
* wurde die Schnittmenge mit der SICHTBAREN Seite. Bei 3565 gewaehlten
* Bildern verschwanden 60. */
const [loeschen, setLoeschen] = useState<{ ids: number[]; namen: MediaItem[] } | null>(null)
/** Wie weit das Loeschen ist -- bei tausenden Dateien dauert es. */
const [loeschFortschritt, setLoeschFortschritt] = useState(0)
const [loescht, setLoescht] = useState(false) const [loescht, setLoescht] = useState(false)
// Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt: // Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt:
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz. // nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
const [ansehen, setAnsehen] = useState<number | null>(null) const [ansehen, setAnsehen] = useState<number | null>(null)
const [verschieben, setVerschieben] = useState<MediaItem[] | null>(null) /** Dieselbe Trennung wie beim Loeschen -- und aus demselben Grund: hier
* stand `items.filter(...)`, also die offene Seite, und von 3565
* gewaehlten Bildern wanderten 60. */
const [verschieben, setVerschieben] = useState<{ ids: number[]; namen: MediaItem[] } | null>(null)
const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null) const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null)
const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null) const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
const { kopiert, kopieren } = useKopieren() const { kopiert, kopieren } = useKopieren()
@@ -197,23 +209,53 @@ export function Galerie() {
async function loeschenAusfuehren() { async function loeschenAusfuehren() {
if (!loeschen) return if (!loeschen) return
setLoescht(true) setLoescht(true)
setLoeschFortschritt(0)
const ergebnis = // IN HAEPPCHEN, nicht in einem Zug.
loeschen.length === 1 //
? await run( // Der Dienst verschiebt jede Datei einzeln in den Papierkorb. Bei 3565
() => api.deleteMedia(loeschen[0]!.id), // Bildern ist das eine Anfrage, die Minuten laeuft -- und die faellt
(r) => `${r.deleted} gelöscht.`, // beim Proxy in die Zeitgrenze, lange bevor sie fertig ist. Dann waere
) // die Haelfte geloescht und die Oberflaeche meldete einen Fehler.
: await run( //
() => api.deleteMediaBulk(loeschen.map((i) => i.id)), // 200 je Runde: gross genug, dass es nicht an der Anzahl der Anfragen
(r) => // haengt, klein genug fuer eine Antwort in Sekunden. Und nach jeder
r.count === loeschen.length // Runde steht im Knopf, wie weit es ist -- bei etwas, das eine Minute
? `${r.count} Dateien gelöscht.` // dauert, ist Stillstand nicht von Haengen zu unterscheiden.
: // Der Dienst überspringt, was er nicht findet. const { note } = useStore.getState()
// Diese Lücke zu verschweigen wäre genau die const PORTION = 200
// Sorte Rückmeldung, die hier nicht vorkommt. const alle = loeschen.ids
`${r.count} von ${loeschen.length} gelöscht — der Rest war schon weg.`, let geloescht = 0
) let abbruch: string | null = null
for (let i = 0; i < alle.length; i += PORTION) {
const teil = alle.slice(i, i + PORTION)
try {
const r = teil.length === 1
? await api.deleteMedia(teil[0]!).then(() => ({ count: 1 }))
: await api.deleteMediaBulk(teil)
geloescht += r.count
} catch (e) {
// NICHT WEITERMACHEN. Wenn eine Runde scheitert, scheitern die
// naechsten vermutlich auch, und tausend Fehlermeldungen sagen
// weniger als eine mit der Zahl, die schon durch ist.
abbruch = e instanceof Error ? e.message : 'Unbekannt'
break
}
setLoeschFortschritt(geloescht)
}
if (abbruch) {
note('fehler', `${geloescht} von ${alle.length} gelöscht, dann brach es ab: ${abbruch}`)
} else if (geloescht === alle.length) {
note('ok', `${geloescht} ${geloescht === 1 ? 'Datei' : 'Dateien'} in den Papierkorb verschoben.`)
} else {
// Der Dienst ueberspringt, was er nicht findet. Diese Luecke zu
// verschweigen waere genau die Sorte Rueckmeldung, die hier nicht
// vorkommt.
note('hinweis', `${geloescht} von ${alle.length} gelöscht — der Rest war schon weg.`)
}
const ergebnis = !abbruch
setLoescht(false) setLoescht(false)
setLoeschen(null) setLoeschen(null)
@@ -243,6 +285,7 @@ export function Galerie() {
daten={ordnerDaten} daten={ordnerDaten}
gewaehlt={ordner} gewaehlt={ordner}
onAngelegt={() => void ordnerLaden()} onAngelegt={() => void ordnerLaden()}
onEntfernt={() => void ordnerLaden()}
onUmbenennen={setOrdnerUmbenennen} onUmbenennen={setOrdnerUmbenennen}
onWahl={(o) => { onWahl={(o) => {
setOrdner(o) setOrdner(o)
@@ -428,7 +471,12 @@ export function Galerie() {
{gewaehlt.size > 0 && ( {gewaehlt.size > 0 && (
<Knopf <Knopf
onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))} onClick={() =>
setVerschieben({
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={ kind={
<> <>
<FolderInput size={15} /> <FolderInput size={15} />
@@ -441,11 +489,23 @@ export function Galerie() {
{gewaehlt.size > 0 && ( {gewaehlt.size > 0 && (
<Knopf <Knopf
art="gefahr" art="gefahr"
onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))} onClick={() =>
setLoeschen({
// ALLE Gewaehlten, nicht nur die sichtbaren.
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={ kind={
<> <>
<Trash2 size={15} /> <Trash2 size={15} />
{gewaehlt.size} löschen {/* WAEHREND ES LAEUFT die Zahl, die schon
durch ist. Bei tausenden Dateien dauert es
eine Minute, und Stillstand ist von Haengen
nicht zu unterscheiden. */}
{loescht && loeschFortschritt > 0
? `${loeschFortschritt} von ${gewaehlt.size}`
: `${gewaehlt.size} löschen`}
</> </>
} }
/> />
@@ -611,7 +671,7 @@ export function Galerie() {
<ExternalLink size={11} /> <ExternalLink size={11} />
</a> </a>
<button <button
onClick={() => setLoeschen([item])} onClick={() => setLoeschen({ ids: [item.id], namen: [item] })}
className="rounded border border-rand px-2 py-1 text-schrift-leise className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht" hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`${item.path} löschen`} aria-label={`${item.path} löschen`}
@@ -666,7 +726,7 @@ export function Galerie() {
onSchliessen={() => setAnsehen(null)} onSchliessen={() => setAnsehen(null)}
onLoeschen={() => { onLoeschen={() => {
setAnsehen(null) setAnsehen(null)
setLoeschen([item]) setLoeschen({ ids: [item.id], namen: [item] })
}} }}
onUmbenennen={() => { onUmbenennen={() => {
setAnsehen(null) setAnsehen(null)
@@ -709,7 +769,8 @@ export function Galerie() {
{verschieben && ( {verschieben && (
<Verschieben <Verschieben
dateien={verschieben} ids={verschieben.ids}
namen={verschieben.namen}
ordner={ordnerDaten} ordner={ordnerDaten}
onAbbruch={() => setVerschieben(null)} onAbbruch={() => setVerschieben(null)}
onFertig={() => { onFertig={() => {
@@ -724,22 +785,37 @@ export function Galerie() {
<Nachfrage <Nachfrage
offen={loeschen !== null} offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`} titel={
bestaetigung="Endgültig löschen" loeschen?.ids.length === 1
? 'Datei löschen?'
: `${loeschen?.ids.length ?? 0} Dateien löschen?`
}
bestaetigung="In den Papierkorb"
wartet={loescht} wartet={loescht}
onNein={() => setLoeschen(null)} onNein={() => setLoeschen(null)}
onJa={() => void loeschenAusfuehren()} onJa={() => void loeschenAusfuehren()}
text={ text={
<> <>
{/* DER SATZ HIER WAR FALSCH und stand aus der Zeit
vor dem Papierkorb: "Es gibt keine Sicherung, aus
der sie zurueckkommt." Es gibt eine -- die Datei
wird verschoben, nicht geloescht, und liegt 30 Tage
im Papierkorb. Eine Warnung, die schlimmer klingt
als die Lage, haelt vom Aufraeumen ab und ist damit
genauso schaedlich wie eine, die zu harmlos klingt. */}
<p className="mb-2"> <p className="mb-2">
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus Verschiebt in den <b>Papierkorb</b> dort liegt alles noch 30 Tage und
der sie zurückkommt. lässt sich zurückholen. Erst danach ist es endgültig weg.
</p> </p>
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs"> <ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
{loeschen?.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)} {loeschen?.namen.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
{loeschen && loeschen.length > 12 && ( {/* Benennen laesst sich nur, was auf der offenen
Seite liegt -- der Rest ist gewaehlt, aber
nicht geladen. Das steht hier so, statt eine
Vollstaendigkeit vorzutaeuschen. */}
{loeschen && loeschen.ids.length > loeschen.namen.slice(0, 12).length && (
<li className="text-schrift-leise"> <li className="text-schrift-leise">
und {loeschen.length - 12} weitere und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
</li> </li>
)} )}
</ul> </ul>
+10 -5
View File
@@ -98,10 +98,10 @@ export function Tokens() {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<Feld <Feld
label="Präfix (optional)" label="Ordner (optional)"
value={prefix} value={prefix}
placeholder="vehicles" placeholder="vehicles, items"
hinweis="Der Token darf dann nur darunter schreiben." hinweis="Nur unter diesen Ordnern darf der Token schreiben. Mehrere durch Komma. Leer = überall."
onChange={(e) => setPrefix(e.target.value)} onChange={(e) => setPrefix(e.target.value)}
/> />
</div> </div>
@@ -237,7 +237,7 @@ export function Tokens() {
<thead> <thead>
<tr className="text-left text-xs text-schrift-leise"> <tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th> <th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Präfix</th> <th className="pb-2 font-medium">Ordner</th>
<th className="pb-2 font-medium">Rechte</th> <th className="pb-2 font-medium">Rechte</th>
<th className="pb-2 font-medium">Grenzen</th> <th className="pb-2 font-medium">Grenzen</th>
{/* Was mit dem Token wirklich abgelegt wurde. {/* Was mit dem Token wirklich abgelegt wurde.
@@ -266,7 +266,12 @@ export function Tokens() {
)} )}
</td> </td>
<td className="py-2 font-mono text-schrift-leise"> <td className="py-2 font-mono text-schrift-leise">
{t.prefix ? `${t.prefix}/` : '— (überall)'} {t.prefix
? t.prefix
.split(',')
.map((p) => `${p.trim()}/`)
.join(' · ')
: '— (überall)'}
</td> </td>
<td className="py-2"> <td className="py-2">
{t.can_delete ? ( {t.can_delete ? (
+17
View File
@@ -57,6 +57,23 @@ $HEIKEL = @{
6379 = 'Redis' 6379 = 'Redis'
27017 = 'MongoDB' 27017 = 'MongoDB'
47001 = 'WinRM-Listener' 47001 = 'WinRM-Listener'
# VERWALTUNGSZUGAENGE VON SPIELESERVERN. Sie sehen harmloser aus als sie
# sind, weil ein Spieleserver ja erreichbar sein SOLL -- aber über diese
# Ports verwaltet man ihn komplett, nicht spielt man auf ihm.
#
# Am 14.08.2026 gemessen: auf dieser Maschine lauschte 10011 auf allen
# Adressen (ts3server.exe aus C:\Server\TS3, läuft seit dem 09.07.2026),
# und eine handangelegte Regel "TS3_10011 (TCP)" ließ beliebige
# Gegenstellen in allen Profilen herein. Der Wächter schwieg, weil der Port
# beim Übernehmen des Standes schon dastand -- genau der Fall, für den es
# diese Liste gibt ("Port 3000 war auch schon immer so").
#
# Im Panel (d4rk_gameserver) ist derselbe Port deshalb ausdrücklich an
# 127.0.0.1 gebunden und bekommt KEINE Firewallregel.
10011 = 'TeamSpeak ServerQuery (Vollzugriff auf den Server)'
25575 = 'Minecraft RCON (Fernsteuerung der Konsole)'
28016 = 'Rust RCON (Fernsteuerung der Konsole)'
8212 = 'Palworld REST-API (Verwaltung, inkl. Kick und Bann)'
} }
# Was nachgerechnet wird. Änderungen daran sind entweder deine -- dann # Was nachgerechnet wird. Änderungen daran sind entweder deine -- dann