Compare commits

...
81 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
D4rkst3randClaude Opus 5 0a7d83aee3 docs: der Proxy konnte seit dem Aufraeumen nicht mehr schreiben
Beim Anlegen eines NPM-Benutzers kam "Internal Error". Im Container-Log stand
"attempt to write a readonly database" -- und der Prozess hielt einen Zeiger
auf /data/database.sqlite (deleted).

DAS WAR DAS AUFRAEUMEN NACH DEM VORFALL SELBST. Die Datenbank wurde am
12.08. um 22:11 per docker cp zurueckgeschrieben, und docker cp ERSETZT die
Datei statt sie zu ueberschreiben. Der seit dem 01.08. laufende Prozess zeigte
danach ins Leere. Rechte, Eigentuemer und Platz waren dabei alle in Ordnung --
die Meldung "readonly" fuehrt in die Irre.

Fuenfzehn Stunden unbemerkt, weil nur Schreibvorgaenge betroffen waren und
niemand welche ausloeste. Gekostet haette es ab dem 03.09. die
Zertifikatsverlaengerungen und ab dem 03.10. gueltige Zertifikate fuer proxy,
portainer und cdn -- ohne Meldung, ohne dass jemand etwas geaendert haette.

Behoben durch Neustart, 4 s Ausfall. Danach zeigt der Zeiger wieder richtig,
alle sechs Seiten antworten, der Benutzer liess sich anlegen.

Und der zweite Teil der Lehre steht auch dort: der Waechter haette das nicht
gefunden. Er zaehlt Ports und rechnet Pruefsummen, er versucht nirgends zu
schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:51:17 +02:00
D4rkst3randClaude Opus 5 4af8366517 fix: der Merker wird erst gesetzt, wenn die Meldung wirklich rausging
Beim ersten echten Meldetest lief das Skript ohne -Webhook. Es hat den Alarm
angezeigt, NICHTS gesendet -- und den Zustand trotzdem als "gemeldet"
abgehakt. Der naechste Lauf MIT Webhook waere still geblieben.

Ein Merker bedeutet "dieser Zustand wurde berichtet". Wenn nichts berichtet
wurde, darf er nicht gesetzt werden -- sonst verschluckt ausgerechnet der
Fehlerfall die Meldung, fuer die das Ganze da ist.

Jetzt vier Faelle, sauber getrennt:

    alles gut         -> Merker leeren, keine Nachricht noetig
    schon gemeldet    -> still bleiben
    kein Webhook      -> sagen, dass NICHTS gemeldet wurde, Merker nicht setzen
    Webhook da        -> senden; NUR bei HTTP 200/204 den Merker setzen

Der letzte Punkt ist der zweite Teil derselben Sache: schlaegt das Senden fehl,
bleibt der Merker leer und der naechste Lauf versucht es erneut. Eine Meldung,
die im Netz haengengeblieben ist, ist keine Meldung.

Nachgemessen, beide Richtungen:

    Alarm ohne Webhook  -> "NICHTS gemeldet", Merker vorhanden: False
    derselbe mit Webhook -> "Gemeldet (HTTP 204)", Merker gesetzt: True

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:09:16 +02:00
D4rkst3randClaude Opus 5 befa393b84 fix: das Alter kommt aus dem NAMEN, nicht aus dem Zeitstempel
Der erste echte Lauf auf dem Heimrechner sagte:

    d4rk_media-2026-08-11-2307.tar.gz, 0 Tage alt

Ein Archiv vom 11. August, an einem 13. August, mit "0 Tage alt" -- und
ausserdem war es gar nicht das neueste. Beides derselbe Fehler: ich habe nach
LastWriteTime sortiert und gerechnet, und die setzt der Nextcloud-Client auf
den Zeitpunkt des HERUNTERLADENS.

Damit war der Alarm wertlos. Er haette nie ausgeloest, weil frisch geholte
Archive immer taufrisch aussehen -- also genau in dem Fall geschwiegen, fuer
den er gebaut ist.

Jetzt wird das Datum aus dem Dateinamen gelesen (d4rk_media-2026-08-13-0929,
d4rkbot-2026-08-12). Das ist das Datum der SICHERUNG, und es ueberlebt jedes
Kopieren. Faellt der Name aus dem Muster, bleibt der Zeitstempel als Notnagel.

Nachgemessen mit Dateien, die ALLE den heutigen Zeitstempel tragen:

    neuestes erkannt: d4rk_media-2026-08-13-0929   0 Tage
    dieses entfernt -> d4rkbot-2026-08-12          1.4 Tage

Ausserdem sagt die Zeile jetzt, wieviel in der QUELLE liegt: "0 neu, 3 in der
Ablage, 5 in der Quelle" beantwortet die Frage, ob der Client ueberhaupt schon
durch ist -- "3 insgesamt" allein tat das nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 10:27:38 +02:00
D4rkst3randClaude Opus 5 9e4132cc7e fix: Platzhalter erkennen, statt den Rechner damit lahmzulegen
Der Heimrechner ist beim ersten Lauf haengengeblieben. Ursache:

    d4rk_media-2026-08-11-2307.tar.gz  327030051  Archive, ReparsePoint
    d4rk_media-2026-08-12-0430.tar.gz  352258706  4199968

4199968 = 0x401620:
    0x400000  RECALL_ON_DATA_ACCESS   beim Zugriff erst herunterladen
    0x001000  OFFLINE                 Inhalt liegt nicht hier
    0x000400  ReparsePoint
    0x000200  SPARSE_FILE
    0x000020  Archive

Der Nextcloud-Client hatte "virtuelle Dateien" aktiviert: im Ordner steht ein
Verweis mit der richtigen GROESSE, aber ohne Inhalt. robocopy fasst alle an,
und jedes Anfassen loest einen Download aus -- bei fuenf Archiven 1,7 GB auf
einmal, waehrend Windows blockierte.

Das Skript prueft das jetzt VORHER und bricht ab, ohne etwas zu kopieren oder
zu loeschen. Mit dem Hinweis, wo man es abstellt, und woran man es im Explorer
sieht: Wolken-Symbol statt gruenem Haken.

An einer Datei mit gesetztem OFFLINE-Attribut nachgemessen -- erkannt und
abgebrochen; ohne das Attribut laeuft es normal durch.

Die Lehre ist dieselbe wie den ganzen Tag: eine Groessenangabe ist keine Datei.
robocopy hat die 336 MB geglaubt, die dastanden, statt zu pruefen, ob sie da
sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 10:22:53 +02:00
D4rkst3randClaude Opus 5 89d0ec2b14 fix: das Abholskript sagt jetzt, WORAN es haengt
Gemeldet: "danach nur ==> Nachsehen und dann passiert nichts mehr."

Zwischen der Ueberschrift und der ersten Ausgabe liefen drei Schritte, die
alle drei minutenlang blockieren koennen -- Test-Path, New-Item und
Resolve-Path, wenn ein Pfad auf eine Netzfreigabe oder eine schlafende Platte
zeigt. Welcher davon stand, war von aussen nicht zu sehen.

Jetzt sagt jeder Schritt VORHER, was er tut. Eine Ausgabe, die erst nach dem
Blockieren kommt, hilft genau dann nicht, wenn man sie braucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 10:06:11 +02:00
D4rkst3randClaude Opus 5 0f3f63749c feat: tools/heim-abholen.ps1 -- die Auswaertskopie, die der Server nicht loeschen kann
Der Weg nach draussen fuehrt jetzt ueber die Nextcloud, die ohnehin jede Nacht
ein geprueftes Archiv bekommt: der Desktop-Client zieht den Ordner auf die
Platte zu Hause. Serverseitig ist dafuer nichts noetig -- kein Konto, kein
Schluessel, kein Skript.

WARUM DAS ALLEIN NICHT REICHT: der Client gleicht in BEIDE Richtungen ab.
Verschwinden die Archive auf dem Server -- Trojaner, Fehlgriff, kaputter
Auftrag --, loescht er sie auch zu Hause. Der synchronisierte Ordner ist eine
Kopie, aber keine Sicherung.

Dieses Skript kopiert daraus in einen zweiten Ordner, den der Client nicht
kennt, und bricht damit die Kette: was einmal dort liegt, bleibt liegen, bis
die Aufbewahrungsfrist es wegraeumt. Der Server kann es nicht mehr erreichen.

robocopy OHNE /MIR, und das ist der ganze Punkt -- /MIR wuerde das Ziel dem
Quellordner angleichen und dort Geloeschtes mitloeschen.

Es weigert sich ausserdem, wenn das Ziel IM synchronisierten Ordner liegt: dann
wuerde der Client die Kopien wieder hochladen und alles waere umsonst.

UND ES MELDET SICH, WENN NICHTS MEHR ANKOMMT. Ein Kopierauftrag, der still
aufhoert, ist genau der Schaden, gegen den er gebaut ist: man glaubt, man haette
eine Sicherung, und merkt es an dem Tag, an dem man sie braucht. Gemeldet wird
nur bei Zustandswechsel -- eine taegliche "alles in Ordnung"-Meldung liest nach
einer Woche niemand mehr, und dann auch die eine nicht, auf die es ankommt.

An Wegwerf-Ordnern durchgespielt, alle Faelle einzeln:

    drei Archive holen                    -> 3 neu
    Datei in der QUELLE geloescht         -> bleibt im Ziel        <- der Punkt
    Aufbewahrung auf 2                    -> aeltestes entfernt
    seit 8 Tagen nichts Neues             -> Alarm
    zweiter Lauf                          -> bleibt still
    neues Archiv kommt an                 -> Entwarnung
    Ziel im Quellordner                   -> abgelehnt

Der vierte Test hat beim ersten Anlauf nichts gemessen: ich hatte die KOPIEN
gealtert statt der Quelle, und robocopy hat sie korrekt neu geholt. Ein Test,
der das Falsche misst, meldet "in Ordnung" und wird geglaubt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:49:06 +02:00
D4rkst3randClaude Opus 5 6e654e0ef8 fix: die Sicherung laedt wieder hoch -- ueber localhost statt ueber Cloudflare
Der naechtliche Lauf scheiterte mit HTTP 413. Nachgemessen mit drei Groessen,
statt geraten:

    1 KB -> 201 | 60 MB -> 201 | 340 MB -> 413, "Server: cloudflare"

cdn.d4rkst3r.de laeuft ueber Cloudflare, und das deckelt Uploads im Gratistarif
bei 100 MB. Das Archiv ist 336 MB. Weder der Proxy (client_max_body_size 0) noch
die Nextcloud (16 GB) waren es.

Bestellt war ein eigener Name ohne Cloudflare. Vorher aber geprueft, ob es den
ueberhaupt braucht -- und er braucht es nicht: die Nextcloud laeuft auf
DERSELBEN Maschine. Ueber http://localhost:11000 verlaesst der Verkehr die
Rueckschleife nicht, und "localhost" steht schon in trusted_domains. Kein DNS,
kein Zertifikat, kein Proxy, und nebenbei fuenfzehnmal schneller:

    ueber 192.168.100.1:11000   340 MB in 59 s
    ueber localhost:11000       340 MB in  4 s

Kein TLS dabei, und das ist in Ordnung: die Verbindung geht nicht hinaus.

Ganzer Lauf zur Gegenprobe: 4630 Eintraege, 335,9 MB, zweite Platte identisch,
hochgeladen 201, zurueckgeholt und verglichen identisch. Statusseite sagt
wieder "sicherung ok an 3 Orten".

WICHTIG, DAMIT NIEMAND ES FALSCH LIEST -- und das steht auch im Skript: diese
Kopie ist KEINE Auswaertskopie. Die Nextcloud liegt auf derselben Platte wie
die Daten. Sie ist die dritte Kopie, nicht der Weg aus dem Haus. Der fehlt
weiterhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:32:44 +02:00
D4rkst3randClaude Opus 5 f138df44a7 feat: tools/waechter.ps1 -- zaehlt offene Tueren und rechnet Dateien nach
Die Statusseite war waehrend des Einbruchs die ganze Zeit gruen. Sie waere auch
gruen geblieben, wenn der Miner gelaufen waere. Es fehlte kein Bildschirm, auf
dem man es haette sehen koennen -- es fehlte etwas, das VON SICH AUS Bescheid
sagt. Deshalb kein Frontend, sondern zwei Pruefungen mit Meldeweg.

  1. Ports, die auf allen Adressen lauschen, gegen einen hinterlegten Stand.
     Port 3000 stand elf Tage offen; das haette am ersten Tag gemeldet werden
     koennen. Dazu eine feste Liste heikler Ports, die auch dann warnt, wenn
     der Zustand "schon immer so" war -- 3000 war auch schon immer so.
  2. Pruefsummen ueber sshd_config, administrators_authorized_keys, beide
     Compose-Dateien und Giteas app.ini und .gitconfig. Der Angriff WAR eine
     Datei, die sich um 21:13:57 aenderte.

Gemeldet wird nur bei Aenderung, mit Merker in den Einstellungen. Ein Waechter,
der taeglich meckert, wird stummgeschaltet und ist dann schlechter als keiner.

VOR DEM EINRICHTEN AN BEIDEN FAELLEN BEWIESEN statt behauptet:

    Testport 45999 geoeffnet  ->  "NEU offen: 45999"
    Datei geaendert           ->  Meldung raus, HTTP 204
    zurueckgestellt           ->  "Wieder in Ordnung (Merker geleert)"

Und er prueft zuerst, ob er ueberhaupt etwas sehen kann: findet er keine
Objekte, meldet er "ungueltig" statt "sauber". Das ist die Lehre aus zwei
Suchen in dieser Nacht, die genau das verwechselt haben.

Die Statusseite zeigt den Stand mit; meldet der Waechter sich zwei Stunden
nicht, ist DAS der Befund -- er laeuft alle 15 Minuten.

Im Bericht ausserdem nachgetragen: die Inventur von aussen (NPM-Verwaltung und
MariaDB standen offen, beide jetzt gesperrt), die abgeschalteten Ueberbleibsel
von Caddy und cloudflared, und zwei Irrtuemer von mir -- "Docker geht an der
Firewall vorbei" und "der MariaDB-root hat kein Passwort". Beide waren aus
Messungen geschlossen, die die Frage nicht beantworten konnten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:22:44 +02:00
D4rkst3randClaude Opus 5 70260060c9 fix: ein Tabulator im Hilfetext -- der Pfad liess sich nicht kopieren
Die Zeile am Ende zeigte

    Get-Content 'C:\Users\...\SSH-Tunnel	unnel.log' -Tail 20

statt \tunnel.log. Aus dem Backslash-t ist beim Erzeugen der Datei ein echter
Tabulator geworden -- und der Pfad, den man kopieren soll, fuehrt damit ins
Leere.

Das ist heute Nacht die dritte Ersetzung, die an einer Escape-Ebene
gescheitert ist (nach dem \n im Compose und dem \n des Angreifers, an dem sein
eigener Schadcode zerbrach). Deshalb steht dort jetzt Join-Path statt eines
zusammengesetzten Textes: wo kein Backslash im Text steht, kann auch keiner
umgedeutet werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 01:35:48 +02:00
D4rkst3randClaude Opus 5 a36fcec57e fix: kein Fenster OHNE Administratorrechte -- VBS-Starter statt S4U
Der S4U-Weg aus dem letzten Commit war richtig gedacht und praktisch unbrauchbar:
das Eintragen einer S4U-Aufgabe braucht erhoehte Rechte, und in einem
gewoehnlichen PowerShell-Fenster scheitert es mit "Zugriff verweigert". Ein
Werkzeug, das Administratorrechte verlangt, benutzt am Ende niemand -- und
genau darum ging es ja: was umstaendlich ist, wird umgangen.

Jetzt startet die Aufgabe wscript.exe mit einem winzigen VBS, und dessen
Run-Aufruf hat den Fenstermodus 0. Das heisst wirklich unsichtbar, im
Unterschied zu "-WindowStyle Hidden", das bei Konsolenprogrammen in einer
interaktiven Sitzung nur halb wirkt. Besondere Rechte braucht es nicht, weil
nichts Besonderes passiert.

Der Anmeldetyp bleibt damit Interactive; das Fenster verhindert der Starter und
nicht der Anmeldetyp.

Das Protokoll aus dem letzten Commit bleibt und wird jetzt erst recht gebraucht:
sichtbar ist nichts mehr, also muss nachlesbar sein, was ssh sagt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 01:33:03 +02:00
D4rkst3randClaude Opus 5 a494f472d1 fix: der Tunnel laeuft jetzt OHNE Fenster -- S4U statt Interactive
Gemeldet: "durch dein ssh script habe ich trotzdem ein powershell fenster
offen, gemerkt habe ich das weil ich es gerade geschlossen habe und die
verbindung weg war."

Genau so ist es. -LogonType Interactive laesst die Aufgabe IN der
Anmeldesitzung laufen; ein Konsolenprogramm bekommt dort ein Fenster, und
"-WindowStyle Hidden" haelt es nur halb zurueck. Wer es zumacht, kappt den
Tunnel, ohne zu wissen was er tut -- und das ist keine Randerscheinung, sondern
passiert genau einmal und dann jedes Mal wieder.

S4U ("Service for User") laeuft AUSSERHALB der Sitzung: es gibt gar keinen
Bildschirm, auf dem etwas erscheinen koennte. Auf lokale Dateien darf es
weiterhin zugreifen, und mehr braucht der Schluessel unter %USERPROFILE% nicht.
Ein Passwort muss dafuer nirgends gespeichert werden.

DAZU GEHOERT EIN PROTOKOLL, sonst tauscht man ein stoerendes Fenster gegen eine
stille Fehlfunktion: ohne Sitzung sieht niemand mehr, was ssh sagt. Ein
abgelehnter Schluessel oder ein besetzter Port waere unsichtbar -- der Tunnel
stuende einfach nicht, und niemand wuesste warum. tunnel.log haelt jeden
Verbindungsversuch fest und wird bei 1 MB verworfen.

Das Skript sagt am Ende auch, wie man ohne Fenster nachsieht:
Get-NetTCPConnection -LocalPort 13389 -State Listen, und die letzten Zeilen des
Protokolls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 01:27:06 +02:00
D4rkst3randClaude Opus 5 8e3a13b374 docs: RDP ist zu -- von aussen gemessen, nicht angenommen
Test-NetConnection vom Heimrechner: TcpTestSucceeded False. Das war der letzte
offene Punkt der Nacht.

Die Messung vom Server aus taugte dafuer NICHT: Verkehr an die eigene
oeffentliche Adresse durchlaeuft die Eingangsregeln nicht und meldete
faelschlich "erreichbar". Zweimal in derselben Nacht in dieselbe Falle getappt,
einmal bei Port 3000 und einmal bei 3389 -- beide Male hat erst die Messung von
DRAUSSEN das Urteil gebracht.

Dazu: sshd nimmt nur noch Schluessel (Passwort UND keyboard-interactive aus,
vor dem ersten Match-Block), RDP per Firewall gesperrt, Zugang ueber
tools/tunnel-einrichten.ps1 als Hintergrundaufgabe.

Seitdem laufen die Angriffe ins Leere: "invalid user root ... [preauth]", und
alle 12 erfolgreichen Anmeldungen kommen von einer einzigen IP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 01:09:06 +02:00
D4rkst3randClaude Opus 5 15f4c7bc3d fix: der Tunnel scheiterte am Benutzernamen, und die Meldung zeigte woandershin
Der Standard fuer -Benutzer war $env:USERNAME -- der Name am HEIMRECHNER. Auf
dem Server heisst das Konto anders (hier "az319" gegen "Darkster"). Das Skript
lief sauber durch, trug die Aufgabe ein, und der Tunnel kam trotzdem nicht.

SCHLIMMER ALS DER FEHLER WAR DIE DIAGNOSE. Am Ende stand fest verdrahtet
"Haeufigste Ursache: der oeffentliche Schluessel ist noch nicht hinterlegt" --
eine GERATENE Ursache, und sie war falsch. Wer dem folgt, prueft den Schluessel,
findet ihn in Ordnung und sucht weiter an der falschen Stelle.

Jetzt wird gemessen: schlaegt der Tunnel fehl, baut das Skript selbst eine
SSH-Verbindung auf und wertet aus, was der Server antwortet -- "Permission
denied" (dann Name ODER Schluessel, beides benannt), nicht erreichbar,
Weiterleitung verweigert, oder ssh kommt durch und es liegt am Ziel-Port.

Und -Benutzer hat keinen Standard mehr. Fehlt er, wird gefragt: "Wie heisst
dein Konto AUF DEM SERVER? (nicht der Name hier am PC)". Einmal fragen ist
besser als einmal in die Irre fuehren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 01:03:53 +02:00
D4rkst3randClaude Opus 5 b82d315f8d feat: tools/tunnel-einrichten.ps1 -- der Tunnel als Hintergrundaufgabe
LAEUFT AUF DEM HEIMRECHNER, nicht auf dem Server.

Seit dem 13.08.2026 ist RDP am Server gesperrt; hinein geht es nur noch durch
einen SSH-Tunnel mit Schluessel. Von Hand hiesse das: vor jeder Sitzung ein
PowerShell-Fenster oeffnen, einen Befehl tippen, das Fenster offen lassen.

DAS HAELT NIEMAND DURCH, UND WAS NIEMAND DURCHHAELT, WIRD UMGANGEN -- am Ende
steht dann wieder ein offener Port. Das Skript macht den Tunnel deshalb zu
etwas, das man nicht sieht: eine Aufgabe, die beim Anmelden startet, sich bei
Abbruch selbst neu aufbaut und kein Fenster zeigt. Danach verbindet man sich
wie immer, nur auf localhost statt auf die Server-Adresse.

Drei Sachen, die es prueft statt sie anzunehmen:

  - Ist ein SSH-Client da? Sonst der Nachinstallationsbefehl statt eines
    kryptischen Fehlers.
  - Gibt es einen Schluessel? Sonst anlegen UND den oeffentlichen Teil gross
    ausgeben, damit klar ist, was zu schicken ist.
  - Ist der lokale Port frei? Das ist der Fehler, der uns heute Nacht eine
    halbe Stunde gekostet hat: 3389 ist unter Windows selbst belegt, die
    Verbindung landet dann auf dem EIGENEN PC, und die Meldung lautet
    "Konsolensitzung bereits aktiv" -- man sucht den Fehler ueberall, nur
    nicht beim Port. Standard ist deshalb 13389.

Die Schleife um ssh ist Absicht: bei Zwangstrennung, Standby oder kurzem
Aussetzer endet ssh, und ohne Schleife waere der Tunnel weg, ohne dass es
jemand merkt -- bis die naechste Verbindung scheitert.
ExitOnForwardFailure=yes gehoert dazu, sonst laeuft ssh scheinbar, waehrend die
Weiterleitung gar nicht steht.

Gedacht auch fuer Mitbenutzer, die sich mit Rechnern nicht auskennen: einmal
ausfuehren, oeffentlichen Schluessel schicken, fertig. Danach sehen sie vom
Tunnel nie wieder etwas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:55:59 +02:00
D4rkst3randClaude Opus 5 9727eae0f5 docs: Einbruch in den Gitea vom 12.08.2026 -- Hergang, Messwerte, Reparatur
Kein Ausfall, kein Datenverlust, kein laufender Schadcode -- aber ein echter
Schreibzugriff von aussen.

Um 21:13:56 rief jemand /api/internal/manager/add-logger auf, legte einen
Datei-Logger auf die .gitconfig, liess die Nutzlast als Logzeile hineinschreiben
und entfernte den Logger eine Sekunde spaeter wieder. Eingeschleust war ein
packObjectsHook, der bei JEDEM Klonen einen Monero-Miner nachgeladen und per
crontab verankert haette.

WARUM TROTZDEM NICHTS LIEF: die Nutzlast beginnt mit "[uploadpack]\n", wobei
das \n als TEXT in der Datei steht statt als Umbruch. Git bricht mit "bad
config line 23" ab, der Hook wurde nie gueltig. Nachgemessen: kein Prozess,
keine Datei, keine crontab-Zeile, nichts in den Repos, kein Docker-Socket im
Container, keine fremden Konten oder Token. Der einzige Schaden war, dass alle
Git-Operationen ausfielen -- daran ist es aufgefallen.

DER WEG HINEIN: Gitea lag mit 0.0.0.0:3000 direkt im Internet, am Proxy vorbei.
Damit war /api/internal/* von aussen erreichbar, geschuetzt nur durch das
INTERNAL_TOKEN. Woher der Angreifer es hatte, liess sich NICHT klaeren -- es
steht in keinem Repo, und RDP war es nicht (2756 Fehlversuche in 14 Tagen, aber
alle 33 erfolgreichen Anmeldungen von der IP des Betreibers). Genau deshalb ist
die Antwort nicht "Token tauschen", sondern den Weg zumachen.

GEAENDERT: .gitconfig gesaeubert; Port 3000 gar nicht mehr veroeffentlicht; NPM
haengt jetzt mit in gitea_default und spricht gitea:3000 an (dafuer musste der
nginx-Resolver auf Dockers DNS 127.0.0.11 umgestellt werden); INTERNAL_TOKEN
und SECRET_KEY getauscht (gefahrlos: 2FA und Anmeldequellen jeweils 0);
/api/internal/ gibt am Proxy 404; frisches Abbild statt des elf Tage alten.

Gegengeprueft: Port 3000 von aussen 000, /api/internal/ 404, git.d4rkst3r.de
200, LFS-Direktiven alle drei erhalten, Push geht beim ersten Versuch, Repos
identisch mit den lokalen Klonen.

DIE LEHRE: der offene Port war nie noetig -- der Proxy haette von Anfang an
uebers interne Netz gehen koennen. Und aufgefallen ist es nicht, weil etwas
ueberwacht wurde, sondern weil der Angreifer geschludert hat. Haette er sein \n
richtig gesetzt, liefe der Miner noch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:13:31 +02:00
D4rkst3r 14fadf5f30 chore: Probe nach dem Abdichten von Gitea 2026-08-13 00:12:11 +02:00
D4rkst3r 4430dc2944 chore: Probe nach dem Saeubern der Gitea-Konfiguration 2026-08-13 00:00:06 +02:00
D4rkst3randClaude Opus 5 ab93e0ae4e feat: die Archive lassen sich aus dem Panel holen
Einstellungen -> "Sicherungen holen": die fertigen Archive mit Datum, Groesse
und einem Knopf. Der erste Schritt, der eine Kopie VON DIESEM RECHNER HERUNTER
bringt -- bisher haette man sich dafuer per Fernwartung auf den Server klicken
muessen, und genau daran hat es gelegen. Nicht Unwille, sondern
Umstaendlichkeit.

Der Ordner haengt NUR LESEND in den Container. Ein Dienst, der seine eigenen
Sicherungen loeschen kann, ist genau das Werkzeug, das man einem uebernommenen
Dienst nicht in die Hand geben will. Nachgemessen im Container:

    touch /sicherungen/probe  ->  Read-only file system

Und es gibt NUR EINE FORM VON NAMEN -- nicht "alles ausser .. und /", sondern
ein Muster, das genau auf die eigenen Archive passt. Wer einen Pfad
hineinschmuggeln will, muesste ihn erst so benennen. Das ist der Unterschied
zwischen dicht gemacht und dicht gebaut:

    ../../etc/passwd                -> 404
    ..%2f..%2fdata%2fmedia.db       -> 400
    media.db                        -> 400
    <archiv>.tar.gz.evil            -> 400
    ohne Anmeldung                  -> 401

Der ganze Weg einmal wirklich gegangen, nicht nur die Kopfzeilen: 336 MB in
15,9 s durch den Proxy, sha256 auf der Platte gleich sha256 heruntergeladen,
tar tzf zeigt 4639 Eintraege. Gestroemt und nicht in den Speicher gelesen --
sonst haetten Dienst UND Proxy die 336 MB gleichzeitig im Arbeitsspeicher.

Jeder Griff steht im Verwaltungs-Verlauf. Geloggt wird der GRIFF und nicht der
Erfolg: ein abgebrochener Download taucht ebenfalls auf, und das ist gewollt --
wer spaeter fragt, wer sich den Bestand geholt hat, will die Anfrage sehen.

Die fuenf neuen Pruefungen sind in tools/rundgang.py mit drin; der steht jetzt
bei 29 in Ordnung, 0 nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:46:03 +02:00
D4rkst3randClaude Opus 5 3a89acaa41 docs: der Server steht im Rechenzentrum -- das dreht die Bewertung um
Ich hatte "gleiches Haus" geschrieben und damit die Platte am Router als
halben Schritt abgetan. Falsch: der Rechner ist ein Windows-Root-Server im
Rechenzentrum, bedient von zu Hause ueber mRemoteNG. Ein Ziel beim Betreiber
daheim sind also zwei Gebaeude, zwei Netze, zwei Stromkreise, zwei Anbieter --
besser wird ein Sicherungsziel kaum.

Gemessen, was dafuer schon dasteht:

    sshd        Running, Automatic, 0.0.0.0:22 und [::]:22
    Firewall    "OpenSSH SSH Server (sshd)" -> Allow
    sshd_config Subsystem sftp sftp-server.exe
    je Nacht    d4rk_media 335,9 MB + d4rkbot 0,9 MB = 336,9 MB

Auf der Serverseite fehlt nichts ausser einem Konto, das nur lesen darf. Der
Rest ist ein Skript auf dem Heimrechner, das HOLT statt zu bekommen -- ein
Ziel, in das der Server schreiben darf, ist auch eines, das ein uebernommener
Server loeschen kann.

Fund am Rande: in sshd_config steht kein PasswordAuthentication no. Der
OpenSSH-Standard ist yes -- auf Port 22, offen ins Internet, mit AllowGroups
einschliesslich administrators.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:40:31 +02:00
D4rkst3randClaude Opus 5 6bd1c7a55e docs: Richtigstellung -- "drei Orte" sind zwei Platten, und keiner ist auswaerts
Nachgemessen, nachdem die Frage aufkam, wie Sicherungen aus dem Haus kommen.
Das Ergebnis widerspricht dem, was ich heute frueh selbst hier eingetragen habe.

ALLE Docker-Volumes liegen in einer einzigen Datei:

    C:\Users\Darkster\AppData\Local\Docker\wsl\disk\docker_data.vhdx   51,5 GB

Das ist Platte 0. Darin: die Daten von d4rk_media, die Datenbank des Bots UND
DIE NEXTCLOUD. cdn.d4rkst3r.de und media.d4rkst3r.de loesen beide auf
88.218.224.10 auf, und die vierzehn nextcloud-aio-Container laufen auf genau
dieser Maschine.

    C:\backup\d4rk_media   Platte 0   dieselbe wie die Daten
    D:\backup\d4rk_media   Platte 1   echt getrennt
    Nextcloud              Platte 0   dieselbe wie die Daten

Die Nextcloud-Kopie stand hier als "die einzige Trennung". Sie ist in Wahrheit
die am WENIGSTEN getrennte von allen dreien -- und der Grund fuer den Irrtum ist
der uebliche: eine Adresse mit eigenem Namen und eigenem Zertifikat SIEHT wie
ein anderer Ort aus. Nachgesehen hatte das niemand.

Was die drei Ziele wirklich abdecken: eine Platte stirbt -> D: faengt es auf,
das war den Aufwand wert. Rechner tot, Feuer, Diebstahl, Verschluesselung ->
alle drei Kopien sind gleichzeitig weg.

"sicherung ok an 3 Orten" auf der Statusseite bleibt woertlich richtig -- sie
zaehlt Ablageorte, nicht Platten und nicht Gebaeude. Wer daraus "also sicher"
liest, liest mehr hinein als dasteht.

In docs/ideen.md steht das Thema wieder offen, mit den Kandidaten und der
Eigenschaft, auf die es dabei ankommt: HOLEN statt SCHICKEN. Ein Ziel, in das
der Server selbst schreiben darf, ist auch eines, das ein uebernommener Server
loeschen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:27:36 +02:00
D4rkst3randClaude Opus 5 608b3b3097 feat: tools/rundgang.py -- 24 Pruefungen gegen den laufenden Dienst
WOZU, wo doch jede Aenderung einzeln gemessen wird: weil sie einzeln gemessen
wird. An einem Tag mit sechs Neustarts fasst niemand mehr alles an, und die
Sachen, die keiner mehr anfasst, sind genau die, die kaputtgehen -- ein WebP,
das nach einem Umbau nicht mehr ausgehandelt wird, meldet sich nicht.

Kein Ersatz fuer die Tests: er prueft nicht, ob eine Funktion das Richtige
rechnet, sondern ob der Dienst, so wie er gerade laeuft, die Sachen noch tut,
die er koennen muss. Deshalb geht er ueber HTTP und nicht am Code vorbei. Nur
lesend -- kein Upload, kein Loeschen, keine Einstellung wird angefasst.

ZUERST kommt, was OHNE ANMELDUNG DICHT SEIN MUSS (acht Wege, alle 401). Das ist
die Sorte Fehler, die man nicht sieht: eine Route, die versehentlich vor dem
Waechter registriert wird, funktioniert praechtig -- fuer alle. Genau das ist
hier schon zweimal passiert.

Dann Bestand, /f/ mit WebP-Aushandlung an derselben Adresse und Vary: Accept,
beide CSV-Exporte auf nackte LF, Papierkorb-Deckel, Sicherungsziele und die
Statusseite.

ZWEI FEHLSCHLAEGE BEIM BAUEN, beide im Test und nicht im Dienst -- sie stehen
als Kommentar drin, damit der naechste sie nicht wiederholt:

  1. Ohne Filter zog er vehicles/asbo.webp und verglich WebP mit WebP. Gleiche
     Groesse, Test rot, Dienst richtig.
  2. Der Filter hiess dann "q=" statt "query=". Unbekannte Parameter ignoriert
     der Dienst und liefert ALLES -- was wie ein kaputter Filter aussieht und
     ein kaputter Aufruf ist.

Ein Test, der das Falsche misst, ist schlimmer als keiner: er wird geglaubt.

Gemessen: 24 in Ordnung, 0 nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:01:23 +02:00
D4rkst3randClaude Opus 5 1baa67c891 fix: die geplante Sicherung zeigte auf einen Pfad mit Versionsnummer
Gefunden beim Nachsehen, ob die Sicherung ueberhaupt laeuft -- sie lief
(12.08.2026 04:30:01, Ergebnis 0), aber sie startete pwsh.exe so:

    C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.4.0_x64__...\pwsh.exe

DIE VERSIONSNUMMER STEHT IM PFAD. Beim naechsten PowerShell-Update heisst der
Ordner anders, die Aufgabe findet ihre pwsh.exe nicht mehr, und die Sicherung
hoert auf zu laufen -- an einem Tag, an dem niemand etwas an ihr geaendert hat.
Der Waechter haette es nach 26 Stunden gemeldet; angenehmer ist es, wenn es gar
nicht erst passiert.

Ersetzt durch den stabilen Ausfuehrungsalias unter LOCALAPPDATA. Der geht, weil
die Aufgabe als Darkster und Interactive laeuft -- fuer einen Dienst unter
SYSTEM waere er wertlos gewesen, und das ist vorher nachgesehen worden.

Gemessen, und zwar der ganze Weg und nicht nur der Start: Aufgabe von Hand
gestartet -> Ergebnis 0, sicherung.letzte sagt ok:true, zweit:true, hoch:true,
4630 Dateien. Argumente unveraendert, Zeitplan unveraendert.

Ausserdem die Uebung aus der NEUEN Kopie gefahren, nicht nur aus der alten --
sonst waere das zweite Ziel eine Datei und kein Sicherungsziel:

    pwsh -File tools\zurueckspielen.ps1 -Ordner D:\backup\d4rk_media
    -> 4630 Eintraege, 4630 Dateien, drei Dateien durch den Dienst geholt:
       identische Bytes. Die Uebung ist bestanden.

Die Aenderung an der geplanten Aufgabe liegt ausserhalb des Repos; sie steht
deshalb in der ROADMAP, damit sie nach einem Neuaufsetzen nicht fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:56:41 +02:00
D4rkst3randClaude Opus 5 74e2128afb feat: die Sicherung liegt an drei Orten statt an zweien
Der letzte offene Punkt aus docs/ideen.md. Ein ZWEITES LAUFWERK, einstellbar im
Panel unter Einstellungen -> Sicherung; sichern.ps1 legt das Archiv dort ab,
vergleicht die Pruefsumme und duennt auch dort aus.

WARUM DAS NICHT DASSELBE IST WIE DIE NEXTCLOUD. Die hilft, wenn der ganze
Rechner weg ist -- aber wer 335 MB, spaeter 100 GB, zurueckholen muss, laedt sie
ueber die Leitung. Das zweite Laufwerk ist in Minuten zurueckgespielt. Zwei
Fragen, zwei Antworten, deshalb zwei Ziele.

UND ES WARNT, WENN ES DIESELBE PLATTE IST -- das ist der eigentliche Inhalt.
Ein zweiter Ordner auf C: sieht im Panel genauso gruen aus wie eine echte zweite
Platte und hilft gegen gar nichts. Verglichen wird die PHYSISCHE Platte und
nicht der Laufwerksbuchstabe: zwei Partitionen derselben NVMe sterben zusammen.
Nachgemessen in beide Richtungen:

    C:\backup gegen D:\backup  ->  Nr. 0 gegen Nr. 1  ->  "andere Platte"
    C:\backup gegen C:\Users   ->  Nr. 0 gegen Nr. 0  ->  Warnung

Auf diesem Rechner sind das zwei getrennte NVMe zu je 954 GB.

GEMESSEN AN EINEM ECHTEN LAUF, keinem Trockentest: 4630 Eintraege, 335,9 MB,
auf beiden lokalen Zielen und in der Nextcloud dieselbe Pruefsumme
FFCE4D0A7310... -- identisch.

Geprueft wird mit der Pruefsumme und nicht mit der Dateigroesse: eine
abgebrochene Kopie auf eine volle Platte hat oft genau die richtige Laenge und
trotzdem Nullen am Ende. Schlaegt das Kopieren oder der Vergleich fehl, ist der
ganze Lauf gescheitert (exit 1, ok:false) -- ein Ziel, das still ausfaellt, ist
genau das, wogegen das hier gebaut ist.

Der Bericht traegt jetzt zweit:true. Fehlt das Feld, ist KEIN zweites Ziel
eingerichtet -- es steht absichtlich nicht als false da, denn das laese sich wie
"hat nicht geklappt".

Die Statusseite zaehlt die Orte mit: "sicherung ok an 3 Orten" statt nur "ok".
Eine Sicherung, die es nur einmal gibt, ist gruen und trotzdem eine, die ein
Plattenausfall mitnimmt -- das gehoert dorthin, wo jemand hinsieht.

Der Pfad wird beim Speichern auf seine FORM geprueft (Laufwerksbuchstabe oder
UNC-Freigabe): der Dienst laeuft im Container und kann ihn nicht nachschlagen,
aber ein relativer Pfad ist mit Sicherheit ein Tippfehler -- und ein Tippfehler
in einem Sicherungsziel faellt sonst erst auf, wenn man die Sicherung braucht.
Nachgemessen: "backup/woauchimmer" -> 400 mit Text.

Und zurueckspielen.ps1 sagt im Kopf, was zu tippen ist, wenn genau diese Platte
das Problem ist. Das ist der Fall, fuer den das Ganze da ist, und niemand soll
ihn um vier Uhr nachts erst herleiten muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:50:44 +02:00
D4rkst3randClaude Opus 5 bbb74138c8 feat: Verlauf als CSV, Meldung bei Upload mit Drossel, Deckel auf dem Papierkorb
Abschnitt 3 aus docs/ideen.md, bis auf die Sicherungsziele.

VERLAUF ALS CSV. Der Export konnte nur den Bestand; wer nachrechnen wollte,
was ueber einen Zeitraum passiert ist, sass vor einer Tabelle, die nach 500
Zeilen umblaettert. Der Knopf sitzt in der Reiterleiste und holt den Verlauf,
DER GERADE OFFEN IST -- zwei Knoepfe nebeneinander waeren die Frage "welchen
von beiden?" an einer Stelle, wo die Antwort schon auf dem Bildschirm steht.

Semikolon und CRLF wie beim Bestands-Export; das war dort nachgemessen worden.
Gemessen an der ausgelieferten Datei: 5371 Zeilen, kein einziges nacktes LF.
Ohne Anmeldung 401 -- die Route liegt hinter dem Waechter, was nach dem
Sitzungs-Fehler von vorgestern ausdruecklich nachgeprueft wurde.

MELDUNG BEI UPLOAD -- die Drossel ist der eigentliche Inhalt. Jeder Upload
schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht EINE
Nachricht raus. Ohne das waere die Funktion ein Schaden: 900 Fahrzeugbilder
ergaeben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte
Meldung bekommt, sieht sie nicht mehr.

Gemessen: drei Uploads mit zehn Sekunden Abstand -> eine Meldung ueber drei
Dateien. Ein einzelner Upload -> eine Meldung ueber eine Datei.

Nur Token-Uploads, absichtlich. Was ueber das Dashboard hereinkommt, hat gerade
jemand selbst hochgeladen und bestaetigt bekommen. Zurueckholen aus dem
Papierkorb meldet gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.

Und derselbe Fehler wie bei 'platte' lauerte schon wieder: die Liste der
Anlaesse steht im Server UND in der Oberflaeche, und der Server verwirft beim
Speichern alles, was in seiner Liste fehlt -- ein Kreuzchen, das sich setzen
laesst und beim naechsten Laden weg ist. Diesmal beide angefasst, in beiden
steht jetzt ein Verweis auf die andere. Nachgemessen: vier geschickt, vier
gespeichert.

DECKEL AUF DEM PAPIERKORB. 30 Tage UND 20 GB, was zuerst greift. Wer 200 GB
loescht, haelt sie sonst 30 Tage doppelt und merkt es erst, wenn die Platte
voll ist. Geraeumt wird das AELTESTE zuerst -- dessen Versehen waere am
ehesten schon aufgefallen.

Ueber PAPIERKORB_MAX_MB verstellbar, und das nicht aus Bequemlichkeit: ein
Deckel von 20 GB laesst sich nicht pruefen, ohne 20 GB zu loeschen. Mit
0.0002 MB nachgemessen -- vier Eintraege zu 422 Bytes, drei entfernt, uebrig
blieb der NEUESTE. Danach zurueck auf 20 GB, gemessen 21474836480.

NEBENBEI, DREIMAL DIESELBE SORTE FEHLER: "0.0 MB" fuer 422 Bytes, "0 KB" fuer
106 Bytes, "0.0 MB von 0.0 MB" beim Deckel. Es gab drei Byte-Formatierer, jeder
mit anderer Untergrenze. Jetzt einer in meldung.ts, von Bytes bis GB, exportiert
und von dash.ts mitbenutzt. Nachgemessen ueber alle Stufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:25:50 +02:00
D4rkst3randClaude Opus 5 6a49713821 feat: die Oberflaeche zu den vier Werkzeugen
Bisher liessen sie sich nur ueber die API benutzen. Jetzt stehen sie dort, wo
man sie sucht.

SPEICHER bekommt zwei Karten:

"Wozu fehlt ein Bild?" -- Muster und Namensliste hinein, beide Richtungen
heraus. Das Muster kommt aus den ADRESSVORLAGEN und wird als Knopfreihe
angeboten, damit hier nicht dieselbe Zeichenkette ein zweites Mal getippt wird.

"Suchen und Ersetzen in Pfaden" -- und dort gibt es KEINEN Knopf, der es sofort
tut. Erst zeigen, dann fragen, dann machen. Die Vorschau nennt die Zahl, die
die Entscheidung traegt: wie oft die betroffenen Dateien zusammen geholt
wurden. Null heisst, es schaut niemand zu; viertausend heisst, es schaut jemand
zu. Gibt es auch nur ein Problem, verschwindet der Ausfuehren-Knopf ganz --
nicht ausgegraut, sondern weg, denn ein Problem verhindert ohnehin alles.

EINSTELLUNGEN bekommt "Einstellungen mitnehmen". Herunterladen ist der
harmlose Knopf; "mit Geheimnissen" ist rot und fragt nach, mit dem Satz, dass
die Datei das Discord-Geheimnis und das Nextcloud-Passwort im Klartext traegt
und der Download im Verwaltungs-Verlauf steht.

TOKEN bekommen das Feld "Bilder verkleinern auf" -- im Anlege-Formular und im
Bearbeiten-Dialog, mit dem Hinweis, dass kleinere Bilder NICHT aufgeblasen
werden. In der Liste steht es als "max 512 px" neben den anderen Grenzen.

Zwei eigene Stolperer beim Bauen, beide vom Uebersetzer gefangen: ein Patch
brach vor dem Schreiben ab, sodass die Haelfte der API-Aufrufe fehlte -- und in
FehlendeKarte blieb ein `note` stehen, das niemand mehr benutzte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:03:04 +02:00
D4rkst3randClaude Opus 5 381feade4e feat: fehlende Bilder finden, Massen-Umbenennen, Bilder verkleinern, Einstellungen mitnehmen
FEHLENDE BILDER. Die Frage, die bisher niemand beantwortet hat: das Fotostudio
weiss, was es aufgenommen hat, der Dienst weiss, was liegt, und die Differenz
rechnet keiner aus -- weshalb "das Handy zeigt bei manchen Autos kein Bild"
erst im Spiel auffaellt.

POST /api/dash/fehlende nimmt ein Muster mit genau EINEM Platzhalter und eine
Liste (als Array oder als Textblock aus einem Textfeld) und gibt beide
Richtungen zurueck: was fehlt, und was daliegt ohne in der Liste zu stehen.
Beides bedeutet etwas anderes -- fehlende sind Arbeit fuers Fotostudio,
ueberzaehlige sind Platz, den man zurueckbekommt.

Gesucht wird KLEINGESCHRIEBEN, weil so abgelegt wird: wer "WEAPON_SMG"
hereinreicht, meint weapon_smg.png und bekaeme sonst ein "fehlt", das gelogen
waere. Gemessen an echten Namen: 6 geprueft, 4 da, 2 fehlen, 985 ueberzaehlig.

MASSEN-UMBENENNEN mit Vorschau. Bei 3578 Items ist eine Umbenennung von Hand
keine. DIE VORSCHAU IST DER EIGENTLICHE TEIL: was hier passiert, ist dieselbe
Warnung wie beim Ordner-Umbenennen -- jede alte Adresse gibt danach 404 --, nur
mal tausend.

Geprueft wird VOR dem ersten Schritt: Kollisionen untereinander, belegte Ziele,
unzulaessige Pfade. Und EIN Problem verhindert ALLES; eine halbe Umbenennung
ist der Zustand, aus dem niemand mehr herausfindet, weil die Grenze nirgends
steht. Gemessen: Vorschau ueber 134 Dateien fasst nichts an; eine erzwungene
Kollision gibt 409 und laesst alle drei Dateien stehen; der saubere Lauf
benennt zwei um, Vorschau wandert mit, ein Eintrag im Verlauf statt zwei.

BILDER BEIM ABLEGEN VERKLEINERN, je Token einstellbar. NICHT als Schutz -- das
waere gemessen falsch, ein 12000x12000-PNG kostet 374 ms und 47 MB, weil
libvips kachelweise arbeitet. Es geht um Plattenplatz und Ladezeit: ein
Bildschirmfoto ist 3840 breit und wird auf einem Handy auf 400 gezeigt.
withoutEnlargement ist dabei entscheidend, sonst blaeht die Einstellung kleine
Icons auf.

DABEI WAERE MIR EIN ECHTER FEHLER UNTERLAUFEN: der sha256 wird weiter oben aus
dem Original gerechnet (fuer den Ersatzpfad, wenn kein X-Path kam). Wird das
Bild verkleinert, ist das eine ANDERE Datei -- ein Datensatz mit dem Hash des
Originals waere schlicht falsch, und jede spaetere Gegenprobe verglichen gegen
etwas, das nirgends liegt. Gemessen nach der Korrektur: 3000x2000 -> 512x341,
82 KB -> 3 KB, und Datenbank und Platte stimmen in Groesse und Hash ueberein.

EINSTELLUNGEN MITNEHMEN. Export als JSON, Import zurueck. GEHEIMNISSE GEHEN
STANDARDMAESSIG NICHT MIT: unter den Schluesseln liegen das
Discord-Client-Geheimnis und das Nextcloud-Passwort, und eine Datei, die man
"mal eben" exportiert und in einen Chat zieht, ist genau der Weg, auf dem so
etwas abhandenkommt. Wer sie braucht, sagt ?geheim=1 -- und der Hinweis steht
dann IN der Datei, nicht nur auf dem Bildschirm, von dem er beim Weiterreichen
abfaellt. Der Export mit Geheimnissen steht im Verwaltungs-Verlauf.

Beim Import ueberschreiben LEERE WERTE NICHTS: eine Datei ohne Geheimnisse --
der Normalfall -- traegt sie als leere Zeichenkette, und die darf ein
vorhandenes Passwort nicht loeschen. Gemessen: nach einem Import mit leerem
client_secret stehen die 32 Zeichen unveraendert da.

Nicht mitgenommen wird sicherung.letzte -- das ist der Bericht des letzten
Laufs, kein Einstellungswert. In eine frische Anlage eingespielt behauptete er
dem Wachhund eine Sicherung, die es dort nie gab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:53:31 +02:00
D4rkst3randClaude Opus 5 29e516559a feat: Sitzungen beenden, Verwaltungs-Verlauf -- und ein Kaestchen, das log
DER FEHLER ZUERST, denn er ist meiner von gestern: die Liste der
Meldungsanlaesse stand ZWEIMAL da -- als Standard in meldung.ts und noch einmal
als Weissliste beim Speichern in dash.ts. Beim Einbau des Plattenwaechters habe
ich nur die erste angefasst.

Ergebnis: das Kaestchen "Der Platz auf der Platte wird knapp" liess sich
ankreuzen, der Server warf den Wert beim Speichern weg, und beim naechsten
Laden war es wieder aus. Ohne ein Wort dazu. Nachgemessen am laufenden Dienst:

    geschickt    ["sicherung","verwaiste","platte"]
    gespeichert  ["sicherung","verwaiste"]

Die Liste steht jetzt an EINER Stelle (ANLAESSE in meldung.ts) und wird von
beiden benutzt. Gegengeprueft: alle drei kommen an.

SITZUNGEN BEENDEN. Wer sich an einem fremden Rechner angemeldet hat und es
spaeter merkt, hatte keine Moeglichkeit das zurueckzunehmen -- ein geaendertes
Passwort half nicht, die Sitzungen haengen an einer eigenen Tabelle und
ueberleben es. Der Knopf steht im Kontomenue und nur dann, wenn es ueberhaupt
eine zweite Sitzung gibt. Gemessen: 87 offen, 86 beendet, meine lebt.

Dabei in die eigene Falle getreten: der Weg lag zuerst bei den anderen
/auth-Wegen -- und die stehen mit Absicht VOR der Wache, weil man sich anmelden
koennen muss, ohne angemeldet zu sein. Dort ist c.get('user') leer, und der Weg
antwortete mit "Cannot read properties of undefined". Hono setzt Middleware und
Handler in der Reihenfolge ihrer Anmeldung zusammen; der Pfad sagt darueber
nichts.

VERWALTUNGS-VERLAUF. Der Datei-Verlauf beantwortet "was ist mit den Dateien
passiert". Wer den Discord-Webhook geaendert, einen Token angelegt oder ein
Konto entfernt hat, stand nirgends. Bei zwei Konten verschmerzbar -- sobald ein
drittes ueber die Discord-Rolle von selbst entsteht, ist es die erste Frage.

Eigene Tabelle und nicht events: dort traegt jede Zeile path und size, und eine
Einstellungsaenderung muesste path mit etwas fuellen, das kein Pfad ist.

WERTE STEHEN DORT NIE, nur Schluesselnamen. Unter den Einstellungen liegen das
Discord-Geheimnis und das Nextcloud-Passwort; ein Verlauf, der sie mitschreibt,
macht aus einer Tabelle mit einem Passwort eine Tabelle mit allen, die es je
gab.

Beim Einbau derselbe Fehlertyp wie gestern beim SVG: im SQL-Kommentar standen
Backticks, und das Schema liegt in einem Template-Literal -- ein Backtick
beendet es. Der Uebersetzer hat es gefangen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:43:57 +02:00
D4rkst3randClaude Opus 5 a91c4261de fix: pruneEvents lief nie von selbst -- und eine Liste, was noch ginge
DIE FUNKTION GIBT ES SEIT DEM ERSTEN TAG und sie raeumt Ereignisse aelter als
180 Tage weg. Aufgerufen wurde sie aber nur von /maintenance/prune-sessions --
einem Weg, den nicht einmal die Oberflaeche anbietet. Die dokumentierte
Aufbewahrung griff damit NIE, und die Tabelle wuchs fuer immer.

Zum Vergleich: pruneSessions haengt seit jeher an einem stuendlichen Takt. Das
eine war verdrahtet, das andere nicht, und der Unterschied fiel niemandem auf,
weil beide in derselben Zeile des Wartungswegs stehen.

Gemessen: nach EINEM Tag mit Umzug und Serienlauf standen 5355 Zeilen in
events. Laeuft jetzt taeglich, mit zwei Minuten Verzoegerung nach dem Start --
nicht stuendlich wie die Sitzungen, denn ein DELETE ueber ein halbes Jahr hat
es nicht eilig.

Dazu docs/ideen.md: was beim Bauen und Messen wirklich aufgefallen ist, nach
Dringlichkeit sortiert. Mit einem eigenen Abschnitt fuer das, was gemessen
wieder heruntergefallen ist -- Doppelte (0,0 MB verschwendet), Aufraeumen nach
Alter (alle Stufen auf 0), eine Grenze fuer Bildmasse (12000x12000 kostet 374 ms
und 47 MB, libvips streamt). Damit sie nicht beim naechsten Mal wieder
aufschlagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:21:09 +02:00
D4rkst3randClaude Opus 5 e8efa2431d feat: Favicon, Token bearbeiten, und Hochladen per Adresse
FAVICON. /favicon.ico antwortete mit 200 und text/html -- die SPA-Rueckfall-
route lieferte index.html als Icon aus. Ein leeres Blatt im Tab und 2 KB umsonst
bei jedem Aufruf, nachgemessen bevor es hier steht.

Jetzt ein SVG in den Farben der Oberflaeche (Grund #0b0d10, Akzent #4ea3ff,
dieselbe Plattenform wie in der Kopfzeile), dazu PNG in 32, 180 und 512 -- AUS
DERSELBEN SVG-Datei gerechnet und nicht zweimal gezeichnet. Plus ein
web-manifest, damit das Symbol auf einem Telefon-Startbildschirm stimmt.

Der erste Anlauf war kaputt, und zwar fuer JEDEN Parser: im SVG-Kommentar stand
"--color-grund", und XML verbietet den doppelten Bindestrich in Kommentaren.
Aufgefallen beim Rechnen der PNG, nicht erst im Browser.

TOKEN BEARBEITEN. Kontingent, Groessengrenze, Arten, Ablauf und Praefix liessen
sich nur beim ANLEGEN setzen -- wer einem bestehenden Token nachtraeglich eine
Grenze geben wollte, musste ihn neu anlegen und damit den Schluessel in jedem
Skript tauschen. Fuer eine Zahl in einer Tabelle der falsche Preis.

PATCH /api/dash/tokens/:id, jedes Feld einzeln. WEGGELASSEN heisst UNVERAENDERT,
null heisst ausdruecklich "keine Grenze" -- ohne diesen Unterschied liesse sich
eine einmal gesetzte Grenze nie wieder loeswerden. Der Schluessel selbst bleibt
unberuehrt, und das ist keine Vorsicht: in der Tabelle steht nur sein Hash.

HOCHLADEN PER ADRESSE. Der Dienst holt die Datei selbst. Das ist die
gefaehrlichste Funktion in diesem Dienst und steht deshalb in einer eigenen
Datei (holen.ts) -- ein Server, der eine vom Benutzer genannte Adresse abruft,
ist ein Angriff mit eigenem Namen.

Geprueft wird das Schema, der Anschluss und die AUFGELOESTE IP -- nicht der
Name. Ein Namensfilter waere einer fuer den, der ihn nicht umgehen will. Und
jede Umleitung wird SELBST gelaufen (redirect: 'manual') und neu geprueft;
liesse man fetch folgen, waere genau dort die Luecke.

Durchgemessen am laufenden Dienst, fuenfzehn Faelle:

    Erfolgsfall                                     abgelegt, 131956 B
    127.0.0.1 / localhost / [::1] / 0.0.0.0         abgelehnt
    10.x / 172.20.x / 192.168.x / 169.254.169.254   abgelehnt
    127.0.0.1.nip.io  (oeffentlicher Name, private IP)   abgelehnt
    192.168.2.1.nip.io / 169.254.169.254.nip.io         abgelehnt
    file:// und gopher://                           abgelehnt
    Anschluss 8080 / 9101                           abgelehnt

Die nip.io-Faelle sind der eigentliche Beleg: ein oeffentlich aufloesbarer Name,
der auf eine private Adresse zeigt, ist der Standardweg um einen Namensfilter
herum -- und faellt hier durch, weil die IP geprueft wird.

Nicht abschliessend geprueft: eine Umleitung, die ins Private zeigt. Mir fehlt
ein oeffentlicher Umleiter, der das tut (httpbingo lehnt es mit 403 ab). Der
Code laeuft die Kette selbst und ruft je Sprung dieselbe Pruefung -- belegt ist
also die Pruefung, nicht die Kette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:18:27 +02:00
D4rkst3randClaude Opus 5 463a821199 feat: der Dienst heisst media.d4rkst3r.de -- beide Namen liefern weiter aus
PUBLIC_URL umgestellt. FILES_HOST ist leer, der Dienst achtet also gar nicht
auf den Hostnamen, und in NPM stehen beide auf DEMSELBEN Host -- die drei
^~-Bloecke gelten damit fuer beide. Nachgemessen: unter beiden Namen health 200
und dasselbe Bild, bytegleich.

Nachgezogen:

    7 Stellen in den Ressourcen   d4rk_phone/config.lua (2)
                                  d4rk_photostudio/config.upload.lua
                                  d4rk_photostudio/config.upload.example.lua (2)
                                  d4rk_photostudio/settings.json (2)
    2 Watchdog-Eintraege im Bot   inklusive Anzeigename
   16 Stellen in Doku/Werkzeugen  README, docs/API.md, Home.md,
                                  npm-advanced.conf, proxy-pruefen.ps1,
                                  uebernehmen.ps1, .env.example

Die Freigabe-Links zeigen von selbst auf den neuen Namen -- sie werden aus
PUBLIC_URL gebaut und stehen nirgends gespeichert. Die alten funktionieren
weiter.

DIE ROADMAP BEHAELT DEN ALTEN NAMEN, an sechzehn Stellen und mit Absicht: dort
steht die Geschichte, warum er ueberhaupt "fivemanage" hiess. Sie zu ersetzen
machte aus einer Begruendung Unsinn.

ZWEI DINGE, DIE DABEI AUFFIELEN.

Cloudflare stand vor dem neuen Namen und brach die WebP-Auslieferung: der
Cache-Schluessel kennt Accept nicht, also bekam ein Aufrufer OHNE
"Accept: image/webp" die WebP-Fassung aus dem Zwischenspeicher -- unsere
eigenen Bytes unter einem .png-Namen. Nach dem Umstellen auf DNS-only gemessen
und in Ordnung: ohne Accept image/png 12295 B, mit Accept image/webp 2270 B,
Server: openresty, vary: Accept, dreimal hintereinander stabil.

Und der Lua-Syntaxtest schlug bei d4rk_phone/config.lua fehl -- an Zeile 93,
`customModel = \`prop_player_phone_02\``. Das ist kein Schaden, sondern eine
CfxLua-Erweiterung: Backticks sind dort ein joaat-Hash und in Standard-Lua 5.4
ein Syntaxfehler. 85 davon stecken in der Datei. luac -p taugt fuer
CfxLua-Dateien also nicht; geprueft wurde stattdessen, dass genau die zwei
URL-Zeilen anders sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:02:28 +02:00
D4rkst3randClaude Opus 5 72fd330ae6 feat: der Dienst nennt sich selbst -- und Cloudflare bricht die WebP-Auslieferung
DER NAME KAM VIERMAL FEST AUS DEM QUELLTEXT: Kopfzeile, Anmeldeseite, Fusszeile
einer Freigabe, Tab-Titel -- und zwar als "d4rk_media", was der Name des
Repositorys ist und nicht der des Dienstes. Wer ihn aendern wollte, musste vier
Stellen finden und eine davon uebersehen.

Er kommt jetzt vom Server, aus der Einstellung marke.name -- DERSELBEN, die
schon in der Fusszeile der Discord-Meldungen steht ("D4RKST3R // MEDIA"). Ein
zweiter Name fuer die Oberflaeche waere ein zweiter, der beim naechsten Mal
anders lautet als der erste.

Ausgeliefert wird er ueber /api/dash/auth/config (oeffentlich, die
Anmeldeseite hat noch keine Sitzung) und in der Freigabe-Antwort (die Seite ist
oeffentlich und holt sonst nichts, was ihn kennen koennte). Angezeigt wird
"<marke> Media" -- die Marke allein waere falsch, sie steht ueber allem, was
hier laeuft.

EINE STELLE BLEIBT ABSICHTLICH: der Schluessel im Speicher des Browsers
(merker.ts) heisst weiter "d4rk_media.". Wer ihn umbenennt, wirft still alle
gemerkten Ansichtseinstellungen weg, und niemand versteht, warum die Galerie
ploetzlich wieder bei 60 Kacheln steht. Steht als Kommentar daneben.

---

UND DER NEUE NAME HAENGT HINTER CLOUDFLARE, was die WebP-Auslieferung bricht.

NPM ist sauber, sogar besser als vorgeschlagen: beide Namen auf DEMSELBEN Host,
die drei ^~-Bloecke gelten damit fuer beide. Der DNS-Eintrag geht aber ueber
Cloudflare (188.114.97.3, Server: cloudflare), waehrend fivemanage direkt auf
88.218.224.10 zeigt.

Gemessen, dieselbe Datei:

    fivemanage (direkt)      ohne Accept  ->  image/png   12295 B   richtig
                              mit Accept  ->  image/webp   2270 B   richtig

    media (Cloudflare)     1. mit Accept  ->  image/webp   2270 B   MISS
                           2. mit Accept  ->  image/webp   2270 B   HIT
                           3. OHNE Accept ->  image/webp   2270 B   HIT   FALSCH

Und andersherum genauso -- wer zuerst kommt, praegt den Zwischenspeicher fuer
alle. Es sind unsere eigenen Bytes (2270 = unsere WebP-Fassung), also kein
Polish, sondern ein Cache-Schluessel ohne Accept. Vary: Accept wird an den
Client durchgereicht, aber nicht zum Unterscheiden benutzt.

Fuer ein Lua-Skript heisst das: items/foo.png liefert WebP-Bytes unter einem
.png-Namen, und das faellt erst auf, wenn irgendwo ein Bild nicht laedt.

PUBLIC_URL ist deshalb NICHT umgestellt. Genau davor stand die Warnung im
letzten Commit, und sie war keine Vorsicht, sondern eine Vorhersage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:43:29 +02:00
D4rkst3randClaude Opus 5 cf2e3d2387 docs: der Name wird media.d4rkst3r.de -- entschieden, mit gemessenem Preisschild
Der alte Name bleibt daneben stehen und zeigt auf denselben Dienst. Kein
Umleiten, kein Stichtag: beide Namen funktionieren, und die Skripte lassen sich
in Ruhe umstellen statt an einem Abend alle gleichzeitig.

Moeglich ist das, weil FILES_HOST leer ist -- der Dienst achtet gar nicht auf
den Hostnamen. Nachgemessen: GET /health mit "Host: media.d4rkst3r.de" an Port
9101 gibt bereits 200.

WAS ES KOSTET, gemessen statt geschaetzt. In diesem Dienst: EINE Variable. Von
4630 Medienpfaden und 15 Einstellungen enthaelt keine einzige den Hostnamen --
die ROADMAP behauptet das seit Monaten, jetzt ist es geprueft.

Ausserhalb, vollstaendig durchsucht:

    d4rk_phone/config.lua                 imageUrl, brandLogoUrl
    d4rk_photostudio/config.upload.lua    base   (+ .example)
    d4rk_photostudio/settings.json        zwei Bildquellen
    d4rkbot, ueberwachte Dienste          zwei Eintraege
    Freigabe-Links                        zwei, bereits herausgegeben

UND ZWEI FALLSTRICKE IM DNS, beide nachgemessen:

Der Platzhalter *.d4rkst3r.de hilft nicht -- er zeigt auf 185.194.237.210 und
damit woandershin als fivemanage (88.218.224.10). Es braucht einen eigenen
A-Eintrag.

Und der neue Name gehoert NICHT hinter Cloudflare: hub.d4rkst3r.de antwortet
von 188.114.96.3 (Cloudflare), fivemanage direkt vom Server. Ginge media ueber
Cloudflare, staende ein ZWEITER Zwischenspeicher davor, der nach Dateiendung
greift -- und wir haetten genau die Probleme wieder, die der NPM-Block gerade
geloest hat: alte Vorschauen und ein weggeworfenes Vary: Accept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:35:47 +02:00
D4rkst3randClaude Opus 5 3f706b27e1 feat: /status fuer die Statusseite -- und der Fehler, den es dabei gefunden hat
/health gab es und bleibt, wie es ist: app.get('/health', c => c.json({ok:true})).
Diese Zeile beweist genau eines -- der Prozess nimmt Anfragen an. Daran haengt
der HEALTHCHECK des Containers, und DORT ist billig richtig: eine schwere
Pruefung, die bei einer langsamen Platte einmal ausfaellt, liesse Docker den
Container neu starten, also genau dann, wenn er unter Last steht.

Fuer eine Statusseite ist das zu duenn -- sie stuende auf Gruen, waehrend die
Platte voll ist und kein Upload mehr angenommen wird.

/status sieht deshalb wirklich nach: Datenbank (eine echte Abfrage, nicht "die
Datei ist da"), Platte (schreiben UND wieder loeschen, der einzige Beweis),
Ausliefern (eine zufaellige Datei aus der Datenbank auf der Platte nachmessen),
Bestand, Platz und Sicherung. 200 wenn der Dienst sein Geschaeft tut, 503 wenn
nicht; ?streng=1 laesst auch eine Beeintraechtigung rot werden -- WELCHES von
beiden richtig ist, weiss nur, wer die Statusseite betreibt.

Oeffentlich, aber wortkarg: keine Dateizahlen, Groessen, Pfade, Tokennamen,
Benutzer. Und mit einer Zehn-Sekunden-Bremse -- ein oeffentlicher Endpunkt, der
auf die Platte schreibt, waere sonst ein Verstaerker.

UND DABEI FIEL EIN FEHLER IN MEINEM EIGENEN ZURUECKSPIEL-SKRIPT AUF.

Der harte Weg sollte an einem Wegwerf-Container geprueft werden. Der traf
zufaellig auf ein GEBRAUCHTES Volume, und die Zahlen waren eindeutig:

    media.db aus dem Archiv, mit altem WAL daneben :     0 Zeilen
    dieselbe Datei ohne die beiden Begleiter       :  4452 Zeilen
    Dateien auf der Platte                        :  4452

zurueckspielen.ps1 entfernte media.db, aber NICHT media.db-wal und
media.db-shm. Die liegen bei einem echten Zurueckspielen immer da -- der
laufende Dienst arbeitet im WAL-Modus. SQLite spielt das WAL der ALTEN
Datenbank ueber die NEUE, und heraus kommt der schlimmste denkbare Zustand: der
Dienst kommt hoch, /health ist gruen, die Mediathek ist leer, waehrend alle
Dateien danebenliegen.

Drei Konsequenzen:

1. Die Aufraeumzeile steht jetzt an EINER Stelle und nimmt media.db-wal,
   media.db-shm und *.tmp mit. Uebung und Ernstfall fahren denselben Befehl --
   zwei Fassungen waeren zwei, von denen die geuebte die harmlosere ist.

2. Die Uebung TAEUSCHT JETZT EINE BESTEHENDE INSTALLATION VOR, bevor sie
   zurueckspielt: Container starten, warten bis media.db-wal daliegt, stoppen,
   und erst dann einspielen. In ein leeres Volume zu spielen probt den Fall,
   der nie eintritt.

3. /status erkennt den Zustand selbst -- "kein Eintrag in der Datenbank, aber
   Dateien auf der Platte". Am kaputten Container gemessen:

       /health sagt:  HTTP 200
       /status sagt:  HTTP 503

Der geuebte Lauf danach, ueber eine vorgetaeuschte Installation:

    im Volume liegt jetzt: files media.db media.db-shm media.db-wal
    4452 Medieneintraege, 4452 Dateien -- gleich viele
    ok  items/shushi.png · items/weedbud_1.png · items/cc-castella.png
    Die Uebung ist bestanden.

Gefunden beim Ueben und nicht im Ernstfall. Genau dafuer gibt es sie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:04:26 +02:00
D4rkst3randClaude Opus 5 338f8e59cf feat: die Sicherung laesst sich jetzt zurueckspielen -- und wurde es auch
Bis hierhin war sie GEPRUEFT, nicht ERPROBT. sichern.ps1 packt sein Archiv aus,
oeffnet die Datenbank, zaehlt, laedt hoch, holt zurueck und vergleicht die
Pruefsumme. Das beantwortet "ist die Datei heil?".

Es beantwortet nicht die Frage, auf die es ankommt: "kommt daraus wieder ein
laufender Dienst?" Dazwischen liegen Handgriffe, die man sonst zum ersten Mal
an dem Abend macht, an dem man sie unter Druck macht -- wie das Volume heisst,
dass der Container stehen muss, wem die Dateien gehoeren muessen, dass thumbs/
und webp/ abgeleitet sind und neu gerechnet werden wollen.

Ein Backup, das man nie zurueckgespielt hat, ist kein Backup, sondern eine
Datei.

DIE UEBUNG IST DER STANDARD, und sie ist der eigentliche Gewinn: Wegwerf-
Volume anlegen, Archiv hineinspielen, einen ZWEITEN Container davorhaengen
(ohne veroeffentlichten Port -- der Uebungslauf soll sich mit nichts anlegen,
was schon laeuft), auf /health warten, nachzaehlen, und dann der Teil, auf den
es ankommt: drei zufaellige Dateien DURCH den Dienst holen und ihr SHA-256
gegen den vergleichen, der in der Datenbank steht. Danach alles abraeumen, im
finally -- ein liegengebliebenes Uebungs-Volume mit 300 MB findet sonst niemand
wieder.

Gelaufen, nicht behauptet:

    4452 Medieneintraege, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
    4452 Dateien -- gleich viele wie Eintraege
    ok  items/velum2.png                  113567 B
    ok  items/flesh-dolphin-2star.png       3623 B
    ok  items/burgermeat.png                4269 B
    Die Uebung ist bestanden.

Der echte Dienst lief die ganze Zeit weiter und war danach unveraendert
(health 200, dasselbe Volume, 4542 Dateien).

DER ERNSTFALL verlangt ein getipptes ZURUECKSPIELEN und sagt vorher, was
passiert. Er loescht thumbs/ und webp/ mit: Abgeleitetes stehenzulassen waere
schlimmer als es neu zu rechnen -- die Galerie zeigte sonst die Vorschau einer
Datei, die dort nicht mehr liegt. Loeschen und Einspielen laufen in EINEM
Container-Aufruf, damit dazwischen niemand den Dienst wieder startet. Geht
etwas schief, sagt das Skript, dass der Container steht, und wie man ihn
startet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:50:46 +02:00
D4rkst3randClaude Opus 5 7877c0f7b9 docs: der Proxy-Block sitzt -- gemessen, nicht geglaubt
Eingetragen und gespeichert. tools/proxy-pruefen.ps1 sagt jetzt fuenf von
fuenf:

    [ok]  /f/  Cache-Control                        public, max-age=300
    [ok]  /t/  Cache-Control                        public, max-age=60
    [ok]  /api/ mit Token -> 200                    HTTP 200
    [ok]  /api/ dieselbe Adresse ohne Token -> 401  HTTP 401
    [ok]  /api/exists?pfad ohne Token -> 401        HTTP 401

Und die Folgen, die daran hingen, einzeln gegengeprueft:

    /api/exists/…webp ohne Token     200 mit voller Auskunft  ->  401
    dieselbe Adresse MIT Token       401 (zwischengespeichert) ->  200
    Vorschau nach erneutem Upload    bis zu 4 h alt            ->  max-age=60
    Vary auf /f/                     wurde entfernt            ->  vary: Accept

Die WebP-Aushandlung am selben Bild: ohne Accept image/png mit 12295 B, mit
Accept: image/webp dann image/webp mit 2270 B. Gleiche Adresse, 82 Prozent
weniger -- das ging vorher nur deshalb, weil /f/ als einziger Pfad schon
ausgenommen war.

Was bleibt: /api/exists?pfad=… ist weiterhin die empfohlene Form. Sie ist
dicht, OHNE von dieser Proxy-Einstellung abzuhaengen -- eine Zugangspruefung,
die an einer Zeile in einer fremden Oberflaeche haengt, ist keine. Der Fall hat
gerade gezeigt, wie schnell so eine Zeile fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:41:51 +02:00
D4rkst3randClaude Opus 5 914f2fb854 docs: der NPM-Block zum Einfuegen, und ein Skript, das sagt ob er sitzt
docs/npm-advanced.conf enthaelt genau das, was in NPM unter Advanced ->
Custom Nginx Configuration gehoert: die drei ^~-Bloecke fuer /f/, /t/ und
/api/, samt Begruendung als Kommentar. Die ganze Datei ist einfuegbar --
nginx kennt '#'. Syntax gegengeprueft mit `nginx -t` in einem
Wegwerf-Container, und zwar mit DIESEM Dateiinhalt und nicht mit einer
abgetippten Kurzfassung.

/s/ steht bewusst NICHT drin: die Schluessel bestehen aus einem festen
Alphabet ohne Punkt, eine Freigabe-Adresse endet also nie auf eine
Bildendung und faellt gar nicht erst in die assets.conf-Regel. /assets/
ebenfalls nicht -- die Dateinamen der gebauten Oberflaeche tragen einen Hash
und aendern sich nie unter demselben Namen, dort ist der Zwischenspeicher
richtig.

tools/proxy-pruefen.ps1 misst danach, ob es gewirkt hat, und sagt sonst, was
zu tun ist. Beim Schreiben hat es gleich eine SCHWAECHE IN SICH SELBST
gezeigt: die erste Fassung prueft "/api/ ohne Token -> 401" und meldete
Erfolg -- der 401 war aber nur ein zwischengespeicherter, den der Proxy an
alle ausliefert. Gemessen bekam ihn sogar ein Aufrufer MIT gueltigem Token.

Verlaesslich ist nur das Paar auf einer FRISCHEN, zufaelligen Adresse:

    GET /api/exists/probe-xyz.webp   mit Token    -> 200
    GET /api/exists/probe-xyz.webp   ohne Token   -> 401  gut
                                                  -> 200  Block fehlt

Aktueller Stand, gemessen:

    [ok]  /f/  Cache-Control                     public, max-age=300
    [!!]  /t/  Cache-Control                     max-age=10608
    [ok]  /api/ mit Token -> 200                 HTTP 200
    [!!]  /api/ dieselbe Adresse ohne Token      HTTP 200 -- der Proxy gibt sie weiter
    [ok]  /api/exists?pfad ohne Token -> 401     HTTP 401

Die letzte Zeile ist der Grund, warum die Abfrageform gebaut wurde: sie ist
dicht, ohne von dieser Proxy-Einstellung abzuhaengen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:34:02 +02:00
D4rkst3randClaude Opus 5 458775f9d9 feat: Konten sichtbar, Kontingent je Token, Plattenwaechter, Umbenennen im Verlauf
KONTEN SICHTBAR. Es gab zwei -- "admin" und eines aus Discord -- und KEINE
Stelle, an der man sie sehen konnte. Weil jeder mit der Rolle auf dem
Discord-Server beim ersten Anmelden still ein Konto bekommt, waeren aus zwei
unbemerkt zwanzig geworden: man erfaehrt es nicht, und wegnehmen ging auch
nicht.

Jetzt eine Liste mit Weg (Discord/Passwort), angelegt, zuletzt da und offenen
Sitzungen. Die Spalte "Weg" ist dabei die wichtigere Auskunft als der Name: ein
Discord-Konto haengt an einer Rolle, die jemand anders vergibt.

Zwei Sperren, beide mit Grund: das EIGENE Konto laesst sich hier nicht
entfernen -- das ist keine Bevormundung, sondern die Vermeidung des einen
Klicks, nach dem niemand mehr hereinkommt -- und das LETZTE auch nicht. Die
Rueckfrage sagt bei einem Discord-Konto dazu, dass die naechste Anmeldung ein
neues anlegt, solange die Rolle bleibt; sonst haelt jemand das Entfernen fuer
eine Aussperrung.

"zuletzt da" wird hoechstens einmal je Stunde geschrieben. Bei jedem Aufruf
waere es ein UPDATE je Anfrage, und das Dashboard stellt beim Blaettern durch
3600 Bilder eine Menge davon.

KONTINGENT JE TOKEN. max_bytes gab es laengst, aber es galt JE DATEI: ein Token
mit "hoechstens 2 MB je Bild" konnte trotzdem die Platte fuellen, es brauchte
nur genug Bilder. quota_bytes gilt fuer alles zusammen, gezaehlt ueber
media.token_id -- also ueber das, was wirklich liegt, statt ueber einen
mitlaufenden Zaehler, der auseinanderlaufen kann.

Auf BEIDEN Wegen geprueft, eigene API und Fivemanage-Weg: eine Grenze, die nur
an einer von zwei Tueren haengt, ist keine.

Die erste Fassung meldete "haelt 0.0 MB von 0.0 MB, und diese Datei braucht 0.0
MB" -- formal richtig, praktisch die Meldung, die genau die Frage nicht
beantwortet, wegen der man sie liest. Die Einheit waechst jetzt mit: "haelt 12
KB von 20 KB, und diese Datei braucht 12 KB."

PLATTENWAECHTER. Meldet bei 80, 90 und 95 Prozent, je Stufe genau einmal; wird
wieder Platz frei, meldet der naechste Engpass erneut. Der Grund steht in der
Meldung: eine volle Platte ist der eine Zustand, in dem gleichzeitig nichts mehr
hereinkommt UND die Sicherung nicht mehr schreiben kann.

Gemessen, und die Abweichung zu df ist gewollt:

    gesamt         1006,9 GB
    frei (bavail)   909,5 GB  ->  9,7 % belegt   <- was der Waechter sieht
    frei (bfree)    960,7 GB  ->  4,6 % belegt
    df sagt                        5 %

bavail zieht die ext4-Reserve ab, an die der Dienst als Nicht-root ohnehin nicht
herankommt. Er warnt damit etwas frueher -- die richtige Richtung. Wer spaeter
den Unterschied zu df sucht, findet ihn als Kommentar an Ort und Stelle.

Dabei fiel ein SCHLAFENDER FEHLER auf: der Merker gegen Wiederholungen lag unter
EINEM Schluessel fuer alle Wachhunde. Mit zwei davon haetten sie sich
gegenseitig ueberschrieben -- einer meldet, der andere haelt daraufhin seinen
eigenen Zustand fuer neu und meldet auch, oder schweigt, obwohl sich etwas
geaendert hat. Der Merker haengt jetzt am Anlass.

UMBENENNEN IM VERLAUF. Stand dort als "verschoben". Technisch stimmt das, fuer
den Leser nicht: verschoben heisst "liegt woanders", umbenannt heisst "heisst
anders, liegt noch da" -- und genau diesen Unterschied schlaegt man im Verlauf
nach. Eigene Art mit eigenem Zeichen. Die Filterleiste kennt jetzt auch move und
rename, die vorher gar nicht filterbar waren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:26:22 +02:00
D4rkst3randClaude Opus 5 06e924daf5 fix: die Sicherung liess den Papierkorb aus -- ein Versprechen, das sie nicht halten konnte
GEFUNDEN BEIM NACHSEHEN, nicht beim Suchen: das Sicherungsskript packte

    tar czf ... -C /data files -C /data/.sicherung media.db

also die Bilder und die Datenbank. Die EINTRAEGE des Papierkorbs stehen in
media.db und waren damit laengst gesichert -- die DATEIEN lagen unter
/data/papierkorb und waren es nicht.

Nach einem Zurueckspielen haette der Papierkorb also Zeilen gezeigt, deren
"zurueckholen" ins Leere greift: die Oberflaeche verspricht etwas, das die
Platte nicht hergibt. Genau die Sorte stiller Abweichung, wegen der dieses
Projekt Sicherungen ueberhaupt gegenprueft.

Er ist jetzt im Archiv, und er kostet fast nichts: was darin liegt, ist
hoechstens 30 Tage alt, danach raeumt der Dienst selbst auf. Die Abfrage
`if [ -d /data/papierkorb ]` davor ist noetig -- hat noch nie jemand etwas
geloescht, gibt es den Ordner gar nicht, und tar braeche mit "Cannot stat" ab.
Ein `mkdir` als Ausweg ginge nicht: das Volume haengt nur lesend.

Gemessen an einem echten Lauf, nicht angenommen:

    d4rk_media-2026-08-11-2307.tar.gz -- 311,9 MB
    in der Sicherung: 4452 Medieneintraege, 4452 Dateien, 2 Token, 2 Benutzer
    hochgeladen (201), zurueckgeholt und verglichen: identisch
    Inhalt:  4459 files/   1 media.db   1 papierkorb/

UND DER PAPIERKORB SAGT ES JETZT SELBST. Die Liste prueft je Zeile, ob die
Datei wirklich noch daliegt (hoechstens 500 stat-Aufrufe), und zeigt fehlende
durchgestrichen samt Erklaerung. Die Sicherung nimmt ihn zwar inzwischen mit --
aber ein Papierkorb, der etwas verspricht, muss es halten koennen, und wenn
nicht, soll er es SAGEN statt es beim Druecken herauszufinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:11:38 +02:00
D4rkst3randClaude Opus 5 92b49a59a5 fix: /api/exists gab ohne Token Auskunft -- der Proxy hatte sie zwischengespeichert
GEMESSEN, nicht vermutet: ein GET auf

    https://fivemanage.d4rkst3r.de/api/exists/vehicles/adder.webp

antwortet OHNE Authorization-Kopf mit 200 und voller Auskunft -- Pfad, Groesse,
SHA-256, Zeitpunkt. Nicht weil die Pruefung fehlt, sondern weil Nginx Proxy
Manager die berechtigte Antwort weggelegt hat und sie danach an jeden ausliefert.

Der Grund steht in dessen assets.conf und greift auf JEDE URL mit Bildendung:

    location ~* ^.*\.(css|js|jpe?g|gif|png|webp|...)$ {
        proxy_cache public-cache;
        proxy_cache_key $host$request_uri;          <- ohne Authorization
        proxy_ignore_headers Set-Cookie Cache-Control Expires ...;
        proxy_cache_valid any 30m;
    }

Unser eigenes Cache-Control hilft nicht, proxy_ignore_headers wirft es weg. Und
es geht auch andersherum: landet zuerst ein 401 im Zwischenspeicher, bekommen
ihn 30 Minuten lang alle -- auch die mit gueltigem Token.

Der Block "location ^~ /api/ { proxy_cache off; }" schliesst das. Aber eine
ZUGANGSPRUEFUNG, DIE AN EINER ZEILE IN EINER FREMDEN OBERFLAECHE HAENGT, IST
KEINE: sie ueberlebt kein Neuanlegen des Proxy-Hosts und keinen Umzug.

Deshalb eine zweite Form, die von sich aus dicht ist:

    GET /api/exists?pfad=vehicles/adder.webp

Die Abfrage zaehlt fuer die location-Regel nicht mit, die URL endet also nie auf
eine Bildendung. Gemessen: mit Token 200, ohne Token 401, und KEINE
Cache-Control-Kopfzeile vom Proxy -- die Regel greift nicht.

Die alte Form bleibt, weil das Fotostudio sie benutzt (server/upload.lua:185).
Dokumentation und die API-Seite im Dashboard nennen ab jetzt nur noch die neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:00:27 +02:00
D4rkst3randClaude Opus 5 44ad7dc6b3 feat: Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
UMBENENNEN gab es gar nicht -- verschieben ja, umbenennen nirgends. Jetzt fuer
Datei und Ordner. Die WARNUNG ist dabei der eigentliche Teil: beim Verschieben
wandert eine Datei, beim Umbenennen aendert sich IHRE ADRESSE, und die steht
womoeglich in einem Skript, das niemand mehr im Kopf hat. Der Dialog zeigt
deshalb die Abrufzahl und beim Ordner die Zahl der betroffenen Dateien, BEVOR
gedrueckt wird -- eine Datei mit viertausend Abrufen umzubenennen ist etwas
anderes als eine mit null, und der Dienst ist die einzige Stelle, die den
Unterschied kennt.

Am echten Fall geprueft, dem Kollisionsfund: items/coiloverss.png (hielt die
+-Fassung) -> items/coiloverss-plus.png, alte Adresse 404, neue 200 mit 4375 B,
Vorschau mitgewandert. Ordner: probe -> beispiele, 5 Dateien, alle Pfade in
einer Transaktion umgeschrieben.

Ein Fallstrick dabei: thumbPath haengt ".webp" an. Fuer eine Datei richtig, fuer
einen ORDNER Unsinn -- der Vorschauordner heisst thumbs/vehicles und nicht
thumbs/vehicles.webp. Dafuer gibt es jetzt moveFolder.

DIE 93 KOLLISIONEN SIND ERLEDIGT. tools/kollisionen.ps1 rechnet dieselbe
Zaehmung auf der Quelle nach und zeigt, was zusammenfaellt -- es aendert nichts,
damit die Entscheidung auf Zahlen steht. Ergebnis: 91 von 93 sind dieselbe Datei
zweimal (WEAPON_SMG.png und weapon_smg.png, byteweise gleich gross), da fehlte
nichts. Echt verloren waren ZWEI, beide mit + im Namen. Beide nachgeholt.

FIVEMANAGE-SPRACHE. POST /api/image|video|audio (v1) und POST /api/v3/file (v3),
Schluessel nackt im Authorization-Kopf. Auf einem laufenden Server stecken die
Fivemanage-Aufrufe in einem Dutzend fremder Ressourcen; sie alle umzuschreiben
tut niemand, und deshalb bliebe dieser Dienst ungenutzt daneben stehen. So ist
der Umzug eine Zeile je Skript: die Adresse.

Beide Formen sind AUS ECHTEM CODE abgelesen und nicht geraten -- fivemanage/sdk
fuer v3, Awleks/Devm-Camera fuer den aelteren Weg ueber screenshot-basic.
Gemessen: v1 -> {url,id,path}, v3 -> {status:"ok",data:{id,url}}, ohne
Schluessel 401, ein Video an /api/image -> 415 mit Grund.

Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
warnt: die Token-Wache hing an use('*'), und der Einhaengepunkt ist /api -- also
galt sie auch fuer /api/dash daneben. Das Dashboard bekam 401 auf die ANMELDUNG.
Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
schon einmal passiert ist. Gemerkt hat es der Gegentest, nicht der Kopf.

FREIGABE-LINKS. /s/<schluessel> zeigt einen Ordner ohne Anmeldung. Was dabei
ausdruecklich dabeisteht, in der Karte und in der Rueckfrage vor dem
Zurueckziehen: FREIGEGEBEN WIRD DIE LISTE, NICHT DER INHALT. Die Dateien sind
ohnehin oeffentlich; ein zurueckgezogener Link macht sie nicht wieder privat, er
nimmt nur die Uebersicht weg. Ohne diesen Satz zieht jemand einen Link zurueck
und glaubt, etwas sei verschwunden.

Die Antwort ist abgemessen: Name, Groesse, Art, Adresse, Laenge. NICHT Hash,
Token, Zeitpunkte, Abrufzahlen, IDs -- nichts davon braucht, wer einen Katalog
ansieht, und jedes davon waere eine Auskunft ueber den Betrieb. Ein unbekannter
Schluessel und ein zurueckgezogener geben dieselbe Antwort.

ZAHLEN, DIE SCHON DA WAREN. Sortieren nach Abrufen und nach "zuletzt geholt";
die Kachel zeigt dann auch diese Zahl statt Groesse und Datum, denn nach etwas
zu ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begruendung. Und je
Token, was damit abgelegt wurde: media.token_id wird seit dem ersten Tag
geschrieben und war NIRGENDS zu sehen. Gemessen: d4rk_photostudio haelt 810
Dateien / 99,5 MB, 3640 liegen ohne Token da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:53:22 +02:00
D4rkst3randClaude Opus 5 d125c6bf72 feat: Vorschau fuer Video und Ton, Ordner anlegen dort, wo man es sucht
Mit den ersten echten Clips fiel auf, was bei 3600 Bildern nie auffiel: alles,
was kein Bild ist, bekam ein FRAGEZEICHEN auf die Kachel -- ausgerechnet das
Zeichen fuer "unbekannt" auf einer voellig gesunden MP4.

Jetzt: ein Standbild aus dem Clip (ein Zehntel hinein, weil viele mit Schwarz
oder einem Einblender beginnen und eine schwarze Kachel so aussagekraeftig ist
wie gar keine), bei Ton das eingebettete Titelbild, dazu ein Abspielzeichen und
die LAUFZEIT in der Ecke. Fehlt eine Vorschau, kommt ein Zeichen, das die ART
benennt, statt eines Fragezeichens.

DER PREIS IST FFMPEG IM ABBILD, und der wurde zweimal gemessen und zweimal
gedrueckt:

    vorher                                 460 MB
    apt-get install ffmpeg                1,11 GB
    statisches ffmpeg + ffprobe            835 MB
    statisches ffmpeg allein               648 MB

Debians Paket schaltet jeden Filter frei und zieht die Abhaengigkeiten mit:
libllvm15 (114 MB), Mesa samt libgl1-mesa-dri, libz3 und libflite1 -- ein
SPRACHSYNTHESIZER. Alles fuer Filter, die ein Dienst ohne Bildschirm nie
anfasst; --no-install-recommends half nicht, es sind harte Abhaengigkeiten.

ffprobe flog danach auch noch raus: 135 MB fuer eine einzige Aufgabe, die
Laenge zu nennen -- die steht auch im Kopf, den ffmpeg beim Oeffnen druckt.
Gegengeprueft an beiden Dateien, auf zwei Nachkommastellen identisch:

    ffprobe  29.767967   |  ffmpeg  Duration: 00:00:29.77
    ffprobe 645.642449   |  ffmpeg  Duration: 00:10:45.64

Der Preis dafuer ist ebenfalls zu benennen: die Fassung haengt jetzt an einer
Zeile im Dockerfile und nicht mehr an apt. Vertretbar, weil ffmpeg hier nur
eigene, bereits abgelegte Dateien anfasst und nicht am Netz haengt.

EIN FEHLER, DER ZWEIMAL DERSELBE WAR. "Keine Bildspur" ist bei Ton der
Normalfall -- die meisten MP3 tragen kein Titelbild. Der erste Anlauf erkannte
das am WORTLAUT der Fehlermeldung, und der ist zwischen den Fassungen anders:

    ffmpeg 5.1  "Stream map '0:v:0' matches no streams."
    ffmpeg 7.1  "Failed to set value '0:v:0' for option 'map'"

Ergebnis: eine gesunde MP3 wurde als "1 fehlgeschlagen" gezaehlt, und dann
sucht jemand einen Fehler, den es nicht gibt. Gelesen wird jetzt die
SPURENLISTE ("Stream #0:1: Video:"), die seit jeher so dasteht -- und die kommt
aus demselben Aufruf wie die Laenge, also ein Blick in die Datei statt drei.
Gemessen: fehler 0, ohneBild 1.

Beide Wege durchgeprueft. Video: 320x180, Durchschnittsfarbe RGB(97,140,155) --
echtes Bild und kein schwarzer Vorspann. Ton mit angehaengtem Titelbild:
Vorschau erzeugt, dauer 8.05, Kachel zeigt 0:08.

UND ZWEI ORDNER-LUECKEN, beide beim Benutzen gefunden.

"Wie lege ich neue Ordner an?" -- gar nicht, ausser man wusste es: der Knopf
sass NUR im Verschieben-Dialog. Wer einen leeren Ordner wollte, musste erst
Dateien auswaehlen und "Verschieben" druecken. Eine Funktion, die man nur ueber
eine andere Funktion erreicht, ist fuer den, der sie sucht, nicht vorhanden.
Jetzt steht sie unter dem Ordnerbaum, mit der Angabe, IN welchem Ordner sie
entsteht -- sonst tippt jemand "waffen", landet in "items/waffen" und sucht
danach an der Wurzel.

Zielordner beim Hochladen: vorher galt stillschweigend der in der Galerie
angeklickte. Richtig, solange man es weiss, und ein Fallstrick, sobald man es
nicht weiss -- wer nach einer Suche hochlaedt, steht in "Alle" und legt an der
Wurzel ab. Der angeklickte bleibt die Voreinstellung, ist jetzt aber sichtbar
und aenderbar, samt "neuer Ordner" direkt daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:22:39 +02:00
D4rkst3randClaude Opus 5 7b35ef5698 feat: Betrachter fuer Lesbares, und aus dem <video> wird ein Player
Bild, Video, Ton und PDF konnte der Betrachter schon. Was fehlte, war
ausgerechnet die Art, die man am ehesten nur lesen und nicht herunterladen
will: eine .md landete im Zweig "laesst sich hier nicht anzeigen".

LESBARES. Markdown gesetzt, .lua und .json eingefaerbt, CSV als Tabelle, alles
Uebrige mit Zeilennummern. Der Tokenizer ist DERSELBE wie fuer die Schnipsel
auf der API-Seite und nicht ein zweiter -- zwei waeren zwei, die auseinander
laufen. Die Zeilennummern liegen in einer eigenen Spalte: so nimmt ein
Markieren mit der Maus sie nicht mit.

Die CSV-Zerlegung kann Anfuehrungszeichen und doppelte darin. Das ist der Teil,
den ein split(';') falsch macht, und CSV aus Excel hat ihn regelmaessig; das
Trennzeichen wird aus der Kopfzeile geraten.

NICHT ALLES WIRD GEHOLT. Die Groesse steht in der Datenbank, also wissen wir
vorher, worauf wir uns einlassen: ueber 512 KB kommt per Bereichsanfrage nur
der Anfang, und das steht auch da. Ein Betrachter, der bei einem 400-MB-
Protokoll den Tab abschiesst, ist schlimmer als einer, der die Datei gar nicht
erst oeffnet. Gemessen: Range: bytes=0-49 -> 206, 50 Bytes.

MARKDOWN OHNE BIBLIOTHEK, und das ist kein Geiz. Der uebliche Weg heisst marked
plus DOMPurify und endet bei dangerouslySetInnerHTML -- damit haengt die
Sicherheit dieser Seite an der Frage, ob die Filterliste vollstaendig ist. Hier
entsteht NIE eine HTML-Zeichenkette: der Text wird zu React-Knoten, und ein
<script> in einer hochgeladenen .md erscheint als die acht Zeichen, die es ist.
Im ganzen ui/ steht kein einziges dangerouslySetInnerHTML.

Die eine Luecke, die React nicht schliesst, ist href: [klick](javascript:...)
kaeme durch. Zwoelf Faelle in Node durchgeprueft, alle bestanden -- http,
https, mailto und relative Ziele durch; javascript:, JavaScript:, mit
Leerzeichen davor, data:, vbscript:, file: abgelehnt und als Text stehen
gelassen.

DER PLAYER. Die Bedienelemente bleiben die des Browsers: ein selbstgebauter
Schieber sieht in jedem Browser anders falsch aus, kennt keine
Tastatursteuerung und keine Untertitel. Drumherum kam, was der Browser nicht
mitbringt.

Lautstaerke ueber Dateien hinweg -- <video> setzt sie bei jedem neuen Element
auf 1 zurueck, und wer sich durch zwanzig Clips klickt, stellt sie sonst
zwanzigmal leise, waehrend der einundzwanzigste ungefragt wieder hochfaehrt.

Weiter zum naechsten Stueck, abschaltbar, und nur wenn das Naechste auch etwas
zum Abspielen ist: nach einem Lied ungefragt bei einem Bild zu landen waere
schlechter als stehenzubleiben.

Tastatur an EINER Stelle: Leertaste haelt an, Pfeile springen fuenf Sekunden,
Umschalt+Pfeil wechselt die Datei. Bei allem anderen blaettern die Pfeile wie
bisher. Dass das so ist, steht als Zeile unter dem Bild -- eine unsichtbare
Sonderregel fuehlt sich an wie ein Fehler.

SERVER. Die Typtabelle kennt jetzt md, markdown, csv, lua, xml, yml, yaml,
toml, ini, cfg, sql und log -- alle als text/plain. Nicht als text/markdown
(braechte nichts) und erst recht nicht als text/html: wer einen Upload-Token
hat, koennte damit eine Seite unter unserem Namen veroeffentlichen. html, css
und js stehen deshalb weiterhin NICHT drin.

Der Betrachter entscheidet trotzdem nach der Endung und erst danach nach der
gemeldeten Art. Grund steht in der Datenbank: items/readme.md lag schon da und
trug application/octet-stream, weil sie vor der erweiterten Tabelle hochgeladen
wurde. Wer nur auf die Art schaut, zeigt die alte README nicht an und eine neue
schon -- und sucht den Unterschied an der falschen Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:58:10 +02:00
D4rkst3randClaude Opus 5 aa820c32af docs: der /t/-Block im Proxy fehlt noch -- gemessen, nicht vermutet
Die Kopfzeile verraet es: unter /f/ kommt unsere "public, max-age=300" an,
unter /t/ steht "max-age=17293" -- das ist NPMs assets.conf, die unsere
Vorgabe ueberschreibt. Der eine fehlende location-Block steht jetzt mit der
Messung daneben in der ROADMAP, samt der Folge: nach einem erneuten Upload
zeigt die Galerie stundenlang die alte Vorschau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:42:14 +02:00
D4rkst3randClaude Opus 5 40c446f859 feat: Papierkorb, Abrufzaehler, WebP ohne Adressaenderung, ZIP hochladen
Vier Dinge, alle am laufenden Dienst gemessen.

PAPIERKORB. Loeschen war endgueltig -- bei einem Knopf "alle 3861 waehlen"
direkt neben "loeschen" ist das die falsche Haerte, und die Sicherung half nur
bis zum letzten naechtlichen Lauf. Die Datei wandert jetzt nach
/data/papierkorb, der Datensatz in eine eigene Tabelle, nach 30 Tagen raeumt
der Dienst stuendlich selbst auf. Der Ablagename traegt eine laufende Nummer:
unter demselben Pfad koennen nacheinander verschiedene Dateien gelegen haben,
und beide sollen zurueckholbar sein. Geprueft: loeschen -> oeffentlich 404,
Eintrag im Papierkorb, zurueckholen -> 200 samt neu gerechneter Vorschau.

ABRUFZAEHLER. media.abrufe und zuletzt_abgerufen. Gezaehlt wird im Speicher und
alle 30 Sekunden weggeschrieben -- ein UPDATE je Kachel waeren bei einer
Galerieseite 120 Schreibvorgaenge. Der 304 zaehlt mit (der Aufrufer WOLLTE die
Datei, er hatte sie nur schon), HEAD nicht. Was vor dem Einbau lag, steht als
"nie geholt" da, auch wenn es taeglich benutzt wurde -- die Karte im
Speicherbericht sagt das auch dazu, statt eine Zahl zu zeigen, die luegt.

WEBP OHNE ADRESSAENDERUNG. Der Pfad ist die Adresse: aus items/foo.png darf
nicht items/foo.webp werden. Die sparsame Fassung liegt deshalb DANEBEN und
wird unter DERSELBEN Adresse ausgeliefert, wenn der Aufrufer per Accept sagt,
dass er WebP versteht. Nachgemessen an einer Datei: 6448 Bytes fuer curl, 2758
fuer einen Browser. Ueber den ganzen Bestand: 3606 Fassungen, 199,3 MB ->
33,1 MB, gespart 166,2 MB. Bei 29 lohnt WebP nicht -- dort bleibt es beim
Original.

Zwei Dinge haengen daran, beide gegengeprueft: "Vary: Accept" (ohne die Zeile
legt ein Zwischenspeicher die WebP-Fassung fuer alle ab, auch fuer die, die sie
nicht lesen koennen) und getrennte ETags mit "-w" (sonst bekaeme jemand auf
seinen If-None-Match hin ein 304 fuer das falsche Bild). Gemessen: derselbe
ETag mit Accept -> 304, ohne Accept -> 200 mit dem PNG.

ZIP HOCHLADEN. Ein Archiv wird ausgepackt statt abgelegt, die Ordner darin
bleiben erhalten und haengen sich hinter den gewaehlten Zielordner. fflate und
nicht adm-zip: reines JavaScript, keine native Bibliothek -- dieses Projekt hat
schon einen halben Abend an einer ABI-Nummer verloren. Grenzen: 5000 Eintraege,
256 MB entpackt, geprueft VOR dem Entpacken (eine Zip-Bombe waere sonst schon
im Speicher). Zip-Slip gemessen: "../../../../etc/passwd" und
"..\..\windows\hosts" abgelehnt, die harmlose Datei im selben Archiv abgelegt.

UND EIN KNOPF, DER GELOGEN HAT. Der erste Nachruestlauf ueber alle Dateien
brauchte mehr als 90 Sekunden -- genau da gibt Nginx Proxy Manager auf. Der
Aufrufer sah einen 504, waehrend die Arbeit im Hintergrund weiterlief und
fertig wurde. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer als einer
ohne Rueckmeldung.

Beide Wartungsknoepfe laufen deshalb in Runden zu 300 Stueck: der Server meldet
"offen" und "fertig", die Oberflaeche ruft erneut auf und zeigt dabei "280
erzeugt, noch 129 offen ...". Gemessen: 7,8 s je Runde statt 90+ am Stueck. Der
Vorschau-Knopf hatte dieselbe Wand und wurde mitgezogen, obwohl er noch nicht
dagegengelaufen war.

Nebenbei: der Verwaisten-Sucher kennt jetzt auch /data/webp (gleiche Schleife,
Ordner im Ergebnis vorangestellt), Verschieben und Loeschen nehmen die sparsame
Fassung mit, und das Loeschen eines Ordners raeumt thumbs/ und webp/ hinterher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:40:55 +02:00
D4rkst3randClaude Opus 5 e4a125e1c6 fix: ein Upload durfte den ganzen Dienst umbringen -- und tat es
BEIM UEBERNEHMEN VON 3670 DATEIEN STARB DER DIENST. Nicht einmal, sondern in
Schleife: acht Neustarts, nach aussen 502 vom Proxy.

    TypeError [ERR_INVALID_STATE]: ReadableStream is already closed
        at ReadableByteStreamController.close (node:internal/webstreams/...)

DIE URSACHE WAR MEINE EIGENE AENDERUNG von vor einer Stunde. Um PowerShells
Formular-Rumpf zu vertragen, hatte ich `c.req.parseBody()` durch
`c.req.raw.formData()` ersetzt -- und damit Hono den Rumpf weggenommen, den es
selbst verwaltet. Beim Aufraeumen der Antwort schliesst dann jemand einen
Strom, der schon zu ist. Die Ausnahme faellt in einem Microtask an, also
AUSSERHALB jedes try/catch, und Node beendet den Prozess.

Beim Test mit 57 Dateien fiel das nicht auf. Bei 3670 schon.

Jetzt geht der Normalfall wieder ueber Hono; der eigene Weg gilt nur noch fuer
die Antwort mit Anfuehrungszeichen an der Grenzmarkierung, fuer die er gedacht
war.

UND EIN NETZ DARUNTER: process.on('uncaughtException') faengt, was ausserhalb
jedes try/catch anfaellt, und laesst den Dienst weiterlaufen. Die Abwaegung
steht im Code: das ist NICHT in jedem Fall richtig -- der Prozess kann danach
kaputt sein. Hier ueberwiegt das Weiterlaufen, weil dieser Dienst keinen
Zustand im Speicher haelt (alles in SQLite und auf der Platte) und ein Dienst,
der wegen EINER Anfrage fuer alle weg ist, der schlechtere Tausch waere. Laut
wird es trotzdem.

DER SCHADEN WAR REPARIERBAR, und zwar mit dem Werkzeug von heute Nachmittag:
206 Dateien waren durch, 2 lagen auf der Platte OHNE Datensatz (geschrieben,
dann starb der Prozess vor dem Eintrag), dazu eine verwaiste Vorschau. Der
Verwaisten-Finder hat sie gefunden, "aufnehmen" hat sie eingetragen -- keine
Datei verloren.

Und die Fehlermeldung im Uebernahmeskript verschwieg die Ursache: sie meldete
"Conversion from JSON failed ... Unexpected character <", obwohl die Wahrheit
ein 502 war. Jetzt wird der HTTP-Code mitgelesen, bei 502 sauber abgebrochen
und gesagt, dass ein neuer Lauf einfach weitermacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:04:27 +02:00
D4rkst3randClaude Opus 5 5d35381952 feat: Dateien aus der Nextcloud uebernehmen
tools/uebernehmen.ps1 holt einen Nextcloud-Ordner in den Dienst. Unterordner
bleiben erhalten, -Probe zeigt erst nur, was passieren wuerde.

UEBER DAS DASHBOARD UND NICHT UEBER DIE TOKEN-API. Nextcloud-Dateien heissen
"Brand Logo (final).PNG" -- checkPath lehnt das mit Recht ab. Der
Dashboard-Weg biegt den Namen bereits vorhersagbar zurecht. Dieselbe Regel an
einer Stelle ist besser als dieselbe Regel an zweien, von denen eine irgendwann
abweicht.

WIEDERHOLBAR ueber overwrite=false: was schon da ist, meldet der Dienst je
Datei als "Pfad ist belegt". Nachgemessen mit 57 Marken -- zweiter Lauf: 0
uebernommen, 57 waren schon da.

DREI FEHLER BEIM BAUEN, alle durch Messen gefunden:

1. Ein kaputter Formular-Rumpf ergab einen NACKTEN 500, aufgefangen nur von der
   Auffanglinie. Jetzt 400 mit Grund.

2. PowerShells `Invoke-RestMethod -Form` scheitert am Dienst. Ich hatte die
   Ursache zuerst falsch: nicht (nur) die Grenzmarkierung in
   Anfuehrungszeichen, die RFC 2046 erlaubt und die wir jetzt vertragen --
   sondern der Rumpf selbst. Mitgeschnitten:

       Content-Disposition: form-data; name=file; filename=a.png

   RFC 7578 verlangt name="file" MIT Anfuehrungszeichen, .NET laesst sie weg,
   und Nodes Parser besteht darauf. Das steht im RUMPF; dafuer braeuchte es
   einen eigenen Multipart-Parser, und der waere unverhaeltnismaessig. Die
   Werkzeuge nehmen deshalb curl.exe -F.

3. PowerShell-Falle: curl.exe gibt zeilenweise ein ARRAY zurueck, und
   -match/-notmatch darauf FILTERT, statt zu pruefen. Ein nicht-leeres Ergebnis
   gilt als wahr -- damit meldete das Skript "Kein Verzeichnis gefunden" bei
   einer tadellosen Antwort mit HTTP 207. Jetzt wird vorher zusammengefuegt,
   auch in sichern.ps1, wo dieselbe Falle nur deshalb nicht zuschlug, weil die
   Ausgabe eine Zeile hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:59:59 +02:00
D4rkst3randClaude Opus 5 51071fc292 docs: README und .env.example auf den Stand -- und Embeds im Stil des Bots
DIE README BESCHRIEB NOCH DEN VORGAENGER: "Fivemanage Lite mit MySQL und
MinIO", Ausprobieren ueber docker-compose.local.yml, vier Geheimnisse, davon
drei fuer Dienste, die es nicht mehr gibt. Jetzt beschreibt sie, was da ist:
ein Container, SQLite, Dateien auf einer Platte -- Starten, was der Dienst
kann, die eine Regel und wie sie im Code durchgesetzt wird, Betrieb (Sicherung,
Wiki, Zurueckspielen), was bewusst fehlt, und die zwei Dinge, die Zeit gekostet
haben.

Und die .env.example nannte DB_PASSWORD, MINIO_ROOT_PASSWORD und
API_TOKEN_HMAC_SECRET -- drei Werte fuer Dienste, die seit Wochen weg sind, und
KEINEN der acht, die das Compose wirklich liest. Nachgezaehlt: jetzt sind alle
acht genannt, zwei als Pflicht, sechs als Optional mit dem Grund dahinter.

EMBEDS IM STIL DES BOTS. Auf den Hinweis hin in d4rkbot/src/embeds.js
nachgesehen: dort gibt es eine zentrale Embed-Fabrik mit Markenfarbe
(#f5c518), Fusszeile "D4RKST3R // <TAG>" und Zeitstempel. Zwei Dienste
desselben Hauses sollen in einem Kanal nicht wie zwei Fremde aussehen -- also
uebernommen statt neu erfunden, samt Feldern statt Fliesstext und Discords
Zeitmarken (<t:...:R>), die "vor 3 Stunden" in der Zeitzone des Lesers
anzeigen.

EINE ABWEICHUNG, mit Absicht: der Bot faerbt alles in der Markenfarbe, hier
faerbt die SCHWERE. Eine Warnung, die aussieht wie jede andere Nachricht, ist
eine Warnung, die man ueberliest -- und diese Meldungen gibt es nur, weil
jemand sie sehen soll.

Die Testnachricht schickt jetzt echte Zahlen mit. So sieht man nicht nur, DASS
etwas ankommt, sondern auch, ob es lesbar ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:38:36 +02:00
D4rkst3randClaude Opus 5 c9cd8eaa84 feat: Token-Grenzen, Speicher nach Art, Aufbewahrungsbericht, Discord-Meldungen
KEINE TOKEN-ARTEN, SONDERN TOKEN-GRENZEN. Fivemanage hat Token-Typen; wir haben
schon ein Rechtesystem, es steht nur auf einer anderen Achse (Praefix und
Loeschrecht). Was wirklich fehlte, sind die drei Schrauben, die die Frage
beantworten "was passiert, wenn DIESER Token abhandenkommt":

  Groessengrenze   ein Token fuer Fahrzeugbilder braucht keine 64 MB
  erlaubte Arten   nur Bilder, kein Video
  Ablaufdatum      fuer eine Anbindung, die man mal ausprobiert

Alle drei am laufenden Dienst durchgemessen: 600 KB gegen 512 KB -> 413 mit
beiden Zahlen; ein mp4 gegen einen Bild-Token -> 403 mit Art UND MIME im Text;
ausserhalb des Praefix -> 403; abgelaufen -> 401 mit DEM DATUM. "Abgelaufen"
und "unbekannt" auseinanderzuhalten spart eine halbe Stunde Suche nach einem
Tippfehler, den es nicht gibt.

Mehr als die Dienstgrenze zu erlauben wird abgelehnt statt still gekappt -- es
waere eine Behauptung, denn durchgelassen wird ohnehin die schaerfere.

SPEICHER NACH ART: ein Balken fuer die Verhaeltnisse, eine Tabelle fuer die
Zahlen, dazu die groessten Dateien.

AUFBEWAHRUNG VORBEREITET, NICHT GEBAUT. Automatisches Loeschen zu bauen, bevor
man weiss, was da liegt, ist der Weg, wie man Daten verliert. Stattdessen der
Bericht davor: wie viel ist aelter als 7/30/90/365 Tage, was liegt doppelt
(gleicher SHA-256 unter zwei Pfaden) und wie viel Platz das kostet. "Alles
aelter als 90 Tage loeschen" ist eine Behauptung, solange niemand weiss, wie
viel das waere. Diese Seite loescht nichts.

DISCORD-MELDUNGEN -- und der Grund ist nicht Discord, sondern eine Luecke, die
wir heute selbst gebaut haben: die Sicherung laeuft nachts um halb fuenf, und
wenn sie aufhoert zu laufen, merkt es niemand. Der Dienst sieht jetzt
STUENDLICH nach und meldet, wenn der letzte Lauf gescheitert oder aelter als 26
Stunden ist (24 waeren zu knapp, 48 zu spaet). Hoechstens EINMAL je Zustand --
eine Meldung, die stuendlich wiederkommt, wird weggeklickt, und dann auch die,
die zaehlt.

Ein Webhook und keine Bot-Anbindung: eine Adresse, kein Token, keine
Berechtigungen. Die Adresse wird geprueft (nur echte discord.com-Webhooks),
und der Testknopf steht gleich daneben -- ein Webhook, den man eintraegt und
erst in drei Wochen im Fehlerfall ausprobiert, ist einer, der dann nicht geht.

Aus deren Doku mitgenommen und NICHT gebaut: presigned URLs (kurzlebige
signierte Adressen fuer Client-Uploads). Waere der richtige Weg fuer
Spieler-Screenshots -- solange keine Spieler hochladen, ist es Vorrat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:30:03 +02:00
D4rkst3randClaude Opus 5 37bf6a68ed feat: das Konto zieht hinter den Namen, und die Ansicht merkt sich
DAS KONTO WAR EIN EIGENER REITER -- gleichrangig neben Galerie und Verlauf,
obwohl man sein Passwort zweimal im Jahr aendert und die Galerie taeglich
oeffnet. Jetzt haengt es hinter dem eigenen Namen oben rechts, zusammen mit dem
Abmelden. Das ist der Platz, an dem jeder danach sucht.

Der Pfad /konto BLEIBT bestehen: ein Lesezeichen darauf soll nicht ins Leere
fuehren, nur weil der Reiter verschwunden ist. Dafuer gibt es jetzt eine
zweite, kleine Liste "versteckt" neben den Reitern -- Seiten mit Adresse, aber
ohne Knopf.

Das Menue schliesst bei Klick daneben und bei Escape. Ein Menue, das offen
bleibt, bis man genau den Knopf wieder trifft, steht im Weg.

DIE ANSICHT MERKT SICH. Seitengroesse, Sortierung und Richtung in der Galerie,
Seitengroesse im Verlauf: wer 120 pro Seite einstellt, meint das dauerhaft und
nicht bis zum naechsten F5. Der Auffrisch-Schalter tat das schon von Hand und
geht jetzt denselben Weg.

BEWUSST NICHT gemerkt werden Filter -- Ordner, Suche, Medienart. Ein gemerkter
Filter fuehrt zu der Frage "warum sehe ich nichts", und die Antwort steht dann
in einer Leiste, auf die niemand schaut, weil er sie nicht selbst gesetzt hat.
Was die Ansicht formt, wird gemerkt; was die Menge einschraenkt, nicht.

localStorage kann werfen (privates Fenster, voller Speicher). Dann verhaelt
sich der Haken wie ein gewoehnlicher Zustand, statt die Seite mitzureissen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:12:49 +02:00
D4rkst3randClaude Opus 5 66c5de1e31 docs: das Wiki gibt es jetzt -- und es kommt aus docs/
Gitea legt das Wiki-Repo erst mit der ersten Seite an; Klon und Push antworten
davor mit 500, obwohl has_wiki auf true steht. Angelegt ueber die API
(POST /api/v1/repos/<besitzer>/<repo>/wiki/new) -- danach ist es ein
gewoehnliches Git-Repo und laesst sich klonen wie jedes andere.

  https://git.d4rkst3r.de/D4rkst3r/d4rk_media/wiki

DIE QUELLE BLEIBT docs/ IM REPO. Ein Wiki hat keinen Zusammenhang mit dem Code:
niemand sieht, ob die Anleitung noch zu dem passt, was der Dienst tut. Unter
docs/ wandert sie im selben Commit mit der Aenderung, die sie beschreibt.
tools/wiki.ps1 traegt sie danach hinaus -- EINSEITIG, und die Wiki-Seite sagt
das auch: wer dort tippt, verliert es beim naechsten Lauf. Unschoen, aber
ehrlicher als zwei Quellen, die auseinanderlaufen.

Zwei Seiten: Home (Zuschnitt, was der Dienst kann, was zu tun ist, wenn etwas
nicht geht) und API (Endpunkte, Kopfzeilen, Fehlerantworten, Grenzen -- und die
beiden Proxy-Fallen von heute).

Nachgemessen: beide Seiten sind ohne Anmeldung lesbar (200), ein zweiter Lauf
des Skripts erkennt "bereits aktuell", und eine Aenderung kommt an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:09:35 +02:00
D4rkst3randClaude Opus 5 28404b78c2 feat: Code mit Farbe, Anleitung im Repo, Galerie mit Seitengroesse
CODE MIT FARBE, ohne Bibliothek. Ein Hervorheber wie highlight.js wiegt mehr
als diese ganze Oberflaeche und kann fuenfzig Sprachen, von denen hier drei
vorkommen. Was Code lesbar macht, sind drei Unterscheidungen -- Kommentar,
Zeichenkette, Schluesselwort. Der Rest ist Zierde. Also ein Tokenizer in einem
Ausdruck, der seine Grenzen kennt: verschachtelte Sonderfaelle faerbt er
falsch, und bei zehn Zeilen Beispiel ist das der richtige Tausch.

DIE ANLEITUNG liegt als docs/API.md IM REPO und damit unter Versionskontrolle,
statt nur im Wiki zu stehen, wo niemand sieht, wann sie zuletzt stimmte. Sie
nennt nicht nur die Endpunkte, sondern was schiefgehen kann und wie die Antwort
dann aussieht -- samt der beiden Proxy-Fallen, die uns heute begegnet sind.

Das Gitea-Wiki gibt es noch NICHT: Gitea legt das Wiki-Repo erst an, wenn die
erste Seite ueber die Oberflaeche erstellt wird (davor antwortet der Klon mit
500). Die API-Seite verlinkt es trotzdem schon -- und sobald es existiert,
schiebt ein Befehl docs/API.md hinein.

GALERIE: die Seitengroesse ist waehlbar (30/60/120/200 statt fester 60), und
die Ordnerspalte rueckt ab 1536 px nach LINKS AUS dem Hauptbereich heraus --
15rem Spalte plus 1rem Abstand sind genau die 16rem, um die dort nach links
gezogen wird. Damit steht das Raster auf derselben Breite wie jede andere
Seite, statt um die Spaltenbreite schmaler zu sein. Darunter stapelt es wie
bisher; auf einem 1280er Schirm gaebe es keinen Rand, in den etwas ruecken
koennte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:05:35 +02:00
D4rkst3randClaude Opus 5 67d2f4ec93 feat: verschieben, Ordner, Adressvorlagen, API-Seite mit Export
VERSCHIEBEN. Dateien in einen anderen Ordner legen -- mit einer Warnung, die
OBEN steht und nicht im Kleingedruckten: der Pfad IST die oeffentliche Adresse,
nach dem Verschieben fuehrt die alte URL ins Leere. Das ist kein Fehler,
sondern der Preis vorhersagbarer Adressen; man muss es nur vorher wissen.

Der Umzug ist ein `rename` und kein Kopieren-dann-Loeschen: auf derselben
Platte ist das unteilbar. Die Vorschau zieht mit, sonst zeigte die Galerie am
neuen Ort nichts und am alten das Bild einer Datei, die dort nicht mehr liegt.
Eine belegte Zieladresse wird NICHT still ueberschrieben -- da verschwaende
sonst eine fremde Datei, und der Verlauf zeigte nur einen Umzug.

Der Verlauf kennt dafuer eine neue Spalte `von` und eine neue Art `move`; ohne
sie stuende dort nur das Ziel und die Frage "wo war die vorher" waere nicht
mehr zu beantworten.

ORDNER anlegen und entfernen. Leere Ordner gibt es sonst nicht -- sie sind der
Teil eines Pfades vor dem letzten Schraegstrich, mehr nicht. Jetzt sind sie ein
echtes Verzeichnis, und /folders liest Tabelle und Platte zusammen. Ein Ordner
MIT Inhalt wird nicht mitgeloescht: das waere ein Knopf, der hunderte Dateien
wegraeumt, ohne sie zu zeigen.

ADRESSVORLAGEN. Der Bauplan einer oeffentlichen Adresse, benannt und
gespeichert (vehicles/{model}.webp). Sie werden auf der Startseite und der
API-Seite mit ausgefuellter Adresse gezeigt. Ohne Platzhalter oder mit ".." im
Muster wird abgelehnt -- beides nachgemessen.

API-SEITE. Anleitung fuer die eigenen Skripte, und der Punkt daran: die
Beispiele stehen MIT DER ECHTEN ADRESSE drin statt mit example.com. Wer
abschreibt, schreibt keinen Tippfehler ab. Dazu Export in drei Formen -- JSON,
CSV (Semikolon und CRLF, sonst steht in Excel alles in einer Spalte) und eine
fertige Lua-Tabelle Name -> Adresse, die man in eine Resource legt.

DREI FEHLER BEIM MESSEN GEFUNDEN:
- rm mit recursive:false wirft bei einem Verzeichnis EISDIR und sagt damit
  etwas ganz anderes, als der Fall ist. rmdir scheitert von selbst, wenn der
  Ordner nicht leer ist -- genau das gewuenschte Verhalten.
- In der Lua-Ausgabe war "\'" gemeint und "\'" geschrieben: in JavaScript ist
  das schlicht ein Apostroph, der Backslash faellt weg. Ein Modellname mit
  Apostroph haette die erzeugte Datei zerlegt.
- Der Export mit Ordnerfilter antwortete mit einem NACKTEN 500 (kaputtes
  ESCAPE in der SQL-Zeile). Ausgerechnet das.

Deshalb hat der Dienst jetzt eine AUFFANGLINIE: app.onError gibt einen Text
statt "Internal Server Error". Wir haben das bisher an den bekannten Stellen
einzeln abgefangen -- und prompt hat ein Tippfehler die Luecke gefunden. Eine
Stelle schliesst sie fuer alles, woran heute niemand denkt.

Dazu: die Verlaufstabelle hat feste Spaltenbreiten (colgroup) und ueberall
dieselben Abstaende -- vorher hatten Kopf und Koerper verschiedene, und der
Pfad wurde abgeschnitten, obwohl daneben Platz frei war. Und die Anmeldeseite
hat zwei weiche Lichter statt einer leeren Flaeche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:01:01 +02:00
D4rkst3randClaude Opus 5 32d69c9945 fix: der Inhalt hat ueberall dieselbe Breite
Konto und Einstellungen standen auf max-w-2xl, alle anderen Seiten auf der
Containerbreite -- beim Wechseln sprang der Inhalt sichtbar. Ein Fehler beim
Bauen, kein Entwurf.

Die Breite steht jetzt an EINER Stelle (BREITE in App.tsx) und gilt fuer
Kopfzeile und Inhalt. Vorher stand sie zweimal da, und zwei Seiten setzten
zusaetzlich ihre eigene: drei Orte fuer eine Zahl, die immer gleich sein soll.

Formulare werden stattdessen INNERHALB ihrer Karte begrenzt. Das war der
eigentliche Grund fuer die Ausnahme -- ein Passwortfeld ueber 1280 Pixel sieht
albern aus. Nur gehoert diese Begrenzung ans Formular und nicht an die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:45:37 +02:00
D4rkst3randClaude Opus 5 2efc04b0b2 feat: nach Medienart filtern -- und die Endung schlaegt das Achselzucken
FILTER. Eine Leiste ueber der Galerie: Alles, Bilder, Video, Ton, Dokumente,
Anderes -- mit der Anzahl AM KNOPF. Ein Knopf "Video", hinter dem nichts liegt,
kostet sonst einen Klick, um das herauszufinden; leere Arten sind deshalb
sichtbar, aber gesperrt.

Die Uebersetzung Art -> SQL steht an EINER Stelle (ARTEN), weil zwei Stellen
zwangslaeufig auseinanderlaufen: die Liste zeigte dann etwas anderes, als der
Zaehler daneben behauptet. "Anderes" ist bewusst die VERNEINUNG der uebrigen
und keine eigene Liste -- sonst faellt ein neuer Typ durch alle Filter und ist
nirgends zu finden. Nachgemessen: die Summe der fuenf Arten ergibt die
Gesamtzahl.

/media/ids nimmt denselben Filter. Ohne das waehlt "alle waehlen" mehr aus, als
zu sehen ist -- und geloescht wuerde Unsichtbares.

DER FUND BEIM MESSEN: clip.mp4, ton.mp3, handbuch.pdf und notiz.txt landeten
ALLE unter "Anderes". Ein Formularteil ohne erkannte Art traegt
"application/octet-stream", und mein Code hat das der Endungstabelle
vorgezogen. Was der Absender ueber die Art sagt, gilt weiterhin -- aber nicht
sein Achselzucken. Danach: mp4 -> video/mp4, mp3 -> audio/mpeg, pdf und txt ->
Dokumente, und .zip bleibt richtigerweise "Anderes".

POLITUR. Ein ruhiger Verlauf im Hintergrund statt einer flachen Flaeche (ein
einfarbiges Dunkel sieht auf einem grossen Monitor aus wie ein Ladefehler),
Bildlaufleisten im Ton der Oberflaeche, ein Balken unter der aktiven Seite,
Karten mit einem Hauch Tiefe, ein spuerbarer Druckpunkt an den Knoepfen, und
Kacheln, die sich beim Darueberfahren leicht heben. Der Seitenwechsel blendet
auf -- und wer prefers-reduced-motion gesetzt hat, bekommt nichts davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:58 +02:00
D4rkst3randClaude Opus 5 4e613dd40d feat: ein Einstellungen-Tab -- und die Sicherung meldet sich zurueck
Die Zugangsdaten der Sicherung standen in einer Textdatei auf der Platte. Jetzt
stehen sie im Panel, zusammen mit der Discord-Einrichtung, die vom Konto
dorthin umgezogen ist.

DER EIGENTLICHE GEWINN IST NICHT DIE BEQUEMLICHKEIT. Eine Textdatei sagt
niemandem, ob die Sicherung heute Nacht gelaufen ist -- und eine Sicherung, die
still aufhoert zu laufen, ist der eigentliche Schaden, nicht die eine Nacht, in
der sie fehlt. Das Skript schreibt sein Ergebnis deshalb nach jedem Lauf in die
Datenbank, und die Karte zeigt es als Erstes: wann, ob erfolgreich, wie viele
Dateien, wie gross, und ob es in der Nextcloud gelandet ist oder nur lokal.
Nachgemessen: {"dateien":228,"groesse":12063419,"hoch":false,"ok":true}.

DAS PASSWORT GEHT NICHT UEBER DIE API. Das Skript laeuft auf dem Wirt, nicht im
Dienst -- es liest die Werte per `docker exec` DIREKT aus media.db. Ueber HTTP
geht das Passwort nur beim Eintragen im Browser, und das ueber TLS. Zurueck gibt
die API nur "hatPasswort: true", wie beim Discord-Geheimnis.

nextcloud.txt neben den Sicherungen gilt weiter und hat VORRANG: wer sie
angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird.

Zwei Fussangeln beim Melden, beide beim Bauen aufgefallen:
- Der JSON-Text geht ueber die STANDARDEINGABE in den Container und nicht als
  Argument -- Anfuehrungszeichen ueberleben den Weg durch zwei Shells nicht.
- Auch dieses Hier-Dokument braucht das -replace "`r": dieselbe CRLF-Falle wie
  bei der Pruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:39:16 +02:00
D4rkst3randClaude Opus 5 74cf56ce81 feat: eine Startseite, und die Sicherung geht in die Nextcloud
STARTSEITE. Wer das Dashboard oeffnet, landete bisher in der Galerie -- mit 228
Kacheln und ohne Antwort auf die erste Frage, die man beim Oeffnen hat: laeuft
alles? Jetzt zuerst eine Uebersicht: Zahlen, was heute dazukam, ein
Bilderstreifen der letzten Uploads, die letzten acht Schritte aus dem Verlauf,
die groessten Ordner, die Zugaenge -- und die ADRESSVORLAGE zum Kopieren, weil
das die haeufigste Frage ueberhaupt ist. Die Galerie zieht auf /galerie um.

Alles in EINER Antwort (/api/dash/uebersicht, gemessen 14 ms). Vier
Einzelabfragen waeren vier Runden ueber den Proxy und koennten auseinander-
laufen: Dateizahl aus der einen und letzte Ereignisse aus der naechsten Antwort
sind zwei verschiedene Zeitpunkte.

Der Router zog dabei nach src/router.ts um. Der Grund ist mehr als Geschmack:
die Startseite braucht eine Navigationsfunktion, die anderen Seiten nicht --
und sobald EINE Seite eine Eigenschaft mehr hat, passt die gemeinsame Liste in
App.tsx nicht mehr. Genau daran ist der erste Versuch gescheitert (TypeScript
hat es gefangen). Als Modulfunktion hat wieder jede Seite dieselbe Signatur.

SICHERUNG IN DIE NEXTCLOUD. Bisher lagen die Archive auf derselben Platte wie
die Daten -- gegen einen Plattenausfall half das gar nicht. Jetzt gehen sie per
WebDAV hinauf, mit curl statt rclone: Nextcloud spricht WebDAV, curl spricht
WebDAV, und curl ist ohnehin da.

Auch dort wird NACHGEZAEHLT: die Kopie wird zurueckgeholt und die Pruefsumme
verglichen. Ein PUT, der 201 sagt, hat nichts bewiesen -- abgeschnittene
Uebertragungen sehen genauso aus. Und ausgeduennt wird ebenfalls, sonst
waechst die Nextcloud still voll.

Die Zugangsdaten liegen NEBEN den Sicherungen (C:\backup\d4rk_media\
nextcloud.txt), nicht im Repo: sie gehoeren zur Maschine, nicht zum Code. Fehlt
die Datei, sagt das Skript das einmal und sichert lokal weiter -- eine
Sicherung, die wegen eines fehlenden Passworts GAR NICHT stattfindet, waere der
schlechtere Tausch.

Dabei einmal reingefallen: die Pruefung brach mit "sh: set: Illegal option -"
ab, nachdem ein Werkzeug die Datei mit CRLF gespeichert hatte -- die Shell im
Container liest dann "set -e\r". Sieht aus wie ein Tippfehler und ist keiner.
Das Skript entfernt die Wagenruecklaeufe jetzt selbst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:27:09 +02:00
D4rkst3randClaude Opus 5 84cb3ee200 feat: Galerie fuer 900 Dateien, und ein Finder fuer Verwaiste
SORTIERUNG. Nach Pfad, Groesse, Anlage- oder Aenderungsdatum, auf- und
absteigend. Bisher gab es genau eine Reihenfolge (zuletzt geaendert), was bei
228 Dateien schon nicht mehr reicht.

Die Sortierung kommt vom Aufrufer und geht in SQL, also wird sie NIEMALS
durchgereicht, sondern gegen eine feste Liste getauscht. Nachgemessen mit
"sort=; DROP TABLE media--": faellt auf den Standard zurueck, Datenbank
unveraendert. Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung
sortiert, sonst stuende "Adder" vor "adder" und dazwischen nichts.

ALLE TREFFER WAEHLEN. GET /media/ids liefert nur die IDs zur aktuellen
Auswahl -- ohne das braucht das Leeren eines Ordners mit 900 Bildern fuenfzehn
Seiten Klicken. Gedeckelt auf 5000, und wer daran stoesst, bekommt es GESAGT
(gekappt: true) statt still eine halbe Auswahl.

VERWAISTE. Ein Knopf zaehlt drei Arten von Abweichung: Dateien ohne Datensatz,
Datensaetze ohne Datei, Vorschauen ohne Bild. ERST ZAEHLEN, DANN FRAGEN -- die
Suche veraendert nichts.

Und das Aufraeumen geht in ZWEI Richtungen, wobei die schonende die
Voreinstellung ist: "aufnehmen" gibt Dateien ohne Datensatz einen, sie sind ja
da und werden ausgeliefert -- sie fehlen nur in der Liste. Erst "aufraeumen"
entfernt etwas, und auch dann nur Verweise auf Dateien, die es nicht mehr gibt,
und abgeleitete Vorschauen. Beides landet im Verlauf.

Am laufenden System durchgespielt: Datei am Dienst vorbei ins Volume gelegt ->
gefunden; aufgenommen -> steht in der Galerie; Datei hinter dem Ruecken
geloescht -> Datensatz ohne Datei gefunden; aufgeraeumt -> weg. Verlauf hat
beide Schritte mit user:admin.

(Der erste Versuch dieses Tests hat gelogen: Git Bash schreibt einen absoluten
Pfad in `docker exec` zu einem Windows-Pfad um -- rm suchte nach
"C:/Program Files/Git/data/...". `rm -f` schweigt dabei. Mit `sh -c` drumherum
bleibt der Pfad heil.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:10:59 +02:00
D4rkst3randClaude Opus 5 4f149e365f feat: Discord-Bild und -Name im Dashboard
Wer ueber Discord hereinkommt, sieht sein Bild in der Kopfzeile, auf der
Kontoseite und neben seinen Eintraegen im Verlauf.

DIE ADRESSE BAUT DER DIENST, nicht die Oberflaeche. Gespeichert wird nur der
Bildstempel; das URL-Schema von Discord steht an EINER Stelle (avatarUrl in
db.ts) und nicht in jeder Ansicht, die ein Bild zeigt. Es hat drei Faelle, und
alle drei stehen dort:

  - Stempel faengt mit "a_" an  -> bewegtes Bild, muss als .gif angefragt werden
  - Stempel vorhanden           -> .png mit gewuenschter Groesse
  - kein Stempel                -> Discords Ersatzbild, dessen Nummer sich aus
                                   der ID ergibt: (id >> 22) % 6. Das ist die
                                   NEUE Rechnung; die alte ueber den
                                   Diskriminator gilt seit den Pseudonymen
                                   nicht mehr. Gegengerechnet an drei IDs.

Bild und Anzeigename werden BEI JEDER ANMELDUNG nachgezogen: wer sein Bild
wechselt, saehe sonst monatelang das alte und fragte sich, ob er im richtigen
Konto sitzt. Der Benutzername im Dashboard bleibt dagegen, wie er ist -- an ihm
haengen die Eintraege im Verlauf.

Der Avatar-Baustein kennt drei Faelle und keinen kaputten: Bild, sonst die
Initialen auf einer Farbe, die sich aus dem Namen ergibt (damit sie beim
Neuladen nicht springt), und fuer einen Token einen Schluessel -- ein Token hat
kein Gesicht, ein Platzhalterbild daneben waere eine Behauptung. Laedt das Bild
nicht (CDN nicht erreichbar, Stempel veraltet), faellt es auf die Initialen
zurueck statt ein kaputtes Bildsymbol zu zeigen.

Nachgemessen: cdn.discordapp.com liefert das Ersatzbild (200, image/png), die
Form der Bildadresse stimmt, und ein Passwort-Konto bekommt sauber avatar:null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:59:42 +02:00
D4rkst3randClaude Opus 5 23405d68d3 feat: mehrere Rollen duerfen herein, nicht nur eine
Wer EINE der eingetragenen Rollen hat, kommt ins Dashboard. Mehrere sind der
Normalfall, sobald mehr als eine Person Zugang haben soll -- Admin und Fotograf
sind selten dieselbe Rolle.

Die bisherige einzelne Rolle geht NICHT verloren: der alte Schluessel
(discord.role_id) wird weitergelesen, bis die Liste einmal gepflegt wird; dann
raeumt das Speichern ihn weg. Zwei Quellen fuer dieselbe Frage laufen sonst
auseinander. Nachgemessen: die vorhandene Rolle stand nach dem Umbau
unveraendert drin, bereit blieb true.

Getrennt wird an Komma, Semikolon, Leerzeichen und Zeilenumbruch -- wer drei
IDs untereinander aus Discord kopiert, soll sie nicht erst zu einer Zeile
zusammenschieben. Doppelte fallen zusammen.

UND DIE SCHLECHTE WIRD BEIM NAMEN GENANNT: "123 ist keine Rollen-ID (17 bis 20
Ziffern)" statt "Rollen-ID ungueltig". Bei fuenf Eintraegen ist die zweite
Meldung eine Suchaufgabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:53:28 +02:00
D4rkst3randClaude Opus 5 1c5bf20eb0 feat: Medien im Fenster ansehen, und eine Auffrischung, die nichts wegzieht
ANSEHEN OHNE NEUEN TAB. Klick auf eine Kachel oeffnet den Betrachter: Bilder,
Videos, Ton und PDF direkt, alles andere mit ehrlichem Hinweis und
Herunterladen-Knopf -- ein Betrachter, der bei einer .zip ein schwarzes Feld
zeigt, sieht aus wie ein kaputter Betrachter. Pfeiltasten blaettern, Escape
schliesst; Adresse kopieren, neuer Tab und Loeschen sitzen in der Kopfzeile.

Gezeigt wird IMMER die Vollfassung, nie die Vorschau: 320 Pixel reichen fuer
eine Kachel, aber nicht dafuer, ein Bild zu beurteilen -- und genau dazu klickt
man darauf.

Damit musste die Auswahl umziehen: der Klick auf die Kachel oeffnet jetzt, das
Kaestchen oben links waehlt aus. Es erscheint beim Darueberfahren und bleibt
sichtbar, solange etwas gewaehlt ist.

AUTOMATISCH AUFFRISCHEN, aber nicht stumpf. Ein billiger Fuehler
(/api/dash/stand: drei Zahlen, GEMESSEN 6 ms je Abfrage ueber TLS und Proxy)
wird alle fuenf Sekunden gefragt; erst wenn er sich aendert, wird richtig
nachgeladen. Ein Tab im Hintergrund fragt gar nicht.

Die dritte Zahl ist kein Zierrat: nach einem LOESCHEN gehen Anzahl und
neuester Zeitstempel auf die alten Werte zurueck -- nur die Ereignis-ID zaehlt
weiter. Ohne sie bliebe eine geloeschte Datei in der Galerie stehen.
Nachgemessen.

UND ES ZIEHT NICHTS WEG: wer etwas ausgewaehlt hat, ein Bild offen hat oder
geblaettert ist, bekommt keinen Neuaufbau unter den Haenden, sondern einen
Knopf "Neues da -- laden". Eine Liste, die sich beim Klicken selbst neu
sortiert, ist der Grund, warum Leute Auto-Refresh abschalten. Der Schalter
merkt sich seinen Zustand.

Kein SSE, und das mit Absicht: dafuer braeuchte NPM zusaetzlich
`proxy_buffering off` -- ein weiterer Handgriff im Proxy, der vergessen werden
kann und dann zu einer Verbindung fuehrt, die scheinbar haengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:49:02 +02:00
D4rkst3randClaude Opus 5 dffec7714c feat: Discord-Anmeldung -- im Panel einrichtbar, nicht in der .env
Auf Wunsch ueber die Oberflaeche statt ueber Umgebungsvariablen. Der Gewinn ist
nicht nur Bequemlichkeit: das Panel legt die REDIRECT-URI zum Kopieren hin, und
genau daran scheitert ein OAuth-Anlauf fast immer.

  settings.ts   Schluessel/Wert in SQLite, mit Umgebung als Rueckfall. Wer
                schon Stack-Variablen gesetzt hat, behaelt sie; das Panel
                uebersteuert. In der Umgebung bleibt, was den Dienst ueberhaupt
                erreichbar macht (PUBLIC_URL, PORT, DATA_DIR) -- wer das per
                Formular verstellen koennte, sperrt sich mit einem Klick aus.
  discord.ts    Der Weg in drei Schritten, ohne Bibliothek und OHNE BOT-TOKEN:
                die Rolle wird mit dem Merkmal des BENUTZERS geholt
                (guilds.members.read). Kein Bot, keine Bot-Rechte, kein zweites
                Geheimnis, das ablaufen kann.

DAS GEHEIMNIS GEHT NIE HERAUS -- auch nicht an eine angemeldete Sitzung. Das
Panel bekommt nur "hatGeheimnis: true". Ein Formular, das den Client-Schluessel
im Klartext zurueckschickt, streut ihn in jeden Browser-Cache. Ein leeres Feld
heisst "unveraendert", nicht "loeschen"; zum Entfernen gibt es "-".

JEDER ABBRUCH IST SICHTBAR: kein weisser Bildschirm, sondern zurueck aufs
Formular mit dem Grund im Adressfeld -- "Du bist nicht auf diesem Server",
"Dir fehlt die noetige Rolle", oder bei 4xx vom Tausch der Hinweis auf die
Redirect-URI. Die IDs werden schon beim Speichern geprueft (17-20 Ziffern),
damit ein halber Link nicht erst beim Anmelden auffaellt.

Das Passwort bleibt als Notausgang. Ein ueber Discord angelegtes Konto bekommt
KEIN brauchbares Passwort ("nur-ueber-discord"): verifyPassword scheitert an
allem, was nicht dem scrypt-Format entspricht -- nachgesehen, nicht vermutet.

Dazu der Verlauf, wie gewuenscht: Suche nach Pfad (mit derselben
LIKE-Maskierung wie in der Galerie -- Suche nach "_" findet 0 statt allem),
waehlbare Seitengroesse (25/50/100/200, Standard 50) und mehr Luft zwischen
"Groesse" und "Von wem".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:44:45 +02:00
D4rkst3randClaude Opus 5 0b295a2302 feat: Verlauf -- wer hat wann was abgelegt, ersetzt oder geloescht
Neuer Reiter, und dahinter eine eigene Tabelle. WARUM NICHT EINFACH
media.created_at -- drei Gruende, jeder allein wuerde reichen:

  1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg, samt
     der Frage, wer es geloescht hat.
  2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
     Auskunft, wann die Datei urspruenglich kam.
  3. media.token_id ist beim Upload aus dem Dashboard NULL -- dort hat niemand
     einen Token, sondern eine Sitzung. Das "von wem" stand also nirgends.

Das ist NICHT das Logging, das die ROADMAP ablehnt: dort geht es um
Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je Upload in
derselben SQLite-Datei, und nach einem halben Jahr raeumt pruneEvents auf.

DER VERLAUF STARTET NICHT LEER. Beim ersten Start wird er aus dem Bestand
nachgetragen (68 Eintraege) -- ein Tab, der am ersten Tag leer ist, obwohl 68
Bilder in der Ablage liegen, sieht aus wie ein kaputter Tab. Die nachgetragenen
Eintraege sind als solche gekennzeichnet: Zeitpunkt stimmt, Urheber steht als
"vor der Aufzeichnung", weil ihn niemand mehr kennt. Die Oberflaeche sagt das
unter der Tabelle, statt es zu verschweigen.

logEvent wirft nie: ein Verlauf, der einen Upload scheitern laesst, waere die
Buchhaltung, die das Geschaeft verhindert.

Nachgemessen: Upload, Ersetzen und Loeschen aus dem Dashboard erscheinen als
user:admin, die Uploads des Fotostudios als token:d4rk_photostudio; beim
Loeschen wird die Groesse VOR dem Loeschen geholt, sonst stuende dort nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:52 +02:00
D4rkst3randClaude Opus 5 3ddab937e7 feat: hochladen aus dem Dashboard -- Bilder, Videos, Ton, PDF
POST /api/dash/upload, eigener Weg neben /api/upload. Der dortige haengt an
einem Bearer-Token; wer im Dashboard sitzt, hat eine SITZUNG und keinen Token,
und einen Token nur zum Hochladen anzulegen waere ein Geheimnis mehr, das
jemand verlieren kann.

Mehrere Dateien auf einmal, und JEDE bekommt ihr eigenes Ergebnis mit Pfad,
Groesse und Grund im Fehlerfall. Ein "3 von 5 hochgeladen" ohne die Namen der
zwei anderen ist genau die Sorte Rueckmeldung, wegen der wir hier neu bauen.
Eine kaputte Datei bricht die anderen nicht ab.

parseBody({ all: true }) ist noetig: ohne das liefert Hono bei mehreren Feldern
gleichen Namens nur das LETZTE, und aus fuenf Dateien wuerde eine.

DATEINAMEN WERDEN ZURECHTGEBOGEN, und zwar sichtbar. checkPath laesst
"Mein Video (final) 2.MP4" mit Recht nicht durch, also macht safeFilename
daraus "mein-video-final-2.mp4" -- Umlaute werden umschrieben und nicht
weggeworfen ("Grüße" -> "gruesse", nicht "gr-e"). Die Ergebnisliste zeigt den
neuen Pfad neben dem alten Namen.

BEIM MESSEN GEFUNDEN: bleibt vom Namen nichts uebrig -- "ЖУРНАЛ.txt",
"   .png", "★★★.txt" --, hiessen bisher ALLE "datei.txt", und die zweite
Datei ueberschrieb die erste stillschweigend. Jetzt haengen die ersten acht
Stellen des SHA-256 daran: gleicher Inhalt ergibt denselben Namen,
verschiedener Inhalt verschiedene. Nachgemessen mit drei solchen Namen.

Der Fortschrittsbalken laeuft ueber XMLHttpRequest und nicht fetch: nur der
kennt upload.onprogress, und bei einem 40-MB-Video ist ein Balken der
Unterschied zwischen "es tut sich was" und "ist es abgestuerzt?".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:29:33 +02:00
D4rkst3randClaude Opus 5 f7086c231c feat: Ordneruebersicht in der Galerie -- und der Proxy haelt keine Bilder mehr fest
ORDNERUEBERSICHT. Eine Spalte neben der Galerie mit allen Ordnern, auf allen
Ebenen, mit Anzahl und Groesse; ein Klick filtert. Neu dafuer:

- GET /api/dash/folders -- die Ordner werden in JavaScript zusammengezaehlt
  und nicht in SQL: fuer eine Baumansicht braucht es JEDE Ebene, und das waere
  in SQLite eine rekursive Abfrage, die niemand mehr liest.
- GET /api/dash/media?folder=... als EIGENER Filter neben der Suche. Die Suche
  findet mit LIKE %...% auch "alt/vehicles/adder.webp"; ein Ordner, der Dinge
  zeigt, die nicht darin liegen, ist keiner. Hier ist der Anfang verankert.
- Die LIKE-Sonderzeichen werden maskiert: ein Ordner "fotos_alt" wuerde sonst
  auch "fotosXalt" treffen, weil _ in LIKE ein beliebiges Zeichen ist.

Die Zahlen sind kumulativ -- "vehicles" enthaelt, was in "vehicles/showroom"
liegt. Alles andere ueberrascht in einer Baumansicht. Nachgemessen ueber drei
Ebenen: vehicles (24) > showroom (2) > tief (1), Filter und Zaehlung stimmen,
und nach dem Aufraeumen steht wieder vehicles (22).

Flache Liste mit Einrueckung statt aufklappbarem Baum: bei einer Handvoll
Ordner ist ein Aufklappmechanismus mehr Bedienung als Nutzen -- und was man
nicht aufklappen muss, kann man auch nicht zugeklappt uebersehen.

Dazu: der NPM-Zwischenspeicher ist zu. Der Schnipsel steht im Feld "Custom
Nginx Configuration"; NPM setzt ihn innerhalb des server-Blocks ein, eine
location ist dort also gueltig. Nachgemessen: cache-control kommt wieder von
uns, und ein ueberschriebenes Bild ist sofort das neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:22:51 +02:00
D4rkst3randClaude Opus 5 aa2575bdf4 feat: Sicherung, Anmeldebremse, Vorschaubilder -- und Schritt 5
SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe).
Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene
Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus
liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu
kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder
kommen aus einem NUR LESEND eingehaengten Volume dazu.

Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/
Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal
wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem
echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die
nie zurueckgespielt wurde, ist eine Hoffnung.

ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch
teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann
Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text,
der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt,
startet den Container neu. Durchgemessen bis zur Erholung.

VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter
/data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes,
Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der
Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie
faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander-
laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und
nennt Zahlen statt "fertig".

SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere
docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei
Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild
darin lag.

Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern
an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt
und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen
wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:00:14 +02:00
D4rkst3randClaude Opus 5 6c8ead8e15 feat: das Dashboard -- und die eine Regel steht im Code, nicht im Vorsatz
Schritt 4. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide,
Zustand; kein Konsta. Quelle in server/ui, Build nach server/web (gitignored),
im Abbild von einer eigenen Docker-Stufe gebaut.

JEDER KNOPF GIBT RUECKMELDUNG -- und zwar erzwungen, nicht vorgenommen:

- api.ts haelt den EINZIGEN fetch und GENAU EINE Fehlerklasse. Der
  Fivemanage-Fehler war, zwei zu haben: geworfen wurde `new Error`, geprueft
  auf `instanceof ApiError`, und damit verschwand jeder Fehlschlag lautlos.
- store.ts haelt run(): Aktion rein, Erfolgstext rein, und es meldet bei
  Erfolg diesen und bei Fehlschlag den Text DES DIENSTES. Wer darueber geht,
  kann keinen stillen Knopf bauen.
- Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel. Der zweite Klick
  eine Sekunde spaeter war der Weg zu neunzehn gleichnamigen Organisationen.
- Fehlermeldungen bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der
  von allein verschwindet, ist einer, den niemand gelesen hat.

@vitejs/plugin-react ist auf ^4 festgenagelt: die 6 verlangt vite ^8. In den
peerDependencies nachgesehen -- Vite 5.4.21 liegt in der Schnittmenge von
plugin-react@4 und @tailwindcss/vite@4.

Am laufenden Container nachgemessen: / kommt als text/html, assets/*.js als
text/javascript, *.css als text/css (die MIME-Falle aus Schritt 1 traegt), und
/tokens, /speicher, /konto liefern dieselbe index.html -- die SPA-Rueckfall-
route tut, wofuer sie gebaut wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:38:38 +02:00
D4rkst3randClaude Opus 5 65828b526c docs: Schritt 3 ist im Spiel gelaufen -- der Durchstich steht
/studio shot adder, und das Bild lag Sekunden spaeter unter
https://fivemanage.d4rkst3r.de/f/vehicles/adder.webp. Von der Dienstseite
gegengeprueft statt der Logzeile geglaubt: 132908 Bytes hier wie dort,
derselbe SHA-256, ausgeliefert als image/webp, erste Bytes RIFF....WEBP --
ein echtes WebP und kein falsch benanntes PNG.

Damit ist die letzte Frage aus dem Pruefstand beantwortet: PerformHttpRequest
verhaelt sich in FiveM wie die Faelschung, Kopfzeilen und base64-Rumpf kommen
unveraendert an, der Rueckruf erreicht den Event-Handler.

Als ungeprueft benannt bleibt, was ungeprueft ist: ein ganzer Serienlauf ueber
900 Fahrzeuge, und Upload.put -- die wartende Form, die der Lauf nicht nimmt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:21:54 +02:00
D4rkst3randClaude Opus 5 5f7f17ce7e feat: der Dienst steht oeffentlich, und die Pfadmeldung sagt jetzt die Regel
Der Stack laeuft unter https://fivemanage.d4rkst3r.de, hinter NPM mit
Let's-Encrypt-Zertifikat, Container auf Port 9101.

DER NAME IST EINE UMKEHR, und sie steht ausfuehrlich im ROADMAP: hier stand,
dass fivecdn und fivemanage wegfallen, weil wir kein Fivemanage betreiben. Das
Argument gilt weiter -- es ist nur teurer als das, was dagegen steht. Der Name
war bereits fertig verkabelt: DNS da, NPM-Host da, Zertifikat gueltig bis
09.11.2026, Ziel host.docker.internal:9101, und dieser Port wurde frei, als die
Lite-Container gingen. Unser Stack auf 9101 kostet damit null Klicks. Der
Wechsel auf media.d4rkst3r.de bleibt billig, weil keine Adresse in der
Datenbank steht.

Ueber die oeffentliche Adresse durchgemessen: Anmeldung durch echtes HTTPS
(der Fall, den curl bisher nicht bezeugen konnte), Token mit Praefix-Fessel,
403 mit Text fuer anderswo/ und fuers Loeschen, Upload und Abruf Byte fuer
Byte identisch.

Und ein Befund aus dem Fotostudio-Anschluss: die Pfadmeldung war irrefuehrend.
SEGMENT verlangt als ERSTES Zeichen einen Buchstaben oder eine Ziffer, die
Meldung sprach aber nur von "nicht mit Punkt beginnend" -- ein fuehrender
Unterstrich faellt ebenso durch. Kein erfundener Fall: das Fotostudio legt
seine Leerbilder als "_plate" ab. Jetzt nennt die Meldung die Regel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:08:36 +02:00
D4rkst3randClaude Opus 5 7bfd3458a1 docs: drei Angaben im ROADMAP waren falsch, alle drei nachgemessen
1. Es gibt keine zweite Maschine. Gebaut wurde auf DEM Server -- hier laufen
   Gitea, Portainer, NPM und die Nextcloud. Die Notiz "auf dieser Maschine
   gibt es weder Docker noch WSL" stammt aus der Zeit vor Docker Desktop.
   Zum Betrieb fehlt damit kein Umzug, sondern nur der oeffentliche Auftritt:
   DNS, ein Host in NPM mit TLS, ein echtes Passwort.

2. Die Portliste war unvollstaendig -- 8443 (Nextcloud AIO) fehlte. Neu
   gemessen aus "docker ps", nicht aus der Erinnerung. 9100/9101 sind frei,
   seit die Lite-Container weg sind; wir bleiben trotzdem auf 9102.

3. "Bis dahin bleibt der Lite-Stack als Vergleichsmassstab laufen" gilt nicht
   mehr: die Container sind geloescht. Was liegen blieb, sind drei Volumes und
   die Abbilder. Alle drei Volumes schreibgeschuetzt durchgezaehlt -- KEIN
   einziges Bild, minio besteht aus 244 KB reiner .minio.sys. Loeschen kostet
   also nichts; es ist nur noch nicht getan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:50:57 +02:00
D4rkst3randClaude Opus 5 7cc89512d8 docs: Schritt 2 ist gemessen -- das Abbild ist gebaut und gelaufen
Der Bau stand ungeprueft im Repo, und alles daran war Behauptung: ob
`npm ci` die fertige Binaerdatei zieht, ob das Volume greift, ob der
Healthcheck etwas taugt. Jetzt nachgesehen, auf einer Maschine mit Docker
Desktop 29.6.2:

- better-sqlite3 kommt als Binaerdatei, nicht aus node-gyp (datiert Mai 2025,
  kein Makefile daneben). Node 22, ABI 127 -- die Festnagelung traegt.
- Derselbe Durchstich wie in Schritt 1, diesmal gegen den Container: 24
  Pruefungen, keine durchgefallen. Am Grundgeruest war nichts zu aendern.
- Healthcheck meldet healthy; das Volume traegt ueber einen Containerwechsel;
  Compose bricht ohne PUBLIC_URL/ADMIN_PASSWORD ab, wie das :? verspricht.

Neu aufgefallen, weil der Container es sichtbar macht: im Volume liegen VIER
Dinge, nicht zwei. SQLite laeuft im WAL-Modus, und das WAL hielt nach wenigen
Uploads 152 KB, die in media.db noch nicht standen -- eine Sicherung nur ueber
media.db waere unvollstaendig. Steht jetzt bei den offenen Entscheidungen.

Und ein Zeuge weniger: curl taugt fuer die Secure-Cookie-Falle nicht, er
schickt das Cookie auch ueber http und meldet Erfolg. Was zaehlt, ist der
Browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:47:51 +02:00
D4rkst3randClaude Opus 5 076d909f62 docs: CLAUDE.md -- die Arbeitsweise gehoert ins Repo, nicht in ein Gedaechtnis
"Pruefen statt raten" stand bisher nur in einer Notiz auf einer Maschine.
Wer dieses Projekt auf einem anderen Rechner aufmacht, faengt sonst ohne
sie an -- und genau dort wird gleich weitergearbeitet.

Dazu die vier Faelle aus diesem Repo, die erst beim Messen aufgefallen
sind, als Beleg statt als Behauptung: der Einhaengepunkt in c.req.path,
das Sternchen, das kein Parameter ist, das try/catch um createReadStream,
das Secure-Cookie, das ueber localhost funktioniert und ueber eine
LAN-Adresse nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:31:11 +02:00
D4rkst3randClaude Opus 5 70210c0363 feat: Dockerfile und Compose -- geschrieben, aber nie gebaut
Auf dieser Maschine gibt es weder Docker noch WSL. Das Abbild ist also
ungebaut und der Container nie gelaufen; das gehoert auf eine Maschine
mit Docker, bevor jemand darauf baut.

Was trotzdem geprueft ist:

Node 22 ist festgenagelt, und das ist kein Zufall. better-sqlite3 11.10.0
liefert fertige Binaerdateien nur fuer ABI 108 (Node 20), 115, 127
(Node 22) und 131 -- Node 24 hat ABI 137 und fehlt. Der naheliegende
Griff zur neuesten Version faellt damit auf node-gyp zurueck und braucht
python3, make und g++ im Abbild. Aus den Veroeffentlichungen des Projekts
abgelesen, nicht aus dem Gedaechtnis.

node dist/index.js -- der Startbefehl des Abbilds -- laeuft und antwortet
auf /health. Bis hierher war der Dienst nur je ueber tsx gestartet
worden. Der Healthcheck gibt Exitcode 0 bei laufendem und 1 bei totem
Dienst.

Dazu eine Falle, die im Compose steht, weil sie teuer ist:
NODE_ENV=production setzt das Sitzungs-Cookie auf Secure. Wer den
Container ohne HTTPS aufruft, bekommt auf die Anmeldung 200 -- und ist
trotzdem nicht angemeldet, weil der Browser das Cookie still verwirft.
Die naechste Anfrage ist 401. Im Browser gegen eine LAN-Adresse
nachgemessen. Die naheliegende Gegenprobe ueber localhost fuehrt in die
Irre: das gilt Browsern als sicherer Kontext und funktioniert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:11:25 +02:00
D4rkst3randClaude Opus 5 5edbe6c45f feat: ein Name statt zwei, und PUBLIC_URL ist nur noch der Ursprung
fivecdn.d4rkst3r.de und fivemanage.d4rkst3r.de fallen weg -- wir betreiben
kein Fivemanage, also tragen unsere Adressen auch nicht dessen Namen.
Dashboard und API liegen an der Wurzel, die Dateien unter /f/.

Der Ein-Namen-Betrieb war bis jetzt nicht ausdrueckbar. Der Kommentar in
config.ts versprach "leer lassen, wenn alles unter einem Namen laufen
soll", aber optional() faellt bei leerem Wert auf den Host aus PUBLIC_URL
zurueck -- und dann gilt JEDE Anfrage als Anfrage an den Dateiwirt: kein
Dashboard, keine API, /health gibt 404 und die Anmeldung 405 "hier gibt
es nur Dateien". Nichts ist kaputt, und niemand kommt darauf. Deshalb ist
der Ein-Namen-Betrieb jetzt der Standard und der zweite Name die Ansage.

PUBLIC_URL ist nur noch der Ursprung; das /f haengt der Dienst selbst an.
Wer es mitschriebe, bekaeme Adressen mit /f/f/, und das faellt erst auf,
wenn das erste Bild fehlt. Praefix und Route haengen jetzt an derselben
Stelle (config.filePrefix), damit die zurueckgegebene Adresse und die
Route, die sie ausliefert, nicht auseinanderlaufen koennen.

Beide Betriebsarten durchgemessen: mit einem Namen kommt
.../f/vehicles/adder.png zurueck und liefert die Datei; mit gesetztem
FILES_HOST kommt sie ohne Praefix, liegt unter dem Dateihost, ist am
Dashboard-Namen zusaetzlich unter /f/ erreichbar, und unter dem Dateihost
gibt es weiterhin keine API. Welche Betriebsart laeuft, sagt der Dienst
in der zweiten Startzeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:58:20 +02:00
102 changed files with 25411 additions and 471 deletions
+64 -21
View File
@@ -1,31 +1,74 @@
# Fivemanage Lite — Geheimnisse und Adressen
# d4rk_media — die Werte fuer den Betrieb.
#
# Diese Datei ist die VORLAGE. Kopiere sie nach .env und trag echte Werte ein.
# Diese Datei ist die VORLAGE. Kopiere sie nach .env und trag echte Werte ein;
# .env selbst gehoert nicht ins Repo (siehe .gitignore).
#
# In Portainer traegst du dieselben Werte stattdessen als Stack-Variablen ein;
# dann braucht es die Datei dort gar nicht.
# In Portainer traegst du dieselben Werte als STACK-VARIABLEN ein — dann
# braucht es die Datei dort gar nicht.
#
# Erzeugen:
# openssl rand -hex 16 DB_PASSWORD, MINIO_ROOT_PASSWORD
# openssl rand -hex 32 API_TOKEN_HMAC_SECRET (muss 32 Byte sein)
# openssl rand -hex 12 ADMIN_PASSWORD
# Nur zwei sind Pflicht. Der Rest hat brauchbare Standards, und der Dienst
# BRICHT AB, wenn eine der beiden fehlt: einer, der ohne Konfiguration
# hochfaehrt, kostet mehr Zeit als einer, der sich weigert.
DB_PASSWORD=
MINIO_ROOT_PASSWORD=
API_TOKEN_HMAC_SECRET=
# ------------------------------------------------------------------- Pflicht
# Der Ursprung, unter dem der Dienst oeffentlich steht — OHNE Pfad und ohne
# abschliessenden Schraegstrich.
#
# Das /f/ haengt der Dienst selbst an. Wer es hier mitschreibt, bekommt
# Adressen mit /f/f/, und das faellt erst auf, wenn das erste Bild fehlt.
PUBLIC_URL=https://media.d4rkst3r.de
# Wird beim ERSTEN Start als Benutzer "admin" angelegt. Ein spaeter geaenderter
# Wert setzt das bestehende Passwort NICHT zurueck.
#
# openssl rand -base64 24
ADMIN_PASSWORD=
# Unter welcher Adresse die Dateien oeffentlich liegen. Ohne sie liefert MinIO
# Links auf den internen Containernamen aus — die gehen von aussen ins Leere.
PUBLIC_URL=https://fivecdn.d4rkst3r.de
# ------------------------------------------------------------------ Optional
# Das Docker-Netz, in dem der bestehende Reverse Proxy haengt.
# Der Name des Benutzers, der beim ersten Start entsteht.
#ADMIN_USERNAME=admin
# Host-Port. Auf diesem Server zeigt der NPM-Eintrag auf 9101.
#
# Wird NUR gebraucht, wenn du docker-compose.proxynet.yml dazunimmst. Beim Weg
# ueber host.docker.internal (docker-compose.hostports.yml) ist es
# gegenstandslos.
# Belegt sind hier 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080, 8443, 8090,
# 9000, 9443, 11000. Nur die LINKE Zahl einer Portabbildung muss einmalig sein.
#HOST_PORT=9101
# An welche Adresse der Port gebunden wird.
#
# `docker network ls` zeigt die Namen. Auf diesem Server heisst es
# wahrscheinlich `web` — ein Bridge-Netz ohne Stack.
PROXY_NETWORK=web
# 0.0.0.0 und NICHT 127.0.0.1: der Reverse Proxy kommt ueber die Docker-Bruecke
# herein, nicht ueber Loopback. Ein an 127.0.0.1 gebundener Port naehme die
# Anfrage nicht an. Zu macht ihn die Firewall, nicht die Bindung.
#BIND_ADDR=0.0.0.0
# ZWEI NAMEN statt einem: hier den Hostnamen eintragen, unter dem es dann
# ausschliesslich Dateien gibt — kein Dashboard, keine API, nichts anzumelden.
# Die URLs kommen dann ohne /f/ aus, und PUBLIC_URL ist der Datei-Name. Beide
# Namen muessen im Proxy auf diesen Port zeigen.
#
# Leer (Standard) heisst: EIN Name, Dateien unter /f/.
#FILES_HOST=
# Groesste erlaubte Datei. Ein freigestelltes Fahrzeugbild wiegt rund 130 KB;
# 64 MB lassen Luft fuer Videos, ohne dass ein Fehlgriff die Platte fuellt.
#
# Je Token laesst sich das im Dashboard weiter einschraenken.
#MAX_UPLOAD_MB=64
# Wie lange eine Anmeldung im Dashboard haelt.
#SESSION_DAYS=30
# ------------------------------------------------------------------- Hinweis
#
# NODE_ENV steht fest auf "production" im Compose und setzt das Sitzungs-Cookie
# auf Secure. Hinter dem Proxy mit TLS ist das richtig.
#
# WER OHNE HTTPS AUSPROBIERT, SPERRT SICH AUS: die Anmeldung antwortet mit 200,
# der Browser verwirft das Cookie still, und die naechste Anfrage ist 401. Die
# naheliegende Gegenprobe ueber http://localhost fuehrt in die Irre — das gilt
# Browsern als sicherer Kontext und funktioniert.
#
# Discord-Anmeldung, Sicherung und Meldungen stehen NICHT hier, sondern im
# Dashboard unter Einstellungen.
+7
View File
@@ -5,5 +5,12 @@
node_modules/
dist/
# Der Build der Oberflaeche. Entsteht aus server/ui mit `npm run build` und
# gehoert so wenig ins Repo wie ein uebersetztes Programm -- im Abbild baut ihn
# eine eigene Stufe.
server/web/
# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo.
data/
.waechter-basis.json
fehlende-item-bilder.txt
+44
View File
@@ -0,0 +1,44 @@
c# Arbeitsweise in diesem Projekt
## Prüfen statt raten
Nichts annehmen, was sich messen lässt. Verhalten von Bibliotheken, Routen,
Headern und Konfiguration am laufenden System oder in der Dokumentation
**nachsehen**, bevor darauf gebaut wird. Was sich nicht klären lässt: **fragen**,
statt etwas zu bauen, das plausibel aussieht.
Der Grund steht in der Projektgeschichte: eine plausible Annahme, die falsch
ist, fällt Stunden später auf und kostet dann genau die Zeit, die das Nachsehen
gespart hätte. Beispiele aus diesem Repo, alle erst beim Messen aufgefallen:
- `c.req.path` trägt in Hono den Einhängepunkt mit — Löschen und `exists`
sahen deshalb am Ziel vorbei.
- Ein Sternchen wird von Hono nicht als Parameter erfasst, `:pfad{.+}` schon,
und der Parameter kommt bereits dekodiert.
- `createReadStream` meldet eine fehlende Datei asynchron; das `try/catch`
darum fängt nichts.
- Ein Secure-Cookie über `localhost` funktioniert, über eine LAN-Adresse nicht
— wer auf localhost gegenprüft, prüft die Ausnahme.
**Im Bericht steht, was gemessen wurde, nicht was erwartet wurde.** Ungeprüftes
wird als ungeprüft benannt — siehe die Docker-Abschnitte in `ROADMAP.md`.
## Wo es langgeht
`ROADMAP.md` ist der Einstieg: Zuschnitt, Stand, Reihenfolge, offene
Entscheidungen. Das Warum einer Änderung steht in der Commit-Nachricht, nicht
nur im Diff.
## Git
Alles läuft auf **`master`**, keine Feature-Branches. Gehostet auf dem eigenen
Gitea (`https://git.d4rkst3r.de/D4rkst3r/d4rk_media`); Lesen geht anonym,
Schieben über die Umgebungsvariable `GITEA_TOKEN`.
## Die eine Regel fürs Dashboard
**Jeder Knopf gibt Rückmeldung, auch der erfolgreiche.** Das ist der Fehler, an
dem der Fivemanage-Anlauf gescheitert ist: ein Knopf ohne Antwort sieht aus wie
ein kaputter Knopf, und das Ergebnis waren neunzehn gleichnamige
Organisationen. Gilt auch für die API — kaputtes JSON bekommt einen 400 mit
Text, keinen nackten 500.
+244 -184
View File
@@ -1,220 +1,280 @@
# d4rk_media
Der Medien-Stack: **Fivemanage Lite** mit MySQL und MinIO. Ein Ablageort für
alle Resourcen — Fahrzeugbilder aus `d4rk_photostudio`, Item-Icons, Marken,
was sonst noch anfällt.
Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen,
ausliefern, in einem Dashboard verwalten. **Ein Container**, SQLite, Dateien auf
einer Platte.
Bisher liegt das auf dem Nextcloud und wird per rclone von Hand hochgeladen.
Das trägt, solange ein Mensch hochlädt. Sobald ein Script es tun soll, fehlt
ein Schreibweg über HTTP — und genau den bringt Lite mit, samt Token je
Resource und einer Oberfläche zum Nachsehen.
Läuft unter **https://media.d4rkst3r.de** — Dashboard und API an der
Wurzel, Dateien unter `/f/`.
---
## Ausprobieren, bevor es auf den Server geht
`0.1.0-beta.23` ist früh. Erst auf Docker Desktop:
```bash
cp .env.example .env # und ausfüllen, siehe unten
docker compose -f docker-compose.local.yml up -d
```
vehicles/adder.webp → https://media.d4rkst3r.de/f/vehicles/adder.webp
```
- Oberfläche: <http://localhost:8080> — `admin` und dein `ADMIN_PASSWORD`
- MinIO: <http://localhost:9001> — `lite` und dein `MINIO_ROOT_PASSWORD`
**Der Pfad ist der Schlüssel.** `vehicles/adder.webp` bleibt
`vehicles/adder.webp`, damit die Vorlage in einem Skript schlicht
`{model}.webp` lauten kann. Die Adressen stehen nirgends in der Datenbank — sie
werden bei jeder Antwort aus `PUBLIC_URL` gebaut. Ein Umzug auf einen anderen
Namen ist eine geänderte Variable, kein Datenbankeingriff.
Wenn das trägt, dasselbe auf dem Server.
---
## Auf dem Server, über Portainer
Portainer kann einen Stack direkt aus diesem Repo ziehen:
**Stacks → Add stack → Repository**
| Feld | Wert |
| | |
|---|---|
| Repository URL | `https://git.d4rkst3r.de/D4rkst3r/d4rk_media` |
| Compose path | `docker-compose.yml` |
| Environment variables | die vier Geheimnisse plus `PUBLIC_URL` |
Die Werte gehören **in die Stack-Variablen**, nicht in eine Datei im Repo.
`.env` steht deshalb in `.gitignore`.
### Wie der Reverse Proxy die Dienste erreicht
Über den **Host**, und das steht fertig in `docker-compose.yml`. Genauso laufen
`d4rkbot` auf 3080 und `cdn-files` auf 8090 schon.
```
fivecdn.d4rkst3r.de -> http://host.docker.internal:9100
fivemanage.d4rkst3r.de -> http://host.docker.internal:9101
```
**Nicht 9000 und 8080** — die sind auf diesem Server vergeben (Portainer und
nextcloud-aio). Im Container bleibt es bei 9000 und 8080; nur die Seite zum
Host wandert. Kollidiert später noch etwas, sind es zwei Variablen:
`MINIO_HOST_PORT` und `LITE_HOST_PORT`.
Die Ports sind an **alle** Adressen gebunden, nicht an `127.0.0.1`. Das ist
kein Versehen: NPM liegt im Standard-Bridge-Netz (`172.17.0.3`) und kommt über
die Docker-Brücke herein, nicht über Loopback — ein an `127.0.0.1` gebundener
Port nimmt diese Anfrage nicht an. **Zu macht sie die Firewall**, nicht die
Bindung. 9100 und 9101 gehören von außen dicht; öffentlich soll nur NPM auf
80/443 sein.
#### Wenn der Proxy im selben Docker-Netz hängt
Dann geht es sauberer, ganz ohne offene Ports — dafür liegt
`docker-compose.proxynet.yml` bereit. **Portainer nimmt unter *Compose path*
aber nur eine Datei**; eine Ergänzung wird dort stillschweigend ignoriert. Wer
diesen Weg will, führt die beiden Dateien zusammen oder startet den Stack von
Hand:
```bash
docker compose -f docker-compose.yml -f docker-compose.proxynet.yml up -d
```
Auf **diesem** Server trifft das nicht zu.
Die Ports sind an **alle Adressen** gebunden, weil `host.docker.internal` auf
diesem Server über die Docker-Brücke kommt und ein `127.0.0.1`-Port die
Anfrage nicht annähme — dieselbe Bindung, mit der `d4rkbot` und `cdn-files`
schon laufen.
**Damit macht die Firewall den Port zu, nicht die Bindung.** 9100 und 9101
gehören von außen dicht; öffentlich erreichbar soll nur NPM auf 80 und 443
sein. Wer weiß, dass Loopback bei ihm erreicht wird, setzt
`BIND_ADDR=127.0.0.1`.
| **Anleitung für Skripte** | [Wiki](https://git.d4rkst3r.de/D4rkst3r/d4rk_media/wiki) — Quelle ist `docs/` |
| **Stand und Reihenfolge** | `ROADMAP.md` |
| **Arbeitsweise** | `CLAUDE.md` — prüfen statt raten |
---
## Die vier Geheimnisse
## Starten
Sie werden **erzeugt**, nicht irgendwo abgeholt. Das Token für die Resourcen
kommt später und woanders her: aus der laufenden Lite-Oberfläche.
```bash
cp .env.example .env # PUBLIC_URL und ADMIN_PASSWORD ausfüllen
docker compose up -d --build
```
**PowerShell** — schreibt die fertige `.env`:
Das war es. Ein Container, ein Volume, kein zweiter Dienst.
**In Portainer:** Stacks → Add stack → Repository, `Compose path:
docker-compose.yml`. Die Werte kommen als Stack-Variablen.
> **`Compose path` nimmt genau EINE Datei.** Ergänzungen werden stillschweigend
> übergangen — alle Container gesund, alles 502. Das hat beim Vorgänger einen
> Nachmittag gekostet.
### An der Oberfläche arbeiten
```bash
npm --prefix server/ui install
npm --prefix server/ui run dev
```
Läuft auf `localhost:5173` und leitet `/api` und `/f` an den Container weiter.
Dass das Sitzungs-Cookie dabei ankommt, liegt an einer Ausnahme: es trägt
`Secure`, und ein Browser nimmt so eines über `http` **nur auf localhost** an.
---
## Was der Dienst kann
**Hochladen** — über die Skript-API mit Token (drei Rumpfformen: multipart,
Base64, rohe Bytes) oder aus dem Dashboard per Ziehen und Ablegen. Bilder,
Videos, Ton, PDF. Ein **ZIP wird ausgepackt** statt abgelegt, und die Ordner
darin bleiben erhalten — 3670 Bilder einzeln ins Fenster zu ziehen ist keine
Bedienung.
**Ausliefern** — mit ETag, `304`, Bereichsanfragen und `nosniff`. Vorschaubilder
werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden
766 KB. Bei **Video** ist es ein Standbild aus dem Clip (ein Zehntel hinein, weil
viele mit Schwarz beginnen), bei **Ton** das eingebettete Titelbild; die Kachel
trägt zusätzlich die Laufzeit.
**Sparen, ohne Adressen zu ändern** — neben jedem PNG und JPEG liegt eine
WebP-Fassung, ausgeliefert unter *derselben* Adresse je nach `Accept` des
Aufrufers. Nachgemessen über den ganzen Bestand: **199 MB → 33 MB**, das sind
83 %. Für Lua ändert sich nichts — es schickt kein `Accept: image/webp` und
bekommt weiterhin das Original.
**Zurückholen** — Gelöschtes liegt 30 Tage im Papierkorb, danach räumt der
Dienst selbst auf. Die Datei ist dann nur nicht mehr öffentlich, nicht weg.
**Zählen** — wie oft eine Datei geholt wurde und wann zuletzt. Beantwortet die
Frage, die ein Aufbewahrungsbericht sonst nicht beantworten kann: *braucht das
noch jemand?*
**Verwalten** — Galerie mit Ordnerbaum, Suche, Filter nach Medienart,
Sortierung, Mehrfachauswahl, Verschieben, Löschen.
**Ansehen ohne neuen Tab** — Bild, Video, Ton, PDF und alles Lesbare:
Markdown wird gesetzt, `.lua` und `.json` eingefärbt, CSV als Tabelle,
alles andere mit Zeilennummern. Video und Ton mit gemerkter Lautstärke und
„weiter zum nächsten Stück"; Leertaste hält an, die Pfeile springen fünf
Sekunden, mit Umschalt wechseln sie die Datei.
Markdown wird **ohne Bibliothek** dargestellt, und das ist kein Geiz: der
übliche Weg endet bei `dangerouslySetInnerHTML`, und die Sicherheit hängt dann
an einer Filterliste. Hier entsteht nie eine HTML-Zeichenkette — der Text wird
zu React-Knoten, ein `<script>` in einer hochgeladenen `.md` erscheint als die
acht Zeichen, die es ist.
**Umbenennen** — Datei und Ordner, mit der Warnung, die dazugehört: die Adresse
ändert sich, und der Dialog sagt vorher, wie oft die Datei geholt wurde bzw.
wie viele Dateien im Ordner betroffen sind.
**Nachvollziehen** — ein Verlauf: wer hat wann was abgelegt, ersetzt,
verschoben oder gelöscht. Dazu: nach Abrufen sortieren, und je Token sehen, was
damit abgelegt wurde.
**Umziehen von Fivemanage** — der Dienst spricht deren Sprache
(`/api/image`, `/api/video`, `/api/audio`, `/api/v3/file`, Schlüssel nackt im
`Authorization`-Kopf). Fremde Skripte ziehen um, indem eine Zeile getauscht
wird. Siehe `docs/API.md`.
**Freigeben**`/s/<schlüssel>` zeigt einen Ordner ohne Anmeldung, etwa einen
Fahrzeugkatalog. Freigegeben wird die **Liste**, nicht der Inhalt: die Dateien
sind ohnehin öffentlich, ein zurückgezogener Link nimmt nur die Übersicht weg.
**Anmelden** — über Discord (eine Rolle auf dem Server genügt) oder mit
Passwort. Das Passwort bleibt als Notausgang.
**Sichern** — täglich, geprüft, in die Nextcloud. Und der Dienst meldet nach
Discord, wenn die Sicherung ausbleibt. Zurückspielen lässt sich das Ganze mit
`tools\zurueckspielen.ps1` — und **üben** lässt es sich auch, in einem
Wegwerf-Volume neben dem laufenden Dienst.
**Beobachten**`/health` für den Container-Healthcheck (billig, beweist nur,
dass der Prozess antwortet) und `/status` für eine Statusseite: das prüft
Datenbank, Platte, Ausliefern, Bestand, Platz und Sicherung und antwortet mit
**503**, wenn der Dienst sein Geschäft nicht mehr tun kann.
---
## Die eine Regel
**Jeder Knopf gibt Rückmeldung, auch der erfolgreiche.**
Das ist der Fehler, an dem der Fivemanage-Anlauf gescheitert ist: dessen
`http-util.ts` warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf
`instanceof ApiError` — und damit verschwand **jeder** Fehler lautlos. `Create
organization` gab weder Erfolg noch Misserfolg zurück; das Ergebnis waren
neunzehn gleichnamige Organisationen.
Deshalb hier:
- In `server/ui/src/api.ts` steht der **einzige** `fetch` und **genau eine**
Fehlerklasse.
- In `store.ts` steht `run()`: Aktion rein, Erfolgstext rein — es meldet bei
Erfolg diesen und bei Fehlschlag den Text **des Dienstes**. Wer darüber geht,
kann keinen stillen Knopf bauen.
- Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel.
- Fehlermeldungen bleiben stehen, bis jemand sie wegklickt.
- Der Dienst hat eine Auffanglinie (`app.onError`): auch ein unerwarteter
Fehler bekommt einen Satz statt eines nackten `Internal Server Error`.
---
## Betrieb
### Sicherung
`tools/sichern.ps1`, täglich 04:30 als geplante Aufgabe.
Die Datenbank wird **nicht kopiert**, sondern über SQLites eigene
Sicherungsschnittstelle herausgeholt — im WAL-Modus liegt das Zuletzte noch
nicht in `media.db`. Die Bilder kommen aus einem **nur lesend** eingehängten
Volume dazu, alles in ein `tar.gz`.
**Und sie prüft sich selbst:** auspacken, Datenbank öffnen, Medien, Token und
Benutzer zählen, mit dem laufenden Dienst vergleichen. Danach geht sie per
WebDAV in die Nextcloud und wird **von dort zurückgeholt und die Prüfsumme
verglichen** — ein `PUT`, der 201 sagt, hat nichts bewiesen.
Zugangsdaten und Aufbewahrung stehen im Panel unter *Einstellungen*.
### Wiki
```bash
pwsh -File tools\wiki.ps1
```
Trägt `docs/*.md` ins Gitea-Wiki. **Einseitig**`docs/` ist die Quelle, das
Wiki die Kopie. Wer dort tippt, verliert es beim nächsten Lauf.
### Zurückspielen
```powershell
function New-Secret([int]$n){ $b = New-Object byte[] $n; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); ($b | ForEach-Object { $_.ToString('x2') }) -join '' }
@("DB_PASSWORD=$(New-Secret 16)","MINIO_ROOT_PASSWORD=$(New-Secret 16)","API_TOKEN_HMAC_SECRET=$(New-Secret 32)","ADMIN_PASSWORD=$(New-Secret 12)","PUBLIC_URL=https://fivecdn.d4rkst3r.de","PROXY_NETWORK=web") | Set-Content -Encoding ascii .env
pwsh -File tools\zurueckspielen.ps1 # ÜBUNG (Standard)
pwsh -File tools\zurueckspielen.ps1 -Echt # der Ernstfall
```
Zwei Fallen stecken darin, beide schon hineingetreten:
**Die Übung ist der eigentliche Gewinn.** Sie legt ein Wegwerf-Volume an,
spielt das Archiv hinein, startet einen *zweiten* Dienst davor, lässt ihn drei
zufällige Dateien ausliefern und vergleicht deren SHA-256 mit dem, was in der
Datenbank steht — dann räumt sie alles wieder ab. Der laufende Dienst wird
nicht angefasst.
- `RandomNumberGenerator::Create()` und **nicht** `::Fill` — letzteres gibt es
erst ab .NET 6, Windows PowerShell 5.1 kennt es nicht.
- `-Encoding ascii` und **nicht** `utf8` — PowerShell 5.1 schreibt sonst ein
BOM voran, und dann heißt die erste Variable `DB_PASSWORD`.
- `Get-Random` wäre die naheliegende Wahl und die falsche: es ist nicht
kryptografisch sicher, und hier wird ein Token-Signaturgeheimnis erzeugt.
Das beantwortet die Frage, die `sichern.ps1` **nicht** beantwortet: nicht „ist
das Archiv heil", sondern „wird daraus wieder ein laufender Dienst". Ein
Backup, das man nie zurückgespielt hat, ist kein Backup, sondern eine Datei.
**Bash / Linux:**
Zuletzt gelaufen und bestanden:
```
4452 Medieneinträge, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
4452 Dateien -- gleich viele wie Einträge
ok items/velum2.png 113567 B
ok items/flesh-dolphin-2star.png 3623 B
ok items/burgermeat.png 4269 B
```
Von Hand ginge es auch:
```bash
printf 'DB_PASSWORD=%s
MINIO_ROOT_PASSWORD=%s
API_TOKEN_HMAC_SECRET=%s
ADMIN_PASSWORD=%s
PUBLIC_URL=https://fivecdn.d4rkst3r.de
PROXY_NETWORK=proxy
' "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" "$(openssl rand -hex 32)" "$(openssl rand -hex 12)" > .env
tar xzf d4rk_media-JJJJ-MM-TT-HHMM.tar.gz
```
---
Ergibt `media.db`, `files/` und `papierkorb/`. Alles drei ins Volume legen,
während der Container steht. Vorschaubilder und WebP-Fassungen sind **nicht** im
Archiv — beides ist abgeleitet und wird mit zwei Knöpfen unter
*Speicher → Wartung* neu gerechnet.
## PostgreSQL, nicht MySQL
`0.1.0-beta.23` geht direkt in den PostgreSQL-Treiber; `DB_DRIVER` wird nicht
ausgewertet. Eine MySQL-DSN endet deshalb in
```
panic: pgdriver: invalid scheme: lite
```
— der Treiber liest sie als URI und findet als Schema den Benutzernamen.
Die `docker-compose.test.yml` des Projekts zeigt MySQL, benutzt dafür aber
`fivemanage/lite:latest` von Docker Hub, und die Reihe steht dort bei
`beta.16` still. Das README des Projekts hat recht: `postgres://…`.
Das Volume heißt deshalb `pgdata` und nicht `db` — wer den Stack schon mit
MySQL laufen hatte, bekommt so eine frische Ablage, statt dass PostgreSQL über
ein MySQL-Verzeichnis stolpert. Das alte Volume bleibt als Waise liegen und
kann weg:
```bash
docker volume rm fivemanager_db
```
---
## Zwei Adressen, nicht eine
Lite spricht MinIO unter zwei verschiedenen Namen an, und beide muessen
stimmen:
| Variable | Wert | Wofuer |
|---|---|---|
| `AWS_ENDPOINT` | `http://minio:9000` | Wie Lite die Dateien **hinlegt** — Containername im internen Netz |
| `BUCKET_DOMAIN` | `$PUBLIC_URL/media` | Woraus die **oeffentliche** Adresse gebaut wird, die Lite nach dem Upload zurueckgibt |
`BUCKET_DOMAIN` steht nicht im README des Projekts, nur in dessen
`.env.template`. Ohne sie kaemen Adressen zurueck, die nur innerhalb von
Docker gelten — der Upload saehe erfolgreich aus, und das Bild waere von
aussen nicht abrufbar.
Den Bucket legt Lite beim Start selbst an und setzt dabei die oeffentliche
Leserichtlinie (`pkg/storage/s3/s3.go`). Es ueberspringt beides, wenn der
Bucket schon existiert — deshalb legt dieser Stack ihn **nicht** vorher an.
---
## Zwei Eigenheiten, die Zeit kosten
**`traces export: … dial tcp [::1]:4318: connect: connection refused`**, alle
fünf Minuten. Kein Fehler: Lite schickt Tracing-Daten an einen Collector, den
dieser Stack bewusst nicht mitbringt. Abschalten geht in `beta.23` **nicht**
`otlpEndpoint` ist eine Konstante in `pkg/otel/exporter.go`, `ENV` wählt nur
zwischen TLS und unverschlüsselt. Weil die Adresse `localhost` ist, würde auch
ein Jaeger-Container daneben nichts nützen; er müsste sich den Netzwerk-
Namensraum teilen. Ignorieren.
**„Create organization" tut nichts.** Doch — die Organisation wird angelegt,
nur geht die Oberfläche danach nicht weiter: `NewOrganizationRoute.tsx` ruft
`mutate(data)` ohne `onSuccess`, ohne Navigation, ohne Meldung. **Seite neu
laden**, dann findet die App sie. Wer mehrfach geklickt hat, hat mehrere.
**Ein Knopf in der Oberfläche tut gar nichts.** Dann steht der Grund im
Container-Log, nicht auf dem Bildschirm. `web/src/utils/http-util.ts` wirft
jeden Fehler als `new Error(…)` weiter, die aufrufenden Hooks prüfen aber auf
`err instanceof ApiError` — das ist danach nie wahr, und der Fehler wird
verschluckt. 401, 500 und ein falscher Rumpf sehen deshalb alle gleich aus:
nichts passiert.
> Der Papierkorb war anfangs **nicht** dabei, und das war ein Fehler: seine
> Einträge stehen in `media.db` und waren damit gesichert, die Dateien lagen
> daneben und waren es nicht. Nach einem Zurückspielen hätte der Papierkorb
> Zeilen gezeigt, deren „zurückholen" ins Leere greift. Er kostet fast nichts —
> was darin liegt, ist höchstens 30 Tage alt.
---
## Was hier bewusst fehlt
**ClickHouse und Jaeger.** Beide stehen in der Entwicklungs-Compose des
Projekts und sind für strukturiertes Logging und Tracing da. Zum Ablegen von
Bildern braucht es sie nicht, und ClickHouse ist eine schwere Abhängigkeit.
Nachrüsten geht jederzeit — die App findet sie über `CLICKHOUSE_*`.
**Logging.** Genau dafür schleppt Fivemanage ClickHouse mit, und genau da ist
der Wartungsaufwand echt. Der Verlauf im Dashboard beantwortet „wer war das" —
mehr braucht es hier nicht.
**Offene Ports in der Betriebsfassung.** Nur der Reverse Proxy spricht mit der
App; Datenbank und MinIO bleiben im internen Netz. Die Entwicklungs-Compose des
Projekts macht 5432, 9000, 16686 und mehr auf — dort in Ordnung, hier nicht.
**Mandanten, Rollen, Teams.** Ein Dienst, den eine Person betreibt.
**Automatisches Löschen.** Vorbereitet ist der Bericht davor: wie viel ist
älter als 7/30/90/365 Tage, was liegt doppelt. Eine Regel zu bauen, bevor man
weiß, was da liegt, ist der Weg, wie man Daten verliert.
**Spielerportal.** Setzt voraus, dass Spieler hochladen. Der richtige Weg wäre
dann `presigned URLs` — kurzlebige signierte Adressen, mit denen ein Client
hochlädt, ohne je einen Token zu sehen.
---
## Zwei Dinge, die Zeit gekostet haben
**Der Proxy hielt Bilder fest.** NPMs `assets.conf` greift über eine Regex auf
*jede* URL, die auf `.webp`, `.png`, `.js`, `.css` endet, legt die Antwort 30
Minuten weg und ignoriert dabei unsere Kopfzeilen. Ein überschriebenes Bild
blieb damit eine halbe Stunde alt — und `/api/exists/…webp` wurde
zwischengespeichert und danach **auch ohne Token** ausgeliefert.
Behoben mit drei Blöcken in der *Custom Nginx Configuration* des Proxy-Hosts:
```nginx
location ^~ /api/ { proxy_cache off; include conf.d/include/proxy.conf; }
location ^~ /f/ { proxy_cache off; include conf.d/include/proxy.conf; }
location ^~ /t/ { proxy_cache off; include conf.d/include/proxy.conf; }
```
Das `^~` ist der ganze Trick: eine gewöhnliche Präfix-Regel **verliert** gegen
die Regex. In einem Wegwerf-Nginx nachgemessen.
**Node 22 ist festgenagelt.** `better-sqlite3` 11.10.0 liefert fertige
Binärdateien für ABI 115 (Node 20) und 127 (Node 22) — **Node 24 hat ABI 137
und fehlt**. Der Griff zur neuesten Version fällt auf `node-gyp` zurück und
braucht python3, make und g++ im Abbild.
---
## Herkunft
Zusammengestellt aus [fivemanage/lite](https://github.com/fivemanage/lite):
dessen `README.md`, `deployments/docker-compose.yml` (Entwicklung, ohne die
App) und `deployments/docker-compose.test.yml` (zeigt die App-Verdrahtung und
dass MySQL genügt).
Der Vorgänger war **Fivemanage Lite** mit PostgreSQL, MinIO und ClickHouse. Der
Stack lief; woran er gescheitert ist, steht oben unter *Die eine Regel* und
ausführlich in `ROADMAP.md`. Von dem, was er über eine Dateiablage hinaus kann,
haben wir nichts gebraucht und alles mitgeschleppt.
**Nicht laufen gelassen.** Rechne mit ein, zwei Anläufen — vor allem beim
ersten Login und beim Bucket.
Seine Compose-Dateien liegen in `legacy/`, als Beleg und wegen der Kommentare —
nichts davon wird noch gestartet.
+1167 -43
View File
File diff suppressed because it is too large Load Diff
+99 -118
View File
@@ -1,130 +1,111 @@
# Fivemanage Lite — fuer den Betrieb, ueber Portainer aus diesem Repo.
# d4rk_media — unser eigener Medien-Dienst. EIN Container.
#
# Abgeleitet aus den beiden Compose-Dateien im Projekt (fivemanage/lite) und
# dem README. Deren docker-compose.yml ist fuer die ENTWICKLUNG: sie startet
# Datenbank, ClickHouse, MinIO und Jaeger, aber nicht die Anwendung, und mit
# Standardpasswoertern. Die docker-compose.test.yml zeigt dafuer, wie der
# App-Container verdrahtet wird — und dass MySQL genuegt.
# In Portainer: Stacks -> Add stack -> Repository
#
# Bewusst NICHT dabei:
# Repository URL https://git.d4rkst3r.de/D4rkst3r/d4rk_media
# Compose path docker-compose.yml
#
# ClickHouse und Jaeger. Beides ist strukturiertes Logging und Tracing. Fuer
# das Ablegen von Bildern braucht es sie nicht, und ClickHouse ist eine
# schwere Abhaengigkeit. Nachruesten geht spaeter.
# EINE Datei, und das ist wichtig: Portainer nimmt unter "Compose path" genau
# eine. Ergaenzungen werden stillschweigend uebergangen -- alle Container
# gesund, alles 502. Das hat beim Lite-Anlauf einen Nachmittag gekostet.
#
# Der Weg nach aussen steht HIER DRIN und nicht in einer Ergaenzungsdatei.
# Zuerst war er ausgelagert, weil sich je Aufbau unterscheidet, wie ein
# Reverse Proxy seine Ziele findet. In Portainer nimmt "Compose path" aber
# EINE Datei — die Ergaenzung wurde stillschweigend nicht angewandt, und das
# Ergebnis war ein Stack, in dem jeder Container gesund meldet und trotzdem
# alles 502 liefert. Eine Trennung, die man nicht anwenden kann, ist keine.
#
# Wer den Proxy im selben Docker-Netz hat, nimmt docker-compose.proxynet.yml
# dazu — das ist der sauberere Weg, aber nicht der hier gegangene.
# Die Werte gehoeren in die STACK-VARIABLEN, nicht in eine Datei im Repo.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: fivemanage_lite
POSTGRES_USER: lite
POSTGRES_PASSWORD: ${DB_PASSWORD:?bitte in .env oder als Stack-Variable setzen}
volumes:
- pgdata:/var/lib/postgresql/data
networks: [internal]
healthcheck:
# Ohne das startet die App gegen eine Datenbank, die noch nicht
# antwortet, und beendet sich — beim ersten Hochfahren jedes Mal.
test: ["CMD-SHELL", "pg_isready -U lite -d fivemanage_lite"]
interval: 10s
timeout: 5s
retries: 12
media:
build:
context: ./server
image: d4rk-media:latest
container_name: d4rk-media
restart: unless-stopped
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: lite
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?bitte setzen}
MINIO_SERVER_URL: ${PUBLIC_URL:?bitte setzen}
volumes:
- minio:/data
networks: [internal]
ports:
# Die Dateien, fuer den Reverse Proxy ueber den Host:
# fivecdn.d4rkst3r.de -> http://host.docker.internal:9100
#
# 9100 statt 9000, weil 9000 auf diesem Server an Portainer geht. Im
# Container bleibt es 9000; nur die Seite zum Host wandert.
#
# An alle Adressen gebunden, und das ist kein Versehen: der Nginx Proxy
# Manager liegt im Standard-Bridge-Netz und kommt ueber die Docker-
# Bruecke herein, nicht ueber Loopback — ein an 127.0.0.1 gebundener
# Port nimmt diese Anfrage nicht an. Genauso laufen hier schon d4rkbot
# (3080) und cdn-files (8090).
#
# ZU IST DER PORT DURCH DIE FIREWALL, nicht durch die Bindung. 9100 und
# 9101 gehoeren von aussen dicht; oeffentlich soll nur NPM auf 80/443
# sein. ss -tlnp | grep -E '9100|9101'
- "${BIND_ADDR:-0.0.0.0}:${MINIO_HOST_PORT:-9100}:9000"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 12
environment:
# Der Ursprung, unter dem der Dienst oeffentlich steht -- OHNE Pfad
# und ohne abschliessenden Schraegstrich:
# https://media.d4rkst3r.de
#
# Das /f/ haengt der Dienst selbst an. Wer es hier mitschreibt,
# bekommt Adressen mit /f/f/, und das faellt erst auf, wenn das
# erste Bild fehlt.
#
# Das :? ist Absicht: fehlt die Variable, bricht der Stack ab,
# statt mit einer Adresse hochzufahren, die niemand erreicht.
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL fehlt, z.B. https://media.d4rkst3r.de}
lite:
# ghcr.io, NICHT Docker Hub: dort steht die Reihe bei 0.1.0-beta.16
# still, auf ghcr.io laeuft sie weiter. Die Test-Compose des Projekts
# zeigt `fivemanage/lite:latest` — das ist Docker Hub und damit alt.
image: ghcr.io/fivemanage/lite:0.1.0-beta.23
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
environment:
PORT: "8080"
ENV: production
# PostgreSQL, und zwar zwingend: beta.23 geht direkt in den
# pgdriver — DB_DRIVER wird nicht ausgewertet. Eine MySQL-DSN endet in
# "pgdriver: invalid scheme: lite", weil der Treiber sie als URI liest.
#
# Die docker-compose.test.yml des Projekts zeigt MySQL, benutzt dafuer
# aber fivemanage/lite:latest von Docker Hub — und die Reihe steht dort
# bei beta.16 still. Das README hat recht, die Testdatei ist alt.
DSN: postgres://lite:${DB_PASSWORD:?bitte setzen}@db:5432/fivemanage_lite?sslmode=disable
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?bitte setzen}
API_TOKEN_HMAC_SECRET: ${API_TOKEN_HMAC_SECRET:?bitte setzen}
S3_PROVIDER: minio
AWS_ENDPOINT: http://minio:9000
AWS_ACCESS_KEY_ID: lite
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?bitte setzen}
AWS_BUCKET: media
# Aus der Vorlage des Projekts uebernommen; fuer MinIO ist die Region
# gleichgueltig, das SDK verlangt aber eine.
AWS_REGION: eu-west-1
# Wird beim ERSTEN Start als Benutzer "admin" angelegt. Ein spaeter
# geaenderter Wert setzt das bestehende Passwort nicht zurueck.
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD fehlt}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
# Woraus die OEFFENTLICHE Adresse gebaut wird, die Lite nach dem Upload
# zurueckgibt. Steht nicht im README, nur in deren .env.template — und
# ohne sie kaemen Adressen zurueck, die nur innerhalb von Docker gelten.
#
# Endpunkt plus Bucket, wie in der Vorlage
# (BUCKET_DOMAIN=http://localhost:9000/lite-dev).
BUCKET_DOMAIN: ${PUBLIC_URL:?bitte setzen}/media
ports:
# Die Oberflaeche: fivemanage.d4rkst3r.de -> http://host.docker.internal:9101
# 9101 statt 8080, weil 8080 an nextcloud-aio geht. Siehe minio oben.
- "${BIND_ADDR:-0.0.0.0}:${LITE_HOST_PORT:-9101}:8080"
networks: [internal]
# EIN NAME (leer, Standard): Dashboard und API an der Wurzel,
# Dateien unter /f/.
#
# ZWEI NAMEN: hier den Hostnamen eintragen, unter dem es dann
# ausschliesslich Dateien gibt -- kein Dashboard, keine API, nichts
# anzumelden. Die URLs kommen dann ohne /f/ aus, und PUBLIC_URL ist
# der Datei-Name. Beide Namen muessen im Proxy auf diesen Port
# zeigen. Welche Betriebsart laeuft, sagt der Dienst beim Start.
FILES_HOST: ${FILES_HOST:-}
networks:
internal:
driver: bridge
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64}
SESSION_DAYS: ${SESSION_DAYS:-30}
# Wieviel der Papierkorb hoechstens halten darf, zusaetzlich zu den
# 30 Tagen. Wer 200 GB loescht, haelt sie sonst 30 Tage doppelt.
# Verstellbar vor allem, damit sich der Deckel PRUEFEN laesst, ohne
# 20 GB zu loeschen -- Nachkommastellen sind erlaubt.
PAPIERKORB_MAX_MB: ${PAPIERKORB_MAX_MB:-20480}
# NODE_ENV=production setzt das Sitzungs-Cookie auf "Secure". Hinter
# NPM mit TLS ist das richtig.
#
# WER OHNE HTTPS AUSPROBIERT, SPERRT SICH AUS -- und zwar auf die
# unangenehme Art. Nachgemessen im Browser gegen
# http://192.168.2.33:8096:
#
# POST /api/dash/auth/login -> 200, sieht nach Erfolg aus
# document.cookie -> leer, still verworfen
# GET /api/dash/auth/me -> 401 "nicht angemeldet"
#
# Die Anmeldung MELDET Erfolg und ist trotzdem keine. Genau die
# Sorte Rueckmeldung, wegen der wir hier neu bauen.
#
# Und die naheliegende Gegenprobe fuehrt in die Irre: ueber
# http://localhost geht es, weil Browser localhost als sicheren
# Kontext behandeln. Also entweder ueber den Proxy mit HTTPS
# testen -- oder zum Ausprobieren NODE_ENV weglassen.
NODE_ENV: production
volumes:
# Bilder UND media.db in einem Volume. Eine Sicherung ist damit
# genau ein Kopiervorgang.
- media-data:/data
# Die fertigen Archive, damit man sie sich aus dem Panel holen kann,
# ohne sich per Fernwartung auf den Server zu klicken. Genau das war
# der Grund, aus dem es lange keine Kopie ausserhalb dieser Maschine
# gab: nicht Unwillen, sondern Umstaendlichkeit.
#
# DAS :ro AM ENDE IST DER WICHTIGE TEIL. Ein Dienst, der seine
# eigenen Sicherungen loeschen kann, ist genau das Werkzeug, das man
# einem uebernommenen Dienst nicht in die Hand geben will.
# Herausgeben reicht.
- ${BACKUP_DIR:-C:/backup/d4rk_media}:/sicherungen:ro
ports:
# Links der Host-Port, rechts der im Container. Nur die LINKE Zahl
# muss auf diesem Server einmalig sein.
#
# Belegt sind 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080,
# 8090, 9000, 9443, 11000 -- und 9100/9101, solange der Lite-Stack
# als Vergleichsmassstab weiterlaeuft. Deshalb 9102.
#
# Gebunden an ALLE Adressen, nicht an 127.0.0.1: NPM liegt im
# Standard-Bridge-Netz und kommt ueber die Docker-Bruecke herein,
# nicht ueber Loopback. Ein an 127.0.0.1 gebundener Port naehme die
# Anfrage nicht an. ZU MACHT IHN DIE FIREWALL, nicht die Bindung --
# 9102 gehoert von aussen dicht, oeffentlich ist nur NPM auf 80/443.
- '${BIND_ADDR:-0.0.0.0}:${HOST_PORT:-9102}:8080'
volumes:
pgdata:
minio:
media-data:
+523
View File
@@ -0,0 +1,523 @@
# Die API von d4rk_media
Diese Seite ist für den Fall geschrieben, dass jemand ein Skript anbindet und
etwas nicht tut, was es soll. Sie nennt deshalb nicht nur die Endpunkte,
sondern auch **was schiefgehen kann und wie die Antwort dann aussieht**.
Der Dienst läuft unter `https://media.d4rkst3r.de`. Dateien liegen darunter
unter `/f/`.
---
## Der Grundsatz: der Pfad ist der Schlüssel
`vehicles/adder.webp` bleibt `vehicles/adder.webp`. Die öffentliche Adresse
entsteht daraus, indem der Ursprung davorgesetzt wird:
```
vehicles/adder.webp → https://media.d4rkst3r.de/f/vehicles/adder.webp
```
Deshalb kann die Vorlage in einem Handy-Skript schlicht `{model}.webp` lauten.
**Die Adressen stehen nirgends in der Datenbank** — sie werden bei jeder
Antwort neu gebaut. Ein Umzug auf einen anderen Namen ist eine geänderte
Variable, kein Datenbankeingriff.
### Erlaubte Pfade
Jeder Teil zwischen den Schrägstrichen muss mit einem **Buchstaben oder einer
Ziffer** beginnen; danach sind zusätzlich Punkt, Strich und Unterstrich
erlaubt. Höchstens 8 Ebenen, höchstens 200 Zeichen.
Damit sind `..`, versteckte Dateien und alles mit Backslash von vornherein
draußen. Was nicht passt, wird **abgelehnt und nicht zurechtgebogen** — sonst
läge eine Datei unter einem anderen Namen als dem, den der Aufrufer erwartet,
und niemand merkte es, bis das Bild fehlt.
> Ein führender Unterstrich fällt durch. Das ist kein erfundener Fall: das
> Fotostudio legt seine Leerbilder als `_plate` ab.
### 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.
Für ein Skript heißt das: **die Vorlage arbeitet mit Kleinbuchstaben.**
```lua
local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item:lower())
```
Über `/api/upload` mit eigenem `X-Path` gilt das nicht — dort steht der Pfad
so, wie er geschickt wird.
---
## Dieselbe Adresse, zwei Fassungen
Neben jedem PNG und JPEG kann eine **WebP-Fassung** liegen. Ausgeliefert wird
sie unter **derselben Adresse** — welche der beiden kommt, entscheidet die
Kopfzeile `Accept` des Aufrufers:
```
Accept: …image/webp… → Content-Type: image/webp (klein)
kein Accept → Content-Type: image/png (das Original)
```
Nachgemessen an `probe/probe-webp.png`: 6448 Bytes für `curl`, 2758 Bytes für
einen Browser. **Für ein Skript ändert sich nichts** — Lua schickt kein
`Accept: image/webp` und bekommt weiterhin Byte für Byte das Original.
Zwei Dinge, die daran hängen:
- Die Antwort trägt `Vary: Accept`. Ohne die Zeile legte ein Zwischenspeicher
die WebP-Fassung für alle ab, auch für die, die sie nicht lesen können.
- Die beiden Fassungen haben **verschiedene ETags** (die WebP-Fassung endet auf
`-w`). Sonst bekäme jemand auf seinen `If-None-Match` hin ein 304 für das
falsche Bild.
Erzeugt werden die Fassungen beim Hochladen; für alles, was vorher schon da
war, gibt es im Dashboard unter **Speicher → Wartung** den Knopf
„Sparsame Fassungen erzeugen". Wo WebP nicht kleiner ausfällt, entsteht keine —
dann bleibt es beim Original.
---
## Umziehen von Fivemanage — ohne Lua anzufassen
Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem Dutzend
Ressourcen: Kamera, Handy, MDT, Fahrzeugstudio. Sie alle umzuschreiben heißt,
fremde Skripte anzufassen — und beim nächsten Update wieder.
**Der Dienst spricht deshalb ihre Sprache.** Der Umzug ist eine Zeile je Skript:
`api.fivemanage.com``media.d4rkst3r.de`. Der Schlüssel bleibt derselbe
Kopf, die Antwort dieselbe Form. Als Schlüssel setzt du einen **Token dieses
Dienstes** ein.
| Fivemanage | hier | Feld | Antwort |
|---|---|---|---|
| `POST /api/image` | ✅ gleich | `image` (oder `file`) | `{ "url": …, "id": …, "path": … }` |
| `POST /api/video` | ✅ gleich | `video` | dito |
| `POST /api/audio` | ✅ gleich | `audio` | dito |
| `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` |
Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so
wie bei Fivemanage. `Bearer …` geht weiterhin auch.
```lua
-- vorher
exports['screenshot-basic']:requestScreenshotUpload(
'https://api.fivemanage.com/api/image', 'image',
{ headers = { Authorization = apiKey } }, cb)
-- nachher — eine Zeile
exports['screenshot-basic']:requestScreenshotUpload(
'https://media.d4rkst3r.de/api/image', 'image',
{ headers = { Authorization = apiKey } }, cb)
```
**Wo die Dateien landen:** unter dem Präfix des Tokens (sonst `uploads/`), mit
dem Anfang des SHA-256 im Namen — `screenshots/shot-841814b4089b.png`. Das ist
Absicht: Bildschirmfotos heißen alle `screenshot.png`, und ohne eigenen Namen
je Aufruf überschriebe das zweite das erste. Gleicher Inhalt ergibt denselben
Namen, verschiedener Inhalt einen anderen — dieselbe Eigenschaft, die
Fivemanages ID auch hat.
**Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt
hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser
Dienst löscht nichts von selbst. Presigned URLs gibt es nicht: der Upload geht
direkt hierher.
> Beide Formen sind **aus echtem Code abgelesen**, nicht geraten:
> [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und
> [Awleks/Devm-Camera](https://github.com/Awleks/Devm-Camera) für den älteren
> Weg über `screenshot-basic`.
---
## Für eine Statusseite
Zwei Adressen, beide öffentlich, beide ohne Anmeldung — und sie beantworten
**verschiedene** Fragen.
```
GET /health -> 200 {"ok":true}
GET /status -> 200 | 503 mit Begründung
```
`/health` beweist genau eines: **der Prozess nimmt Anfragen an.** Daran hängt
auch der `HEALTHCHECK` des Containers, und dort ist eine billige Prüfung
richtig — eine schwere, die bei einer langsamen Platte einmal ausfällt, ließe
Docker den Container neu starten, also genau dann, wenn er unter Last steht.
`/status` sieht wirklich nach:
| Prüfung | wie |
|---|---|
| `datenbank` | eine echte Abfrage gegen eine echte Tabelle |
| `platte` | schreiben **und wieder löschen** — der einzige Beweis |
| `ausliefern` | eine zufällige Datei aus der Datenbank auf der Platte nachmessen |
| `bestand` | kein Eintrag, aber Dateien da? Dann stimmt etwas nicht |
| `platz` | ab 95 % belegt |
| `sicherung` | älter als 26 Stunden oder fehlgeschlagen |
```json
{ "dienst": "d4rk_media", "ok": true, "stand": "gesund", "seit": 4271,
"pruefungen": [ { "was": "datenbank", "ok": true }, ] }
```
**Zwei Stufen, und der Unterschied ist der zwischen „tut es nicht" und „braucht
Aufmerksamkeit".** Datenbank, Platte, Ausliefern und Bestand sind das Geschäft
dieses Dienstes — fällt eines aus, ist er `gestoert` und die Antwort ist
**503**. Eine alte Sicherung oder eine volle Platte machen ihn
`beeintraechtigt`: er liefert weiter aus, die Antwort bleibt **200**.
Wer auch das rot haben will, hängt `?streng=1` an — dann gibt alles außer
`gesund` eine 503.
Die Prüfung `sicherung` sagt zusätzlich, an **wie vielen Orten** das letzte
Archiv liegt (`an 3 Orten`, oder `nur an einem Ort`). Eine Sicherung, die es
nur einmal gibt, ist grün und trotzdem eine, die ein Plattenausfall mitnimmt.
### In Uptime Kuma
```
Dienst läuft https://media.d4rkst3r.de/status
Braucht Pflege https://media.d4rkst3r.de/status?streng=1
```
> **Warum `bestand` dabei ist.** Nachgemessen an genau diesem Dienst: wird eine
> Sicherung zurückgespielt, ohne `media.db-wal` daneben zu entfernen, spielt
> SQLite das WAL der **alten** Datenbank über die neue. Ergebnis: 0 Einträge,
> während 4452 Dateien danebenliegen. Der Dienst kommt hoch, `/health` ist
> grün, die Mediathek ist leer.
>
> ```
> /health sagt: HTTP 200
> /status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte
> ```
Was **nicht** hinausgeht: Dateizahlen, Größen, Pfade, Tokennamen, Benutzer. Die
einzige Zahl ist die Laufzeit in Sekunden.
---
## Anmeldung: Token
Alle Skript-Wege brauchen einen Token im Kopf:
```
Authorization: Bearer d4rk_…
```
Tokens werden im Dashboard angelegt. Der Klartext wird **genau einmal** gezeigt
— danach steht nur noch sein Hash in der Datenbank, und auch der Dienst kann
ihn nicht mehr herausgeben.
Ein Token kann **sechs** Fesseln haben — alle im Dashboard einstellbar, auch
nachträglich (`PATCH`, ohne dass sich der Schlüssel ändert):
| Fessel | Wirkung |
|---|---|
| Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` |
| darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 |
| größte Datei | schärfer als `MAX_UPLOAD_MB`; darüber 413 |
| Kontingent | wieviel dieser Token **insgesamt** halten darf; darüber 413 |
| erlaubte Arten | Bilder, Video, Ton, Dokumente, Anderes; sonst 403 |
| gültig bis | danach 401 mit dem Datum im Satz |
> **Das Kontingent ist etwas anderes als die Größe einer Datei.** Ein Token mit
> „höchstens 2 MB je Bild" konnte die Platte trotzdem füllen — es brauchte nur
> genug Bilder. Gezählt wird über das, was wirklich liegt, nicht über einen
> mitlaufenden Zähler:
>
> ```
> Kontingent erschoepft: dieser Token haelt 12 KB von 20 KB,
> und diese Datei braucht 12 KB.
> ```
### ⚠️ Bilder können kleiner zurückkommen, als sie hingingen
Ein Token kann eine **größte Kantenlänge** tragen. Ist sie gesetzt und das Bild
größer, rechnet der Dienst es herunter, **bevor** er es ablegt:
```
3000 × 2000 hingeschickt → 512 × 341 abgelegt, 82 KB → 3 KB
```
Für ein Skript heißt das zweierlei:
1. **`size` in der Antwort ist die Größe der abgelegten Datei**, nicht die der
geschickten.
2. **`sha256` ebenso.** Wer den Hash vor dem Upload selbst rechnet und mit der
Antwort vergleicht, bekommt bei gesetzter Kantenlänge einen Unterschied —
und der ist kein Fehler. Der Hash gehört zu dem, was liegt; alles andere
wäre gelogen.
Kleinere Bilder werden **nicht** aufgeblasen, und Video, Ton und Dokumente
bleiben unberührt. Ohne gesetzte Kantenlänge passiert gar nichts — der
Standard ist „ablegen, wie es kommt".
---
## POST /api/upload
Nimmt die Datei in einer von **drei Rumpfformen** entgegen:
1. `multipart/form-data` mit dem Feld `file`
2. Base64-Text, wenn `X-Encoding: base64` gesetzt ist
3. rohe Bytes, sonst
### Kopfzeilen
| Kopf | Bedeutung |
|---|---|
| `X-Path` | Wohin die Datei kommt. **Ohne ihn** wird aus dem SHA-256 ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar. |
| `X-Encoding` | `base64`, wenn der Rumpf Base64-Text ist. Ein Data-URL-Vorspann (`data:image/webp;base64,`) wird abgeschnitten. |
| `X-Overwrite` | `false` verweigert das Überschreiben und antwortet mit 409. Standard ist überschreiben. |
| `X-Mime` | Überschreibt die Art. Sonst entscheidet die Endung. |
### Antwort
```json
{
"url": "https://media.d4rkst3r.de/f/vehicles/adder.webp",
"path": "vehicles/adder.webp",
"size": 132760,
"sha256": "36cb4ddf…",
"mime": "image/webp",
"replaced": false
}
```
Trägt der Token eine Kantenlänge und wurde wirklich gerechnet, kommt ein Feld
dazu — sonst steht es gar nicht erst da, denn ein Feld, das immer dasteht, sagt
nichts:
```json
"verkleinert": { "von": "3000x2000", "auf": "512x341" }
```
### Was schiefgehen kann
| Antwort | Grund |
|---|---|
| 401 | Token fehlt oder ist unbekannt |
| 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) |
| 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 |
| 409 | `X-Overwrite: false` und der Pfad ist belegt |
| 413 | größer als `MAX_UPLOAD_MB` (Standard 64), **oder** größer als die Grenze dieses Tokens, **oder** sein Kontingent ist erschöpft — welches, steht im Satz |
Jede Fehlerantwort ist JSON mit einem Feld `error` und einem **Satz auf
Deutsch**. Ein nackter 500 ohne Text ist genau der Fehler, wegen dem dieser
Dienst gebaut wurde.
---
## Aus einer FiveM-Resource
```lua
local TOKEN = 'd4rk_…' -- im Dashboard anlegen
local function hochladen(pfad, bytes, cb)
PerformHttpRequest('https://media.d4rkst3r.de/api/upload',
function(status, body)
if status ~= 200 then return cb(nil, body) end
cb(json.decode(body).url)
end, 'POST', B64.encode(bytes), {
['Authorization'] = 'Bearer ' .. TOKEN,
['X-Path'] = pfad,
['X-Encoding'] = 'base64',
['Content-Type'] = 'text/plain; charset=utf-8',
})
end
```
**Warum Base64 und nicht die rohen Bytes.** `PerformHttpRequest` nimmt den
Rumpf als Lua-Zeichenkette, und ob Nullbytes darin unverändert ankommen, hat
hier niemand nachgemessen — ein PNG besteht zu gutem Teil daraus. Base64 kostet
ein Drittel mehr auf der Leitung und nimmt die Frage aus dem Weg. Nachgemessen
ist: Base64 kommt mit demselben SHA-256 an wie die rohen Bytes.
**Warum vom Server und nicht aus der NUI.** Die NUI läuft unter `https`, und
CEF hebt jede `http`-Adresse selbst dorthin an. Ein Upload aus dem Fenster
heraus wäre entweder Mixed Content oder ginge ins Leere.
---
## GET /api/exists?pfad=&lt;pfad&gt;
Für „nur fehlende" in einem Serienlauf: eine winzige Antwort gegen 130 KB
Upload.
> **Diese Form und nicht mehr `/api/exists/<pfad>`** — der Grund ist
> nachgemessen und unangenehm. Die alte Form endet auf `.webp`, und Nginx
> Proxy Managers `assets.conf` greift auf **jede** URL mit Bildendung:
>
> ```nginx
> 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;
> }
> ```
>
> Der Proxy legt die berechtigte Antwort weg und liefert sie danach **an jeden
> aus, auch ohne Token** — gemessen: 200 mit voller Auskunft, `Authorization`
> gar nicht gesetzt. Unser eigenes `Cache-Control` hilft nicht,
> `proxy_ignore_headers` wirft es weg. Und es geht auch andersherum: landet
> zuerst ein `401` im Zwischenspeicher, bekommen ihn 30 Minuten lang alle.
>
> Die Abfrageform endet nie auf eine Bildendung und ist deshalb **von sich aus**
> dicht — ohne von einer Zeile in einer fremden Oberfläche abzuhängen. Die alte
> Form bleibt, weil Skripte sie benutzen; neu gebaut wird mit dieser.
```json
{ "exists": true, "path": "vehicles/adder.webp",
"url": "…", "size": 132760, "sha256": "…", "updated_at": 1786466462488 }
```
> **Drei Zustände, nicht zwei.** Wer einen Netzfehler als „nicht da" durchgehen
> lässt, lädt bei einem Husten alles ein zweites Mal hoch. Ein Aufrufer sollte
> zwischen `exists: false` und „ich weiß es nicht" unterscheiden.
---
## DELETE /api/media/&lt;pfad&gt;
Braucht einen Token mit dem Recht **darf löschen**.
```json
{ "deleted": "vehicles/adder.webp" }
```
Löschen ist endgültig: die Datei ist von der Platte weg, und es gibt keinen
Papierkorb. Die tägliche Sicherung hilft nur bis zum letzten Lauf.
---
## Die Wege des Dashboards
Diese brauchen eine **Sitzung** und keinen Token — sie sind für die Oberfläche
gedacht. Hier stehen sie, weil man sie auch mit `curl` und einem Cookie
benutzen kann, wenn man etwas einmalig braucht.
| Weg | Wozu |
|---|---|
| `POST /api/dash/upload-url` | Der Dienst holt die Datei selbst von einer Adresse. Siehe unten. |
| `POST /api/dash/fehlende` | Muster + Namensliste → was fehlt, was überzählig ist |
| `POST /api/dash/media/ersetzen` | Suchen und Ersetzen über Pfade. **Ohne `ausfuehren: true` wird nur gerechnet und gezeigt.** |
| `POST /api/dash/media/umbenennen` | Eine Datei; die Antwort nennt ihre Abrufzahl |
| `POST /api/dash/folders/umbenennen` | Ein Ordner mit allem darin, in einer Transaktion |
| `PATCH /api/dash/tokens/:id` | Grenzen ändern. Weggelassene Felder bleiben, `null` löscht eine Grenze. |
| `GET /api/dash/verwaltung` | Wer hat wann was eingestellt |
| `GET /api/dash/verlauf/export` | Verlauf als CSV; `?was=verwaltung` für den anderen. Semikolon + CRLF, höchstens 20 000 Zeilen. |
| `GET /api/dash/einstellungen/export` | Alle Einstellungen als JSON. **Ohne Geheimnisse**, es sei denn `?geheim=1`. |
| `POST /api/dash/einstellungen/import` | Zurück. Leere Werte überschreiben nichts. |
| `GET/POST/DELETE /api/dash/freigaben` | Nur-Lesen-Links auf einen Ordner |
| `POST /api/dash/maintenance/thumbs` \| `/webp` | Abgeleitetes nachziehen, **in Runden zu 300** |
### POST /api/dash/upload-url
```json
{ "url": "https://…/bild.png", "ordner": "items", "name": "optional.png" }
```
**Das ist die vorsichtigste Stelle im ganzen Dienst.** Ein Server, der eine vom
Benutzer genannte Adresse abruft, ist ein Angriff mit eigenem Namen — deshalb
wird geprüft, und zwar die **aufgelöste IP** und nicht der Name:
| abgelehnt | Beispiel |
|---|---|
| alles außer http/https | `file://`, `gopher://` |
| andere Anschlüsse als 80/443 | `http://host:9101/` |
| Rückschleife und Heimnetz | `127.0.0.1`, `::1`, `10.x`, `172.1631.x`, `192.168.x`, `169.254.x`, `0.0.0.0` |
| öffentliche Namen auf private Adressen | `127.0.0.1.nip.io` |
Der letzte Punkt ist der eigentliche: ein auflösbarer Name, der auf eine private
Adresse zeigt, ist der Standardweg um jeden Namensfilter herum. **Und jede
Umleitung wird einzeln neu geprüft** — `fetch` folgen zu lassen wäre genau dort
die Lücke.
---
## Grenzen
| | |
|---|---|
| Größte Datei | 64 MB (`MAX_UPLOAD_MB`), je Token weiter einschränkbar |
| Kontingent je Token | einstellbar, ohne Grenze wenn leer |
| Pfadlänge | 200 Zeichen, höchstens 8 Ebenen |
| Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) |
| Sitzung | 30 Tage |
| Papierkorb | 30 Tage **und** höchstens 20 GB (`PAPIERKORB_MAX_MB`) — was zuerst greift, greift |
Der Papierkorb hat zwei Grenzen, weil eine nicht reicht: wer 200 GB löscht,
hält sie dreißig Tage lang doppelt. Beim Ausleeren fliegt erst, was zu alt ist,
dann das **Älteste**, bis der Deckel wieder passt. Die Zahl steht in der
Antwort von `GET /api/dash/papierkorb` als `maxBytes`.
---
## Meldungen nach Discord
Vier Anlässe, alle einzeln abschaltbar: `sicherung`, `verwaiste`, `platte`,
`upload`.
**`upload` meldet nicht sofort.** Jeder Upload schiebt eine Frist von zwei
Minuten nach hinten; erst wenn es ruhig bleibt, geht **eine** Nachricht raus —
mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
Das ist keine Bequemlichkeit, sondern der Grund, warum die Funktion überhaupt
brauchbar ist: ein Serienlauf des Fotostudios legt 900 Bilder ab. 900
Nachrichten wären keine Meldung mehr, sondern ein Ausfall des Kanals — Discord
drosselt Webhooks, und wer danach eine *echte* Meldung bekommt, sieht sie nicht
mehr.
---
## Der Zwischenspeicher des Proxys
Vor dem Dienst steht Nginx Proxy Manager. Dessen `assets.conf` greift über eine
Regel auf **jede URL, die auf `.webp`, `.png`, `.js`, `.css` … endet** — und
legt die Antwort für 30 Minuten weg, dabei ignoriert sie unsere eigenen
Kopfzeilen.
Das hatte zwei sichtbare Folgen, beide nachgemessen und beide behoben:
- Ein **überschriebenes Bild** blieb bis zu 30 Minuten alt, obwohl Upload und
Datenbank „fertig" sagten.
- `/api/exists/…webp` wurde mit Antwort zwischengespeichert und danach **auch
ohne Token** ausgeliefert.
Behoben durch drei Blöcke in der *Custom Nginx Configuration* des Proxy-Hosts:
```nginx
location ^~ /api/ { proxy_cache off; include conf.d/include/proxy.conf; }
location ^~ /f/ { proxy_cache off; include conf.d/include/proxy.conf; }
location ^~ /t/ { proxy_cache off; include conf.d/include/proxy.conf; }
```
Das `^~` ist der ganze Trick: eine gewöhnliche Präfix-Regel **verliert** gegen
die Regex aus `assets.conf`. In einem Wegwerf-Nginx nachgemessen.
---
## Export
Im Dashboard unter **API** — oder direkt, mit gültiger Sitzung:
```
/api/dash/export?format=json alles, was der Dienst weiß
/api/dash/export?format=csv Semikolon und CRLF, für Excel
/api/dash/export?format=lua fertige Tabelle Name → Adresse
/api/dash/export?format=lua&folder=vehicles
```
Die Lua-Fassung liest man in einer Resource als `Urls.adder` aus.
---
*Diese Seite wird aus `docs/` im Repo erzeugt — Änderungen hier gehen beim nächsten Lauf verloren.*
+67
View File
@@ -0,0 +1,67 @@
# d4rk_media
Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen,
ausliefern, in einem Dashboard verwalten.
**Läuft unter** https://media.d4rkst3r.de — Dashboard und API an der
Wurzel, Dateien unter `/f/`.
---
## Wo was steht
| | |
|---|---|
| **[API](API)** | Endpunkte, Kopfzeilen, Fehlermeldungen, Grenzen — und was beim Anbinden schiefgeht |
| `ROADMAP.md` im Repo | Zuschnitt, Stand, Reihenfolge, offene Entscheidungen |
| `CLAUDE.md` im Repo | die Arbeitsweise: prüfen statt raten |
Das Warum einer Änderung steht in der Commit-Nachricht, nicht nur im Diff.
---
## Der Zuschnitt in vier Sätzen
**Ein Container.** SQLite statt PostgreSQL, Dateien auf einer Platte statt
MinIO. Das ist nicht die kleine Lösung, sondern die, die man nicht pflegt.
**Der Pfad ist der Schlüssel.** `vehicles/adder.webp` bleibt
`vehicles/adder.webp`, damit die Vorlage im Handy `{model}.webp` lauten kann.
Die Adressen stehen nirgends in der Datenbank — sie werden bei jeder Antwort
neu gebaut.
**Jeder Knopf gibt Rückmeldung, auch der erfolgreiche.** Das ist der Fehler, an
dem der Fivemanage-Anlauf gescheitert ist: ein Knopf ohne Antwort sieht aus wie
ein kaputter Knopf, und das Ergebnis waren neunzehn gleichnamige
Organisationen.
**Nicht nachgebaut wird das Logging.** Dafür schleppt Fivemanage ClickHouse
mit, und genau da ist der Wartungsaufwand echt.
---
## Was der Dienst kann
- **Hochladen** über die Skript-API (Token) oder aus dem Dashboard (Sitzung) —
Bilder, Videos, Ton, PDF
- **Ausliefern** mit ETag, Bereichsanfragen und Vorschaubildern
- **Galerie** mit Ordnerbaum, Suche, Filter nach Medienart, Sortierung,
Mehrfachauswahl, Verschieben und Löschen
- **Verlauf**: wer hat wann was abgelegt, ersetzt, verschoben oder gelöscht
- **Anmeldung** über Discord (Rolle auf dem Server) oder Passwort
- **Sicherung** täglich, geprüft, in die Nextcloud
---
## Wenn etwas nicht geht
1. **Ein Bild ändert sich nicht.** Der Proxy hielt Bilder früher 30 Minuten
fest. Behoben — siehe [API](API), Abschnitt *Zwischenspeicher des Proxys*.
2. **Die Anmeldung sagt 200, aber man ist nicht angemeldet.** `NODE_ENV=production`
setzt das Cookie auf `Secure`; über eine nackte `http`-Adresse verwirft der
Browser es still. Über den Proxy mit TLS gibt es das Problem nicht.
3. **Ein Upload gibt 400 mit „ist als Pfadteil nicht erlaubt".** Jeder Teil
eines Pfades muss mit einem Buchstaben oder einer Ziffer beginnen. Ein
führender Unterstrich fällt durch.
4. **Ein Fehler ohne Text.** Sollte es nicht mehr geben: der Dienst hat eine
Auffanglinie, die auch bei unerwarteten Fehlern einen Satz mitgibt.
+253
View File
@@ -0,0 +1,253 @@
# Was noch ginge
Stand 12.08.2026. Diese Liste ist **nicht** eine Sammlung von allem, was
denkbar wäre — sie ist das, was beim Bauen und Messen wirklich aufgefallen ist.
Was hier steht, hat einen Grund; was keinen hatte, steht am Ende unter
„Verworfen, mit Begründung".
Sortiert nach dem, was ich zuerst täte.
---
## 1 · Sicher wertvoll
### ~~Die Oberfläche auf dem Telefon~~ — abgelehnt am 12.08.2026
> Vom Betreiber verworfen: das Dashboard wird nicht mobil benutzt. Der Rest des
> Abschnitts bleibt stehen, damit die Messung nicht verlorengeht, falls sich das
> je ändert.
**Gemessen:** 18 Umbruchpunkte (`sm:`/`lg:`/`xl:`) im ganzen Frontend. Die
Galerie ist ein Raster und passt sich an — die **vier Tabellen** in Verlauf,
Token, Konten und Papierkorb nicht. Auf einem Telefon laufen sie seitlich
heraus.
Der Umbau ist keine Kosmetik: Karten statt Tabellenzeilen unterhalb einer
Breite, so wie es die Galerie schon macht. Betrifft vier Dateien.
**Lohnt sich nur, wenn du das Dashboard mobil aufmachst.** Sonst ist es
Arbeit für einen Bildschirm, den niemand benutzt.
### Sitzungen beenden können
**Gemessen:** 86 offene Sitzungen, davon 82 von `admin` — meine Testanmeldungen
von heute. Sie laufen nach 30 Tagen aus, und `pruneSessions` räumt stündlich
die abgelaufenen weg. Was fehlt, ist ein Knopf **„alle anderen Sitzungen
beenden"** im Kontomenü.
Das ist mehr als Ordnung: wer sich an einem fremden Rechner angemeldet hat und
es später merkt, hat heute keine Möglichkeit, das zurückzunehmen — außer das
Passwort zu ändern und zu hoffen, dass das die Sitzungen mitnimmt (tut es
nicht, sie hängen an einer eigenen Tabelle).
### Was der Verlauf nicht sieht
**Gemessen:** `events` kennt `upload`, `replace`, `delete`, `move`, `rename`
alles über **Dateien**. Nichts über **Einstellungen**. Wer den Discord-Webhook
geändert hat, wer einen Token angelegt oder gelöscht hat, wer ein Konto
entfernt hat: steht nirgends.
Bei zwei Konten ist das verschmerzbar. Sobald ein drittes dazukommt — und über
die Discord-Rolle entsteht es von selbst — ist es die erste Frage, die man
stellt, wenn etwas anders ist als gestern.
---
## 2 · Nützlich, wenn der Fall eintritt
### Massen-Umbenennen
Suchen und Ersetzen über Pfade, mit Vorschau vor dem Zuschlagen. Bei 3578 Items
ist eine Umbenennung von Hand keine.
Der Anlass wäre etwa: `items/` nach `inventar/` verschieben, oder eine
Namenskonvention ändern. Heute geht das nur Datei für Datei oder gar nicht.
**Die Warnung wäre dieselbe wie beim Ordner-Umbenennen** — jede alte Adresse
gibt danach 404 —, nur mal tausend.
### Bilder beim Ablegen verkleinern
Eine Obergrenze für die Kantenlänge, einstellbar je Token. Wer ein 6000 × 4000
großes Foto hochlädt, bekommt es auf, sagen wir, 2048 gerechnet.
**Gemessen und deshalb keine Dringlichkeit:** ein 12000 × 12000 großes PNG hat
den Dienst *nicht* in Verlegenheit gebracht — Vorschau in 374 ms, Speicher bei
47 MB von 31 GB. libvips arbeitet kachelweise und dekodiert nie das ganze Bild.
Es geht also um Plattenplatz und Ladezeit beim Ausliefern, nicht um Stabilität.
### Fehlende Bilder finden
Eine Liste hereinreichen (Fahrzeugmodelle, Item-Namen) und beantwortet
bekommen: **wozu fehlt ein Bild?** Heute beantwortet das niemand — das
Fotostudio weiß, was es aufgenommen hat, der Dienst weiß, was liegt, und die
Differenz rechnet niemand aus.
Das wäre ein Endpunkt, der eine Liste entgegennimmt und die fehlenden
zurückgibt. Klein, und die Antwort auf „warum zeigt das Handy bei manchen
Autos kein Bild".
### Einstellungen aus- und einlesen
Discord-Zugang, Sicherungsziel, Vorlagen, Meldungsanlässe — alles steht in
`settings` und ist beim Neuaufsetzen von Hand nachzutragen. Ein Export als JSON
(**ohne** die Geheimnisse, oder mit einer ausdrücklichen Ansage) macht aus
einer Stunde Klickerei fünf Minuten.
---
## 3 · Wäre schön, drängt nicht
### ~~Verlauf als CSV~~ — gebaut am 12.08.2026
`GET /api/dash/verlauf/export`, wahlweise `?was=verwaltung`. Der Knopf sitzt in
der Reiterleiste und holt **den Verlauf, der gerade offen ist** — zwei Knöpfe
nebeneinander wären die Frage „welchen von beiden?" an einer Stelle, wo die
Antwort schon auf dem Bildschirm steht.
Semikolon und CRLF wie beim Bestands-Export, und aus demselben Grund: Excel in
deutscher Einstellung nimmt bei Komma alles in eine Spalte und bei nacktem LF
die halbe Datei in eine Zelle. Gemessen an der ausgelieferten Datei: 5364
Zeilen, 5364 CRLF, **kein einziges nacktes LF**.
### ~~Meldung bei Upload~~ — gebaut am 12.08.2026
Mit Drossel, und die ist der eigentliche Inhalt. Jeder Upload schiebt eine
Frist von zwei Minuten nach hinten; erst nach der Ruhe geht **eine** Nachricht
raus, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
Ohne das wäre die Funktion ein Schaden statt eines Nutzens: 900 Fahrzeugbilder
ergäben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte
Meldung bekommt, sieht sie nicht mehr. Ein Serienlauf ergibt jetzt eine Zeile,
ein einzelnes Bildschirmfoto ergibt eine Zeile — beide sagen dasselbe, nur mit
anderen Zahlen.
Der Anlass heißt `upload` und ist **abschaltbar wie die anderen drei**. Die
Liste steht an zwei Stellen (Server und Oberfläche) — genau die Verdopplung,
die bei `platte` schon einmal ein Kreuzchen verschluckt hat; deshalb steht
jetzt in beiden ein Verweis auf die andere. Nachgemessen: vier geschickt, vier
gespeichert.
### ~~Papierkorb mit Größendeckel~~ — gebaut am 12.08.2026
20 GB. Beim Ausleeren wird erst nach Alter geräumt (30 Tage) und dann nach
Größe, **das Älteste zuerst** — dessen Versehen wäre am ehesten schon
aufgefallen. Die Grenze steht in der Antwort von `/api/dash/papierkorb`, damit
die Oberfläche sie nicht ein zweites Mal hinschreibt.
Bei 910 GB frei ist das heute keine Grenze, sondern eine Zusicherung: der
Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
### Ein Ziel AUSSERHALB dieser Maschine — offen, aber der Weg steht fest
Nachgemessen am 12.08.2026: **es verlässt heute nichts diesen Rechner.** Auch
die Nextcloud nicht — sie läuft hier, in denselben Docker-Volumes, auf derselben
Platte 0 wie die Daten. Details in der ROADMAP.
**Wo dieser Rechner steht, ist dabei der entscheidende Punkt**, und er ist mir
zuerst durchgerutscht: es ist ein Windows-Root-Server **im Rechenzentrum**. Der
Betreiber sitzt zu Hause und ist per mRemoteNG verbunden. Das dreht die
Bewertung um — eine Platte zu Hause ist nicht „dasselbe Haus", sondern zwei
Gebäude, zwei Netze, zwei Stromkreise, zwei Anbieter. Besser wird ein
Sicherungsziel kaum.
| Weg | deckt ab | Haken |
|---|---|---|
| **Rechner zu Hause holt ab** | Platte, Rechner, Rechenzentrum, übernommener Server | muss laufen, wenn geholt wird |
| **5-TB-Platte am Router**, vom Heimrechner beschrieben | dasselbe | Router-USB ist langsam; für 337 MB je Nacht reicht es |
| **Discord-Kanal** (nur der Bot) | dasselbe | 9 MB je Datei — für d4rk_media mit 336 MB unbrauchbar |
| 5-TB-Platte **am Server** | nur Plattenausfall | dritte Platte im selben Gehäuse — `D:` kann das schon |
### Der erste Schritt ist gemacht: von Hand holen
Einstellungen → **Sicherungen holen** listet die Archive und gibt sie heraus
(nur lesend eingehängt, Namensmuster statt Filter, jeder Griff im
Verwaltungs-Verlauf). Das ersetzt keine geplante Abholung — aber es macht die
Auswärtskopie zu etwas, das man im Vorbeigehen erledigt, statt zu einem
Vorhaben. Was noch fehlt, ist, dass es **von allein** passiert.
### Warum HOLEN und nicht SCHICKEN
Ein Ziel, in das der Server selbst schreiben darf, ist auch eines, das ein
übernommener Server löschen kann. Wer vom anderen Ende abholt, hält **keine
Zugangsdaten auf der Maschine, die geschützt werden soll**. Das ist der eine
Unterschied, der im Ernstfall zählt.
### Was dafür schon dasteht
```
sshd Running, Automatic, lauscht auf 0.0.0.0:22 und [::]:22
Firewall "OpenSSH SSH Server (sshd)" -> Allow
sshd_config Subsystem sftp sftp-server.exe
AllowGroups administrators "openssh users" sshusers
AuthorizedKeysFile .ssh/authorized_keys
je Nacht d4rk_media 335,9 MB + d4rkbot 0,9 MB = 336,9 MB
```
**Auf der Serverseite fehlt also nichts** außer einem Konto, das nur lesen darf.
Der Rest ist ein Skript auf dem Heimrechner, das per SFTP zieht und N Stände
behält.
### Ein Fund am Rande, der dazugehört
In `sshd_config` steht **kein** `PasswordAuthentication no`. Der Standard von
OpenSSH ist `yes` — auf Port 22, offen ins Internet, auf einem Server, dessen
`AllowGroups` die **Administratoren** einschließt. Wer ohnehin ein
Sicherungskonto mit Schlüssel anlegt, sollte bei der Gelegenheit auf
Schlüssel-only umstellen.
---
### ~~Mehrere Sicherungsziele~~ — gebaut am 12.08.2026
Ein **zweites Laufwerk**, einstellbar im Panel unter Einstellungen → Sicherung.
Damit liegt jedes Archiv an drei Orten: `C:ackup`, das zweite Laufwerk und
die Nextcloud.
Die drei sind nicht dreimal dasselbe. Die Nextcloud hilft, wenn der ganze
Rechner weg ist — aber wer 335 MB (oder später 100 GB) zurückholen muss, lädt
sie über die Leitung. Das zweite Laufwerk ist in Minuten zurückgespielt.
**Und es warnt, wenn es dieselbe Platte ist.** Das ist der eigentliche Inhalt:
ein zweiter Ordner auf C: sieht im Panel genauso grün aus wie eine echte zweite
Platte und hilft gegen gar nichts. Verglichen wird die *physische* Platte, nicht
der Laufwerksbuchstabe — zwei Partitionen derselben NVMe sterben zusammen.
Nachgemessen in beide Richtungen: `C:` gegen `D:` ergibt Nr. 0 gegen Nr. 1 und
ein „andere Platte"; `C:` gegen `C:` ergibt zweimal Nr. 0 und die Warnung.
Ein echter Lauf: 4630 Einträge, 335,9 MB, auf beiden Zielen die Prüfsumme
`FFCE4D0A7310…` — identisch. Die Statusseite sagt seitdem
`sicherung ok an 3 Orten` statt nur `ok`.
---
## Verworfen, mit Begründung
Diese standen auf der Liste und sind **gemessen** wieder heruntergefallen. Sie
stehen hier, damit sie nicht beim nächsten Mal wieder aufschlagen.
| Idee | Warum nicht |
|---|---|
| Doppelte Dateien zusammenführen | 5 Gruppen, **0,0 MB** verschwendet. Es gibt nichts zu holen. |
| Aufräumen nach Alter | Alle vier Altersstufen im Bericht stehen auf **0 Dateien**. Es ist nichts alt. |
| Grenze für Bildmaße als *Schutz* | 12000 × 12000 → 374 ms, 47 MB. libvips streamt, die Gefahr gibt es nicht. |
| Plattenplatz-Wächter | **Gebaut.** 80/90/95 %, meldet nach Discord. |
| Kontingent je Token | **Gebaut**, auf beiden Upload-Wegen geprüft. |
| Eigene Statusseite bauen | `/status` reicht — der d4rkbot zeigt sie schon an. |
---
## Und was heute noch schnell repariert wurde
**`pruneEvents` lief nie von selbst.** Die Funktion gibt es seit dem ersten Tag
und sie räumt Ereignisse älter als 180 Tage weg — aufgerufen wurde sie aber nur
von `/maintenance/prune-sessions`, einem Weg, den nicht einmal die Oberfläche
anbietet. Die dokumentierte Aufbewahrung griff damit **nie**, und die Tabelle
wuchs für immer.
Zum Vergleich: `pruneSessions` hängt seit jeher an einem stündlichen Takt. Das
eine war verdrahtet, das andere nicht, und der Unterschied fiel niemandem auf,
weil beide in derselben Zeile stehen.
Gemessen: nach **einem** Tag mit Umzug und Serienlauf standen 5355 Zeilen in
`events`. Läuft jetzt täglich, mit zwei Minuten Verzögerung nach dem Start.
+91
View File
@@ -0,0 +1,91 @@
# d4rk_media — der Block fuer Nginx Proxy Manager
#
# WOHIN: NPM oeffnen -> Hosts -> Proxy Hosts -> media.d4rkst3r.de
# -> Edit -> Reiter "Advanced" -> Feld "Custom Nginx Configuration"
#
# WAS: den GESAMTEN Inhalt dieser Datei dort hineinkopieren. Was schon drin
# steht (der /f/-Block), ist unten enthalten -- also ersetzen, nicht
# anhaengen.
#
# DANACH: "Save" druecken. ERST DANN schreibt NPM seine Datei
# /data/nginx/proxy_host/9.conf neu und laedt nginx nach. Ohne Save
# passiert nichts, und das ist die wahrscheinlichste Erklaerung dafuer,
# dass der Block beim letzten Mal nicht angekommen ist.
#
# Die Kommentarzeilen duerfen mit hinein -- nginx kennt '#'. Die Syntax ist
# gegengeprueft: `nginx -t` in einem Wegwerf-Container, "syntax is ok".
#
# ----------------------------------------------------------------------------
# WARUM DAS UEBERHAUPT NOETIG IST
#
# NPM bindet in jeden Proxy-Host eine assets.conf ein. Deren Regel greift auf
# JEDE Adresse, die auf eine Bild- oder Skriptendung endet:
#
# location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|...)$ {
# proxy_cache public-cache;
# proxy_cache_key $host$request_uri; <- der Token zaehlt NICHT mit
# proxy_ignore_headers Set-Cookie Cache-Control Expires ...;
# proxy_cache_valid any 30m;
# proxy_hide_header Cache-Control;
# proxy_hide_header Vary;
# }
#
# Drei gemessene Folgen davon:
#
# 1. /api/exists/vehicles/adder.webp endet auf ".webp" und faellt hinein. Der
# Proxy legt die BERECHTIGTE Antwort weg und liefert sie danach an jeden
# aus -- nachgemessen: 200 mit voller Auskunft, ganz ohne Token. Und
# andersherum: landet zuerst ein 401 im Speicher, bekommen ihn 30 Minuten
# lang alle, auch mit gueltigem Token.
#
# 2. /t/… (die Vorschaubilder) tragen dadurch "Cache-Control: max-age=12799"
# statt unserer "public, max-age=60". Nach einem erneuten Upload zeigt die
# Galerie deshalb stundenlang die ALTE Vorschau.
#
# 3. "proxy_hide_header Vary" wuerde unser "Vary: Accept" wegwerfen. Daran
# haengt die WebP-Auslieferung: ohne die Zeile bekaeme ein Aufrufer, der
# kein WebP versteht, die WebP-Fassung aus dem Zwischenspeicher.
#
# NACHGEMESSEN AM LAUFENDEN DIENST, damit klar ist, dass das kein
# theoretisches Problem ist -- dieselbe Adresse, dreimal:
#
# GET /api/exists/probe-xyz.webp mit gueltigem Token -> 200
# GET /api/exists/probe-xyz.webp OHNE Token -> 200 (!)
# GET /api/exists/vehicles/adder.webp mit gueltigem Token -> 401 (!)
#
# Die zweite Zeile ist die Auskunft ohne Berechtigung. Die dritte ist derselbe
# Fehler andersherum: dort lag ein 401 im Speicher, und den bekam auch, wer
# einen gueltigen Token hatte.
#
# Das "^~" ist der ganze Trick: eine gewoehnliche Praefix-Location VERLIERT
# gegen eine Regex-Location. Mit "^~" gewinnt sie und die Regex kommt gar nicht
# mehr zum Zuge. In einem Wegwerf-nginx nachgemessen.
#
# NICHT ausgenommen wird /assets/ -- die gebaute Oberflaeche. Deren Dateinamen
# tragen einen Hash (index-DCMjxAfP.js), aendern sich also nie unter demselben
# Namen. Dort ist der Zwischenspeicher genau richtig.
# ----------------------------------------------------------------------------
# AB HIER IST ES KONFIGURATION.
# Die Dateien selbst. Sie koennen sich unter derselben Adresse aendern (ein
# neu aufgenommenes Fahrzeugbild ersetzt das alte), und sie tragen "Vary:
# Accept" fuer die sparsame WebP-Fassung.
location ^~ /f/ {
proxy_cache off;
include conf.d/include/proxy.conf;
}
# Die Vorschaubilder. Aendern sich, sobald ein Bild neu hochgeladen wird --
# und dann soll das Dashboard es sehen und nicht in vier Stunden.
location ^~ /t/ {
proxy_cache off;
include conf.d/include/proxy.conf;
}
# Die API. Hier haengt eine Zugangspruefung dran, und eine zwischengespeicherte
# Antwort kennt den Token nicht, der sie erlaubt hat.
location ^~ /api/ {
proxy_cache off;
include conf.d/include/proxy.conf;
}
+325
View File
@@ -0,0 +1,325 @@
# Einbruch in den Gitea, 12.08.2026
Kein Ausfall, kein Datenverlust, kein laufender Schadcode — aber ein echter
Schreibzugriff von außen. Das hier ist der Hergang, so wie er **gemessen**
wurde, und was daraus geändert wurde.
## Was passiert ist
```
21:13:56 POST /api/internal/manager/add-logger -> 200 OK
21:13:57 POST /api/internal/manager/remove-logger/... -> 200 OK
21:13:57 /data/gitea/home/.gitconfig neu geschrieben
```
Der Angreifer hat über Giteas **interne Manager-API** einen Datei-Logger auf
die `.gitconfig` gelegt, seine Nutzlast als Logzeile hineinschreiben lassen und
den Logger eine Sekunde später wieder entfernt. Der Vorgang räumt hinter sich
auf; ohne den Nebeneffekt wäre er unbemerkt geblieben.
Eingeschleust wurde ein `packObjectsHook` — ein Kommando, das Git bei jedem
`upload-pack` (also bei jedem Klonen und Ziehen) ausführt. Dekodiert:
- lädt `xmrig` von `111.90.158.58` bzw. `111.90.139.202`
- tarnt ihn als `/var/tmp/.gt_sys_health` mit Konfiguration daneben
- schürft gegen `gulf.moneroocean.stream:443`/`:80`, Worker-ID `gitea`,
Wallet `89wM69z64AMhAKAY…`
- verankert sich per `crontab` (`*/3 * * * *` und `@reboot`) und `/etc/cron.d/gt_sys`
- killt konkurrierende Miner und eigene Altfassungen
## Warum trotzdem nichts lief
**Die Nutzlast hat sich selbst zerschossen.** Die eingeschleuste Zeile beginnt
mit `[uploadpack]\n` — wobei das `\n` als **Text** in der Datei steht und nicht
als Zeilenumbruch. Git kann die Datei damit nicht lesen, bricht mit
`fatal: bad config line 23` ab, und der Hook wurde nie gültig.
Nachgemessen, im Container wie auf dem Wirt:
| | Befund |
|---|---|
| Miner-Prozess | keiner |
| `/var/tmp/.gt_*`, `/tmp/.gt_*` | nichts |
| crontab, `/etc/cron.d` | unverändert |
| Nutzlast in Repos | keine |
| Docker-Socket im Container | **nicht gemountet** — kein Ausbruch auf den Wirt |
| Gitea-Konten | eines, das eigene |
| Fremde Token oder SSH-Schlüssel | keine |
Der einzige Schaden war, dass **alle Git-Operationen ausfielen** — inklusive
der Pushes aus diesem Projekt. Der Angriff ist nur daran aufgefallen.
## Der Weg hinein
Gitea lag mit `0.0.0.0:3000` **direkt im Internet**, am Reverse-Proxy vorbei —
`http://88.218.224.10:3000/` antwortete mit 200. Damit war auch
`/api/internal/*` von außen erreichbar; geschützt nur durch das
`INTERNAL_TOKEN`.
**Woher der Angreifer das Token hatte, ließ sich nicht klären.** Es steht in
keinem Repo, und RDP war es nicht (siehe unten). Bleibt eine Lücke in Gitea
1.26.4 oder ein Leck, das hier nicht sichtbar ist. Genau deshalb ist die
Antwort nicht „Token tauschen", sondern **den Weg zumachen**.
### RDP: beschossen, nicht geknackt
Cloudflare meldete offene RDP-Server. Nachgesehen im Sicherheitsprotokoll,
14 Tage:
```
2756 fehlgeschlagene Anmeldungen, verteilt auf viele IPs
33 erfolgreiche von aussen -- alle von EINER IP, dem Anschluss des Betreibers
```
RDP war also **nicht** der Einbruchsweg. NLA ist aktiv. Trotzdem gehört der
Zugang eingegrenzt — 2756 Versuche sind ein Dauerbeschuss.
## Was geändert wurde
1. **`.gitconfig` gesäubert** — die zwei vergifteten Zeilen raus, 22 Zeilen
übrig. Git funktioniert wieder beim ersten Versuch.
2. **Port 3000 ist weg.** Nicht auf localhost gebunden, sondern **gar nicht
mehr veröffentlicht**. Von außen: keine Antwort.
3. **Der Proxy geht jetzt durchs interne Netz.** NPM hängt zusätzlich in
`gitea_default` und spricht `gitea:3000` an. Dafür musste der nginx-Resolver
auf Dockers eigenen DNS (`127.0.0.11`) umgestellt werden — der alte stammte
noch aus der Zeit, als NPM nur im `bridge`-Netz hing.
4. **`INTERNAL_TOKEN` und `SECRET_KEY` getauscht.** Gefahrlos hier, weil weder
2FA noch externe Anmeldequellen eingerichtet sind (beides nachgezählt: 0).
5. **`/api/internal/` gibt am Proxy jetzt 404.** Dritte Sperre: selbst wenn das
Token erneut leckt, ist der Weg von außen zu. Gitea ruft diese Pfade nur
containerintern über localhost.
6. **Frisches Abbild.** Der Container lief elf Tage mit dem Stand von damals —
`gitea/gitea:latest` heißt nicht, dass das Laufende aktuell ist.
Gegengeprüft, beides:
```
Port 3000 von aussen -> 000 (zu)
/api/internal/manager/add-logger -> 404 (gesperrt)
https://git.d4rkst3r.de/ -> 200
https://git.d4rkst3r.de/api/v1/version -> 200
grosse Uploads (LFS): 3 von 3 Direktiven noch da
Push aus diesem Projekt: geht, beim ersten Versuch
Repos gegen die lokalen Klone: identisch
```
## Was noch offen ist
- **Alle API-Token und Webhook-Geheimnisse als geleakt behandeln** und
tauschen: `GITEA_TOKEN`, das Token `Claude`, Devlog- und Gitea-Webhooks.
Nicht weil ein Diebstahl belegt ist, sondern weil er nicht ausgeschlossen
werden kann.
- **Admin-Passwort ändern.**
- ~~RDP eingrenzen~~ — erledigt, siehe unten.
- **Beweise** liegen unter `C:\backup\vorfall-2026-08-12\`: die vergiftete
`.gitconfig`, die ursprüngliche `app.ini`, das vollständige Gitea-Log
(24 MB) und die alte Compose-Datei.
## Die Lehre, die über diesen Fall hinausgeht
Der offene Port war **nie nötig**. Der Proxy hätte von Anfang an über das
interne Netz gehen können; die Veröffentlichung war Bequemlichkeit beim
Einrichten. Sie stand elf Tage, und in dieser Zeit hat jemand sie gefunden.
Und: aufgefallen ist es nicht, weil etwas überwacht wurde, sondern weil der
Angreifer geschludert hat. **Hätte er sein `\n` richtig gesetzt, liefe der
Miner noch** — und die einzige Spur wäre eine erhöhte CPU-Last gewesen, die
niemandem auffällt.
---
## Nachtrag 13.08.2026: RDP ist zu, der Zugang laeuft ueber einen Tunnel
`sshd` nimmt seit 00:2x **nur noch Schluessel** an — `PasswordAuthentication no`
*und* `KbdInteractiveAuthentication no`, denn unter Windows laeuft die
Passwortabfrage sonst ueber den zweiten Weg weiter. Die Zeilen stehen **vor**
dem ersten `Match`-Block; weiter unten waeren sie stillschweigend wirkungslos
gewesen.
```
Darkster@127.0.0.1: Permission denied (publickey).
```
RDP ist per Firewall gesperrt (TCP und UDP, alle Profile). Der Zugang geht
durch einen SSH-Tunnel; `tools/tunnel-einrichten.ps1` richtet ihn auf dem
Heimrechner als Hintergrundaufgabe ein — ohne Fenster, mit Neuaufbau bei
Abbruch. **Was niemand durchhaelt, wird umgangen, und am Ende steht wieder ein
offener Port.**
Beides gemessen, und zwar von der richtigen Seite:
```
auf dem Server 127.0.0.1:3389 erreichbar (der Tunnel traegt)
::1 Typ 7, Darkster (RDP kam durch)
vom Heimrechner Test-NetConnection 88.218.224.10 -Port 3389
TcpTestSucceeded : False (von aussen zu)
```
Die Messung vom Server aus taugte dafuer **nicht** — Verkehr an die eigene
oeffentliche Adresse durchlaeuft die Eingangsregeln nicht und meldete
faelschlich "erreichbar". Zweimal in dieser Nacht in dieselbe Falle getappt,
einmal bei Port 3000 und einmal bei 3389.
Seitdem laufen die Angriffe ins Leere:
```
00:53:12 Connection closed by invalid user root 31.77.227.120 [preauth]
alle 12 erfolgreichen Anmeldungen: eine IP, die des Betreibers
```
---
## Nachtrag 13.08.2026, vormittags: die Inventur, und drei Irrtümer von mir
### Was von außen wirklich offen war
Erst eine Messung **vom Heimrechner** hat die Lage geklärt — vom Server aus ist
sie nicht zu beantworten:
```
vorher nachher
80 True (gewollt) 80 True
81 True NPM-Verwaltung 81 False
135 True Windows-RPC 135 False
3306 True MariaDB 3306 False
445/3389/5985/9000/9443/11000 durchweg False
```
**Die Verwaltungsoberfläche des Reverse-Proxys und eine Datenbank standen offen
im Internet.** Beides ist jetzt per Firewall gesperrt; die NPM-Oberfläche
erreicht man weiter über `https://proxy.d4rkst3r.de`, weil sie containerintern
auf `127.0.0.1:81` läuft.
### ⚠️ Irrtum 1: „Docker geht an der Windows-Firewall vorbei"
**Falsch.** Port 81 wird von Docker veröffentlicht, und die Sperrregel greift
dort sehr wohl — nachgemessen von außen. Ich hatte das Gegenteil behauptet,
nachdem ich es *vom Server aus* geprüft hatte, und deswegen sogar eine Regel
wieder gelöscht. Verkehr an die eigene öffentliche Adresse durchläuft die
Eingangsregeln nicht; diese Messung kann die Frage gar nicht beantworten.
### ⚠️ Irrtum 2: „Der MariaDB-root hat kein Passwort"
**Falsch.** Er hat eines — das FiveM benutzt. Dass ich ohne hineinkam, liegt an
der Standardeinrichtung von MariaDB unter Windows:
```json
"plugin": "mysql_native_password",
"auth_or": [ { }, { "plugin": "gssapi", "authentication_string": "SID:BA" } ]
```
`auth_or` heißt „oder": Passwort **oder** Windows-Authentifizierung, und
`SID:BA` ist die Gruppe der lokalen Administratoren. Ich war als Administrator
unterwegs. Ein Angreifer aus dem Internet hat keine Windows-Identität — MySQL
fällt als Einbruchsweg aus.
Meine Prüfung davor war kaputt (`JSON_LENGTH` auf eine Zeichenkette gibt immer
`NULL`), und statt das zu bemerken, habe ich das Ergebnis geglaubt.
### Vergessene Dienste — der Verdacht des Betreibers war richtig
```
Caddy-AutoStart [Ready] beim Systemstart nie gelaufen
CloudflaredTunnel [Ready] beim Systemstart nie gelaufen
C:\Users\Darkster\.cloudflared\ Tunnel-Zugangsdaten vom 23.03.2026
```
Beide waren **aktiv** und wären beim nächsten Neustart angesprungen. Ein Tunnel
ist ein Eingang, der an jeder Firewall vorbeigeht, weil er von innen aufgebaut
wird. Umfang war eng (`fivem-api.d4rkst3r.de``localhost:30121`, wo nichts
lauscht), der Caddyfile leer. Beide Aufgaben sind **abgeschaltet, nicht
gelöscht**.
**Offen für den Betreiber:** den Tunnel `3a4a599b-…` im Cloudflare-Konto
löschen. Solange er dort registriert ist, sind die Zugangsdaten auf der Platte
gültig.
Außerdem gefunden: **Azure Arc** (vier Dienste, seit 04.08., Status
`Disconnected`, ohne Abonnement) — vermutlich aus dem Anbieter-Abbild. Und
**IPBan** läuft, ein Brute-Force-Blocker.
### Der Wächter
`tools/waechter.ps1`, alle 15 Minuten. Zählt die Ports, die auf allen Adressen
lauschen, und rechnet Prüfsummen über `sshd_config`,
`administrators_authorized_keys`, beide Compose-Dateien sowie Giteas `app.ini`
und `.gitconfig`. Gemeldet wird **nur bei Änderung**, und die Statusseite zeigt
den Stand.
Vor dem Einrichten an beiden Fällen bewiesen, statt es zu behaupten:
```
Testport 45999 geoeffnet -> "NEU offen: 45999"
Datei harmlos geaendert -> Meldung raus, HTTP 204
Datei zurueckgestellt -> "Wieder in Ordnung (Merker geleert)"
```
Er prüft zuerst, ob er überhaupt etwas sehen kann, und meldet sonst
„ungültig" statt „sauber" — die Lehre aus zwei Suchen, die genau das
verwechselt haben.
---
## Nachtrag 13.08.2026 — ein Schaden aus dem Aufräumen selbst
Beim Einrichten des Panels in `d4rk_gameserver` sollte ein Benutzer im Nginx
Proxy Manager angelegt werden. Die Oberfläche antwortete mit **„Internal
Error"**. Im Container-Log stand, was sie verschwieg:
```
insert into `user` ... - attempt to write a readonly database
```
**Der Nginx Proxy Manager konnte seit dem 12.08.2026, 22:11 Uhr, nichts mehr
speichern.** Der letzte erfolgreiche Schreibvorgang war um 22:06 Uhr.
### Die Ursache war das Aufräumen
```
lrwx------ 1 root root 64 Aug 11 16:04 18 -> /data/database.sqlite (deleted)
```
Der Prozess hielt einen Zeiger auf eine **gelöschte** Datei. Beim Aufräumen
nach dem Vorfall wurde `/data/database.sqlite` per `docker cp` zurückgeschrieben
— und `docker cp` **ersetzt** die Datei, statt sie zu überschreiben. Der seit
dem 01.08. laufende Prozess zeigte danach ins Leere. SQLite meldet das als
„readonly database", was in die Irre führt: Rechte, Eigentümer und Platz waren
alle in Ordnung (root, 905 GB frei, Verzeichnis beschreibbar).
### Was es fast gekostet hätte
Nicht den Betrieb — Lesen ging weiter, alle Seiten liefen, fünfzehn Stunden
lang fiel nichts auf. Aber:
| Wann | Was |
|---|---|
| ab 03.09.2026 | Erste Zertifikatsverlängerung, wäre still gescheitert |
| ab 03.10.2026 | `proxy`, `portainer` und `cdn` laufen in TLS-Warnungen |
Ohne Meldung, ohne Eintrag, ohne dass jemand etwas geändert hätte.
### Behoben
Container neu gestartet, **4 Sekunden** Ausfall. Danach zeigt der Dateizeiger
wieder auf die echte Datei, alle sechs Seiten antworten, und der Benutzer ließ
sich anlegen. Die Datenbank liegt vorher als
`database.sqlite.vor-neustart-2026-08-13` daneben.
### Die Lehre
**Eine Datei unter einem laufenden Dienst zu ersetzen ist etwas anderes, als
sie zu überschreiben.** Richtig wäre gewesen:
```sh
docker exec <container> sh -c 'cat /pfad/neu > /data/database.sqlite' # behält die Inode
# oder
docker cp neu <container>:/data/database.sqlite && docker restart <container>
```
Und der zweite Teil derselben Lehre: der Fehler war **fünfzehn Stunden lang
unsichtbar**, weil nur Schreibvorgänge betroffen waren und niemand welche
auslöste. Der Wächter in `tools/waechter.ps1` hätte ihn nicht gefunden — er
zählt Ports und rechnet Prüfsummen, er versucht nirgends zu schreiben. Ob eine
Schreibprobe je Dienst dazugehört, steht in `ROADMAP.md` als offene Frage.
+14
View File
@@ -0,0 +1,14 @@
# Der Fivemanage-Anlauf
Was hier liegt, hat den Lite-Stack betrieben: PostgreSQL, MinIO, ClickHouse,
Dashboard. Der Stack lief; woran er gescheitert ist, steht in der ROADMAP
unter "Warum nicht Fivemanage Lite" — kurz: eine Oberflaeche, in der jeder
Fehlschlag lautlos verschluckt wird.
**Die Container sind am 11.08.2026 geloescht worden**, dazu die drei Volumes
(nachgezaehlt: kein einziges Bild darin) und die Abbilder. Diese Dateien
bleiben als Beleg und weil in ihren Kommentaren Dinge stehen, die uns wieder
begegnen — Portainer nimmt unter "Compose path" genau EINE Datei, Host-Ports
gehoeren an 0.0.0.0 und nicht an 127.0.0.1.
Nichts hiervon wird noch gestartet.
+130
View File
@@ -0,0 +1,130 @@
# Fivemanage Lite — fuer den Betrieb, ueber Portainer aus diesem Repo.
#
# Abgeleitet aus den beiden Compose-Dateien im Projekt (fivemanage/lite) und
# dem README. Deren docker-compose.yml ist fuer die ENTWICKLUNG: sie startet
# Datenbank, ClickHouse, MinIO und Jaeger, aber nicht die Anwendung, und mit
# Standardpasswoertern. Die docker-compose.test.yml zeigt dafuer, wie der
# App-Container verdrahtet wird — und dass MySQL genuegt.
#
# Bewusst NICHT dabei:
#
# ClickHouse und Jaeger. Beides ist strukturiertes Logging und Tracing. Fuer
# das Ablegen von Bildern braucht es sie nicht, und ClickHouse ist eine
# schwere Abhaengigkeit. Nachruesten geht spaeter.
#
# Der Weg nach aussen steht HIER DRIN und nicht in einer Ergaenzungsdatei.
# Zuerst war er ausgelagert, weil sich je Aufbau unterscheidet, wie ein
# Reverse Proxy seine Ziele findet. In Portainer nimmt "Compose path" aber
# EINE Datei — die Ergaenzung wurde stillschweigend nicht angewandt, und das
# Ergebnis war ein Stack, in dem jeder Container gesund meldet und trotzdem
# alles 502 liefert. Eine Trennung, die man nicht anwenden kann, ist keine.
#
# Wer den Proxy im selben Docker-Netz hat, nimmt docker-compose.proxynet.yml
# dazu — das ist der sauberere Weg, aber nicht der hier gegangene.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: fivemanage_lite
POSTGRES_USER: lite
POSTGRES_PASSWORD: ${DB_PASSWORD:?bitte in .env oder als Stack-Variable setzen}
volumes:
- pgdata:/var/lib/postgresql/data
networks: [internal]
healthcheck:
# Ohne das startet die App gegen eine Datenbank, die noch nicht
# antwortet, und beendet sich — beim ersten Hochfahren jedes Mal.
test: ["CMD-SHELL", "pg_isready -U lite -d fivemanage_lite"]
interval: 10s
timeout: 5s
retries: 12
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: lite
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?bitte setzen}
MINIO_SERVER_URL: ${PUBLIC_URL:?bitte setzen}
volumes:
- minio:/data
networks: [internal]
ports:
# Die Dateien, fuer den Reverse Proxy ueber den Host:
# fivecdn.d4rkst3r.de -> http://host.docker.internal:9100
#
# 9100 statt 9000, weil 9000 auf diesem Server an Portainer geht. Im
# Container bleibt es 9000; nur die Seite zum Host wandert.
#
# An alle Adressen gebunden, und das ist kein Versehen: der Nginx Proxy
# Manager liegt im Standard-Bridge-Netz und kommt ueber die Docker-
# Bruecke herein, nicht ueber Loopback — ein an 127.0.0.1 gebundener
# Port nimmt diese Anfrage nicht an. Genauso laufen hier schon d4rkbot
# (3080) und cdn-files (8090).
#
# ZU IST DER PORT DURCH DIE FIREWALL, nicht durch die Bindung. 9100 und
# 9101 gehoeren von aussen dicht; oeffentlich soll nur NPM auf 80/443
# sein. ss -tlnp | grep -E '9100|9101'
- "${BIND_ADDR:-0.0.0.0}:${MINIO_HOST_PORT:-9100}:9000"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 12
lite:
# ghcr.io, NICHT Docker Hub: dort steht die Reihe bei 0.1.0-beta.16
# still, auf ghcr.io laeuft sie weiter. Die Test-Compose des Projekts
# zeigt `fivemanage/lite:latest` — das ist Docker Hub und damit alt.
image: ghcr.io/fivemanage/lite:0.1.0-beta.23
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
environment:
PORT: "8080"
ENV: production
# PostgreSQL, und zwar zwingend: beta.23 geht direkt in den
# pgdriver — DB_DRIVER wird nicht ausgewertet. Eine MySQL-DSN endet in
# "pgdriver: invalid scheme: lite", weil der Treiber sie als URI liest.
#
# Die docker-compose.test.yml des Projekts zeigt MySQL, benutzt dafuer
# aber fivemanage/lite:latest von Docker Hub — und die Reihe steht dort
# bei beta.16 still. Das README hat recht, die Testdatei ist alt.
DSN: postgres://lite:${DB_PASSWORD:?bitte setzen}@db:5432/fivemanage_lite?sslmode=disable
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?bitte setzen}
API_TOKEN_HMAC_SECRET: ${API_TOKEN_HMAC_SECRET:?bitte setzen}
S3_PROVIDER: minio
AWS_ENDPOINT: http://minio:9000
AWS_ACCESS_KEY_ID: lite
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?bitte setzen}
AWS_BUCKET: media
# Aus der Vorlage des Projekts uebernommen; fuer MinIO ist die Region
# gleichgueltig, das SDK verlangt aber eine.
AWS_REGION: eu-west-1
# Woraus die OEFFENTLICHE Adresse gebaut wird, die Lite nach dem Upload
# zurueckgibt. Steht nicht im README, nur in deren .env.template — und
# ohne sie kaemen Adressen zurueck, die nur innerhalb von Docker gelten.
#
# Endpunkt plus Bucket, wie in der Vorlage
# (BUCKET_DOMAIN=http://localhost:9000/lite-dev).
BUCKET_DOMAIN: ${PUBLIC_URL:?bitte setzen}/media
ports:
# Die Oberflaeche: fivemanage.d4rkst3r.de -> http://host.docker.internal:9101
# 9101 statt 8080, weil 8080 an nextcloud-aio geht. Siehe minio oben.
- "${BIND_ADDR:-0.0.0.0}:${LITE_HOST_PORT:-9101}:8080"
networks: [internal]
networks:
internal:
driver: bridge
volumes:
pgdata:
minio:
+19
View File
@@ -0,0 +1,19 @@
# Was nicht ins Abbild gehoert. node_modules besonders: die enthalten eine
# native Binaerdatei fuer WINDOWS, und die im Abbild waere unbrauchbar.
node_modules/
dist/
# Dasselbe fuer die Oberflaeche. `node_modules/` oben greift NUR auf oberster
# Ebene -- ohne diese Zeile wandern ein paar hundert Megabyte Windows-Pakete in
# den Bauzusammenhang und `npm ci` im Abbild baut auf ihnen auf.
ui/node_modules/
# Der Build der Oberflaeche vom Entwicklungsrechner. Im Abbild entsteht er neu
# (Stufe "web"); der hier waere nur eine zweite, aeltere Wahrheit.
web/
# Die Ablage des Dienstes. Gehoert ins Volume, nie ins Abbild.
data/
# Das echte Geheimnis. Die Werte kommen als Stack-Variablen, nicht als Datei.
.env
+22 -16
View File
@@ -3,26 +3,32 @@
# Ohne PUBLIC_URL und ADMIN_PASSWORD startet der Dienst nicht — das ist
# Absicht, siehe config.ts.
# Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL
# gebaut, die nach dem Upload zurueckkommt. Ohne abschliessenden Schraegstrich.
PUBLIC_URL=http://localhost:8080/f
# Unter welcher Adresse der Dienst oeffentlich steht. NUR der Ursprung, ohne
# Pfad und ohne abschliessenden Schraegstrich.
#
# Das /f/ haengt der Dienst selbst an. Wer es hier mitschreibt, bekommt
# Adressen mit /f/f/ — und das faellt erst auf, wenn das erste Bild fehlt.
#
# In Produktion also: PUBLIC_URL=https://media.d4rkst3r.de
PUBLIC_URL=http://localhost:8080
# DER STOLPERSTEIN BEIM LOKALEN START.
# EIN NAME ODER ZWEI.
#
# Ohne diese Zeile nimmt der Dienst den Host aus PUBLIC_URL — lokal also
# "localhost". Dann gilt jede Anfrage als Anfrage an den Dateiwirt, und dort
# gibt es absichtlich weder Dashboard noch API: /health antwortet 404, die
# Anmeldung 405 "hier gibt es nur Dateien". Nichts ist kaputt, der Dienst tut
# genau das, was er soll — nur eben unter dem einen Namen, den man lokal hat.
# Leer lassen (Standard) heisst EIN Name: Dashboard und API an der Wurzel,
# Dateien unter /f/. Die zurueckgegebene Adresse lautet dann
# https://media.d4rkst3r.de/f/vehicles/adder.webp. Ein DNS-Eintrag, ein Host
# im Proxy — dafuer traegt die oeffentliche Adresse auch das Anmeldeformular.
#
# In Produktion sind es zwei verschiedene Namen und der Standard stimmt:
# PUBLIC_URL=https://fivecdn.d4rkst3r.de
# FILES_HOST=fivecdn.d4rkst3r.de (Dashboard: fivemanage.d4rkst3r.de)
# Wer hier einen Hostnamen eintraegt, bekommt ZWEI Namen: unter diesem gibt es
# dann ausschliesslich Dateien, kein Dashboard, keine API, nichts anzumelden.
# Die URLs kommen ohne /f/ aus, und der Name, den jeder Spieler kennt, hat
# keine Angriffsflaeche. Beide Namen muessen im Proxy auf denselben Port
# zeigen; PUBLIC_URL ist dann der Datei-Name:
# PUBLIC_URL=https://cdn.d4rkst3r.de
# FILES_HOST=cdn.d4rkst3r.de
#
# Lokal deshalb ein Name, den niemand aufruft — dann bleibt localhost fuer
# Dashboard und API frei. Zum Testen des Dateiwirts:
# curl -H "Host: files.localhost" http://localhost:8080/vehicles/adder.webp
FILES_HOST=files.localhost
# Welche Betriebsart laeuft, sagt der Dienst beim Start.
FILES_HOST=
# Wird beim ERSTEN Start angelegt. Ein spaeter geaendertes Passwort setzt das
# bestehende nicht zurueck.
+113
View File
@@ -0,0 +1,113 @@
# Das Abbild: ein Prozess, eine Platte, sonst nichts.
#
# NODE 22 IST FESTGENAGELT, und das ist kein Zufall. better-sqlite3 ist ein
# nativer Baustein und laedt beim Installieren eine fertige Binaerdatei
# herunter (`prebuild-install || node-gyp rebuild`). Fuer 11.10.0 gibt es die
# nur fuer diese ABI-Versionen:
#
# Node 20 -> ABI 115 vorhanden
# Node 22 -> ABI 127 vorhanden <- wir
# Node 24 -> ABI 137 FEHLT
#
# Wer auf 24 hochzieht, faellt auf node-gyp zurueck, und das braucht python3,
# make und g++ im Abbild. Der Bau bricht dann ab -- laut und sofort, immerhin.
# Vor einem Versionssprung also nachsehen, ob es die ABI als Binaerdatei gibt:
# https://github.com/WiseLibs/better-sqlite3/releases
#
# bookworm-slim und nicht alpine: musl-Binaerdateien gibt es zwar auch, aber
# der glibc-Weg ist der ausgetretene. Das Abbild ist dafuer groesser -- bei
# einem Container auf dem eigenen Server ist das der guenstigere Tausch.
# ------------------------------------------------------------ Abhaengigkeiten
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Nur was der Betrieb braucht. Hier laeuft prebuild-install und holt die
# native Binaerdatei fuer diese Node-Version.
RUN npm ci --omit=dev
# --------------------------------------------------------------- Oberflaeche
# Eigene Stufe, damit ein geaendertes Dashboard nicht den Server neu uebersetzt
# und umgekehrt. Was hier entsteht, ist reines HTML/CSS/JS -- im Betrieb laeuft
# davon nichts, es wird nur ausgeliefert.
FROM node:22-bookworm-slim AS web
WORKDIR /ui
COPY ui/package.json ui/package-lock.json ./
# Hier bewusst OHNE --omit=dev: vite und tsc sind devDependencies, und ohne sie
# gibt es nichts zu bauen.
RUN npm ci
COPY ui/ ./
# vite.config.ts schreibt nach ../web, also /web. Wer das dort aendert, aendert
# die Zeile im Betriebsabbild mit.
RUN npm run build
# -------------------------------------------------------------------- Bauen
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ------------------------------------------------------------------ Betrieb
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# ffmpeg -- fuer EIN EINZIGES Bild je Video, und trotzdem die groesste einzelne
# Zutat dieses Abbilds.
#
# DIE ABWAEGUNG, ausgesprochen: eine Kachel ohne Vorschau ist ein Fragezeichen.
# Bei 3600 Bildern faellt das nicht auf, bei einer Handvoll Clips ist die
# Galerie dann eine Liste von Dateinamen -- und dafuer braucht es keine
# Galerie. Ein Standbild kann sharp nicht liefern: libvips dekodiert kein
# Video, das ist kein Einstellungsproblem.
#
# Gerechnet wird NUR beim Hochladen und beim Nachziehen, nie beim Ausliefern.
# ffmpeg laeuft also nicht im Anfrageweg.
#
# WARUM NICHT `apt-get install ffmpeg` -- nachgemessen und wieder verworfen:
# Debians Paket schaltet jeden Filter frei und zieht die Abhaengigkeiten dafuer
# mit. Das Abbild wuchs von 460 MB auf 1,11 GB, und die groessten Brocken
# waren libllvm15 (114 MB), Mesa samt libgl1-mesa-dri (25 MB), libz3 (22 MB)
# und libflite1 (27 MB, ein SPRACHSYNTHESIZER). Alles fuer Filter, die ein
# Dienst ohne Bildschirm nie anfasst; --no-install-recommends half nicht, denn
# es sind harte Abhaengigkeiten.
#
# Also die statischen Binaerdateien, auf eine Fassung FESTGENAGELT. Der Preis
# dafuer ist ehrlich zu benennen: Aktualisierungen kommen nicht mehr ueber apt,
# sondern nur, wenn jemand diese Zeile anfasst. Vertretbar, weil ffmpeg hier
# nur eigene, bereits abgelegte Dateien anfasst und nicht am Netz haengt.
# NUR ffmpeg, NICHT ffprobe: das zweite Werkzeug waegt noch einmal 135 MB und
# haette hier eine einzige Aufgabe gehabt -- die Laenge zu nennen. Die steht
# auch im Kopf, den ffmpeg beim Oeffnen druckt; nachgemessen an beiden Dateien,
# auf zwei Nachkommastellen identisch (siehe medienDauer in storage.ts).
COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /usr/local/bin/
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# Die gebaute Oberflaeche. Der Dienst liefert sie aus, wenn sie da ist, und
# sagt sonst, dass sie fehlt (siehe index.ts) -- diese Auskunft bleibt als
# Netz, falls die Stufe oben je leer ausgeht.
COPY --from=web /web ./web
# Ein Ordner fuer Bilder UND media.db, damit eine Sicherung aus genau einem
# Kopiervorgang ueber ein Volume besteht.
ENV DATA_DIR=/data
RUN mkdir -p /data && chown -R node:node /data /app
# Nicht als root. Der Dienst nimmt Dateien von aussen entgegen und schreibt sie
# auf die Platte -- das ist die Stelle, an der man das nicht will.
USER node
ENV PORT=8080
EXPOSE 8080
# Ohne curl im Abbild: Node kann das selbst, fetch ist ab 18 eingebaut.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/index.js"]
+623 -1
View File
@@ -10,7 +10,9 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"hono": "^4.6.14"
"fflate": "^0.8.3",
"hono": "^4.6.14",
"sharp": "^0.35.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
@@ -19,6 +21,16 @@
"typescript": "^5.7.2"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -473,6 +485,554 @@
"hono": "^4"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -667,6 +1227,12 @@
"node": ">=6"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -893,6 +1459,55 @@
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -984,6 +1599,13 @@
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
+3 -1
View File
@@ -11,7 +11,9 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"hono": "^4.6.14"
"fflate": "^0.8.3",
"hono": "^4.6.14",
"sharp": "^0.35.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
+90 -7
View File
@@ -50,6 +50,13 @@ export function createSession(userId: number): { id: string; expiresAt: number }
return { id, expiresAt }
}
/** Wann jemand zuletzt da war -- hoechstens einmal je Stunde geschrieben.
*
* Bei jedem Aufruf zu schreiben waere ein UPDATE je Anfrage, und das Dashboard
* stellt beim Blaettern durch 3600 Bilder eine Menge davon. Auf die Stunde
* genau reicht fuer die Frage "benutzt das noch jemand" vollauf. */
const STUNDE = 3600_000
export function userForSession(sessionId: string | undefined): User | null {
if (!sessionId) return null
const row = db
@@ -59,7 +66,39 @@ export function userForSession(sessionId: string | undefined): User | null {
WHERE s.id = ? AND s.expires_at > ?`,
)
.get(sessionId, now()) as User | undefined
return row ?? null
if (!row) return null
const jetzt = now()
if (!row.zuletzt_da || jetzt - row.zuletzt_da > STUNDE) {
db.prepare('UPDATE users SET zuletzt_da = ? WHERE id = ?').run(jetzt, row.id)
}
return row
}
/** Alle Sitzungen eines Benutzers beenden -- ausser der laufenden.
*
* WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt,
* hatte bisher keine Moeglichkeit, das zurueckzunehmen. Das Passwort zu
* aendern half nicht -- die Sitzungen haengen an einer eigenen Tabelle und
* ueberleben es.
*
* Die EIGENE bleibt stehen: wer auf den Knopf drueckt, will sich nicht selbst
* aussperren. */
export function destroyOtherSessions(userId: number, behalten: string): number {
const r = db
.prepare('DELETE FROM sessions WHERE user_id = ? AND id <> ?')
.run(userId, behalten)
return r.changes
}
/** Wie viele Sitzungen gerade offen sind. Abgelaufene zaehlen nicht mit --
* sie sind nur noch nicht weggeraeumt. */
export function countSessions(userId: number): number {
return (
db
.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?')
.get(userId, now()) as { n: number }
).n
}
export function destroySession(sessionId: string) {
@@ -85,12 +124,22 @@ export function tokenHash(token: string): string {
* bekommt. */
export function tokenFromHeader(header: string | undefined): Token | null {
if (!header) return null
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
if (!match?.[1]) return null
// "Bearer xyz" ODER nur "xyz" -- und das zweite ist nicht Bequemlichkeit,
// sondern Vertraeglichkeit: Fivemanage nimmt den Schluessel NACKT im
// Authorization-Kopf entgegen, und genau so steht er in jedem FiveM-Skript,
// das dorthin hochlaedt (nachgesehen im SDK und in Devm-Camera). Wer von
// dort umzieht, soll nur die Adresse tauschen muessen.
//
// Ein Risiko ist das keines: geprueft wird ohnehin der Hash gegen die
// Tabelle. Ein Kopf, der zu keinem Token gehoert, faellt so oder so durch.
const roh = header.trim()
const wert = /^Bearer\s+(.+)$/i.exec(roh)?.[1] ?? roh
if (!wert) return null
const row = db
.prepare('SELECT * FROM tokens WHERE hash = ?')
.get(tokenHash(match[1])) as Token | undefined
.get(tokenHash(wert)) as Token | undefined
if (!row) return null
db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id)
@@ -98,10 +147,44 @@ export function tokenFromHeader(header: string | undefined): Token | null {
}
/** 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 {
if (!token.prefix) return true
const prefix = token.prefix.replace(/\/+$/, '') + '/'
return path.startsWith(prefix)
const fesseln = tokenPrefixes(token)
// Keine Fessel heisst ueberall -- so war es, so bleibt es.
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
+48
View File
@@ -0,0 +1,48 @@
// Eine Sicherung der Datenbank, die auch stimmt, waehrend geschrieben wird.
//
// WARUM NICHT EINFACH KOPIEREN. media.db laeuft im WAL-Modus. Was zuletzt
// hochgeladen wurde, steht dann noch in media.db-wal und nicht in media.db —
// eine Kopie nur der einen Datei ist deshalb still unvollstaendig. Beim ersten
// Nachsehen im Container war das WAL nach wenigen Uploads 152 KB gross.
//
// Und selbst alle drei Dateien zu kopieren reicht nicht sicher: waehrend des
// Kopierens kann geschrieben werden, und dann passen sie nicht mehr zueinander.
//
// Deshalb SQLites eigene Sicherungsschnittstelle. Sie laeuft auf der offenen
// Datenbank, nimmt eine in sich stimmige Aufnahme und faengt von vorn an, wenn
// dazwischen geschrieben wurde. Das Ergebnis ist EINE Datei ohne WAL daneben.
//
// Aufruf im Container:
//
// node dist/backup.js /data/.sicherung/media.db
//
// Der Rest — Bilder dazupacken, herausholen, pruefen — steht in
// tools/sichern.ps1; hier bleibt nur der Teil, der die Datenbank kennt.
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { db } from './db.js'
const ziel = process.argv[2]
if (!ziel) {
console.error('[sicherung] Kein Ziel angegeben. Aufruf: node dist/backup.js <pfad>')
process.exit(2)
}
mkdirSync(dirname(ziel), { recursive: true })
const zeilen = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
db.backup(ziel)
.then(() => {
// Die Zeilenzahl mit ausgeben: sie ist der Wert, gegen den die
// Sicherung hinterher geprueft wird. Eine Sicherung, die niemand
// nachzaehlt, ist eine Vermutung.
console.log(`[sicherung] ${ziel} geschrieben, ${zeilen.n} Medieneintraege`)
process.exit(0)
})
.catch((err: unknown) => {
console.error(`[sicherung] fehlgeschlagen: ${err instanceof Error ? err.message : err}`)
process.exit(1)
})
+29 -12
View File
@@ -40,11 +40,30 @@ for (const dir of [dataDir, filesDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
}
/** Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL
/** Unter welcher Adresse der Dienst oeffentlich steht. Daraus wird die URL
* gebaut, die nach dem Upload zurueckkommt — und die im Handy in der Vorlage
* steht. Ohne abschliessenden Schraegstrich. */
* steht. Nur der Ursprung, ohne Pfad und ohne abschliessenden Schraegstrich:
* https://media.d4rkst3r.de
*
* Das /f/ im Ein-Namen-Betrieb haengt der Dienst selbst an. Wer es hier
* mitschriebe, bekaeme Adressen mit /f/f/ — und die faende niemand, bis das
* erste Bild fehlt. */
const publicUrl = required('PUBLIC_URL').replace(/\/+$/, '')
/** Ein Name oder zwei?
*
* Gesetzt → ZWEI Namen: unter diesem Host gibt es ausschliesslich Dateien,
* kein Dashboard, keine API. Die oeffentliche Adresse hat damit
* keine Angriffsflaeche, und die URLs kommen ohne Praefix aus.
* Leer → EIN Name: Dashboard und API an der Wurzel, Dateien unter /f/.
*
* Frueher war der Standard der Host aus PUBLIC_URL. Das ist bei zwei Namen
* richtig und bei einem toedlich: dann gilt JEDE Anfrage als Anfrage an den
* Dateiwirt, /health antwortet 404 und die Anmeldung 405 "hier gibt es nur
* Dateien". Nichts ist kaputt, und niemand kommt darauf. Deshalb ist der
* Ein-Namen-Betrieb jetzt der Standard und der zweite Name die Ansage. */
const filesHost = (process.env.FILES_HOST ?? '').trim().toLowerCase()
export const config = {
port: number('PORT', 8080),
dataDir,
@@ -53,16 +72,14 @@ export const config = {
publicUrl,
/** Der Hostname, unter dem NUR Dateien ausgeliefert werden.
*
* Trifft eine Anfrage unter diesem Namen ein, gibt es weder Dashboard
* noch API — nur GET auf Dateien. Das haelt die oeffentliche Adresse frei
* von jeder Angriffsflaeche und macht die URLs zugleich huebsch:
* https://fivecdn.d4rkst3r.de/vehicles/adder.webp statt eines /f/-Praefix.
*
* Leer lassen, wenn alles unter einem Namen laufen soll — dann liegen die
* Dateien unter /f/. */
filesHost: optional('FILES_HOST', new URL(publicUrl).host),
/** Der Hostname, unter dem NUR Dateien ausgeliefert werden — oder leer,
* wenn alles unter einem Namen laeuft. Siehe oben. */
filesHost,
/** Was zwischen Adresse und Pfad steht. Bei zwei Namen nichts, bei einem
* "/f" — beides an genau EINER Stelle entschieden, damit die gebaute URL
* und die Route, die sie ausliefert, nicht auseinanderlaufen koennen. */
filePrefix: filesHost ? '' : '/f',
/** Wird beim Start angelegt, falls es noch keinen Benutzer gibt. */
adminPassword: required('ADMIN_PASSWORD'),
+434
View File
@@ -62,13 +62,274 @@ CREATE TABLE IF NOT EXISTS media (
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC);
-- Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht.
--
-- WARUM EINE EIGENE TABELLE und nicht einfach media.created_at. Drei Gruende,
-- und jeder allein wuerde reichen:
--
-- 1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg --
-- samt der Frage, wer es geloescht hat.
-- 2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
-- Auskunft, wann die Datei urspruenglich kam.
-- 3. media.token_id ist bei einem Upload aus dem Dashboard NULL: dort hat
-- niemand einen Token, sondern eine Sitzung. Das "von wem" stand also
-- nirgends.
--
-- Das ist NICHT das Logging, das in der ROADMAP abgelehnt wird -- dort geht es
-- um Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je
-- Upload in derselben SQLite-Datei.
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
-- 'upload' | 'replace' | 'delete'
kind TEXT NOT NULL,
path TEXT NOT NULL,
size INTEGER,
-- 'token' | 'user' | 'unbekannt'
actor_kind TEXT NOT NULL,
actor TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_at ON events(at DESC);
-- Einstellungen, die zur Laufzeit aenderbar sein sollen.
--
-- Bewusst NICHT alles: was den Dienst ueberhaupt hochfahren laesst (PUBLIC_URL,
-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
-- verstellbar sein soll -- derzeit die Discord-Anmeldung.
-- Der Papierkorb.
--
-- Loeschen war bis hierhin endgueltig: die Datei war von der Platte weg, und
-- die Sicherung half nur bis zum letzten naechtlichen Lauf. Bei einem Knopf
-- "alle 3670 waehlen" direkt neben "loeschen" ist das die falsche Haerte.
--
-- Die Datei wandert deshalb nach /data/papierkorb und der Datensatz hierher.
-- Was hier steht, ist alles, was zum Zurueckholen noetig ist -- der Pfad ist
-- der Schluessel, und den behalten wir.
CREATE TABLE IF NOT EXISTS papierkorb (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
mime TEXT NOT NULL,
geloescht INTEGER NOT NULL,
von TEXT NOT NULL,
-- Der Name IM Papierkorb. Nicht der Pfad: zwei Dateien koennen nacheinander
-- unter demselben Pfad gelegen haben, und beide sollen zurueckholbar sein.
ablage TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
-- Freigaben: ein Ordner, den man ohne Anmeldung ansehen kann.
--
-- WOZU. Die Dateien selbst sind ohnehin oeffentlich -- wer die Adresse kennt,
-- bekommt das Bild. Was NICHT oeffentlich ist, ist die LISTE: welche Bilder es
-- gibt. Genau die will man manchmal zeigen (ein Fahrzeugkatalog fuer Spieler)
-- und sonst nicht.
--
-- Der Schluessel ist eine Zufallszeichenkette in der Adresse und kein Passwort:
-- wer den Link hat, sieht die Liste. Das ist dieselbe Zusicherung wie bei einem
-- Nextcloud-Freigabelink, und sie steht so auch im Dashboard -- damit niemand
-- glaubt, hier haenge eine Anmeldung dran.
CREATE TABLE IF NOT EXISTS freigaben (
id INTEGER PRIMARY KEY,
schluessel TEXT NOT NULL UNIQUE,
ordner TEXT NOT NULL,
titel TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
-- Nicht "wie oft geoeffnet" als Selbstzweck: es beantwortet die Frage, ob
-- ein alter Link noch benutzt wird, bevor man ihn wegwirft.
aufrufe INTEGER NOT NULL DEFAULT 0,
zuletzt INTEGER
);
CREATE INDEX IF NOT EXISTS freigaben_ordner ON freigaben(ordner);
-- Was jemand VERWALTET hat -- Einstellungen, Token, Konten, Freigaben.
--
-- EINE EIGENE TABELLE und nicht events, obwohl beides ein Verlauf ist. Der
-- Grund steht in den Spalten: events traegt path und size und beantwortet
-- "was ist mit den DATEIEN passiert". Wer dort eine Einstellungsaenderung
-- hineinschriebe, muesste path mit etwas fuellen, das kein Pfad ist -- und
--
-- (Und in diesem Kommentar stehen keine Backticks, obwohl sie sich hier
-- anboeten: das Schema liegt in einem Template-Literal, und ein Backtick
-- beendet es. Der Uebersetzer hat es gefangen, der Browser haette es nicht.)
-- danach filtert jede Ansicht, die Dateien zeigen will, den Fremdkoerper wieder
-- heraus.
--
-- WAS HIER NIE HINEINGEHOERT: der WERT einer Einstellung. Unter den
-- Schluesseln stehen das Discord-Geheimnis und das Nextcloud-Passwort. Ein
-- Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort eine
-- Tabelle mit allen Passwoertern, die es je gab. Aufgeschrieben wird deshalb
-- WELCHER Schluessel angefasst wurde, nicht womit.
CREATE TABLE IF NOT EXISTS verwaltung (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
wer TEXT NOT NULL,
-- 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
bereich TEXT NOT NULL,
-- 'angelegt' | 'geaendert' | 'entfernt'
tat TEXT NOT NULL,
ziel TEXT NOT NULL DEFAULT '',
-- Kurz und ohne Werte: "webhook, anlaesse" oder "quota_bytes, arten".
was TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS verwaltung_zeit ON verwaltung(at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`)
// Nachtraegliche Spalten. SQLite kann kein "ADD COLUMN IF NOT EXISTS", also
// wird nachgesehen — das ist die ganze Wanderung, die dieses Projekt braucht.
{
const spalten = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]
if (!spalten.some((s) => s.name === 'discord_id')) {
db.exec('ALTER TABLE users ADD COLUMN discord_id TEXT')
// Kein UNIQUE in der Spaltendefinition: SQLite erlaubt das bei ADD
// COLUMN nicht. Der Index danach leistet dasselbe.
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)')
console.log('[db] Spalte users.discord_id angelegt')
}
// Der Bildstempel, NICHT die fertige Adresse: Discord baut die URL aus ID
// und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe
// avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des
// Schemas eine Baustelle in jeder Zeile.
// Grenzen je Token. Sie beantworten die Frage, die "Token-Arten" nur
// scheinbar beantwortet: was passiert, wenn DIESER Token abhandenkommt.
// Das GESAMTKONTINGENT, nicht die Groesse EINER Datei -- max_bytes gab es
// laengst, aber es galt je Upload. Ein Token mit "hoechstens 2 MB je Bild"
// konnte trotzdem die Platte fuellen, es brauchte nur genug Bilder.
const kSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
// Groesste erlaubte Kantenlaenge fuer Bilder ueber diesen Token. Null =
// unveraendert ablegen.
if (!kSpalten.some((s) => s.name === 'max_kante')) {
db.exec('ALTER TABLE tokens ADD COLUMN max_kante INTEGER')
console.log('[db] Spalte tokens.max_kante angelegt')
}
if (!kSpalten.some((s) => s.name === 'quota_bytes')) {
db.exec('ALTER TABLE tokens ADD COLUMN quota_bytes INTEGER')
console.log('[db] Spalte tokens.quota_bytes angelegt')
}
const tSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
if (!tSpalten.some((s) => s.name === 'max_bytes')) {
db.exec('ALTER TABLE tokens ADD COLUMN max_bytes INTEGER')
// Komma-getrennte Artnamen ('bild,video'). Leer heisst: alles, was der
// Dienst kennt.
db.exec("ALTER TABLE tokens ADD COLUMN arten TEXT NOT NULL DEFAULT ''")
db.exec('ALTER TABLE tokens ADD COLUMN expires_at INTEGER')
console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt')
}
const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[]
if (!eSpalten.some((s) => s.name === 'von')) {
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
// Spalte stuende im Verlauf nur das Ziel, und die Frage "wo war die
// vorher" waere nicht mehr zu beantworten.
db.exec('ALTER TABLE events ADD COLUMN von TEXT')
console.log('[db] Spalte events.von angelegt')
}
// Abrufe. Die Frage "was braucht eigentlich noch jemand" laesst sich von
// innen nicht beantworten -- von aussen schon: wer wird geholt, wie oft,
// wann zuletzt. Das ist die Zahl, die dem Aufbewahrungsbericht fehlt.
// Die Laenge in Sekunden -- nur bei Video und Ton gefuellt. Beantwortet
// auf der Kachel die Frage, die ein Standbild nicht beantwortet: WIE LANG.
const dSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
if (!dSpalten.some((s) => s.name === 'dauer')) {
db.exec('ALTER TABLE media ADD COLUMN dauer REAL')
console.log('[db] Spalte media.dauer angelegt')
}
const mSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
if (!mSpalten.some((s) => s.name === 'abrufe')) {
db.exec('ALTER TABLE media ADD COLUMN abrufe INTEGER NOT NULL DEFAULT 0')
db.exec('ALTER TABLE media ADD COLUMN zuletzt_abgerufen INTEGER')
console.log('[db] Spalten media.abrufe und zuletzt_abgerufen angelegt')
}
// Wann war der zuletzt da? Steht sonst nirgends: sessions weiss es, aber
// eine abgelaufene Sitzung ist weg, und dann sieht ein Konto, das seit
// einem halben Jahr niemand benutzt, aus wie eines von gestern.
if (!spalten.some((s) => s.name === 'zuletzt_da')) {
db.exec('ALTER TABLE users ADD COLUMN zuletzt_da INTEGER')
console.log('[db] Spalte users.zuletzt_da angelegt')
}
if (!spalten.some((s) => s.name === 'discord_avatar')) {
db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT')
db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT')
console.log('[db] Spalten users.discord_avatar und users.discord_name angelegt')
}
}
// Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur
// wenn er leer ist.
//
// Ohne das stuende der Tab am ersten Tag leer da, obwohl 46 Bilder in der
// Ablage liegen; das sieht aus wie ein kaputter Tab. Die nachgetragenen
// Eintraege sind ehrlich als solche gekennzeichnet: sie tragen den
// urspruenglichen Zeitpunkt, aber ueber Loeschungen und Ersetzungen von
// frueher weiss niemand mehr etwas.
{
const vorhanden = db.prepare('SELECT COUNT(*) AS n FROM events').get() as { n: number }
const medien = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
if (vorhanden.n === 0 && medien.n > 0) {
const nachtragen = db.prepare(
`INSERT INTO events (at, kind, path, size, actor_kind, actor)
SELECT m.created_at, 'upload', m.path, m.size,
CASE WHEN t.name IS NULL THEN 'unbekannt' ELSE 'token' END,
COALESCE(t.name, 'vor der Aufzeichnung')
FROM media m LEFT JOIN tokens t ON t.id = m.token_id`,
)
const info = nachtragen.run()
console.log(`[db] Verlauf aus dem Bestand nachgetragen: ${info.changes} Eintraege`)
}
}
export type User = {
zuletzt_da?: number | null
id: number
username: string
password_hash: string
created_at: number
/** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto
* hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */
discord_id: string | null
/** Der Bildstempel von Discord, nicht die Adresse. Null heisst: kein
* eigenes Bild, dann gilt Discords Ersatzbild. */
discord_avatar: string | null
discord_name: string | null
}
/** Die Adresse des Discord-Bildes — an EINER Stelle gebaut.
*
* Faengt der Stempel mit "a_" an, ist es ein bewegtes Bild und muss als .gif
* angefragt werden; sonst kommt ein Standbild. Ohne eigenen Stempel gibt es
* Discords Ersatzbild, dessen Nummer sich aus der ID ergibt (die neue
* Rechnung mit >> 22, nicht die alte ueber den Diskriminator).
*
* Null nur, wenn gar keine Discord-ID da ist — dann zeigt die Oberflaeche
* Initialen. */
export function avatarUrl(
discordId: string | null,
stempel: string | null,
groesse = 64,
): string | null {
if (!discordId) return null
if (stempel) {
const endung = stempel.startsWith('a_') ? 'gif' : 'png'
return `https://cdn.discordapp.com/avatars/${discordId}/${stempel}.${endung}?size=${groesse}`
}
const nummer = Number((BigInt(discordId) >> 22n) % 6n)
return `https://cdn.discordapp.com/embed/avatars/${nummer}.png`
}
export type Token = {
@@ -79,6 +340,18 @@ export type Token = {
can_delete: number
created_at: number
last_used_at: number | null
/** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des
* Dienstes gilt (MAX_UPLOAD_MB). */
/** Groesste erlaubte EINZELNE Datei. */
max_bytes: number | null
/** Wieviel dieser Token INSGESAMT halten darf. Null = so viel er will. */
quota_bytes: number | null
/** Groesste Kantenlaenge fuer Bilder. Null = ablegen, wie es kommt. */
max_kante: number | null
/** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */
arten: string
/** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */
expires_at: number | null
}
export type Media = {
@@ -90,10 +363,171 @@ export type Media = {
token_id: number | null
created_at: number
updated_at: number
/** Wie oft die Datei oeffentlich geholt wurde. */
abrufe: number
zuletzt_abgerufen: number | null
/** Laenge in Sekunden — nur bei Video und Ton, sonst null. */
dauer: number | null
}
export type Freigabe = {
id: number
schluessel: string
ordner: string
titel: string
created_at: number
aufrufe: number
zuletzt: number | null
}
export type BenutzerZeile = {
id: number
username: string
created_at: number
zuletzt_da: number | null
discord_id: string | null
discord_name: string | null
discord_avatar: string | null
}
export type VerwaltungZeile = {
id: number
at: number
wer: string
bereich: string
tat: string
ziel: string
was: string
}
export type PapierkorbEintrag = {
id: number
path: string
size: number
sha256: string
mime: string
geloescht: number
von: string
ablage: string
}
// 'rename' steht neben 'move', obwohl es technisch dasselbe ist: eine Datei
// bekommt einen anderen Pfad. Fuer den, der den Verlauf LIEST, ist es aber
// etwas anderes -- verschoben heisst "liegt woanders", umbenannt heisst "heisst
// anders, liegt noch da". Ein Verlauf, der beides "verschoben" nennt, laesst
// genau die Frage offen, wegen der man ihn aufschlaegt.
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
export type MediaEvent = {
id: number
at: number
kind: EventKind
path: string
size: number | null
actor_kind: 'token' | 'user' | 'unbekannt'
actor: string
/** Bei 'move' und 'rename': der Pfad, unter dem die Datei vorher lag. */
von: string | null
}
export const now = () => Date.now()
const eventEinfuegen = db.prepare(
`INSERT INTO events (at, kind, path, size, actor_kind, actor, von)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
/** Einen Eintrag in den Verlauf schreiben.
*
* Wirft NICHT: ein Verlauf, der einen Upload scheitern laesst, waere die
* Buchhaltung, die das Geschaeft verhindert. Wenn hier etwas schiefgeht,
* steht es in der Konsole und die Datei liegt trotzdem richtig. */
export function logEvent(
kind: EventKind,
path: string,
size: number | null,
actorKind: MediaEvent['actor_kind'],
actor: string,
von?: string,
): void {
try {
eventEinfuegen.run(now(), kind, path, size, actorKind, actor, von ?? null)
} catch (err) {
console.error('[events] konnte nicht schreiben:', err)
}
}
/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
// -- Abrufe zaehlen, ohne die Auslieferung zu bremsen -------------------------
//
// Ein UPDATE je Bildabruf waere bei einer Galerieseite mit 120 Kacheln ein
// Schreibvorgang je Kachel. Stattdessen wird im Speicher gezaehlt und
// gelegentlich weggeschrieben -- wer wissen will, ob eine Datei benutzt wird,
// braucht keine Genauigkeit auf die Sekunde.
//
// Was bei einem Absturz verlorengeht, sind hoechstens 30 Sekunden Zaehlerei.
const offeneAbrufe = new Map<string, { anzahl: number; zuletzt: number }>()
export function zaehleAbruf(path: string): void {
const bisher = offeneAbrufe.get(path)
if (bisher) {
bisher.anzahl++
bisher.zuletzt = now()
} else {
offeneAbrufe.set(path, { anzahl: 1, zuletzt: now() })
}
}
const abrufSchreiben = db.prepare(
'UPDATE media SET abrufe = abrufe + ?, zuletzt_abgerufen = ? WHERE path = ?',
)
export function schreibeAbrufe(): void {
if (offeneAbrufe.size === 0) return
const stapel = [...offeneAbrufe.entries()]
offeneAbrufe.clear()
try {
db.transaction(() => {
for (const [path, w] of stapel) abrufSchreiben.run(w.anzahl, w.zuletzt, path)
})()
} catch (err) {
console.error('[abrufe] konnten nicht geschrieben werden:', err)
}
}
/** Eine Verwaltungshandlung festhalten.
*
* Wirft NIE: ein Verlauf, der die Handlung verhindert, ueber die er berichten
* soll, waere die falsche Reihenfolge. Faellt er aus, steht die Zeile eben
* nicht da -- die Aenderung selbst ist trotzdem passiert. */
export function logVerwaltung(
wer: string,
bereich: string,
tat: string,
ziel = '',
was = '',
): void {
try {
db.prepare(
'INSERT INTO verwaltung (at, wer, bereich, tat, ziel, was) VALUES (?, ?, ?, ?, ?, ?)',
).run(now(), wer, bereich, tat, ziel, was)
} catch (err) {
console.error('[verwaltung] nicht geschrieben:', err)
}
}
/** Dieselbe Frist wie beim Datei-Verlauf. */
export function pruneVerwaltung(tage = 180) {
return db.prepare('DELETE FROM verwaltung WHERE at < ?').run(now() - tage * 86_400_000)
}
export function pruneEvents(tage = 180) {
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
}
/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich —
* sonst waechst die Tabelle ewig, und niemand merkt es. */
export function pruneSessions() {
+198
View File
@@ -0,0 +1,198 @@
// Eine Datei von einer fremden Adresse holen.
//
// WOZU. Ein einzelnes Bild von woanders zu uebernehmen hiess bisher:
// herunterladen, im Dateimanager wiederfinden, ins Fenster ziehen. Drei
// Schritte fuer etwas, das der Dienst selbst tun kann.
//
// UND DAS IST DIE GEFAEHRLICHSTE FUNKTION IN DIESEM DIENST, deshalb steht sie
// in einer eigenen Datei. Ein Server, der eine vom Benutzer genannte Adresse
// abruft, ist ein Angriff mit eigenem Namen: SSRF. Wer ihn dazu bringt,
//
// http://127.0.0.1:9101/api/dash/... die eigene API von innen
// http://169.254.169.254/latest/meta/ die Zugangsdaten der Cloud
// http://192.168.2.1/ den Router im Heimnetz
//
// abzurufen, laesst den Dienst Dinge lesen, die von aussen unerreichbar sind --
// und bekommt sie als Datei zurueck, unter einer oeffentlichen Adresse.
//
// Dass hier eine Anmeldung davorsteht, ist KEIN Ersatz fuer die Pruefung. Es
// gibt zwei Konten, eines davon entsteht ueber eine Discord-Rolle, die jemand
// anders vergibt.
import { lookup } from 'node:dns/promises'
import { isIP } from 'node:net'
/** Wie lange der Dienst auf eine fremde Adresse wartet. */
const FRIST_MS = 20_000
/** Wie oft einer Umleitung gefolgt wird.
*
* Und JEDE davon wird neu geprueft -- das ist der Kern. Ein Server kann mit
* 302 auf http://127.0.0.1 zeigen, und `fetch` folgt von sich aus. Deshalb
* steht unten `redirect: 'manual'`: die Kette wird selbst gelaufen, damit
* jeder Halt durch dieselbe Pruefung geht wie der erste.
*/
const UMLEITUNGEN_MAX = 3
export class HolFehler extends Error {}
/** Adressbereiche, die von hier aus niemanden etwas angehen.
*
* Geprueft wird die AUFGELOESTE IP und nicht der Name: `localtest.me` zeigt
* auf 127.0.0.1, und ein eigener DNS-Eintrag kann auf alles zeigen. Ein
* Namensfilter waere ein Filter fuer den, der ihn nicht umgehen will. */
function istPrivat(ip: string): boolean {
if (isIP(ip) === 6) {
const v = ip.toLowerCase()
if (v === '::1' || v === '::') return true
// fc00::/7 (eindeutig lokal) und fe80::/10 (verbindungslokal)
if (/^f[cd]/.test(v) || /^fe[89ab]/.test(v)) return true
// IPv4 in IPv6-Schreibweise: ::ffff:127.0.0.1
const eingebettet = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v)
if (eingebettet?.[1]) return istPrivat(eingebettet[1])
return false
}
const t = ip.split('.').map(Number)
if (t.length !== 4 || t.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true
const [a, b] = t as [number, number, number, number]
if (a === 0) return true // dieses Netz
if (a === 10) return true // privat
if (a === 127) return true // Rueckschleife
if (a === 169 && b === 254) return true // verbindungslokal UND Cloud-Metadaten
if (a === 172 && b >= 16 && b <= 31) return true // privat
if (a === 192 && b === 168) return true // privat
if (a === 100 && b >= 64 && b <= 127) return true // Provider-internes NAT
if (a >= 224) return true // Rundruf und reserviert
return false
}
/** Eine Adresse pruefen: Schema, Anschluss, aufgeloeste IP. Wirft HolFehler. */
async function pruefeZiel(roh: string): Promise<URL> {
let u: URL
try {
u = new URL(roh)
} catch {
throw new HolFehler('Das ist keine gueltige Adresse.')
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new HolFehler(`Nur http und https — "${u.protocol.replace(':', '')}" nicht.`)
}
// Ein Anschluss, der nicht der uebliche ist, deutet fast immer auf einen
// Dienst im eigenen Netz. Die Ausnahme waere ein oeffentlicher Server auf
// einem krummen Port; die ist selten genug, um sie abzulehnen.
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
if (port !== 80 && port !== 443) {
throw new HolFehler(`Nur die ueblichen Anschluesse 80 und 443 — nicht ${port}.`)
}
const name = u.hostname.replace(/^\[|\]$/g, '')
let adressen: string[]
if (isIP(name)) {
adressen = [name]
} else {
try {
adressen = (await lookup(name, { all: true })).map((a) => a.address)
} catch {
throw new HolFehler(`"${name}" laesst sich nicht aufloesen.`)
}
}
if (adressen.length === 0) throw new HolFehler(`"${name}" hat keine Adresse.`)
// JEDE aufgeloeste Adresse muss oeffentlich sein. Ein Name mit zwei
// Eintraegen -- einer oeffentlich, einer auf 127.0.0.1 -- waere sonst ein
// Wuerfelspiel darueber, welchen Node nimmt.
for (const ip of adressen) {
if (istPrivat(ip)) {
throw new HolFehler(
`"${name}" zeigt auf ${ip} und damit ins eigene Netz. Das geht von hier aus nicht.`,
)
}
}
return u
}
export type Geholt = { data: Buffer; mime: string; name: string; von: string }
/** Die Datei holen. `hoechstens` ist die Grenze in Bytes.
*
* Der Rumpf wird STUECKWEISE gelesen und abgebrochen, sobald die Grenze
* reisst. `content-length` allein zu glauben waere naiv -- die Angabe kommt
* vom fremden Server und muss nicht stimmen. */
export async function holeVonAdresse(roh: string, hoechstens: number): Promise<Geholt> {
let ziel = await pruefeZiel(roh)
let antwort: Response | null = null
for (let sprung = 0; sprung <= UMLEITUNGEN_MAX; sprung++) {
const res = await fetch(ziel, {
redirect: 'manual',
signal: AbortSignal.timeout(FRIST_MS),
headers: { 'User-Agent': 'd4rk_media/1.0 (+holen)' },
}).catch((err) => {
throw new HolFehler(
`Nicht erreichbar: ${err instanceof Error ? (err.cause as { code?: string })?.code ?? err.name : 'unbekannt'}`,
)
})
if (res.status >= 300 && res.status < 400) {
const wohin = res.headers.get('location')
await res.body?.cancel().catch(() => {})
if (!wohin) throw new HolFehler(`Umleitung ohne Ziel (HTTP ${res.status}).`)
if (sprung === UMLEITUNGEN_MAX) {
throw new HolFehler(`Mehr als ${UMLEITUNGEN_MAX} Umleitungen.`)
}
// Neu pruefen. GENAU HIER waere die Luecke, wenn man `fetch` selbst
// folgen liesse.
ziel = await pruefeZiel(new URL(wohin, ziel).href)
continue
}
if (!res.ok) {
await res.body?.cancel().catch(() => {})
throw new HolFehler(`Die Gegenstelle antwortet mit HTTP ${res.status}.`)
}
antwort = res
break
}
if (!antwort) throw new HolFehler('Keine Antwort.')
const angekuendigt = Number(antwort.headers.get('content-length') ?? NaN)
if (Number.isFinite(angekuendigt) && angekuendigt > hoechstens) {
await antwort.body?.cancel().catch(() => {})
throw new HolFehler(`Zu gross: ${angekuendigt} > ${hoechstens} Bytes.`)
}
const stuecke: Buffer[] = []
let gesamt = 0
const leser = antwort.body?.getReader()
if (!leser) throw new HolFehler('Die Antwort hat keinen Rumpf.')
for (;;) {
const { done, value } = await leser.read()
if (done) break
gesamt += value.byteLength
if (gesamt > hoechstens) {
await leser.cancel().catch(() => {})
throw new HolFehler(
`Zu gross: die Gegenstelle schickt mehr als ${hoechstens} Bytes.`,
)
}
stuecke.push(Buffer.from(value))
}
if (gesamt === 0) throw new HolFehler('Die Gegenstelle hat nichts geschickt.')
// Der Name aus dem Pfad, sonst aus dem Hostnamen. Zurechtgebogen wird er
// erst beim Ablegen -- hier soll stehen, was dort stand.
const ausPfad = decodeURIComponent(ziel.pathname.split('/').pop() ?? '')
return {
data: Buffer.concat(stuecke),
mime: (antwort.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '',
name: ausPfad || ziel.hostname,
von: ziel.href,
}
}
+245 -20
View File
@@ -1,19 +1,23 @@
// Der Einstiegspunkt.
//
// ZWEI HOSTNAMEN, EIN PROZESS. Kommt eine Anfrage unter FILES_HOST herein,
// gibt es nur Dateien — kein Dashboard, keine API, kein Anmeldeformular. Das
// hat zwei Gruende:
// EIN NAME ODER ZWEI, ein Prozess in beiden Faellen. Was gilt, entscheidet
// FILES_HOST, und der Start sagt es in einer Zeile.
//
// 1. Die oeffentliche Adresse bleibt huebsch. Das Handy fragt
// https://fivecdn.d4rkst3r.de/vehicles/adder.webp ab und nicht
// irgendein /f/-Praefix — die Vorlage lautet weiterhin schlicht
// {model}.webp.
// 2. Der Name, der im Internet steht und den jeder Spieler kennt, hat
// ueberhaupt keine Angriffsflaeche. Es gibt dort nichts anzumelden.
// EIN NAME (Standard). Dashboard und API an der Wurzel, Dateien unter /f/:
// https://media.d4rkst3r.de/f/vehicles/adder.webp
// Ein DNS-Eintrag, ein Host im Proxy. Dafuer traegt die oeffentliche Adresse
// auch das Anmeldeformular.
//
// Unter dem anderen Namen laeuft das Dashboard samt API, und die Dateien sind
// dort zusaetzlich unter /f/ erreichbar — praktisch fuer die Galerie, wenn
// jemand den zweiten Hostnamen noch nicht eingerichtet hat.
// ZWEI NAMEN. Unter FILES_HOST gibt es dann ausschliesslich Dateien — kein
// Dashboard, keine API, nichts anzumelden:
// https://cdn.example.de/vehicles/adder.webp
// Das haelt den Namen, den jeder Spieler kennt, ohne Angriffsflaeche und
// spart das Praefix. Unter dem anderen Namen laeuft das Dashboard, und die
// Dateien sind dort zusaetzlich unter /f/ erreichbar.
//
// In beiden Faellen gilt: DER PFAD IST DER SCHLUESSEL. vehicles/adder.webp
// bleibt vehicles/adder.webp, damit die Vorlage im Handy {model}.webp lauten
// kann. Nur der Teil davor aendert sich — und der steht in PUBLIC_URL.
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
@@ -24,15 +28,64 @@ import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { config } from './config.js'
import { ensureAdmin } from './auth.js'
import { pruneSessions } from './db.js'
import { absolutePath, checkPath, mimeFor } from './storage.js'
import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js'
import { pruefePlatte, pruefeSicherung } from './meldung.js'
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
import { uploadRoutes } from './routes/upload.js'
import { dashRoutes } from './routes/dash.js'
import { fivemanageRoutes } from './routes/fivemanage.js'
import { freigabeRoutes } from './routes/freigabe.js'
import { statusRoutes } from './routes/status.js'
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
const app = new Hono()
if (config.dev) app.use('*', logger())
// Ein unerwarteter Fehler bekommt einen TEXT, keinen nackten 500.
//
// Genau das ist der Fehler, wegen dem dieses Projekt existiert: bei Fivemanage
// endete jeder Fehlschlag als "Internal Server Error" ohne ein Wort dazu. Wir
// haben das an den bekannten Stellen einzeln abgefangen -- und prompt hat ein
// Tippfehler in einer SQL-Zeile beim Export genau diese nackte Antwort
// erzeugt. Eine Auffanglinie an EINER Stelle schliesst die Luecke fuer alles,
// woran heute niemand denkt.
//
// Die Meldung geht mit hinaus. Das ist eine bewusste Abwaegung: dieses
// Dashboard hat einen Benutzerkreis von einer Person, und ein Fehler, den man
// nicht lesen kann, kostet mehr als er verbirgt. Die Spur bleibt zusaetzlich
// in der Konsole.
app.onError((err, c) => {
console.error('[fehler]', c.req.method, c.req.path, err)
const text = err instanceof Error ? err.message : String(err)
if (c.req.path.startsWith('/api/')) {
return c.json({ error: text }, 500)
}
return c.text(`Serverfehler: ${text}`, 500)
})
// Ein Fehler AUSSERHALB jedes try/catch darf den Dienst nicht umbringen.
//
// Genau das ist beim Uebernehmen von 3670 Dateien passiert: undici warf
// "ReadableStream is already closed" aus einem Microtask -- also an einer
// Stelle, an der kein try/catch der Welt steht. Node beendet den Prozess
// daraufhin, Docker startet ihn neu, die naechste Anfrage kippt ihn wieder:
// eine Neustartschleife, und nach aussen 502.
//
// DIE ABWAEGUNG, ausgesprochen: einen unerwarteten Fehler zu ueberleben ist
// nicht in jedem Fall richtig -- der Prozess KANN danach in einem kaputten
// Zustand sein. Hier ueberwiegt trotzdem das Weiterlaufen: dieser Dienst haelt
// keinen Zustand im Speicher, der verderben koennte (alles steht in SQLite und
// auf der Platte), und ein Dienst, der wegen EINER Anfrage fuer alle weg ist,
// ist der schlechtere Tausch.
//
// Laut wird es trotzdem: die Spur steht vollstaendig in der Konsole.
process.on('uncaughtException', (err) => {
console.error('[unerwartet] uncaughtException — der Dienst laeuft weiter:', err)
})
process.on('unhandledRejection', (grund) => {
console.error('[unerwartet] unhandledRejection — der Dienst laeuft weiter:', grund)
})
// -------------------------------------------------------- Dateien ausliefern
/** Liefert eine Datei aus, mit ETag und Bereichsanfragen.
@@ -47,7 +100,7 @@ async function serveFile(c: any, rawPath: string) {
return c.text('nicht gefunden', 404)
}
const absolute = absolutePath(path)
let absolute = absolutePath(path)
let info
try {
info = await stat(absolute)
@@ -56,10 +109,45 @@ async function serveFile(c: any, rawPath: string) {
return c.text('nicht gefunden', 404)
}
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
// DIE SPARSAME FASSUNG, unter DERSELBEN Adresse.
//
// Der Browser sagt mit `Accept`, was er versteht. Versteht er WebP und
// liegt eine Fassung daneben, bekommt er sie. Die Adresse bleibt
// items/foo.png, und kein Skript merkt etwas davon. Lua, curl und alles
// ohne diese Ansage bekommen unveraendert das Original.
//
// Der Vergleich ist absichtlich stumpf: `Accept` traegt Gewichte, und ein
// "image/webp;q=0" hiesse eigentlich "bitte nicht". Das schickt in der
// Praxis niemand -- und faellt jemand doch darauf herein, bekommt er ein
// gueltiges Bild, nur in einem anderen Format. Der Preis eines Fehlers ist
// hier klein genug, dass die einfache Pruefung die richtige ist.
let typ = mimeFor(path)
let sparsam = false
if (lohntWebp(typ) && (c.req.header('accept') ?? '').includes('image/webp')) {
try {
const wInfo = await stat(webpPfad(path))
if (wInfo.isFile()) {
absolute = webpPfad(path)
info = wInfo
typ = 'image/webp'
sparsam = true
}
} catch {
/* keine sparsame Fassung -- dann eben das Original */
}
}
// Das Kennzeichen gehoert IN den ETag: sonst traegt das Original und die
// sparsame Fassung dieselbe Marke, sobald beide zufaellig gleich gross und
// gleich alt sind, und ein Aufrufer bekaeme auf seinen 304 hin das falsche
// Bild aus seinem eigenen Zwischenspeicher.
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}${sparsam ? '-w' : ''}"`
const headers: Record<string, string> = {
'Content-Type': mimeFor(path),
'Content-Type': typ,
'Cache-Control': 'public, max-age=300',
// OHNE DIESE ZEILE legt ein Zwischenspeicher die WebP-Fassung fuer ALLE
// ab -- auch fuer die, die sie nicht lesen koennen.
Vary: 'Accept',
ETag: etag,
'Last-Modified': info.mtime.toUTCString(),
'Accept-Ranges': 'bytes',
@@ -68,6 +156,13 @@ async function serveFile(c: any, rawPath: string) {
'X-Content-Type-Options': 'nosniff',
}
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
//
// Auch der 304 zaehlt: der Aufrufer WOLLTE die Datei, er hatte sie nur
// schon. Fuer die Frage "braucht das noch jemand" ist das dasselbe.
// HEAD zaehlt nicht -- das fragt nach der Datei, nicht nach dem Inhalt.
if (c.req.method !== 'HEAD') zaehleAbruf(path)
if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers)
if (c.req.method === 'HEAD') {
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
@@ -96,6 +191,11 @@ async function serveFile(c: any, rawPath: string) {
// ------------------------------------------------------------ Der Dateiwirt
app.use('*', async (c, next) => {
// Kein zweiter Name gesetzt: dann gibt es hier nichts zu entscheiden.
// Ohne diese Zeile wuerde im Ein-Namen-Betrieb jede Anfrage als Anfrage an
// den Dateiwirt gelten und die eigene API verschwinden.
if (!config.filesHost) return next()
const host = (c.req.header('host') ?? '').split(':')[0]?.toLowerCase()
if (host !== config.filesHost.split(':')[0]?.toLowerCase()) return next()
@@ -109,13 +209,77 @@ app.use('*', async (c, next) => {
// ------------------------------------------------------- Dashboard und API
// Der HEALTHCHECK des Containers haengt hier dran, und deshalb bleibt es
// billig: eine schwere Pruefung, die bei einer langsamen Platte einmal
// ausfaellt, liesse Docker den Container neu starten -- also genau dann, wenn
// er unter Last steht.
//
// Was diese Zeile beweist, ist genau eines: der Prozess nimmt Anfragen an.
// Fuer eine STATUSSEITE ist das zu duenn -- dafuer gibt es /status daneben,
// das wirklich nachsieht.
app.get('/health', (c) => c.json({ ok: true }))
// Fuer die Statusseite. Oeffentlich wie /health, aber es prueft Datenbank,
// Platte, Ausliefern und die Sicherung -- und antwortet mit 503, wenn der
// Dienst sein Geschaeft nicht mehr tun kann.
app.route('/status', statusRoutes)
app.route('/api', uploadRoutes)
// NACH uploadRoutes: unsere eigenen Wege haben Vorrang, falls je ein Name
// zusammenfaellt. Derzeit tut er das nicht (/upload, /exists, /media gegen
// /image, /video, /audio, /file, /v3/file), aber die Reihenfolge sagt, was
// gelten soll, wenn es einmal so kommt.
app.route('/api', fivemanageRoutes)
app.route('/api/dash', dashRoutes)
// Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/<schluessel> laesst
// sich vorlesen und abtippen.
app.route('/api/s', freigabeRoutes)
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
/** Die Vorschaubilder, unter /t/.
*
* Absichtlich NICHT unter dem Dateihost und absichtlich ohne Anmeldung: die
* Bilder selbst sind ohnehin oeffentlich, und das Dashboard laedt die
* Vorschauen mit gewoehnlichen <img>-Marken — mit Anmeldung braeuchte es
* dafuer einen Umweg ueber fetch und Blob-Adressen.
*
* Fehlt eine Vorschau, kommt 404 und das Dashboard nimmt das Vollbild. Kein
* Erzeugen auf Zuruf: eine Route, die bei jeder Anfrage rechnen kann, ist
* eine Route, die sich mit 900 gleichzeitigen Anfragen umbringen laesst. */
app.get('/t/*', async (c) => {
const roh = c.req.path.slice(3)
let path: string
try {
path = checkPath(decodeURIComponent(roh))
} catch {
return c.text('nicht gefunden', 404)
}
const absolute = thumbPath(path)
let info
try {
info = await stat(absolute)
if (!info.isFile()) return c.text('nicht gefunden', 404)
} catch {
return c.text('nicht gefunden', 404)
}
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
if (c.req.header('if-none-match') === etag) return c.body(null, 304)
return c.body(createReadStream(absolute) as any, 200, {
'Content-Type': 'image/webp',
'Content-Length': String(info.size),
ETag: etag,
// Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen
// wird, und dann soll das Dashboard es sehen.
'Cache-Control': 'public, max-age=60',
'X-Content-Type-Options': 'nosniff',
})
})
// Die gebaute Oberflaeche. Alles, was keine API ist und keine Datei, bekommt
// index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router.
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
@@ -160,6 +324,10 @@ function decodeSafely(raw: string): string {
* Stylesheet noch Modul. */
const WEB_MIME: Record<string, string> = {
html: 'text/html; charset=utf-8',
// webmanifest steht hier und nicht in der geteilten Tabelle: es ist eine
// Datei der Oberflaeche. Ohne den Eintrag kaeme sie als
// application/octet-stream, und der Browser wuerde sie verwerfen.
webmanifest: 'application/manifest+json; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
@@ -223,9 +391,66 @@ ensureAdmin()
pruneSessions()
setInterval(pruneSessions, 3600_000).unref()
// UND DIE EREIGNISSE, taeglich. Das stand bisher NUR an einem Wartungsweg
// (/maintenance/prune-sessions), den nicht einmal die Oberflaeche anbietet --
// die dokumentierten 180 Tage Aufbewahrung griffen damit nie, und die Tabelle
// wuchs fuer immer. Nach einem einzigen Tag mit Umzug und Serienlauf standen
// dort 5355 Zeilen.
//
// Nicht stuendlich wie die Sitzungen: ein DELETE ueber eine Zeitspanne von
// einem halben Jahr hat es nicht eilig, und 24 Stunden Verzoegerung beim
// Wegraeumen fallen niemandem auf.
setTimeout(() => {
pruneEvents()
pruneVerwaltung()
}, 120_000).unref()
setInterval(() => {
pruneEvents()
pruneVerwaltung()
}, 86_400_000).unref()
// Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst
// soll erst stehen) und danach stuendlich.
//
// DAS IST DER PUNKT DER GANZEN MELDUNGSFUNKTION: eine Sicherung, die aufhoert
// zu laufen, faellt sonst erst auf, wenn man sie braucht.
setTimeout(() => void pruefeSicherung(), 60_000).unref()
setInterval(() => void pruefeSicherung(), 3600_000).unref()
// Und der Platz auf der Platte. Derselbe Takt und derselbe Grund: eine volle
// Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und
// nichts mehr gesichert wird.
setTimeout(() => void pruefePlatte(), 75_000).unref()
setInterval(() => void pruefePlatte(), 3600_000).unref()
// Die gezaehlten Abrufe wegschreiben. Alle 30 Sekunden reicht: wer wissen
// will, ob eine Datei benutzt wird, braucht keine Genauigkeit auf die Sekunde.
setInterval(schreibeAbrufe, 30_000).unref()
// Und beim Herunterfahren, damit die letzten nicht verlorengehen.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
schreibeAbrufe()
process.exit(0)
})
}
// Den Papierkorb ausleeren, was ueberfaellig ist. Beim Start mit Verzoegerung,
// danach stuendlich.
setTimeout(() => void papierkorbAusleeren(), 90_000).unref()
setInterval(() => void papierkorbAusleeren(), 3600_000).unref()
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`[d4rk_media] Port ${info.port}`)
console.log(`[d4rk_media] Dateien oeffentlich unter ${config.publicUrl}`)
console.log(`[d4rk_media] Dateiwirt hoert auf Host "${config.filesHost}"`)
console.log(
`[d4rk_media] Dateien oeffentlich unter ${config.publicUrl}${config.filePrefix}/`,
)
// Welche Betriebsart laeuft, steht beim Start da und nicht im Quelltext.
// Wer sich wundert, warum das Dashboard nicht aufgeht, liest hier zuerst.
console.log(
config.filesHost
? `[d4rk_media] Zwei Namen: Dateiwirt hoert auf "${config.filesHost}", Dashboard und API unter jedem anderen`
: '[d4rk_media] Ein Name: Dashboard und API an der Wurzel, Dateien unter /f/',
)
console.log(`[d4rk_media] Daten in ${config.dataDir}`)
})
+415
View File
@@ -0,0 +1,415 @@
// Meldungen nach Discord.
//
// WOZU. Die Sicherung laeuft nachts um halb fuenf, und wenn sie aufhoert zu
// laufen, merkt es niemand -- das Panel zeigt es nur dem, der hinschaut. Genau
// dafuer ist das hier: nicht fuer Jubelmeldungen, sondern fuer die zwei, drei
// Faelle, in denen jemand etwas tun muss.
//
// Ein WEBHOOK und keine Bot-Anbindung: eine Adresse, kein Token, keine
// Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen
// will, loescht ihn in Discord.
//
// DER AUSSEHEN FOLGT DEM d4rkbot. Dessen src/embeds.js hat eine zentrale
// Embed-Fabrik: Markenfarbe, Fusszeile "D4RKST3R // <TAG>", Zeitstempel. Zwei
// Dienste desselben Hauses sollen in einem Kanal nicht wie zwei Fremde
// aussehen, also uebernehmen wir das Muster statt ein eigenes zu erfinden.
//
// EINE ABWEICHUNG, mit Absicht: der Bot faerbt alles in der Markenfarbe, hier
// faerbt die SCHWERE. Eine Warnung, die aussieht wie jede andere Nachricht,
// ist eine Warnung, die man ueberliest -- und diese Meldungen gibt es nur,
// weil jemand sie sehen soll.
import { statfs } from 'node:fs/promises'
import { config } from './config.js'
import { getSetting, setSetting } from './settings.js'
import { now } from './db.js'
/** Wofuer es Meldungen gibt -- an EINER Stelle.
*
* Die Liste stand zweimal da: hier als Standard, und in dash.ts noch einmal
* als Weissliste beim Speichern. Beim Einbau des Plattenwaechters habe ich nur
* die erste angefasst -- Ergebnis: das Kaestchen liess sich ankreuzen, der
* Server warf den Wert beim Speichern weg, und beim naechsten Laden war es
* wieder aus. Ohne ein Wort dazu.
*
* Nachgemessen am laufenden Dienst, bevor es hier stand:
* geschickt ["sicherung","verwaiste","platte"]
* gespeichert ["sicherung","verwaiste"]
*
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
* ist. Jetzt ist es eine.
*/
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte', 'upload'] as const
export const MELDUNG_KEYS = {
webhook: 'discord.webhook',
/** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste', 'platte'. */
anlaesse: 'discord.anlaesse',
/** Womit zuletzt gemeldet wurde -- gegen Wiederholungen.
*
* ALS PRAEFIX, nicht als ein Schluessel: es gibt inzwischen ZWEI
* Wachhunde (Sicherung und Plattenplatz), und mit einem gemeinsamen
* Merker haetten sie sich gegenseitig ueberschrieben. Der eine meldet,
* der andere haelt daraufhin seinen eigenen Zustand fuer neu und meldet
* auch -- oder umgekehrt schweigt einer, obwohl sich etwas geaendert hat. */
zuletzt: 'discord.zuletzt',
/** Der Name in der Fusszeile. Wie beim Bot einstellbar, mit demselben
* Standard. */
marke: 'marke.name',
} as const
/** Dieselben Werte wie im d4rkbot (src/runtime-settings.js). */
const MARKE_STANDARD = 'D4RKST3R'
const FARBEN = {
marke: 0xf5c518,
gut: 0x3fb950,
warnung: 0xd29922,
schlecht: 0xf85149,
} as const
export type Schwere = keyof typeof FARBEN
export type MeldungConfig = {
hatWebhook: boolean
anlaesse: string[]
}
export function meldungConfig(): MeldungConfig {
const roh = getSetting(MELDUNG_KEYS.anlaesse)
return {
hatWebhook: Boolean(getSetting(MELDUNG_KEYS.webhook)),
// Ohne Eintrag gilt: alles, was es gibt. Wer einen Webhook eintraegt,
// will Meldungen -- sonst haette er es gelassen.
anlaesse: roh
? roh.split(',').map((a) => a.trim()).filter(Boolean)
: [...ANLAESSE],
}
}
type Feld = { name: string; value: string; inline?: boolean }
/** Eine Nachricht schicken. Wirft nie.
*
* `einmalig` ist der Schluessel gegen Wiederholungen: dieselbe Zeichenkette
* wird nur einmal gemeldet, bis sich der Zustand aendert. */
export async function melde(
titel: string,
opt: {
text?: string
felder?: Feld[]
schwere?: Schwere
anlass?: string
einmalig?: string
tag?: string
} = {},
): Promise<boolean> {
try {
const webhook = getSetting(MELDUNG_KEYS.webhook)
if (!webhook) return false
if (opt.anlass && !meldungConfig().anlaesse.includes(opt.anlass)) return false
if (opt.einmalig) {
const merker = `${MELDUNG_KEYS.zuletzt}.${opt.anlass ?? 'allgemein'}`
const zuletzt = getSetting(merker)
if (zuletzt === opt.einmalig) return false
setSetting(merker, opt.einmalig)
}
const marke = getSetting(MELDUNG_KEYS.marke) || MARKE_STANDARD
const antwort = await fetch(webhook, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: 'd4rk_media',
embeds: [
{
title: titel,
description: opt.text,
color: FARBEN[opt.schwere ?? 'marke'],
// Felder statt Fliesstext, wo es Zahlen sind: "228
// Dateien / 11,5 MB / vor 3 Stunden" liest sich in
// einer Zeile schlechter als nebeneinander.
fields: opt.felder,
footer: { text: `${marke} // ${opt.tag ?? 'MEDIA'}` },
timestamp: new Date(now()).toISOString(),
},
],
}),
})
// Discord antwortet auf einen Webhook mit 204.
if (!antwort.ok) {
console.error('[discord] Webhook antwortete mit', antwort.status)
return false
}
return true
} catch (err) {
// Eine Meldung, die den Dienst mitreisst, waere die Buchhaltung, die
// das Geschaeft verhindert.
console.error('[discord] Meldung fehlgeschlagen:', err)
return false
}
}
// Die unterste Stufe ist neu: bisher meldete das hier nur Sicherungsarchive,
// und die sind nie klein. Ein 200-Byte-Bildschirmfoto wurde zu "0 KB" -- eine
// Zahl, die aussieht, als waere etwas kaputt.
export const groesse = (n: number): string =>
n < 1024
? `${n} Bytes`
: n < 1024 * 1024
? `${Math.round(n / 1024)} KB`
: n < 1024 * 1024 * 1024
? `${(n / 1024 / 1024).toFixed(1)} MB`
: `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
/** Wacht ueber die Sicherung.
*
* Der eigentliche Punkt der ganzen Datei. Geprueft wird stuendlich, gemeldet
* hoechstens einmal je Zustand:
*
* - der letzte Lauf ist gescheitert
* - der letzte Lauf ist laenger als 26 Stunden her (taeglich um 04:30 plus
* Luft; 24 waeren zu knapp, 48 zu spaet)
* - es gab noch nie einen
*/
export async function pruefeSicherung(): Promise<void> {
const roh = getSetting('sicherung.letzte')
if (!roh) {
await melde('Noch keine Sicherung gemeldet', {
text: 'Es wurde noch **nie** eine Sicherung eingetragen. Läuft die geplante Aufgabe?',
schwere: 'warnung',
anlass: 'sicherung',
einmalig: 'nie',
tag: 'SICHERUNG',
})
return
}
try {
const letzte = JSON.parse(roh) as {
at: number
ok: boolean
dateien?: number
groesse?: number
hoch?: boolean
fehler?: string
}
const stunden = (now() - letzte.at) / 3_600_000
const wann = `<t:${Math.floor(letzte.at / 1000)}:R>`
if (!letzte.ok) {
await melde('Die Sicherung ist fehlgeschlagen', {
text: letzte.fehler ?? 'Es wurde kein Grund gemeldet.',
felder: [
{ name: 'Wann', value: wann, inline: true },
{
name: 'Zuletzt gut',
value: letzte.dateien ? `${letzte.dateien} Dateien` : '—',
inline: true,
},
],
schwere: 'schlecht',
anlass: 'sicherung',
einmalig: `fehler:${letzte.at}`,
tag: 'SICHERUNG',
})
} else if (stunden > 26) {
await melde('Die Sicherung ist überfällig', {
text: 'Läuft die geplante Aufgabe noch? Erwartet wird sie täglich um 04:30.',
felder: [
{ name: 'Letzter Lauf', value: wann, inline: true },
{ name: 'Das ist', value: `${Math.floor(stunden)} Stunden her`, inline: true },
],
schwere: 'warnung',
anlass: 'sicherung',
einmalig: `alt:${letzte.at}`,
tag: 'SICHERUNG',
})
}
} catch {
/* kaputter Eintrag -- beim naechsten Lauf wieder */
}
}
/** Die Testnachricht. Zeigt bewusst, WIE eine echte aussieht — sonst prueft
* man nur, ob irgendetwas ankommt, und nicht, ob es lesbar ist. */
export async function testMeldung(wer: string, bestand: { files: number; bytes: number }) {
return melde('Testnachricht', {
text: `Ausgelöst von **${wer}**. So sieht eine Meldung von hier aus.`,
felder: [
{ name: 'Bestand', value: `${bestand.files} Dateien`, inline: true },
{ name: 'Belegt', value: groesse(bestand.bytes), inline: true },
],
schwere: 'gut',
})
}
// ------------------------------------------------------------ Plattenplatz
//
// WOZU. Der Dienst schreibt so lange Bilder, wie die Platte sie annimmt -- und
// wenn sie voll ist, faellt das nicht beim Hochladen auf, sondern bei der
// naechsten SICHERUNG, die dann auch nicht mehr schreiben kann. Eine volle
// Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und
// nichts mehr gerettet wird.
//
// Gemeldet wird nach STUFEN und nicht bei jedem Prozentpunkt: `einmalig`
// bekommt die Stufe als Wert, es kommt also je Stufe genau eine Meldung. Faellt
// der Platz weiter, meldet die naechste Stufe; wird wieder Platz frei, faellt
// der Zustand auf "gut" und der naechste Engpass meldet erneut.
// Die Schweregrade heissen hier so wie ueberall im Dienst -- 'schlecht',
// 'warnung', 'marke'. Ich hatte zuerst 'fehler' und 'hinweis' geschrieben, was
// naheliegt und falsch ist; der Uebersetzer hat es gefangen.
const STUFEN = [
{ ab: 0.95, name: 'kritisch' as const, schwere: 'schlecht' as const },
{ ab: 0.9, name: 'eng' as const, schwere: 'warnung' as const },
{ ab: 0.8, name: 'knapp' as const, schwere: 'marke' as const },
]
export async function pruefePlatte(): Promise<void> {
let frei: number
let gesamt: number
try {
// statfs gibt es in node:fs/promises seit 18.15 -- hier laeuft 22.
const s = await statfs(config.dataDir)
gesamt = s.blocks * s.bsize
// bavail und NICHT bfree: ein Teil ist fuer root reserviert, und der
// Dienst laeuft nicht als root -- bfree waere eine Zahl, an die wir gar
// nicht herankommen.
//
// DAS HEISST: WIR MELDEN FRUEHER ALS `df`. Auf dieser Platte gemessen:
//
// gesamt 1006,9 GB
// frei (bavail) 909,5 GB -> 9,7 % belegt <- was hier zaehlt
// frei (bfree) 960,7 GB -> 4,6 % belegt
// df sagt 5 %
//
// Die Luecke ist die ext4-Reserve. Der Unterschied ist gewollt: wer
// gewarnt werden will, bevor es eng wird, will die Zahl, die IHN
// betrifft. Wer spaeter den Unterschied zu `df` sucht, findet ihn hier.
frei = s.bavail * s.bsize
} catch (err) {
// Kein Grund zu laermen: auf manchen Dateisystemen gibt es das nicht.
console.error('[platte] liess sich nicht messen:', err)
return
}
if (!gesamt) return
const belegt = 1 - frei / gesamt
const stufe = STUFEN.find((s) => belegt >= s.ab)
const gb = (n: number) => `${(n / 1073741824).toFixed(1)} GB`
if (!stufe) {
// Wieder Luft. Den Merker zuruecksetzen, damit der naechste Engpass
// wieder meldet -- ohne das bliebe es nach einem einmaligen Volllaufen
// fuer immer still.
setSetting(`${MELDUNG_KEYS.zuletzt}.platte`, 'gut')
return
}
await melde(`Der Platz wird ${stufe.name}`, {
text:
`Unter \`${config.dataDir}\` sind noch **${gb(frei)}** von ${gb(gesamt)} frei ` +
`(${(belegt * 100).toFixed(1)} % belegt).\n\n` +
'Ist die Platte voll, nimmt der Dienst nichts mehr an **und die Sicherung ' +
'kann nicht mehr schreiben** — beides zugleich.',
felder: [
{ name: 'Frei', value: gb(frei), inline: true },
{ name: 'Gesamt', value: gb(gesamt), inline: true },
{ name: 'Belegt', value: `${(belegt * 100).toFixed(1)} %`, inline: true },
],
schwere: stufe.schwere,
anlass: 'platte',
einmalig: stufe.name,
tag: 'PLATTE',
})
}
// ------------------------------------------------------------ Neues ist da
//
// WOZU. Etwas landet hier und niemand erfaehrt es. Eine Meldung darueber ist
// genau dann nuetzlich, wenn sie selten ist.
//
// UND GENAU DAS IST DAS PROBLEM: ein Serienlauf des Fotostudios legt 900
// Fahrzeugbilder ab. Neunhundert Nachrichten waeren keine Meldung mehr, sondern
// ein Ausfall des Kanals -- Discord drosselt Webhooks, und der Rest ginge
// verloren. Wer danach eine ECHTE Meldung bekommt, sieht sie nicht mehr.
//
// NUR TOKEN-UPLOADS, ABSICHTLICH. Was ueber das Dashboard hereinkommt, hat
// gerade jemand selbst hochgeladen und auf dem Bildschirm bestaetigt bekommen --
// eine Meldung darueber erzaehlt ihm, was er selbst getan hat. Und das
// Zurueckholen aus dem Papierkorb oder das Aufnehmen verwaister Dateien meldet
// gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.
//
// DESHALB WIRD GESAMMELT. Nach der Ruhe kommt EINE Nachricht: wieviel, von wem,
// wohin, mit ein paar Beispielen. Ein Serienlauf ergibt eine Meldung, ein
// einzelnes Bild ergibt eine Meldung -- beide sagen dasselbe, nur mit anderen
// Zahlen.
/** Wie lange nach dem letzten Upload gewartet wird, bevor gemeldet wird.
*
* Zwei Minuten: lang genug, dass ein Serienlauf mit Pausen zwischen den
* Bildern als EIN Vorgang durchgeht, kurz genug, dass ein einzelnes Bild
* nicht gefuehlt verlorengeht. */
const SAMMEL_MS = 120_000
type Gesammelt = { anzahl: number; bytes: number; wer: Set<string>; pfade: string[] }
let gesammelt: Gesammelt | null = null
let zeitgeber: NodeJS.Timeout | null = null
/** Einen Upload vormerken. Meldet NICHT sofort, sondern nach der Ruhe. */
export function meldeUpload(pfad: string, bytes: number, wer: string): void {
// Frueh raus, wenn niemand Upload-Meldungen will: sonst sammelte der
// Dienst 900 Pfade zusammen, um sie am Ende wegzuwerfen.
if (!meldungConfig().anlaesse.includes('upload')) return
if (!gesammelt) gesammelt = { anzahl: 0, bytes: 0, wer: new Set(), pfade: [] }
gesammelt.anzahl++
gesammelt.bytes += bytes
gesammelt.wer.add(wer)
// Nur die ersten paar Pfade behalten: bei 900 Dateien waere die Liste
// groesser als die Nachricht, die sie beschreiben soll.
if (gesammelt.pfade.length < 5) gesammelt.pfade.push(pfad)
// JEDER Upload schiebt die Frist nach hinten. So wird aus einem Lauf mit
// Pausen ein Vorgang und nicht zwanzig.
if (zeitgeber) clearTimeout(zeitgeber)
zeitgeber = setTimeout(() => void sammelMelden(), SAMMEL_MS)
// Ohne unref() haelt ein offener Zeitgeber den Prozess am Leben und das
// Herunterfahren wartet zwei Minuten auf nichts.
zeitgeber.unref()
}
async function sammelMelden(): Promise<void> {
const g = gesammelt
// ERST leeren, dann melden: waehrend des Netzaufrufs kann der naechste
// Upload kommen, und der gehoert in die naechste Runde, nicht in diese.
gesammelt = null
zeitgeber = null
if (!g) return
const einzeln = g.anzahl === 1
// Auch im Log, nicht nur im Kanal: wer spaeter fragt „warum kam da nur
// EINE Meldung fuer 900 Bilder", findet hier die Antwort mit Zahl.
console.log(
`[upload] ${g.anzahl} Datei(en), ${groesse(g.bytes)}, von ${[...g.wer].join(', ')} -- eine Meldung`,
)
await melde(einzeln ? 'Etwas Neues ist da' : `${g.anzahl} neue Dateien`, {
text:
g.pfade.map((p) => `\`${p}\``).join('\n') +
(g.anzahl > g.pfade.length ? `\n… und ${g.anzahl - g.pfade.length} weitere` : ''),
felder: [
{ name: 'Zusammen', value: groesse(g.bytes), inline: true },
{ name: 'Von', value: [...g.wer].join(', ').slice(0, 100) || '—', inline: true },
],
schwere: 'gut',
anlass: 'upload',
tag: 'UPLOAD',
})
}
+3319 -20
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
// Anmeldung ueber Discord.
//
// DER ABLAUF, in drei Schritten und ohne Bibliothek:
//
// 1. /api/dash/auth/discord → wir schicken den Browser zu Discord
// 2. Discord fragt den Benutzer → und schickt ihn mit einem `code` zurueck
// 3. /…/discord/callback → wir tauschen den Code gegen ein Zugangs-
// merkmal, holen Benutzer UND Rolle, und
// legen bei Erfolg eine Sitzung an
//
// WARUM OHNE BOT-TOKEN. Die Rolle wird mit dem Merkmal des BENUTZERS geholt
// (`guilds.members.read` → /users/@me/guilds/<server>/member). Damit braucht
// dieser Dienst keinen Bot, keine Bot-Berechtigung und kein zweites Geheimnis,
// das ablaufen kann.
//
// WAS SCHIEFGEHEN KANN, geht sichtbar schief: jeder Abbruch landet wieder auf
// der Anmeldeseite, mit dem Grund im Adressfeld. Ein OAuth-Weg, der bei einem
// Fehler eine weisse Seite zeigt, ist der Grund, warum niemand ihn debuggen
// mag.
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { randomBytes } from 'node:crypto'
import { config } from '../config.js'
import { db, now, type User } from '../db.js'
import { SESSION_COOKIE, createSession } from '../auth.js'
import { discordConfig } from '../settings.js'
export const discordRoutes = new Hono()
/** Muss ZEICHEN FUER ZEICHEN mit dem uebereinstimmen, was im Discord-Portal
* eingetragen ist — daran scheitert der erste Anlauf fast immer. Deshalb
* zeigt das Panel genau diese Zeichenkette an. */
export const redirectUri = () => `${config.publicUrl}/api/dash/auth/discord/callback`
const STATE_COOKIE = 'dstate'
/** Zurueck zur Anmeldeseite, mit dem Grund im Adressfeld. */
const zurueck = (c: any, grund: string) =>
c.redirect(`/?discord=${encodeURIComponent(grund)}`, 302)
// ------------------------------------------------------------------ Schritt 1
discordRoutes.get('/discord', (c) => {
const d = discordConfig()
if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.')
// Der Zustandswert schuetzt gegen untergeschobene Rueckleitungen: nur wer
// diesen Weg begonnen hat, hat das Cookie dazu.
const state = randomBytes(24).toString('base64url')
setCookie(c, STATE_COOKIE, state, {
httpOnly: true,
secure: !config.dev,
sameSite: 'Lax',
path: '/',
maxAge: 600,
})
const url = new URL('https://discord.com/oauth2/authorize')
url.searchParams.set('client_id', d.clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', redirectUri())
// identify: wer es ist. guilds.members.read: welche Rollen er auf DIESEM
// Server hat. Mehr wird nicht erfragt — was man nicht hat, kann man nicht
// verlieren.
url.searchParams.set('scope', 'identify guilds.members.read')
url.searchParams.set('state', state)
return c.redirect(url.toString(), 302)
})
// ------------------------------------------------------------------ Schritt 3
discordRoutes.get('/discord/callback', async (c) => {
const d = discordConfig()
if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.')
// Discord meldet Ablehnungen selbst — etwa wenn jemand auf "Abbrechen"
// klickt. Das ist kein Fehler, sondern eine Antwort.
const fehler = c.req.query('error_description') ?? c.req.query('error')
if (fehler) return zurueck(c, `Discord: ${fehler}`)
const state = c.req.query('state')
const erwartet = getCookie(c, STATE_COOKIE)
deleteCookie(c, STATE_COOKIE, { path: '/' })
if (!state || !erwartet || state !== erwartet) {
return zurueck(c, 'Der Anmeldeversuch passt nicht zu diesem Browser. Noch einmal.')
}
const code = c.req.query('code')
if (!code) return zurueck(c, 'Discord hat keinen Code mitgeschickt.')
try {
// -- Code gegen Zugangsmerkmal --------------------------------------
const tokenAntwort = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: d.clientId,
client_secret: d.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri(),
}),
})
if (!tokenAntwort.ok) {
const text = (await tokenAntwort.text()).slice(0, 200)
// Der haeufigste Fall steht hier zuerst, weil er sonst eine
// Viertelstunde kostet.
return zurueck(
c,
`Discord lehnt den Tausch ab (${tokenAntwort.status}). Stimmt die Redirect-URI im Portal genau? ${text}`,
)
}
const { access_token } = (await tokenAntwort.json()) as { access_token?: string }
if (!access_token) return zurueck(c, 'Discord hat kein Zugangsmerkmal geliefert.')
const mitMerkmal = { headers: { authorization: `Bearer ${access_token}` } }
// -- Wer ist das ----------------------------------------------------
const meAntwort = await fetch('https://discord.com/api/users/@me', mitMerkmal)
if (!meAntwort.ok) return zurueck(c, 'Discord gibt den Benutzer nicht heraus.')
const me = (await meAntwort.json()) as {
id: string
username: string
global_name?: string | null
avatar?: string | null
}
// -- Und welche Rolle hat er hier -----------------------------------
const memberAntwort = await fetch(
`https://discord.com/api/users/@me/guilds/${d.guildId}/member`,
mitMerkmal,
)
if (memberAntwort.status === 404) {
return zurueck(c, 'Du bist nicht auf diesem Discord-Server.')
}
if (!memberAntwort.ok) {
return zurueck(
c,
`Die Mitgliedschaft liess sich nicht pruefen (${memberAntwort.status}). Stimmt die Server-ID?`,
)
}
const member = (await memberAntwort.json()) as { roles?: string[] }
// EINE der eingetragenen Rollen reicht. Mehrere Rollen sind der
// Normalfall, sobald mehr als eine Person Zugang haben soll — Admin
// und Fotograf sind selten dieselbe Rolle.
const hatRolle =
Array.isArray(member.roles) && member.roles.some((r) => d.roleIds.includes(r))
if (!hatRolle) {
// Bewusst ohne die Namen der Rollen: wer keine hat, muss auch nicht
// erfahren, welche es gaebe.
return zurueck(c, 'Dir fehlt eine der noetigen Rollen auf dem Server.')
}
// -- Konto finden oder anlegen --------------------------------------
let user = db.prepare('SELECT * FROM users WHERE discord_id = ?').get(me.id) as
| User
| undefined
const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40)
if (!user) {
// Der Name muss eindeutig sein; zwei Leute koennen denselben
// Anzeigenamen haben. Bei Kollision haengt die Discord-ID dran.
const belegt = db.prepare('SELECT 1 FROM users WHERE username = ?').get(anzeige)
const username = belegt ? `${anzeige} (${me.id})` : anzeige
// KEIN brauchbares Passwort: verifyPassword scheitert an einem
// String, der nicht dem scrypt-Format entspricht — dieses Konto
// kommt also ausschliesslich ueber Discord herein.
const info = db
.prepare(
`INSERT INTO users (username, password_hash, created_at, discord_id,
discord_avatar, discord_name)
VALUES (?, 'nur-ueber-discord', ?, ?, ?, ?)`,
)
.run(username, now(), me.id, me.avatar ?? null, anzeige)
user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as User
console.log(`[discord] Konto angelegt: ${username} (${me.id})`)
} else {
// BEI JEDER ANMELDUNG nachziehen. Wer sein Bild wechselt, sieht
// sonst monatelang das alte — und wundert sich, ob er im richtigen
// Konto sitzt. Der ANZEIGENAME im Dashboard bleibt dagegen, wie er
// ist: an ihm haengen die Eintraege im Verlauf.
db.prepare(
'UPDATE users SET discord_avatar = ?, discord_name = ? WHERE id = ?',
).run(me.avatar ?? null, anzeige, user.id)
}
const session = createSession(user.id)
setCookie(c, SESSION_COOKIE, session.id, {
httpOnly: true,
secure: !config.dev,
sameSite: 'Lax',
path: '/',
expires: new Date(session.expiresAt),
})
return c.redirect('/', 302)
} catch (err) {
console.error('[discord]', err)
return zurueck(
c,
`Der Weg ueber Discord brach ab: ${err instanceof Error ? err.message : 'unbekannt'}`,
)
}
})
+347
View File
@@ -0,0 +1,347 @@
// Der Umzugsweg: sprechen wie Fivemanage.
//
// WOZU. Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem
// Dutzend Ressourcen -- Kamera, Handy, MDT, Fahrzeugstudio, jede mit ihrem
// eigenen Autor und ihrer eigenen Fassung. Sie alle auf unsere Schnittstelle
// umzuschreiben heisst: ein Dutzend fremde Skripte anfassen, jedes davon
// beim naechsten Update wieder. Das tut niemand, und deshalb bliebe der
// Dienst ungenutzt neben dem alten stehen.
//
// SPRICHT DER DIENST DAGEGEN IHRE SPRACHE, ist der Umzug EINE Zeile je Skript:
// die Adresse. Der Schluessel bleibt derselbe Kopf, die Antwort dieselbe Form.
//
// NACHGESEHEN, NICHT GERATEN. Beide Formen stammen aus echtem Code:
//
// v1 github.com/Awleks/Devm-Camera, client/client.lua:
// requestScreenshotUpload('https://api.fivemanage.com/api/image',
// 'image', { headers = { Authorization = apiKey } })
// -> die Antwort wird als resp.url gelesen
//
// v3 github.com/fivemanage/sdk, features/images/server/main.ts:
// POST https://api.fivemanage.com/api/v3/file
// Felder: file, metadata?, path?, filename?, retentionExempt?
// -> { status, data: { id, url } }
//
// Der Schluessel steht bei beiden NACKT im Authorization-Kopf, ohne "Bearer".
// Das nimmt tokenFromHeader inzwischen an.
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { config } from '../config.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js'
import {
checkPath,
kannBewegtVorschau,
kannVorschau,
lohntWebp,
mimeFor,
publicUrlFor,
safeFilename,
sha256,
writeBewegtThumb,
writeFileAtomic,
writeThumb,
writeWebp,
} from '../storage.js'
type Vars = { token: Token }
export const fivemanageRoutes = new Hono<{ Variables: Vars }>()
const wache = async (c: Context<{ Variables: Vars }>, next: Next) => {
const token = tokenFromHeader(c.req.header('authorization'))
if (!token) {
// Die Form der FEHLERANTWORT ist auch Teil der Vertraeglichkeit: ein
// Skript, das auf `status` prueft, soll hier nicht ins Leere greifen.
return c.json({ status: 'error', message: 'Token fehlt oder ist unbekannt' }, 401)
}
if (token.expires_at && token.expires_at < now()) {
return c.json({ status: 'error', message: 'Token ist abgelaufen' }, 401)
}
c.set('token', token)
await next()
}
// AN DIE EIGENEN PFADE, NICHT AN '*'.
//
// Mit '*' galt die Wache fuer alles unterhalb des Einhaengepunkts -- und der
// ist /api, also auch fuer /api/dash daneben. Das Dashboard bekam daraufhin
// 401 auf die ANMELDUNG, obwohl es nie einen Token haben kann.
//
// Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
// schon einmal passiert ist. Ich bin trotzdem hineingelaufen; gemerkt hat es
// der Gegentest, nicht der Kopf.
for (const weg of ['/image', '/video', '/audio', '/file', '/v3/file']) {
fivemanageRoutes.use(weg, wache)
}
/** Wo eine Datei landet, wenn der Aufrufer nichts sagt.
*
* UND SIE UEBERSCHREIBT NIE. Das ist der Unterschied zu /api/upload, wo der
* Pfad die Absicht ist: hier kommen Bildschirmfotos, die alle "screenshot.png"
* heissen. Ohne einen eigenen Namen je Aufruf ueberschriebe das zweite Foto
* das erste, und niemand merkte es -- der Aufrufer bekaeme sogar eine gueltige
* Adresse zurueck.
*
* Der Hash macht den Namen: gleicher Inhalt ergibt denselben Namen (zweimal
* dasselbe Bild kostet keinen zweiten Platz), verschiedener Inhalt einen
* anderen. Genau die Eigenschaft, die Fivemanages ID auch hat. */
function pfadFuer(token: Token, mime: string, digest: string, name?: string): string {
const basis = token.prefix ? token.prefix.replace(/\/+$/, '') : 'uploads'
const kurz = digest.slice(0, 12)
if (name) {
const sauber = safeFilename(name, kurz)
const punkt = sauber.lastIndexOf('.')
const stamm = punkt > 0 ? sauber.slice(0, punkt) : sauber
const endung = punkt > 0 ? sauber.slice(punkt + 1) : endungFuer(mime)
return `${basis}/${stamm}-${kurz}.${endung}`
}
return `${basis}/${kurz}.${endungFuer(mime)}`
}
const ENDUNGEN: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
'video/mp4': 'mp4',
'video/webm': 'webm',
'audio/mpeg': 'mp3',
'audio/ogg': 'ogg',
'audio/wav': 'wav',
}
const endungFuer = (mime: string) => ENDUNGEN[mime.split(';')[0]?.trim().toLowerCase() ?? ''] ?? 'bin'
/** Die Datei aus dem Formular holen.
*
* DER FELDNAME IST NICHT FEST. screenshot-basic bekommt ihn als Argument
* uebergeben ('image' bei Devm-Camera), das SDK schickt 'file', und andere
* Skripte nehmen 'video' oder 'audio'. Ein Dienst, der nur eines davon
* annimmt, lehnt die Haelfte der Aufrufer mit "Feld fehlt" ab -- und der
* Aufrufer sucht den Fehler bei sich. */
const FELDER = ['file', 'image', 'video', 'audio', 'data', 'media'] as const
async function dateiAus(
c: Context<{ Variables: Vars }>,
): Promise<{ datei: File; pfad?: string; name?: string }> {
const typ = c.req.header('content-type') ?? ''
if (!typ.includes('multipart/form-data')) {
throw new Error('Erwartet wird multipart/form-data')
}
// parseBody und NICHT c.req.raw.formData(): letzteres nimmt Hono den Rumpf
// weg, und beim Aufraeumen schliesst dann jemand einen Strom, der schon zu
// ist. Das hat diesen Dienst schon einmal in eine Neustartschleife
// geschickt (siehe dash.ts).
const roh = await c.req.parseBody()
for (const feld of FELDER) {
const wert = roh[feld]
if (wert && typeof wert !== 'string') {
return {
datei: wert as unknown as File,
pfad: typeof roh['path'] === 'string' ? roh['path'] : undefined,
name: typeof roh['filename'] === 'string' ? roh['filename'] : undefined,
}
}
}
throw new Error(`Keine Datei dabei — erwartet wird eines der Felder: ${FELDER.join(', ')}`)
}
/** Was beim Ablegen herauskam: entweder die fertige Adresse, oder eine
* Antwort, die der Aufrufer so bekommen soll.
*
* Ein Entweder-Oder und kein "hat das Ergebnis ein status-Feld?" -- die
* Unterscheidung gehoert in den Typ und nicht in eine Ratefunktion. */
type Abgelegt =
| { ok: true; path: string; url: string; id: string }
| { ok: false; antwort: Response }
/** Der gemeinsame Weg. `art` schraenkt ein, was durchgeht -- /api/image nimmt
* eben nur Bilder, so wie es dort auch heisst. */
async function ablegen(
c: Context<{ Variables: Vars }>,
art?: 'image' | 'video' | 'audio',
): Promise<Abgelegt> {
const token = c.get('token')
let gefunden: Awaited<ReturnType<typeof dateiAus>>
try {
gefunden = await dateiAus(c)
} catch (err) {
return {
ok: false,
antwort: c.json(
{
status: 'error',
message: err instanceof Error ? err.message : 'Rumpf unlesbar',
},
400,
),
}
}
const { datei } = gefunden
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
if (datei.size > grenze) {
return {
ok: false,
antwort: c.json(
{ status: 'error', message: `zu gross: ${datei.size} > ${grenze} Bytes` },
413,
),
}
}
const data = Buffer.from(await datei.arrayBuffer())
if (data.length === 0) {
return { ok: false, antwort: c.json({ status: 'error', message: 'leere Datei' }, 400) }
}
// Dasselbe Kontingent wie auf dem eigenen Weg -- eine Grenze, die nur an
// einer von zwei Tueren haengt, ist keine.
const zuViel = kontingentUeberschritten(token, data.length)
if (zuViel) {
return { ok: false, antwort: c.json({ status: 'error', message: zuViel }, 413) }
}
const digest = sha256(data)
const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? ''
// Der Name entscheidet, wenn der Absender nur mit den Schultern zuckt --
// dieselbe Regel wie beim Dashboard-Upload.
const mime =
gemeldet && gemeldet !== 'application/octet-stream'
? gemeldet
: mimeFor(gefunden.name ?? datei.name ?? '')
if (art && !mime.startsWith(`${art}/`)) {
return {
ok: false,
antwort: c.json(
{
status: 'error',
message: `Hier gehen nur ${art}-Dateien hinein, das hier ist ${mime}`,
},
415,
),
}
}
// Ein ausdruecklicher Pfad (v3 kennt das Feld) hat Vorrang -- aber er wird
// geprueft wie jeder andere, nicht zurechtgebogen.
let path: string
try {
path = gefunden.pfad
? checkPath(gefunden.pfad.replace(/^\/+|\/+$/g, ''))
: checkPath(pfadFuer(token, mime, digest, gefunden.name ?? datei.name))
} catch (err) {
return {
ok: false,
antwort: c.json({ status: 'error', message: (err as Error).message }, 400),
}
}
if (!tokenAllows(token, path)) {
return {
ok: false,
antwort: c.json(
{
status: 'error',
message: `dieser Token darf nur unter ${fesselText(token)} schreiben`,
},
403,
),
}
}
if (token.arten) {
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
const meine = mime.startsWith('image/')
? 'bild'
: mime.startsWith('video/')
? 'video'
: mime.startsWith('audio/')
? 'ton'
: 'andere'
if (!erlaubt.includes(meine)) {
return {
ok: false,
antwort: c.json(
{
status: 'error',
message: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen`,
},
403,
),
}
}
}
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
| Media
| undefined
await writeFileAtomic(path, data)
if (kannVorschau(mime)) await writeThumb(path, data)
if (lohntWebp(mime)) await writeWebp(path, data)
let dauer: number | null = null
if (kannBewegtVorschau(mime)) dauer = (await writeBewegtThumb(path, mime)).dauer
const zeit = now()
if (vorhanden) {
db.prepare(
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ?
WHERE id = ?`,
).run(data.length, digest, mime, dauer, token.id, zeit, vorhanden.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(path, data.length, digest, mime, dauer, token.id, zeit, zeit)
}
logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name)
meldeUpload(path, data.length, token.name)
return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) }
}
// ------------------------------------------------------------------ Die Wege
//
// v1: eine Adresse je Art, Antwort NUR { url, id }. Genau das liest
// Devm-Camera (resp.url), und genau das lesen die meisten aelteren
// Skripte. Zusaetzliche Felder schaden nicht, fehlende schon.
for (const [weg, art] of [
['/image', 'image'],
['/video', 'video'],
['/audio', 'audio'],
] as const) {
fivemanageRoutes.post(weg, async (c) => {
const r = await ablegen(c, art)
if (!r.ok) return r.antwort
return c.json({ url: r.url, id: r.id, path: r.path })
})
}
/** Ohne Einschraenkung der Art -- der Sammelweg. */
fivemanageRoutes.post('/file', async (c) => {
const r = await ablegen(c)
if (!r.ok) return r.antwort
return c.json({ url: r.url, id: r.id, path: r.path })
})
/** v3: dieselbe Arbeit, andere Verpackung.
*
* { status: "ok", data: { id, url } } -- so prueft es das SDK mit valibot,
* und ein fehlendes Feld laesst es die Antwort verwerfen. */
fivemanageRoutes.post('/v3/file', async (c) => {
const r = await ablegen(c)
if (!r.ok) return r.antwort
return c.json({ status: 'ok', data: { id: r.id, url: r.url } })
})
+118
View File
@@ -0,0 +1,118 @@
// Freigaben: einen Ordner ansehen, ohne sich anzumelden.
//
// WAS HIER WIRKLICH FREIGEGEBEN WIRD -- und das ist der Satz, den das Dashboard
// auch anzeigt: die DATEIEN sind ohnehin oeffentlich. Wer die Adresse eines
// Bildes kennt, bekommt es, mit oder ohne Freigabe. Neu ist die LISTE: welche
// Bilder es in einem Ordner gibt. Die war bisher nur hinter der Anmeldung zu
// haben.
//
// Der Schluessel in der Adresse ist KEIN Passwort. Wer den Link hat, sieht die
// Liste -- dieselbe Zusicherung wie bei einem Nextcloud-Freigabelink. Was hier
// NICHT geht: hochladen, loeschen, umbenennen, einen anderen Ordner sehen.
//
// Und was hier bewusst FEHLT: eine eigene Oberflaeche mit eigenem Aufbau. Die
// Freigabe liefert JSON, und die gebaute Oberflaeche zeigt es an -- eine zweite
// Galerie waere eine zweite, die beim naechsten Mal anders aussieht.
import { randomBytes } from 'node:crypto'
import { Hono } from 'hono'
import { db, now, type Freigabe, type Media } from '../db.js'
import { config } from '../config.js'
import { getSetting, MARKE_KEY } from '../settings.js'
import { publicUrlFor } from '../storage.js'
export const freigabeRoutes = new Hono()
/** Der Schluessel: 16 Zeichen aus 32 moeglichen, also rund 80 Bit.
*
* Zu viel, um ihn zu raten, und kurz genug, um ihn vorzulesen. Ohne i, l, o
* und die Null -- ein Link, den jemand aus einem Bildschirmfoto abtippt, soll
* nicht an einer Verwechslung scheitern. */
const ZEICHEN = 'abcdefghjkmnpqrstuvwxyz23456789'
export function neuerSchluessel(): string {
const roh = randomBytes(16)
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.
*
* OHNE ANMELDUNG, und deshalb wird hier genau abgemessen, was hinausgeht:
* Pfad, Groesse, Art, Adresse, Laenge. NICHT: Hash, Token, Zeitpunkte,
* Abrufzahlen, IDs. Nichts davon braucht jemand, der einen Katalog ansieht,
* und jedes davon waere eine Auskunft ueber den Betrieb dieses Dienstes. */
freigabeRoutes.get('/:schluessel', (c) => {
const eintrag = db
.prepare('SELECT * FROM freigaben WHERE schluessel = ?')
.get(c.req.param('schluessel')) as Freigabe | undefined
// Dieselbe Antwort fuer "gibt es nicht" und "gab es mal": ein Unterschied
// waere eine Auskunft an jemanden, der Schluessel durchprobiert.
if (!eintrag) return c.json({ error: 'nicht gefunden' }, 404)
db.prepare('UPDATE freigaben SET aufrufe = aufrufe + 1, zuletzt = ? WHERE id = ?').run(
now(),
eintrag.id,
)
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
.prepare(
`SELECT path, size, mime, dauer FROM media
WHERE path LIKE ? ESCAPE '\\' ORDER BY path COLLATE NOCASE LIMIT ?`,
)
.all(`${maskiert}/%`, GRENZE) as Pick<Media, 'path' | 'size' | 'mime' | 'dauer'>[]
return c.json({
// Auch hier der Name aus derselben Einstellung -- die Freigabeseite ist
// oeffentlich und holt sonst nichts, was ihn kennen koennte.
marke: getSetting(MARKE_KEY) || 'D4RKST3R',
titel: eintrag.titel || eintrag.ordner,
ordner: eintrag.ordner,
// Der Praefix, den die Oberflaeche abschneidet -- damit dort
// "adder.webp" steht und nicht "vehicles/adder.webp". Wer den ganzen
// Pfad sehen will, sieht ihn in der Adresse.
// 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) => ({
name: r.path.slice(eintrag.ordner.length + 1),
size: r.size,
mime: r.mime,
dauer: r.dauer,
url: publicUrlFor(r.path),
// Die Vorschau liegt unter /t/ und braucht keine Anmeldung -- das
// war schon vorher so, sonst zeigte das Dashboard keine Kacheln.
thumb: `${config.publicUrl}/t/${r.path}`,
})),
})
})
+319
View File
@@ -0,0 +1,319 @@
// Der Endpunkt fuer die Statusseite.
//
// WARUM NICHT /health. Das gibt es und bleibt, wie es ist:
//
// app.get('/health', (c) => c.json({ ok: true }))
//
// Diese Zeile beweist genau eines -- der Prozess nimmt HTTP-Anfragen an. Sie
// haengt am HEALTHCHECK im Dockerfile, und DORT ist eine billige Pruefung
// richtig: eine schwere, die bei einer langsamen Platte einmal ausfaellt,
// liesse Docker den Container neu starten. Der Dienst kippte dann genau dann,
// wenn er unter Last steht.
//
// Fuer eine Statusseite ist sie aber zu duenn. Sie stuende auf Gruen, waehrend
// die Platte voll ist und kein Upload mehr angenommen wird.
//
// HIER WIRD DESHALB WIRKLICH NACHGESEHEN, und zwar an den vier Stellen, an
// denen dieser Dienst scheitern kann:
//
// Datenbank eine echte Abfrage, nicht "die Datei ist da"
// Platte schreiben UND wieder loeschen -- der einzige Beweis
// Ausliefern eine Datei aus der Datenbank suchen und nachmessen
// Sicherung wie alt ist der letzte Lauf
//
// WAS NICHT HINAUSGEHT: Dateizahlen, Groessen, Pfade, Tokennamen, Benutzer.
// Ein oeffentlicher Endpunkt sagt "gesund" oder "krank" -- nicht, wie gross
// der Bestand ist. Die einzige Zahl, die herausgeht, ist die Laufzeit, und die
// steht in jedem Serverkopf ohnehin naeher dran, als es hier stuende.
import { randomBytes } from 'node:crypto'
import { readdir, rm, stat, statfs, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { Hono } from 'hono'
import { config } from '../config.js'
import { db, now, type Media } from '../db.js'
import { getSetting } from '../settings.js'
import { absolutePath } from '../storage.js'
export const statusRoutes = new Hono()
type Pruefung = { was: string; ok: boolean; hinweis?: string }
/** Wie alt eine Sicherung sein darf, bevor es auffaellt. 26 Stunden -- die
* gleiche Schwelle wie beim Wachhund in meldung.ts, damit nicht zwei Stellen
* verschiedener Meinung darueber sind, was "aktuell" heisst. */
const SICHERUNG_STUNDEN = 26
// -- Nicht bei jeder Anfrage rechnen -----------------------------------------
//
// Der Endpunkt ist OEFFENTLICH und schreibt auf die Platte. Ohne Bremse waere
// er ein Verstaerker: wer ihn im Sekundentakt abruft, erzeugt im Sekundentakt
// Schreibvorgaenge. Ein Ergebnis von vor zehn Sekunden ist fuer eine
// Statusseite so gut wie ein frisches -- Monitore fragen ohnehin im
// Minutentakt.
const FRISCHE_MS = 10_000
let letzte: { zeit: number; antwort: Antwort } | null = null
type Antwort = {
dienst: string
ok: boolean
stand: 'gesund' | 'beeintraechtigt' | 'gestoert'
seit: number
pruefungen: Pruefung[]
}
async function pruefen(): Promise<Antwort> {
const pruefungen: Pruefung[] = []
// -- Datenbank ------------------------------------------------------------
//
// Eine ECHTE Abfrage gegen eine echte Tabelle. `SELECT 1` bewiese nur, dass
// SQLite antwortet; eine beschaedigte Datei faellt erst auf, wenn jemand
// eine Seite davon liest.
let medien = 0
try {
medien = (db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }).n
pruefungen.push({ was: 'datenbank', ok: true })
} catch (err) {
pruefungen.push({
was: 'datenbank',
ok: false,
hinweis: err instanceof Error ? err.message : 'nicht lesbar',
})
}
// -- Platte ---------------------------------------------------------------
//
// SCHREIBEN und wieder loeschen. Ein `stat` auf den Ordner bewiese nur, dass
// es ihn gibt -- eine volle oder schreibgeschuetzte Platte sieht dabei
// genauso aus wie eine gesunde. Die Probe liegt im Datenordner und NICHT
// unter files/: dort wuerde sie der Verwaisten-Sucher finden.
const probe = join(config.dataDir, `.status-${process.pid}-${randomBytes(4).toString('hex')}`)
try {
await writeFile(probe, 'probe')
await rm(probe, { force: true })
pruefungen.push({ was: 'platte', ok: true })
} catch (err) {
await rm(probe, { force: true }).catch(() => {})
pruefungen.push({
was: 'platte',
ok: false,
hinweis: err instanceof Error ? err.message : 'nicht beschreibbar',
})
}
// -- Ausliefern -----------------------------------------------------------
//
// Die Probe aufs Exempel: eine Datei aus der Datenbank holen und nachsehen,
// ob sie mit der eingetragenen Groesse dort liegt. Damit haengen Datenbank
// UND Platte UND die Pfadaufloesung gemeinsam mit im Test -- das ist der
// Unterschied zwischen "beide Teile leben" und "sie passen zueinander".
//
// Ein leerer Bestand ist kein Fehler: ein frisch aufgesetzter Dienst ist
// gesund und hat nichts auszuliefern.
if (medien > 0) {
try {
const zeile = db
.prepare('SELECT path, size FROM media ORDER BY RANDOM() LIMIT 1')
.get() as Pick<Media, 'path' | 'size'>
const info = await stat(absolutePath(zeile.path))
if (info.size === zeile.size) {
pruefungen.push({ was: 'ausliefern', ok: true })
} else {
pruefungen.push({
was: 'ausliefern',
ok: false,
hinweis: 'Groesse auf der Platte weicht vom Eintrag ab',
})
}
} catch {
// Der Pfad steht ABSICHTLICH nicht in der Meldung: das hier ist
// oeffentlich.
pruefungen.push({
was: 'ausliefern',
ok: false,
hinweis: 'eine eingetragene Datei liegt nicht auf der Platte',
})
}
} else {
// KEIN EINTRAG, ABER DATEIEN AUF DER PLATTE -- der eine Zustand, in dem
// alles gesund AUSSIEHT und trotzdem alles weg ist.
//
// Nachgemessen, und zwar an genau diesem Dienst: wird eine Sicherung
// zurueckgespielt, ohne media.db-wal daneben zu entfernen, spielt SQLite
// das WAL der ALTEN Datenbank ueber die neue. Ergebnis: 0 Zeilen,
// waehrend 4452 Dateien danebenliegen. Der Dienst kommt hoch, /health
// ist gruen, die Mediathek ist leer.
//
// Ein frisch aufgesetzter Dienst hat weder Eintraege noch Dateien und
// ist voellig gesund. Erst der WIDERSPRUCH aus beidem ist das Zeichen.
try {
const drin = await readdir(config.filesDir)
if (drin.length > 0) {
pruefungen.push({
was: 'bestand',
ok: false,
hinweis: 'kein Eintrag in der Datenbank, aber Dateien auf der Platte',
})
}
} catch {
/* kein Dateiordner -- bei einem frischen Dienst normal */
}
}
// -- Platz ----------------------------------------------------------------
//
// Nur als Beeintraechtigung, nicht als Stoerung: bei 95 % laeuft alles
// noch, aber nicht mehr lange. Prozent statt Gigabyte -- die absolute Zahl
// waere eine Auskunft ueber die Maschine.
try {
const s = await statfs(config.dataDir)
const belegt = 1 - (s.bavail * s.bsize) / (s.blocks * s.bsize)
if (belegt >= 0.95) {
pruefungen.push({
was: 'platz',
ok: false,
hinweis: `${Math.round(belegt * 100)} % belegt`,
})
} else {
pruefungen.push({ was: 'platz', ok: true })
}
} catch {
// Auf manchen Dateisystemen gibt es statfs nicht. Kein Grund, den
// Dienst deswegen krank zu nennen.
}
// -- Sicherung ------------------------------------------------------------
const roh = getSetting('sicherung.letzte')
if (!roh) {
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'noch keine gemeldet' })
} else {
try {
const l = JSON.parse(roh) as {
at: number
ok: boolean
hoch?: boolean
zweit?: boolean
}
const stunden = (now() - l.at) / 3_600_000
if (!l.ok) {
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'letzte fehlgeschlagen' })
} else if (stunden > SICHERUNG_STUNDEN) {
pruefungen.push({
was: 'sicherung',
ok: false,
hinweis: `letzte vor ${Math.round(stunden)} Stunden`,
})
} else {
// WIEVIELE ORTE, nicht nur "gelaufen". Eine Sicherung, die es
// nur einmal gibt, ist gruen und trotzdem eine, die ein
// Plattenausfall mitnimmt -- das gehoert auf die Statusseite,
// wo jemand hinschaut, und nicht nur ins Panel.
const orte = 1 + (l.zweit ? 1 : 0) + (l.hoch ? 1 : 0)
pruefungen.push({
was: 'sicherung',
ok: true,
hinweis: orte === 1 ? 'nur an einem Ort' : `an ${orte} Orten`,
})
}
} catch {
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'Eintrag unlesbar' })
}
}
// -- Der Waechter ---------------------------------------------------------
//
// Geschrieben von tools/waechter.ps1 auf dem WIRT -- der Dienst im Container
// sieht weder die Ports der Maschine noch ihre Dateien. Hier steht nur, was
// der Waechter zuletzt gefunden hat.
//
// FEHLT DER EINTRAG GANZ, ist der Waechter nicht eingerichtet. Das ist kein
// Fehler des Dienstes, aber es gehoert gesagt: eine Statusseite, die
// schweigt, weil niemand hinsieht, ist genau das Problem, gegen das der
// Waechter gebaut wurde.
const wRoh = getSetting('waechter.letzte')
if (!wRoh) {
pruefungen.push({ was: 'waechter', ok: true, hinweis: 'nicht eingerichtet' })
} else {
try {
const w = JSON.parse(wRoh) as {
at: number
ok: boolean
ports?: number
heikel?: number[]
befund?: string[]
}
const stunden = (now() - w.at) / 3_600_000
if (stunden > 2) {
// Er laeuft alle 15 Minuten. Zwei Stunden Schweigen heisst: er
// laeuft nicht mehr -- und ein Waechter, von dem man glaubt,
// dass er wacht, ist schlimmer als keiner.
pruefungen.push({
was: 'waechter',
ok: false,
hinweis: `meldet sich seit ${Math.round(stunden)} Stunden nicht`,
})
} else if (!w.ok) {
pruefungen.push({
was: 'waechter',
ok: false,
hinweis: (w.befund ?? []).join('; ') || 'Befund ohne Text',
})
} else {
const heikel = w.heikel?.length
pruefungen.push({
was: 'waechter',
ok: true,
hinweis: heikel
? `${w.ports} Ports offen, davon ${heikel} heikel`
: `${w.ports} Ports offen`,
})
}
} catch {
pruefungen.push({ was: 'waechter', ok: false, hinweis: 'Eintrag unlesbar' })
}
}
// -- Das Urteil -----------------------------------------------------------
//
// ZWEI STUFEN, und der Unterschied ist der zwischen "tut es nicht" und
// "braucht Aufmerksamkeit". Datenbank, Platte und Ausliefern sind das
// Geschaeft dieses Dienstes -- faellt eines aus, ist er gestoert. Eine alte
// Sicherung oder eine volle Platte tun heute noch nicht weh und morgen
// sehr.
const HART = ['datenbank', 'platte', 'ausliefern', 'bestand']
const gestoert = pruefungen.some((p) => HART.includes(p.was) && !p.ok)
const schwach = pruefungen.some((p) => !p.ok)
return {
dienst: 'd4rk_media',
ok: !gestoert,
stand: gestoert ? 'gestoert' : schwach ? 'beeintraechtigt' : 'gesund',
// Auf Sekunden gerundet -- eine Statusseite zeigt "seit 3 Tagen" an,
// keine Millisekunden.
seit: Math.round(process.uptime()),
pruefungen,
}
}
/** Der Endpunkt.
*
* `?streng=1` laesst auch eine Beeintraechtigung mit 503 antworten. Ohne den
* Schalter ist eine alte Sicherung kein "down" -- der Dienst liefert ja aus.
* WELCHES von beiden richtig ist, weiss nur, wer die Statusseite betreibt:
* deshalb ein Schalter und keine Entscheidung meinerseits. */
statusRoutes.get('/', async (c) => {
if (!letzte || now() - letzte.zeit > FRISCHE_MS) {
letzte = { zeit: now(), antwort: await pruefen() }
}
const a = letzte.antwort
const streng = c.req.query('streng') === '1'
const schlecht = streng ? a.stand !== 'gesund' : !a.ok
return c.json(a, schlecht ? 503 : 200, {
// Eine zwischengespeicherte Statusmeldung ist keine. Der Proxy greift
// hier zwar nicht zu (die Adresse endet auf keine Bildendung), aber das
// ist eine Einstellung und keine Zusicherung.
'Cache-Control': 'no-store',
})
})
+218 -24
View File
@@ -22,18 +22,26 @@
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { config } from '../config.js'
import { db, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js'
import { fesselText, tokenAllows, tokenFromHeader } 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 }
@@ -50,12 +58,90 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
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,
)
}
c.set('token', token)
await next()
}
/** 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. */
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('/exists', requireToken)
uploadRoutes.use('/exists/*', requireToken)
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
@@ -94,14 +180,24 @@ uploadRoutes.post('/upload', async (c) => {
// 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 > config.maxUploadBytes) {
return c.json(
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
413,
)
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)
@@ -111,12 +207,13 @@ uploadRoutes.post('/upload', async (c) => {
const { data } = body
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
if (data.length > config.maxUploadBytes) {
return c.json(
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
413,
)
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)
@@ -139,7 +236,7 @@ uploadRoutes.post('/upload', async (c) => {
if (!tokenAllows(token, path)) {
return c.json(
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` },
{ error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
403,
)
}
@@ -158,33 +255,85 @@ uploadRoutes.post('/upload', async (c) => {
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, data)
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 = ?, token_id = ?, updated_at = ?
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ?
WHERE id = ?`,
).run(data.length, digest, mime, token.id, timestamp, existing.id)
).run(daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, existing.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
`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: data.length,
sha256: digest,
size: daten.length,
sha256: digestAbgelegt,
mime,
replaced: Boolean(existing),
// Nur dann, wenn wirklich gerechnet wurde -- ein Feld, das immer
// dasteht, sagt nichts.
...(verkleinert ? { verkleinert } : {}),
})
})
@@ -214,17 +363,54 @@ uploadRoutes.delete('/media/:pfad{.+}', async (c) => {
}
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. */
uploadRoutes.get('/exists/:pfad{.+}', (c) => {
* 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(c.req.param('pfad'))
path = checkPath(roh)
} catch {
return c.json({ exists: false }, 200)
}
@@ -237,4 +423,12 @@ uploadRoutes.get('/exists/:pfad{.+}', (c) => {
? { exists: true, path, url: publicUrlFor(path), ...row }
: { exists: false, path },
)
}
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')))
+208
View File
@@ -0,0 +1,208 @@
// Einstellungen, die zur Laufzeit verstellbar sind.
//
// WAS HIER LIEGT UND WAS NICHT. In der Umgebung bleibt, was den Dienst
// ueberhaupt erreichbar macht: PUBLIC_URL, PORT, DATA_DIR, ADMIN_PASSWORD. Wer
// die per Formular verstellen koennte, koennte sich mit einem Klick
// aussperren, und die Reparatur waere ein Eingriff in die Datenbank.
//
// Hier liegt der Rest — derzeit die Discord-Anmeldung. Der Gewinn ist nicht
// nur Bequemlichkeit: das Panel kann die Redirect-URI gleich zum Kopieren
// hinlegen, und die ist die Stelle, an der ein OAuth-Anlauf am haeufigsten
// scheitert.
//
// DAS GEHEIMNIS LIEGT DAMIT IN media.db, und die steckt in jeder Sicherung
// unter C:\backup. Die .env ist dort nicht drin. Beides liegt unverschluesselt
// auf derselben Platte, der Unterschied ist die Zahl der Kopien — wer den
// Client-Schluessel zurueckziehen will, tut das im Discord-Portal, nicht hier.
import { db, now } from './db.js'
const lesen = db.prepare('SELECT value FROM settings WHERE key = ?')
const schreiben = db.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
/** Ein Wert aus der Datenbank — oder, wenn dort nichts steht, aus der
* Umgebung. Die Reihenfolge ist Absicht: wer schon Stack-Variablen gesetzt
* hat, behaelt sie, und wer das Panel benutzt, uebersteuert sie. */
export function getSetting(key: string, envName?: string): string {
const zeile = lesen.get(key) as { value: string } | undefined
if (zeile && zeile.value !== '') return zeile.value
return (envName ? (process.env[envName] ?? '') : '').trim()
}
export function setSetting(key: string, value: string): void {
schreiben.run(key, value, now())
}
/** Ob ein Wert aus der Datenbank kommt (true) oder aus der Umgebung (false).
* Die Oberflaeche sagt das dazu — sonst wundert sich jemand, warum ein
* geleertes Feld weiterhin wirkt. */
export function ausDatenbank(key: string): boolean {
const zeile = lesen.get(key) as { value: string } | undefined
return Boolean(zeile && zeile.value !== '')
}
// ------------------------------------------------------------ Adressvorlagen
//
// Eine Vorlage ist der Bauplan einer oeffentlichen Adresse:
//
// vehicles/{model}.webp -> https://…/f/vehicles/adder.webp
//
// Wozu speichern? Weil genau diese Zeichenkette an der anderen Seite gebraucht
// wird -- im Handy, im Skript, im Fotostudio -- und weil sie sich jemand sonst
// aus Ordnername plus Endung zusammenreimt. Einmal falsch zusammengereimt
// heisst: das Bild fehlt, und gesucht wird an der falschen Stelle.
//
// Als JSON in EINER Zeile der Einstellungstabelle. Eine eigene Tabelle fuer
// eine Handvoll Zeilen waere eine Wanderung mehr, die niemand braucht.
/** Wie der Dienst sich nennt.
*
* DIESELBE Einstellung, die auch in der Fusszeile der Discord-Meldungen steht
* (`D4RKST3R // MEDIA`). Ein zweiter Name fuer die Oberflaeche waere ein
* zweiter, der beim naechsten Mal anders lautet als der erste -- und dann
* heisst dasselbe Ding an zwei Stellen verschieden.
*
* Der Standard steht in meldung.ts; hier wird er nur weitergereicht. */
export const MARKE_KEY = 'marke.name'
export const VORLAGEN_KEY = 'vorlagen'
export type Vorlage = { name: string; muster: string }
export function vorlagen(): Vorlage[] {
try {
const roh = getSetting(VORLAGEN_KEY)
if (!roh) return []
const daten = JSON.parse(roh)
if (!Array.isArray(daten)) return []
return daten.filter(
(v): v is Vorlage =>
v && typeof v.name === 'string' && typeof v.muster === 'string',
)
} catch {
// Ein kaputter Eintrag darf die Einstellungsseite nicht mitreissen.
return []
}
}
export function setzeVorlagen(liste: Vorlage[]): void {
setSetting(VORLAGEN_KEY, JSON.stringify(liste))
}
// ----------------------------------------------------------------- Sicherung
//
// Die Werte fuer die Sicherung stehen hier, obwohl das Skript sie braucht und
// nicht der Dienst. Der Grund ist die Rueckmeldung: eine Textdatei auf der
// Platte sagt niemandem, ob die Sicherung heute Nacht gelaufen ist. Ein
// Eintrag in der Datenbank kann das Panel anzeigen.
//
// tools/sichern.ps1 liest sie ueber `docker exec` DIREKT aus media.db und
// nicht ueber die API: so verlaesst das Passwort den Rechner nie -- ausser
// beim Eintragen im Browser, und das geht ueber TLS.
export const SICHERUNG_KEYS = {
basis: 'sicherung.basis',
benutzer: 'sicherung.benutzer',
passwort: 'sicherung.passwort',
ordner: 'sicherung.ordner',
behalten: 'sicherung.behalten',
/** Ein ZWEITER lokaler Ablageort, moeglichst auf einer anderen Platte.
*
* WOZU, wenn es doch schon die Nextcloud gibt: die ist weit weg. Wer
* 100 GB zurueckholen muss, laedt sie ueber die Leitung -- das ist die
* Sicherung fuer den Fall, dass das Haus abbrennt, nicht fuer den Fall,
* dass eine Platte stirbt. Fuer den zweiten will man die Kopie IM Rechner
* haben, nur eben nicht auf derselben Platte.
*
* Der Pfad gilt auf dem HOST, nicht im Container -- ihn prueft deshalb
* nicht der Dienst, sondern tools/sichern.ps1. */
zweitziel: 'sicherung.zweitziel',
/** Schreibt das Skript nach jedem Lauf. JSON, damit das Panel es anzeigen
* kann, ohne dass hier eine weitere Tabelle noetig waere. */
letzte: 'sicherung.letzte',
} as const
export type SicherungConfig = {
basis: string
benutzer: string
ordner: string
behalten: number
zweitziel: string
hatPasswort: boolean
bereit: boolean
}
export function sicherungConfig(): SicherungConfig {
const basis = getSetting(SICHERUNG_KEYS.basis)
const benutzer = getSetting(SICHERUNG_KEYS.benutzer)
const passwort = getSetting(SICHERUNG_KEYS.passwort)
const ordner = getSetting(SICHERUNG_KEYS.ordner) || 'Sicherungen/d4rk_media'
const behalten = Number(getSetting(SICHERUNG_KEYS.behalten)) || 14
return {
basis,
benutzer,
ordner,
behalten,
zweitziel: getSetting(SICHERUNG_KEYS.zweitziel),
hatPasswort: Boolean(passwort),
bereit: Boolean(basis && benutzer && passwort),
}
}
// ------------------------------------------------------------------- Discord
export const DISCORD_KEYS = {
clientId: 'discord.client_id',
clientSecret: 'discord.client_secret',
guildId: 'discord.guild_id',
/** MEHRERE Rollen, mit Komma getrennt. Der alte Schluessel mit genau einer
* Rolle wird weiter gelesen (siehe rollen()) — wer schon eine eingetragen
* hatte, soll sie nicht verlieren, nur weil hier eine Liste daraus wurde. */
roleIds: 'discord.role_ids',
roleIdAlt: 'discord.role_id',
} as const
export type DiscordConfig = {
clientId: string
clientSecret: string
guildId: string
/** Wer EINE davon hat, darf herein. Leere Liste heisst: nicht eingerichtet. */
roleIds: string[]
/** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance.
* Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist
* schlimmer als ein Knopf, den es nicht gibt. */
bereit: boolean
}
/** Die Rollen, aus welcher Quelle sie auch kommen.
*
* Reihenfolge: die neue Liste, dann der alte Einzelwert, dann die Umgebung.
* Getrennt wird an Komma, Semikolon, Leerzeichen und Zeilenumbruch — wer eine
* Liste aus Discord kopiert, soll sie nicht erst von Hand aufraeumen. */
export function rollen(): string[] {
const roh =
getSetting(DISCORD_KEYS.roleIds) ||
getSetting(DISCORD_KEYS.roleIdAlt) ||
(process.env.DISCORD_ROLE_IDS ?? process.env.DISCORD_ROLE_ID ?? '')
return [...new Set(roh.split(/[\s,;]+/).map((t) => t.trim()).filter(Boolean))]
}
export function discordConfig(): DiscordConfig {
const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID')
const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET')
const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID')
const roleIds = rollen()
return {
clientId,
clientSecret,
guildId,
roleIds,
bereit: Boolean(clientId && clientSecret && guildId && roleIds.length > 0),
}
}
+537 -4
View File
@@ -10,6 +10,7 @@
// unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand
// merkt es, bis das Bild fehlt.
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve, sep } from 'node:path'
@@ -41,10 +42,16 @@ export function checkPath(raw: string): string {
for (const segment of segments) {
if (!SEGMENT.test(segment)) {
// Die Meldung nennt die Regel so, wie sie ist. Vorher stand hier
// "nicht mit Punkt beginnend" — das ist nur die Haelfte: das erste
// Zeichen muss ein Buchstabe oder eine Ziffer sein, ein fuehrender
// Unterstrich faellt also ebenso durch. Aufgefallen an
// "_probe.webp", und das ist kein erfundener Fall: das Fotostudio
// legt seine Leerbilder als "_plate" ab.
throw new PathError(
`"${segment}" ist als Pfadteil nicht erlaubt ` +
'(Buchstaben, Ziffern, Punkt, Strich, Unterstrich; ' +
'nicht mit Punkt beginnend)',
'(erstes Zeichen: Buchstabe oder Ziffer; danach auch ' +
'Punkt, Strich, Unterstrich)',
)
}
}
@@ -75,6 +82,28 @@ const MIME: Record<string, string> = {
pdf: 'application/pdf',
json: 'application/json',
txt: 'text/plain; charset=utf-8',
// Lesbares. ALLES ALS text/plain, auch das Markdown -- und das ist die
// Stelle, an der man sich vertun kann: eine eigene Art wie text/markdown
// brächte nichts, aber text/html brächte eine Luecke. Wer einen
// Upload-Token hat, koennte damit eine Seite unter unserem Namen
// veroeffentlichen; als text/plain zeigt der Browser den Quelltext an und
// fuehrt nichts aus.
//
// Deshalb stehen html, css und js hier NICHT, und das soll auch so
// bleiben. Die Oberflaeche hat dafuer ihre eigene Tabelle in index.ts.
md: 'text/plain; charset=utf-8',
markdown: 'text/plain; charset=utf-8',
csv: 'text/plain; charset=utf-8',
lua: 'text/plain; charset=utf-8',
xml: 'text/plain; charset=utf-8',
yml: 'text/plain; charset=utf-8',
yaml: 'text/plain; charset=utf-8',
toml: 'text/plain; charset=utf-8',
ini: 'text/plain; charset=utf-8',
cfg: 'text/plain; charset=utf-8',
sql: 'text/plain; charset=utf-8',
log: 'text/plain; charset=utf-8',
}
export function mimeFor(path: string): string {
@@ -82,6 +111,79 @@ export function mimeFor(path: string): string {
return MIME[ext] ?? 'application/octet-stream'
}
/** Aus einem Dateinamen von der Platte einen zulaessigen Pfadteil machen.
*
* Der Weg vom Dashboard ist ein anderer als der vom Skript: dort schickt
* jemand `X-Path` und weiss genau, was er will. Hier zieht jemand
* "Mein Video (final) 2.MP4" ins Fenster, und `checkPath` wuerde das mit
* Recht ablehnen — Klammern, Leerzeichen, alles nicht erlaubt.
*
* Deshalb wird der Name hier zurechtgebogen, und zwar VORHERSAGBAR:
* Kleinbuchstaben, alles Fremde wird zum Strich, Striche werden
* zusammengefasst. Aus obigem wird "mein-video-final-2.mp4". Wer den
* Dateinamen exakt behalten will, benennt ihn vorher um — das ist ehrlicher,
* als so zu tun, als koennte jeder Name eine URL werden.
*
* `eindeutig` ist der Rettungsanker fuer den Fall, dass vom Namen NICHTS
* uebrig bleibt — bei "ЖУРНАЛ.txt" oder " .png". Ohne ihn hiessen beide
* "datei.txt" bzw. "datei.png", und die zweite Datei ueberschriebe die erste
* stillschweigend. Genau das ist beim Messen passiert. Ueblicherweise gibt
* der Aufrufer die ersten Stellen des SHA-256 mit: gleicher Inhalt ergibt
* denselben Namen, verschiedener Inhalt verschiedene. */
export function safeFilename(name: string, eindeutig?: string): string {
const punkt = name.lastIndexOf('.')
const roh = punkt > 0 ? name.slice(0, punkt) : name
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) =>
s
.replace(/ä/g, 'ae')
.replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe')
.replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue')
.replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[^A-Za-z0-9]+/, '')
.replace(/[-.]+$/, '')
const stamm = zaehmen(roh)
// 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
// kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und
// sieht im Ergebnis, was daraus geworden ist.
const basis = stamm || (eindeutig ? `datei-${eindeutig}` : 'datei')
return ende ? `${basis}.${ende}` : basis
}
export function extensionFor(mime: string): string {
const base = mime.split(';')[0]?.trim().toLowerCase()
for (const [ext, value] of Object.entries(MIME)) {
@@ -94,6 +196,57 @@ export const sha256 = (data: Buffer) => createHash('sha256').update(data).digest
export const absolutePath = (path: string) => join(config.filesDir, path)
/** Ein Bild auf eine Hoechstkante rechnen -- oder es lassen.
*
* WOFUER NICHT: als Schutz vor riesigen Bildern. Das waere gemessen falsch --
* ein 12000 x 12000 grosses PNG kostet 374 ms und 47 MB, weil libvips
* kachelweise arbeitet und nie das ganze Bild dekodiert.
*
* WOFUER DANN: Plattenplatz und Ladezeit. Ein Bildschirmfoto aus dem Spiel ist
* 3840 breit; auf einem Handy wird es auf 400 gezeigt. Die restlichen 3440
* liegen auf der Platte und gehen bei jedem Abruf ueber die Leitung.
*
* `withoutEnlargement` ist entscheidend: ein 200 Pixel breites Icon wird NICHT
* auf 2048 aufgeblasen. Sonst machte die Einstellung kleine Dateien groesser.
*
* Gibt den neuen Puffer zurueck, oder den alten, wenn nichts zu tun war. Wirft
* nie -- ein Bild, das sich nicht rechnen laesst, wird abgelegt wie es kam. */
export async function vielleichtKleiner(
data: Buffer,
mime: string,
maxKante: number | null,
): Promise<{ data: Buffer; verkleinert: false | { von: string; auf: string } }> {
if (!maxKante || maxKante < 16) return { data, verkleinert: false }
if (!kannVorschau(mime)) return { data, verkleinert: false }
try {
const { default: sharp } = await import('sharp')
const bild = sharp(data, { animated: false })
const vorher = await bild.metadata()
const groesste = Math.max(vorher.width ?? 0, vorher.height ?? 0)
if (!groesste || groesste <= maxKante) return { data, verkleinert: false }
// IM URSPRUNGSFORMAT bleiben: aus einem PNG ein WebP zu machen aendert
// die Endung nicht mit, und dann liegt eine .png da, die keine ist.
// Die sparsame Fassung entsteht ohnehin daneben.
const neu = await bild
.resize({ width: maxKante, height: maxKante, fit: 'inside', withoutEnlargement: true })
.toBuffer()
const nachher = await sharp(neu).metadata()
return {
data: neu,
verkleinert: {
von: `${vorher.width}x${vorher.height}`,
auf: `${nachher.width}x${nachher.height}`,
},
}
} catch (err) {
console.error(`[kleiner] ${err instanceof Error ? err.message : err}`)
return { data, verkleinert: false }
}
}
/** Schreibt die Datei.
*
* Erst daneben, dann umbenennen: ein Ueberschreiben ist damit entweder ganz
@@ -114,8 +267,384 @@ export async function writeFileAtomic(path: string, data: Buffer): Promise<void>
}
}
// -------------------------------------------------------------- Vorschaubilder
//
// WARUM UEBERHAUPT. Ein freigestelltes Fahrzeugbild wiegt rund 124 KB. Eine
// Galerieseite zeigt 60 davon — das sind 7,2 MB, nur damit jemand sieht, WELCHE
// Bilder da sind. Bei 900 Fahrzeugen liegen 109 MB in der Ablage. Die Vorschau
// wiegt ein Fuenfzigstel und reicht fuer eine Kachel voellig.
//
// WO SIE LIEGEN. Unter /data/thumbs, im selben Baum wie die Bilder, mit
// ".webp" hinten dran. Also thumbs/vehicles/adder.webp.webp — haesslich, aber
// eindeutig: haengte man die Endung nicht an, kollidierten adder.png und
// adder.webp im Vorschauordner.
//
// KEINE SPALTE IN DER DATENBANK. Ob es eine Vorschau gibt, sagt das
// Dateisystem. Eine Spalte waere eine zweite Wahrheit, die auseinanderlaufen
// kann — und das Dashboard faellt ohnehin auf das Vollbild zurueck, wenn die
// Vorschau fehlt.
export const THUMB_BREITE = 320
export const thumbPath = (path: string) => join(config.dataDir, 'thumbs', `${path}.webp`)
export const kannVorschau = (mime: string) =>
/^image\/(png|jpeg|webp|avif|gif)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Eine Vorschau erzeugen. Schlaegt sie fehl, ist das KEIN Grund, den Upload
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
const ziel = thumbPath(path)
try {
// Der Import steht hier drin und nicht oben: sharp zieht eine native
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
// Bild kommt.
const { default: sharp } = await import('sharp')
await mkdir(dirname(ziel), { recursive: true })
const bild = await sharp(data, { animated: false })
// withoutEnlargement: ein 64 Pixel breites Bild wird nicht auf 320
// aufgeblasen — das kostet Platz und sieht schlechter aus als das
// Original.
.resize({ width: THUMB_BREITE, withoutEnlargement: true })
.webp({ quality: 72 })
.toBuffer()
const temp = `${ziel}.${process.pid}.tmp`
await writeFile(temp, bild)
await rename(temp, ziel)
return true
} catch (err) {
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
return false
}
}
// ------------------------------------------- Vorschau fuer Video und Ton
//
// Bis hierhin bekam alles, was kein Bild ist, ein Fragezeichen auf die Kachel.
// Bei 3600 Bildern faellt das nicht auf; bei einer Handvoll Clips ist die
// Galerie dann eine Liste von Dateinamen.
//
// VIDEO: ein Standbild. sharp kann das nicht -- libvips dekodiert kein Video.
// Also ffmpeg, und zwar auf die Datei AUF DER PLATTE und nicht auf den Puffer:
// ein Container wie MP4 hat seine Inhaltsangabe je nach Kodierer am Ende, und
// ohne Springen findet ffmpeg sie nicht.
//
// TON: das eingebettete Titelbild, wenn eines da ist. Fuer ffmpeg ist das
// schlicht die Videospur der Datei -- derselbe Aufruf, andere Zeitmarke.
// Ist keines da, entsteht keine Vorschau, und die Kachel zeigt ein Notenzeichen
// statt eines leeren Rahmens.
export const kannBewegtVorschau = (mime: string) =>
/^(video|audio)\//.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Wie lange wir ffmpeg zugestehen.
*
* Ein beschaedigter Container kann einen Dekodierer beliebig lange
* beschaeftigen. 20 Sekunden reichen fuer ein Standbild aus jeder gesunden
* Datei -- was laenger braucht, bekommt eben keine Vorschau. */
const FFMPEG_MS = 20_000
/** Was in der Datei steckt: wie lang, und ob es ueberhaupt ein Bild gibt.
*
* BEIDES AUS EINEM AUFRUF. `ffmpeg -i datei` ohne Ausgabeziel bricht mit einem
* Fehler ab ("At least one output file must be specified") -- seinen Kopf hat
* es da aber laengst gedruckt, und darin steht alles, was wir brauchen. Der
* Rueckgabewert wird deshalb bewusst ignoriert und nur stderr gelesen. Kein
* `-f null -`: das dekodierte die ganze Datei, um am Ende dasselbe zu wissen.
*
* OHNE ffprobe, und das ist eine gemessene Entscheidung: das Werkzeug haette
* als statische Binaerdatei 135 MB ins Abbild gebracht. Gegengeprueft an
* beiden Dateien, die hier liegen, auf zwei Nachkommastellen identisch:
*
* ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77
* ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64
*
* Fuer eine Beschriftung wie "0:29" ist das genauer als noetig.
*
* `hatBild` WIRD AUS DER SPURENLISTE GELESEN und nicht aus einer
* Fehlermeldung. Genau daran ist der erste Anlauf gescheitert: er erkannte
* "keine Bildspur" am Wortlaut, und der ist zwischen den Fassungen anders --
*
* ffmpeg 5.1 "Stream map '0:v:0' matches no streams."
* ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'"
*
* -- worauf eine voellig gesunde MP3 als "1 fehlgeschlagen" gezaehlt wurde.
* Die Zeile "Stream #0:1: Video:" dagegen steht seit jeher so da. */
export type MedienInfo = { dauer: number | null; hatBild: boolean }
export async function medienInfo(path: string): Promise<MedienInfo> {
const { stderr } = await ffmpegRoh(['-i', absolutePath(path)])
// "Duration: 00:10:45.64," -- bei Stroemen ohne Laengenangabe "Duration: N/A".
const treffer = /Duration:\s*(\d+):(\d\d):(\d\d(?:\.\d+)?)/.exec(stderr)
const sekunden = treffer
? Number(treffer[1]) * 3600 + Number(treffer[2]) * 60 + Number(treffer[3])
: NaN
return {
dauer: Number.isFinite(sekunden) && sekunden > 0 ? sekunden : null,
// Ein eingebettetes Titelbild ist fuer ffmpeg eine Bildspur:
// "Stream #0:1: Video: mjpeg ... (attached pic)".
hatBild: /Stream #\d+:\d+[^\n]*:\s*Video:/.test(stderr),
}
}
/** Was bei einem Versuch herauskam.
*
* DREI Zustaende und nicht zwei, und das ist nachgemessen: eine MP3 OHNE
* eingebettetes Titelbild ist voellig in Ordnung -- sie hat schlicht keine
* Bildspur. Mit einem blossen true/false zaehlte der Nachzieh-Knopf sie als
* "1 fehlgeschlagen", und dann sucht jemand einen Fehler, den es nicht gibt.
* Genau so ist es beim ersten Lauf passiert. */
export type BewegtErgebnis = 'ok' | 'keinBild' | 'fehler'
/** Ein Standbild bzw. Titelbild als Vorschau, samt Laenge.
*
* Gibt BEIDES zurueck, weil beides aus demselben Blick in die Datei kommt: ein
* zweiter Aufruf nur fuer die Sekundenzahl waere die doppelte Arbeit.
*
* Wie writeThumb: ein Fehlschlag ist KEIN Grund, den Upload scheitern zu
* lassen. */
export async function writeBewegtThumb(
path: string,
mime: string,
): Promise<{ ergebnis: BewegtErgebnis; dauer: number | null }> {
const ziel = thumbPath(path)
const quelle = absolutePath(path)
const istVideo = mime.startsWith('video')
const info = await medienInfo(path)
// Keine Bildspur: bei Ton der Normalfall (die meisten MP3 tragen kein
// Titelbild). Hier wird ffmpeg gar nicht erst ein zweites Mal bemueht.
if (!info.hatBild) return { ergebnis: 'keinBild', dauer: info.dauer }
try {
await mkdir(dirname(ziel), { recursive: true })
// Bei Video NICHT den Anfang nehmen, wenn sich das vermeiden laesst:
// viele Clips beginnen mit Schwarz oder einem Einblender, und eine
// schwarze Kachel ist so aussagekraeftig wie gar keine. Ein Zehntel
// hinein ist fast immer Bild.
//
// Bei Ton NICHT springen: das eingebettete Titelbild ist ein einzelnes
// Standbild, und ein -ss darauf findet nichts mehr.
const start = istVideo && info.dauer ? Math.min(info.dauer * 0.1, 10) : 0
const temp = `${ziel}.${process.pid}.tmp.webp`
await ffmpeg([
// -ss VOR -i: dann springt ffmpeg, statt bis dorthin zu dekodieren.
...(start > 0 ? ['-ss', start.toFixed(2)] : []),
'-i', quelle,
// Nur die Bildspur, genau ein Bild. Bei einer MP3 ist das
// eingebettete Titelbild genau diese Spur.
'-map', '0:v:0',
'-frames:v', '1',
// Nicht aufblasen: ein 64 Pixel breites Titelbild bleibt klein.
'-vf', `scale='min(${THUMB_BREITE},iw)':-2`,
'-f', 'webp',
'-y', temp,
])
await rename(temp, ziel)
return { ergebnis: 'ok', dauer: info.dauer }
} catch (err) {
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
return { ergebnis: 'fehler', dauer: info.dauer }
}
}
/** ffmpeg aufrufen, OHNE zu urteilen: gibt zurueck, was herauskam, samt
* Rueckgabewert. Fuer den Fall, dass ein Fehlschlag die Antwort IST -- siehe
* medienDauer.
*
* execFile und NICHT exec: ohne Shell dazwischen gibt es keine Zeichenkette,
* in der ein Dateiname etwas anderes bedeuten koennte. Der Pfad ist zwar durch
* checkPath gegangen, aber diese Abwehr kostet nichts. */
function ffmpegRoh(args: string[]): Promise<{ code: number | null; stderr: string }> {
return new Promise((erfuellen) => {
execFile(
'ffmpeg',
args,
{ timeout: FFMPEG_MS, maxBuffer: 4 * 1024 * 1024 },
(fehler, _stdout, stderr) => {
erfuellen({
code: fehler ? ((fehler as { code?: number }).code ?? 1) : 0,
stderr,
})
},
)
})
}
/** Dasselbe, aber ein Fehlschlag ist ein Fehlschlag. */
async function ffmpeg(args: string[]): Promise<void> {
const { code, stderr } = await ffmpegRoh(args)
if (code !== 0) {
// Die letzte Zeile von ffmpeg ist die, die den Grund nennt.
throw new Error(stderr.trim().split('\n').pop() ?? `ffmpeg endete mit ${code}`)
}
}
/** Eine Datei samt Vorschau an einen anderen Pfad legen.
*
* DER PFAD IST DER SCHLUESSEL — deshalb ist Verschieben hier kein Kopieren
* mit anschliessendem Loeschen, sondern ein `rename`: das ist auf derselben
* Platte unteilbar. Ein abgebrochenes Kopieren hinterliesse die Datei zweimal
* oder gar nicht.
*
* Die Vorschau zieht mit. Bliebe sie liegen, zeigte die Galerie am neuen Ort
* kein Bild und am alten Ort das eines Bildes, das dort nicht mehr liegt. */
export async function moveFile(von: string, nach: string): Promise<void> {
const ziel = absolutePath(nach)
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(von), ziel)
const abgeleitet: [string, string][] = [
[thumbPath(von), thumbPath(nach)],
[webpPfad(von), webpPfad(nach)],
]
for (const [vonPfad, nachPfad] of abgeleitet) {
try {
await mkdir(dirname(nachPfad), { recursive: true })
await rename(vonPfad, nachPfad)
} catch {
// Nicht vorhanden — kein Fehler. Beides ist abgeleitet und laesst
// sich jederzeit neu rechnen.
}
}
}
// ---------------------------------------------------------- Die sparsame Fassung
//
// DAS PROBLEM. Die uebernommenen Item-Bilder sind PNG. Als WebP waeren sie ein
// Bruchteil davon. Nur: DER PFAD IST DIE ADRESSE -- aus items/foo.png darf
// nicht items/foo.webp werden, sonst zeigt jedes Skript ins Leere, das den
// Namen schon kennt.
//
// DIE LOESUNG steht im Protokoll selbst. Der Browser sagt mit `Accept`, was er
// versteht. Wir legen die sparsame Fassung DANEBEN und liefern sie unter
// DERSELBEN Adresse aus, wenn der Aufrufer sie mag. Wer sie nicht mag -- Lua,
// curl, ein aelteres Werkzeug -- bekommt unveraendert das Original. Niemand
// muss etwas umstellen.
//
// Dazu gehoert `Vary: Accept` in der Antwort. Ohne die Zeile legt ein
// Zwischenspeicher die WebP-Fassung fuer ALLE ab, auch fuer die, die sie nicht
// lesen koennen. Unser Proxy speichert /f/ zwar gar nicht zwischen -- aber das
// ist eine Einstellung, die jemand aendern kann, und darauf soll die
// Richtigkeit nicht beruhen.
export const webpPfad = (path: string) => join(config.dataDir, 'webp', `${path}.webp`)
/** Fuer welche Arten sich eine sparsame Fassung lohnt.
*
* WebP selbst NICHT: das waere ein Umkodieren ohne Gewinn. GIF auch nicht --
* dort braeuchte es animiertes WebP, und der Gewinn ist klein gegen das
* Risiko, dass die Bewegung dabei verlorengeht. */
export const lohntWebp = (mime: string) =>
/^image\/(png|jpeg)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Die sparsame Fassung erzeugen. Gibt ihre Groesse zurueck, oder null.
*
* Schlaegt es fehl, ist das KEIN Fehler fuer den Aufrufer: dann wird eben
* weiter das Original ausgeliefert, so wie bisher. */
export async function writeWebp(path: string, data: Buffer): Promise<number | null> {
const ziel = webpPfad(path)
try {
const { default: sharp } = await import('sharp')
await mkdir(dirname(ziel), { recursive: true })
// OHNE Groessenaenderung -- anders als bei der Vorschau. Es geht um die
// Kodierung, nicht um die Aufloesung: wer ein 4K-Bild hochlaedt, will
// ein 4K-Bild, nur kleiner in Bytes.
const bild = await sharp(data, { animated: false }).webp({ quality: 82 }).toBuffer()
// NUR wenn es sich lohnt. Bei kleinen oder schon dichten PNG faellt
// WebP durchaus groesser aus -- dann waere die "sparsame" Fassung eine
// Verschwendung mit zusaetzlicher Verwaltung obendrauf. Eine alte
// Fassung, die nun nicht mehr lohnt, muss dabei weg: sonst liefert der
// Dienst weiter die groessere aus.
if (bild.length >= data.length) {
await rm(ziel, { force: true })
return null
}
const temp = `${ziel}.${process.pid}.tmp`
await writeFile(temp, bild)
await rename(temp, ziel)
return bild.length
} catch (err) {
console.error(`[webp] ${path}: ${err instanceof Error ? err.message : err}`)
return null
}
}
/** Einen ganzen Ordner an einen anderen Platz legen.
*
* NICHT dasselbe wie moveFile mit einem Ordner darin, und das ist ein
* Fallstrick, der beim Schreiben fast durchgerutscht waere: `thumbPath` haengt
* ".webp" an. Fuer eine Datei ist das richtig (vehicles/adder.png ->
* thumbs/vehicles/adder.png.webp), fuer einen ORDNER waere es Unsinn -- der
* Vorschauordner heisst thumbs/vehicles und nicht thumbs/vehicles.webp.
*
* Deshalb hier die Ordnerfassung: dieselben drei Baeume, aber ohne Endung. */
export async function moveFolder(von: string, nach: string): Promise<void> {
await mkdir(dirname(join(config.filesDir, nach)), { recursive: true })
await rename(join(config.filesDir, von), join(config.filesDir, nach))
for (const ordner of ['thumbs', 'webp']) {
try {
await mkdir(dirname(join(config.dataDir, ordner, nach)), { recursive: true })
await rename(
join(config.dataDir, ordner, von),
join(config.dataDir, ordner, nach),
)
} catch {
// Gibt es nicht — kein Fehler. Beides ist abgeleitet und laesst
// sich mit einem Knopf neu rechnen.
}
}
}
export async function deleteFile(path: string): Promise<void> {
await rm(absolutePath(path), { force: true })
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
// beim naechsten Bild unter demselben Pfad sieht man das alte.
await rm(thumbPath(path), { force: true })
await rm(webpPfad(path), { force: true })
}
// ------------------------------------------------------------- Papierkorb
//
// Verschieben statt Loeschen. Der Ablagename traegt eine laufende Nummer, weil
// unter demselben Pfad nacheinander verschiedene Dateien gelegen haben koennen
// -- und beide sollen zurueckholbar sein.
export const papierkorbPfad = (ablage: string) => join(config.dataDir, 'papierkorb', ablage)
/** Die Datei in den Papierkorb legen. Gibt den Ablagenamen zurueck.
*
* Die VORSCHAU wandert NICHT mit: sie laesst sich aus dem Bild neu rechnen,
* und ein Papierkorb, der Abgeleitetes mitschleppt, ist doppelt so gross wie
* noetig. */
export async function inDenPapierkorb(path: string, id: number): Promise<string> {
const ablage = `${id}-${path.replace(/[\\/]/g, '_')}`
const ziel = papierkorbPfad(ablage)
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(path), ziel)
await rm(thumbPath(path), { force: true })
await rm(webpPfad(path), { force: true })
return ablage
}
/** Zurueck an ihren Platz. */
export async function ausDemPapierkorb(ablage: string, path: string): Promise<void> {
const ziel = absolutePath(path)
await mkdir(dirname(ziel), { recursive: true })
await rename(papierkorbPfad(ablage), ziel)
}
export async function fileExists(path: string): Promise<boolean> {
@@ -127,5 +656,9 @@ export async function fileExists(path: string): Promise<boolean> {
}
}
/** Die oeffentliche Adresse einer Datei. */
export const publicUrlFor = (path: string) => `${config.publicUrl}/${path}`
/** Die oeffentliche Adresse einer Datei.
*
* filePrefix ist bei zwei Namen leer und bei einem "/f" — so kommt hier
* dieselbe Adresse heraus, unter der die Datei auch tatsaechlich liegt. */
export const publicUrlFor = (path: string) =>
`${config.publicUrl}${config.filePrefix}/${path}`
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>D4RKST3R Media</title>
<meta name="robots" content="noindex, nofollow" />
<!-- Ohne diese vier Zeilen fragt jeder Browser /favicon.ico ab, und die
SPA-Rueckfallroute antwortet mit index.html: 200, Inhaltstyp
text/html, ein leeres Blatt im Tab und 2 KB umsonst bei jedem
Aufruf. Nachgemessen, bevor es hier stand.
SVG zuerst: es bleibt in jeder Groesse scharf. Das 32er PNG ist
fuer die Browser daneben, das 180er fuer das Symbol auf dem
Startbildschirm eines Telefons. Alle drei stammen aus DERSELBEN
SVG-Datei -- gerechnet, nicht zweimal gezeichnet. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/favicon-180.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#0b0d10" />
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2825
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "d4rk-media-ui",
"version": "0.1.0",
"description": "Dashboard fuer d4rk_media",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"type": "module",
"dependencies": {
"lucide-react": "^1.31.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^5.4.21"
},
"private": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="D4RKST3R Media">
<!--
Dieselbe Form wie in der Kopfzeile: eine Platte. Der Dienst ist eine
Ablage, und das Zeichen sagt genau das.
Die Farben stammen aus ui/src/index.css (Grund #0b0d10, Akzent #4ea3ff)
und sind nicht danebengeraten.
Ein SVG und keine .ico: es bleibt in jeder Groesse scharf und wiegt
weniger als ein einziges Pixelformat. Fuer die zwei Stellen, die kein
SVG nehmen, liegen daneben PNG-Fassungen, die AUS DIESER DATEI gerechnet
werden. Eine Quelle, kein zweites Zeichnen, das beim naechsten Mal
anders aussieht.
Und im Kommentar steht kein doppelter Bindestrich: XML verbietet ihn,
und die Datei war damit fuer jeden Parser kaputt. Aufgefallen beim
Rechnen der PNG, nicht erst im Browser.
-->
<rect width="64" height="64" rx="14" fill="#0b0d10"/>
<g fill="none" stroke="#4ea3ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<rect x="12" y="20" width="40" height="24" rx="5"/>
<path d="M12 32h40"/>
</g>
<circle cx="43" cy="38" r="3" fill="#4ea3ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"name": "D4RKST3R Media",
"short_name": "Media",
"start_url": "/",
"display": "standalone",
"background_color": "#0b0d10",
"theme_color": "#0b0d10",
"icons": [
{ "src": "/favicon-180.png", "sizes": "180x180", "type": "image/png" },
{ "src": "/favicon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect } from 'react'
import {
Code2,
Gauge,
HardDrive,
History,
Images,
KeyRound,
PieChart,
Settings,
Trash2,
} from 'lucide-react'
import { useStore } from './store'
import { Anmelden } from './pages/Anmelden'
import { Start } from './pages/Start'
import { Galerie } from './pages/Galerie'
import { Speicher } from './pages/Speicher'
import { Tokens } from './pages/Tokens'
import { Konto } from './pages/Konto'
import { Einstellungen } from './pages/Einstellungen'
import { Verlauf } from './pages/Verlauf'
import { Api } from './pages/Api'
import { Papierkorb } from './pages/Papierkorb'
import { Freigabe } from './pages/Freigabe'
import { Meldungen } from './components/Meldungen'
import { gehZu, usePfad } from './router'
import { Kontomenue } from './components/Kontomenue'
import { dienstName, useMarke } from './marke'
/** Die Breite des Inhalts an EINER Stelle.
*
* Sie stand vorher zweimal da (Kopfzeile und Inhalt), und zwei Seiten setzten
* zusätzlich ihre eigene: dadurch waren Konto und Einstellungen schmaler als
* der Rest, und die Kopfzeile lief über beiden anders. Formulare werden
* stattdessen INNERHALB ihrer Karte begrenzt.
*/
const BREITE = 'mx-auto w-full max-w-7xl px-4'
const seiten = [
{ pfad: '/', name: 'Start', kind: Gauge, Inhalt: Start },
{ pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie },
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
{ pfad: '/papierkorb', name: 'Papierkorb', kind: Trash2, Inhalt: Papierkorb },
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
] as const
/** Seiten OHNE Reiter. Sie sind ueber die Adresse und ueber das Kontomenue
* erreichbar -- ein Lesezeichen auf /konto soll nicht ins Leere fuehren, nur
* weil der Reiter verschwunden ist. */
const versteckt = [{ pfad: '/konto', Inhalt: Konto }] as const
export function App() {
// Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne
// Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das
// ganze Dashboard neu — bei 60 Kacheln mit Bildern ist das spürbar.
const user = useStore((s) => s.user)
const checking = useStore((s) => s.checking)
const checkSession = useStore((s) => s.checkSession)
const pfad = usePfad()
const marke = useMarke()
useEffect(() => {
void checkSession()
}, [checkSession])
// EINE FREIGABE GEHT VOR ALLEM ANDEREN -- vor dem Prüfen der Sitzung, vor
// dem Anmeldeformular, vor der Kopfzeile. Wer einen Link bekommen hat, ist
// kein Benutzer dieses Dashboards; ihm ein Anmeldeformular zu zeigen wäre
// die falsche Antwort auf "sieh dir das an".
//
// Und deshalb steht das hier VOR dem `checking`-Zweig: sonst blitzt für
// einen Moment das Rädchen der Sitzungsprüfung auf, die es gar nicht
// braucht.
if (pfad.startsWith('/s/')) {
return <Freigabe schluessel={decodeURIComponent(pfad.slice(3))} />
}
// Solange nicht feststeht, ob eine Sitzung besteht, wird NICHTS gezeigt.
// Sonst blitzt bei jedem Neuladen kurz das Anmeldeformular auf, obwohl man
// angemeldet ist — und das sieht aus wie ein Fehler.
if (checking) {
return (
<div className="flex min-h-full items-center justify-center">
<HardDrive className="animate-pulse text-schrift-leise" />
<Meldungen />
</div>
)
}
if (!user) {
return (
<>
<Anmelden />
<Meldungen />
</>
)
}
const ohneReiter = versteckt.find((s) => s.pfad === pfad)
const aktiv = seiten.find((s) => s.pfad === pfad) ?? seiten[0]
const Inhalt = ohneReiter ? ohneReiter.Inhalt : aktiv.Inhalt
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-40 border-b border-rand bg-flaeche/80 backdrop-blur">
<div className={`${BREITE} flex flex-wrap items-center gap-x-6 gap-y-2 py-2.5`}>
<a
href="/"
onClick={(e) => {
e.preventDefault()
gehZu('/')
}}
className="flex items-center gap-2 font-semibold"
>
<HardDrive size={18} className="text-akzent" />
{dienstName(marke)}
</a>
<nav className="flex flex-1 gap-1">
{seiten.map((s) => {
const an = s.pfad === aktiv.pfad
return (
<a
key={s.pfad}
href={s.pfad}
onClick={(e) => {
e.preventDefault()
gehZu(s.pfad)
}}
className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-1.5
text-sm transition-colors ${
an
? 'bg-flaeche-hoch text-schrift after:absolute after:inset-x-2.5 after:-bottom-[9px] after:h-0.5 after:rounded-full after:bg-akzent'
: 'text-schrift-leise hover:bg-flaeche-hoch/40 hover:text-schrift'
}`}
>
<s.kind size={15} />
{s.name}
</a>
)
})}
</nav>
<Kontomenue />
</div>
</header>
<main key={aktiv.pfad} className={`auftauchen ${BREITE} flex-1 py-5`}>
<Inhalt />
</main>
<Meldungen />
</div>
)
}
+630
View File
@@ -0,0 +1,630 @@
// Der EINZIGE Ort, an dem fetch steht.
//
// Und die Stelle, an der der Fivemanage-Anlauf gescheitert ist: dessen
// http-util.ts warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf
// `instanceof ApiError` — und damit wurde jeder Fehler lautlos verschluckt.
// Deshalb gibt es hier genau eine Fehlerklasse, und `request` wirft
// ausschließlich diese. Wer sie fängt, hat immer einen Text zum Anzeigen.
export class ApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
/** Kein Netz, kein Server, DNS tot — alles, was nie beim Dienst ankam. */
export class NetworkError extends ApiError {
constructor(message: string) {
super(message, 0)
this.name = 'NetworkError'
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: Response
try {
response = await fetch(path, {
// Das Sitzungs-Cookie ist httpOnly; ohne diese Zeile schickt der
// Browser es nicht mit, und jede Antwort wäre 401.
credentials: 'same-origin',
...init,
headers: {
...(init.body ? { 'content-type': 'application/json' } : {}),
...init.headers,
},
})
} catch (err) {
// fetch wirft nur, wenn die Anfrage gar nicht stattgefunden hat. Das
// ist ein anderer Fall als "der Dienst sagt nein", und er verdient
// einen anderen Satz.
throw new NetworkError(
`Der Dienst ist nicht erreichbar (${err instanceof Error ? err.message : 'unbekannt'})`,
)
}
const text = await response.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
// Der Dienst antwortet auf allen API-Wegen mit JSON. Kommt hier
// etwas anderes an, ist der Proxy davor oder die Sitzung schuld —
// dann steht der Anfang der Antwort in der Meldung, statt dass
// "unerwarteter Fehler" auf dem Bildschirm steht.
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${text.slice(0, 140)}`,
response.status,
)
}
throw new ApiError('Die Antwort war kein JSON', response.status)
}
}
if (!response.ok) {
const message =
data && typeof data === 'object' && typeof (data as any).error === 'string'
? (data as any).error
: `HTTP ${response.status}`
throw new ApiError(message, response.status)
}
return data as T
}
const get = <T,>(path: string) => request<T>(path)
const post = <T,>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
const del = <T,>(path: string) => request<T>(path, { method: 'DELETE' })
// -- Die Formen, die der Dienst wirklich liefert -------------------------------
//
// Abgeschrieben aus routes/dash.ts, nicht erfunden. Wo der Server
// Datenbankspalten durchreicht, heißen sie hier genauso (can_delete,
// created_at) — ein hübscherer Name auf halbem Weg wäre eine Übersetzung, die
// beim nächsten Feld vergessen wird.
export type User = {
id: number
username: string
/** Kam dieses Konto ueber Discord herein? */
discord: boolean
discordName: string | null
/** Fertige Adresse vom Dienst das URL-Schema von Discord steht dort und
* nicht hier. Null, wenn es kein Discord-Konto ist. */
avatar: string | null
}
export type MediaItem = {
id: number
path: string
size: number
sha256: string
mime: string
token_id: number | null
created_at: number
updated_at: number
url: string
/** Wie oft die Datei oeffentlich geholt wurde. */
abrufe: number
zuletzt_abgerufen: number | null
/** Laenge in Sekunden — nur bei Video und Ton, sonst null. */
dauer: number | null
}
export type MediaPage = {
total: number
limit: number
offset: number
items: MediaItem[]
}
export type Stats = {
files: number
bytes: number
tokens: number
folders: { folder: string; files: number; bytes: number }[]
publicUrl: string
}
export type PapierkorbEintrag = {
id: number
path: string
size: number
sha256: string
mime: string
geloescht: number
von: string
/** Liegt die Datei wirklich noch im Papierkorb? Der Eintrag steht in der
* Datenbank, die Datei daneben auf der Platte zwei Wahrheiten, die
* auseinanderlaufen koennen. */
dateiDa: boolean
}
export type PapierkorbSeite = {
items: PapierkorbEintrag[]
n: number
bytes: number
haltbarTage: number
/** Eintraege ohne Datei. Sollte 0 sein. */
verwaist: number
}
export type Speicher = {
nachArt: { art: string; files: number; bytes: number }[]
groesste: MediaItem[]
aelteste: MediaItem[]
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
verschwendet: number
stufen: { tage: number; files: number; bytes: number }[]
/** Was seit dem Einbau des Zaehlers NIE geholt wurde. */
nieGeholt: { n: number; bytes: number }
beliebteste: MediaItem[]
}
export type TokenRow = {
id: number
name: string
prefix: string
can_delete: number
created_at: number
last_used_at: number | null
/** Groesste erlaubte EINZELNE Datei. Null = die Grenze des Dienstes. */
max_bytes: number | null
/** Wieviel dieser Token INSGESAMT halten darf. Null = unbegrenzt. */
quota_bytes: number | null
/** Groesste Kantenlaenge fuer Bilder. Null = ablegen wie es kommt. */
max_kante: number | null
/** Erlaubte Arten, mit Komma. Leer = alle. */
arten: string
expires_at: number | null
abgelaufen: boolean
/** Wieviel mit DIESEM Token abgelegt wurde. */
dateien: number
bytes: number
/** Wann zuletzt etwas dabei herauskam anders als `last_used_at`, das
* auch ein Anklopfen ohne Ergebnis mitzaehlt. */
zuletztAbgelegt: number | null
}
/** Der Klartext kommt genau einmal beim Anlegen. Danach steht nur noch der
* Hash in der Datenbank, und auch der Dienst kann ihn nicht mehr zeigen. */
export type NewToken = {
id: number
name: string
prefix: string
canDelete: boolean
token: string
}
/** Ein Ordner mit KUMULATIVEN Zahlen: "vehicles" enthält, was in
* "vehicles/showroom" liegt. `tiefe` ist die Einrückung im Baum. */
export type FolderRow = { folder: string; files: number; bytes: number; tiefe: number }
export type Folders = {
items: FolderRow[]
gesamt: { files: number; bytes: number }
wurzel: { files: number; bytes: number }
}
/** Der billige Fuehler fuer die automatische Auffrischung. */
export type Stand = { medien: number; neuest: number; ereignis: number }
export type Uebersicht = {
files: number
bytes: number
heuteHoch: number
wocheHoch: number
publicUrl: string
filePrefix: string
letzte: MediaEvent[]
bilder: MediaItem[]
ordner: { folder: string; files: number; bytes: number }[]
tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[]
discord: boolean
vorlagen: Vorlage[]
}
export type Sicherung = {
eingehaengt: boolean
ordner: string
bytes: number
items: { name: string; size: number; at: number }[]
}
export type Vorlage = { name: string; muster: string }
/** Ein Konto. `weg` sagt, worueber jemand hereinkommt die wichtigere Auskunft
* als der Name, denn ein Discord-Konto haengt an einer Rolle, die jemand
* anders vergibt. */
export type BenutzerRow = {
id: number
username: string
created_at: number
zuletzt_da: number | null
weg: 'discord' | 'passwort'
discordName: string | null
avatar: string | null
sitzungen: number
}
/** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann.
*
* Freigegeben ist die LISTE, nicht der Inhalt die Dateien sind ohnehin
* oeffentlich. Das steht so auch in der Karte, weil das Gegenteil zu glauben
* gefaehrlich waere. */
export type FreigabeRow = {
id: number
schluessel: string
ordner: string
titel: string
created_at: number
aufrufe: number
zuletzt: number | null
url: string
}
export type VerschiebeErgebnis = {
verschoben: number
fehlgeschlagen: number
ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[]
}
export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere'
export type Arten = Record<string, number>
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' | 'abrufe' | 'geholt'
export type Richtung = 'auf' | 'ab'
export type Verwaiste = {
dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
datensatzOhneDatei: { anzahl: number; beispiele: string[] }
/** Vorschauen UND sparsame Fassungen, deren Original es nicht mehr gibt.
* Die Beispiele tragen den Ordner vorn thumbs/ oder webp/. */
abgeleitetOhneBild: { anzahl: number; beispiele: string[] }
}
/** Was EIN STAPEL sparsame Fassungen nachziehen" zurueckmeldet.
*
* `fertig` ist der Punkt: der Server rechnet hoechstens ein paar hundert
* Stueck je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Solange `fertig`
* falsch ist, steht in `offen`, wieviele noch fehlen. */
export type WebpLauf = {
erzeugt: number
vorhanden: number
uebersprungen: number
lohntNicht: number
fehler: number
gesamt: number
offen: number
fertig: boolean
vorher: number
nachher: number
gespart: number
}
/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */
export type LetzteSicherung = {
at: number
ok: boolean
dateien?: number
groesse?: number
/** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */
hoch?: boolean
/** true = auch auf der zweiten Platte. undefined heisst kein zweites Ziel
* eingerichtet" und nicht „hat nicht geklappt" -- schiefgegangen waere ein
* Grund fuer ok:false, nicht fuer ein stilles false. */
zweit?: boolean
fehler?: string
} | null
export type Einstellungen = {
vorlagen: Vorlage[]
meldungen: { hatWebhook: boolean; anlaesse: string[] }
sicherung: {
basis: string
benutzer: string
ordner: string
behalten: number
zweitziel: string
hatPasswort: boolean
bereit: boolean
letzte: LetzteSicherung
}
discord: {
clientId: string
guildId: string
/** Wer EINE davon hat, darf herein. */
roleIds: string[]
/** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */
hatGeheimnis: boolean
bereit: boolean
redirectUri: string
ausDerUmgebung: {
clientId: boolean
clientSecret: boolean
guildId: boolean
roleIds: boolean
}
}
}
/** Eine Zeile aus dem Verwaltungs-Verlauf.
*
* `was` traegt NIE einen Wert, nur Schluesselnamen unter den Einstellungen
* liegen das Discord-Geheimnis und das Nextcloud-Passwort. */
export type VerwaltungRow = {
id: number
at: number
wer: string
bereich: 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
tat: 'angelegt' | 'geaendert' | 'entfernt'
ziel: string
was: string
}
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
export type MediaEvent = {
id: number
at: number
kind: EventKind
path: string
size: number | null
actor_kind: 'token' | 'user' | 'unbekannt'
actor: string
/** Nur bei 'move': wo die Datei vorher lag. */
von: string | null
}
export type EventsSeite = {
total: number
limit: number
offset: number
items: MediaEvent[]
akteure: {
actor: string
actor_kind: string
n: number
zuletzt: number
avatar: string | null
}[]
/** Ab wann aufgezeichnet wird — null, solange nichts drinsteht. */
seit: number | null
}
export type UploadErgebnis = {
name: string
ok: boolean
path?: string
url?: string
size?: number
replaced?: boolean
error?: string
}
export type ThumbLauf = {
erzeugt: number
vorhanden: number
uebersprungen: number
fehler: number
gesamt: number
/** Wieviele noch fehlen. Siehe WebpLauf — derselbe Grund. */
offen: number
fertig: boolean
/** Ton ohne eingebettetes Titelbild. KEIN Fehler — der Normalfall. */
ohneBild: number
}
/** Die Adresse der Vorschau zu einem Pfad.
*
* Ob es sie gibt, weiss nur das Dateisystem deshalb faellt die Galerie bei
* 404 auf das Vollbild zurueck, statt hier zu fragen. */
export const vorschauUrl = (path: string) =>
'/t/' + path.split('/').map(encodeURIComponent).join('/')
export const api = {
login: (username: string, password: string) =>
post<{ user: User }>('/api/dash/auth/login', { username, password }),
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
/** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */
authConfig: () => get<{ discord: boolean; marke: string }>('/api/dash/auth/config'),
me: () => get<{ user: User; sitzungen?: number }>('/api/dash/auth/me'),
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
media: (
query: string,
folder: string,
art: Art,
limit: number,
offset: number,
sort: Sortierung = 'geaendert',
dir: Richtung = 'ab',
) =>
get<MediaPage>(
`/api/dash/media?query=${encodeURIComponent(query)}` +
`&folder=${encodeURIComponent(folder)}&art=${art}` +
`&limit=${limit}&offset=${offset}&sort=${sort}&dir=${dir}`,
),
/** Nur die IDs fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern.
* Nimmt DIESELBEN Einschraenkungen wie die Liste. */
medienIds: (query: string, folder: string, art: Art) =>
get<{ ids: number[]; gekappt: boolean }>(
`/api/dash/media/ids?query=${encodeURIComponent(query)}` +
`&folder=${encodeURIComponent(folder)}&art=${art}`,
),
arten: (query: string, folder: string) =>
get<Arten>(
`/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
),
folders: () => get<Folders>('/api/dash/folders'),
verschieben: (ids: number[], ziel: string) =>
post<VerschiebeErgebnis>('/api/dash/media/move', { ids, ziel }),
ordnerAnlegen: (pfad: string) => post<{ ok: true; pfad: string }>('/api/dash/folders', { pfad }),
ordnerEntfernen: (pfad: string) =>
del<{ ok: true }>('/api/dash/folders/' + pfad.split('/').map(encodeURIComponent).join('/')),
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
deleteMediaBulk: (ids: number[]) =>
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
events: (
kind: string,
actor: string,
query: string,
limit: number,
offset: number,
) =>
get<EventsSeite>(
`/api/dash/events?kind=${encodeURIComponent(kind)}` +
`&actor=${encodeURIComponent(actor)}` +
`&query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
),
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
sicherungen: () => get<Sicherung>('/api/dash/sicherungen'),
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) =>
post<{ ok: true; meldungen: { hatWebhook: boolean; anlaesse: string[] } }>(
'/api/dash/settings',
{ meldungen },
),
meldenTesten: () => post<{ ok: true }>('/api/dash/maintenance/melden'),
vorlagenSpeichern: (liste: Vorlage[]) =>
post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }),
einstellungenSpeichern: (discord: Record<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
stand: () => get<Stand>('/api/dash/stand'),
speicher: () => get<Speicher>('/api/dash/speicher'),
papierkorb: () => get<PapierkorbSeite>('/api/dash/papierkorb'),
papierkorbZurueck: (ids: number[]) =>
post<{
zurueck: number
fehlgeschlagen: number
ergebnisse: { path: string; ok: boolean; error?: string }[]
}>('/api/dash/papierkorb/zurueck', { ids }),
papierkorbLeeren: (ids?: number[]) =>
post<{ geleert: number }>('/api/dash/papierkorb/leeren', ids ? { ids } : {}),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
webpNachziehen: () => post<WebpLauf>('/api/dash/maintenance/webp'),
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
'/api/dash/maintenance/orphans',
{ aktion },
),
/** Wozu fehlt ein Bild — und was liegt da, ohne in der Liste zu stehen. */
fehlende: (muster: string, namen: string) =>
post<{
muster: string
geprueft: number
da: number
fehlt: { name: string; pfad: string }[]
ueberzaehlig: string[]
ueberzaehligGesamt: number
unbrauchbar: { name: string; grund: string }[]
}>('/api/dash/fehlende', { muster, namen }),
/** Ohne `ausfuehren` wird nur gerechnet und gezeigt — angefasst wird nichts. */
ersetzen: (suchen: string, ersetzen: string, ordner: string, ausfuehren = false) =>
post<{
vorschau?: true
treffer?: number
plan?: { alt: string; neu: string; abrufe: number }[]
planGesamt?: number
probleme?: { alt: string; grund: string }[]
abrufeGesamt?: number
umbenannt?: number
gescheitert?: { alt: string; grund: string }[]
beispiele?: { alt: string; neu: string }[]
}>('/api/dash/media/ersetzen', { suchen, ersetzen, ordner, ausfuehren }),
einstellungenImport: (werte: Record<string, string>, ueberschreiben: boolean) =>
post<{ gesetzt: string[]; uebersprungen: string[] }>('/api/dash/einstellungen/import', {
werte,
ueberschreiben,
}),
verwaltung: (limit = 100, offset = 0) =>
get<{ items: VerwaltungRow[]; total: number; limit: number; offset: number }>(
`/api/dash/verwaltung?limit=${limit}&offset=${offset}`,
),
sitzungenBeenden: () =>
post<{ beendet: number; offen: number }>('/api/dash/auth/sitzungen/beenden'),
benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'),
benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`),
freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'),
freigabeAnlegen: (ordner: string, titel: string) =>
post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>(
'/api/dash/freigaben',
{ ordner, titel },
),
freigabeWeg: (id: number) => del<{ ok: true }>(`/api/dash/freigaben/${id}`),
tokenAendern: (
id: number,
daten: {
name?: string
prefix?: string
canDelete?: boolean
maxMb?: string | null
quotaMb?: string | null
maxKante?: string | null
arten?: string[] | null
gueltigTage?: string | null
},
) => request<TokenRow & { geaendert: string[] }>(`/api/dash/tokens/${id}`, {
method: 'PATCH',
body: JSON.stringify(daten),
}),
/** Der Dienst holt die Datei selbst. Die Adressprüfung steht im Server
* (holen.ts) hier kommt nur der Satz zurück, wenn sie greift. */
hochladenVonAdresse: (url: string, ordner: string, name?: string) =>
post<{
path: string
url: string
size: number
mime: string
replaced: boolean
von: string
}>('/api/dash/upload-url', { url, ordner, name }),
tokens: () =>
get<{ items: TokenRow[]; ohneToken: { dateien: number; bytes: number } }>(
'/api/dash/tokens',
),
umbenennen: (id: number, name: string) =>
post<{ alt: string; neu: string; url: string; abrufe: number }>(
'/api/dash/media/umbenennen',
{ id, name },
),
ordnerUmbenennen: (pfad: string, name: string) =>
post<{ alt: string; neu: string; dateien: number }>('/api/dash/folders/umbenennen', {
pfad,
name,
}),
createToken: (daten: {
name: string
prefix: string
canDelete: boolean
maxMb?: string
maxKante?: string
quotaMb?: string
arten?: string[]
gueltigTage?: string
}) => post<NewToken>('/api/dash/tokens', daten),
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
}
+97
View File
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type Stand } from './api'
import { useMerker } from './merker'
// Automatisch auffrischen — aber nicht, indem man alle fünf Sekunden alles neu
// lädt.
//
// Gefragt wird ein billiger Fühler (/api/dash/stand: drei Zahlen). Erst wenn
// der sich ändert, gibt es überhaupt etwas zu tun. Das hält den Verkehr klein
// genug, dass ein offener Tab neben einem Serienlauf niemanden stört.
//
// UND ES ZIEHT NICHTS WEG. Wer gerade etwas ausgewählt hat oder ein Bild offen
// hat, bekommt keinen Neuaufbau unter den Händen, sondern einen Hinweis mit
// Knopf. Eine Liste, die sich beim Klicken selbst neu sortiert, ist der Grund,
// warum Leute Auto-Refresh abschalten.
const TAKT_MS = 5000
const gleich = (a: Stand | null, b: Stand | null) =>
a !== null &&
b !== null &&
a.medien === b.medien &&
a.neuest === b.neuest &&
a.ereignis === b.ereignis
export function useAuffrischen({
laden,
blockiert,
}: {
/** Was zu tun ist, wenn sich etwas geändert hat. */
laden: () => void
/** Solange true, wird nicht von selbst nachgeladen es gibt nur den
* Hinweis. Etwa: eine Auswahl steht, oder ein Bild ist offen. */
blockiert: boolean
}) {
const [an, setAn] = useMerker('auffrischen', true)
const [wartet, setWartet] = useState(false)
const bekannt = useRef<Stand | null>(null)
const blockiertRef = useRef(blockiert)
blockiertRef.current = blockiert
/** Nach eigenem Zutun (Löschen, Hochladen) gleichzuziehen sonst meldet
* der Fühler die eigene Änderung als neu". */
const merken = useCallback(async () => {
try {
bekannt.current = await api.stand()
setWartet(false)
} catch {
/* beim nächsten Takt wieder */
}
}, [])
const jetztLaden = useCallback(() => {
setWartet(false)
laden()
void merken()
}, [laden, merken])
useEffect(() => {
if (!an) return
let lebt = true
const takt = async () => {
// Ein Tab im Hintergrund braucht nichts zu wissen. Das spart bei
// einem Bildschirm voller offener Tabs mehr, als es kostet.
if (document.visibilityState === 'hidden') return
try {
const stand = await api.stand()
if (!lebt) return
if (bekannt.current === null) {
bekannt.current = stand
return
}
if (gleich(bekannt.current, stand)) return
if (blockiertRef.current) {
setWartet(true)
} else {
bekannt.current = stand
laden()
}
} catch {
// Ein Aussetzer im Netz ist kein Grund für eine Fehlermeldung
// alle fünf Sekunden. Beim nächsten Takt noch einmal.
}
}
void takt()
const uhr = setInterval(takt, TAKT_MS)
return () => {
lebt = false
clearInterval(uhr)
}
}, [an, laden])
return { an, setAn, wartet, jetztLaden, merken }
}
+323
View File
@@ -0,0 +1,323 @@
import { useEffect, useRef, type ReactNode } from 'react'
import {
Check,
ChevronLeft,
ChevronRight,
Copy,
Download,
ExternalLink,
FileQuestion,
PenLine,
Repeat,
Trash2,
X,
} from 'lucide-react'
import type { MediaItem } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { useMerker } from '../merker'
import { useKopieren } from './Bausteine'
import { Player } from './Player'
import { istLesbar, Textansicht } from './Textansicht'
/** Eine Datei ansehen, ohne die Seite zu verlassen.
*
* Bilder, Videos, Ton, PDF und alles Lesbare (Markdown, Code, CSV, Klartext)
* werden HIER gezeigt; alles andere bekommt einen ehrlichen Hinweis samt
* Herunterladen-Knopf. Ein Betrachter, der bei einer .zip ein leeres
* schwarzes Feld zeigt, sieht aus wie ein kaputter Betrachter.
*
* Gezeigt wird IMMER die Vollfassung und nie die Vorschau: 320 Pixel breit
* reichen für eine Kachel, aber nicht dafür, ein Bild zu beurteilen und
* genau dazu klickt man darauf. */
export function Ansehen({
item,
naechstes,
hatVorher,
hatNachher,
onVorher,
onNachher,
onSchliessen,
onLoeschen,
onUmbenennen,
}: {
item: MediaItem
/** Was danach kaeme gebraucht fuer "weiter zum naechsten Stueck": nur
* wenn das auch etwas Abspielbares ist, wird weitergesprungen. */
naechstes?: MediaItem
hatVorher: boolean
hatNachher: boolean
onVorher: () => void
onNachher: () => void
onSchliessen: () => void
onLoeschen: () => void
/** Fehlt in Ansichten, in denen Umbenennen keinen Sinn ergibt. */
onUmbenennen?: () => void
}) {
const note = useStore((s) => s.note)
const { kopiert, kopieren } = useKopieren()
const [weiter, setWeiter] = useMerker('player.weiter', true)
const art = item.mime.split('/')[0]
const spielt = art === 'video' || art === 'audio'
const element = useRef<HTMLMediaElement | null>(null)
// Tastatur: EINE Stelle fuer alles. Escape zu, Pfeile weiter — wer 900
// Bilder durchsieht, will nicht 900 Mal zielen.
//
// BEI VIDEO UND TON SPRINGEN DIE PFEILE ABER IN DER DATEI, nicht in der
// Liste. Das ist der Griff, den jeder aus jedem Player kennt, und "in der
// Aufnahme fuenf Sekunden zurueck" ist dort haeufiger als "eine Datei
// zurueck". Zum Blaettern kommt die Umschalttaste dazu — und die Pfeile am
// Bildrand tun es ohnehin, die sieht man.
//
// Die Leertaste haelt an und laesst weiterlaufen. Sie steht HIER und nicht
// im Player: sonst rollt der Browser damit die Seite darunter.
useEffect(() => {
const taste = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onSchliessen()
return
}
const el = element.current
if (spielt && el) {
if (e.key === ' ') {
e.preventDefault()
if (el.paused) void el.play()
else el.pause()
return
}
if (!e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
e.preventDefault()
const ziel = el.currentTime + (e.key === 'ArrowLeft' ? -5 : 5)
// Number.isFinite: solange nichts geladen ist, ist duration
// NaN — und eine Zuweisung von NaN an currentTime wirft.
el.currentTime = Number.isFinite(el.duration)
? Math.min(Math.max(0, ziel), el.duration)
: Math.max(0, ziel)
return
}
}
if (e.key === 'ArrowLeft' && hatVorher) onVorher()
else if (e.key === 'ArrowRight' && hatNachher) onNachher()
}
window.addEventListener('keydown', taste)
return () => window.removeEventListener('keydown', taste)
}, [hatVorher, hatNachher, onVorher, onNachher, onSchliessen, spielt])
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm"
onClick={onSchliessen}
>
{/* Kopfzeile */}
<header
className="flex shrink-0 items-center gap-3 border-b border-white/10 px-4 py-2.5"
onClick={(e) => e.stopPropagation()}
>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm text-schrift" title={item.path}>
{item.path}
</p>
<p className="text-xs text-schrift-leise">
{bytes(item.size)} · {item.mime} · {zeit(item.updated_at)}
</p>
</div>
<button
onClick={async () => {
if (!(await kopieren(item.url, 'url')))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="flex items-center gap-1.5 rounded border border-white/15 px-2.5 py-1.5
text-xs text-schrift-leise hover:bg-white/10 hover:text-schrift"
>
{kopiert === 'url' ? (
<>
<Check size={13} className="text-gut" /> kopiert
</>
) : (
<>
<Copy size={13} /> Adresse
</>
)}
</button>
{/* Nur bei Abspielbarem ein Schalter ohne Wirkung ist ein
Schalter, an dem jemand herumdrueckt und sich fragt, warum
nichts passiert. */}
{spielt && (
<button
onClick={() => setWeiter((w) => !w)}
title={
weiter
? 'Nach dem Ende weiter zum nächsten Stück'
: 'Am Ende stehen bleiben'
}
className={`flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-xs ${
weiter
? 'border-akzent/50 bg-akzent/15 text-akzent'
: 'border-white/15 text-schrift-leise hover:bg-white/10 hover:text-schrift'
}`}
>
<Repeat size={13} />
{weiter ? 'weiter' : 'einzeln'}
</button>
)}
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-white/10 hover:text-schrift"
aria-label="In neuem Tab öffnen"
>
<ExternalLink size={13} />
</a>
{onUmbenennen && (
<button
onClick={onUmbenennen}
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-white/10 hover:text-schrift"
aria-label="Umbenennen"
title="Umbenennen"
>
<PenLine size={13} />
</button>
)}
<button
onClick={onLoeschen}
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-schlecht/20 hover:text-schlecht"
aria-label="Löschen"
>
<Trash2 size={13} />
</button>
<button
onClick={onSchliessen}
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-white/10 hover:text-schrift"
aria-label="Schließen (Esc)"
>
<X size={14} />
</button>
</header>
{/* Der Inhalt */}
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-4">
{hatVorher && (
<Pfeil links onClick={onVorher} />
)}
{hatNachher && <Pfeil onClick={onNachher} />}
<div
className="flex min-h-0 max-h-full w-full max-w-full items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
{art === 'image' ? (
<img
src={item.url}
alt={item.path}
className="max-h-[calc(100vh-9rem)] max-w-full object-contain"
/>
) : spielt ? (
<Player
item={item}
elementRef={element}
onEnde={() => {
// Nur weiter, wenn das Naechste auch etwas zum
// Abspielen ist. Sonst landet man nach einem
// Lied ungefragt bei einem Bild.
if (!weiter || !hatNachher) return
const naechsteArt = naechstes?.mime.split('/')[0]
if (naechsteArt === 'video' || naechsteArt === 'audio') {
onNachher()
}
}}
/>
) : item.mime === 'application/pdf' ? (
<iframe
src={item.url}
title={item.path}
className="h-[calc(100vh-9rem)] w-full max-w-4xl rounded border border-white/15 bg-white"
/>
) : istLesbar(item) ? (
<Textansicht item={item} />
) : (
// Der ehrliche Fall: wir können es nicht zeigen, und
// sagen das, statt ein leeres Feld hinzustellen.
<div className="flex flex-col items-center gap-3 rounded-lg border border-white/15 bg-flaeche px-10 py-8 text-center">
<FileQuestion size={32} className="text-schrift-leise" />
<p className="text-sm text-schrift-leise">
Diese Art lässt sich hier nicht anzeigen
<br />
<code className="font-mono text-xs">{item.mime}</code>
</p>
<a
href={item.url}
download
className="flex items-center gap-2 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift hover:bg-flaeche-hoch"
>
<Download size={15} />
Herunterladen
</a>
</div>
)}
</div>
{/* Die Tastenbelegung DAZUSCHREIBEN. Dass die Pfeile bei einem
Video springen statt zu blättern, ist die richtige Wahl
aber sie ist unsichtbar, und eine unsichtbare Sonderregel
fühlt sich an wie ein Fehler. Eine Zeile Text kostet nichts. */}
<p
className="shrink-0 text-center text-[11px] text-schrift-leise/60"
onClick={(e) => e.stopPropagation()}
>
{spielt ? (
<>
<Taste>Leer</Taste> Wiedergabe · <Taste></Taste>
<Taste></Taste> 5 s springen · <Taste>Umschalt</Taste>+
<Taste></Taste>
<Taste></Taste> Datei wechseln · <Taste>Esc</Taste> schließen
</>
) : (
<>
<Taste></Taste>
<Taste></Taste> blättern · <Taste>Esc</Taste> schließen
</>
)}
</p>
</div>
</div>
)
}
const Taste = ({ children }: { children: ReactNode }) => (
<kbd className="mx-0.5 rounded border border-white/15 px-1 py-0.5 font-mono text-[10px]">
{children}
</kbd>
)
function Pfeil({ links, onClick }: { links?: boolean; onClick: () => void }) {
return (
<button
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full border border-white/15
bg-black/50 p-2 text-schrift-leise hover:bg-black/80 hover:text-schrift
${links ? 'left-4' : 'right-4'}`}
aria-label={links ? 'Vorheriges' : 'Nächstes'}
>
{links ? <ChevronLeft size={20} /> : <ChevronRight size={20} />}
</button>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { KeyRound } from 'lucide-react'
/** Ein Bild zu einem Namen oder ein ehrlicher Ersatz.
*
* Drei Fälle, und jeder sieht anders aus:
*
* Bild da das Bild von Discords CDN
* kein Bild die Initialen auf farbigem Grund
* kein Mensch ein Schlüssel, denn ein Token hat kein Gesicht
*
* Der Fehlerfall ist mitgedacht: lädt das Bild nicht (CDN nicht erreichbar,
* Stempel veraltet), fällt es auf die Initialen zurück statt ein kaputtes
* Bildsymbol zu zeigen. */
export function Avatar({
name,
url,
token,
groesse = 24,
}: {
name: string
url?: string | null
token?: boolean
groesse?: number
}) {
const [gefallen, setGefallen] = useState(false)
const stil = { width: groesse, height: groesse }
if (token) {
return (
<span
className="flex shrink-0 items-center justify-center rounded border border-rand bg-flaeche-hoch text-schrift-leise"
style={stil}
title="Token"
>
<KeyRound size={Math.round(groesse * 0.55)} />
</span>
)
}
if (url && !gefallen) {
return (
<img
src={url}
alt={name}
title={name}
loading="lazy"
onError={() => setGefallen(true)}
className="shrink-0 rounded-full object-cover"
style={stil}
/>
)
}
// Die Initialen. Die Farbe ergibt sich aus dem Namen, damit derselbe Name
// immer dieselbe Farbe bekommt — sonst springt sie bei jedem Neuladen.
const initialen = name
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((t) => t[0]?.toUpperCase() ?? '')
.join('')
let summe = 0
for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360
return (
<span
className="flex shrink-0 items-center justify-center rounded-full font-medium text-white"
style={{
...stil,
backgroundColor: `hsl(${summe} 45% 40%)`,
fontSize: Math.round(groesse * 0.42),
}}
title={name}
>
{initialen || '?'}
</span>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
// -- Knopf ---------------------------------------------------------------------
type KnopfArt = 'haupt' | 'still' | 'gefahr'
const arten: Record<KnopfArt, string> = {
haupt: 'bg-akzent text-grund hover:bg-akzent/85 border-akzent',
still: 'bg-flaeche-hoch text-schrift hover:bg-rand border-rand',
gefahr: 'bg-schlecht/15 text-schlecht hover:bg-schlecht/25 border-schlecht/40',
}
/** Ein Knopf, der beim Arbeiten aussieht, als arbeite er.
*
* `wartet` sperrt ihn und zeigt den Kreisel. Ohne das ist der zweite Klick
* einen Wimpernschlag später eine zweite Anfrage bei "Organisation anlegen"
* war genau das der Weg zu neunzehn gleichnamigen Einträgen. */
export function Knopf({
art = 'still',
wartet = false,
kind,
className = '',
disabled,
...rest
}: {
art?: KnopfArt
wartet?: boolean
kind?: ReactNode
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...rest}
disabled={disabled || wartet}
className={`inline-flex items-center justify-center gap-2 rounded-md border px-3 py-1.5
text-sm font-medium transition-all active:scale-[0.97]
disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100
${arten[art]} ${className}`}
>
{wartet && <Loader2 size={15} className="animate-spin" />}
{kind}
</button>
)
}
// -- Eingabefeld ---------------------------------------------------------------
export function Feld({
label,
hinweis,
className = '',
...rest
}: { label?: string; hinweis?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-medium text-schrift-leise">{label}</span>
)}
<input
{...rest}
className={`w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none ${className}`}
/>
{hinweis && <span className="mt-1 block text-xs text-schrift-leise">{hinweis}</span>}
</label>
)
}
// -- Karte ---------------------------------------------------------------------
export function Karte({
titel,
rechts,
children,
}: {
titel?: string
rechts?: ReactNode
children: ReactNode
}) {
return (
<section className="rounded-lg border border-rand bg-flaeche shadow-sm shadow-black/20">
{(titel || rechts) && (
<header className="flex items-center justify-between gap-3 border-b border-rand px-4 py-2.5">
<h2 className="text-sm font-semibold">{titel}</h2>
{rechts}
</header>
)}
<div className="p-4">{children}</div>
</section>
)
}
// -- Nachfrage vor dem Löschen -------------------------------------------------
/** Ein Dialog, der sagt, WAS gelöscht wird nicht "Sind Sie sicher?".
*
* Löschen ist die einzige Stelle hier, die sich nicht rückgängig machen
* lässt: die Datei ist danach von der Platte weg, und es gibt keine
* Sicherung, aus der sie zurückkäme. */
export function Nachfrage({
offen,
titel,
text,
bestaetigung,
wartet,
gesperrt,
onJa,
onNein,
}: {
offen: boolean
titel: string
text: ReactNode
bestaetigung: string
wartet?: boolean
/** Sperrt den Bestaetigungsknopf, solange die Eingabe unvollstaendig ist.
* Gebraucht, seit die Nachfrage auch Felder enthaelt (Umbenennen) ein
* Knopf, der bei leerem Feld gedrueckt werden kann, erzeugt einen Fehler,
* den der Dialog vorher schon kannte. */
gesperrt?: boolean
onJa: () => void
onNein: () => void
}) {
// Escape schließt. Ein Dialog, aus dem man nur mit der Maus herauskommt,
// ist ein Dialog, den man versehentlich bestätigt.
useEffect(() => {
if (!offen) return
const ab = (e: KeyboardEvent) => {
if (e.key === 'Escape') onNein()
}
window.addEventListener('keydown', ab)
return () => window.removeEventListener('keydown', ab)
}, [offen, onNein])
if (!offen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onNein}
>
<div
className="w-full max-w-md rounded-lg border border-rand bg-flaeche shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="border-b border-rand px-4 py-3 text-sm font-semibold">{titel}</h3>
<div className="px-4 py-4 text-sm text-schrift-leise">{text}</div>
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
<Knopf onClick={onNein} kind="Abbrechen" />
<Knopf
art="gefahr"
wartet={wartet}
disabled={gesperrt}
onClick={onJa}
kind={bestaetigung}
/>
</div>
</div>
</div>
)
}
// -- Leerzustand ---------------------------------------------------------------
export function Leer({ text, kind }: { text: string; kind?: ReactNode }) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-schrift-leise">
{kind}
<p>{text}</p>
</div>
)
}
// -- In die Zwischenablage -----------------------------------------------------
/** Kopieren MIT Rückmeldung. Ein Kopierknopf ohne sichtbare Antwort ist der
* Urtyp des kaputt aussehenden Knopfes: man klickt zweimal, weil man nicht
* weiß, ob der erste Klick etwas getan hat. */
export function useKopieren() {
const [kopiert, setKopiert] = useState<string | null>(null)
useEffect(() => {
if (kopiert === null) return
const t = setTimeout(() => setKopiert(null), 1500)
return () => clearTimeout(t)
}, [kopiert])
async function kopieren(text: string, marke: string) {
try {
await navigator.clipboard.writeText(text)
setKopiert(marke)
return true
} catch {
// navigator.clipboard gibt es nur im sicheren Kontext. Über https
// ist das gegeben; wer den Dienst über eine nackte LAN-Adresse
// aufruft, bekommt hier ein ehrliches Nein statt eines Knopfes,
// der scheinbar nichts tut.
return false
}
}
return { kopiert, kopieren }
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { KeyRound, Trash2, UserRound } from 'lucide-react'
import { api, type BenutzerRow } from '../api'
import { run, useStore } from '../store'
import { zeit } from '../format'
import { Avatar } from './Avatar'
import { Karte, Nachfrage } from './Bausteine'
/** Wer ein Konto hat.
*
* WOZU ÜBERHAUPT. Es gab zwei Konten und keine Stelle, an der man sie sehen
* konnte. Und weil jeder mit der Rolle auf dem Discord-Server beim ersten
* Anmelden **still** ein Konto bekommt, wären aus zwei unbemerkt zwanzig
* geworden man erfährt es nicht, und wegnehmen ging auch nicht.
*
* Die Spalte Weg" ist dabei die wichtigere Auskunft als der Name: ein
* Discord-Konto hängt an einer Rolle, die jemand anders vergibt. Wer die Rolle
* verliert, behält trotzdem sein Konto hier geprüft wird die Rolle nur beim
* Anmelden. */
export function BenutzerKarte() {
const note = useStore((s) => s.note)
const [items, setItems] = useState<BenutzerRow[] | null>(null)
const [ich, setIch] = useState(0)
const [weg, setWeg] = useState<BenutzerRow | null>(null)
async function laden() {
try {
const r = await api.benutzer()
setItems(r.items)
setIch(r.ich)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!items) return null
return (
<Karte titel="Konten">
<p className="mb-3 text-xs text-schrift-leise">
Wer sich hier anmelden kann. Ein Konto über Discord entsteht{' '}
<strong className="text-schrift">von selbst</strong>, sobald sich jemand mit der
passenden Rolle zum ersten Mal anmeldet deshalb steht diese Liste hier.
Entfernen wirft auch die offenen Sitzungen weg, der Zugang ist also sofort zu.
</p>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Weg</th>
<th className="pb-2 font-medium">Angelegt</th>
<th className="pb-2 font-medium">Zuletzt da</th>
<th className="pb-2 text-right font-medium">Sitzungen</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id} className="border-t border-rand">
<td className="py-2">
<span className="flex items-center gap-2">
<Avatar url={u.avatar} name={u.username} groesse={22} />
<span className="font-medium">{u.username}</span>
{u.id === ich && (
<span className="rounded bg-akzent/15 px-1.5 py-0.5 text-[11px] text-akzent">
das bist du
</span>
)}
</span>
</td>
<td className="py-2">
{u.weg === 'discord' ? (
<span className="flex items-center gap-1 text-xs text-schrift-leise">
<UserRound size={12} /> Discord
</span>
) : (
<span className="flex items-center gap-1 text-xs text-schrift-leise">
<KeyRound size={12} /> Passwort
</span>
)}
</td>
<td className="py-2 text-schrift-leise">{zeit(u.created_at)}</td>
<td className="py-2 text-schrift-leise">
{u.zuletzt_da ? (
zeit(u.zuletzt_da)
) : (
// Nicht „nie": gezählt wird erst, seit es
// die Spalte gibt. Eine Null zu behaupten,
// wo nichts gemessen wurde, wäre gelogen.
<span title="Gezählt wird erst seit dem Einbau dieser Spalte.">
</span>
)}
</td>
<td className="py-2 text-right tabular-nums text-schrift-leise">
{u.sitzungen}
</td>
<td className="py-2 text-right">
<button
onClick={() => setWeg(u)}
disabled={u.id === ich || items.length <= 1}
title={
u.id === ich
? 'Das eigene Konto lässt sich hier nicht entfernen'
: items.length <= 1
? 'Das letzte Konto bleibt'
: 'Konto entfernen'
}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht
disabled:cursor-not-allowed disabled:opacity-30
disabled:hover:bg-transparent disabled:hover:text-schrift-leise"
aria-label={`${u.username} entfernen`}
>
<Trash2 size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Nachfrage
offen={weg !== null}
titel="Konto entfernen?"
bestaetigung="Entfernen"
onNein={() => setWeg(null)}
onJa={async () => {
const u = weg
setWeg(null)
if (!u) return
await run(() => api.benutzerWeg(u.id), (r) => `${r.username} entfernt.`)
void laden()
}}
text={
<p>
<code className="font-mono">{weg?.username}</code> kommt danach nicht mehr
herein, und die {weg?.sitzungen ?? 0} offenen Sitzungen enden sofort.
{weg?.weg === 'discord' && (
<>
{' '}
<strong className="text-schrift">Aber:</strong> hat diese Person
weiterhin die Rolle auf deinem Discord-Server, legt die nächste
Anmeldung ein neues Konto an. Wer dauerhaft draußen bleiben soll,
verliert die Rolle.
</>
)}
</p>
}
/>
</Karte>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { Check, Copy } from 'lucide-react'
import { useKopieren } from './Bausteine'
import { useStore } from '../store'
/** Code mit Farbe ohne Bibliothek.
*
* Ein Hervorheber wie highlight.js wiegt mehr als diese ganze Oberfläche und
* kann fünfzig Sprachen, von denen hier drei vorkommen. Was man wirklich
* braucht, um Code lesbar zu machen, sind drei Unterscheidungen: Kommentar,
* Zeichenkette, Schlüsselwort. Der Rest ist Zierde.
*
* Deshalb ein Tokenizer in einem Ausdruck. Er ist bewusst einfach und kennt
* seine Grenzen verschachtelte Sonderfälle färbt er falsch. Bei einem
* Beispielblock von zehn Zeilen ist das der richtige Tausch. */
export type Sprache = 'lua' | 'bash' | 'json'
const MUSTER: Record<Sprache, RegExp> = {
// Reihenfolge zählt: Kommentare zuerst, sonst frisst eine Zeichenkette
// das `--` einer Kommentarzeile.
lua: /(--[^\n]*)|('[^'\n]*'|"[^"\n]*")|(\b(?:local|function|end|if|then|else|elseif|return|for|in|do|while|nil|true|false|and|or|not)\b)|(\b\d+\b)/g,
bash: /(#[^\n]*)|('[^'\n]*'|"[^"\n]*")|(\b(?:curl|echo|export|cd|if|then|fi|for|do|done)\b)|(-{1,2}[A-Za-z][\w-]*)/g,
json: /()("(?:[^"\\]|\\.)*")|(\b(?:true|false|null)\b)|(-?\b\d+(?:\.\d+)?\b)/g,
}
const FARBEN = ['text-schrift-leise/70 italic', 'text-gut', 'text-akzent', 'text-warn']
/** Wird auch von der Textansicht benutzt: EIN Tokenizer fuer den Schnipsel auf
* der API-Seite und fuer die geoeffnete .lua im Betrachter. Zwei waeren zwei,
* die auseinanderlaufen. */
export function faerben(text: string, sprache: Sprache) {
const muster = new RegExp(MUSTER[sprache].source, 'g')
const stuecke: React.ReactNode[] = []
let letzte = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(text)) !== null) {
// Ein leerer Treffer wuerde die Schleife anhalten — bei json steht
// eine leere Gruppe fuer "keine Kommentare".
if (treffer[0] === '') {
muster.lastIndex++
continue
}
if (treffer.index > letzte) stuecke.push(text.slice(letzte, treffer.index))
const gruppe = [1, 2, 3, 4].find((i) => treffer![i] !== undefined)
stuecke.push(
<span key={treffer.index} className={FARBEN[(gruppe ?? 1) - 1]}>
{treffer[0]}
</span>,
)
letzte = treffer.index + treffer[0].length
}
if (letzte < text.length) stuecke.push(text.slice(letzte))
return stuecke
}
export function Code({
text,
sprache = 'bash',
marke,
}: {
text: string
sprache?: Sprache
marke: string
}) {
const note = useStore((s) => s.note)
const { kopiert, kopieren } = useKopieren()
return (
<div className="group relative">
<pre className="overflow-x-auto rounded-md border border-rand bg-grund p-3 pr-12 font-mono text-xs leading-relaxed">
<code>{faerben(text, sprache)}</code>
</pre>
<button
onClick={async () => {
if (!(await kopieren(text, marke)))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="absolute top-2 right-2 rounded border border-rand bg-flaeche px-2 py-1
text-schrift-leise opacity-0 transition-opacity group-hover:opacity-100
focus-visible:opacity-100 hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Kopieren"
>
{kopiert === marke ? <Check size={13} className="text-gut" /> : <Copy size={13} />}
</button>
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react'
import { Check, Copy, ExternalLink, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
import { api, type Einstellungen } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf, useKopieren } from '../components/Bausteine'
/** Die Discord-Anmeldung einrichten im Panel statt in der .env.
*
* Der eigentliche Gewinn steht ganz oben in der Karte: die Redirect-URI zum
* Kopieren. Sie muss ZEICHEN FÜR ZEICHEN mit dem übereinstimmen, was im
* Discord-Portal steht, und daran scheitert der erste Anlauf fast immer. */
export function DiscordKarte() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Einstellungen | null>(null)
const [laedt, setLaedt] = useState(true)
const [speichert, setSpeichert] = useState(false)
const { kopiert, kopieren } = useKopieren()
const [clientId, setClientId] = useState('')
const [geheimnis, setGeheimnis] = useState('')
const [guildId, setGuildId] = useState('')
const [rollen, setRollen] = useState('')
async function laden() {
setLaedt(true)
try {
const e = await api.einstellungen()
setDaten(e)
setClientId(e.discord.clientId)
setGuildId(e.discord.guildId)
setRollen(e.discord.roleIds.join('\n'))
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function speichern() {
setSpeichert(true)
const ergebnis = await run(
() =>
api.einstellungenSpeichern({
clientId,
// Leer heißt „nicht anfassen" — sonst würde jedes Speichern
// das Geheimnis wegwerfen, weil es nie im Formular steht.
...(geheimnis ? { clientSecret: geheimnis } : {}),
guildId,
roleIds: rollen,
}),
(r) =>
r.bereit
? 'Gespeichert. Die Discord-Anmeldung ist eingerichtet.'
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
)
setSpeichert(false)
if (ergebnis) {
setGeheimnis('')
void laden()
}
}
if (laedt) {
return (
<Karte titel="Anmeldung über Discord">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
if (!daten) return null
const d = daten.discord
return (
<Karte
titel="Anmeldung über Discord"
rechts={
d.bereit ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
<ShieldCheck size={14} /> eingerichtet ·{' '}
{d.roleIds.length === 1 ? '1 Rolle' : `${d.roleIds.length} Rollen`}
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
<ShieldX size={14} /> nicht eingerichtet
</span>
)
}
>
<div className="flex flex-col gap-4">
<div>
<p className="mb-1 text-xs font-medium text-schrift-leise">
Diese Adresse muss im Discord-Portal unter OAuth2 Redirects stehen
Zeichen für Zeichen:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{d.redirectUri}
</code>
<button
onClick={async () => {
if (!(await kopieren(d.redirectUri, 'uri')))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Adresse kopieren"
>
{kopiert === 'uri' ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noreferrer"
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Discord-Portal öffnen"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Client-ID"
value={clientId}
placeholder="17 bis 20 Ziffern"
hinweis={d.ausDerUmgebung.clientId ? 'kommt derzeit aus der Umgebung' : undefined}
onChange={(e) => setClientId(e.target.value)}
/>
<Feld
label="Client-Geheimnis"
type="password"
value={geheimnis}
placeholder={d.hatGeheimnis ? '•••••••• (hinterlegt)' : 'noch keines'}
hinweis={
d.ausDerUmgebung.clientSecret
? 'kommt derzeit aus der Umgebung'
: 'leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
}
onChange={(e) => setGeheimnis(e.target.value)}
/>
<Feld
label="Server-ID"
value={guildId}
placeholder="Rechtsklick auf den Server → ID kopieren"
hinweis={d.ausDerUmgebung.guildId ? 'kommt derzeit aus der Umgebung' : undefined}
onChange={(e) => setGuildId(e.target.value)}
/>
<label className="block sm:col-span-2">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Rollen-IDs eine je Zeile
</span>
<textarea
value={rollen}
onChange={(e) => setRollen(e.target.value)}
rows={3}
spellCheck={false}
placeholder={'143902xxxxxxxxxxxxx\n143903xxxxxxxxxxxxx'}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 font-mono
text-sm text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
<span className="mt-1 block text-xs text-schrift-leise">
{d.ausDerUmgebung.roleIds
? 'kommt derzeit aus der Umgebung'
: 'Wer EINE davon hat, darf herein. Komma, Semikolon oder Zeilenumbruch trennen — kopier sie einfach hinein.'}
</span>
</label>
</div>
<p className="text-xs text-schrift-leise">
Die IDs bekommst du in Discord über Einstellungen Erweitert
Entwicklermodus, danach Rechtsklick ID kopieren. Es wird nur{' '}
<code className="font-mono">identify</code> und{' '}
<code className="font-mono">guilds.members.read</code> erfragt kein Bot,
keine Nachrichten, nichts weiter. Das Passwort bleibt als Notausgang
bestehen.
</p>
<div>
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
</div>
</div>
</Karte>
)
}
+232
View File
@@ -0,0 +1,232 @@
import { useState } from 'react'
import { AlertTriangle, Eye, Replace } from 'lucide-react'
import { api, type Folders } from '../api'
import { run } from '../store'
import { Karte, Knopf, Nachfrage } from './Bausteine'
type Vorschau = Awaited<ReturnType<typeof api.ersetzen>>
/** Suchen und Ersetzen über viele Pfade.
*
* DIE VORSCHAU IST DER EIGENTLICHE TEIL, nicht die Bequemlichkeit dahinter.
* Was hier passiert, ist dieselbe Warnung wie beim Umbenennen eines Ordners
* **jede alte Adresse gibt danach 404** , nur mal tausend. Wer das ohne
* Gegenlesen auslöst, hat keine Gelegenheit mehr, es zu bemerken.
*
* Deshalb gibt es hier keinen Knopf, der es sofort tut. Erst zeigen, dann
* fragen, dann machen. */
export function ErsetzenKarte({
ordnerDaten,
fertig,
}: {
ordnerDaten: Folders | null
fertig: () => void
}) {
const [suchen, setSuchen] = useState('')
const [ersetzen, setErsetzen] = useState('')
const [ordner, setOrdner] = useState('')
const [vorschau, setVorschau] = useState<Vorschau | null>(null)
const [laeuft, setLaeuft] = useState(false)
const [fragt, setFragt] = useState(false)
const bereit = suchen.trim().length > 0
const machbar =
vorschau?.vorschau === true &&
(vorschau.probleme?.length ?? 0) === 0 &&
(vorschau.planGesamt ?? 0) > 0
return (
<Karte titel="Suchen und Ersetzen in Pfaden">
<p className="mb-3 text-xs text-schrift-leise">
Für den Fall, dass sich eine Namenskonvention ändert oder ein Ordner anders heißen
soll. <strong className="text-schrift">Erst wird gezeigt, was passieren würde</strong>{' '}
angefasst wird nichts, bevor du es gelesen hast.
</p>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Suchen
</span>
<input
value={suchen}
onChange={(e) => {
setSuchen(e.target.value)
setVorschau(null)
}}
placeholder="weapon_"
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-sm text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Ersetzen durch
</span>
<input
value={ersetzen}
onChange={(e) => {
setErsetzen(e.target.value)
setVorschau(null)
}}
placeholder="leer = entfernen"
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-sm text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Nur im Ordner
</span>
<select
value={ordner}
onChange={(e) => {
setOrdner(e.target.value)
setVorschau(null)
}}
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
>
<option value="">(überall)</option>
{(ordnerDaten?.items ?? []).map((o) => (
<option key={o.folder} value={o.folder}>
{' '.repeat(o.tiefe * 2)}
{o.folder.split('/').pop()}
</option>
))}
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Knopf
wartet={laeuft && !fragt}
disabled={!bereit}
onClick={async () => {
setLaeuft(true)
const r = await run(
() => api.ersetzen(suchen, ersetzen, ordner, false),
(x) =>
(x.probleme?.length ?? 0) > 0
? `${x.planGesamt} möglich, ${x.probleme?.length} nicht — siehe unten.`
: `${x.planGesamt} Datei(en) würden umbenannt.`,
)
setLaeuft(false)
setVorschau(r)
}}
kind={
<>
<Eye size={15} />
Zeigen, was passieren würde
</>
}
/>
{machbar && (
<Knopf
art="gefahr"
onClick={() => setFragt(true)}
kind={
<>
<Replace size={15} />
{vorschau?.planGesamt} umbenennen
</>
}
/>
)}
</div>
{vorschau?.vorschau && (
<div className="mt-4 flex flex-col gap-3 border-t border-rand pt-3">
<p className="text-sm">
<strong>{vorschau.treffer}</strong> Treffer ·{' '}
<strong className="text-akzent">{vorschau.planGesamt}</strong> umbenennbar ·{' '}
<strong className={vorschau.probleme?.length ? 'text-schlecht' : ''}>
{vorschau.probleme?.length ?? 0}
</strong>{' '}
Probleme
</p>
{(vorschau.abrufeGesamt ?? 0) > 0 && (
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
Die betroffenen Dateien wurden zusammen{' '}
<strong>{vorschau.abrufeGesamt}×</strong> geholt. Jede alte Adresse
gibt danach 404 und keine davon meldet sich von selbst.
</span>
</p>
)}
{(vorschau.probleme?.length ?? 0) > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-schlecht">
Das geht nicht und deshalb wird gar nichts angefasst
</p>
<pre className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px]">
{vorschau.probleme?.map((p) => `${p.alt}\n → ${p.grund}`).join('\n')}
</pre>
</div>
)}
{(vorschau.plan?.length ?? 0) > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-schrift-leise">
So sähe es aus
{(vorschau.planGesamt ?? 0) > (vorschau.plan?.length ?? 0) &&
` — erste ${vorschau.plan?.length} von ${vorschau.planGesamt}`}
</p>
<pre className="max-h-56 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px] leading-relaxed">
{vorschau.plan?.map((p) => `${p.alt}\n → ${p.neu}`).join('\n')}
</pre>
</div>
)}
</div>
)}
<Nachfrage
offen={fragt}
titel={`${vorschau?.planGesamt} Dateien umbenennen?`}
bestaetigung="Umbenennen"
wartet={laeuft}
onNein={() => setFragt(false)}
onJa={async () => {
setLaeuft(true)
const r = await run(
() => api.ersetzen(suchen, ersetzen, ordner, true),
(x) =>
`${x.umbenannt} umbenannt` +
((x.gescheitert?.length ?? 0) > 0
? `, ${x.gescheitert?.length} nicht`
: '.'),
)
setLaeuft(false)
setFragt(false)
if (r) {
setVorschau(null)
setSuchen('')
setErsetzen('')
fertig()
}
}}
text={
<p className="flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
<span>
<code className="font-mono">{suchen}</code> wird zu{' '}
<code className="font-mono">{ersetzen || '(nichts)'}</code>, in{' '}
{vorschau?.planGesamt} Datei(en).{' '}
<strong className="text-schrift">
Alle bisherigen Adressen dieser Dateien geben danach 404.
</strong>{' '}
Rückgängig geht das nur, indem du es andersherum noch einmal machst
und das findet die Dateien nur, solange niemand sonst etwas geändert
hat.
</span>
</p>
}
/>
</Karte>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react'
import { Check, ListChecks } from 'lucide-react'
import { api, type Vorlage } from '../api'
import { run } from '../store'
import { Karte, Knopf } from './Bausteine'
/** Wozu fehlt ein Bild?
*
* DIE FRAGE, DIE BISHER NIEMAND BEANTWORTET HAT. Das Fotostudio weiß, was es
* aufgenommen hat. Dieser Dienst weiß, was liegt. Die Differenz rechnet
* keiner aus und deshalb fällt das Handy zeigt bei manchen Autos kein Bild"
* erst im Spiel auf.
*
* Das Muster kommt aus den **Adressvorlagen**, damit hier nicht dieselbe
* Zeichenkette ein zweites Mal getippt wird. Wer keine angelegt hat, tippt
* sie aber der Vorschlag steht daneben. */
export function FehlendeKarte() {
const [vorlagen, setVorlagen] = useState<Vorlage[]>([])
const [muster, setMuster] = useState('vehicles/{model}.webp')
const [namen, setNamen] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [ergebnis, setErgebnis] = useState<Awaited<
ReturnType<typeof api.fehlende>
> | null>(null)
useEffect(() => {
api.einstellungen()
.then((e) => {
setVorlagen(e.vorlagen)
if (e.vorlagen[0]) setMuster(e.vorlagen[0].muster)
})
.catch(() => {
/* dann eben ohne Vorschläge — das Feld ist trotzdem benutzbar */
})
}, [])
const anzahl = namen.split(/[\r\n,;]+/).filter((n) => n.trim()).length
return (
<Karte titel="Wozu fehlt ein Bild?">
<p className="mb-3 text-xs text-schrift-leise">
Eine Liste von Namen hineingeben Fahrzeugmodelle, Item-Namen und beantwortet
bekommen, wozu <strong className="text-schrift">kein Bild</strong> daliegt. Und
umgekehrt: was daliegt, ohne in der Liste zu stehen.
</p>
<div className="flex flex-col gap-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Muster genau ein Platzhalter
</span>
<input
value={muster}
onChange={(e) => setMuster(e.target.value)}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-sm text-schrift focus:border-akzent focus:outline-none"
/>
{vorlagen.length > 0 && (
<span className="mt-1 flex flex-wrap gap-1">
{vorlagen.map((v) => (
<button
key={v.muster}
onClick={() => setMuster(v.muster)}
className="rounded border border-rand px-1.5 py-0.5 font-mono
text-[11px] text-schrift-leise hover:bg-flaeche-hoch
hover:text-schrift"
>
{v.name}
</button>
))}
</span>
)}
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Namen einer je Zeile {anzahl > 0 && `(${anzahl})`}
</span>
<textarea
value={namen}
onChange={(e) => setNamen(e.target.value)}
rows={5}
placeholder={'adder\nzentorno\nkuruma'}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-xs text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<Knopf
art="haupt"
wartet={laeuft}
disabled={!muster.trim() || anzahl === 0}
className="self-start"
onClick={async () => {
setLaeuft(true)
const r = await run(
() => api.fehlende(muster.trim(), namen),
(x) =>
x.fehlt.length === 0
? `Alle ${x.geprueft} sind da.`
: `${x.fehlt.length} von ${x.geprueft} fehlen.`,
)
setLaeuft(false)
if (r) setErgebnis(r)
}}
kind={
<>
<ListChecks size={15} />
Nachsehen
</>
}
/>
</div>
{ergebnis && (
<div className="mt-4 flex flex-col gap-3 border-t border-rand pt-3">
<p className="text-sm">
<strong className="text-gut">{ergebnis.da}</strong> da ·{' '}
<strong className={ergebnis.fehlt.length ? 'text-schlecht' : ''}>
{ergebnis.fehlt.length}
</strong>{' '}
fehlen · <strong>{ergebnis.ueberzaehligGesamt}</strong> überzählig
</p>
{ergebnis.fehlt.length > 0 && (
<Liste
titel="Fehlt — dafür gibt es kein Bild"
farbe="text-schlecht"
zeilen={ergebnis.fehlt.map((f) => `${f.name}${f.pfad}`)}
/>
)}
{ergebnis.ueberzaehligGesamt > 0 && (
<Liste
titel={`Liegt da, steht aber nicht in der Liste${
ergebnis.ueberzaehligGesamt > ergebnis.ueberzaehlig.length
? ` — erste ${ergebnis.ueberzaehlig.length} von ${ergebnis.ueberzaehligGesamt}`
: ''
}`}
farbe="text-warn"
zeilen={ergebnis.ueberzaehlig}
/>
)}
{ergebnis.unbrauchbar.length > 0 && (
<Liste
titel="Als Pfad nicht zulässig"
farbe="text-schrift-leise"
zeilen={ergebnis.unbrauchbar.map((u: { name: string; grund: string }) => `${u.name}${u.grund}`)}
/>
)}
{ergebnis.fehlt.length === 0 && ergebnis.ueberzaehligGesamt === 0 && (
<p className="flex items-center gap-2 text-sm text-gut">
<Check size={15} /> Liste und Bestand stimmen überein.
</p>
)}
</div>
)}
</Karte>
)
}
function Liste({ titel, farbe, zeilen }: { titel: string; farbe: string; zeilen: string[] }) {
return (
<div>
<p className={`mb-1 text-xs font-medium ${farbe}`}>{titel}</p>
<pre className="max-h-48 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px] leading-relaxed">
{zeilen.join('\n')}
</pre>
</div>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react'
import { Check, Copy, ExternalLink, Link2, Share2, Trash2 } from 'lucide-react'
import { api, type Folders, type FreigabeRow } from '../api'
import { run, useStore } from '../store'
import { zeit } from '../format'
import { Karte, Knopf, Nachfrage, useKopieren } from './Bausteine'
/** Freigaben: ein Ordner zum Ansehen, ohne Anmeldung.
*
* WAS HIER WIRKLICH FREIGEGEBEN WIRD, steht groß dabei sonst entsteht ein
* gefährliches Missverständnis in beide Richtungen: die DATEIEN sind ohnehin
* öffentlich (wer die Adresse eines Bildes kennt, bekommt es), neu ist nur die
* LISTE. Und umgekehrt: einen Link zurückzuziehen macht die Bilder nicht
* wieder privat.
*
* Wer das nicht weiß, zieht einen Link zurück und glaubt, damit sei etwas
* verschwunden. */
export function FreigabenKarte() {
const note = useStore((s) => s.note)
const [items, setItems] = useState<FreigabeRow[] | null>(null)
const [ordner, setOrdner] = useState<Folders | null>(null)
const [wahl, setWahl] = useState('')
const [titel, setTitel] = useState('')
const [legtAn, setLegtAn] = useState(false)
const [weg, setWeg] = useState<FreigabeRow | null>(null)
const { kopiert, kopieren } = useKopieren()
async function laden() {
try {
const [f, o] = await Promise.all([api.freigaben(), api.folders()])
setItems(f.items)
setOrdner(o)
if (!wahl && o.items[0]) setWahl(o.items[0].folder)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}
useEffect(() => {
void laden()
// Einmal beim Öffnen. Freigaben ändern sich nicht von selbst.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!items) return null
return (
<Karte titel="Freigaben">
<p className="mb-3 text-xs text-schrift-leise">
Ein Link auf einen Ordner, den jeder ohne Anmeldung ansehen kann etwa ein
Fahrzeugkatalog für Spieler.{' '}
<strong className="text-schrift">
Freigegeben wird die Liste, nicht der Inhalt:
</strong>{' '}
die Dateien selbst sind ohnehin öffentlich, wer eine Adresse kennt, bekommt das
Bild. Ein zurückgezogener Link macht sie deshalb nicht wieder privat er nimmt
nur die Übersicht weg.
</p>
<div className="mb-4 flex flex-wrap items-end gap-2">
<label className="min-w-40 flex-1">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Ordner
</span>
<select
value={wahl}
onChange={(e) => setWahl(e.target.value)}
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
>
{(ordner?.items ?? []).map((o) => (
<option key={o.folder} value={o.folder}>
{' '.repeat(o.tiefe * 2)}
{o.folder.split('/').pop()} ({o.files})
</option>
))}
</select>
</label>
<label className="min-w-40 flex-1">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Überschrift (freiwillig)
</span>
<input
value={titel}
onChange={(e) => setTitel(e.target.value)}
placeholder="Fahrzeugkatalog"
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5 text-sm
text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<Knopf
art="haupt"
wartet={legtAn}
disabled={!wahl}
onClick={async () => {
setLegtAn(true)
const r = await run(
() => api.freigabeAnlegen(wahl, titel),
(x) => `Link für ${x.ordner} steht (${x.dateien} Dateien).`,
)
setLegtAn(false)
if (r) {
setTitel('')
void laden()
}
}}
kind={
<>
<Share2 size={15} />
Link erzeugen
</>
}
/>
</div>
{items.length === 0 ? (
<p className="text-sm text-schrift-leise">Noch keine Freigabe.</p>
) : (
<ul className="flex flex-col gap-2">
{items.map((f) => (
<li
key={f.id}
className="flex flex-wrap items-center gap-2 rounded-md border border-rand
bg-grund px-3 py-2"
>
<Link2 size={14} className="shrink-0 text-schrift-leise" />
<span className="w-40 shrink-0 truncate text-sm" title={f.ordner}>
{f.titel || f.ordner}
</span>
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
{f.url}
</code>
{/* Wird der Link noch benutzt? Genau die Frage, die
man sich stellt, bevor man ihn wegwirft. */}
<span
className="shrink-0 text-[11px] text-schrift-leise tabular-nums"
title={f.zuletzt ? `zuletzt ${zeit(f.zuletzt)}` : 'nie geöffnet'}
>
{f.aufrufe}× geöffnet
</span>
<button
onClick={async () => {
if (!(await kopieren(f.url, String(f.id))))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Link kopieren"
>
{kopiert === String(f.id) ? (
<Check size={13} className="text-gut" />
) : (
<Copy size={13} />
)}
</button>
<a
href={f.url}
target="_blank"
rel="noreferrer"
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Öffnen"
>
<ExternalLink size={13} />
</a>
<button
onClick={() => setWeg(f)}
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht"
aria-label="Zurückziehen"
>
<Trash2 size={13} />
</button>
</li>
))}
</ul>
)}
<Nachfrage
offen={weg !== null}
titel="Link zurückziehen?"
bestaetigung="Zurückziehen"
onNein={() => setWeg(null)}
onJa={async () => {
const f = weg
setWeg(null)
if (!f) return
await run(() => api.freigabeWeg(f.id), 'Link zurückgezogen.')
void laden()
}}
text={
<p>
Der Link auf <code className="font-mono">{weg?.ordner}</code> führt danach
ins Leere.{' '}
<strong className="text-schrift">
Die Dateien bleiben öffentlich erreichbar
</strong>{' '}
wer eine ihrer Adressen kennt oder gespeichert hat, kommt weiterhin
heran. Weg ist nur die Übersicht.
</p>
}
/>
</Karte>
)
}
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useRef, useState } from 'react'
import { CheckCircle2, FolderPlus, Upload, X, XCircle } from 'lucide-react'
import { api, type Folders, type UploadErgebnis } from '../api'
import { run, useStore } from '../store'
import { bytes } from '../format'
import { Knopf } from './Bausteine'
import { VonAdresse } from './VonAdresse'
/** Hochladen aus dem Dashboard.
*
* Bilder, Videos, Ton, PDF was der Dienst kennt, steht in der MIME-Tabelle
* in storage.ts. Alles andere landet als application/octet-stream und wird
* heruntergeladen statt angezeigt; das ist kein Fehler, sondern die sichere
* Voreinstellung.
*
* JEDE Datei bekommt ihre eigene Zeile mit Ergebnis. "3 von 5 hochgeladen"
* ohne die Namen der zwei anderen ist genau die Sorte Rückmeldung, wegen der
* wir hier neu bauen.
*
* DER ZIELORDNER WIRD HIER GEWÄHLT und nicht nur nebenan angezeigt. Vorher
* galt stillschweigend der in der Galerie angeklickte Ordner das ist
* richtig, solange man es weiß, und ein Fallstrick, sobald man es nicht weiß:
* wer nach einer Suche hochlädt, steht in Alle" und legt an der Wurzel ab.
* Der angeklickte Ordner ist deshalb weiterhin die VOREINSTELLUNG, aber eben
* eine sichtbare und änderbare. */
export function Hochladen({
ordner,
ordnerDaten,
fertig,
}: {
ordner: string
ordnerDaten: Folders | null
fertig: () => void
}) {
const note = useStore((s) => s.note)
const [ziel, setZiel] = useState(ordner)
const [neu, setNeu] = useState<string | null>(null)
const [ueber, setUeber] = useState(false)
const [laeuft, setLaeuft] = useState(false)
const [fortschritt, setFortschritt] = useState(0)
const [ergebnisse, setErgebnisse] = useState<UploadErgebnis[] | null>(null)
const feld = useRef<HTMLInputElement>(null)
// Wechselt der Ordner in der Galerie, wechselt das Ziel mit. Sonst lädt
// jemand nach einem Ordnerwechsel weiter in den vorherigen.
useEffect(() => setZiel(ordner), [ordner])
async function ordnerAnlegen() {
const name = (neu ?? '').trim()
if (!name) return
const pfad = ziel ? `${ziel}/${name}` : name
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
if (r) {
setNeu(null)
setZiel(r.pfad)
// Damit der neue Ordner auch in der Liste steht -- sonst zeigt die
// Auswahl gleich darauf einen Namen, den sie nicht kennt.
fertig()
}
}
async function schicken(dateien: FileList | File[]) {
const liste = [...dateien]
if (liste.length === 0) return
setLaeuft(true)
setFortschritt(0)
setErgebnisse(null)
const form = new FormData()
for (const d of liste) form.append('file', d)
if (ziel) form.append('folder', ziel)
try {
// XMLHttpRequest und nicht fetch: nur der kennt upload.onprogress.
// Bei einem 40-MB-Video ist ein Balken der Unterschied zwischen
// "es tut sich was" und "ist es abgestürzt?".
const antwort = await new Promise<{ status: number; text: string }>(
(auf, ab) => {
const x = new XMLHttpRequest()
x.open('POST', '/api/dash/upload')
x.withCredentials = true
x.upload.onprogress = (e) => {
if (e.lengthComputable) setFortschritt(e.loaded / e.total)
}
x.onload = () => auf({ status: x.status, text: x.responseText })
x.onerror = () => ab(new Error('Die Verbindung brach ab'))
x.send(form)
},
)
const daten = JSON.parse(antwort.text)
if (antwort.status !== 200) {
throw new Error(daten?.error ?? `HTTP ${antwort.status}`)
}
setErgebnisse(daten.ergebnisse)
if (daten.fehlgeschlagen === 0) {
note('ok', `${daten.hochgeladen} hochgeladen.`)
} else {
note(
'fehler',
`${daten.hochgeladen} hochgeladen, ${daten.fehlgeschlagen} nicht — siehe Liste.`,
)
}
if (daten.hochgeladen > 0) fertig()
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLaeuft(false)
if (feld.current) feld.current.value = ''
}
}
return (
<div className="flex flex-col gap-2">
{/* Die Ordnerwahl STEHT ÜBER dem Ablegefeld und nicht darunter:
sie ist die Entscheidung, die man VOR dem Ziehen trifft. */}
<div className="flex items-center gap-2">
<label className="shrink-0 text-xs text-schrift-leise">Ziel</label>
<select
value={ziel}
onChange={(e) => setZiel(e.target.value)}
disabled={laeuft}
className="min-w-0 flex-1 rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift outline-none focus:border-akzent
disabled:opacity-50"
>
<option value="">(Wurzel)</option>
{(ordnerDaten?.items ?? []).map((o) => (
<option key={o.folder} value={o.folder}>
{/* Die Einrückung mit festen Leerzeichen: ein
<option> nimmt kein Layout an, aber es nimmt
Zeichen. */}
{'\u00a0'.repeat(o.tiefe * 2)}
{o.folder.split('/').pop()}
</option>
))}
</select>
<button
onClick={() => setNeu(neu === null ? '' : null)}
disabled={laeuft}
title="Neuen Ordner darin anlegen"
className="shrink-0 rounded-md border border-rand p-1.5 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift disabled:opacity-50"
aria-label="Neuen Ordner anlegen"
>
<FolderPlus size={14} />
</button>
</div>
{neu !== null && (
<div className="flex items-center gap-2">
<span className="shrink-0 font-mono text-xs text-schrift-leise">
{ziel ? `${ziel}/` : ''}
</span>
<input
autoFocus
value={neu}
onChange={(e) => setNeu(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void ordnerAnlegen()
else if (e.key === 'Escape') setNeu(null)
}}
placeholder="name"
className="min-w-0 flex-1 rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift outline-none focus:border-akzent"
/>
<Knopf
art="haupt"
onClick={() => void ordnerAnlegen()}
kind="Anlegen"
/>
</div>
)}
<div
onDragOver={(e) => {
e.preventDefault()
setUeber(true)
}}
onDragLeave={() => setUeber(false)}
onDrop={(e) => {
e.preventDefault()
setUeber(false)
void schicken(e.dataTransfer.files)
}}
onClick={() => feld.current?.click()}
className={`flex cursor-pointer flex-col items-center gap-1 rounded-lg border
border-dashed px-4 py-5 text-center text-sm transition-colors ${
ueber
? 'border-akzent bg-akzent/10 text-schrift'
: 'border-rand text-schrift-leise hover:border-akzent/50 hover:text-schrift'
}`}
>
<Upload size={20} />
<span>
Dateien hierher ziehen oder <span className="text-akzent">auswählen</span>
</span>
<span className="text-xs">
nach <code className="font-mono">{ziel ? `${ziel}/` : '(Wurzel)'}</code> ·
Bilder, Videos, Ton, PDF, Text ein ZIP wird ausgepackt
</span>
</div>
<input
ref={feld}
type="file"
multiple
hidden
onChange={(e) => e.target.files && void schicken(e.target.files)}
/>
{laeuft && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-flaeche-hoch">
<div
className="h-1.5 rounded-full bg-akzent transition-[width]"
style={{ width: `${Math.round(fortschritt * 100)}%` }}
/>
</div>
)}
{ergebnisse && (
<div className="rounded-lg border border-rand bg-flaeche">
<div className="flex items-center justify-between border-b border-rand px-3 py-1.5">
<span className="text-xs font-semibold">Ergebnis</span>
<button
onClick={() => setErgebnisse(null)}
className="text-schrift-leise hover:text-schrift"
aria-label="Liste schließen"
>
<X size={14} />
</button>
</div>
<ul className="max-h-52 overflow-auto p-2 text-xs">
{ergebnisse.map((e, i) => (
<li key={i} className="flex items-start gap-2 py-0.5">
{e.ok ? (
<CheckCircle2 size={13} className="mt-0.5 shrink-0 text-gut" />
) : (
<XCircle size={13} className="mt-0.5 shrink-0 text-schlecht" />
)}
<span className="min-w-0 flex-1">
<span className="font-mono">{e.path ?? e.name}</span>
{e.ok ? (
<span className="text-schrift-leise">
{' '}
{bytes(e.size ?? 0)}
{e.replaced && ' (ersetzt)'}
{/* Der zurechtgebogene Name wird
GEZEIGT: wer "Mein Video.MP4"
zieht, soll sehen, dass daraus
"mein-video.mp4" wurde. */}
{e.path && !e.path.endsWith(e.name) && (
<span className="text-schrift-leise/70">
{' '}
· aus {e.name}"
</span>
)}
</span>
) : (
<span className="text-schlecht"> {e.error}</span>
)}
</span>
</li>
))}
</ul>
</div>
)}
<VonAdresse ordner={ziel} ordnerDaten={ordnerDaten} fertig={fertig} />
{ergebnisse === null && !laeuft && (
<Knopf
className="self-start lg:hidden"
onClick={() => feld.current?.click()}
kind="Dateien wählen"
/>
)}
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, LogOut, UserCog , ShieldOff } from 'lucide-react'
import { run, useStore } from '../store'
import { gehZu } from '../router'
import { Avatar } from './Avatar'
import { api } from '../api'
/** Das Konto hinter dem eigenen Namen.
*
* Vorher war es ein eigener Reiter neben Galerie und Verlauf und stand damit
* gleichrangig neben Dingen, die man täglich benutzt, obwohl man sein Passwort
* zweimal im Jahr ändert. Hinter dem Namen ist der Platz, an dem jeder danach
* sucht.
*
* Der Pfad /konto bleibt bestehen: ein Lesezeichen darauf soll nicht ins Leere
* führen, nur weil der Reiter verschwunden ist. */
export function Kontomenue() {
const user = useStore((s) => s.user)
const logout = useStore((s) => s.logout)
const sitzungen = useStore((s) => s.sitzungen)
const checkSession = useStore((s) => s.checkSession)
const [offen, setOffen] = useState(false)
const huelle = useRef<HTMLDivElement>(null)
// Klick daneben und Escape schließen. Ein Menü, das offen bleibt, bis man
// genau den Knopf wieder trifft, ist ein Menü, das im Weg steht.
useEffect(() => {
if (!offen) return
const daneben = (e: MouseEvent) => {
if (!huelle.current?.contains(e.target as Node)) setOffen(false)
}
const taste = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOffen(false)
}
document.addEventListener('mousedown', daneben)
window.addEventListener('keydown', taste)
return () => {
document.removeEventListener('mousedown', daneben)
window.removeEventListener('keydown', taste)
}
}, [offen])
if (!user) return null
const eintrag =
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-schrift-leise ' +
'transition-colors hover:bg-flaeche-hoch hover:text-schrift'
return (
<div ref={huelle} className="relative">
<button
onClick={() => setOffen((o) => !o)}
aria-expanded={offen}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm text-schrift-leise
transition-colors hover:bg-flaeche-hoch hover:text-schrift"
>
<Avatar name={user.username} url={user.avatar} groesse={26} />
<span className="hidden max-w-32 truncate sm:inline">{user.username}</span>
<ChevronDown
size={14}
className={`transition-transform ${offen ? 'rotate-180' : ''}`}
/>
</button>
{offen && (
<div
className="absolute right-0 z-50 mt-1 w-56 overflow-hidden rounded-lg border
border-rand bg-flaeche shadow-xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-rand px-3 py-2.5">
<Avatar name={user.username} url={user.avatar} groesse={32} />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.username}</p>
<p className="text-[11px] text-schrift-leise">
{user.discord ? 'über Discord' : 'mit Passwort'}
</p>
</div>
</div>
<button
className={eintrag}
onClick={() => {
setOffen(false)
gehZu('/konto')
}}
>
<UserCog size={15} />
Konto und Passwort
</button>
{/* Nur wenn es ueberhaupt eine zweite gibt. Ein Knopf, der
"0 andere Sitzungen beendet" meldet, ist ein Knopf, der
nichts tut -- und beim naechsten Mal drueckt man ihn
wieder, weil man es vergessen hat. */}
{sitzungen > 1 && (
<button
className={`${eintrag} border-t border-rand`}
onClick={async () => {
setOffen(false)
await run(
() => api.sitzungenBeenden(),
(r) =>
`${r.beendet} andere Sitzung(en) beendet. Diese hier bleibt.`,
)
void checkSession()
}}
>
<ShieldOff size={15} />
<span className="flex-1 text-left">
Andere Sitzungen beenden
<span className="block text-[11px] text-schrift-leise">
{sitzungen} offen an fremden Rechnern auch
</span>
</span>
</button>
)}
<button
className={`${eintrag} border-t border-rand hover:text-schlecht`}
onClick={() => {
setOffen(false)
void run(logout, 'Abgemeldet.')
}}
>
<LogOut size={15} />
Abmelden
</button>
</div>
)}
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
import type { ReactNode } from 'react'
/** Markdown darstellen ohne Bibliothek, und das ist hier kein Geiz.
*
* Der übliche Weg heißt `marked` plus `DOMPurify`: der erste macht aus
* Markdown eine HTML-Zeichenkette, der zweite versucht, das Gefährliche wieder
* herauszuschneiden, und am Ende steht ein `dangerouslySetInnerHTML`. Damit
* hängt die Sicherheit dieser Seite an der Frage, ob die Filterliste
* vollständig ist.
*
* HIER ENTSTEHT NIE EINE HTML-ZEICHENKETTE. Der Text wird zu React-Knoten, und
* React setzt alles als Text ein. Ein `<script>` in einer hochgeladenen
* `.md` erscheint als die acht Zeichen, die es ist. Es gibt kein
* `dangerouslySetInnerHTML` in dieser Datei, und das ist der ganze Punkt
* die Dateien hier kommen von außen, jeder mit einem Upload-Token kann eine
* ablegen.
*
* Was er kann: Überschriften, Absätze, fett, kursiv, durchgestrichen, Code
* am Stück und im Fluss, Listen (auch nummeriert), Zitate, Tabellen, Linien,
* Links, Bilder.
*
* Was er NICHT kann, damit niemand danach sucht: verschachtelte Listen,
* Fußnoten, Aufgabenlisten, eingebettetes HTML (siehe oben), Zeilenumbruch
* durch zwei Leerzeichen. Das ist die Grenze, und für eine README neben ein
* paar Bildern reicht sie. */
export function Markdown({ text }: { text: string }) {
return <div className="flex flex-col gap-3">{bloecke(text)}</div>
}
// -- Blockebene ---------------------------------------------------------------
function bloecke(text: string): ReactNode[] {
const zeilen = text.replace(/\r\n?/g, '\n').split('\n')
const raus: ReactNode[] = []
let i = 0
const schluessel = () => `b${i}`
while (i < zeilen.length) {
const zeile = zeilen[i] ?? ''
// Leerzeile: nichts zu tun, sie trennt bereits.
if (!zeile.trim()) {
i++
continue
}
// Code am Stück. Ein nicht geschlossener Block läuft bis zum Ende --
// besser als der Rest der Datei in Kursiv.
const zaun = /^```(\w*)\s*$/.exec(zeile)
if (zaun) {
const start = i + 1
let ende = start
while (ende < zeilen.length && !/^```\s*$/.test(zeilen[ende] ?? '')) ende++
raus.push(
<pre
key={schluessel()}
className="overflow-x-auto rounded-md border border-rand bg-grund p-3 font-mono text-xs leading-relaxed"
>
<code>{zeilen.slice(start, ende).join('\n')}</code>
</pre>,
)
i = ende + 1
continue
}
// Linie. Vor der Liste geprüft, sonst frisst "***" die Kursivschrift.
if (/^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(zeile)) {
raus.push(<hr key={schluessel()} className="border-rand" />)
i++
continue
}
const ueber = /^(#{1,6})\s+(.*)$/.exec(zeile)
if (ueber) {
const stufe = ueber[1]!.length
const groesse = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm'][
stufe - 1
]
raus.push(
<p
key={schluessel()}
className={`${groesse} font-semibold text-schrift ${stufe <= 2 ? 'mt-2 border-b border-rand pb-1' : ''}`}
>
{inline(ueber[2] ?? '')}
</p>,
)
i++
continue
}
// Tabelle: Kopf, Trennzeile aus Strichen, dann Zeilen.
if (zeile.includes('|') && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(zeilen[i + 1] ?? '')) {
const kopf = spalten(zeile)
const zeilenRaus: string[][] = []
let j = i + 2
while (j < zeilen.length && (zeilen[j] ?? '').includes('|')) {
zeilenRaus.push(spalten(zeilen[j] ?? ''))
j++
}
raus.push(
<div key={schluessel()} className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-rand text-left">
{kopf.map((z, k) => (
<th key={k} className="px-3 py-1.5 font-medium">
{inline(z)}
</th>
))}
</tr>
</thead>
<tbody>
{zeilenRaus.map((r, k) => (
<tr key={k} className="border-b border-rand/50">
{r.map((z, l) => (
<td key={l} className="px-3 py-1.5">
{inline(z)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
)
i = j
continue
}
if (/^ {0,3}>\s?/.test(zeile)) {
const teile: string[] = []
while (i < zeilen.length && /^ {0,3}>\s?/.test(zeilen[i] ?? '')) {
teile.push((zeilen[i] ?? '').replace(/^ {0,3}>\s?/, ''))
i++
}
raus.push(
<blockquote
key={schluessel()}
className="border-l-2 border-akzent/50 pl-3 text-schrift-leise"
>
{inline(teile.join(' '))}
</blockquote>,
)
continue
}
const punkte = /^ {0,3}([-*+]|\d+\.)\s+/.exec(zeile)
if (punkte) {
const nummeriert = /\d/.test(punkte[1] ?? '')
const eintraege: string[] = []
while (i < zeilen.length) {
const m = /^ {0,3}([-*+]|\d+\.)\s+(.*)$/.exec(zeilen[i] ?? '')
if (!m) break
if (/\d/.test(m[1] ?? '') !== nummeriert) break
eintraege.push(m[2] ?? '')
i++
}
const Liste = nummeriert ? 'ol' : 'ul'
raus.push(
<Liste
key={schluessel()}
className={`ml-5 flex list-outside flex-col gap-1 text-sm ${
nummeriert ? 'list-decimal' : 'list-disc'
}`}
>
{eintraege.map((e, k) => (
<li key={k}>{inline(e)}</li>
))}
</Liste>,
)
continue
}
// Absatz: alles bis zur nächsten Leerzeile.
const teile: string[] = []
while (i < zeilen.length && (zeilen[i] ?? '').trim() && !istBlockanfang(zeilen[i] ?? '')) {
teile.push(zeilen[i] ?? '')
i++
}
// Kein Fortschritt heißt Endlosschleife — lieber die Zeile roh
// ausgeben als den Browser einfrieren.
if (teile.length === 0) {
teile.push(zeile)
i++
}
raus.push(
<p key={schluessel()} className="text-sm leading-relaxed">
{inline(teile.join(' '))}
</p>,
)
}
return raus
}
const istBlockanfang = (z: string) =>
/^```/.test(z) || /^#{1,6}\s/.test(z) || /^ {0,3}>/.test(z) || /^ {0,3}([-*+]|\d+\.)\s/.test(z)
const spalten = (zeile: string) =>
zeile
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((z) => z.trim())
// -- Im Fluss -----------------------------------------------------------------
/** Nur Adressen, die nichts ausführen.
*
* `[klick](javascript:…)` ist der eine Weg, auf dem hier trotz React-Knoten
* noch Code hereinkäme href wird von React NICHT geprüft. Erlaubt sind
* deshalb http, https, mailto und relative Ziele; alles andere wird zu
* gewöhnlichem Text. */
function sichereAdresse(roh: string): string | null {
const ziel = roh.trim()
if (/^(https?:|mailto:)/i.test(ziel)) return ziel
if (/^[a-z][a-z0-9+.-]*:/i.test(ziel)) return null
return ziel
}
const MUSTER =
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(!?\[[^\]\n]*\]\([^)\s]+\))/g
function inline(text: string): ReactNode[] {
const raus: ReactNode[] = []
const muster = new RegExp(MUSTER.source, 'g')
let letzte = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(text)) !== null) {
if (treffer.index > letzte) raus.push(text.slice(letzte, treffer.index))
const roh = treffer[0]
const k = treffer.index
if (treffer[1]) {
raus.push(
<code key={k} className="rounded bg-grund px-1 py-0.5 font-mono text-[0.85em]">
{roh.slice(1, -1)}
</code>,
)
} else if (treffer[2]) {
raus.push(
<strong key={k} className="font-semibold text-schrift">
{roh.slice(2, -2)}
</strong>,
)
} else if (treffer[3]) {
raus.push(
<s key={k} className="text-schrift-leise">
{roh.slice(2, -2)}
</s>,
)
} else if (treffer[4]) {
raus.push(<em key={k}>{roh.slice(1, -1)}</em>)
} else {
const m = /^(!?)\[([^\]]*)\]\(([^)\s]+)\)$/.exec(roh)!
const ziel = sichereAdresse(m[3] ?? '')
if (!ziel) {
// Abgelehnt — als Text stehen lassen, damit sichtbar bleibt,
// dass da etwas stand.
raus.push(roh)
} else if (m[1]) {
raus.push(
<img
key={k}
src={ziel}
alt={m[2] ?? ''}
className="my-2 max-h-96 max-w-full rounded border border-rand"
/>,
)
} else {
raus.push(
<a
key={k}
href={ziel}
target="_blank"
rel="noreferrer noopener"
className="text-akzent underline underline-offset-2 hover:text-akzent/80"
>
{m[2] ?? ziel}
</a>,
)
}
}
letzte = k + roh.length
}
if (letzte < text.length) raus.push(text.slice(letzte))
return raus
}
+48
View File
@@ -0,0 +1,48 @@
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
import { useStore, type NoteKind } from '../store'
const gestalt: Record<NoteKind, { farbe: string; kind: typeof Info }> = {
ok: { farbe: 'border-gut/40 bg-gut/10 text-gut', kind: CheckCircle2 },
fehler: { farbe: 'border-schlecht/40 bg-schlecht/10 text-schlecht', kind: AlertTriangle },
hinweis: { farbe: 'border-akzent/40 bg-akzent/10 text-akzent', kind: Info },
}
/** Die Rückmeldungen, unten rechts.
*
* Erfolge verschwinden nach vier Sekunden von allein, Fehler bleiben stehen,
* bis jemand sie wegklickt das entscheidet der Laden, nicht diese Ansicht. */
export function Meldungen() {
const notes = useStore((s) => s.notes)
const dismiss = useStore((s) => s.dismiss)
if (notes.length === 0) return null
return (
<div
className="fixed right-4 bottom-4 z-60 flex w-[min(26rem,calc(100vw-2rem))] flex-col gap-2"
role="status"
aria-live="polite"
>
{notes.map((n) => {
const { farbe, kind: Kind } = gestalt[n.kind]
return (
<div
key={n.id}
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm
shadow-lg backdrop-blur ${farbe}`}
>
<Kind size={16} className="mt-0.5 shrink-0" />
<span className="flex-1 break-words text-schrift">{n.text}</span>
<button
onClick={() => dismiss(n.id)}
className="shrink-0 rounded p-0.5 text-schrift-leise hover:text-schrift"
aria-label="Meldung schließen"
>
<X size={14} />
</button>
</div>
)
})}
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Bell, BellOff, Send } from 'lucide-react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from './Bausteine'
// ACHTUNG, DIESE LISTE HAT EINE ZWILLINGSSCHWESTER: `ANLAESSE` in
// server/src/meldung.ts. Der Server verwirft beim Speichern alles, was dort
// nicht steht -- eine Zeile, die nur hier steht, ergibt ein Kreuzchen, das sich
// setzen laesst und beim naechsten Laden wieder weg ist. Genau das ist mit
// 'platte' schon einmal passiert: gesendet 3, gespeichert 2. Wer hier etwas
// ergaenzt, ergaenzt es DORT auch.
const ANLAESSE = [
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
['verwaiste', 'Verwaiste Dateien gefunden'],
['platte', 'Der Platz auf der Platte wird knapp'],
['upload', 'Neue Dateien sind angekommen'],
] as const
/** Meldungen nach Discord.
*
* Der Grund ist nicht Discord, sondern eine Lücke: die Sicherung läuft nachts
* um halb fünf, und wenn sie aufhört zu laufen, merkt es niemand das Panel
* zeigt es nur dem, der hinschaut. Ein Webhook schließt genau das.
*
* Deshalb steht der Testknopf gleich daneben. Ein Webhook, den man einträgt
* und erst in drei Wochen im Fehlerfall ausprobiert, ist ein Webhook, der dann
* nicht funktioniert. */
export function MeldungenKarte() {
const note = useStore((s) => s.note)
const [hatWebhook, setHatWebhook] = useState(false)
const [anlaesse, setAnlaesse] = useState<string[]>([])
const [webhook, setWebhook] = useState('')
const [speichert, setSpeichert] = useState(false)
const [testet, setTestet] = useState(false)
async function laden() {
try {
const e = await api.einstellungen()
setHatWebhook(e.meldungen.hatWebhook)
setAnlaesse(e.meldungen.anlaesse)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Karte
titel="Meldungen nach Discord"
rechts={
hatWebhook ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
<Bell size={14} /> eingerichtet
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
<BellOff size={14} /> aus
</span>
)
}
>
<div className="flex flex-col gap-4">
<p className="text-xs text-schrift-leise">
Nicht für Jubelmeldungen, sondern für die zwei Fälle, in denen jemand etwas
tun muss. Der wichtigste: <strong>die Sicherung läuft nachts und niemand
merkt, wenn sie aufhört.</strong> Der Dienst sieht stündlich nach und meldet
höchstens einmal je Anlass.
</p>
<Feld
label="Webhook-Adresse"
type="password"
value={webhook}
placeholder={
hatWebhook ? '•••••••• (hinterlegt)' : 'https://discord.com/api/webhooks/…'
}
hinweis={
'In Discord: Serverkanal → Bearbeiten → Integrationen → Webhooks. ' +
'Leer lassen heißt unverändert, ein „-" entfernt ihn.'
}
onChange={(e) => setWebhook(e.target.value)}
/>
<div>
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Wann gemeldet wird
</span>
<div className="flex flex-col gap-1">
{ANLAESSE.map(([wert, text]) => (
<label key={wert} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={anlaesse.includes(wert)}
onChange={(e) =>
setAnlaesse((a) =>
e.target.checked
? [...a, wert]
: a.filter((x) => x !== wert),
)
}
className="accent-akzent"
/>
{text}
</label>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Knopf
art="haupt"
wartet={speichert}
onClick={async () => {
setSpeichert(true)
const r = await run(
() =>
api.meldungenSpeichern({
...(webhook ? { webhook } : {}),
anlaesse,
}),
(x) =>
x.meldungen.hatWebhook
? 'Gespeichert. Jetzt den Testknopf drücken.'
: 'Gespeichert — ohne Webhook wird nichts gemeldet.',
)
setSpeichert(false)
if (r) {
setWebhook('')
void laden()
}
}}
kind="Speichern"
/>
<Knopf
wartet={testet}
disabled={!hatWebhook}
onClick={async () => {
setTestet(true)
await run(
() => api.meldenTesten(),
'Testnachricht geschickt — steht sie in Discord?',
)
setTestet(false)
}}
kind={
<>
<Send size={15} />
Testnachricht
</>
}
/>
</div>
</div>
</Karte>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { useRef, useState } from 'react'
import { AlertTriangle, Download, Upload } from 'lucide-react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Karte, Knopf, Nachfrage } from './Bausteine'
/** Einstellungen mitnehmen.
*
* Discord-Zugang, Sicherungsziel, Adressvorlagen, Meldungsanlässe, Marke
* alles steht in einer Tabelle und wäre beim Neuaufsetzen von Hand
* nachzutragen. Eine Stunde Klickerei, bei der man genau die eine Zeile
* vergisst, die man erst drei Wochen später braucht.
*
* GEHEIMNISSE GEHEN STANDARDMÄSSIG NICHT MIT. Eine Datei, die man mal eben"
* exportiert und in einen Chat zieht, ist genau der Weg, auf dem ein Passwort
* abhandenkommt. Wer sie braucht, sagt es ausdrücklich und der Hinweis steht
* dann **in** der Datei, nicht nur hier auf dem Bildschirm, von dem er beim
* Weiterreichen abfällt. */
export function MitnehmenKarte() {
const note = useStore((s) => s.note)
const [fragt, setFragt] = useState(false)
const [ueberschreiben, setUeberschreiben] = useState(false)
const feld = useRef<HTMLInputElement>(null)
function holen(mitGeheim: boolean) {
// Über einen Link und nicht über fetch: so bekommt die Datei den Namen
// aus dem Content-Disposition und landet im Download-Ordner statt im
// Speicher des Browsers.
const a = document.createElement('a')
a.href = `/api/dash/einstellungen/export${mitGeheim ? '?geheim=1' : ''}`
a.rel = 'noreferrer'
a.click()
setFragt(false)
}
async function einlesen(datei: File) {
let daten: { werte?: Record<string, string> }
try {
daten = JSON.parse(await datei.text())
} catch {
note('fehler', 'Das ist keine gültige JSON-Datei.')
return
}
if (!daten.werte || typeof daten.werte !== 'object') {
note('fehler', 'In der Datei fehlt das Feld „werte" — ist das ein Export von hier?')
return
}
await run(
() => api.einstellungenImport(daten.werte as Record<string, string>, ueberschreiben),
(r) =>
r.gesetzt.length === 0
? `Nichts gesetzt — ${r.uebersprungen.length} übersprungen. ${
ueberschreiben ? '' : 'Vorhandenes bleibt stehen, solange der Haken aus ist.'
}`
: `${r.gesetzt.length} gesetzt, ${r.uebersprungen.length} übersprungen.`,
)
if (feld.current) feld.current.value = ''
}
return (
<Karte titel="Einstellungen mitnehmen">
<p className="mb-3 text-xs text-schrift-leise">
Alles, was unter Einstellungen steht, als eine Datei für einen Umzug oder als
Notiz daneben. <strong className="text-schrift">Ohne Geheimnisse</strong>: das
Discord-Client-Geheimnis und das Nextcloud-Passwort bleiben hier und sind nach
einem Einspielen von Hand nachzutragen.
</p>
<div className="flex flex-wrap items-center gap-2">
<Knopf
onClick={() => holen(false)}
kind={
<>
<Download size={15} />
Herunterladen
</>
}
/>
<Knopf
art="gefahr"
onClick={() => setFragt(true)}
kind="mit Geheimnissen"
/>
<span className="mx-1 h-5 w-px bg-rand" />
<Knopf
onClick={() => feld.current?.click()}
kind={
<>
<Upload size={15} />
Einspielen
</>
}
/>
<label className="flex items-center gap-1.5 text-xs text-schrift-leise">
<input
type="checkbox"
checked={ueberschreiben}
onChange={(e) => setUeberschreiben(e.target.checked)}
className="accent-akzent"
/>
Vorhandenes überschreiben
</label>
<input
ref={feld}
type="file"
accept="application/json,.json"
hidden
onChange={(e) => e.target.files?.[0] && void einlesen(e.target.files[0])}
/>
</div>
<p className="mt-2 text-[11px] text-schrift-leise">
Beim Einspielen überschreiben <strong>leere Werte nichts</strong> eine Datei ohne
Geheimnisse darf ein vorhandenes Passwort nicht löschen. Und ohne den Haken bleibt
stehen, was schon dasteht.
</p>
<Nachfrage
offen={fragt}
titel="Export mit Geheimnissen?"
bestaetigung="Trotzdem herunterladen"
onNein={() => setFragt(false)}
onJa={() => holen(true)}
text={
<p className="flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
<span>
Die Datei enthält dann das <strong>Discord-Client-Geheimnis</strong> und
das <strong>Nextcloud-Passwort</strong> im Klartext. Nicht in einen Chat
ziehen, nicht in ein Repository legen. Dass sie es enthält, steht auch in
der Datei selbst dieser Hinweis hier fällt beim Weiterreichen ab.
<br />
<br />
Der Download wird im Verwaltungs-Verlauf vermerkt.
</span>
</p>
}
/>
</Karte>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useState } from 'react'
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, Trash2, X } from 'lucide-react'
import { api, type Folders } from '../api'
import { bytes } from '../format'
import { run } from '../store'
/** Die Ordnerübersicht neben der Galerie.
*
* Kein aufklappbarer Baum, sondern eine flache Liste mit Einrückung. Bei
* einer Handvoll Ordner ist ein Aufklappmechanismus mehr Bedienung als
* Nutzen und was man nicht aufklappen muss, kann man auch nicht zugeklappt
* übersehen.
*
* HIER STEHT AUCH "NEUER ORDNER", und das ist eine Korrektur: anlegen ging
* bisher NUR im Verschieben-Dialog. Wer einen leeren Ordner wollte, musste
* also erst Dateien auswählen, Verschieben" drücken und dort das Feld
* finden eine Funktion, die man nur über eine andere Funktion erreicht, ist
* für den, der sie sucht, nicht vorhanden. */
export function Ordner({
daten,
gewaehlt,
onWahl,
onAngelegt,
onUmbenennen,
onEntfernt,
}: {
daten: Folders | null
gewaehlt: string
onWahl: (ordner: string) => void
/** Damit die Liste den neuen Ordner auch zeigt. */
onAngelegt: () => 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 [legtAn, setLegtAn] = useState(false)
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() {
const name = (neu ?? '').trim()
if (!name) return
setLegtAn(true)
// Relativ zum GEWÄHLTEN Ordner. Wer in "items" steht und "waffen"
// tippt, meint items/waffen -- alles andere wäre eine Überraschung.
const pfad = gewaehlt ? `${gewaehlt}/${name}` : name
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
setLegtAn(false)
if (r) {
setNeu(null)
onAngelegt()
onWahl(r.pfad)
}
}
const zeile = (
aktiv: boolean,
kind: React.ReactNode,
name: string,
anzahl: number,
groesse: number,
tiefe = 0,
onClick?: () => void,
pfad?: string,
) => (
<div
key={name + tiefe}
className={`group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm
transition-colors ${
aktiv
? 'bg-flaeche-hoch text-schrift'
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
}`}
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
>
<button onClick={onClick} className="flex min-w-0 flex-1 items-center gap-2 text-left">
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={name}>
{name}
</span>
</button>
{/* Erst beim Darueberfahren. Eine Zeile mit drei Zahlen und einem
dauerhaft sichtbaren Knopf waere Laerm und bei einem Dutzend
Ordnern zwoelfmal derselbe. */}
{pfad !== undefined && (
<button
onClick={(e) => {
e.stopPropagation()
onUmbenennen(pfad)
}}
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100"
aria-label={`${pfad} umbenennen`}
title="Umbenennen"
>
<PenLine size={12} />
</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">
{anzahl}
</span>
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
{bytes(groesse)}
</span>
</div>
)
return (
<aside className="w-full shrink-0 self-start rounded-lg border border-rand bg-flaeche p-2 lg:w-60">
<h2 className="px-2 py-1 text-xs font-semibold text-schrift-leise">Ordner</h2>
{zeile(
gewaehlt === '',
<Layers size={14} />,
'Alle',
daten.gesamt.files,
daten.gesamt.bytes,
0,
() => onWahl(''),
)}
{daten.items.map((o) =>
zeile(
gewaehlt === o.folder,
gewaehlt === o.folder ? <FolderOpen size={14} /> : <Folder size={14} />,
// Nur das letzte Stück anzeigen — der Weg dorthin steht
// schon in der Einrückung. Der volle Pfad hängt im title.
o.folder.split('/').pop() ?? o.folder,
o.files,
o.bytes,
o.tiefe,
() => onWahl(o.folder),
o.folder,
),
)}
{daten.wurzel.files > 0 && (
<p className="mt-2 px-2 text-[11px] text-schrift-leise">
{daten.wurzel.files} Datei(en) liegen ohne Ordner direkt an der Wurzel zu
sehen unter Alle".
</p>
)}
<div className="mt-2 border-t border-rand pt-2">
{neu === null ? (
<button
onClick={() => setNeu('')}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-xs text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift"
>
<FolderPlus size={14} />
Neuer Ordner
</button>
) : (
<div className="flex flex-col gap-1.5 px-1">
{/* WO er entsteht, steht dabei. Ohne diese Zeile tippt
jemand "waffen", landet in "items/waffen" und sucht
danach an der Wurzel. */}
<p className="px-1 text-[11px] text-schrift-leise">
in{' '}
<code className="font-mono">{gewaehlt || '(Wurzel)'}</code>
</p>
<div className="flex items-center gap-1">
<input
autoFocus
value={neu}
onChange={(e) => setNeu(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void anlegen()
else if (e.key === 'Escape') setNeu(null)
}}
placeholder="name"
className="min-w-0 flex-1 rounded border border-rand bg-grund px-2 py-1
font-mono text-xs text-schrift outline-none
focus:border-akzent"
/>
<button
onClick={() => void anlegen()}
disabled={legtAn || !neu.trim()}
className="rounded border border-rand p-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-gut disabled:opacity-40"
aria-label="Anlegen"
>
<Check size={13} />
</button>
<button
onClick={() => setNeu(null)}
className="rounded border border-rand p-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Abbrechen"
>
<X size={13} />
</button>
</div>
<p className="px-1 text-[11px] text-schrift-leise">
Buchstabe oder Ziffer zuerst, danach auch Punkt, Strich, Unterstrich.
</p>
</div>
)}
</div>
</aside>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, type RefObject } from 'react'
import { Music } from 'lucide-react'
import type { MediaItem } from '../api'
import { useMerker } from '../merker'
/** Video und Ton abspielen.
*
* Die Bedienelemente sind die des Browsers, und das ist eine Entscheidung:
* ein selbstgebauter Schieber sieht in jedem Browser anders falsch aus,
* kennt keine Tastatursteuerung und keine Untertitel. Was der Browser NICHT
* mitbringt, steht hier drumherum:
*
* LAUTSTÄRKE ÜBER DATEIEN HINWEG. Das `<video>`-Element setzt sie bei jedem
* neuen Element auf 1 zurück. Wer sich durch zwanzig Clips klickt, stellt sie
* sonst zwanzigmal leise und beim einundzwanzigsten fährt sie ungefragt
* wieder hoch, wenn jemand danebensteht.
*
* WEITER ZUM NÄCHSTEN. Ohne das ist es kein Player, sondern eine Vorschau,
* die nach jedem Stück stehen bleibt. Abschaltbar, denn beim Durchsehen von
* Aufnahmen will man das Gegenteil.
*
* Beides wird gemerkt. Es sind Ansichtssachen im Sinne von `merker.ts`: sie
* formen die Ansicht, sie schränken nichts ein. */
export function Player({
item,
elementRef,
onEnde,
}: {
item: MediaItem
elementRef: RefObject<HTMLMediaElement | null>
onEnde: () => void
}) {
const [ton, setTon] = useMerker('player.ton', { laut: 1, still: false })
const istVideo = item.mime.startsWith('video')
// Die gemerkte Lautstärke auf das NEUE Element übertragen. Ohne diesen
// Effekt gilt sie nur für das erste, danach fängt jedes bei 1 an.
useEffect(() => {
const el = elementRef.current
if (!el) return
el.volume = ton.laut
el.muted = ton.still
}, [elementRef, item.url, ton.laut, ton.still])
const merken = () => {
const el = elementRef.current
if (!el) return
// Nur schreiben, wenn sich wirklich etwas geändert hat: sonst löst der
// eigene Effekt oben ein volumechange aus, das wieder schreibt.
if (el.volume !== ton.laut || el.muted !== ton.still) {
setTon({ laut: el.volume, still: el.muted })
}
}
if (istVideo) {
return (
<video
// Der Schlüssel zwingt React, beim Wechsel ein NEUES Element zu
// bauen. Ohne ihn behält das alte seine Abspielposition, und
// der nächste Clip startet mitten im Bild.
key={item.url}
ref={elementRef as RefObject<HTMLVideoElement>}
src={item.url}
controls
autoPlay
onVolumeChange={merken}
onEnded={onEnde}
className="max-h-[calc(100vh-9rem)] max-w-full rounded"
/>
)
}
return (
<div className="w-full max-w-lg rounded-lg border border-white/15 bg-flaeche p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-akzent/15">
<Music size={22} className="text-akzent" />
</div>
<div className="min-w-0">
{/* Der Dateiname, gross. Bei Ton gibt es kein Bild, an dem
man erkennt, was gerade laeuft. */}
<p className="truncate text-sm text-schrift" title={item.path}>
{item.path.split('/').pop()}
</p>
<p className="truncate text-xs text-schrift-leise">
{item.path.split('/').slice(0, -1).join('/') || '(Wurzel)'}
</p>
</div>
</div>
<audio
key={item.url}
ref={elementRef as RefObject<HTMLAudioElement>}
src={item.url}
controls
autoPlay
onVolumeChange={merken}
onEnded={onEnde}
className="w-full"
/>
</div>
)
}
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react'
import { Download, HardDriveDownload, Loader2 } from 'lucide-react'
import { api, type Sicherung } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte } from './Bausteine'
/** Die fertigen Archive herunterladen.
*
* WOZU: die Sicherung lief längst, aber sie lag ausschließlich auf **dieser**
* Maschine auf zwei Platten, und die dritte Kopie in der Nextcloud läuft
* auch hier. Wer sie woanders haben wollte, musste sich per Fernwartung auf
* den Server klicken und sie von Hand ziehen. Das ist der Grund, aus dem es
* keine Kopie außerhalb gab: nicht Unwillen, sondern Umständlichkeit.
*
* Ein Knopf hier ersetzt keine geplante Abholung aber er macht die
* Auswärtskopie zu etwas, das man im Vorbeigehen erledigt, statt zu einem
* Vorhaben. */
export function SicherungenKarte() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Sicherung | null>(null)
const [laedt, setLaedt] = useState(true)
useEffect(() => {
api.sicherungen()
.then(setDaten)
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
.finally(() => setLaedt(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (laedt) {
return (
<Karte titel="Sicherungen holen">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
return (
<Karte
titel="Sicherungen holen"
rechts={
daten?.items.length ? (
<span className="text-xs text-schrift-leise">
{daten.items.length} Stück · {bytes(daten.bytes)}
</span>
) : undefined
}
>
{!daten?.eingehaengt ? (
<p className="text-sm text-schrift-leise">
Der Sicherungsordner ist nicht eingehängt. In der{' '}
<code className="font-mono text-xs">docker-compose.yml</code> braucht der
Dienst{' '}
<code className="font-mono text-xs">
{daten?.ordner ?? '/sicherungen'}
</code>{' '}
nur lesend.
</p>
) : daten.items.length === 0 ? (
<p className="text-sm text-schrift-leise">
Noch keine Archive in <code className="font-mono text-xs">{daten.ordner}</code>.
Die Sicherung läuft täglich um 04:30.
</p>
) : (
<>
<p className="mb-3 flex items-start gap-2 text-xs text-schrift-leise">
<HardDriveDownload size={14} className="mt-0.5 shrink-0" />
<span>
Alle Kopien liegen auf <strong className="text-schrift">dieser</strong>{' '}
Maschine auch die in der Nextcloud, die hier mitläuft. Ein Archiv,
das du herunterlädst, ist das erste, das den Server verlässt.
</span>
</p>
<ul className="flex flex-col divide-y divide-rand">
{daten.items.map((s) => (
<li key={s.name} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs text-schrift">
{s.name}
</p>
<p className="text-xs text-schrift-leise">
{zeit(s.at)} · {bytes(s.size)}
</p>
</div>
{/* Ein <a download> und kein fetch: 336 MB gehören nicht
durch den Speicher des Browsers, und der Name kommt
aus dem Content-Disposition. */}
<a
href={`/api/dash/sicherungen/${s.name}`}
onClick={() => note('ok', `${s.name} wird geladen — ${bytes(s.size)}.`)}
className="flex shrink-0 items-center gap-1.5 rounded-md border
border-rand px-2.5 py-1.5 text-xs text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
>
<Download size={14} />
Holen
</a>
</li>
))}
</ul>
</>
)}
</Karte>
)
}
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react'
import { FileQuestion, FileText, Film, Image, Loader2, Music } from 'lucide-react'
import { api, type Speicher } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Leer } from './Bausteine'
const ARTEN: Record<string, { text: string; kind: typeof Image; farbe: string }> = {
bild: { text: 'Bilder', kind: Image, farbe: 'bg-akzent' },
video: { text: 'Video', kind: Film, farbe: 'bg-warn' },
ton: { text: 'Ton', kind: Music, farbe: 'bg-gut' },
dokument: { text: 'Dokumente', kind: FileText, farbe: 'bg-schrift-leise' },
andere: { text: 'Anderes', kind: FileQuestion, farbe: 'bg-schlecht' },
}
/** Was liegt da eigentlich nach Art, nach Größe, nach Alter.
*
* ERST SEHEN, DANN ENTSCHEIDEN. Eine Aufbewahrungsregel (alles älter als 90
* Tage weg") ist eine Behauptung, solange niemand weiß, wie viel das wäre.
* Diese Karten löschen nichts; sie liefern die Zahlen, mit denen man
* entscheiden kann. */
export function SpeicherKarten() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Speicher | null>(null)
useEffect(() => {
api.speicher()
.then(setDaten)
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
}, [note])
if (!daten) {
return (
<Karte titel="Nach Medienart">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
const gesamt = daten.nachArt.reduce((s, a) => s + a.bytes, 0) || 1
return (
<>
<Karte titel="Nach Medienart">
<div className="flex flex-col gap-2">
{/* Ein Balken für alles, damit die Verhältnisse auf einen
Blick stimmen Zahlen untereinander liest man, Anteile
sieht man. */}
<div className="flex h-2.5 overflow-hidden rounded-full bg-grund">
{daten.nachArt
.filter((a) => a.bytes > 0)
.map((a) => (
<div
key={a.art}
className={ARTEN[a.art]?.farbe ?? 'bg-rand'}
style={{ width: `${(a.bytes / gesamt) * 100}%` }}
title={`${ARTEN[a.art]?.text}: ${bytes(a.bytes)}`}
/>
))}
</div>
<table className="w-full text-sm">
<tbody>
{daten.nachArt.map((a) => {
const g = ARTEN[a.art]
const Kind = g?.kind ?? FileQuestion
return (
<tr
key={a.art}
className={`border-t border-rand first:border-0 ${
a.files === 0 ? 'text-schrift-leise/50' : ''
}`}
>
<td className="py-1.5">
<span className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${g?.farbe ?? 'bg-rand'}`}
/>
<Kind size={14} />
{g?.text ?? a.art}
</span>
</td>
<td className="py-1.5 text-right tabular-nums">
{a.files}
</td>
<td className="w-24 py-1.5 text-right tabular-nums">
{bytes(a.bytes)}
</td>
<td className="w-16 py-1.5 text-right text-xs text-schrift-leise tabular-nums">
{gesamt > 1 ? `${Math.round((a.bytes / gesamt) * 100)} %` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Karte>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Die größten Dateien">
{daten.groesste.length === 0 ? (
<Leer text="Noch nichts da." />
) : (
<ul className="flex flex-col gap-1 text-sm">
{daten.groesste.map((m) => (
<li key={m.id} className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
title={m.path}
>
{m.path}
</span>
<span className="shrink-0 text-xs tabular-nums">
{bytes(m.size)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte titel="Doppelt abgelegt">
{daten.doppelte.length === 0 ? (
<p className="py-2 text-sm text-schrift-leise">
Nichts doppelt jeder Inhalt liegt genau einmal.
</p>
) : (
<div className="flex flex-col gap-2">
<p className="text-xs text-schrift-leise">
Derselbe Inhalt unter mehreren Pfaden. Kein Fehler aber{' '}
<strong className="text-warn">{bytes(daten.verschwendet)}</strong>{' '}
Platz, den niemand gewählt hat.
</p>
<ul className="flex max-h-48 flex-col gap-1 overflow-auto text-xs">
{daten.doppelte.map((d) => (
<li key={d.sha256} className="rounded border border-rand p-1.5">
<span className="text-schrift-leise">
{d.n}× · {bytes(d.bytes / d.n)}
</span>
<span className="mt-0.5 block truncate font-mono">
{d.pfade.replaceAll('|', ' · ')}
</span>
</li>
))}
</ul>
</div>
)}
</Karte>
</div>
<Karte titel="Wird das überhaupt geholt?">
<p className="mb-3 text-xs text-schrift-leise">
Die Frage, die eine Aufbewahrungsregel wirklich braucht und die sich von
innen nicht beantworten lässt. Gezählt wird seit dem Einbau des Zählers;
was davor lag, steht hier als nie geholt", auch wenn es täglich benutzt
wurde. Nach ein paar Wochen stimmt die Zahl.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-md border border-rand bg-grund p-3">
<p className="text-xs text-schrift-leise">Nie geholt</p>
<p className="text-xl font-semibold">
{daten.nieGeholt.n}{' '}
<span className="text-sm font-normal text-schrift-leise">
({bytes(daten.nieGeholt.bytes)})
</span>
</p>
</div>
<div className="rounded-md border border-rand bg-grund p-3">
<p className="mb-1 text-xs text-schrift-leise">Am häufigsten geholt</p>
{daten.beliebteste.length === 0 ? (
<p className="text-sm text-schrift-leise">noch nichts gezählt</p>
) : (
<ul className="flex flex-col gap-0.5 text-xs">
{daten.beliebteste.slice(0, 5).map((m) => (
<li key={m.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono" title={m.path}>
{m.path}
</span>
<span className="shrink-0 tabular-nums text-schrift-leise">
{m.abrufe}×
</span>
</li>
))}
</ul>
)}
</div>
</div>
</Karte>
<Karte titel="Wie alt der Bestand ist">
<p className="mb-3 text-xs text-schrift-leise">
Die Zahlen, die eine Aufbewahrungsregel braucht. Alles älter als 90 Tage
löschen" ist eine Behauptung, solange niemand weiß, wie viel das wäre
hier steht es. <strong>Gelöscht wird hier nichts.</strong>
</p>
<table className="w-full text-sm">
<tbody>
{daten.stufen.map((st) => (
<tr key={st.tage} className="border-t border-rand first:border-0">
<td className="py-1.5">älter als {st.tage} Tage</td>
<td className="py-1.5 text-right tabular-nums">
{st.files} Dateien
</td>
<td className="w-24 py-1.5 text-right tabular-nums">
{bytes(st.bytes)}
</td>
</tr>
))}
</tbody>
</table>
{daten.aelteste[0] && (
<p className="mt-3 text-xs text-schrift-leise">
Das älteste ist{' '}
<code className="font-mono">{daten.aelteste[0].path}</code> vom{' '}
{zeit(daten.aelteste[0].created_at)}.
</p>
)}
</Karte>
</>
)
}
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useState, type ReactNode } from 'react'
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
import type { MediaItem } from '../api'
import { bytes } from '../format'
import { faerben, type Sprache } from './Code'
import { Markdown } from './Markdown'
/** Lesbare Dateien im Betrachter zeigen: Markdown, Code, CSV, Klartext.
*
* Bis hierhin landete eine `.md` im Zweig lässt sich hier nicht anzeigen"
* ausgerechnet die Art, die man am ehesten nur lesen und nicht herunterladen
* will.
*
* ES WIRD NICHT ALLES GEHOLT. Die Größe steht in der Datenbank, also wissen
* wir vorher, worauf wir uns einlassen. Über der Grenze wird per
* Bereichsanfrage nur der Anfang geladen und das auch gesagt ein Betrachter,
* der bei einem 400-MB-Protokoll den Tab abschießt, ist schlimmer als einer,
* der die Datei gar nicht erst öffnet. */
/** 512 KB. Das sind rund 10 000 Zeilen mehr liest ohnehin niemand am Stück,
* und der Herunterladen-Knopf steht daneben. */
const HOECHSTENS = 512 * 1024
type Darstellung = 'markdown' | 'tabelle' | 'code' | 'klartext'
const endung = (pfad: string) => pfad.split('.').pop()?.toLowerCase() ?? ''
/** Ob wir diese Datei überhaupt anzeigen können.
*
* ENTSCHIEDEN WIRD NACH DER ENDUNG und erst danach nach der gemeldeten Art.
* Der Grund steht in der Datenbank: alles, was vor der erweiterten
* Typtabelle hochgeladen wurde, trägt dort `application/octet-stream`. Wer
* nur auf die Art schaut, zeigt eine alte README nicht an und eine neue schon
* und sucht den Unterschied an der falschen Stelle. */
export function istLesbar(item: MediaItem): boolean {
return (
LESBAR.has(endung(item.path)) ||
item.mime.startsWith('text/') ||
item.mime === 'application/json'
)
}
const LESBAR = new Set([
'md', 'markdown', 'txt', 'log', 'csv', 'json', 'lua', 'xml', 'yml', 'yaml',
'toml', 'ini', 'cfg', 'sql', 'js', 'ts', 'css', 'html', 'sh', 'ps1',
])
const SPRACHE: Record<string, Sprache> = {
lua: 'lua',
json: 'json',
sh: 'bash',
ps1: 'bash',
}
function darstellungFuer(pfad: string): Darstellung {
const e = endung(pfad)
if (e === 'md' || e === 'markdown') return 'markdown'
if (e === 'csv') return 'tabelle'
if (e in SPRACHE) return 'code'
return 'klartext'
}
export function Textansicht({ item }: { item: MediaItem }) {
const [text, setText] = useState<string | null>(null)
const [fehler, setFehler] = useState('')
const [gekappt, setGekappt] = useState(false)
useEffect(() => {
let lebt = true
setText(null)
setFehler('')
setGekappt(false)
const zuGross = item.size > HOECHSTENS
fetch(item.url, zuGross ? { headers: { Range: `bytes=0-${HOECHSTENS - 1}` } } : undefined)
.then(async (r) => {
// Ein Dienst, der Bereichsanfragen NICHT beherrscht, antwortet
// mit 200 und der ganzen Datei. Unserer kann es -- aber die
// Prüfung kostet nichts und der Fehlschlag wäre still.
if (!r.ok && r.status !== 206) throw new Error(`HTTP ${r.status}`)
const roh = await r.text()
if (!lebt) return
setGekappt(zuGross)
setText(zuGross ? roh.slice(0, HOECHSTENS) : roh)
})
.catch((err) => lebt && setFehler(err instanceof Error ? err.message : 'unbekannt'))
return () => {
lebt = false
}
}, [item.url, item.size])
if (fehler) {
return (
<Rahmen>
<div className="flex items-center gap-2 text-sm text-schlecht">
<AlertTriangle size={16} />
Die Datei ließ sich nicht laden: {fehler}
</div>
<a
href={item.url}
download
className="mt-3 inline-flex items-center gap-2 rounded-md border border-rand px-3 py-1.5 text-sm hover:bg-flaeche-hoch"
>
<Download size={15} /> Herunterladen
</a>
</Rahmen>
)
}
if (text === null) {
return (
<Rahmen>
<div className="flex justify-center py-8">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Rahmen>
)
}
const art = darstellungFuer(item.path)
return (
<Rahmen>
{gekappt && (
<p className="mb-3 flex items-center gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn">
<AlertTriangle size={14} className="shrink-0" />
Nur die ersten {bytes(HOECHSTENS)} von {bytes(item.size)} der Rest steht im
Herunterladen.
</p>
)}
{art === 'markdown' ? (
<Markdown text={text} />
) : art === 'tabelle' ? (
<Tabelle text={text} />
) : (
<Zeilen text={text} sprache={SPRACHE[endung(item.path)]} />
)}
</Rahmen>
)
}
function Rahmen({ children }: { children: ReactNode }) {
return (
<div
className="max-h-[calc(100vh-9rem)] w-full max-w-4xl overflow-auto rounded-lg border border-white/15 bg-flaeche p-5 text-left"
// Ohne das rollt der Klick zum Hintergrund durch und schließt den
// Betrachter mitten im Lesen.
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
)
}
/** Klartext und Code mit Zeilennummern.
*
* Die Nummern liegen in einer eigenen Spalte statt am Zeilenanfang: so nimmt
* ein Markieren mit der Maus sie NICHT mit, und ein kopierter Ausschnitt lässt
* sich ohne Nacharbeit einfügen. */
function Zeilen({ text, sprache }: { text: string; sprache?: Sprache }) {
const zeilen = text.split('\n')
return (
<div className="flex gap-3 font-mono text-xs leading-relaxed">
<pre className="shrink-0 text-right text-schrift-leise/40 select-none">
{zeilen.map((_, i) => i + 1).join('\n')}
</pre>
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-words">
<code>{sprache ? faerben(text, sprache) : text}</code>
</pre>
</div>
)
}
/** CSV als Tabelle.
*
* Mit Anführungszeichen und doppelten Anführungszeichen darin das ist der
* Teil, den eine Zerlegung mit `split(';')` falsch macht, und CSV aus Excel
* hat ihn regelmäßig. Trennzeichen wird aus der Kopfzeile geraten: wo mehr
* Semikolons als Kommata stehen, ist es ein Semikolon (deutsches Excel). */
function Tabelle({ text }: { text: string }) {
const zeilen = text.replace(/\r\n?/g, '\n').split('\n').filter((z) => z.length > 0)
const kopf = zeilen[0] ?? ''
const trenner = (kopf.match(/;/g)?.length ?? 0) > (kopf.match(/,/g)?.length ?? 0) ? ';' : ','
const zerlegt = zeilen.slice(0, 2000).map((z) => csvZeile(z, trenner))
const breite = Math.max(...zerlegt.map((z) => z.length), 1)
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b border-rand text-left">
{Array.from({ length: breite }, (_, i) => (
<th key={i} className="px-2 py-1 font-medium whitespace-nowrap">
{zerlegt[0]?.[i] ?? ''}
</th>
))}
</tr>
</thead>
<tbody>
{zerlegt.slice(1).map((z, k) => (
<tr key={k} className="border-b border-rand/40">
{Array.from({ length: breite }, (_, i) => (
<td key={i} className="px-2 py-1 font-mono">
{z[i] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
{zeilen.length > 2000 && (
<p className="mt-2 text-xs text-schrift-leise">
Es werden 2000 von {zeilen.length} Zeilen gezeigt.
</p>
)}
</div>
)
}
function csvZeile(zeile: string, trenner: string): string[] {
const raus: string[] = []
let feld = ''
let inAnfuehrung = false
for (let i = 0; i < zeile.length; i++) {
const z = zeile[i]
if (inAnfuehrung) {
if (z === '"') {
// Zwei Anführungszeichen sind eines im Feld.
if (zeile[i + 1] === '"') {
feld += '"'
i++
} else inAnfuehrung = false
} else feld += z
} else if (z === '"') inAnfuehrung = true
else if (z === trenner) {
raus.push(feld)
feld = ''
} else feld += z
}
raus.push(feld)
return raus
}
@@ -0,0 +1,211 @@
import { useState } from 'react'
import { KeyRound } from 'lucide-react'
import { api, type TokenRow } from '../api'
import { run } from '../store'
import { Feld, Nachfrage } from './Bausteine'
const ARTEN = [
['bild', 'Bilder'],
['video', 'Video'],
['ton', 'Ton'],
['dokument', 'Dokumente'],
['andere', 'Anderes'],
] as const
/** Die Grenzen eines Tokens nachträglich ändern.
*
* WARUM ES DAS BRAUCHT: Kontingent, Arten, Ablauf und Präfix ließen sich nur
* beim **Anlegen** setzen. Wer einem bestehenden Token nachträglich eine
* Grenze geben wollte, musste ihn neu anlegen und damit den Schlüssel in
* jedem Skript tauschen, das ihn benutzt. Für eine Zahl in einer Tabelle ist
* das der falsche Preis.
*
* DER SCHLÜSSEL BLEIBT UNBERÜHRT, und das ist keine Vorsicht: in der Datenbank
* steht nur sein Hash. Er ließe sich gar nicht ändern, ohne einen neuen
* auszugeben und das wäre dann kein Bearbeiten mehr. Steht auch im Dialog,
* damit niemand danach sucht. */
export function TokenBearbeiten({
token,
onAbbruch,
onFertig,
}: {
token: TokenRow
onAbbruch: () => void
onFertig: () => void
}) {
const [name, setName] = useState(token.name)
const [prefix, setPrefix] = useState(token.prefix)
const [canDelete, setCanDelete] = useState(Boolean(token.can_delete))
const [maxMb, setMaxMb] = useState(
token.max_bytes ? String(Math.round((token.max_bytes / 1048576) * 10) / 10) : '',
)
const [quotaMb, setQuotaMb] = useState(
token.quota_bytes ? String(Math.round(token.quota_bytes / 1048576)) : '',
)
const [maxKante, setMaxKante] = useState(token.max_kante ? String(token.max_kante) : '')
const [arten, setArten] = useState<string[]>(
token.arten ? token.arten.split(',').map((a) => a.trim()).filter(Boolean) : [],
)
const [gueltigTage, setGueltigTage] = useState('')
const [laeuft, setLaeuft] = useState(false)
return (
<Nachfrage
offen
titel={`Token „${token.name}" bearbeiten`}
bestaetigung="Speichern"
wartet={laeuft}
gesperrt={!name.trim()}
onNein={onAbbruch}
onJa={async () => {
setLaeuft(true)
const r = await run(
() =>
api.tokenAendern(token.id, {
name: name.trim(),
prefix,
canDelete,
// Leer heißt ausdrücklich „keine Grenze" und nicht
// „nicht angefasst" — sonst ließe sich eine einmal
// gesetzte Grenze nie wieder loswerden.
maxMb: maxMb.trim() || null,
quotaMb: quotaMb.trim() || null,
maxKante: maxKante.trim() || null,
arten,
// Nur schicken, wenn etwas dasteht: ein leeres Feld
// soll den Ablauf NICHT löschen, sondern ihn
// unverändert lassen. Zum Löschen gibt es den Knopf
// darunter.
...(gueltigTage.trim() ? { gueltigTage: gueltigTage.trim() } : {}),
}),
(x) => `${x.name}: ${x.geaendert.length} Angabe(n) geändert.`,
)
setLaeuft(false)
if (r) onFertig()
}}
text={
<div className="flex flex-col gap-3">
<Feld
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<Feld
label="Präfix (leer = überall)"
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
placeholder="vehicles"
className="font-mono"
hinweis="Gilt nur für künftige Uploads — was schon liegt, bleibt liegen."
/>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Größte Datei (MB)"
type="number"
min={0}
step="0.5"
value={maxMb}
placeholder="leer = Grenze des Dienstes"
onChange={(e) => setMaxMb(e.target.value)}
/>
<Feld
label="Kontingent insgesamt (MB)"
type="number"
min={0}
value={quotaMb}
placeholder="leer = unbegrenzt"
onChange={(e) => setQuotaMb(e.target.value)}
/>
</div>
<Feld
label="Bilder verkleinern auf (Pixel)"
type="number"
min={16}
step="64"
value={maxKante}
placeholder="leer = ablegen wie es kommt"
hinweis="Größte Kantenlänge. Kleinere Bilder werden nicht aufgeblasen."
onChange={(e) => setMaxKante(e.target.value)}
/>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={canDelete}
onChange={(e) => setCanDelete(e.target.checked)}
className="mt-0.5 accent-akzent"
/>
<span>
darf auch <strong className="text-schrift">löschen</strong>
<span className="block text-xs text-schrift-leise">
Ohne dieses Recht antwortet <code className="font-mono">DELETE</code>{' '}
mit 403. Das Fotostudio braucht es nicht.
</span>
</span>
</label>
<div>
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Erlaubte Arten (nichts angekreuzt = alle)
</span>
<div className="flex flex-wrap gap-2">
{ARTEN.map(([wert, text]) => (
<label
key={wert}
className="flex items-center gap-1.5 rounded border border-rand
px-2 py-1 text-xs text-schrift-leise"
>
<input
type="checkbox"
checked={arten.includes(wert)}
onChange={(e) =>
setArten((alt) =>
e.target.checked
? [...alt, wert]
: alt.filter((a) => a !== wert),
)
}
className="accent-akzent"
/>
{text}
</label>
))}
</div>
</div>
<Feld
label="Gültigkeit verlängern (Tage ab heute)"
type="number"
min={1}
value={gueltigTage}
placeholder={
token.expires_at
? 'leer = so lassen'
: 'leer = weiterhin unbefristet'
}
onChange={(e) => setGueltigTage(e.target.value)}
hinweis={
token.expires_at
? 'Zählt ab heute, nicht ab dem Anlegen.'
: undefined
}
/>
<p className="flex items-start gap-2 rounded border border-rand bg-grund px-3 py-2 text-xs text-schrift-leise">
<KeyRound size={14} className="mt-0.5 shrink-0" />
<span>
<strong className="text-schrift">Der Schlüssel bleibt derselbe.</strong>{' '}
In der Datenbank steht nur sein Hash er ließe sich gar nicht ändern,
ohne einen neuen auszugeben. Deine Skripte müssen also nichts
anfassen.
</span>
</p>
</div>
}
/>
)
}
+182
View File
@@ -0,0 +1,182 @@
import { useState } from 'react'
import { AlertTriangle, PenLine } from 'lucide-react'
import { api, type MediaItem } from '../api'
import { run } from '../store'
import { Feld, Nachfrage } from './Bausteine'
/** Eine Datei umbenennen.
*
* DER PFAD IST DIE ADRESSE das ist der Satz, an dem dieses ganze Projekt
* haengt, und beim Umbenennen ist er eine Warnung: die alte Adresse steht
* womoeglich in einem Skript, das niemand mehr im Kopf hat, und sie meldet
* sich nicht, wenn sie ins Leere zeigt.
*
* Deshalb steht die Zahl der ABRUFE gross daneben. Eine Datei, die noch nie
* jemand geholt hat, umzubenennen ist harmlos; eine mit viertausend Abrufen
* ist eine Entscheidung. Der Dienst ist die einzige Stelle, die den
* Unterschied kennt also sagt er ihn. */
export function Umbenennen({
datei,
onAbbruch,
onFertig,
}: {
datei: MediaItem
onAbbruch: () => void
onFertig: () => void
}) {
const alt = datei.path.split('/').pop() ?? datei.path
const ordner = datei.path.split('/').slice(0, -1).join('/')
const [name, setName] = useState(alt)
const [laeuft, setLaeuft] = useState(false)
const geaendert = name.trim() !== alt && name.trim().length > 0
// Die Endung mitzuaendern ist erlaubt, aber selten gewollt: sie entscheidet
// ueber den Dateityp, den der Dienst meldet.
const endungAlt = alt.includes('.') ? alt.split('.').pop() : ''
const endungNeu = name.includes('.') ? name.split('.').pop() : ''
const endungWeg = endungAlt !== endungNeu
return (
<Nachfrage
offen
titel="Umbenennen"
bestaetigung="Umbenennen"
wartet={laeuft}
gesperrt={!geaendert}
onNein={onAbbruch}
onJa={async () => {
setLaeuft(true)
const r = await run(
() => api.umbenennen(datei.id, name.trim()),
(x) => `Heisst jetzt ${x.neu}.`,
)
setLaeuft(false)
if (r) onFertig()
}}
text={
<div className="flex flex-col gap-3">
<Feld
label="Neuer Name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
className="font-mono"
/>
<p className="text-xs text-schrift-leise">
Der Ordner bleibt:{' '}
<code className="font-mono">{ordner ? `${ordner}/` : '(Wurzel)'}</code>
</p>
{/* Was daraus wird, STEHT DA vollstaendig und zum Lesen.
Wer eine Adresse aendert, soll die neue sehen, bevor er
drueckt, und nicht danach. */}
{geaendert && (
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
{ordner ? `${ordner}/` : ''}
<span className="text-akzent">{name.trim()}</span>
</p>
)}
{datei.abrufe > 0 && (
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
Diese Datei wurde <strong>{datei.abrufe}×</strong> geholt. Die
alte Adresse gibt danach 404 wer sie in einem Skript stehen
hat, merkt es erst, wenn das Bild fehlt.
</span>
</p>
)}
{geaendert && endungWeg && (
<p className="flex items-start gap-2 text-xs text-schrift-leise">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />
<span>
Die Endung aendert sich von{' '}
<code className="font-mono">.{endungAlt || '—'}</code> auf{' '}
<code className="font-mono">.{endungNeu || '—'}</code>. Damit
aendert sich auch der Typ, den der Dienst meldet.
</span>
</p>
)}
</div>
}
/>
)
}
/** Einen Ordner umbenennen derselbe Vorgang, anderer Hebel.
*
* Hier aendern sich auf einen Schlag ALLE Adressen darin. Deshalb steht die
* Zahl der betroffenen Dateien in der Nachfrage, und zwar bevor gedrueckt
* wird. */
export function OrdnerUmbenennen({
pfad,
dateien,
onAbbruch,
onFertig,
}: {
pfad: string
dateien: number
onAbbruch: () => void
onFertig: (neu: string) => void
}) {
const alt = pfad.split('/').pop() ?? pfad
const oben = pfad.split('/').slice(0, -1).join('/')
const [name, setName] = useState(alt)
const [laeuft, setLaeuft] = useState(false)
const geaendert = name.trim() !== alt && name.trim().length > 0
return (
<Nachfrage
offen
titel="Ordner umbenennen"
bestaetigung="Umbenennen"
wartet={laeuft}
gesperrt={!geaendert}
onNein={onAbbruch}
onJa={async () => {
setLaeuft(true)
const r = await run(
() => api.ordnerUmbenennen(pfad, name.trim()),
(x) => `${x.alt} heisst jetzt ${x.neu} (${x.dateien} Dateien).`,
)
setLaeuft(false)
if (r) onFertig(r.neu)
}}
text={
<div className="flex flex-col gap-3">
<Feld
label="Neuer Name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
className="font-mono"
/>
{geaendert && (
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
{oben ? `${oben}/` : ''}
<span className="text-akzent">{name.trim()}</span>/
</p>
)}
{dateien > 0 && (
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
<strong>{dateien} Dateien</strong> bekommen damit eine neue
Adresse. Jede alte gibt danach 404 und keine davon meldet sich
von selbst.
</span>
</p>
)}
</div>
}
/>
)
}
export const UmbenennenZeichen = PenLine
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
import { api, type Folders, type MediaItem } from '../api'
import { run, useStore } from '../store'
import { Feld, Knopf } from './Bausteine'
/** Dateien in einen anderen Ordner legen.
*
* DIE WARNUNG STEHT OBEN UND NICHT IM KLEINGEDRUCKTEN. Der Pfad IST die
* öffentliche Adresse: wer ein Bild verschiebt, macht jede Stelle ungültig,
* die die alte URL benutzt im Handy, in einer Vorlage, in einem
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
export function Verschieben({
ids,
namen,
ordner,
onFertig,
onAbbruch,
}: {
/** 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
onFertig: () => void
onAbbruch: () => void
}) {
const [ziel, setZiel] = useState('')
const [neuerOrdner, setNeuerOrdner] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [fortschritt, setFortschritt] = useState(0)
const beispiel = namen[0]
const name = beispiel?.path.split('/').pop() ?? ''
const neuerPfad = ziel ? `${ziel}/${name}` : name
async function verschieben() {
setLaeuft(true)
const { note } = useStore.getState()
// IN HAEPPCHEN, wie beim Loeschen: der Dienst verschiebt jede Datei
// einzeln, und tausende in einer Anfrage laufen in die Zeitgrenze des
// Proxys. 200 je Runde, und der Knopf sagt, wie weit es ist.
const PORTION = 200
let bewegt = 0
let daneben: string[] = []
let abbruch: string | null = null
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)
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() {
const pfad = neuerOrdner.trim()
if (!pfad) return
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
if (r) {
setZiel(r.pfad)
setNeuerOrdner('')
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onAbbruch}
>
<div
className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<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" />
{ids.length === 1 ? 'Datei verschieben' : `${ids.length} Dateien verschieben`}
</h3>
<div className="flex flex-col gap-3 px-4 py-4">
<p className="flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-warn" />
<span className="text-schrift">
Der Pfad <strong>ist</strong> die öffentliche Adresse. Nach dem
Verschieben führt die alte URL ins Leere überall, wo sie steht.
</span>
</p>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Zielordner
</span>
<select
value={ziel}
onChange={(e) => setZiel(e.target.value)}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
<option value="">(Wurzel)</option>
{ordner?.items.map((o) => (
<option key={o.folder} value={o.folder}>
{o.folder}
</option>
))}
</select>
</label>
<div className="flex items-end gap-2">
<Feld
label="oder neuen Ordner anlegen"
value={neuerOrdner}
placeholder="z. B. archiv/2026"
className="flex-1"
onChange={(e) => setNeuerOrdner(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void ordnerAnlegen()
}
}}
/>
<Knopf
disabled={!neuerOrdner.trim()}
onClick={() => void ordnerAnlegen()}
kind={<Plus size={15} />}
/>
</div>
{/* Zeigen, WAS daraus wird — nicht nur, wohin es geht. */}
{beispiel && (
<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-gut">{neuerPfad}</div>
{ids.length > 1 && (
<div className="mt-1 text-schrift-leise">
und {ids.length - 1} weitere
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
<Knopf onClick={onAbbruch} kind="Abbrechen" />
<Knopf
art="haupt"
wartet={laeuft}
onClick={() => void 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>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { KeyRound, Link2, Loader2, Settings, UserRound } from 'lucide-react'
import { api, type VerwaltungRow } from '../api'
import { useStore } from '../store'
import { zeit } from '../format'
import { Leer } from './Bausteine'
/** Wer hat wann was EINGESTELLT.
*
* Der Datei-Verlauf daneben beantwortet was ist mit den Dateien passiert".
* Diese Liste beantwortet die andere Frage, die man stellt, wenn etwas anders
* ist als gestern: **wer hat daran gedreht?**
*
* Bei zwei Konten ist das verschmerzbar. Sobald ein drittes dazukommt und
* über die Discord-Rolle entsteht es von selbst ist es die erste Frage.
*
* WERTE STEHEN HIER NIE, nur Namen von Schlüsseln und Zielen. Unter den
* Einstellungen liegen das Discord-Geheimnis und das Nextcloud-Passwort; ein
* Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort
* eine Tabelle mit allen, die es je gab. */
const BEREICHE = {
einstellung: { text: 'Einstellung', kind: Settings, farbe: 'text-akzent' },
token: { text: 'Token', kind: KeyRound, farbe: 'text-warn' },
benutzer: { text: 'Konto', kind: UserRound, farbe: 'text-akzent' },
freigabe: { text: 'Freigabe', kind: Link2, farbe: 'text-gut' },
ordner: { text: 'Ordner', kind: Settings, farbe: 'text-schrift-leise' },
} as const
const TATEN = {
angelegt: { text: 'angelegt', farbe: 'text-gut' },
geaendert: { text: 'geändert', farbe: 'text-warn' },
entfernt: { text: 'entfernt', farbe: 'text-schlecht' },
} as const
export function VerwaltungListe() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<VerwaltungRow[] | null>(null)
useEffect(() => {
let lebt = true
api.verwaltung(200)
.then((r) => lebt && setDaten(r.items))
.catch((err) => lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
return () => {
lebt = false
}
}, [note])
if (!daten) {
return (
<div className="flex justify-center py-12">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (daten.length === 0) {
return (
<Leer
kind={<Settings size={26} className="text-schrift-leise/50" />}
text="Noch nichts verwaltet — oder alles, bevor es diese Liste gab."
/>
)
}
return (
<table className="w-full text-sm">
<colgroup>
<col className="w-36" />
<col className="w-28" />
<col className="w-24" />
<col />
<col className="w-32" />
</colgroup>
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="px-3 pb-2 font-medium">Wann</th>
<th className="px-3 pb-2 font-medium">Bereich</th>
<th className="px-3 pb-2 font-medium">Was</th>
<th className="px-3 pb-2 font-medium">Ziel</th>
<th className="px-3 pb-2 font-medium">Wer</th>
</tr>
</thead>
<tbody>
{daten.map((z) => {
const b = BEREICHE[z.bereich] ?? BEREICHE.ordner
const t = TATEN[z.tat] ?? TATEN.geaendert
const Zeichen = b.kind
return (
<tr key={z.id} className="border-t border-rand">
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
{zeit(z.at)}
</td>
<td className="px-3 py-1.5">
<span className={`flex items-center gap-1.5 text-xs ${b.farbe}`}>
<Zeichen size={13} />
{b.text}
</span>
</td>
<td className={`px-3 py-1.5 text-xs ${t.farbe}`}>{t.text}</td>
<td className="px-3 py-1.5">
<span className="font-mono text-xs">{z.ziel || '—'}</span>
{z.was && (
<span className="block text-[11px] text-schrift-leise">
{z.was}
</span>
)}
</td>
<td className="truncate px-3 py-1.5 text-schrift-leise">{z.wer}</td>
</tr>
)
})}
</tbody>
</table>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from 'react'
import { Link2, Loader2 } from 'lucide-react'
import { api, type Folders } from '../api'
import { run } from '../store'
import { bytes } from '../format'
import { Knopf } from './Bausteine'
/** Eine Datei holen, statt sie hochzuladen.
*
* Der Dienst ruft die Adresse selbst ab. Das spart den Umweg über die
* Festplatte herunterladen, im Dateimanager wiederfinden, ins Fenster
* ziehen , und bei einem einzelnen Bild sind das drei Schritte für nichts.
*
* DASS DAS EINE HEIKLE FUNKTION IST, steht im Server (`holen.ts`): eine
* Adresse, die auf `127.0.0.1` oder ins Heimnetz zeigt, ließe den Dienst Dinge
* lesen, die von außen unerreichbar sind. Geprüft wird dort hier kommt nur
* der Satz zurück, wenn die Prüfung greift, und der ist zum Lesen gemacht. */
export function VonAdresse({
ordner,
ordnerDaten,
fertig,
}: {
ordner: string
ordnerDaten: Folders | null
fertig: () => void
}) {
const [offen, setOffen] = useState(false)
const [url, setUrl] = useState('')
const [ziel, setZiel] = useState(ordner)
const [name, setName] = useState('')
const [laeuft, setLaeuft] = useState(false)
if (!offen) {
return (
<button
onClick={() => {
setZiel(ordner)
setOffen(true)
}}
className="flex items-center gap-1.5 self-start text-xs text-schrift-leise
hover:text-schrift"
>
<Link2 size={13} />
oder von einer Adresse holen
</button>
)
}
async function holen() {
if (!url.trim()) return
setLaeuft(true)
const r = await run(
() => api.hochladenVonAdresse(url.trim(), ziel, name.trim() || undefined),
(x) =>
`${x.path} geholt (${bytes(x.size)})` +
(x.replaced ? ' — ersetzt' : '') +
// Nach einer Umleitung ist das nicht mehr die Adresse, die
// eingetippt wurde. Das gehört gesagt.
(x.von !== url.trim() ? `, von ${x.von}` : ''),
)
setLaeuft(false)
if (r) {
setUrl('')
setName('')
fertig()
}
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-rand bg-flaeche p-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-medium text-schrift">
<Link2 size={13} /> Von einer Adresse holen
</span>
<button
onClick={() => setOffen(false)}
className="text-xs text-schrift-leise hover:text-schrift"
>
zu
</button>
</div>
<input
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !laeuft && void holen()}
placeholder="https://…/bild.png"
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5 text-sm
text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-32 flex-1">
<span className="mb-1 block text-[11px] text-schrift-leise">Ziel</span>
<select
value={ziel}
onChange={(e) => setZiel(e.target.value)}
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
>
<option value="">(Wurzel)</option>
{(ordnerDaten?.items ?? []).map((o) => (
<option key={o.folder} value={o.folder}>
{' '.repeat(o.tiefe * 2)}
{o.folder.split('/').pop()}
</option>
))}
</select>
</label>
<label className="min-w-32 flex-1">
<span className="mb-1 block text-[11px] text-schrift-leise">
Name (freiwillig)
</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="aus der Adresse"
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<Knopf
art="haupt"
disabled={!url.trim() || laeuft}
onClick={() => void holen()}
kind={
laeuft ? (
<>
<Loader2 size={14} className="animate-spin" /> holt
</>
) : (
'Holen'
)
}
/>
</div>
<p className="text-[11px] text-schrift-leise">
Nur <code className="font-mono">http</code> und{' '}
<code className="font-mono">https</code>, nur die Anschlüsse 80 und 443, und die
Adresse darf nicht ins eigene Netz zeigen auch nicht über eine Umleitung.
</p>
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { Link2, Plus, Trash2 } from 'lucide-react'
import { api, type Vorlage } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from './Bausteine'
/** Adressvorlagen verwalten.
*
* Eine Vorlage ist der Bauplan einer öffentlichen Adresse:
* `vehicles/{model}.webp` wird zu `https://…/f/vehicles/adder.webp`.
*
* Wozu speichern? Weil genau diese Zeichenkette auf der anderen Seite
* gebraucht wird im Handy, im Skript, im Fotostudio und weil sie sich
* sonst jemand aus Ordnername plus Endung zusammenreimt. Einmal falsch
* zusammengereimt heißt: das Bild fehlt, und gesucht wird an der falschen
* Stelle. */
export function VorlagenKarte({ publicUrl, filePrefix }: { publicUrl: string; filePrefix: string }) {
const note = useStore((s) => s.note)
const [liste, setListe] = useState<Vorlage[] | null>(null)
const [speichert, setSpeichert] = useState(false)
const [name, setName] = useState('')
const [muster, setMuster] = useState('')
useEffect(() => {
api.einstellungen()
.then((e) => setListe(e.vorlagen))
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
}, [note])
async function sichern(neu: Vorlage[]) {
setSpeichert(true)
const r = await run(() => api.vorlagenSpeichern(neu), 'Vorlagen gespeichert.')
setSpeichert(false)
if (r) setListe(r.vorlagen)
return Boolean(r)
}
if (!liste) return null
return (
<Karte titel="Adressvorlagen">
<div className="flex flex-col gap-3">
{liste.length === 0 ? (
<p className="text-sm text-schrift-leise">
Noch keine. Eine Vorlage sagt, wie die öffentliche Adresse aussieht
etwa <code className="font-mono">vehicles/&#123;model&#125;.webp</code>.
</p>
) : (
<ul className="flex flex-col gap-2">
{liste.map((v, i) => (
<li
key={i}
className="flex items-center gap-2 rounded-md border border-rand bg-grund px-3 py-2"
>
<Link2 size={14} className="shrink-0 text-schrift-leise" />
<span className="w-32 shrink-0 truncate text-sm">{v.name}</span>
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
{publicUrl}
{filePrefix}/{v.muster}
</code>
<button
onClick={() =>
void sichern(liste.filter((_, j) => j !== i))
}
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`${v.name} entfernen`}
>
<Trash2 size={13} />
</button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-end gap-2 border-t border-rand pt-3">
<Feld
label="Name"
value={name}
placeholder="Fahrzeuge"
className="w-40"
onChange={(e) => setName(e.target.value)}
/>
<Feld
label="Muster"
value={muster}
placeholder="vehicles/{model}.webp"
hinweis="Ein Platzhalter in geschweiften Klammern ist Pflicht."
className="min-w-56 flex-1"
onChange={(e) => setMuster(e.target.value)}
/>
<Knopf
art="haupt"
wartet={speichert}
disabled={!name.trim() || !muster.trim()}
onClick={async () => {
if (
await sichern([
...liste,
{ name: name.trim(), muster: muster.trim() },
])
) {
setName('')
setMuster('')
}
}}
kind={
<>
<Plus size={15} />
Hinzufügen
</>
}
/>
</div>
</div>
</Karte>
)
}
+40
View File
@@ -0,0 +1,40 @@
/** Bytes so, wie ein Mensch sie liest. 1024er-Stufen, weil der Rest der
* Werkzeugkette (du, ls, Explorer) das auch tut. */
export function bytes(n: number): string {
if (n < 1024) return `${n} B`
const stufen = ['KB', 'MB', 'GB', 'TB']
let wert = n / 1024
let i = 0
while (wert >= 1024 && i < stufen.length - 1) {
wert /= 1024
i++
}
return `${wert.toFixed(wert < 10 ? 1 : 0)} ${stufen[i]}`
}
/** Zeitstempel des Dienstes sind Millisekunden aus Date.now(). */
export function zeit(ms: number | null): string {
if (!ms) return '—'
return new Date(ms).toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export const istBild = (mime: string) => mime.startsWith('image/')
/** Sekunden als 1:07 oder 1:02:03.
*
* Stunden nur, wenn es welche gibt: "0:01:07" auf einer Kachel ist drei
* Zeichen Laerm fuer eine Auskunft, die niemand gestellt hat. */
export function dauerText(sekunden: number): string {
const ganz = Math.round(sekunden)
const s = ganz % 60
const m = Math.floor(ganz / 60) % 60
const h = Math.floor(ganz / 3600)
const zwei = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${zwei(m)}:${zwei(s)}` : `${m}:${zwei(s)}`
}
+90
View File
@@ -0,0 +1,90 @@
@import 'tailwindcss';
/* Tailwind 4 ist CSS-first: die Gestaltung steht hier, nicht in einer
tailwind.config.js. Was unter @theme steht, wird zu Hilfsklassen aus
--color-grund wird bg-grund, text-grund, border-grund. */
@theme {
--color-grund: #0b0d10;
--color-flaeche: #14181d;
--color-flaeche-hoch: #1c222a;
--color-rand: #2a323c;
--color-schrift: #e6eaf0;
--color-schrift-leise: #93a1b1;
--color-akzent: #4ea3ff;
--color-akzent-tief: #1b4f85;
--color-gut: #3fb950;
--color-schlecht: #f85149;
--color-warn: #d29922;
--font-sans:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Consolas, monospace;
}
html,
body,
#wurzel {
height: 100%;
}
body {
background: var(--color-grund);
color: var(--color-schrift);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Der Fokusrahmen bleibt sichtbar. Ein Dashboard, das sich nur mit der Maus
bedienen lässt, ist bei 900 Kacheln kein Vergnügen. */
:focus-visible {
outline: 2px solid var(--color-akzent);
outline-offset: 2px;
}
::selection {
background: var(--color-akzent-tief);
}
/* Ein ruhiger Verlauf statt einer flachen Flaeche. Er kostet nichts und nimmt
dem Dunkel die Pappigkeit auf einem grossen Monitor sieht ein einfarbiger
Hintergrund aus wie ein Ladefehler. */
body {
background-image: radial-gradient(
1200px 600px at 50% -10%,
color-mix(in oklab, var(--color-akzent) 7%, transparent),
transparent 70%
);
background-attachment: fixed;
}
/* Bildlaufleisten im Ton der Oberflaeche. Die hellen Standardleisten von
Windows reissen in einem dunklen Fenster jedes Mal ein Loch. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-rand) transparent;
}
/* Was neu hereinkommt, blendet auf. Ohne das springen 60 Kacheln auf einmal
ins Bild und man sucht die Stelle, an der man war. */
@keyframes auftauchen {
from {
opacity: 0;
transform: translateY(4px);
}
}
.auftauchen {
animation: auftauchen 0.18s ease-out;
}
/* Wer das nicht will, bekommt es nicht. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
const wurzel = document.getElementById('wurzel')
if (!wurzel) throw new Error('Der Aufhängepunkt #wurzel fehlt in index.html')
createRoot(wurzel).render(
<StrictMode>
<App />
</StrictMode>,
)
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import { api } from './api'
/** Wie der Dienst sich nennt an EINER Stelle.
*
* Vorher stand d4rk_media" viermal fest im Quelltext: in der Kopfzeile, auf
* der Anmeldeseite, unter einer Freigabe und im Tab-Titel. Das ist der
* Repository-Name, nicht der Name des Dienstes und wer ihn ändern wollte,
* musste vier Stellen finden und eine davon übersehen.
*
* Er kommt jetzt vom Server, aus derselben Einstellung, die auch in der
* Fußzeile der Discord-Meldungen steht (`D4RKST3R // MEDIA`). Ein zweiter
* Name für die Oberfläche wäre ein zweiter, der beim nächsten Mal anders
* lautet.
*
* DER STANDARD STEHT HIER TROTZDEM: bis die Antwort da ist, muss etwas in der
* Kopfzeile stehen, und ein leerer Platz, der eine Zehntelsekunde später
* zuspringt, sieht aus wie ein Fehler. */
export const MARKE_STANDARD = 'D4RKST3R'
/** Aus der Marke den Namen des Dienstes: D4RKST3R" D4RKST3R Media".
*
* Die Marke allein wäre falsch sie steht über allem, was hier läuft
* (Bot, Hub, Gitea). Dieser Dienst ist ihr Medienteil, und genau so heißt er
* auch in der Discord-Fußzeile. */
export const dienstName = (marke: string) => `${marke} Media`
/** Der Name für die angemeldete Oberfläche. Holt ihn einmal und merkt ihn sich
* für die Lebensdauer der Seite er ändert sich nicht zwischen zwei Klicks. */
export function useMarke(): string {
const [marke, setMarke] = useState(MARKE_STANDARD)
useEffect(() => {
let lebt = true
api.authConfig()
.then((c) => lebt && c.marke && setMarke(c.marke))
.catch(() => {
/* dann eben der Standard — dafür ist er da */
})
return () => {
lebt = false
}
}, [])
// Der Tab-Titel steht in index.html fest und kann die Einstellung nicht
// kennen. Hier nachzuziehen ist die einzige Stelle, an der das geht.
useEffect(() => {
document.title = dienstName(marke)
}, [marke])
return marke
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
/** Ein Zustand, der einen Neuladevorgang überlebt.
*
* Für ANSICHTSSACHEN gedacht: wie viele Kacheln je Seite, wonach sortiert
* wird. Wer 120 pro Seite einstellt, meint das dauerhaft und nicht bis zum
* nächsten F5.
*
* BEWUSST NICHT für Filter. Ein gemerkter Ordner- oder Artfilter führt zu der
* Frage warum sehe ich nichts", und die Antwort steht dann in einer Leiste,
* auf die niemand schaut, weil er sie nicht selbst gesetzt hat. Was die
* Ansicht formt, wird gemerkt; was die Menge einschränkt, nicht.
*
* localStorage kann werfen im privaten Fenster mancher Browser, oder wenn
* der Speicher voll ist. Dann verhält sich der Haken wie ein gewöhnlicher
* Zustand, statt die Seite mitzureißen. */
export function useMerker<T>(schluessel: string, standard: T) {
// ABSICHTLICH der alte, feste Name und NICHT die Marke: das hier ist der
// Schluessel im Speicher des Browsers. Wer ihn umbenennt, wirft still alle
// gemerkten Ansichtseinstellungen weg -- Seitengroesse, Sortierung -- und
// niemand versteht, warum die Galerie ploetzlich wieder bei 60 Kacheln
// steht.
const voll = `d4rk_media.${schluessel}`
const [wert, setWert] = useState<T>(() => {
try {
const roh = localStorage.getItem(voll)
return roh === null ? standard : (JSON.parse(roh) as T)
} catch {
return standard
}
})
useEffect(() => {
try {
localStorage.setItem(voll, JSON.stringify(wert))
} catch {
/* dann eben nur für diese Sitzung */
}
}, [voll, wert])
return [wert, setWert] as const
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState, type FormEvent } from 'react'
import { HardDrive } from 'lucide-react'
import { api } from '../api'
import { useStore } from '../store'
import { Feld, Knopf } from '../components/Bausteine'
import { dienstName, MARKE_STANDARD } from '../marke'
export function Anmelden() {
const login = useStore((s) => s.login)
const note = useStore((s) => s.note)
const [name, setName] = useState('admin')
const [passwort, setPasswort] = useState('')
const [wartet, setWartet] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [discord, setDiscord] = useState(false)
const [marke, setMarke] = useState(MARKE_STANDARD)
// Ob der Discord-Knopf ueberhaupt Sinn hat, weiss nur der Dienst. Ein
// Knopf, der zu einer Fehlerseite fuehrt, weil nichts eingerichtet ist,
// ist genau die Sorte Knopf, die wir hier nicht bauen.
useEffect(() => {
api.authConfig()
.then((c) => {
setDiscord(c.discord)
if (c.marke) setMarke(c.marke)
})
.catch(() => setDiscord(false))
}, [])
// Der Weg ueber Discord endet bei einem Abbruch wieder hier, mit dem Grund
// im Adressfeld. Ohne diese Zeilen laege er dort und niemand saehe ihn.
useEffect(() => {
const grund = new URLSearchParams(window.location.search).get('discord')
if (!grund) return
setFehler(grund)
note('fehler', grund)
window.history.replaceState(null, '', window.location.pathname)
}, [note])
async function absenden(e: FormEvent) {
e.preventDefault()
setFehler(null)
setWartet(true)
try {
await login(name, passwort)
} catch (err) {
// Der Fehler steht AM FORMULAR und nicht nur in einer Meldung, die
// nach vier Sekunden geht: hier schaut man hin, wenn etwas nicht
// klappt. Der Text kommt vom Dienst ("Benutzername oder Passwort
// stimmt nicht"), nicht von hier.
const text = err instanceof Error ? err.message : 'Unbekannter Fehler'
setFehler(text)
note('fehler', text)
} finally {
setWartet(false)
}
}
return (
<div className="relative flex min-h-full items-center justify-center overflow-hidden p-4">
{/* Zwei weiche Lichter im Hintergrund. Sie kosten nichts, brauchen
kein Bild und machen aus dem leeren Dunkel eine Seite. */}
<div
aria-hidden
className="pointer-events-none absolute -top-40 left-1/2 h-96 w-[36rem] -translate-x-1/2
rounded-full bg-akzent/20 blur-[100px]"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-48 right-1/4 h-80 w-96 rounded-full
bg-akzent-tief/30 blur-[100px]"
/>
<form
onSubmit={absenden}
className="relative w-full max-w-sm rounded-xl border border-rand bg-flaeche/80
p-7 shadow-2xl shadow-black/40 backdrop-blur"
>
<div className="mb-1 flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-akzent/15 text-akzent">
<HardDrive size={20} />
</span>
<h1 className="text-lg font-semibold">{dienstName(marke)}</h1>
</div>
<p className="mb-6 text-xs text-schrift-leise">
Bilder, Videos und Ton für die eigenen Ressourcen.
</p>
<div className="flex flex-col gap-3">
<Feld
label="Benutzername"
value={name}
autoComplete="username"
onChange={(e) => setName(e.target.value)}
/>
<Feld
label="Passwort"
type="password"
value={passwort}
autoComplete="current-password"
onChange={(e) => setPasswort(e.target.value)}
/>
</div>
{fehler && (
<p className="mt-3 rounded-md border border-schlecht/40 bg-schlecht/10 px-3 py-2 text-sm text-schlecht">
{fehler}
</p>
)}
<Knopf
art="haupt"
type="submit"
wartet={wartet}
disabled={!name || !passwort}
className="mt-5 w-full"
kind="Anmelden"
/>
<p className="mt-4 text-center text-[11px] text-schrift-leise">
Nach fünf Fehlversuchen wird kurz gesperrt.
</p>
{discord && (
<>
<div className="my-4 flex items-center gap-3 text-xs text-schrift-leise">
<span className="h-px flex-1 bg-rand" />
oder
<span className="h-px flex-1 bg-rand" />
</div>
{/* Ein echtes <a> und kein fetch: der Weg fuehrt zu
Discord und wieder zurueck, das ist eine
Seitennavigation und keine Anfrage im Hintergrund. */}
<a
href="/api/dash/auth/discord"
className="flex w-full items-center justify-center gap-2 rounded-md
border border-[#5865F2] bg-[#5865F2] px-3 py-1.5 text-sm
font-medium text-white transition-colors hover:bg-[#4752c4]"
>
<svg viewBox="0 0 127 96" className="h-4 w-4" fill="currentColor" aria-hidden>
<path d="M107 8A105 105 0 0 0 81 0l-3 7a97 97 0 0 0-29 0l-4-7a105 105 0 0 0-26 8C2 33-1 57 1 81a106 106 0 0 0 32 16l7-11c-4-1-8-3-11-5l3-2a75 75 0 0 0 64 0l3 2c-4 2-8 4-12 5l7 11a106 106 0 0 0 32-16c3-28-2-52-19-73ZM43 66c-6 0-11-6-11-13s5-13 11-13 12 6 11 13c0 7-5 13-11 13Zm42 0c-6 0-11-6-11-13s5-13 11-13 11 6 11 13c0 7-5 13-11 13Z" />
</svg>
Mit Discord anmelden
</a>
</>
)}
</form>
</div>
)
}
+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useState } from 'react'
import { Check, Copy, Download, FileJson, FileSpreadsheet, FileCode2 } from 'lucide-react'
import { api, type Uebersicht } from '../api'
import { useStore } from '../store'
import { Karte, useKopieren } from '../components/Bausteine'
import { Code } from '../components/Code'
import { BookOpen, ExternalLink } from 'lucide-react'
/** Anleitung und Export für die eigenen Skripte.
*
* Der Punkt dieser Seite: die Beispiele stehen hier MIT DER ECHTEN ADRESSE
* drin, nicht mit `https://example.com`. Wer etwas abschreibt, schreibt damit
* auch keinen Tippfehler ab und das ist die häufigste Fehlerquelle beim
* Anbinden eines Dienstes.
*
* Der Token steht bewusst NICHT hier. Er ist nach dem Anlegen nicht mehr
* lesbar, auch nicht für den Dienst; im Beispiel steht deshalb ein
* Platzhalter. */
export function Api() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Uebersicht | null>(null)
const { kopiert, kopieren } = useKopieren()
useEffect(() => {
api.uebersicht()
.then(setDaten)
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
}, [note])
if (!daten) return null
const B = daten.publicUrl
const F = `${daten.publicUrl}${daten.filePrefix}`
async function kopiereBlock(text: string, marke: string) {
if (!(await kopieren(text, marke))) note('fehler', 'Die Zwischenablage ist gesperrt.')
}
return (
<div className="flex flex-col gap-4">
{/* Die ausfuehrliche Fassung liegt im Wiki. HIER steht das, was man
beim Anbinden in der Hand haben muss dort das Drumherum, das
sich in einer Karte nicht lesen laesst. */}
<a
href="https://git.d4rkst3r.de/D4rkst3r/d4rk_media/wiki"
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-lg border border-rand bg-flaeche px-4 py-3
text-sm transition-colors hover:border-akzent"
>
<BookOpen size={18} className="shrink-0 text-akzent" />
<span className="flex-1">
Die ausführliche Anleitung im Wiki
<span className="block text-xs text-schrift-leise">
Alle Endpunkte, Fehlermeldungen, Grenzen und was beim Anbinden
schiefgehen kann.
</span>
</span>
<ExternalLink size={15} className="shrink-0 text-schrift-leise" />
</a>
<Karte titel="Export">
<div className="flex flex-wrap items-center gap-2">
{(
[
['json', 'JSON', FileJson, 'Alles, was der Dienst weiß'],
['csv', 'CSV', FileSpreadsheet, 'für Tabellen (Semikolon, für Excel)'],
['lua', 'Lua', FileCode2, 'fertige Tabelle Name → Adresse'],
] as const
).map(([f, text, Symbol, hinweis]) => (
<a
key={f}
href={`/api/dash/export?format=${f}`}
className="flex items-center gap-2 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
title={hinweis}
>
<Symbol size={15} />
{text}
<Download size={13} className="opacity-60" />
</a>
))}
<p className="text-xs text-schrift-leise">
{daten.files} Einträge. Die Lua-Fassung liest man in einer Resource als{' '}
<code className="font-mono">Urls.adder</code> aus.
</p>
</div>
</Karte>
<Karte titel="Hochladen aus einer FiveM-Resource">
<p className="mb-2 text-xs text-schrift-leise">
Server-Seite, nicht NUI: die NUI läuft unter https und dürfte eine
http-Adresse gar nicht aufrufen. Base64, weil{' '}
<code className="font-mono">PerformHttpRequest</code> den Rumpf als
Lua-Zeichenkette nimmt und ein Bild voller Nullbytes ist.
</p>
<Code
sprache="lua"
marke="lua-upload"
text={`local TOKEN = 'd4rk_…' -- im Dashboard unter Token anlegen
local function hochladen(pfad, bytes, cb)
PerformHttpRequest('${B}/api/upload', function(status, body)
if status ~= 200 then return cb(nil, body) end
cb(json.decode(body).url)
end, 'POST', b64(bytes), {
['Authorization'] = 'Bearer ' .. TOKEN,
['X-Path'] = pfad, -- z. B. vehicles/adder.webp
['X-Encoding'] = 'base64',
['Content-Type'] = 'text/plain; charset=utf-8',
})
end`}
/>
</Karte>
<Karte titel="Die drei Wege der Skript-API">
<div className="flex flex-col gap-3">
<Code
sprache="bash"
marke="curl-upload"
text={`# Hochladen — rohe Bytes, X-Path bestimmt die spätere Adresse
curl -X POST ${B}/api/upload \\
-H "Authorization: Bearer d4rk_…" \\
-H "X-Path: vehicles/adder.webp" \\
--data-binary @adder.webp
# {"url":"${F}/vehicles/adder.webp","size":,"sha256":,"replaced":false}`}
/>
<Code
sprache="bash"
marke="curl-exists"
text={`# Gibt es das schon? — für "nur fehlende" in einem Serienlauf
curl -H "Authorization: Bearer d4rk_…" \\
${B}/api/exists?pfad=vehicles/adder.webp
# {"exists":true,"url":"${F}/vehicles/adder.webp","size":}`}
/>
<Code
sprache="bash"
marke="curl-delete"
text={`# Löschen — der Token braucht dafür das Recht "darf löschen"
curl -X DELETE -H "Authorization: Bearer d4rk_…" \\
${B}/api/media/vehicles/adder.webp`}
/>
</div>
</Karte>
<Karte titel="Kopfzeilen beim Hochladen">
<table className="w-full text-sm">
<colgroup>
<col className="w-36" />
<col />
</colgroup>
<tbody>
{(
[
['X-Path', 'Wohin die Datei kommt. Ohne ihn wird aus dem Hash ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar.'],
['X-Encoding', '„base64", wenn der Rumpf base64-Text ist. Data-URL-Vorspann wird abgeschnitten.'],
['X-Overwrite', '„false" verweigert das Überschreiben und antwortet mit 409.'],
['X-Mime', 'Überschreibt die Art. Sonst entscheidet die Endung.'],
] as const
).map(([k, t]) => (
<tr key={k} className="border-t border-rand first:border-0">
<td className="py-2 pr-3 align-top font-mono text-xs">{k}</td>
<td className="py-2 text-schrift-leise">{t}</td>
</tr>
))}
</tbody>
</table>
</Karte>
<Karte titel="Adressen">
<div className="flex flex-col gap-2 text-sm">
{daten.vorlagen.length === 0 && (
<p className="text-xs text-schrift-leise">
Noch keine Vorlage angelegt das geht unter Einstellungen.
</p>
)}
{daten.vorlagen.map((v, i) => (
<div key={i} className="flex items-center gap-2">
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
{v.name}
</span>
<code className="min-w-0 flex-1 truncate rounded border border-rand bg-grund px-2 py-1 font-mono text-xs">
{F}/{v.muster}
</code>
<button
onClick={() => void kopiereBlock(`${F}/${v.muster}`, 'vl' + i)}
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Kopieren"
>
{kopiert === 'vl' + i ? (
<Check size={13} className="text-gut" />
) : (
<Copy size={13} />
)}
</button>
</div>
))}
</div>
</Karte>
</div>
)
}
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react'
import {
AlertTriangle,
CheckCircle2,
CloudUpload,
HardDrive,
Loader2,
ShieldCheck,
ShieldX,
} from 'lucide-react'
import { api, type Einstellungen as EinstellungenDaten } from '../api'
import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
import { VorlagenKarte } from '../components/VorlagenKarte'
import { SicherungenKarte } from '../components/SicherungenKarte'
import { FreigabenKarte } from '../components/FreigabenKarte'
import { BenutzerKarte } from '../components/BenutzerKarte'
import { MitnehmenKarte } from '../components/MitnehmenKarte'
import { MeldungenKarte } from '../components/MeldungenKarte'
/** Alles, was sich einstellen lässt, an einer Stelle.
*
* Bisher lag die Discord-Karte beim Konto und die Zugangsdaten der Sicherung
* in einer Textdatei auf der Platte. Beides ist jetzt hier und das ist mehr
* als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht
* gelaufen ist. Diese Seite tut es. */
export function Einstellungen() {
const [urls, setUrls] = useState({ publicUrl: '', filePrefix: '' })
// Die Vorlagenkarte zeigt die FERTIGE Adresse und nicht nur das Muster —
// dafuer braucht sie den Ursprung, und der steht in der Uebersicht.
useEffect(() => {
api.uebersicht()
.then((u) => setUrls({ publicUrl: u.publicUrl, filePrefix: u.filePrefix }))
.catch(() => {})
}, [])
return (
<div className="flex flex-col gap-4">
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
<FreigabenKarte />
<BenutzerKarte />
<MitnehmenKarte />
<SicherungKarte />
<SicherungenKarte />
<MeldungenKarte />
<DiscordKarte />
</div>
)
}
function SicherungKarte() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<EinstellungenDaten | null>(null)
const [laedt, setLaedt] = useState(true)
const [speichert, setSpeichert] = useState(false)
const [basis, setBasis] = useState('')
const [benutzer, setBenutzer] = useState('')
const [passwort, setPasswort] = useState('')
const [ordner, setOrdner] = useState('')
const [behalten, setBehalten] = useState('14')
const [zweitziel, setZweitziel] = useState('')
async function laden() {
setLaedt(true)
try {
const e = await api.einstellungen()
setDaten(e)
setBasis(e.sicherung.basis)
setBenutzer(e.sicherung.benutzer)
setOrdner(e.sicherung.ordner)
setBehalten(String(e.sicherung.behalten))
setZweitziel(e.sicherung.zweitziel)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function speichern() {
setSpeichert(true)
const ergebnis = await run(
() =>
api.sicherungSpeichern({
basis,
benutzer,
...(passwort ? { passwort } : {}),
ordner,
behalten: Number(behalten),
zweitziel,
}),
(r) =>
r.bereit
? 'Gespeichert. Die nächste Sicherung geht in die Nextcloud.'
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
)
setSpeichert(false)
if (ergebnis) {
setPasswort('')
void laden()
}
}
if (laedt) {
return (
<Karte titel="Sicherung">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
if (!daten) return null
const s = daten.sicherung
const l = s.letzte
return (
<Karte
titel="Sicherung"
rechts={
s.bereit ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
<ShieldCheck size={14} /> eingerichtet
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
<ShieldX size={14} /> nur lokal
</span>
)
}
>
<div className="flex flex-col gap-4">
{/* Das Wichtigste zuerst: LIEF sie? Eine Sicherung, die still
aufhoert zu laufen, ist der eigentliche Schaden. */}
<div
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${
!l
? 'border-rand bg-grund text-schrift-leise'
: l.ok
? 'border-gut/40 bg-gut/10'
: 'border-schlecht/40 bg-schlecht/10'
}`}
>
{!l ? (
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
) : l.ok ? (
<CheckCircle2 size={16} className="mt-0.5 shrink-0 text-gut" />
) : (
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
)}
<span className="text-schrift">
{!l ? (
'Noch keine Sicherung gemeldet. Sie läuft täglich um 04:30 als geplante Aufgabe.'
) : (
<>
Zuletzt {zeit(l.at)} {l.ok ? 'erfolgreich' : 'FEHLGESCHLAGEN'}
{l.dateien != null && `, ${l.dateien} Dateien`}
{l.groesse != null && `, ${bytes(l.groesse)}`}
{l.hoch === true && ', in der Nextcloud'}
{l.hoch === false && ', nur lokal'}
{l.zweit === true && ', auf der zweiten Platte'}
{l.fehler && (
<span className="block text-xs text-schlecht">{l.fehler}</span>
)}
</>
)}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Nextcloud-Adresse"
value={basis}
placeholder="https://cdn.d4rkst3r.de"
onChange={(e) => setBasis(e.target.value)}
/>
<Feld
label="Benutzername"
value={benutzer}
placeholder="wie beim Anmelden in der Nextcloud"
onChange={(e) => setBenutzer(e.target.value)}
/>
<Feld
label="App-Passwort"
type="password"
value={passwort}
placeholder={s.hatPasswort ? '•••••••• (hinterlegt)' : 'noch keines'}
hinweis='leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
onChange={(e) => setPasswort(e.target.value)}
/>
<Feld
label="Ordner"
value={ordner}
placeholder="Sicherungen/d4rk_media"
onChange={(e) => setOrdner(e.target.value)}
/>
<Feld
label="Wie viele behalten"
type="number"
min={1}
max={365}
value={behalten}
hinweis="gilt überall gleich"
onChange={(e) => setBehalten(e.target.value)}
/>
<Feld
label="Zweites Ziel (andere Platte)"
value={zweitziel}
placeholder={'D:\\backup\\d4rk_media'}
className="font-mono"
hinweis="Pfad auf dem Server, leer lassen heißt: nur ein lokales Ziel."
onChange={(e) => setZweitziel(e.target.value)}
/>
</div>
<p className="flex items-start gap-2 text-xs text-schrift-leise">
<HardDrive size={14} className="mt-0.5 shrink-0" />
<span>
<strong className="text-schrift">
Die zwei Ziele beantworten zwei verschiedene Fragen.
</strong>{' '}
Die Nextcloud hilft, wenn der ganze Rechner weg ist aber wer 100 GB
zurückholen muss, lädt sie über die Leitung. Das zweite Ziel liegt im
Rechner und ist in Minuten zurückgespielt; es hilft nur, wenn es auf einer{' '}
<strong className="text-schrift">anderen Platte</strong> liegt als die
Daten. Ein zweiter Ordner auf derselben Platte sieht aus wie eine
Sicherung und ist keine.
</span>
</p>
<p className="flex items-start gap-2 text-xs text-schrift-leise">
<CloudUpload size={14} className="mt-0.5 shrink-0" />
<span>
Nimm ein <strong>App-Passwort</strong>, nicht dein echtes: Nextcloud
Einstellungen Sicherheit Neues App-Passwort erstellen". Es lässt
sich einzeln zurückziehen. Das Skript liest die Werte direkt aus der
Datenbank des Dienstes über HTTP geht das Passwort nur hier beim
Eintragen, und das über TLS.
</span>
</p>
<div>
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
</div>
</div>
</Karte>
)
}
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react'
import { FileQuestion, Film, Image, Loader2, Music, Play } from 'lucide-react'
import { bytes, dauerText, istBild } from '../format'
import { dienstName, MARKE_STANDARD } from '../marke'
/** Ein freigegebener Ordner ohne Anmeldung.
*
* DIESE SEITE STEHT AUSSERHALB der angemeldeten Oberfläche und weiß nichts von
* ihr: kein Store, keine Reiter, kein Kontomenü. Wer hierherkommt, hat einen
* Link bekommen und will Bilder sehen.
*
* Sie zeigt ABSICHTLICH weniger als die Galerie: keine Pfade, keine Zeitpunkte,
* keine Abrufzahlen, keine Knöpfe. Was ein Katalog braucht, ist das Bild, der
* Name und die Adresse zum Kopieren. */
type Eintrag = {
name: string
size: number
mime: string
dauer: number | null
url: string
thumb: string
}
type Seite = { marke: string; titel: string; ordner: string; anzahl: number; items: Eintrag[] }
export function Freigabe({ schluessel }: { schluessel: string }) {
const [daten, setDaten] = useState<Seite | null>(null)
const [fehler, setFehler] = useState('')
const [gross, setGross] = useState<Eintrag | null>(null)
useEffect(() => {
let lebt = true
fetch(`/api/s/${encodeURIComponent(schluessel)}`)
.then(async (r) => {
if (!r.ok) throw new Error(r.status === 404 ? 'nicht gefunden' : `HTTP ${r.status}`)
return r.json()
})
.then((d) => lebt && setDaten(d))
.catch((e) => lebt && setFehler(e instanceof Error ? e.message : 'unbekannt'))
return () => {
lebt = false
}
}, [schluessel])
if (fehler) {
return (
<div className="flex min-h-screen items-center justify-center bg-grund p-6">
<div className="max-w-sm text-center">
<FileQuestion size={34} className="mx-auto text-schrift-leise/50" />
<p className="mt-3 text-sm text-schrift">Diesen Link gibt es nicht.</p>
<p className="mt-1 text-xs text-schrift-leise">
Er wurde zurückgezogen, oder er war nie einer. Beides sieht von hier
gleich aus und das ist Absicht.
</p>
</div>
</div>
)
}
if (!daten) {
return (
<div className="flex min-h-screen items-center justify-center bg-grund">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
return (
<div className="min-h-screen bg-grund">
<header className="border-b border-rand bg-flaeche">
<div className="mx-auto w-full max-w-7xl px-4 py-4">
<h1 className="text-lg font-semibold text-schrift">{daten.titel}</h1>
<p className="mt-0.5 text-xs text-schrift-leise">
{daten.anzahl} {daten.anzahl === 1 ? 'Datei' : 'Dateien'} ·{' '}
{bytes(daten.items.reduce((s, i) => s + i.size, 0))}
</p>
</div>
</header>
<main className="mx-auto w-full max-w-7xl px-4 py-5">
<div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
{daten.items.map((i) => (
<figure
key={i.url}
className="overflow-hidden rounded-lg border border-rand bg-flaeche"
>
<button
onClick={() => setGross(i)}
className="block w-full"
aria-label={`${i.name} groß ansehen`}
>
<div className="relative flex aspect-square items-center justify-center bg-grund">
<Kachel eintrag={i} />
</div>
</button>
<figcaption className="border-t border-rand p-2">
<p
className="truncate font-mono text-[11px] text-schrift"
title={i.name}
>
{i.name}
</p>
<p className="text-[11px] text-schrift-leise">{bytes(i.size)}</p>
</figcaption>
</figure>
))}
</div>
<p className="mt-6 text-center text-[11px] text-schrift-leise">
Bereitgestellt mit {dienstName(daten.marke || MARKE_STANDARD)}
</p>
</main>
{gross && (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/85 p-4"
onClick={() => setGross(null)}
>
<div onClick={(e) => e.stopPropagation()} className="max-h-full">
{gross.mime.startsWith('video') ? (
<video
src={gross.url}
controls
autoPlay
className="max-h-[80vh] max-w-full rounded"
/>
) : gross.mime.startsWith('audio') ? (
<audio src={gross.url} controls autoPlay className="w-80" />
) : (
<img
src={gross.url}
alt={gross.name}
className="max-h-[80vh] max-w-full object-contain"
/>
)}
</div>
<p className="font-mono text-xs text-schrift-leise">{gross.name}</p>
</div>
)}
</div>
)
}
/** Dieselbe Regel wie in der Galerie: erst die Vorschau, dann ein Zeichen, das
* die Art benennt. Ein Fragezeichen auf einer gesunden MP4 sieht aus wie ein
* Fehler und hier sieht es ein Gast. */
function Kachel({ eintrag }: { eintrag: Eintrag }) {
const [weg, setWeg] = useState(false)
const bewegt = eintrag.mime.startsWith('video') || eintrag.mime.startsWith('audio')
if (!weg) {
return (
<>
<img
src={eintrag.thumb}
onError={(e) => {
const bild = e.currentTarget
if (istBild(eintrag.mime) && !bild.dataset.gefallen) {
bild.dataset.gefallen = 'ja'
bild.src = eintrag.url
return
}
setWeg(true)
}}
alt={eintrag.name}
loading="lazy"
className="h-full w-full object-contain"
/>
{bewegt && (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/55 p-2">
<Play size={14} className="translate-x-[1px] fill-white text-white" />
</span>
</span>
)}
{eintrag.dauer !== null && eintrag.dauer > 0 && (
<span className="pointer-events-none absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[11px] tabular-nums text-white">
{dauerText(eintrag.dauer)}
</span>
)}
</>
)
}
const Zeichen = eintrag.mime.startsWith('video')
? Film
: eintrag.mime.startsWith('audio')
? Music
: istBild(eintrag.mime)
? Image
: FileQuestion
return <Zeichen size={26} className="text-schrift-leise/40" />
}
+889
View File
@@ -0,0 +1,889 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Check,
ChevronLeft,
ChevronRight,
Copy,
ExternalLink,
FileQuestion,
ArrowDownUp,
FileText,
Film,
FolderInput,
Layers,
Music,
ImageOff,
Loader2,
Play,
RefreshCw,
Search,
Trash2,
X,
} from 'lucide-react'
import {
api,
vorschauUrl,
type Art,
type Arten,
type Folders,
type MediaItem,
type MediaPage,
type Richtung,
type Sortierung,
} from '../api'
import { run, useStore } from '../store'
import { bytes, dauerText, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
import { Hochladen } from '../components/Hochladen'
import { Ansehen } from '../components/Ansehen'
import { Verschieben } from '../components/Verschieben'
import { OrdnerUmbenennen, Umbenennen } from '../components/Umbenennen'
import { useAuffrischen } from '../auffrischen'
import { useMerker } from '../merker'
const SEITENGROESSEN = [30, 60, 120, 200] as const
export function Galerie() {
const note = useStore((s) => s.note)
const [suche, setSuche] = useState('')
const [ordner, setOrdner] = useState('')
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
const [seite, setSeite] = useState(0)
const [art, setArt] = useState<Art>('')
const [arten, setArten] = useState<Arten | null>(null)
// Ansichtssachen ueberleben das Neuladen -- wer 120 einstellt, meint das
// dauerhaft und nicht bis zum naechsten F5.
const [proSeite, setProSeite] = useMerker('galerie.proSeite', 60)
const [sortierung, setSortierung] = useMerker<Sortierung>('galerie.sort', 'geaendert')
const [richtung, setRichtung] = useMerker<Richtung>('galerie.dir', 'ab')
const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
/** 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)
// 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.
const [ansehen, setAnsehen] = useState<number | 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 [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
const { kopiert, kopieren } = useKopieren()
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
// eine Anfrage, und bei "adder" sind das fünf, von denen vier veraltet
// ankommen können.
const [sucheAktiv, setSucheAktiv] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setSucheAktiv(suche)
setSeite(0)
}, 250)
return () => clearTimeout(t)
}, [suche])
// Welche Anfrage die neueste ist. Eine ältere, die später ankommt, darf
// die neuere nicht überschreiben — sonst steht nach schnellem Tippen das
// Ergebnis von vorletzter Eingabe auf dem Schirm.
const lauf = useRef(0)
const laden = useCallback(async () => {
const meine = ++lauf.current
setLaedt(true)
try {
const seiteDaten = await api.media(
sucheAktiv,
ordner,
art,
proSeite,
seite * proSeite,
sortierung,
richtung,
)
if (meine !== lauf.current) return
setDaten(seiteDaten)
} catch (err) {
if (meine !== lauf.current) return
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
if (meine === lauf.current) setLaedt(false)
}
}, [sucheAktiv, ordner, art, seite, proSeite, sortierung, richtung, note])
useEffect(() => {
void laden()
}, [laden])
// Die Ordnerzahlen aendern sich beim Loeschen und beim Hochladen. Sie
// haengen deshalb am selben Auslöser wie die Liste -- eine Uebersicht, die
// "21 Dateien" behauptet, waehrend daneben 19 stehen, ist schlimmer als
// keine.
// Die Zahlen an den Knoepfen haengen an Suche und Ordner, aber NICHT an
// der gewaehlten Art: sonst zeigte "Video (3)" nach dem Klick "Video (3)"
// und alle anderen 0.
const artenLaden = useCallback(async () => {
try {
setArten(await api.arten(sucheAktiv, ordner))
} catch {
// Die Zahlen sind Beiwerk. Faellt der Aufruf aus, bleiben die
// Knoepfe ohne Zahl statt die Seite mit einem Fehler zu fluten.
}
}, [sucheAktiv, ordner])
useEffect(() => {
void artenLaden()
}, [artenLaden])
const ordnerLaden = useCallback(async () => {
try {
setOrdnerDaten(await api.folders())
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}, [note])
useEffect(() => {
void ordnerLaden()
}, [ordnerLaden])
const allesLaden = useCallback(() => {
void laden()
void ordnerLaden()
void artenLaden()
}, [laden, ordnerLaden, artenLaden])
// Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt
// es nur den Hinweis und keinen Neuaufbau unter den Haenden.
const frisch = useAuffrischen({
laden: allesLaden,
blockiert: gewaehlt.size > 0 || ansehen !== null || loeschen !== null,
})
const items = daten?.items ?? []
const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
function umschalten(id: number) {
setGewaehlt((alt) => {
const neu = new Set(alt)
if (neu.has(id)) neu.delete(id)
else neu.add(id)
return neu
})
}
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
async function alleTrefferWaehlen() {
const antwort = await run(
() => api.medienIds(sucheAktiv, ordner, art),
(r) =>
r.gekappt
? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.`
: `${r.ids.length} gewählt.`,
)
if (antwort) setGewaehlt(new Set(antwort.ids))
}
function alleUmschalten() {
setGewaehlt((alt) => {
const neu = new Set(alt)
if (alleGewaehlt) items.forEach((i) => neu.delete(i.id))
else items.forEach((i) => neu.add(i.id))
return neu
})
}
async function loeschenAusfuehren() {
if (!loeschen) return
setLoescht(true)
setLoeschFortschritt(0)
// IN HAEPPCHEN, nicht in einem Zug.
//
// Der Dienst verschiebt jede Datei einzeln in den Papierkorb. Bei 3565
// Bildern ist das eine Anfrage, die Minuten laeuft -- und die faellt
// beim Proxy in die Zeitgrenze, lange bevor sie fertig ist. Dann waere
// die Haelfte geloescht und die Oberflaeche meldete einen Fehler.
//
// 200 je Runde: gross genug, dass es nicht an der Anzahl der Anfragen
// haengt, klein genug fuer eine Antwort in Sekunden. Und nach jeder
// Runde steht im Knopf, wie weit es ist -- bei etwas, das eine Minute
// dauert, ist Stillstand nicht von Haengen zu unterscheiden.
const { note } = useStore.getState()
const PORTION = 200
const alle = loeschen.ids
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)
setLoeschen(null)
if (ergebnis) {
setGewaehlt(new Set())
void laden()
void ordnerLaden()
void frisch.merken()
}
}
async function adresseKopieren(item: MediaItem) {
const ok = await kopieren(item.url, String(item.id))
if (!ok) note('fehler', 'Die Zwischenablage ist gesperrt — nur über https erreichbar.')
}
return (
// Ab 2xl (1536 px) rueckt die Ordnerspalte nach LINKS AUS dem
// Hauptbereich heraus: 15rem Spalte + 1rem Abstand = die 16rem, um die
// hier nach links gezogen wird. Damit steht das Raster auf genau
// derselben Breite wie jede andere Seite.
//
// Darunter stapelt es wie bisher — auf einem 1280er Schirm waere kein
// Rand da, in den etwas ruecken koennte.
<div className="flex flex-col gap-4 lg:flex-row 2xl:-ml-64">
<Ordner
daten={ordnerDaten}
gewaehlt={ordner}
onAngelegt={() => void ordnerLaden()}
onEntfernt={() => void ordnerLaden()}
onUmbenennen={setOrdnerUmbenennen}
onWahl={(o) => {
setOrdner(o)
setSeite(0)
setGewaehlt(new Set())
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Nach Art filtern. Die Zahlen stehen dabei ein Knopf "Video",
hinter dem nichts liegt, kostet einen Klick, um das
herauszufinden. */}
<div className="flex flex-wrap items-center gap-1.5">
{(
[
['', 'Alles', Layers],
['bild', 'Bilder', ImageOff],
['video', 'Video', Film],
['ton', 'Ton', Music],
['dokument', 'Dokumente', FileText],
['andere', 'Anderes', FileQuestion],
] as const
).map(([wert, text, Symbol]) => {
const n = arten?.[wert || 'alle']
const an = art === wert
// Leere Arten bleiben stehen statt zu verschwinden: eine
// Leiste, die ihre Knöpfe wechselt, trifft man nicht.
return (
<button
key={wert}
onClick={() => {
setArt(wert as Art)
setSeite(0)
setGewaehlt(new Set())
}}
disabled={n === 0 && wert !== ''}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
an
? 'border-akzent bg-akzent/15 text-schrift'
: 'border-rand text-schrift-leise hover:text-schrift'
}`}
>
<Symbol size={13} />
{text}
{n !== undefined && (
<span className="tabular-nums opacity-70">{n}</span>
)}
</button>
)
})}
</div>
{/* Kopfzeile: Suche, Auswahl, Löschen */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
size={15}
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
/>
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Pfad durchsuchen, z. B. vehicles/"
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
focus:outline-none"
/>
{suche && (
<button
onClick={() => setSuche('')}
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
aria-label="Suche leeren"
>
<X size={14} />
</button>
)}
</div>
<span className="text-sm text-schrift-leise">
{laedt ? (
<Loader2 size={15} className="animate-spin" />
) : (
<>
{gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'}
{sucheAktiv && ' gefunden'}
</>
)}
</span>
{frisch.wartet && (
<Knopf
art="haupt"
onClick={frisch.jetztLaden}
kind={
<>
<RefreshCw size={14} />
Neues da laden
</>
}
/>
)}
<label
className="flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise"
title="Alle fünf Sekunden nachsehen, ob sich etwas geändert hat"
>
<input
type="checkbox"
checked={frisch.an}
onChange={(e) => frisch.setAn(e.target.checked)}
className="accent-akzent"
/>
automatisch
</label>
<label className="flex items-center gap-1.5 text-xs text-schrift-leise">
<ArrowDownUp size={13} />
<select
value={sortierung}
onChange={(e) => {
setSortierung(e.target.value as Sortierung)
setSeite(0)
}}
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
<option value="geaendert">zuletzt geändert</option>
<option value="angelegt">zuerst hochgeladen</option>
<option value="pfad">Pfad</option>
<option value="groesse">Größe</option>
{/* Gezählt wurde längst ordnen konnte man danach
nicht, und damit beantwortete die Zahl die Frage
was wird eigentlich gebraucht" nur für die zehn
im Speicherbericht. */}
<option value="abrufe">Abrufe</option>
<option value="geholt">zuletzt geholt</option>
</select>
<select
value={proSeite}
onChange={(e) => {
setProSeite(Number(e.target.value))
setSeite(0)
}}
title="wie viele Kacheln je Seite"
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
{SEITENGROESSEN.map((n) => (
<option key={n} value={n}>
{n} / Seite
</option>
))}
</select>
<button
onClick={() => {
setRichtung(richtung === 'ab' ? 'auf' : 'ab')
setSeite(0)
}}
className="rounded-md border border-rand px-2 py-1.5 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
title={richtung === 'ab' ? 'absteigend' : 'aufsteigend'}
>
{richtung === 'ab' ? '↓' : '↑'}
</button>
</label>
{items.length > 0 && (
<Knopf
onClick={alleUmschalten}
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
/>
)}
{/* Erst anbieten, wenn es ueberhaupt mehr als eine Seite gibt --
sonst steht dort ein Knopf, der dasselbe tut wie der davor. */}
{gesamt > items.length && (
<Knopf
onClick={() => void alleTrefferWaehlen()}
kind={`Alle ${gesamt} wählen`}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
onClick={() =>
setVerschieben({
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={
<>
<FolderInput size={15} />
verschieben
</>
}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
art="gefahr"
onClick={() =>
setLoeschen({
// ALLE Gewaehlten, nicht nur die sichtbaren.
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={
<>
<Trash2 size={15} />
{/* 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`}
</>
}
/>
)}
</div>
<Hochladen
ordner={ordner}
ordnerDaten={ordnerDaten}
fertig={() => {
void laden()
void ordnerLaden()
void frisch.merken()
}}
/>
{/* Das Raster */}
{items.length === 0 && !laedt ? (
<Leer
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
text={
sucheAktiv
? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.`
: ordner
? `In ${ordner}/ liegt nichts.`
: 'Noch keine Datei hochgeladen.'
}
/>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{items.map((item) => {
const an = gewaehlt.has(item.id)
return (
<figure
key={item.id}
className={`group relative overflow-hidden rounded-lg border bg-flaeche
transition-all hover:-translate-y-0.5 hover:shadow-lg
hover:shadow-black/30 ${
an
? 'border-akzent ring-1 ring-akzent/40'
: 'border-rand hover:border-schrift-leise/40'
}`}
>
{/* Klick auf die Kachel ÖFFNET, Klick auf das
Kästchen WÄHLT AUS. Vorher tat der Klick
beides nicht gleichzeitig er wählte nur
aus, und zum Ansehen musste man einen
neuen Tab aufmachen. */}
<button
onClick={() => setAnsehen(item.id)}
className="block w-full"
aria-label={`${item.path} ansehen`}
>
<div className="relative flex aspect-square items-center justify-center bg-grund">
<Vorschau item={item} />
{/* Das Abspielzeichen liegt ÜBER dem
Standbild. Ohne es sieht ein Video
aus wie ein Bild, und man klickt es
in der Erwartung an, ein Bild zu
sehen. */}
{(item.mime.startsWith('video') ||
item.mime.startsWith('audio')) && (
<span
className="pointer-events-none absolute inset-0 flex
items-center justify-center"
>
<span className="rounded-full bg-black/55 p-2">
<Play
size={16}
className="translate-x-[1px] fill-white text-white"
/>
</span>
</span>
)}
{item.dauer !== null && item.dauer > 0 && (
<span
className="pointer-events-none absolute right-1.5 bottom-1.5
rounded bg-black/70 px-1.5 py-0.5 text-[11px]
tabular-nums text-white"
>
{dauerText(item.dauer)}
</span>
)}
</div>
</button>
{/* Immer sichtbar, wenn gewählt; sonst erst
beim Darüberfahren. Ein Kästchen, das man
erst suchen muss, ist keines eines, das
bei 60 Kacheln immer leuchtet, ist Lärm. */}
<button
onClick={(e) => {
e.stopPropagation()
umschalten(item.id)
}}
aria-pressed={an}
aria-label={`${item.path} auswählen`}
className={`absolute top-2 left-2 rounded border p-1 transition-opacity ${
an
? 'border-akzent bg-akzent text-grund opacity-100'
: 'border-rand bg-grund/80 text-schrift-leise opacity-0 group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100'
}`}
>
<Check size={12} />
</button>
<figcaption className="border-t border-rand p-2">
<p
className="truncate font-mono text-xs text-schrift"
title={item.path}
>
{item.path}
</p>
<p className="mt-0.5 text-[11px] text-schrift-leise">
{/* Wonach sortiert wird, STEHT AUCH DA.
Nach einer Zahl zu ordnen, die man
nirgends sieht, ist eine Reihenfolge
ohne Begründung man sieht nur, dass
sich etwas geändert hat. */}
{sortierung === 'abrufe' || sortierung === 'geholt' ? (
<>
{item.abrufe}× geholt ·{' '}
{item.zuletzt_abgerufen
? zeit(item.zuletzt_abgerufen)
: 'nie'}
</>
) : (
<>
{bytes(item.size)} · {zeit(item.updated_at)}
</>
)}
</p>
<div className="mt-2 flex gap-1">
<button
onClick={() => void adresseKopieren(item)}
className="flex flex-1 items-center justify-center gap-1 rounded border
border-rand py-1 text-[11px] text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
>
{kopiert === String(item.id) ? (
<>
<Check size={11} className="text-gut" />
kopiert
</>
) : (
<>
<Copy size={11} />
Adresse
</>
)}
</button>
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label="In neuem Tab öffnen"
>
<ExternalLink size={11} />
</a>
<button
onClick={() => setLoeschen({ ids: [item.id], namen: [item] })}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`${item.path} löschen`}
>
<Trash2 size={11} />
</button>
</div>
</figcaption>
</figure>
)
})}
</div>
)}
{/* Blättern */}
{seiten > 1 && (
<div className="flex items-center justify-center gap-3">
<Knopf
disabled={seite === 0}
onClick={() => setSeite((s) => s - 1)}
kind={<ChevronLeft size={15} />}
/>
<span className="text-sm text-schrift-leise">
Seite {seite + 1} von {seiten}
</span>
<Knopf
disabled={seite + 1 >= seiten}
onClick={() => setSeite((s) => s + 1)}
kind={<ChevronRight size={15} />}
/>
</div>
)}
</div>
{(() => {
if (ansehen === null) return null
const i = items.findIndex((x) => x.id === ansehen)
// Die Datei kann zwischenzeitlich verschwunden sein -- etwa
// weil daneben geloescht wurde. Dann schliesst sich der
// Betrachter, statt auf ein Loch zu zeigen.
if (i === -1) return null
const item = items[i]!
return (
<Ansehen
item={item}
naechstes={items[i + 1]}
hatVorher={i > 0}
hatNachher={i < items.length - 1}
onVorher={() => setAnsehen(items[i - 1]!.id)}
onNachher={() => setAnsehen(items[i + 1]!.id)}
onSchliessen={() => setAnsehen(null)}
onLoeschen={() => {
setAnsehen(null)
setLoeschen({ ids: [item.id], namen: [item] })
}}
onUmbenennen={() => {
setAnsehen(null)
setUmbenennen(item)
}}
/>
)
})()}
{umbenennen && (
<Umbenennen
datei={umbenennen}
onAbbruch={() => setUmbenennen(null)}
onFertig={() => {
setUmbenennen(null)
allesLaden()
}}
/>
)}
{ordnerUmbenennen !== null && (
<OrdnerUmbenennen
pfad={ordnerUmbenennen}
dateien={
ordnerDaten?.items.find((o) => o.folder === ordnerUmbenennen)?.files ?? 0
}
onAbbruch={() => setOrdnerUmbenennen(null)}
onFertig={(neu) => {
setOrdnerUmbenennen(null)
// Steht die Ansicht IM umbenannten Ordner, muss sie
// mitwandern -- sonst zeigt sie auf einen Pfad, den es
// nicht mehr gibt, und der Inhalt ist leer.
if (ordner === ordnerUmbenennen || ordner.startsWith(ordnerUmbenennen + '/')) {
setOrdner(neu + ordner.slice(ordnerUmbenennen.length))
}
allesLaden()
}}
/>
)}
{verschieben && (
<Verschieben
ids={verschieben.ids}
namen={verschieben.namen}
ordner={ordnerDaten}
onAbbruch={() => setVerschieben(null)}
onFertig={() => {
setVerschieben(null)
setGewaehlt(new Set())
void laden()
void ordnerLaden()
void artenLaden()
}}
/>
)}
<Nachfrage
offen={loeschen !== null}
titel={
loeschen?.ids.length === 1
? 'Datei löschen?'
: `${loeschen?.ids.length ?? 0} Dateien löschen?`
}
bestaetigung="In den Papierkorb"
wartet={loescht}
onNein={() => setLoeschen(null)}
onJa={() => void loeschenAusfuehren()}
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">
Verschiebt in den <b>Papierkorb</b> dort liegt alles noch 30 Tage und
lässt sich zurückholen. Erst danach ist es endgültig weg.
</p>
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
{loeschen?.namen.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
{/* 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">
und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
</li>
)}
</ul>
</>
}
/>
</div>
)
}
/** Was auf der Kachel steht.
*
* ERST DIE VORSCHAU, immer: 60 Vollbilder sind 7,2 MB, nur damit jemand sieht,
* WELCHE Dateien da sind. Video und Ton haben seit ffmpeg ebenfalls eine
* ein Standbild aus dem Clip, bei einer MP3 das eingebettete Titelbild.
*
* FEHLT SIE, wird nicht geraten. Bei einem Bild nimmt der Browser über
* `onError` das Vollbild; bei allem anderen kommt ein Zeichen, das die ART
* benennt. Vorher stand dort für jede Nicht-Bild-Datei ein Fragezeichen
* also ausgerechnet das Zeichen für unbekannt" auf einer völlig gesunden
* MP4. */
function Vorschau({ item }: { item: MediaItem }) {
const [gescheitert, setGescheitert] = useState(false)
const bewegt = item.mime.startsWith('video') || item.mime.startsWith('audio')
// Sobald die Datei wechselt, ist ein früherer Fehlschlag hinfällig.
useEffect(() => setGescheitert(false), [item.id])
if (!gescheitert) {
return (
<img
src={vorschauUrl(item.path)}
onError={(e) => {
// Bei einem Bild lohnt der zweite Versuch mit dem Vollbild:
// die Vorschau ist abgeleitet und kann fehlen, das Bild
// selbst liegt da. Bei Video und Ton wäre derselbe Griff
// grober Unfug — er lüde den ganzen Clip in ein <img>.
const bild = e.currentTarget
if (istBild(item.mime) && !bild.dataset.gefallen) {
bild.dataset.gefallen = 'ja'
bild.src = item.url
return
}
setGescheitert(true)
}}
alt={item.path}
loading="lazy"
className="h-full w-full object-contain"
/>
)
}
const Zeichen = item.mime.startsWith('video')
? Film
: item.mime.startsWith('audio')
? Music
: item.mime === 'application/pdf' ||
item.mime.startsWith('text/') ||
item.mime.startsWith('application/json')
? FileText
: istBild(item.mime)
? ImageOff
: FileQuestion
return (
<Zeichen
size={28}
className={bewegt ? 'text-schrift-leise/30' : 'text-schrift-leise/50'}
/>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useState, type FormEvent } from 'react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { Avatar } from '../components/Avatar'
export function Konto() {
const user = useStore((s) => s.user)
const note = useStore((s) => s.note)
const [aktuell, setAktuell] = useState('')
const [neu, setNeu] = useState('')
const [wieder, setWieder] = useState('')
const [wartet, setWartet] = useState(false)
const zuKurz = neu.length > 0 && neu.length < 8
const ungleich = wieder.length > 0 && neu !== wieder
async function wechseln(e: FormEvent) {
e.preventDefault()
// Die Wiederholung prüft nur diese Seite — der Dienst kennt sie nicht.
// Deshalb hier ein eigener Satz statt einer Anfrage, die nichts
// bewirken kann.
if (neu !== wieder) {
note('fehler', 'Die beiden neuen Passwörter stimmen nicht überein.')
return
}
setWartet(true)
const ok = await run(
() => api.changePassword(aktuell, neu),
'Passwort geändert. Alle anderen Anmeldungen wurden beendet.',
)
setWartet(false)
if (ok) {
setAktuell('')
setNeu('')
setWieder('')
}
}
// Der Discord-Name steht nur daneben, wenn er vom Anzeigenamen abweicht —
// sonst stuende dort zweimal dasselbe.
const herkunft =
user?.discordName && user.discordName !== user.username
? `über Discord angemeldet — dort „${user.discordName}"`
: 'über Discord angemeldet'
return (
<div className="flex flex-col gap-4">
<Karte titel="Konto">
<div className="mb-4 flex items-center gap-3 border-b border-rand pb-4">
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={44} />
<div className="min-w-0">
<p className="truncate font-medium">{user?.username ?? '—'}</p>
<p className="text-xs text-schrift-leise">
{user?.discord ? herkunft : 'mit Passwort angemeldet'}
</p>
</div>
</div>
{user?.discord && (
<p className="mb-4 rounded-md border border-rand bg-grund px-3 py-2 text-xs text-schrift-leise">
Dieses Konto hat kein Passwort es kommt ausschließlich über Discord
herein. Das Formular unten wirkt nur für Konten mit Passwort.
</p>
)}
<form onSubmit={wechseln} className="flex max-w-md flex-col gap-3">
<Feld
label="Aktuelles Passwort"
type="password"
autoComplete="current-password"
value={aktuell}
onChange={(e) => setAktuell(e.target.value)}
/>
<Feld
label="Neues Passwort"
type="password"
autoComplete="new-password"
value={neu}
onChange={(e) => setNeu(e.target.value)}
hinweis={zuKurz ? 'Mindestens 8 Zeichen.' : undefined}
/>
<Feld
label="Neues Passwort wiederholen"
type="password"
autoComplete="new-password"
value={wieder}
onChange={(e) => setWieder(e.target.value)}
hinweis={ungleich ? 'Stimmt nicht mit dem neuen Passwort überein.' : undefined}
/>
<p className="text-xs text-schrift-leise">
Ein Wechsel beendet alle anderen Sitzungen. Diese hier bleibt bestehen.
</p>
<div>
<Knopf
art="haupt"
type="submit"
wartet={wartet}
disabled={!aktuell || neu.length < 8 || neu !== wieder}
kind="Passwort ändern"
/>
</div>
</form>
</Karte>
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from 'react'
import { AlertTriangle, Loader2, RotateCcw, Trash2 } from 'lucide-react'
import { api, type PapierkorbSeite, type PapierkorbEintrag } from '../api'
import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Knopf, Leer, Nachfrage } from '../components/Bausteine'
/** Der Papierkorb.
*
* Löschen war bis vor Kurzem endgültig die Sicherung half nur bis zum
* letzten nächtlichen Lauf. Bei einem Knopf alle 3670 wählen" direkt neben
* löschen" ist das die falsche Härte.
*
* Was hier steht, liegt weiterhin auf der Platte, nur nicht mehr unter seiner
* öffentlichen Adresse. Nach 30 Tagen räumt der Dienst selbst auf. */
export function Papierkorb() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<PapierkorbSeite | null>(null)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [laeuft, setLaeuft] = useState(false)
const [fragt, setFragt] = useState<PapierkorbEintrag[] | 'alles' | null>(null)
const laden = useCallback(async () => {
try {
setDaten(await api.papierkorb())
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}, [note])
useEffect(() => {
void laden()
}, [laden])
if (!daten) {
return (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
const items = daten.items
const auswahl = items.filter((i) => gewaehlt.has(i.id))
async function zurueck(liste: PapierkorbEintrag[]) {
setLaeuft(true)
await run(
() => api.papierkorbZurueck(liste.map((e) => e.id)),
(r) =>
r.fehlgeschlagen === 0
? `${r.zurueck} zurückgeholt.`
: `${r.zurueck} zurückgeholt, ${r.fehlgeschlagen} nicht: ` +
r.ergebnisse
.filter((e) => !e.ok)
.slice(0, 3)
.map((e) => `${e.path} (${e.error})`)
.join('; '),
)
setLaeuft(false)
setGewaehlt(new Set())
void laden()
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-schrift-leise">
{daten.n} {daten.n === 1 ? 'Eintrag' : 'Einträge'} · {bytes(daten.bytes)}
</span>
{auswahl.length > 0 && (
<>
<Knopf
art="haupt"
wartet={laeuft}
onClick={() => void zurueck(auswahl)}
kind={
<>
<RotateCcw size={15} />
{auswahl.length} zurückholen
</>
}
/>
<Knopf
art="gefahr"
onClick={() => setFragt(auswahl)}
kind={`${auswahl.length} endgültig löschen`}
/>
</>
)}
{items.length > 0 && (
<Knopf
art="gefahr"
className="ml-auto"
onClick={() => setFragt('alles')}
kind={
<>
<Trash2 size={15} />
Papierkorb leeren
</>
}
/>
)}
</div>
{daten.verwaist > 0 && (
<p className="flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10
px-3 py-2 text-xs text-warn">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
Bei <strong>{daten.verwaist}</strong> {daten.verwaist === 1 ? 'Eintrag' : 'Einträgen'}{' '}
fehlt die Datei auf der Platte zurückholen geht dort nicht. Das kommt
vor, wenn eine Sicherung zurückgespielt wurde, die den Papierkorb nicht
enthielt. Sie stehen unten durchgestrichen; endgültig löschen" räumt sie
auf.
</span>
</p>
)}
<Karte>
{items.length === 0 ? (
<Leer
kind={<Trash2 size={26} className="text-schrift-leise/50" />}
text="Der Papierkorb ist leer."
/>
) : (
<table className="w-full table-fixed text-sm">
<colgroup>
<col className="w-10" />
<col />
<col className="w-24" />
<col className="w-40" />
<col className="w-32" />
</colgroup>
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="px-3 pb-2" />
<th className="px-3 pb-2 font-medium">Pfad</th>
<th className="px-3 pb-2 text-right font-medium">Größe</th>
<th className="px-3 pb-2 font-medium">Gelöscht</th>
<th className="px-3 pb-2 font-medium">Von</th>
</tr>
</thead>
<tbody>
{items.map((e) => (
<tr
key={e.id}
className={`border-t border-rand ${e.dateiDa ? '' : 'opacity-50'}`}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={gewaehlt.has(e.id)}
onChange={(ev) =>
setGewaehlt((alt) => {
const neu = new Set(alt)
if (ev.target.checked) neu.add(e.id)
else neu.delete(e.id)
return neu
})
}
className="accent-akzent"
/>
</td>
<td
className={`max-w-0 truncate px-3 py-1.5 font-mono text-xs ${
e.dateiDa ? '' : 'line-through'
}`}
title={
e.dateiDa
? e.path
: `${e.path} — die Datei fehlt auf der Platte`
}
>
{e.path}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">
{bytes(e.size)}
</td>
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
{zeit(e.geloescht)}
</td>
<td className="truncate px-3 py-1.5 text-schrift-leise">
{e.von}
</td>
</tr>
))}
</tbody>
</table>
)}
</Karte>
<p className="text-xs text-schrift-leise">
Einträge werden nach {daten.haltbarTage} Tagen von selbst endgültig entfernt.
Solange sie hier stehen, liegen sie weiterhin auf der Platte nur nicht mehr
unter ihrer öffentlichen Adresse.
</p>
<Nachfrage
offen={fragt !== null}
titel={fragt === 'alles' ? 'Papierkorb leeren?' : 'Endgültig löschen?'}
bestaetigung="Endgültig löschen"
wartet={laeuft}
onNein={() => setFragt(null)}
onJa={async () => {
setLaeuft(true)
await run(
() =>
api.papierkorbLeeren(
fragt === 'alles' ? undefined : (fragt ?? []).map((e) => e.id),
),
(r) => `${r.geleert} endgültig entfernt.`,
)
setLaeuft(false)
setFragt(null)
setGewaehlt(new Set())
void laden()
}}
text={
<p className="flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
<span>
{fragt === 'alles'
? `Alle ${daten.n} Einträge werden von der Platte entfernt.`
: `${(fragt as PapierkorbEintrag[])?.length} Einträge werden von der Platte entfernt.`}{' '}
Danach hilft nur noch die Sicherung und die ist bis zu 24 Stunden
alt.
</span>
</p>
}
/>
</div>
)
}
+423
View File
@@ -0,0 +1,423 @@
import { useEffect, useState } from 'react'
import {
Check,
Copy,
FileArchive,
Folder,
HardDrive,
Images,
KeyRound,
Loader2,
SearchCheck,
} from 'lucide-react'
import { api, type Stats } from '../api'
import { run, useStore } from '../store'
import { bytes } from '../format'
import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { type Folders, type Verwaiste } from '../api'
import { SpeicherKarten } from '../components/SpeicherKarten'
import { FehlendeKarte } from '../components/FehlendeKarte'
import { ErsetzenKarte } from '../components/ErsetzenKarte'
import { api as apiFolders } from '../api'
export function Speicher() {
const note = useStore((s) => s.note)
const [stats, setStats] = useState<Stats | null>(null)
const [laedt, setLaedt] = useState(true)
const [zieht, setZieht] = useState(false)
const [spart, setSpart] = useState(false)
const [fortschritt, setFortschritt] = useState({ thumbs: '', webp: '' })
// Die Ordnerliste brauchen zwei Karten hier — geholt wird sie einmal.
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
useEffect(() => {
apiFolders.folders().then(setOrdnerDaten).catch(() => {
/* die Auswahl bleibt dann bei "(überall)" — kein Grund zu lärmen */
})
}, [])
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
const [sucht, setSucht] = useState(false)
const [handelt, setHandelt] = useState(false)
const [fragt, setFragt] = useState<'aufnehmen' | 'aufraeumen' | null>(null)
const { kopiert, kopieren } = useKopieren()
useEffect(() => {
let lebt = true
api.stats()
.then((s) => lebt && setStats(s))
.catch(
(err) =>
lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'),
)
.finally(() => lebt && setLaedt(false))
return () => {
lebt = false
}
}, [note])
if (laedt) {
return (
<div className="flex justify-center py-12">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (!stats) return <Leer text="Die Statistik ließ sich nicht laden." />
const groesster = Math.max(1, ...stats.folders.map((f) => f.bytes))
/** Eine Wartungsarbeit, die in RUNDEN läuft für beide Knöpfe hier.
*
* WARUM ÜBERHAUPT RUNDEN: der Server rechnet höchstens ein paar hundert
* Stück je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Der erste Lauf
* über 3861 Dateien lief genau da hinein der Anwender sah einen 504,
* während die Arbeit im Hintergrund weiterlief. Ein Knopf, der Erfolg als
* Fehler meldet, ist schlimmer als einer ohne Rückmeldung.
*
* Der Umbau kostet nichts und bringt etwas: statt eines Rädchens, das
* minutenlang schweigt, steht eine Zahl da, die sich bewegt.
*
* Ein Fehler bricht ab, statt weiterzuschleifen sonst rennt die Schleife
* gegen dieselbe Wand und wiederholt dieselbe Meldung. */
async function runden<T extends { erzeugt: number; offen: number; fertig: boolean }>(
setzeWartet: (b: boolean) => void,
welche: 'thumbs' | 'webp',
lauf: () => Promise<T>,
fertigText: (erzeugt: number, letzte: T) => string,
) {
setzeWartet(true)
let erzeugt = 0
for (;;) {
let r: T
try {
r = await lauf()
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
break
}
erzeugt += r.erzeugt
if (r.fertig) {
note('ok', fertigText(erzeugt, r))
break
}
setFortschritt((alt) => ({
...alt,
[welche]: `${erzeugt} erzeugt, noch ${r.offen} offen …`,
}))
}
setzeWartet(false)
setFortschritt((alt) => ({ ...alt, [welche]: '' }))
}
return (
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<Zahl kind={<HardDrive size={16} />} was="Dateien" wert={String(stats.files)} />
<Zahl kind={<Folder size={16} />} was="Belegt" wert={bytes(stats.bytes)} />
<Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} />
</div>
<SpeicherKarten />
<Karte titel="Öffentliche Adresse">
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm">
{stats.publicUrl}
</code>
<button
onClick={() => void kopieren(stats.publicUrl, 'url')}
className="rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Adresse kopieren"
>
{kopiert === 'url' ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
</div>
<p className="mt-2 text-xs text-schrift-leise">
Dateien liegen darunter unter <code className="font-mono">/f/</code>. Diese
Adresse steht nirgends in der Datenbank sie wird bei jeder Antwort aus{' '}
<code className="font-mono">PUBLIC_URL</code> gebaut, ein Umzug ist deshalb
eine geänderte Variable.
</p>
</Karte>
<FehlendeKarte />
<ErsetzenKarte ordnerDaten={ordnerDaten} fertig={() => setOrdnerDaten(null)} />
<Karte titel="Wartung">
<div className="flex flex-wrap items-center gap-3">
<Knopf
wartet={zieht}
onClick={() =>
void runden(
setZieht,
'thumbs',
api.thumbsNachziehen,
(erzeugt, r) =>
(erzeugt === 0
? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.`
: `${erzeugt} Vorschauen erzeugt.`) +
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
)
}
kind={
<>
<Images size={15} />
Fehlende Vorschaubilder erzeugen
</>
}
/>
{fortschritt.thumbs ? (
<span className="text-sm tabular-nums">{fortschritt.thumbs}</span>
) : (
<p className="text-xs text-schrift-leise">
Die Galerie lädt Vorschauen statt Vollbilder. Bei Video ist es ein
Standbild aus dem Clip, bei Ton das eingebettete Titelbild und
dass die meisten MP3 keines haben, ist kein Fehler.
</p>
)}
</div>
<div className="mt-4 border-t border-rand pt-4">
<div className="flex flex-wrap items-center gap-3">
<Knopf
wartet={spart}
onClick={() =>
void runden(
setSpart,
'webp',
api.webpNachziehen,
(erzeugt, r) =>
(erzeugt === 0
? 'Nichts zu tun — alle Fassungen sind schon da.'
: `${erzeugt} Fassungen erzeugt.`) +
` Insgesamt ${bytes(r.vorher)}${bytes(r.nachher)}, spart ${bytes(r.gespart)}.` +
(r.lohntNicht ? ` Bei ${r.lohntNicht} lohnt WebP nicht.` : '') +
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
)
}
kind={
<>
<FileArchive size={15} />
Sparsame Fassungen erzeugen
</>
}
/>
{fortschritt.webp ? (
<span className="text-sm tabular-nums">{fortschritt.webp}</span>
) : (
<p className="max-w-md text-xs text-schrift-leise">
Legt neben jedes PNG und JPEG eine WebP-Fassung. Die Adresse
ändert sich <strong className="text-schrift">nicht</strong> wer
WebP versteht, bekommt die kleine Datei, alle anderen unverändert
das Original. Bei vielen Bildern läuft das in mehreren Runden.
</p>
)}
</div>
</div>
<div className="mt-4 border-t border-rand pt-4">
<div className="flex flex-wrap items-center gap-3">
<Knopf
wartet={sucht}
onClick={async () => {
setSucht(true)
const v = await run(
() => api.verwaiste(),
(r) =>
r.dateiOhneDatensatz.anzahl +
r.datensatzOhneDatei.anzahl +
r.abgeleitetOhneBild.anzahl ===
0
? 'Alles passt zusammen.'
: 'Abweichungen gefunden — siehe unten.',
)
setSucht(false)
if (v) setVerwaiste(v)
}}
kind={
<>
<SearchCheck size={15} />
Verwaiste suchen
</>
}
/>
<p className="text-xs text-schrift-leise">
Dateien ohne Datensatz, Datensätze ohne Datei, Abgeleitetes ohne
Bild. Sucht nur verändert nichts.
</p>
</div>
{verwaiste && (
<div className="mt-3 flex flex-col gap-2 text-sm">
<Befund
titel="Dateien ohne Datensatz"
daten={verwaiste.dateiOhneDatensatz}
hinweis="Liegen da und werden ausgeliefert, fehlen aber in der Liste."
knopf={
verwaiste.dateiOhneDatensatz.anzahl > 0 ? (
<Knopf
art="haupt"
wartet={handelt}
onClick={() => setFragt('aufnehmen')}
kind="In die Liste aufnehmen"
/>
) : null
}
/>
<Befund
titel="Datensätze ohne Datei"
daten={verwaiste.datensatzOhneDatei}
hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild."
/>
<Befund
titel="Abgeleitetes ohne Bild"
daten={verwaiste.abgeleitetOhneBild}
hinweis="Vorschauen und sparsame Fassungen, deren Original weg ist. Belegen Platz, sonst harmlos."
/>
{(verwaiste.datensatzOhneDatei.anzahl > 0 ||
verwaiste.abgeleitetOhneBild.anzahl > 0) && (
<div>
<Knopf
art="gefahr"
wartet={handelt}
onClick={() => setFragt('aufraeumen')}
kind="Leere Datensätze und Abgeleitetes entfernen"
/>
</div>
)}
</div>
)}
</div>
</Karte>
<Nachfrage
offen={fragt !== null}
titel={fragt === 'aufnehmen' ? 'In die Liste aufnehmen?' : 'Aufräumen?'}
bestaetigung={fragt === 'aufnehmen' ? 'Aufnehmen' : 'Entfernen'}
wartet={handelt}
onNein={() => setFragt(null)}
onJa={async () => {
if (!fragt) return
setHandelt(true)
await run(
() => api.verwaisteBehandeln(fragt),
(r) =>
fragt === 'aufnehmen'
? `${r.aufgenommen} Dateien in die Liste aufgenommen.`
: `${r.datensaetze} Datensätze und ${r.vorschauen} Vorschauen entfernt.`,
)
setHandelt(false)
setFragt(null)
const v = await run(() => api.verwaiste(), 'Neu gezählt.')
if (v) setVerwaiste(v)
}}
text={
fragt === 'aufnehmen' ? (
<p>
Die gefundenen Dateien bekommen einen Datensatz und erscheinen in der
Galerie. Es wird nichts gelöscht und nichts überschrieben.
</p>
) : (
<p>
Datensätze, deren Datei fehlt, werden entfernt die Dateien sind
ohnehin weg. Verwaiste Vorschauen werden gelöscht; sie lassen sich
jederzeit neu rechnen.
</p>
)
}
/>
<Karte titel="Größte Ordner">
{stats.folders.length === 0 ? (
<Leer text="Noch nichts abgelegt." />
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Ordner</th>
<th className="pb-2 text-right font-medium">Dateien</th>
<th className="pb-2 text-right font-medium">Belegt</th>
<th className="w-1/3 pb-2 pl-4 font-medium">Anteil</th>
</tr>
</thead>
<tbody>
{stats.folders.map((f) => (
<tr key={f.folder} className="border-t border-rand">
<td className="py-2 font-mono">{f.folder}</td>
<td className="py-2 text-right text-schrift-leise">
{f.files}
</td>
<td className="py-2 text-right">{bytes(f.bytes)}</td>
<td className="py-2 pl-4">
<div className="h-1.5 w-full rounded-full bg-grund">
<div
className="h-1.5 rounded-full bg-akzent"
style={{
width: `${(f.bytes / groesster) * 100}%`,
}}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Karte>
</div>
)
}
function Befund({
titel,
daten,
hinweis,
knopf,
}: {
titel: string
daten: { anzahl: number; beispiele: string[] }
hinweis: string
knopf?: React.ReactNode
}) {
return (
<div className="rounded-md border border-rand bg-grund p-3">
<div className="flex flex-wrap items-center gap-2">
<span className={daten.anzahl > 0 ? 'font-medium text-warn' : 'text-schrift-leise'}>
{daten.anzahl}
</span>
<span className="text-sm">{titel}</span>
<span className="flex-1 text-xs text-schrift-leise">{hinweis}</span>
{knopf}
</div>
{daten.beispiele.length > 0 && (
<ul className="mt-2 max-h-28 overflow-auto font-mono text-[11px] text-schrift-leise">
{daten.beispiele.map((b) => (
<li key={b}>{b}</li>
))}
{daten.anzahl > daten.beispiele.length && (
<li> und {daten.anzahl - daten.beispiele.length} weitere</li>
)}
</ul>
)}
</div>
)
}
function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) {
return (
<div className="rounded-lg border border-rand bg-flaeche p-4">
<div className="flex items-center gap-2 text-xs text-schrift-leise">
{kind}
{was}
</div>
<p className="mt-1 text-2xl font-semibold">{wert}</p>
</div>
)
}
+304
View File
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from 'react'
import {
Check,
Copy,
Folder,
FolderInput,
HardDrive,
Images,
KeyRound,
Loader2,
RefreshCw,
ShieldCheck,
Trash2,
TrendingUp,
Upload,
PenLine,
} from 'lucide-react'
import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Leer, useKopieren } from '../components/Bausteine'
import { Avatar } from '../components/Avatar'
import { useAuffrischen } from '../auffrischen'
import { gehZu } from '../router'
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
}
/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich.
*
* Bisher landete man in der Galerie mit 228 Kacheln und ohne Antwort auf
* die erste Frage, die man beim Öffnen hat: läuft alles? Diese Seite
* beantwortet sie in einem Blick und verlinkt den Rest. */
export function Start() {
const note = useStore((s) => s.note)
const user = useStore((s) => s.user)
const [daten, setDaten] = useState<Uebersicht | null>(null)
const [laedt, setLaedt] = useState(true)
const { kopiert, kopieren } = useKopieren()
const laden = useCallback(async () => {
try {
setDaten(await api.uebersicht())
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}, [note])
useEffect(() => {
void laden()
}, [laden])
// Hier zieht nichts weg: die Seite hat keine Auswahl und kein offenes
// Fenster, sie darf sich jederzeit erneuern.
useAuffrischen({ laden: () => void laden(), blockiert: false })
if (laedt) {
return (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (!daten) return <Leer text="Die Übersicht ließ sich nicht laden." />
// Die eingetragenen Vorlagen — und solange keine da ist, die eine, die
// dieses Projekt ohnehin benutzt. Eine leere Karte waere hier nur ein
// Loch.
const vorlagen =
daten.vorlagen.length > 0
? daten.vorlagen
: [{ name: 'Fahrzeuge', muster: 'vehicles/{model}.webp' }]
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={38} />
<div>
<h1 className="text-lg font-semibold">
Hallo {user?.discordName ?? user?.username ?? ''}
</h1>
<p className="text-xs text-schrift-leise">
{daten.files} Dateien · {bytes(daten.bytes)} belegt
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kachel
kind={<Images size={16} />}
was="Dateien"
wert={String(daten.files)}
onClick={() => gehZu('/galerie')}
/>
<Kachel kind={<HardDrive size={16} />} was="Belegt" wert={bytes(daten.bytes)} />
<Kachel
kind={<TrendingUp size={16} />}
was="Neu heute"
wert={String(daten.heuteHoch)}
unten={`${daten.wocheHoch} in sieben Tagen`}
onClick={() => gehZu('/verlauf')}
/>
<Kachel
kind={<KeyRound size={16} />}
was="Token"
wert={String(daten.tokens.length)}
onClick={() => gehZu('/tokens')}
/>
</div>
{/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */}
<Karte
titel={vorlagen.length === 1 ? 'Adressvorlage' : 'Adressvorlagen'}
rechts={
<button
onClick={() => gehZu('/einstellungen')}
className="text-xs text-schrift-leise hover:text-schrift"
>
verwalten
</button>
}
>
<div className="flex flex-col gap-2">
{vorlagen.map((v, i) => {
const voll = `${daten.publicUrl}${daten.filePrefix}/${v.muster}`
return (
<div key={i} className="flex items-center gap-2">
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
{v.name}
</span>
<code className="min-w-0 flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{voll}
</code>
<button
onClick={async () => {
if (!(await kopieren(voll, 'v' + i)))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label={`${v.name} kopieren`}
>
{kopiert === 'v' + i ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
</div>
)
})}
</div>
</Karte>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Zuletzt hochgeladen">
{daten.bilder.length === 0 ? (
<Leer text="Noch nichts da." />
) : (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{daten.bilder.map((b) => (
<button
key={b.id}
onClick={() => gehZu('/galerie')}
title={b.path}
className="aspect-square overflow-hidden rounded border border-rand bg-grund"
>
<img
src={vorschauUrl(b.path)}
onError={(e) => {
const i = e.currentTarget
if (i.dataset.gefallen) return
i.dataset.gefallen = 'ja'
i.src = b.url
}}
alt={b.path}
loading="lazy"
className="h-full w-full object-contain"
/>
</button>
))}
</div>
)}
</Karte>
<Karte titel="Zuletzt passiert">
{daten.letzte.length === 0 ? (
<Leer text="Noch nichts passiert." />
) : (
<ul className="flex flex-col gap-1.5 text-sm">
{daten.letzte.map((e) => {
const a = arten[e.kind]
const Kind = a.kind
return (
<li key={e.id} className="flex items-center gap-2">
<Kind size={13} className={`shrink-0 ${a.farbe}`} />
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
title={e.path}
>
{e.path}
</span>
<span className="shrink-0 text-[11px] text-schrift-leise">
{zeit(e.at)}
</span>
</li>
)
})}
</ul>
)}
</Karte>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Größte Ordner" rechts={<Folder size={14} className="text-schrift-leise" />}>
{daten.ordner.length === 0 ? (
<Leer text="Noch nichts abgelegt." />
) : (
<ul className="flex flex-col gap-1.5 text-sm">
{daten.ordner.map((o) => (
<li key={o.folder} className="flex items-center gap-2">
<span className="flex-1 truncate font-mono text-xs">
{o.folder}
</span>
<span className="text-xs text-schrift-leise">
{o.files} Dateien
</span>
<span className="w-16 text-right text-xs">
{bytes(o.bytes)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte titel="Zugänge">
<ul className="flex flex-col gap-1.5 text-sm">
{daten.tokens.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<KeyRound size={13} className="shrink-0 text-schrift-leise" />
<span className="flex-1 truncate">{t.name}</span>
<span className="text-xs text-schrift-leise">
{t.last_used_at ? `zuletzt ${zeit(t.last_used_at)}` : 'nie benutzt'}
</span>
</li>
))}
<li className="mt-1 flex items-center gap-2 border-t border-rand pt-2 text-xs">
<ShieldCheck
size={13}
className={daten.discord ? 'text-gut' : 'text-schrift-leise'}
/>
Discord-Anmeldung {daten.discord ? 'eingerichtet' : 'nicht eingerichtet'}
</li>
</ul>
</Karte>
</div>
</div>
)
}
function Kachel({
kind,
was,
wert,
unten,
onClick,
}: {
kind: React.ReactNode
was: string
wert: string
unten?: string
onClick?: () => void
}) {
const inhalt = (
<>
<div className="flex items-center gap-2 text-xs text-schrift-leise">
{kind}
{was}
</div>
<p className="mt-1 text-2xl font-semibold">{wert}</p>
{unten && <p className="text-[11px] text-schrift-leise">{unten}</p>}
</>
)
// Nur was wirklich irgendwohin fuehrt, wird ein Knopf. Eine Kachel, die
// aussieht wie ein Knopf und nichts tut, ist der Anfang des Misstrauens.
return onClick ? (
<button
onClick={onClick}
className="rounded-lg border border-rand bg-flaeche p-4 text-left transition-colors hover:border-akzent"
>
{inhalt}
</button>
) : (
<div className="rounded-lg border border-rand bg-flaeche p-4">{inhalt}</div>
)
}
+457
View File
@@ -0,0 +1,457 @@
import { useEffect, useState, type FormEvent } from 'react'
import { AlertTriangle, Check, Copy, KeyRound, Loader2, PenLine, Plus, Trash2 } from 'lucide-react'
import { api, type NewToken, type TokenRow } from '../api'
import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { TokenBearbeiten } from '../components/TokenBearbeiten'
export function Tokens() {
const note = useStore((s) => s.note)
const [rows, setRows] = useState<TokenRow[] | null>(null)
const [laedt, setLaedt] = useState(true)
const [name, setName] = useState('')
const [prefix, setPrefix] = useState('')
const [darfLoeschen, setDarfLoeschen] = useState(false)
const [maxMb, setMaxMb] = useState('')
const [quotaMb, setQuotaMb] = useState('')
const [maxKante, setMaxKante] = useState('')
const [gueltigTage, setGueltigTage] = useState('')
const [arten, setArten] = useState<string[]>([])
const [legtAn, setLegtAn] = useState(false)
const [frisch, setFrisch] = useState<NewToken | null>(null)
const [weg, setWeg] = useState<TokenRow | null>(null)
const [bearbeiten, setBearbeiten] = useState<TokenRow | null>(null)
const [loescht, setLoescht] = useState(false)
const [ohneToken, setOhneToken] = useState<{ dateien: number; bytes: number } | null>(null)
const { kopiert, kopieren } = useKopieren()
async function laden() {
setLaedt(true)
try {
const antwort = await api.tokens()
setRows(antwort.items)
setOhneToken(antwort.ohneToken)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function anlegen(e: FormEvent) {
e.preventDefault()
setLegtAn(true)
const neu = await run(
() =>
api.createToken({
name: name.trim(),
prefix: prefix.trim(),
canDelete: darfLoeschen,
maxMb: maxMb.trim(),
quotaMb: quotaMb.trim(),
maxKante: maxKante.trim(),
arten,
gueltigTage: gueltigTage.trim(),
}),
(t) => `Token "${t.name}" angelegt.`,
)
setLegtAn(false)
if (neu) {
setFrisch(neu)
setName('')
setPrefix('')
setDarfLoeschen(false)
setMaxMb('')
setGueltigTage('')
setArten([])
void laden()
}
}
async function loeschen() {
if (!weg) return
setLoescht(true)
const ok = await run(() => api.deleteToken(weg.id), `Token "${weg.name}" gelöscht.`)
setLoescht(false)
setWeg(null)
if (ok) void laden()
}
return (
<div className="flex flex-col gap-4">
<Karte titel="Neuen Token anlegen">
<form onSubmit={anlegen} className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Name"
value={name}
placeholder="d4rk_photostudio"
hinweis="Nur zur Wiedererkennung in dieser Liste."
onChange={(e) => setName(e.target.value)}
/>
<Feld
label="Ordner (optional)"
value={prefix}
placeholder="vehicles, items"
hinweis="Nur unter diesen Ordnern darf der Token schreiben. Mehrere durch Komma. Leer = überall."
onChange={(e) => setPrefix(e.target.value)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Größte Datei (MB, optional)"
type="number"
min={0}
step="0.5"
value={maxMb}
placeholder="leer = Grenze des Dienstes"
hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB."
onChange={(e) => setMaxMb(e.target.value)}
/>
<Feld
label="Kontingent insgesamt (MB, optional)"
type="number"
min={0}
step="1"
value={quotaMb}
placeholder="leer = unbegrenzt"
hinweis="Die Grenze links gilt je Datei. Diese hier gilt für alles zusammen — ohne sie kann ein Token die Platte füllen, es braucht nur genug kleine Dateien."
onChange={(e) => setQuotaMb(e.target.value)}
/>
<Feld
label="Bilder verkleinern auf (Pixel, optional)"
type="number"
min={16}
step="64"
value={maxKante}
placeholder="leer = ablegen wie es kommt"
hinweis="Größte Kantenlänge. Kleinere Bilder werden NICHT aufgeblasen. Spart Platz und Ladezeit — als Schutz braucht es das nicht."
onChange={(e) => setMaxKante(e.target.value)}
/>
<Feld
label="Gültig für (Tage, optional)"
type="number"
min={1}
value={gueltigTage}
placeholder="leer = unbefristet"
hinweis="Danach antwortet der Dienst mit einer Meldung, die das sagt."
onChange={(e) => setGueltigTage(e.target.value)}
/>
</div>
<div>
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Erlaubte Arten (optional)
</span>
<div className="flex flex-wrap gap-1.5">
{(
[
['bild', 'Bilder'],
['video', 'Video'],
['ton', 'Ton'],
['dokument', 'Dokumente'],
['andere', 'Anderes'],
] as const
).map(([wert, text]) => {
const an = arten.includes(wert)
return (
<button
key={wert}
type="button"
onClick={() =>
setArten((a) =>
an ? a.filter((x) => x !== wert) : [...a, wert],
)
}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
an
? 'border-akzent bg-akzent/15 text-schrift'
: 'border-rand text-schrift-leise hover:text-schrift'
}`}
>
{text}
</button>
)
})}
</div>
<span className="mt-1 block text-xs text-schrift-leise">
Nichts gewählt heißt: alles erlaubt. Ein Token, der nur Bilder darf,
kann kein Video hochladen auch wenn er abhandenkommt.
</span>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={darfLoeschen}
onChange={(e) => setDarfLoeschen(e.target.checked)}
className="mt-0.5 accent-akzent"
/>
<span>
darf löschen
<span className="block text-xs text-schrift-leise">
Ohne dieses Häkchen kann der Token nur schreiben. Ein Werkzeug,
das nur Bilder ablegt, braucht es nicht.
</span>
</span>
</label>
<div>
<Knopf
art="haupt"
type="submit"
wartet={legtAn}
disabled={!name.trim()}
kind={
<>
<Plus size={15} />
Anlegen
</>
}
/>
</div>
</form>
</Karte>
<Karte titel="Vorhandene Token">
{laedt ? (
<div className="flex justify-center py-8">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
) : !rows || rows.length === 0 ? (
<Leer
kind={<KeyRound size={26} className="text-schrift-leise/50" />}
text="Noch kein Token angelegt."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Ordner</th>
<th className="pb-2 font-medium">Rechte</th>
<th className="pb-2 font-medium">Grenzen</th>
{/* Was mit dem Token wirklich abgelegt wurde.
`token_id` steht seit dem ersten Tag in jeder
Zeile und war bis hierhin NIRGENDS zu sehen
dabei ist es die Auskunft, die vor dem
Löschen zählt. */}
<th className="pb-2 text-right font-medium">Abgelegt</th>
<th className="pb-2 font-medium">Angelegt</th>
<th className="pb-2 font-medium">Zuletzt benutzt</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr
key={t.id}
className={`border-t border-rand ${t.abgelaufen ? 'opacity-50' : ''}`}
>
<td className="py-2 font-medium">
{t.name}
{t.abgelaufen && (
<span className="ml-2 rounded bg-schlecht/15 px-1.5 py-0.5 text-[11px] text-schlecht">
abgelaufen
</span>
)}
</td>
<td className="py-2 font-mono text-schrift-leise">
{t.prefix
? t.prefix
.split(',')
.map((p) => `${p.trim()}/`)
.join(' · ')
: '— (überall)'}
</td>
<td className="py-2">
{t.can_delete ? (
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
schreiben + löschen
</span>
) : (
<span className="text-xs text-schrift-leise">
nur schreiben
</span>
)}
</td>
<td className="py-2 text-xs text-schrift-leise">
{[
t.max_bytes ? `max ${bytes(t.max_bytes)}/Datei` : null,
t.quota_bytes
? `${bytes(t.bytes)} von ${bytes(t.quota_bytes)}`
: null,
t.max_kante ? `max ${t.max_kante} px` : null,
t.arten ? t.arten.replaceAll(',', ', ') : null,
t.expires_at
? `bis ${zeit(t.expires_at).slice(0, 10)}`
: null,
]
.filter(Boolean)
.join(' · ') || '—'}
</td>
<td className="py-2 text-right whitespace-nowrap">
{t.dateien === 0 ? (
<span className="text-xs text-schrift-leise"></span>
) : (
<span title={
t.zuletztAbgelegt
? `zuletzt ${zeit(t.zuletztAbgelegt)}`
: undefined
}>
<span className="tabular-nums">{t.dateien}</span>
<span className="text-xs text-schrift-leise">
{' '}
· {bytes(t.bytes)}
</span>
</span>
)}
</td>
<td className="py-2 text-schrift-leise">
{zeit(t.created_at)}
</td>
<td className="py-2 text-schrift-leise">
{zeit(t.last_used_at)}
</td>
<td className="py-2 text-right whitespace-nowrap">
<button
onClick={() => setBearbeiten(t)}
className="mr-1 rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label={`Token ${t.name} bearbeiten`}
title="Grenzen bearbeiten"
>
<PenLine size={13} />
</button>
<button
onClick={() => setWeg(t)}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`Token ${t.name} löschen`}
>
<Trash2 size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{ohneToken && ohneToken.dateien > 0 && (
<p className="mt-3 border-t border-rand pt-3 text-xs text-schrift-leise">
Dazu kommen <strong className="text-schrift">{ohneToken.dateien}</strong>{' '}
Dateien ({bytes(ohneToken.bytes)}) <strong>ohne</strong> Token aus dem
Dashboard hochgeladen. Dort gibt es eine Sitzung und keinen Token;
ohne diese Zeile ginge die Summe nicht auf.
</p>
)}
</Karte>
{/* Der Klartext genau einmal.
Deshalb ein Dialog, den man bewusst schließen muss, und kein
Zettel, der nach vier Sekunden verschwindet. */}
{frisch && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl">
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
<KeyRound size={16} className="text-akzent" />
Token {frisch.name}" angelegt
</h3>
<div className="px-4 py-4">
<p className="mb-3 flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm text-warn">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span className="text-schrift">
Dies ist das einzige Mal, dass der Token im Klartext zu sehen
ist. Danach steht nur noch sein Hash in der Datenbank auch
der Dienst kann ihn nicht mehr zeigen.
</span>
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-2 font-mono text-sm">
{frisch.token}
</code>
<Knopf
art="haupt"
onClick={async () => {
const ok = await kopieren(frisch.token, 'neu')
if (!ok)
note(
'fehler',
'Die Zwischenablage ist gesperrt — bitte von Hand markieren.',
)
}}
kind={
kopiert === 'neu' ? (
<>
<Check size={15} />
kopiert
</>
) : (
<>
<Copy size={15} />
Kopieren
</>
)
}
/>
</div>
<p className="mt-3 text-xs text-schrift-leise">
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
</p>
</div>
<div className="flex justify-end border-t border-rand px-4 py-3">
<Knopf
onClick={() => setFrisch(null)}
kind="Habe ich mir notiert"
/>
</div>
</div>
</div>
)}
{bearbeiten && (
<TokenBearbeiten
token={bearbeiten}
onAbbruch={() => setBearbeiten(null)}
onFertig={() => {
setBearbeiten(null)
void laden()
}}
/>
)}
<Nachfrage
offen={weg !== null}
titel="Token löschen?"
bestaetigung="Token löschen"
wartet={loescht}
onNein={() => setWeg(null)}
onJa={() => void loeschen()}
text={
<>
<p>
{weg?.name}" wird sofort ungültig. Was ihn benutzt etwa der
Fotostudio bekommt ab dann 401 und lädt nichts mehr hoch.
</p>
<p className="mt-2">Hochgeladene Dateien bleiben.</p>
</>
}
/>
</div>
)
}
+422
View File
@@ -0,0 +1,422 @@
import { useCallback, useEffect, useState } from 'react'
import {
ChevronLeft,
ChevronRight,
Download,
History,
Loader2,
PenLine,
RefreshCw,
Search,
FolderInput,
Trash2,
Upload,
X,
} from 'lucide-react'
import { api, type EventKind, type EventsSeite } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Knopf, Leer } from '../components/Bausteine'
import { useAuffrischen } from '../auffrischen'
import { useMerker } from '../merker'
import { Avatar } from '../components/Avatar'
import { VerwaltungListe } from '../components/VerwaltungListe'
const SEITENGROESSEN = [25, 50, 100, 200] as const
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
// Eigene Art, obwohl es technisch ein Verschieben ist: verschoben heisst
// „liegt woanders", umbenannt heisst „heisst anders, liegt noch da". Wer
// den Verlauf aufschlägt, will genau diesen Unterschied wissen.
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
}
export function Verlauf() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<EventsSeite | null>(null)
const [laedt, setLaedt] = useState(true)
const [seite, setSeite] = useState(0)
const [art, setArt] = useState<EventKind | ''>('')
const [wer, setWer] = useState('')
const [proSeite, setProSeite] = useMerker('verlauf.proSeite', 50)
const [suche, setSuche] = useState('')
// Zwei Verläufe, eine Seite. Der eine beantwortet „was ist mit den Dateien
// passiert", der andere „wer hat daran gedreht" — dieselbe Frage aus zwei
// Richtungen, und beide gehören dorthin, wo man Verlauf sucht.
const [reiter, setReiter] = useState<'dateien' | 'verwaltung'>('dateien')
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
// "vehicles" eine Anfrage je Buchstabe raus.
const [sucheAktiv, setSucheAktiv] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setSucheAktiv(suche)
setSeite(0)
}, 250)
return () => clearTimeout(t)
}, [suche])
const laden = useCallback(async () => {
setLaedt(true)
try {
setDaten(await api.events(art, wer, sucheAktiv, proSeite, seite * proSeite))
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLaedt(false)
}
}, [art, wer, sucheAktiv, proSeite, seite, note])
useEffect(() => {
void laden()
}, [laden])
// Im Verlauf ist das Mitlesen der eigentliche Zweck: waehrend ein
// Serienlauf laeuft, sieht man hier zu. Blockiert wird nur, wenn man
// geblaettert hat -- eine Seite drei, die unter den Haenden zu Seite eins
// wird, ist aergerlich.
const frisch = useAuffrischen({ laden: () => void laden(), blockiert: seite > 0 })
const items = daten?.items ?? []
// Die Bilder stehen bei den Akteuren und nicht an jedem Eintrag — sonst
// schleppte jede der 200 Zeilen dieselbe Adresse mit.
const bilder = new Map((daten?.akteure ?? []).map((a) => [a.actor, a.avatar]))
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
if (reiter === 'verwaltung') {
return (
<div className="flex flex-col gap-4">
<Reiter reiter={reiter} setReiter={setReiter} />
<Karte>
<VerwaltungListe />
</Karte>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<Reiter reiter={reiter} setReiter={setReiter} />
<div className="flex flex-wrap items-center gap-2">
<Filter an={art === ''} onClick={() => { setArt(''); setSeite(0) }} text="Alles" />
{(Object.keys(arten) as EventKind[]).map((k) => (
<Filter
key={k}
an={art === k}
onClick={() => { setArt(k); setSeite(0) }}
text={arten[k].text}
/>
))}
<span className="mx-1 h-4 w-px bg-rand" />
<Filter an={wer === ''} onClick={() => { setWer(''); setSeite(0) }} text="alle" />
{daten?.akteure.map((a) => (
<Filter
key={a.actor}
an={wer === a.actor}
onClick={() => { setWer(a.actor); setSeite(0) }}
text={`${a.actor} (${a.n})`}
kind={
a.actor_kind === 'unbekannt' ? null : (
<Avatar
name={a.actor}
url={a.avatar}
token={a.actor_kind === 'token'}
groesse={16}
/>
)
}
/>
))}
{frisch.wartet && (
<Knopf
art="haupt"
className="ml-auto"
onClick={frisch.jetztLaden}
kind={
<>
<RefreshCw size={14} />
Neues da laden
</>
}
/>
)}
<label
className={`flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise ${
frisch.wartet ? '' : 'ml-auto'
}`}
title="Alle fünf Sekunden nachsehen, ob etwas dazugekommen ist"
>
<input
type="checkbox"
checked={frisch.an}
onChange={(e) => frisch.setAn(e.target.checked)}
className="accent-akzent"
/>
automatisch
</label>
<span className="text-sm text-schrift-leise">
{laedt ? (
<Loader2 size={15} className="animate-spin" />
) : (
`${daten?.total ?? 0} Einträge`
)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
size={15}
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
/>
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Pfad durchsuchen, z. B. vehicles/adder"
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
focus:outline-none"
/>
{suche && (
<button
onClick={() => setSuche('')}
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
aria-label="Suche leeren"
>
<X size={14} />
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs text-schrift-leise">
pro Seite
<select
value={proSeite}
onChange={(e) => {
setProSeite(Number(e.target.value))
setSeite(0)
}}
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
{SEITENGROESSEN.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<Karte>
{items.length === 0 && !laedt ? (
<Leer
kind={<History size={26} className="text-schrift-leise/50" />}
text="Für diese Auswahl gibt es nichts."
/>
) : (
<table className="w-full table-fixed text-sm">
{/* Die Spaltenbreiten stehen HIER und nicht verteilt an
den Zellen: vorher hatten Kopf und Koerper
verschiedene Abstaende, und der Pfad wurde
abgeschnitten, obwohl daneben Platz frei war. */}
<colgroup>
<col className="w-40" />
<col className="w-32" />
<col />
<col className="w-24" />
<col className="w-44" />
</colgroup>
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="px-3 pb-2 font-medium">Wann</th>
<th className="px-3 pb-2 font-medium">Was</th>
<th className="px-3 pb-2 font-medium">Pfad</th>
<th className="px-3 pb-2 text-right font-medium">Größe</th>
<th className="px-3 pb-2 font-medium">Von wem</th>
</tr>
</thead>
<tbody>
{items.map((e) => {
const a = arten[e.kind]
const Kind = a.kind
return (
<tr key={e.id} className="border-t border-rand">
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
{zeit(e.at)}
</td>
<td className={`px-3 py-1.5 whitespace-nowrap ${a.farbe}`}>
<span className="flex items-center gap-1.5">
<Kind size={13} />
{a.text}
</span>
</td>
<td
className="max-w-0 truncate px-3 py-1.5 font-mono text-xs"
title={e.von ? `${e.von}${e.path}` : e.path}
>
{e.von && (
<span className="text-schrift-leise">
{e.von} {' '}
</span>
)}
{e.path}
</td>
<td className="px-3 py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
{e.size === null ? '—' : bytes(e.size)}
</td>
<td className="px-3 py-1.5 whitespace-nowrap">
<span className="flex items-center gap-2">
{e.actor_kind !== 'unbekannt' && (
<Avatar
name={e.actor}
url={bilder.get(e.actor)}
token={e.actor_kind === 'token'}
groesse={18}
/>
)}
<span
className={
e.actor_kind === 'unbekannt'
? 'text-schrift-leise italic'
: ''
}
>
{e.actor}
</span>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</Karte>
{seiten > 1 && (
<div className="flex items-center justify-center gap-3">
<Knopf
disabled={seite === 0}
onClick={() => setSeite((s) => s - 1)}
kind={<ChevronLeft size={15} />}
/>
<span className="text-sm text-schrift-leise">
Seite {seite + 1} von {seiten}
</span>
<Knopf
disabled={seite + 1 >= seiten}
onClick={() => setSeite((s) => s + 1)}
kind={<ChevronRight size={15} />}
/>
</div>
)}
{/* Was der Verlauf NICHT weiß, gehört dazugesagt. Ein Tab, der so
tut, als wüsste er alles, ist schlimmer als einer, der seine
Grenze nennt. */}
<p className="text-xs text-schrift-leise">
Aufgezeichnet wird seit {daten?.seit ? zeit(daten.seit) : '—'}. Einträge mit
vor der Aufzeichnung" wurden nachträglich aus dem Bestand erschlossen ihr
Zeitpunkt stimmt, aber wer sie abgelegt hat, weiß niemand mehr. Der Verlauf
behält ein halbes Jahr.
</p>
</div>
)
}
function Filter({
an,
onClick,
text,
kind,
}: {
an: boolean
onClick: () => void
text: string
kind?: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
transition-colors ${
an
? 'border-akzent bg-akzent/15 text-schrift'
: 'border-rand text-schrift-leise hover:text-schrift'
}`}
>
{kind}
{text}
</button>
)
}
/** Die zwei Verläufe nebeneinander.
*
* Als Reiter und nicht als zweite Seite im Menü: es ist beides ein Verlauf,
* und wer einen sucht, sucht nicht zwei Menüeinträge ab. */
function Reiter({
reiter,
setReiter,
}: {
reiter: 'dateien' | 'verwaltung'
setReiter: (r: 'dateien' | 'verwaltung') => void
}) {
// Jeder Knopf gibt Rueckmeldung, auch der erfolgreiche: ein Download, der
// nur im Browser-Balken auftaucht, sieht aus wie ein Knopf, der nichts tut.
const note = useStore((s) => s.note)
const stil = (an: boolean) =>
`rounded-md px-3 py-1.5 text-sm ${
an
? 'bg-flaeche-hoch text-schrift'
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
}`
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1 rounded-lg border border-rand bg-flaeche p-1">
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
Dateien
</button>
<button
className={stil(reiter === 'verwaltung')}
onClick={() => setReiter('verwaltung')}
>
Verwaltung
</button>
</div>
{/* Der Knopf holt IMMER den Verlauf, der gerade offen ist. Zwei
Knoepfe nebeneinander waeren die Frage welchen von beiden?" an
einer Stelle, wo die Antwort schon auf dem Bildschirm steht. */}
<button
onClick={() => {
// Ueber einen Link und nicht ueber fetch: so bekommt die
// Datei den Namen aus dem Content-Disposition und landet im
// Download-Ordner statt im Speicher des Browsers.
const a = document.createElement('a')
a.href = `/api/dash/verlauf/export?was=${reiter}`
a.rel = 'noreferrer'
a.click()
note('ok', `Der ${reiter === 'dateien' ? 'Datei' : 'Verwaltungs'}-Verlauf wird heruntergeladen.`)
}}
title="Als CSV herunterladen — Semikolon und CRLF, damit Excel es direkt aufmacht"
className="flex items-center gap-1.5 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
>
<Download size={15} />
CSV
</button>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react'
// Ein winziger Router über die History-API statt einer Bibliothek.
//
// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut
// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein
// Neuladen auf /tokens im Router landet und nicht im 404.
//
// `gehZu` steht hier als MODULFUNKTION und nicht als Eigenschaft der Seiten.
// Der Unterschied ist mehr als Geschmack: sobald eine einzige Seite eine
// Navigationsfunktion durchgereicht bekäme, hätten alle Seiten verschiedene
// Signaturen — und die gemeinsame Liste in App.tsx ginge nicht mehr. Genau
// daran ist der erste Versuch gescheitert.
/** Woanders hin, ohne die Seite neu zu laden. */
export function gehZu(pfad: string) {
if (window.location.pathname === pfad) return
window.history.pushState(null, '', pfad)
// pushState löst KEIN popstate aus — das tut nur der Zurück-Knopf. Ohne
// dieses Ereignis bliebe die Ansicht stehen, obwohl die Adresse wechselt.
window.dispatchEvent(new PopStateEvent('popstate'))
}
export function usePfad(): string {
const [pfad, setPfad] = useState(window.location.pathname)
useEffect(() => {
const zurueck = () => setPfad(window.location.pathname)
window.addEventListener('popstate', zurueck)
return () => window.removeEventListener('popstate', zurueck)
}, [])
return pfad
}
+105
View File
@@ -0,0 +1,105 @@
import { create } from 'zustand'
import { api, ApiError, type User } from './api'
// -- Rückmeldungen -------------------------------------------------------------
//
// DIE EINE REGEL: jeder Knopf gibt Rückmeldung, auch der erfolgreiche. Deshalb
// steht die Meldungsliste hier im Laden und nicht in irgendeiner Seite — sie
// gehört zu jeder von ihnen.
export type NoteKind = 'ok' | 'fehler' | 'hinweis'
export type Note = { id: number; kind: NoteKind; text: string }
let nextNoteId = 1
type State = {
user: User | null
/** Solange true, weiß noch niemand, ob eine Sitzung besteht in dieser
* Zeit darf weder das Anmeldeformular noch das Dashboard aufblitzen. */
checking: boolean
notes: Note[]
/** Wie viele Sitzungen dieses Konto offen hat. 0 = noch nicht geladen. */
sitzungen: number
checkSession: () => Promise<void>
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
note: (kind: NoteKind, text: string) => void
dismiss: (id: number) => void
}
export const useStore = create<State>((set, get) => ({
user: null,
checking: true,
sitzungen: 0,
notes: [],
async checkSession() {
try {
const { user, sitzungen } = await api.me()
set({ user, sitzungen: sitzungen ?? 0, checking: false })
} catch (err) {
// 401 ist hier kein Fehler, sondern die Antwort "noch nicht
// angemeldet". Alles andere ist einer und wird gesagt.
if (err instanceof ApiError && err.status === 401) {
set({ user: null, checking: false })
return
}
set({ user: null, checking: false })
get().note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
}
},
async login(username, password) {
const { user } = await api.login(username, password)
set({ user })
get().note('ok', `Angemeldet als ${user.username}.`)
},
async logout() {
await api.logout()
set({ user: null })
// Die Erfolgsmeldung kommt hier NICHT: der Aufrufer geht über `run`,
// und der meldet. Beides hieße zwei Zettel für einen Klick.
},
note(kind, text) {
const id = nextNoteId++
set((s) => ({ notes: [...s.notes, { id, kind, text }] }))
// Fehler bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der von
// allein verschwindet, ist ein Fehler, den niemand gelesen hat.
if (kind !== 'fehler') {
setTimeout(() => get().dismiss(id), 4000)
}
},
dismiss(id) {
set((s) => ({ notes: s.notes.filter((n) => n.id !== id) }))
},
}))
/** Eine Aktion ausführen und IMMER etwas dazu sagen.
*
* Der Erfolgstext wird mitgegeben, der Fehlertext kommt vom Dienst. Wer
* darüber geht, kann keinen stillen Knopf bauen und genau darum geht es. */
export async function run<T>(
action: () => Promise<T>,
erfolg: string | ((result: T) => string),
): Promise<T | null> {
const { note } = useStore.getState()
try {
const result = await action()
note('ok', typeof erfolg === 'function' ? erfolg(result) : erfolg)
return result
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
// Eine abgelaufene Sitzung ist kein gewöhnlicher Fehler: sie führt
// zurück aufs Anmeldeformular, statt jeden weiteren Knopf mit
// "nicht angemeldet" zu quittieren.
if (err instanceof ApiError && err.status === 401) {
useStore.setState({ user: null })
}
return null
}
}
+23
View File
@@ -0,0 +1,23 @@
{
// EIN tsconfig, kein Composite-Projekt. Das spart die tsbuildinfo-Dateien
// und die Frage, warum vite.config.js plötzlich neben vite.config.ts liegt.
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwind from '@tailwindcss/vite'
// @vitejs/plugin-react ist auf ^4 festgenagelt, und das ist kein Zufall: die 6
// verlangt Vite ^8. Vite 5 bleibt (so steht es in der ROADMAP), und die 4 ist
// die letzte Fassung, die dazu passt — nachgesehen in den peerDependencies,
// nicht geraten.
export default defineConfig({
plugins: [react(), tailwind()],
build: {
// Der Dienst liefert die gebaute Oberflaeche aus ../web aus; der Pfad
// steht in index.ts als `new URL('../web/', import.meta.url)`,
// ausgehend von dist/. Wer das hier aendert, aendert ihn dort mit.
outDir: '../web',
emptyOutDir: true,
},
server: {
// Im Entwicklungsbetrieb laeuft die Oberflaeche unter
// http://localhost:5173 und der Dienst im Container auf 9101. Ohne
// diesen Umweg gaebe es zwei Ursprunge und das Sitzungs-Cookie kaeme
// nie an.
//
// Dass das ueberhaupt geht, liegt an einer Ausnahme: der Container
// setzt das Cookie mit `Secure`, und ein Browser nimmt so eines ueber
// http NUR auf localhost an. Hier hilft die Ausnahme — beim Pruefen
// einer LAN-Adresse fuehrt genau sie in die Irre (siehe ROADMAP).
proxy: {
'/api': { target: 'http://127.0.0.1:9101' },
'/f': { target: 'http://127.0.0.1:9101' },
},
},
})
+288
View File
@@ -0,0 +1,288 @@
# Holt die Sicherungen aus dem synchronisierten Nextcloud-Ordner in eine
# EIGENE Ablage — und bricht damit die Rückkopplung.
#
# LÄUFT AUF DEM HEIMRECHNER, nicht auf dem Server.
#
# pwsh -File heim-abholen.ps1 -Quelle "D:\Nextcloud\Sicherungen\d4rk_media" -Ziel "D:\Archiv\d4rk_media"
# pwsh -File heim-abholen.ps1 ... -Einrichten <- als tägliche Aufgabe eintragen
# pwsh -File heim-abholen.ps1 ... -Webhook "https://discord.com/api/webhooks/..."
#
# WOZU, wo der Nextcloud-Client doch schon synchronisiert.
#
# WEIL ER IN BEIDE RICHTUNGEN ABGLEICHT. Verschwinden die Archive auf dem
# Server — durch einen Verschlüsselungstrojaner, einen Fehlgriff, einen
# kaputten Auftrag —, löscht der Client sie auch hier. Der synchronisierte
# Ordner ist deshalb eine Kopie, aber keine Sicherung.
#
# Dieses Skript kopiert daraus in einen zweiten Ordner, den der Client NICHT
# kennt. Was einmal hier liegt, bleibt liegen, bis die Aufbewahrungsfrist es
# wegräumt. Damit ist die Kette gebrochen: der Server kann diese Kopien nicht
# mehr erreichen.
#
# UND ES MELDET SICH, WENN NICHTS MEHR ANKOMMT. Ein Kopierauftrag, der still
# aufhört zu laufen, ist genau der Schaden, gegen den er gebaut wurde: man
# glaubt, man hätte eine Sicherung, und merkt es an dem Tag, an dem man sie
# braucht. Deshalb prüft er das Alter des neuesten Archivs und schlägt Alarm.
[CmdletBinding()]
param(
# Der Ordner, den der Nextcloud-Client synchronisiert.
[Parameter(Mandatory = $true)][string]$Quelle,
# Die eigene Ablage. MUSS ausserhalb des synchronisierten Ordners liegen --
# sonst schiebt der Client die Kopien wieder zum Server hoch, und die
# Rueckkopplung, die wir brechen wollen, ist wieder da.
[Parameter(Mandatory = $true)][string]$Ziel,
[int]$Behalten = 30,
# Ab wann "es kommt nichts mehr" gemeldet wird. Die Sicherung laeuft
# taeglich; zwei Tage lassen Raum fuer einen ausgefallenen Lauf, drei waeren
# schon zwei verpasste.
[int]$AlarmNachTagen = 2,
[string]$Webhook = '',
[switch]$Einrichten
)
$ErrorActionPreference = 'Stop'
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
function Warn($t) { Write-Host " $t" -ForegroundColor Yellow }
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
$MUSTER = @('*.tar.gz', '*.db.gz')
# Das Datum aus dem NAMEN lesen, nicht aus dem Zeitstempel der Datei.
#
# WARUM: der Nextcloud-Client setzt LastWriteTime auf den Zeitpunkt des
# HERUNTERLADENS. Ein Archiv vom 11. August, das heute ankommt, sieht damit
# taufrisch aus -- und der Alarm "es kommt nichts mehr an" würde nie
# auslösen, weil alles immer neu wirkt. Genau das ist beim ersten echten Lauf
# passiert: "d4rk_media-2026-08-11-2307.tar.gz, 0 Tage alt".
#
# Der Name trägt das Datum der SICHERUNG, und der überlebt jedes Kopieren:
# d4rk_media-2026-08-13-0929.tar.gz
# d4rkbot-2026-08-13.db.gz
function ArchivDatum($datei) {
if ($datei.Name -match '(\d{4})-(\d{2})-(\d{2})(?:-(\d{2})(\d{2}))?') {
$std = if ($Matches[4]) { [int]$Matches[4] } else { 0 }
$min = if ($Matches[5]) { [int]$Matches[5] } else { 0 }
try {
return Get-Date -Year ([int]$Matches[1]) -Month ([int]$Matches[2]) -Day ([int]$Matches[3]) `
-Hour $std -Minute $min -Second 0
} catch { }
}
# Kein Datum im Namen -- dann bleibt nur der Zeitstempel, mit allen
# Nachteilen. Besser als gar keine Reihenfolge.
return $datei.LastWriteTime
}
# -- Als Aufgabe eintragen ------------------------------------------------------
if ($Einrichten) {
$pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source
if (-not $pwsh) { $pwsh = (Get-Command powershell.exe).Source }
$arg = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$PSCommandPath`"" +
" -Quelle `"$Quelle`" -Ziel `"$Ziel`" -Behalten $Behalten -AlarmNachTagen $AlarmNachTagen"
if ($Webhook) { $arg += " -Webhook `"$Webhook`"" }
# Ueber einen VBS-Starter, damit kein Fenster aufgeht -- wie beim Tunnel.
# Ein Fenster, das man zumachen kann, wird irgendwann zugemacht.
$ordner = Join-Path $env:LOCALAPPDATA 'Sicherungs-Abholung'
New-Item -ItemType Directory -Force -Path $ordner | Out-Null
$starter = Join-Path $ordner 'start.vbs'
# Die Anfuehrungszeichen im Argumenttext muessen fuer VBS verdoppelt werden.
# Als Here-String, weil die verschachtelte Variante beim ersten Anlauf ein
# unlesbares Knaeuel war -- und prompt nicht geparst hat.
$vbsArg = $arg -replace '"', '""'
@"
' Erzeugt von heim-abholen.ps1 -- startet die Abholung OHNE Fenster.
Set sh = CreateObject("WScript.Shell")
sh.Run """$pwsh"" $vbsArg", 0, False
"@ | Set-Content $starter -Encoding ASCII
Unregister-ScheduledTask -TaskName 'Sicherungen abholen' -Confirm:$false -ErrorAction SilentlyContinue
$aktion = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "`"$starter`""
# ZWEI Ausloeser: taeglich mittags, UND beim Anmelden. Der zweite ist der
# wichtige -- ein Rechner, der nachts aus ist, holt sonst nie etwas.
$t1 = New-ScheduledTaskTrigger -Daily -At 12:00
$t2 = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"
$prinzipal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
$optionen = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName 'Sicherungen abholen' -Action $aktion -Trigger @($t1, $t2) `
-Principal $prinzipal -Settings $optionen `
-Description "Kopiert Sicherungen aus $Quelle nach $Ziel und haelt $Behalten Staende." | Out-Null
Gut 'Als Aufgabe eingetragen: taeglich 12:00 und bei jeder Anmeldung.'
}
# -- Prüfen ---------------------------------------------------------------------
Schritt 'Nachsehen'
# JEDER Schritt sagt VORHER, was er tut. Beim ersten Anlauf blieb das Skript
# nach "Nachsehen" stehen, und von aussen war nicht zu sehen, WORAN --
# Test-Path, New-Item und Resolve-Path koennen alle drei minutenlang
# blockieren, wenn ein Pfad auf eine Netzfreigabe oder eine schlafende Platte
# zeigt. Eine Ausgabe, die erst NACH dem Blockieren kommt, hilft genau dann
# nicht, wenn man sie braucht.
Write-Host " Quelle pruefen: $Quelle ..." -NoNewline
if (-not (Test-Path $Quelle)) {
Write-Host ''
Schlecht "Die Quelle gibt es nicht: $Quelle"
Schlecht 'Synchronisiert der Nextcloud-Client diesen Ordner wirklich?'
exit 1
}
Write-Host ' da.'
# Das Ziel darf NICHT im synchronisierten Ordner liegen -- sonst laedt der
# Client die Kopien wieder hoch und die Rueckkopplung ist zurueck.
$qVoll = (Resolve-Path $Quelle).Path.TrimEnd('\')
Write-Host " Ziel anlegen/pruefen: $Ziel ..." -NoNewline
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
$zVoll = (Resolve-Path $Ziel).Path.TrimEnd('\')
Write-Host ' da.'
if ($zVoll.StartsWith($qVoll, [StringComparison]::OrdinalIgnoreCase)) {
Schlecht 'Das Ziel liegt IM synchronisierten Ordner. Dann waere alles umsonst:'
Schlecht 'der Client wuerde die Kopien wieder hochladen und mitloeschen.'
exit 1
}
Gut "aus $qVoll"
Gut "nach $zVoll"
# -- Holen ----------------------------------------------------------------------
#
# robocopy OHNE /MIR, und das ist der ganze Punkt: /MIR wuerde das Ziel dem
# Quellordner angleichen und dort geloeschte Dateien auch hier loeschen. Genau
# das soll nicht passieren.
# -- Sind es ueberhaupt Dateien, oder nur Platzhalter? --------------------------
#
# DAS HAT EINEN RECHNER LAHMGELEGT. Der Nextcloud-Client kann "virtuelle
# Dateien" anlegen: im Ordner steht dann ein Verweis mit der richtigen GROESSE,
# aber ohne Inhalt. Wer so eine Datei anfasst, loest einen Download aus -- und
# robocopy fasst alle an. Bei fuenf Archiven waren das 1,7 GB auf einmal,
# waehrend Windows blockierte.
#
# Erkennbar an zwei Attributen:
# 0x001000 OFFLINE -- Inhalt liegt nicht hier
# 0x400000 RECALL_ON_DATA_ACCESS -- wird beim Zugriff nachgeladen
#
# Lieber abbrechen und es sagen, als die Maschine in die Knie zwingen.
Schritt 'Sind es echte Dateien?'
$platzhalter = @(Get-ChildItem $qVoll -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '\.(tar|db)\.gz$' -and ($_.Attributes -band 0x401000) })
if ($platzhalter.Count) {
Schlecht "$($platzhalter.Count) von $(@(Get-ChildItem $qVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count) Archiven sind nur PLATZHALTER."
Write-Host ''
Warn 'Der Nextcloud-Client hat "virtuelle Dateien" aktiviert: im Ordner steht'
Warn 'ein Verweis mit der richtigen Groesse, aber ohne Inhalt. Sie zu kopieren'
Warn 'wuerde alles auf einmal herunterladen und den Rechner blockieren.'
Write-Host ''
Warn 'Im Explorer sieht man es am WOLKEN-Symbol neben der Datei; ein gruener'
Warn 'Haken heisst "liegt wirklich hier". Windows 11 zeigt ausserdem an, wenn'
Warn 'es gerade etwas nachlaedt.'
Write-Host ''
Warn 'Im Nextcloud-Client: Einstellungen -> Konto -> Drei-Punkte-Menue'
Warn ' -> "Virtuelle Dateien deaktivieren"'
Warn 'Danach laedt er im Hintergrund alles herunter -- mit Fortschritt und'
Warn 'unterbrechbar. Erst wenn das durch ist, hat diese Abholung Sinn.'
Write-Host ''
Schlecht 'Abgebrochen. Es wurde nichts kopiert und nichts geloescht.'
exit 1
}
Gut 'ja, alle Archive liegen wirklich lokal'
Schritt 'Kopieren (nur neue, nichts loeschen)'
$zaehlen = { @(Get-ChildItem $zVoll -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count }
$vorher = & $zaehlen
robocopy $qVoll $zVoll @MUSTER /XO /R:2 /W:5 /NP /NJH /NJS /NDL /NC /NS | Out-Null
# robocopy meldet ueber den Rueckgabewert: alles unter 8 ist Erfolg.
if ($LASTEXITCODE -ge 8) { Schlecht "robocopy meldet Fehler ($LASTEXITCODE)"; exit 1 }
$nachher = & $zaehlen
$inQuelle = @(Get-ChildItem $qVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count
Gut "$($nachher - $vorher) neu, $nachher in der Ablage, $inQuelle in der Quelle"
# -- Ausdünnen ------------------------------------------------------------------
$alle = @(Get-ChildItem $zVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' } |
Sort-Object @{ Expression = { ArchivDatum $_ } } -Descending)
if ($alle.Count -gt $Behalten) {
Schritt "Alte Staende entfernen (mehr als $Behalten)"
$alle | Select-Object -Skip $Behalten | ForEach-Object {
Remove-Item $_.FullName -Force
Gut "entfernt: $($_.Name)"
}
}
# -- Kommt überhaupt noch etwas an? ---------------------------------------------
Schritt 'Alter der neuesten Sicherung'
$neuestes = $alle | Select-Object -First 1
if (-not $neuestes) {
$text = 'In der Ablage liegt KEINE einzige Sicherung.'
Schlecht $text
} else {
$tage = [math]::Round(((Get-Date) - (ArchivDatum $neuestes)).TotalDays, 1)
if ($tage -gt $AlarmNachTagen) {
$text = "Die neueste Sicherung ist $tage Tage alt ($($neuestes.Name)). Es kommt nichts mehr an."
Schlecht $text
} else {
$text = $null
Gut "$($neuestes.Name), $tage Tage alt, $([math]::Round($neuestes.Length/1MB)) MB"
}
}
# -- Melden, falls gewünscht ----------------------------------------------------
#
# Nur im Alarmfall und nur bei ZUSTANDSWECHSEL. Eine tägliche Meldung "alles in
# Ordnung" liest nach einer Woche niemand mehr, und dann auch die eine nicht,
# auf die es ankommt.
$merker = Join-Path $zVoll '.letzter-alarm.txt'
$zuletzt = if (Test-Path $merker) { (Get-Content $merker -Raw).Trim() } else { '' }
$jetzt = if ($text) { $text } else { '' }
# DER MERKER WIRD ERST GESETZT, WENN DIE MELDUNG WIRKLICH RAUSGING.
#
# Vorher stand er ganz oben und wurde bei jedem Zustandswechsel geschrieben --
# auch wenn gar kein Webhook konfiguriert war oder das Senden fehlschlug. Beim
# ersten echten Test ist genau das passiert: ein Lauf OHNE -Webhook hat den
# Alarm als "gemeldet" abgehakt, und der naechste Lauf MIT Webhook waere still
# geblieben.
#
# Ein Merker bedeutet "dieser Zustand wurde berichtet". Wenn nichts berichtet
# wurde, darf er nicht gesetzt werden -- sonst verschluckt genau der Fehlerfall
# die Meldung, fuer die das Ganze da ist.
if (-not $jetzt) {
# Alles in Ordnung: Entwarnung braucht keine Nachricht, aber der Merker
# muss weg, damit derselbe Zustand spaeter wieder gemeldet werden kann.
if ($zuletzt) { Set-Content $merker '' -Encoding UTF8; Gut 'Wieder in Ordnung.' }
} elseif ($jetzt -eq $zuletzt) {
Warn 'Schon gemeldet -- bleibt still, bis sich etwas aendert.'
} elseif (-not $Webhook) {
Write-Host ''
Warn 'Kein -Webhook angegeben -- es wurde NICHTS gemeldet.'
Warn 'Der Merker bleibt deshalb leer: beim naechsten Lauf mit Webhook'
Warn 'geht die Meldung dann wirklich raus.'
} else {
$rumpf = @{
username = 'Sicherungs-Abholung'
embeds = @(@{
title = 'Bei dir zu Hause kommt keine Sicherung mehr an'
description = $jetzt
color = 15158332
fields = @(@{ name = 'Ablage'; value = "``$zVoll``"; inline = $false })
footer = @{ text = 'D4RKST3R // ABHOLUNG' }
})
} | ConvertTo-Json -Depth 6
$code = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST `
-H 'content-type: application/json' --data-binary '@-' $Webhook
Write-Host ''
if ($code -in '200', '204') {
Gut "Gemeldet (HTTP $code)."
Set-Content $merker $jetzt -Encoding UTF8
} else {
# NICHT merken: beim naechsten Lauf noch einmal versuchen. Eine
# Meldung, die im Netz haengengeblieben ist, ist keine Meldung.
Schlecht "Melden fehlgeschlagen: HTTP $code -- wird beim naechsten Lauf erneut versucht."
}
}
+174
View File
@@ -0,0 +1,174 @@
# Welche Quelldateien beim Uebernehmen KEINEN eigenen Platz bekommen haben.
#
# Beim Import der Item-Bilder kamen 3669 Dateien an, aber nur 3576 Eintraege
# heraus. Die Differenz sind KEINE Fehler -- es sind Namen, die nach der
# Zaehmung (safeFilename: Kleinbuchstaben, Fremdes wird zum Strich) auf
# denselben Pfad fallen:
#
# WEAPON_SMG.png -> weapon_smg.png
# weapon_smg.png -> weapon_smg.png <- derselbe Platz
#
# Der Import laedt mit overwrite=false, hat also NICHTS ueberschrieben. Die
# zweite Datei blieb schlicht liegen.
#
# DIESES SKRIPT AENDERT NICHTS. Es liest die Quelle, rechnet dieselbe Zaehmung
# nach und zeigt, welche Namen zusammenfallen -- damit die Entscheidung darueber
# auf Zahlen steht und nicht auf einer Vermutung.
#
# pwsh -File tools\kollisionen.ps1 -Von 'CDN/items'
# pwsh -File tools\kollisionen.ps1 -Von 'CDN/items' -Csv befund.csv
param(
[Parameter(Mandatory)][string]$Von,
[string]$Csv = ''
)
$ErrorActionPreference = 'Stop'
# ------------------------------------------------- Zugang aus dem Dashboard
#
# Dieselbe Quelle wie Sicherung und Import: die Werte stehen im Panel unter
# Einstellungen und damit in media.db -- so verlaesst das Passwort den Rechner
# nicht.
function hol($schluessel) {
docker exec d4rk-media node -e @"
const db = require('better-sqlite3')('/data/media.db', { readonly: true })
const z = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel')
process.stdout.write(z ? z.value : '')
"@
}
$nc = @{
basis = hol 'sicherung.basis'
benutzer = hol 'sicherung.benutzer'
passwort = hol 'sicherung.passwort'
}
if (-not ($nc.basis -and $nc.benutzer -and $nc.passwort)) {
Write-Host 'Die Nextcloud-Werte fehlen. Dashboard -> Einstellungen -> Sicherung.' -ForegroundColor Red
exit 1
}
$auth = "$($nc.benutzer):$($nc.passwort)"
$wurzel = "$($nc.basis.TrimEnd('/'))/remote.php/dav/files/$($nc.benutzer)"
# ------------------------------------------------------------ Quelle lesen
Write-Host "Lese $Von ..." -ForegroundColor Cyan
$antwort = curl.exe -s -u $auth -X PROPFIND -H 'Depth: infinity' "$wurzel/$Von/"
# -join, NICHT direkt auf das Zeilen-Array: curl.exe gibt unter PowerShell ein
# Array zurueck, und -match darauf FILTERT, statt zu pruefen. Zweimal darauf
# hereingefallen, deshalb steht es hier.
$roh = $antwort -join "`n"
if ($roh -notmatch '<d:multistatus|<D:multistatus') {
Write-Host 'Keine brauchbare Antwort von der Nextcloud:' -ForegroundColor Red
Write-Host $roh.Substring(0, [Math]::Min(300, $roh.Length))
exit 1
}
$praefix = "/remote.php/dav/files/$($nc.benutzer)/$Von/"
$dateien = [regex]::Matches($roh, '<d:href>([^<]+)</d:href>') |
ForEach-Object { [uri]::UnescapeDataString($_.Groups[1].Value) } |
Where-Object { $_ -notmatch '/$' } |
ForEach-Object { $_ -replace [regex]::Escape($praefix), '' }
Write-Host " $($dateien.Count) Dateien" -ForegroundColor DarkGray
# --------------------------------------------- Dieselbe Zaehmung nachrechnen
#
# Wort fuer Wort aus server/src/storage.ts (safeFilename). Weicht das je
# auseinander, zeigt dieses Skript etwas anderes an als der Dienst tut -- und
# das waere schlimmer als kein Skript.
function Zaehmen([string]$s) {
$s = $s.ToLowerInvariant()
$s = $s -replace 'ä', 'ae' -replace 'ö', 'oe' -replace 'ü', 'ue' -replace 'ß', 'ss'
$s = $s -replace '[^a-z0-9._-]+', '-'
$s = $s -replace '-{2,}', '-'
$s = $s -replace '^[^a-z0-9]+', ''
$s = $s -replace '[-.]+$', ''
return $s
}
function SichererName([string]$name) {
$punkt = $name.LastIndexOf('.')
$stamm = if ($punkt -gt 0) { $name.Substring(0, $punkt) } else { $name }
$endung = if ($punkt -gt 0) { $name.Substring($punkt + 1) } else { '' }
$basis = Zaehmen $stamm
$ende = Zaehmen $endung
if (-not $basis) { $basis = 'datei' }
if ($ende) { return "$basis.$ende" } else { return $basis }
}
# ------------------------------------------------------------- Gruppieren
$gruppen = @{}
foreach ($d in $dateien) {
# Unterordner bleiben erhalten, jedes Stueck einzeln gezaehmt -- genau wie
# beim Import.
$ziel = ($d -split '/' | ForEach-Object { SichererName $_ }) -join '/'
if (-not $gruppen.ContainsKey($ziel)) { $gruppen[$ziel] = @() }
$gruppen[$ziel] += $d
}
$kollisionen = $gruppen.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 } | Sort-Object Name
$verloren = ($kollisionen | ForEach-Object { $_.Value.Count - 1 } | Measure-Object -Sum).Sum
if (-not $verloren) { $verloren = 0 }
Write-Host ''
Write-Host "Quelldateien : $($dateien.Count)"
Write-Host "Verschiedene Ziele: $($gruppen.Count)"
Write-Host "Kollisionsgruppen : $($kollisionen.Count)"
Write-Host "Ohne eigenen Platz: $verloren" -ForegroundColor $(if ($verloren) { 'Yellow' } else { 'Green' })
Write-Host ''
if ($kollisionen.Count -eq 0) {
Write-Host 'Nichts faellt zusammen.' -ForegroundColor Green
exit 0
}
# ------------------------------------------------------------------- Zeigen
#
# GLEICHER INHALT ODER NICHT -- das ist die Frage, an der die Entscheidung
# haengt. Deshalb steht die Groesse dabei: gleiche Groesse heisst mit hoher
# Wahrscheinlichkeit dieselbe Datei unter anderer Schreibweise, und dann fehlt
# nichts.
$groessen = @{}
foreach ($m in [regex]::Matches($roh, '(?s)<d:response>.*?</d:response>')) {
$t = $m.Value
if ($t -match '<d:href>([^<]+)</d:href>' ) {
$href = [uri]::UnescapeDataString($matches[1])
if ($t -match '<d:getcontentlength>(\d+)</d:getcontentlength>') {
$groessen[($href -replace [regex]::Escape($praefix), '')] = [int]$matches[1]
}
}
}
$zeilen = foreach ($k in $kollisionen) {
$gleich = ($k.Value | ForEach-Object { $groessen[$_] } | Select-Object -Unique).Count -eq 1
foreach ($q in $k.Value) {
[pscustomobject]@{
Ziel = $k.Name
Quelle = $q
Bytes = $groessen[$q]
GleicheGroesse = $gleich
}
}
}
$zeilen | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
$gleicheGruppen = ($kollisionen | Where-Object {
($_.Value | ForEach-Object { $groessen[$_] } | Select-Object -Unique).Count -eq 1
}).Count
Write-Host ''
Write-Host "Davon Gruppen mit DURCHWEG GLEICHER Groesse: $gleicheGruppen von $($kollisionen.Count)"
Write-Host 'Gleiche Groesse heisst fast immer: dieselbe Datei, andere Schreibweise. Dann fehlt nichts.'
if ($Csv) {
$zeilen | Export-Csv -Path $Csv -NoTypeInformation -Encoding utf8 -Delimiter ';'
Write-Host ''
Write-Host "Geschrieben nach $Csv" -ForegroundColor Green
}
+114
View File
@@ -0,0 +1,114 @@
# Sitzt der NPM-Block? Eine Antwort, keine Vermutung.
#
# pwsh -File tools\proxy-pruefen.ps1
#
# Nach dem Einfuegen von docs\npm-advanced.conf und "Save" in NPM ausfuehren.
# Das Skript AENDERT NICHTS -- es misst nur, was ueber die Leitung kommt.
#
# WARUM ES DAS GIBT: der Block wurde schon einmal fuer eingefuegt gehalten und
# war es nicht. Die Kopfzeile sagt es eindeutig, aber man muss sie eben ansehen.
param(
[string]$Basis = 'https://media.d4rkst3r.de',
# Fuer die API-Messung. Ohne ihn wird sie uebersprungen -- sie braucht einen
# gueltigen Token, um "mit" und "ohne" unterscheiden zu koennen.
[string]$Token = ''
)
$ErrorActionPreference = 'Continue'
$fehler = 0
function Kopf([string]$url, [hashtable]$header = @{}) {
try {
$a = Invoke-WebRequest -Uri $url -Method Head -Headers $header -SkipHttpErrorCheck `
-MaximumRedirection 0 -ErrorAction Stop
return @{ code = [int]$a.StatusCode; kopf = $a.Headers }
} catch {
return @{ code = 0; kopf = @{}; fehler = $_.Exception.Message }
}
}
function Wert($kopf, [string]$name) {
foreach ($k in $kopf.Keys) {
if ($k -ieq $name) { return ($kopf[$k] -join ', ') }
}
return ''
}
function Zeile([string]$was, [bool]$gut, [string]$gemessen) {
$zeichen = if ($gut) { ' [ok] ' } else { ' [!!] ' }
$farbe = if ($gut) { 'Green' } else { 'Red' }
Write-Host ($zeichen + $was.PadRight(42) + $gemessen) -ForegroundColor $farbe
if (-not $gut) { $script:fehler++ }
}
Write-Host ''
Write-Host "Messe $Basis" -ForegroundColor Cyan
Write-Host ''
# -- 1. Die Dateien ------------------------------------------------------------
#
# Unsere Vorgabe ist "public, max-age=300". Kommt etwas anderes an, hat
# assets.conf sie ueberschrieben -- dann fehlt der Block.
$f = Kopf "$Basis/f/vehicles/adder.webp"
$cc = Wert $f.kopf 'Cache-Control'
Zeile '/f/ Cache-Control' ($cc -match 'max-age=300') $(if ($cc) { $cc } else { '(keine)' })
# -- 2. Die Vorschaubilder -----------------------------------------------------
$t = Kopf "$Basis/t/vehicles/adder.webp"
$cc = Wert $t.kopf 'Cache-Control'
Zeile '/t/ Cache-Control' ($cc -match 'max-age=60\b') $(if ($cc) { $cc } else { '(keine)' })
# -- 3. Die API, und das ist die entscheidende ---------------------------------
#
# EIN EINZELNER AUFRUF TAUGT HIER NICHT, und das ist beim Schreiben dieses
# Skripts aufgefallen: "ohne Token -> 401" sah nach Erfolg aus, war aber nur
# ein zwischengespeicherter 401, den der Proxy an ALLE ausliefert -- gemessen
# gab er ihn sogar Aufrufern MIT gueltigem Token.
#
# Verlaesslich ist nur das Paar auf einer FRISCHEN Adresse, die noch in keinem
# Zwischenspeicher liegt:
#
# mit Token -> 200 (der Dienst antwortet)
# ohne Token -> 401 (der Proxy hat NICHT weggelegt) <- gut
# ohne Token -> 200 (er hat weggelegt) <- Block fehlt
#
# Die Adresse endet auf ".webp", damit sie ueberhaupt in die assets.conf-Regel
# faellt -- ohne diese Endung wuerde die Messung nichts messen.
if (-not $Token) {
Write-Host ' [--] /api/ (uebersprungen) kein -Token uebergeben' -ForegroundColor DarkGray
Write-Host ' Dashboard -> Token -> einen anlegen, dann:' -ForegroundColor DarkGray
Write-Host ' pwsh -File tools\proxy-pruefen.ps1 -Token d4rk_...' -ForegroundColor DarkGray
} else {
$zufall = -join ((1..12) | ForEach-Object { 'abcdefghijklmnopqrstuvwxyz0123456789'[(Get-Random -Max 36)] })
$probe = "$Basis/api/exists/probe-$zufall.webp"
$mit = Kopf $probe @{ Authorization = "Bearer $Token" }
Zeile '/api/ mit Token -> 200' ($mit.code -eq 200) "HTTP $($mit.code)"
$ohne = Kopf $probe
Zeile '/api/ dieselbe Adresse ohne Token -> 401' ($ohne.code -eq 401) `
$(if ($ohne.code -eq 200) { 'HTTP 200 — der Proxy gibt sie weiter!' } else { "HTTP $($ohne.code)" })
}
# Die Abfrageform ist von sich aus dicht -- sie muss auch OHNE den Block 401
# geben. Faellt SIE durch, stimmt etwas am Dienst und nicht am Proxy.
$b = Kopf "$Basis/api/exists?pfad=vehicles/adder.webp"
Zeile '/api/exists?pfad ohne Token -> 401' ($b.code -eq 401) "HTTP $($b.code)"
Write-Host ''
if ($fehler -eq 0) {
Write-Host ' Der Block sitzt. Nichts weiter zu tun.' -ForegroundColor Green
} else {
Write-Host " $fehler Messung(en) sagen: der Block ist NICHT wirksam." -ForegroundColor Red
Write-Host ''
Write-Host ' Naechste Schritte:' -ForegroundColor Yellow
Write-Host ' 1. In NPM den Proxy-Host oeffnen -> Advanced -> Inhalt von'
Write-Host ' docs\npm-advanced.conf einfuegen -> SAVE druecken.'
Write-Host ' 2. Gegenpruefen, was NPM daraus gemacht hat:'
Write-Host ' docker exec npm sh -c "grep -n ''location'' /data/nginx/proxy_host/9.conf"'
Write-Host ' Stehen dort ^~ /f/, ^~ /t/ und ^~ /api/, ist gespeichert worden.'
Write-Host ' 3. Dieses Skript erneut ausfuehren.'
}
Write-Host ''
exit $(if ($fehler -eq 0) { 0 } else { 1 })
+201
View File
@@ -0,0 +1,201 @@
"""Ein Rundgang durch den LAUFENDEN Dienst.
python tools/rundgang.py <- gegen PUBLIC_URL aus .env
python tools/rundgang.py http://localhost:8096
WOZU, wo doch jede Aenderung einzeln gemessen wird: weil sie einzeln gemessen
wird. An einem Tag mit sechs Neustarts fasst niemand mehr alles an, und die
Sachen, die keiner mehr anfasst, sind genau die, die kaputtgehen -- ein WebP,
das nach einem Umbau nicht mehr ausgehandelt wird, meldet sich nicht.
WAS ER NICHT IST: ein Ersatz fuer die Tests. Er prueft nicht, ob eine Funktion
das Richtige rechnet, sondern ob der Dienst, so wie er gerade laeuft, die
Sachen noch tut, die er koennen muss. Deshalb geht er ueber HTTP und nicht am
Code vorbei.
Er braucht das Admin-Passwort aus der .env, liest sie aber nur -- und laeuft
NUR LESEND: kein Upload, kein Loeschen, keine Einstellung wird angefasst.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
WURZEL = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ENV = dict(re.findall(r'^([A-Z_]+)=(.*)$',
open(os.path.join(WURZEL, '.env'), encoding='utf-8').read(), re.M))
BASIS = (sys.argv[1] if len(sys.argv) > 1 else ENV.get('PUBLIC_URL', '')).rstrip('/')
if not BASIS:
sys.exit('Keine Adresse: weder als Argument noch als PUBLIC_URL in der .env.')
ABLAGE = tempfile.mkdtemp(prefix='rundgang-')
KEKS = os.path.join(ABLAGE, 'keks.txt')
RUMPF = os.path.join(ABLAGE, 'rumpf.bin')
CRLF = b'\r\n'
gut, schlecht = 0, 0
def curl(*args, keks=True):
"""Der Rumpf geht in eine DATEI und nicht durch die Standardausgabe: ein PNG
durch einen Textkanal zu ziehen, endet unter Windows an cp1252 -- und der
Rundgang stirbt dann an der Datei, die er gerade erfolgreich geholt hat."""
befehl = ['curl', '-s', '-o', RUMPF, '-w', '%{http_code}|%{size_download}|%{content_type}']
if keks:
befehl += ['-b', KEKS]
r = subprocess.run(befehl + list(args), capture_output=True, text=True, timeout=90)
code, groesse, typ = (r.stdout.split('|') + ['', '', ''])[:3]
try:
text = open(RUMPF, 'rb').read().decode('utf-8')
except (UnicodeDecodeError, FileNotFoundError):
text = ''
return code, int(groesse or 0), typ, text
def pruefe(name, bedingung, gemessen):
global gut, schlecht
if bedingung:
gut += 1
print(f' ok {name:<44} {gemessen}')
else:
schlecht += 1
print(f' NEIN {name:<44} {gemessen}')
print(f'Rundgang gegen {BASIS}\n')
# -- Anmelden -----------------------------------------------------------------
r = subprocess.run(
['curl', '-s', '-c', KEKS, '-X', 'POST', f'{BASIS}/api/dash/auth/login',
'-H', 'content-type: application/json', '--data-binary', '@-',
'-o', os.devnull, '-w', '%{http_code}'],
input=json.dumps({'username': ENV.get('ADMIN_USERNAME', 'admin'),
'password': ENV['ADMIN_PASSWORD']}),
capture_output=True, text=True,
)
pruefe('Anmelden', r.stdout == '200', f'HTTP {r.stdout}')
if r.stdout != '200':
sys.exit('Ohne Anmeldung hat der Rest keinen Sinn.')
# -- Wer nicht angemeldet ist, kommt nicht rein --------------------------------
#
# Steht ZUERST, weil es die Sorte Fehler ist, die man nicht sieht: eine Route,
# die versehentlich vor dem Waechter registriert wird, funktioniert praechtig --
# fuer alle. Genau das ist hier schon zweimal passiert.
print('\n-- Was ohne Anmeldung dicht sein muss ' + '-' * 32)
for weg in ('/api/dash/media', '/api/dash/tokens', '/api/dash/verlauf/export',
'/api/dash/papierkorb', '/api/dash/benutzer', '/api/dash/settings',
'/api/dash/einstellungen/export', '/api/dash/verwaltung',
'/api/dash/sicherungen'):
code, *_ = curl(f'{BASIS}{weg}', keks=False)
pruefe(weg, code == '401', f'HTTP {code}')
print('\n-- Was ohne Anmeldung offen sein muss ' + '-' * 32)
for weg in ('/health', '/status'):
code, *_ = curl(f'{BASIS}{weg}', keks=False)
pruefe(weg, code == '200', f'HTTP {code}')
# -- Der Bestand ---------------------------------------------------------------
print('\n-- Der Bestand ' + '-' * 55)
code, _, _, rumpf = curl(f'{BASIS}/api/dash/uebersicht')
u = json.loads(rumpf) if code == '200' else {}
pruefe('Uebersicht', code == '200' and u.get('files', 0) > 0, f"{u.get('files')} Dateien")
code, _, _, rumpf = curl(f'{BASIS}/api/dash/media?limit=1')
erstes = ((json.loads(rumpf).get('items') if code == '200' else None) or [{}])[0]
pruefe('Mediathek blaettert', bool(erstes.get('path')), erstes.get('path', '-'))
# AUSDRUECKLICH EIN PNG, und das ist zweimal danebengegangen:
#
# 1. Ohne Filter kam "vehicles/asbo.webp" -- da vergleicht der Rundgang WebP
# mit WebP, findet gleiche Groessen und meldet einen Fehler, den der Dienst
# nicht hat.
# 2. Der Filter hiess dann "q=" statt "query=". Unbekannte Parameter
# ignoriert der Dienst, liefert also ALLES -- was wie ein kaputter Filter
# aussieht und ein kaputter Aufruf ist.
#
# Ein Test, der das Falsche misst, ist schlimmer als keiner: er wird geglaubt.
code, _, _, rumpf = curl(f'{BASIS}/api/dash/media?query=.png&limit=5')
png = next((i for i in (json.loads(rumpf).get('items') or [])
if i['path'].lower().endswith('.png')), {})
pruefe('Ein PNG zum Vergleichen', bool(png.get('path')), png.get('path', 'keines im Bestand'))
# -- Ausliefern ----------------------------------------------------------------
print('\n-- Eine Datei wirklich ausliefern ' + '-' * 36)
if png.get('path'):
ziel = f"{BASIS}/f/{png['path']}"
code, groesse, typ, _ = curl(ziel, keks=False)
pruefe('/f/ liefert aus', code == '200' and groesse > 0, f'{groesse} B, {typ}')
# WebP an DERSELBEN Adresse -- der Kern der Aushandlung, und die Stelle, an
# der der Zwischenspeicher des Proxys schon zweimal danebengelegen hat.
code, gw, tw, _ = curl('-H', 'Accept: image/webp', ziel, keks=False)
pruefe('WebP an derselben Adresse', code == '200' and tw == 'image/webp', f'{gw} B, {tw}')
pruefe('WebP ist kleiner', 0 < gw < groesse, f'{gw} B statt {groesse} B')
r = subprocess.run(['curl', '-s', '-D', '-', '-o', os.devnull,
'-H', 'Accept: image/webp', ziel], capture_output=True, text=True)
hat = 'vary: accept' in r.stdout.lower()
pruefe('Vary: Accept gesetzt', hat, 'ja' if hat else 'FEHLT -- der Proxy mischt die Fassungen')
# -- Verlauf als CSV -----------------------------------------------------------
print('\n-- Der Verlauf als CSV ' + '-' * 47)
for anhang, name in (('', 'dateien'), ('?was=verwaltung', 'verwaltung')):
datei = os.path.join(ABLAGE, f'{name}.csv')
r = subprocess.run(['curl', '-s', '-b', KEKS, '-D', '-', '-o', datei,
f'{BASIS}/api/dash/verlauf/export{anhang}'],
capture_output=True, text=True)
roh = open(datei, 'rb').read()
nackte = roh.count(b'\n') - roh.count(b'\r\n')
# Nackte LF sind der Punkt: Excel packt bei ihnen die halbe Datei in eine
# Zelle, und das faellt erst dem auf, der die Datei braucht.
pruefe(f'CSV {name}', name in r.stdout and nackte == 0,
f'{roh.count(CRLF)} Zeilen, {nackte} nackte LF')
# -- Die Archive zum Mitnehmen -------------------------------------------------
print(chr(10) + '-- Die Archive zum Mitnehmen ' + '-' * 40)
code, _, _, rumpf = curl(f'{BASIS}/api/dash/sicherungen')
sich = json.loads(rumpf)
pruefe('Sicherungsordner eingehaengt', sich.get('eingehaengt') is True,
f"{len(sich.get('items', []))} Archive in {sich.get('ordner')}")
# Der Weg nach draussen ist der, den man pruefen MUSS: dort liegt der ganze
# Bestand als eine Datei, und ein Muster, das zu viel durchlaesst, gaebe jede
# Datei des Servers heraus. Geprueft wird deshalb, was NICHT gehen darf.
for boese, was in (('../../etc/passwd', 'Pfad hoch'),
('..%2f..%2fdata%2fmedia.db', 'kodiert hoch'),
('media.db', 'andere Datei'),
('d4rk_media-2026-01-01-0000.tar.gz.evil', 'Anhang drangehaengt')):
code, *_ = curl(f'{BASIS}/api/dash/sicherungen/{boese}')
pruefe(f'abgewiesen: {was}', code in ('400', '404'), f'HTTP {code}')
# -- Papierkorb und Sicherung --------------------------------------------------
print('\n-- Papierkorb und Sicherung ' + '-' * 42)
code, _, _, rumpf = curl(f'{BASIS}/api/dash/papierkorb')
pk = json.loads(rumpf)
pruefe('Deckel steht in der Antwort', pk.get('maxBytes', 0) > 0,
f"{round(pk.get('maxBytes', 0) / 1024 ** 3, 1)} GB")
code, _, _, rumpf = curl(f'{BASIS}/api/dash/settings')
einst = json.loads(rumpf)
sic, letzte = einst['sicherung'], (einst['sicherung'].get('letzte') or {})
pruefe('Zweites Ziel eingetragen', bool(sic.get('zweitziel')),
sic.get('zweitziel') or 'keines -- die Sicherung liegt auf EINER Platte')
pruefe('Letzte Sicherung gut', letzte.get('ok') is True,
f"ok={letzte.get('ok')}, zweit={letzte.get('zweit')}, hoch={letzte.get('hoch')}")
code, _, _, rumpf = curl(f'{BASIS}/status?streng=1', keks=False)
st = json.loads(rumpf)
schief = [p['was'] for p in st['pruefungen'] if not p['ok']]
pruefe('Alle Pruefungen der Statusseite gruen', not schief, ', '.join(schief) or 'alle')
for p in st['pruefungen']:
if p.get('hinweis'):
print(f" {p['was']}: {p['hinweis']}")
print('\n' + '=' * 70)
print(f' {gut} in Ordnung, {schlecht} nicht.')
sys.exit(1 if schlecht else 0)
+469
View File
@@ -0,0 +1,469 @@
# Sicherung von d4rk_media: Datenbank und Bilder in EIN Archiv.
#
# Aufruf:
# pwsh -File tools\sichern.ps1
# pwsh -File tools\sichern.ps1 -Ziel D:\woanders -Behalten 30
#
# Was passiert, und warum in dieser Reihenfolge:
#
# 1. Die Datenbank wird IM laufenden Container ueber SQLites eigene
# Sicherungsschnittstelle als eine in sich stimmige Datei abgelegt
# (dist/backup.js). Ein blosses Kopieren waere wegen des WAL still
# unvollstaendig.
# 2. Ein zweiter, kurzlebiger Container packt Bilder und diese Aufnahme in
# ein tar.gz. Er haengt das Volume NUR LESEND ein — eine Sicherung, die
# schreiben kann, kann auch kaputtmachen.
# 3. Das Archiv wird GEPRUEFT: ausgepackt, die Datenbank geoeffnet, die
# Eintraege gezaehlt und mit dem laufenden Dienst verglichen. Eine
# Sicherung, die niemand nachzaehlt, ist eine Vermutung.
# 4. Das Archiv geht auf ein ZWEITES LAUFWERK, wenn im Panel eines steht
# (Einstellungen → Sicherung → "Zweites Ziel"). Auch hier wird die
# Pruefsumme verglichen — eine Kopie, die niemand nachzaehlt, ist eine
# Vermutung.
#
# WOZU, wo es doch die Nextcloud gibt: die beiden beantworten zwei
# verschiedene Fragen. Die Nextcloud hilft, wenn der ganze Rechner weg
# ist — aber wer 100 GB zurueckholen muss, laedt sie ueber die Leitung.
# Das zweite Laufwerk ist in Minuten zurueckgespielt. Es hilft nur, wenn
# es eine ANDERE PLATTE ist: ein zweiter Ordner auf derselben sieht aus
# wie eine Sicherung und ist keine.
#
# 4a. Das Archiv geht in die NEXTCLOUD, wenn im Panel Zugangsdaten stehen
# (Einstellungen → Sicherung). Bis dahin lag die Sicherung auf derselben
# Platte wie die Daten — gegen einen Plattenausfall half sie gar nicht.
# 4c. Und der Lauf MELDET SICH ZURUECK: das Ergebnis landet in der Datenbank
# und steht danach im Panel. Eine Sicherung, die still aufhoert zu laufen,
# ist der eigentliche Schaden — nicht die eine Nacht, in der sie fehlt.
# 5. Alte Archive fliegen raus, an ALLEN drei Orten, sobald mehr als
# -Behalten da sind.
#
# WARUM localhost UND NICHT DER OEFFENTLICHE NAME. Am 13.08.2026 scheiterte der
# naechtliche Upload mit HTTP 413. Nachgemessen mit drei Groessen:
#
# 1 KB -> 201 | 60 MB -> 201 | 340 MB -> 413, "Server: cloudflare"
#
# cdn.d4rkst3r.de laeuft ueber Cloudflare, und das deckelt Uploads im
# Gratistarif bei 100 MB. Das Archiv ist 336 MB.
#
# Der Umweg war ohnehin unnoetig: die Nextcloud laeuft auf DERSELBEN Maschine.
# Ueber http://localhost:11000 geht der Verkehr gar nicht erst hinaus -- kein
# Cloudflare, kein Proxy, kein Zertifikat, und nebenbei fuenfzehnmal schneller:
#
# ueber 192.168.100.1:11000 340 MB in 59 s
# ueber localhost:11000 340 MB in 4 s
#
# Dass dabei kein TLS im Spiel ist, ist in Ordnung: die Verbindung verlaesst die
# Rueckschleife nicht. "localhost" steht bereits in Nextclouds trusted_domains.
#
# WICHTIG, DAMIT NIEMAND ES FALSCH LIEST: diese Kopie ist KEINE Auswaertskopie.
# Die Nextcloud liegt im selben Docker-Volume-Bereich auf derselben Platte wie
# die Daten. Sie ist die dritte Kopie, nicht der Weg aus dem Haus -- der fehlt
# weiterhin.
#
# WARUM CURL UND NICHT RCLONE. Nextcloud spricht WebDAV, curl spricht WebDAV,
# und curl ist ohnehin da. rclone waere ein weiteres Programm, das installiert,
# konfiguriert und aktuell gehalten werden will — fuer PUT, PROPFIND und DELETE
# lohnt das nicht.
#
# NICHT MIT DRIN sind die Vorschaubilder unter /data/thumbs, und das ist
# Absicht: sie lassen sich aus den Bildern jederzeit neu rechnen (Dashboard →
# Speicher → "Fehlende Vorschaubilder erzeugen"). Sie mitzusichern hiesse, jede
# Nacht Abgeleitetes mitzuschleppen. Wer zurueckspielt, drueckt den Knopf.
#
# Der Dienst laeuft die ganze Zeit weiter. Nichts hier haelt ihn an.
[CmdletBinding()]
param(
[string]$Ziel = 'C:\backup\d4rk_media',
[string]$Container = 'd4rk-media',
[string]$Volume = 'd4rk_media_media-data',
[string]$Abbild = 'd4rk-media:latest',
[int]$Behalten = 14,
# Leer heisst: aus den Einstellungen im Panel holen. Ein Wert hier hat
# Vorrang -- fuer den Fall, dass jemand einmalig woanders hinsichern will.
[string]$Zweitziel = ''
)
$ErrorActionPreference = 'Stop'
function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
function Gut($text) { Write-Host " $text" -ForegroundColor Green }
function Schlecht($text) { Write-Host " $text" -ForegroundColor Red }
# Das Ergebnis in die Datenbank des Dienstes schreiben, damit es im Panel
# steht. Schlaegt DAS fehl, ist es kein Grund, die Sicherung als gescheitert zu
# behandeln — das Archiv liegt dann trotzdem richtig.
$inDerWolke = $false
$aufZweiter = $null
function Melde($ok, $dateien, $groesse, $hoch, $fehler) {
$eintrag = @{
at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
ok = [bool]$ok
dateien = $dateien
groesse = $groesse
hoch = [bool]$hoch
}
# $null heisst "kein zweites Ziel eingerichtet" und steht dann GAR NICHT im
# Bericht -- ein Feld, das immer "false" sagt, liest sich wie "hat nicht
# geklappt", und das waere eine Falschmeldung.
if ($null -ne $script:aufZweiter) { $eintrag['zweit'] = [bool]$script:aufZweiter }
if ($fehler) { $eintrag['fehler'] = "$fehler" }
$json = ($eintrag | ConvertTo-Json -Compress)
# Ueber die Standardeingabe und nicht als Argument: der JSON-Text enthaelt
# Anfuehrungszeichen, und die ueberleben den Weg durch zwei Shells nicht.
$skript = @"
let roh = '';
process.stdin.on('data', (d) => (roh += d)).on('end', () => {
const db = require('better-sqlite3')('/data/media.db');
db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' +
'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
.run('sicherung.letzte', roh.trim(), Date.now());
});
"@ -replace "`r", ''
$json | docker exec -i $Container node -e $skript 2>&1 | Out-Null
}
# -- Vorbedingungen ------------------------------------------------------------
$laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}'
if ($laeuft -ne $Container) {
Schlecht "Der Container '$Container' laeuft nicht. Ohne ihn gibt es keine stimmige Aufnahme der Datenbank."
exit 1
}
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
$stempel = Get-Date -Format 'yyyy-MM-dd-HHmm'
$name = "d4rk_media-$stempel.tar.gz"
$archiv = Join-Path $Ziel $name
# -- 1. Datenbank stimmig herausholen ------------------------------------------
Schritt 'Datenbank sichern (SQLite-Sicherungsschnittstelle im Container)'
# Auch hier zusammenfuegen: die Ausgabe kaeme sonst als Array, und -match
# darauf filtert, statt zu pruefen (siehe uebernehmen.ps1). Solange es eine
# Zeile ist, faellt das nicht auf -- bei zweien schon.
$ausgabe = (docker exec $Container node dist/backup.js /data/.sicherung/media.db 2>&1) -join "`n"
if ($LASTEXITCODE -ne 0) {
Schlecht "Fehlgeschlagen: $ausgabe"
exit 1
}
Gut $ausgabe
# Die erwartete Zeilenzahl steht in der Meldung -- sie ist der Massstab fuer
# Schritt 3.
$erwartet = if ($ausgabe -match '(\d+) Medieneintraege') { [int]$Matches[1] } else { -1 }
# -- 2. Alles in ein Archiv ----------------------------------------------------
Schritt 'Bilder und Datenbank packen'
# Drei -C: die Bilder kommen als files/... in das Archiv, die Aufnahme als
# media.db daneben, und der Papierkorb als papierkorb/. Bewusst das eigene
# Abbild statt alpine -- dort ist GNU tar drin, und das kann mehrere -C.
#
# DER PAPIERKORB MUSS MIT, und das ist nachgerechnet und nicht Vorsicht: seine
# EINTRAEGE stehen in media.db und waren damit laengst gesichert -- die DATEIEN
# lagen unter /data/papierkorb und waren es nicht. Nach einem Zurueckspielen
# haette der Papierkorb also Zeilen gezeigt, deren "zurueckholen" ins Leere
# greift. Ein Papierkorb, der ein Versprechen gibt, das er nach einer
# Wiederherstellung nicht halten kann, ist schlimmer als keiner.
#
# Er kostet fast nichts: was darin liegt, ist hoechstens 30 Tage alt, danach
# raeumt der Dienst selbst auf.
#
# Die Abfrage davor ist noetig: hat noch nie jemand etwas geloescht, gibt es den
# Ordner gar nicht, und tar braeche mit "Cannot stat" ab. Eine Sicherung, die
# daran scheitert, dass nichts im Papierkorb liegt, waere absurd. Das Volume
# haengt NUR LESEND -- ein `mkdir` als Ausweg ginge hier also ohnehin nicht.
$packen = "if [ -d /data/papierkorb ]; then tar czf '/out/$name' -C /data files -C /data/.sicherung media.db -C /data papierkorb; else tar czf '/out/$name' -C /data files -C /data/.sicherung media.db; fi"
docker run --rm `
-v "${Volume}:/data:ro" `
-v "${Ziel}:/out" `
--entrypoint sh $Abbild -c $packen
if ($LASTEXITCODE -ne 0) { Schlecht 'Packen fehlgeschlagen.'; exit 1 }
# Die Zwischenaufnahme im Volume wieder weg: sie gehoert nicht zum Bestand und
# wuerde bei der naechsten Sicherung mitgezaehlt.
docker exec $Container rm -rf /data/.sicherung | Out-Null
$groesse = (Get-Item $archiv).Length
Gut "$name$([math]::Round($groesse/1MB, 1)) MB"
# -- 3. Nachzaehlen ------------------------------------------------------------
Schritt 'Sicherung pruefen (auspacken, Datenbank oeffnen, zaehlen)'
# Die Wagenruecklaeufe muessen raus. Diese Datei liegt unter Windows und wird
# je nach Werkzeug mit CRLF gespeichert; die Shell im Container liest dann
# "set -e\r" und antwortet mit "Illegal option -". Der Fehler sieht aus wie ein
# Tippfehler im Skript und ist keiner — genau einmal reingefallen.
$skript = (@"
set -e
mkdir -p /tmp/p && cd /tmp/p
tar xzf '/in/$name'
# Aus /app heraus starten und nicht aus /tmp/p: Node sucht node_modules vom
# Arbeitsverzeichnis aufwaerts, und ueber /tmp liegt kein better-sqlite3.
cd /app
node -e "
const db = require('better-sqlite3')('/tmp/p/media.db', { readonly: true, fileMustExist: true });
const m = db.prepare('SELECT COUNT(*) AS n FROM media').get().n;
const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n;
const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
console.log(JSON.stringify({ media: m, tokens: t, users: u }));
"
find /tmp/p/files -type f | wc -l
"@) -replace "`r", ''
$pruefung = docker run --rm -v "${Ziel}:/in:ro" --entrypoint sh $Abbild -c $skript
if ($LASTEXITCODE -ne 0) { Schlecht "Pruefung fehlgeschlagen: $pruefung"; exit 1 }
$zeilen = @($pruefung -split "`n" | Where-Object { $_.Trim() })
$zahlen = $zeilen[0] | ConvertFrom-Json
$dateien = [int]$zeilen[1].Trim()
Gut "in der Sicherung: $($zahlen.media) Medieneintraege, $dateien Dateien, $($zahlen.tokens) Token, $($zahlen.users) Benutzer"
if ($erwartet -ge 0 -and $zahlen.media -ne $erwartet) {
Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege."
exit 1
}
# Eine Datei mehr oder weniger als Eintraege ist ein echter Befund und kein
# Rundungsfehler: dann liegt eine Datei ohne Datensatz herum oder umgekehrt.
if ($dateien -ne $zahlen.media) {
Write-Host " Hinweis: $dateien Dateien zu $($zahlen.media) Eintraegen — das sollte gleich sein." -ForegroundColor Yellow
}
Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.'
# -- Wohin ueberhaupt? Zugangsdaten und Ziele lesen ----------------------------
#
# Beides kommt aus derselben Quelle und wird deshalb an EINER Stelle geholt: die
# Nextcloud-Zugangsdaten und der Pfad des zweiten Laufwerks. Was danach kommt,
# sind die drei Ziele in der Reihenfolge, in der sie im Ernstfall gebraucht
# werden -- erst die Platte nebenan, dann die Wolke.
#
# Die Zugangsdaten liegen NEBEN den Sicherungen und nicht im Repo: sie gehoeren
# zu dieser Maschine, nicht zum Code. Fehlt die Datei, wird das einmal gesagt
# und lokal weitergesichert — eine Sicherung, die wegen eines fehlenden
# Passworts GAR NICHT stattfindet, waere der schlechtere Tausch.
#
# Aufbau der Datei (eine Zuweisung je Zeile, # ist ein Kommentar):
#
# basis = https://cdn.d4rkst3r.de
# benutzer = darkster
# passwort = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx <- APP-PASSWORT, nicht das echte
# ordner = Sicherungen/d4rk_media
#
# Das App-Passwort steht in Nextcloud unter Einstellungen → Sicherheit →
# "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne
# dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie.
# Die Werte kommen aus der Datenbank des Dienstes, eingetragen im Panel unter
# Einstellungen → Sicherung. Gelesen wird DIREKT ueber `docker exec` und nicht
# ueber die API: so verlaesst das Passwort den Rechner nie.
#
# nextcloud.txt neben den Sicherungen gilt weiterhin und hat VORRANG — wer sie
# angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird.
$z = @{}
$zugangDatei = Join-Path $Ziel 'nextcloud.txt'
if (Test-Path $zugangDatei) {
foreach ($zeile in Get-Content $zugangDatei) {
if ($zeile -match '^\s*#') { continue }
if ($zeile -match '^\s*([a-z]+)\s*=\s*(.+?)\s*$') { $z[$Matches[1]] = $Matches[2] }
}
Gut "Zugang aus $zugangDatei"
} else {
$roh = docker exec $Container node -e @"
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
const hol = (k) => { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(k); return r ? r.value : ''; };
console.log(JSON.stringify({
basis: hol('sicherung.basis'),
benutzer: hol('sicherung.benutzer'),
passwort: hol('sicherung.passwort'),
ordner: hol('sicherung.ordner'),
behalten: hol('sicherung.behalten'),
zweitziel: hol('sicherung.zweitziel'),
}));
"@
if ($LASTEXITCODE -eq 0 -and $roh) {
$j = $roh | ConvertFrom-Json
foreach ($k in 'basis', 'benutzer', 'passwort', 'ordner', 'behalten', 'zweitziel') {
if ($j.$k) { $z[$k] = $j.$k }
}
}
}
if ($z['behalten']) { $Behalten = [int]$z['behalten'] }
if (-not $Zweitziel -and $z['zweitziel']) { $Zweitziel = $z['zweitziel'] }
# -- 4. Auf die zweite Platte --------------------------------------------------
#
# Das billigste Ziel von allen -- ein Kopiervorgang im selben Rechner -- und das
# einzige, aus dem sich hundert Gigabyte in Minuten zurueckholen lassen.
#
# GEPRUEFT WIRD MIT DER PRUEFSUMME und nicht mit der Dateigroesse: eine
# abgebrochene Kopie auf eine volle Platte hat oft genau die richtige Laenge und
# trotzdem Nullen am Ende.
#
# UND ES WIRD GEWARNT, WENN ES DIESELBE PLATTE IST -- das ist der eigentliche
# Fehler, gegen den das hier gebaut ist. Ein zweiter Ordner auf C: sieht im
# Panel genauso gruen aus wie eine echte zweite Platte und hilft gegen gar
# nichts. Verglichen wird die PHYSISCHE Platte und nicht der Laufwerksbuchstabe:
# zwei Partitionen derselben NVMe sterben zusammen.
function PlattenNummer($pfad) {
# $null heisst "kein Urteil moeglich" (Netzfreigabe, ungewoehnliche
# Einhaengung) und ist ausdruecklich nicht dasselbe wie "andere Platte".
try {
$wurzel = [System.IO.Path]::GetPathRoot((Resolve-Path $pfad).Path)
$buchstabe = $wurzel.TrimEnd('\', ':')
if ($buchstabe.Length -ne 1) { return $null }
return (Get-Partition -DriveLetter $buchstabe -ErrorAction Stop).DiskNumber
} catch { return $null }
}
if (-not $Zweitziel) {
Write-Host ' Kein zweites Ziel eingerichtet (Panel -> Einstellungen -> Sicherung).' -ForegroundColor Yellow
} else {
Schritt "Auf die zweite Platte: $Zweitziel"
try {
New-Item -ItemType Directory -Force -Path $Zweitziel -ErrorAction Stop | Out-Null
} catch {
Schlecht "Das zweite Ziel ist nicht erreichbar: $($_.Exception.Message)"
$aufZweiter = $false
Melde $false $zahlen.media $groesse $inDerWolke "Zweites Ziel: $($_.Exception.Message)"
exit 1
}
$hier = PlattenNummer $Ziel
$dort = PlattenNummer $Zweitziel
if ($null -ne $hier -and $hier -eq $dort) {
Write-Host " ACHTUNG: $Zweitziel liegt auf DERSELBEN Platte (Nr. $hier) wie $Ziel." -ForegroundColor Yellow
Write-Host ' Gegen einen Plattenausfall hilft das nicht -- ein anderes Laufwerk waehlen.' -ForegroundColor Yellow
} elseif ($null -ne $hier -and $null -ne $dort) {
Gut "andere Platte: Nr. $dort statt Nr. $hier"
}
$zweitDatei = Join-Path $Zweitziel $name
try {
Copy-Item $archiv $zweitDatei -Force -ErrorAction Stop
} catch {
Schlecht "Kopieren fehlgeschlagen: $($_.Exception.Message)"
$aufZweiter = $false
Melde $false $zahlen.media $groesse $inDerWolke "Zweites Ziel: $($_.Exception.Message)"
exit 1
}
$hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash
$dortHash = (Get-FileHash $zweitDatei -Algorithm SHA256).Hash
if ($hierHash -ne $dortHash) {
Schlecht 'Die Kopie auf der zweiten Platte ist NICHT identisch.'
Remove-Item $zweitDatei -Force -ErrorAction SilentlyContinue
$aufZweiter = $false
Melde $false $zahlen.media $groesse $inDerWolke 'Pruefsumme der zweiten Kopie weicht ab'
exit 1
}
Gut "kopiert und verglichen: identisch ($($hierHash.Substring(0,12))...)"
$aufZweiter = $true
# Auch dort ausduennen, sonst laeuft die zweite Platte still voll.
$dortAlle = Get-ChildItem $Zweitziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending
if ($dortAlle.Count -gt $Behalten) {
$dortAlle | Select-Object -Skip $Behalten | ForEach-Object {
Remove-Item $_.FullName -Force
Gut "auf der zweiten Platte entfernt: $($_.Name)"
}
}
}
# -- 4a. In die Nextcloud ------------------------------------------------------
if (-not ($z['basis'] -and $z['benutzer'] -and $z['passwort'])) {
Write-Host ' Keine Nextcloud eingerichtet (Panel → Einstellungen → Sicherung) — die Sicherung bleibt lokal.' -ForegroundColor Yellow
} else {
if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' }
$basis = $z['basis'].TrimEnd('/')
$wurzel = "$basis/remote.php/dav/files/$($z['benutzer'])"
$auth = "$($z['benutzer']):$($z['passwort'])"
Schritt "In die Nextcloud: $($z['ordner'])"
# Die Ordner Ebene fuer Ebene anlegen. MKCOL legt NUR EINE an; auf einen
# fehlenden Zwischenschritt antwortet es mit 409, und dann sucht man den
# Fehler beim Passwort.
$pfadBisher = ''
foreach ($teil in $z['ordner'].Split('/')) {
if (-not $teil) { continue }
$pfadBisher = if ($pfadBisher) { "$pfadBisher/$teil" } else { $teil }
# 201 = angelegt, 405 = gab es schon. Beides ist in Ordnung.
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X MKCOL "$wurzel/$pfadBisher"
if ($code -notin @('201', '405')) {
$fehlertext = "MKCOL $pfadBisher -> HTTP $code"
if ($code -eq '401') { $fehlertext += ' (Benutzer oder App-Passwort stimmt nicht)' }
Schlecht $fehlertext
Melde $false $zahlen.media $groesse $false $fehlertext
exit 1
}
}
$fern = "$wurzel/$($z['ordner'])/$name"
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $fern
if ($code -notin @('201', '204')) {
Schlecht "Hochladen fehlgeschlagen -> HTTP $code"
Melde $false $zahlen.media $groesse $false "Hochladen -> HTTP $code"
exit 1
}
Gut "hochgeladen ($code)"
# NACHZAEHLEN, auch hier: zurueckholen und die Pruefsumme vergleichen. Ein
# PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene
# Uebertragungen sehen genauso aus. Bei ein paar Megabyte ist das billig.
$zurueck = Join-Path $env:TEMP "pruefe-$name"
curl.exe -s -u $auth -o $zurueck $fern
$hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash
$dortHash = if (Test-Path $zurueck) { (Get-FileHash $zurueck -Algorithm SHA256).Hash } else { '' }
Remove-Item $zurueck -Force -ErrorAction SilentlyContinue
if ($hierHash -ne $dortHash) {
Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.'
Melde $false $zahlen.media $groesse $false 'Pruefsumme der Kopie weicht ab'
exit 1
}
Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)"
$inDerWolke = $true
# Auch draussen ausduennen. Sonst waechst die Nextcloud still voll, und das
# faellt erst auf, wenn sie voll ist.
$liste = (curl.exe -s -u $auth -X PROPFIND -H 'Depth: 1' "$wurzel/$($z['ordner'])") -join "`n"
$fernListe = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') |
ForEach-Object { $_.Value } | Sort-Object -Unique -Descending)
if ($fernListe.Count -gt $Behalten) {
foreach ($alt in $fernListe | Select-Object -Skip $Behalten) {
$c = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X DELETE "$wurzel/$($z['ordner'])/$alt"
if ($c -in @('204', '200')) { Gut "in der Nextcloud entfernt: $alt" }
else { Write-Host " konnte $alt nicht entfernen (HTTP $c)" -ForegroundColor Yellow }
}
}
}
# -- 5. Alte Sicherungen ausduennen (lokal) ------------------------------------
$alle = Get-ChildItem $Ziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending
if ($alle.Count -gt $Behalten) {
Schritt "Alte Sicherungen entfernen (mehr als $Behalten)"
$alle | Select-Object -Skip $Behalten | ForEach-Object {
Remove-Item $_.FullName -Force
Gut "entfernt: $($_.Name)"
}
}
Melde $true $zahlen.media $groesse $inDerWolke $null
Write-Host ''
Gut "Fertig: $archiv"
Write-Host " Zurueckspielen: tar xzf <archiv>, dann media.db, files/ und papierkorb/" -ForegroundColor DarkGray
Write-Host " ins Volume legen," -ForegroundColor DarkGray
Write-Host " waehrend der Container steht. Siehe ROADMAP." -ForegroundColor DarkGray
+235
View File
@@ -0,0 +1,235 @@
# Richtet den SSH-Tunnel zum Server als HINTERGRUNDAUFGABE ein.
#
# LÄUFT AUF DEM HEIMRECHNER, nicht auf dem Server.
#
# pwsh -File tunnel-einrichten.ps1
# pwsh -File tunnel-einrichten.ps1 -Benutzer Florian -LokalerPort 13390
#
# WOZU. Seit dem 13.08.2026 ist RDP am Server gesperrt (Hintergrund:
# docs/vorfall-2026-08-12-gitea.md). Hinein geht es nur noch durch einen
# SSH-Tunnel mit Schlüssel. Von Hand hieße das: vor jeder Sitzung ein
# PowerShell-Fenster öffnen, einen Befehl tippen, das Fenster offen lassen.
#
# Das hält niemand durch, und was niemand durchhält, wird umgangen -- am Ende
# steht dann wieder ein offener Port. Deshalb wird der Tunnel hier zu etwas,
# das man nicht sieht: eine Aufgabe, die beim Anmelden startet, sich bei
# Verbindungsabbruch selbst neu aufbaut und kein Fenster zeigt.
#
# Danach verbindet man sich wie immer -- nur nicht mehr auf die Server-Adresse,
# sondern auf localhost mit dem hier eingestellten Port.
#
# WAS ES BRAUCHT: einen SSH-Schlüssel, dessen öffentlicher Teil auf dem Server
# hinterlegt ist. Fehlt einer, legt dieses Skript ihn an und zeigt, was zu
# schicken ist.
[CmdletBinding()]
param(
[string]$Server = '88.218.224.10',
# KEIN Standard aus $env:USERNAME. Der lokale Windows-Name und das Konto auf
# dem Server sind oft verschieden -- hier hiess der eine "az319" und der
# andere "Darkster". Das Skript lief dann sauber durch und der Tunnel kam
# trotzdem nicht, mit einer Fehlermeldung, die auf den Schluessel zeigte
# statt auf den Namen. Lieber einmal fragen als einmal in die Irre fuehren.
[string]$Benutzer,
# Der Port auf DIESEM Rechner. Nicht 3389 nehmen: Windows lauscht dort
# selbst, und dann landet die Verbindung auf dem eigenen PC statt im Tunnel
# -- mit der irreführenden Meldung "Konsolensitzung bereits aktiv".
[int]$LokalerPort = 13389,
[int]$ZielPort = 3389,
[string]$AufgabenName = 'SSH-Tunnel zum Rootserver'
)
$ErrorActionPreference = 'Stop'
if (-not $Benutzer) {
Write-Host "Wie heisst dein Konto AUF DEM SERVER? (nicht der Name hier am PC)" -ForegroundColor Yellow
$Benutzer = (Read-Host ' Benutzername').Trim()
if (-not $Benutzer) { Write-Host ' Ohne Benutzernamen geht es nicht.' -ForegroundColor Red; exit 1 }
}
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
function Warnung($t) { Write-Host " $t" -ForegroundColor Yellow }
# -- 1. Ist überhaupt ein SSH-Client da? ---------------------------------------
Schritt 'Voraussetzungen prüfen'
$ssh = (Get-Command ssh.exe -ErrorAction SilentlyContinue).Source
if (-not $ssh) {
Write-Host ' Kein ssh.exe gefunden. Unter Windows 10/11 nachinstallieren mit:' -ForegroundColor Red
Write-Host ' Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0' -ForegroundColor Red
exit 1
}
Gut "ssh.exe: $ssh"
# -- 2. Schlüssel ---------------------------------------------------------------
$schluessel = Join-Path $env:USERPROFILE '.ssh\id_ed25519'
if (-not (Test-Path $schluessel)) {
Schritt 'Es gibt noch keinen Schlüssel -- einen anlegen'
New-Item -ItemType Directory -Force -Path (Split-Path $schluessel) | Out-Null
# Ohne Passphrase, damit die Hintergrundaufgabe ohne Rückfrage startet. Der
# Schutz liegt dann darin, dass die Datei nur diesem Windows-Konto gehört.
# Wer eine Passphrase will, nimmt sie -- muss den Schlüssel dann aber in den
# ssh-agent legen, sonst hängt die Aufgabe an der Abfrage.
& ssh-keygen -t ed25519 -N '""' -f $schluessel -C "$Benutzer-$env:COMPUTERNAME" | Out-Null
Gut 'Schlüssel angelegt.'
Write-Host ''
Write-Host ' ===> DIESE ZEILE muss auf dem Server hinterlegt werden:' -ForegroundColor Yellow
Write-Host ''
Write-Host (" " + (Get-Content "$schluessel.pub")) -ForegroundColor White
Write-Host ''
Write-Host ' Schick sie demjenigen, der den Server betreut. Erst danach' -ForegroundColor Yellow
Write-Host ' funktioniert der Tunnel -- das Skript richtet ihn trotzdem' -ForegroundColor Yellow
Write-Host ' schon ein.' -ForegroundColor Yellow
Write-Host ''
} else {
Gut "Schlüssel vorhanden: $schluessel"
}
# -- 3. Ist der lokale Port frei? ----------------------------------------------
#
# Vorher prüfen und nicht hinterher rätseln: ist er belegt, bindet ssh nicht,
# und die RDP-Verbindung landet bei dem Programm, das dort schon lauscht.
Schritt "Lokalen Port $LokalerPort prüfen"
$belegt = Get-NetTCPConnection -State Listen -LocalPort $LokalerPort -ErrorAction SilentlyContinue
if ($belegt) {
$wer = (Get-Process -Id $belegt[0].OwningProcess -ErrorAction SilentlyContinue).ProcessName
Write-Host " Port $LokalerPort ist belegt (von $wer). Nimm einen anderen:" -ForegroundColor Red
Write-Host " pwsh -File tunnel-einrichten.ps1 -LokalerPort $($LokalerPort + 1)" -ForegroundColor Red
exit 1
}
Gut 'frei'
# -- 4. Das Tunnelskript ablegen ------------------------------------------------
#
# Eine Schleife und kein einzelner Aufruf: bei Zwangstrennung, Standby oder
# kurzem Leitungsaussetzer endet ssh, und ohne Schleife wäre der Tunnel dann
# weg -- ohne dass es jemand merkt, bis die nächste Verbindung scheitert.
$ordner = Join-Path $env:LOCALAPPDATA 'SSH-Tunnel'
New-Item -ItemType Directory -Force -Path $ordner | Out-Null
$skript = Join-Path $ordner 'tunnel.ps1'
@"
# Erzeugt von tunnel-einrichten.ps1 -- von Hand ändern lohnt nicht,
# lieber das Einrichtungsskript neu laufen lassen.
#
# PROTOKOLL, weil es kein Fenster mehr gibt: die Aufgabe laeuft ausserhalb der
# Sitzung, also sieht niemand mehr, was ssh sagt. Ohne diese Datei waere ein
# abgelehnter Schluessel oder ein besetzter Port unsichtbar -- der Tunnel
# stuende einfach nicht, und niemand wuesste warum.
`$log = Join-Path `$PSScriptRoot 'tunnel.log'
if ((Test-Path `$log) -and (Get-Item `$log).Length -gt 1MB) { Remove-Item `$log -Force }
while (`$true) {
("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] verbinde ...") | Add-Content `$log
# -N: nur weiterleiten, keine Shell.
# ExitOnForwardFailure: lieber sofort scheitern und neu versuchen, als
# scheinbar zu laufen, während die Weiterleitung gar nicht steht.
# ServerAliveInterval: erkennt tote Leitungen, statt still einzufrieren.
& '$ssh' -N ``
-o ExitOnForwardFailure=yes ``
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 ``
-o StrictHostKeyChecking=accept-new ``
-L ${LokalerPort}:localhost:${ZielPort} ``
$Benutzer@$Server 2>&1 | Add-Content `$log
("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] beendet, neuer Versuch in 5 s") | Add-Content `$log
Start-Sleep -Seconds 5
}
"@ | Set-Content -Path $skript -Encoding UTF8
Gut "Tunnelskript: $skript"
# -- 5. Als Aufgabe beim Anmelden ----------------------------------------------
Schritt 'Als Hintergrundaufgabe eintragen'
Unregister-ScheduledTask -TaskName $AufgabenName -Confirm:$false -ErrorAction SilentlyContinue
$pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source
if (-not $pwsh) { $pwsh = (Get-Command powershell.exe).Source }
# EIN VBS-STARTER ALS ZWISCHENSCHICHT, und der Umweg hat zwei Gruende.
#
# 1. "-WindowStyle Hidden" versteckt ein Konsolenprogramm nur halb. Die Aufgabe
# lief interaktiv in der Anmeldesitzung, bekam dort ein Fenster -- und wer
# es zumacht, kappt den Tunnel, ohne zu wissen was er tut.
# 2. Der naheliegende Ausweg, die Aufgabe per S4U ausserhalb der Sitzung laufen
# zu lassen, braucht erhoehte Rechte. In einem gewoehnlichen Fenster
# scheitert das Eintragen mit "Zugriff verweigert" -- und ein Werkzeug, das
# Administratorrechte verlangt, benutzt am Ende niemand.
#
# wscript.exe startet mit Fenstermodus 0, und das heisst wirklich unsichtbar.
# Es braucht keine besonderen Rechte, weil nichts Besonderes passiert.
$starter = Join-Path $ordner 'tunnel.vbs'
@"
' Erzeugt von tunnel-einrichten.ps1. Startet den Tunnel OHNE Fenster.
' Die 0 im Run-Aufruf ist der ganze Trick: Fenstermodus "unsichtbar".
Set sh = CreateObject("WScript.Shell")
sh.Run """$pwsh"" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ""$skript""", 0, False
"@ | Set-Content -Path $starter -Encoding ASCII
$aktion = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "`"$starter`""
$ausloeser = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"
# Interactive und NICHT S4U: S4U braucht Administratorrechte zum Eintragen.
# Das Fenster verhindert der VBS-Starter, nicht der Anmeldetyp.
$prinzipal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
$optionen = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $AufgabenName -Action $aktion -Trigger $ausloeser `
-Principal $prinzipal -Settings $optionen `
-Description "Haelt den SSH-Tunnel zu $Server offen. RDP verbindet sich danach auf localhost:$LokalerPort." | Out-Null
Gut 'eingetragen (startet künftig bei jeder Anmeldung)'
# -- 6. Jetzt starten und nachsehen ---------------------------------------------
Schritt 'Starten und nachmessen'
Start-ScheduledTask -TaskName $AufgabenName
$bis = (Get-Date).AddSeconds(25)
do {
Start-Sleep -Seconds 2
$steht = Get-NetTCPConnection -State Listen -LocalPort $LokalerPort -ErrorAction SilentlyContinue
} while (-not $steht -and (Get-Date) -lt $bis)
Write-Host ''
if ($steht) {
Gut "Der Tunnel steht: localhost:$LokalerPort -> ${Server}:$ZielPort"
Write-Host ''
Write-Host " In der Fernwartung ab jetzt eintragen:" -ForegroundColor White
Write-Host " Hostname : localhost" -ForegroundColor White
Write-Host " Port : $LokalerPort" -ForegroundColor White
Write-Host ''
Write-Host " Nicht mehr auf $Server verbinden -- das ist gesperrt." -ForegroundColor DarkGray
Write-Host ''
Write-Host ' Es gibt jetzt KEIN Fenster mehr. Nachsehen kann man so:' -ForegroundColor DarkGray
Write-Host " Get-NetTCPConnection -LocalPort $LokalerPort -State Listen" -ForegroundColor DarkGray
Write-Host " Get-Content '$(Join-Path $ordner 'tunnel.log')' -Tail 20" -ForegroundColor DarkGray
} else {
Warnung "Der Tunnel steht noch nicht auf Port $LokalerPort."
Write-Host ''
# NACHSEHEN STATT RATEN. Die erste Fassung behauptete hier "der Schluessel
# fehlt auf dem Server" -- und lag falsch: es war der Benutzername. Eine
# geratene Ursache schickt den Suchenden in die falsche Richtung, und das
# kostet mehr Zeit als gar kein Hinweis.
Schritt 'Nachsehen, woran es liegt'
$antwort = & $ssh -o BatchMode=yes -o ConnectTimeout=10 `
-o StrictHostKeyChecking=accept-new "$Benutzer@$Server" 'exit' 2>&1 | Out-String
if ($antwort -match 'Permission denied') {
Warnung 'Der Server nimmt den Schluessel nicht an. Zwei Ursachen sind haeufig:'
Warnung " - Der Benutzername stimmt nicht. Du hast '$Benutzer' benutzt --"
Warnung ' ist das dein Konto AUF DEM SERVER, nicht das hier am PC?'
Warnung ' - Der oeffentliche Schluessel ist dort noch nicht hinterlegt:'
Write-Host ''
Write-Host (" " + (Get-Content "$schluessel.pub")) -ForegroundColor White
} elseif ($antwort -match 'Could not resolve|timed out|refused|unreachable') {
Warnung "Der Server ist nicht erreichbar: $($antwort.Trim())"
} elseif ($antwort.Trim()) {
Warnung "ssh sagt: $($antwort.Trim())"
} else {
Warnung 'ssh selbst kommt durch -- dann klemmt es an der Weiterleitung.'
Warnung "Steht auf dem SERVER etwas auf Port $ZielPort und laesst er"
Warnung 'Weiterleitungen zu (AllowTcpForwarding)?'
}
}
+229
View File
@@ -0,0 +1,229 @@
# Dateien aus der Nextcloud in d4rk_media uebernehmen.
#
# Aufruf:
# pwsh -File tools\uebernehmen.ps1 -Von 'CDN/brands' -Nach brands
# pwsh -File tools\uebernehmen.ps1 -Von 'CDN/items' -Nach items -Probe
#
# -Von Pfad IN der Nextcloud, relativ zum eigenen Ordner
# -Nach Zielordner im Dienst. Leer = der letzte Teil von -Von
# -Probe nur zeigen, nichts hochladen
#
# UNTERORDNER BLEIBEN ERHALTEN: aus CDN/brands/logos/x.png wird
# brands/logos/x.png. Wer das nicht will, sortiert vorher in der Nextcloud um.
#
# WARUM UEBER DAS DASHBOARD UND NICHT UEBER DIE TOKEN-API. Nextcloud-Dateien
# heissen "Brand Logo (final).PNG" -- checkPath lehnt das mit Recht ab, denn
# Klammern und Leerzeichen haben in einer oeffentlichen Adresse nichts zu
# suchen. Der Dashboard-Weg biegt den Namen bereits VORHERSAGBAR zurecht
# (brand-logo-final.png). Dieselbe Regel an einer Stelle ist besser als
# dieselbe Regel an zweien, von denen eine irgendwann abweicht.
#
# WIEDERHOLBAR: hochgeladen wird mit overwrite=false. Was schon da ist, meldet
# der Dienst je Datei als "Pfad ist belegt" -- der Lauf laesst sich also
# jederzeit abbrechen und noch einmal starten, ohne 500 Dateien erneut zu
# schieben.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Von,
[string]$Nach = '',
[switch]$Probe,
[string]$Dienst = 'https://media.d4rkst3r.de',
[string]$Container = 'd4rk-media',
[int]$ProStapel = 20
)
$ErrorActionPreference = 'Stop'
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
if (-not $Nach) { $Nach = ($Von.TrimEnd('/') -split '/')[-1] }
$Nach = $Nach.Trim('/')
# -- Zugang zur Nextcloud ------------------------------------------------------
#
# Dieselbe Quelle wie bei der Sicherung: die Werte stehen im Panel unter
# Einstellungen und werden hier direkt aus der Datenbank gelesen. So gibt es
# EINEN Ort fuer diese Zugangsdaten und nicht zwei.
$roh = docker exec $Container node -e @"
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
const hol = (k) => { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(k); return r ? r.value : ''; };
console.log(JSON.stringify({
basis: hol('sicherung.basis'), benutzer: hol('sicherung.benutzer'), passwort: hol('sicherung.passwort'),
}));
"@
if ($LASTEXITCODE -ne 0) { Schlecht 'Der Container antwortet nicht.'; exit 1 }
$nc = $roh | ConvertFrom-Json
if (-not ($nc.basis -and $nc.benutzer -and $nc.passwort)) {
Schlecht 'Keine Nextcloud hinterlegt. Panel -> Einstellungen -> Sicherung ausfuellen.'
exit 1
}
$wurzel = "$($nc.basis.TrimEnd('/'))/remote.php/dav/files/$($nc.benutzer)"
$auth = "$($nc.benutzer):$($nc.passwort)"
# -- Was liegt dort ------------------------------------------------------------
Schritt "In der Nextcloud nachsehen: $Von"
# Depth: infinity nennt auch alles in Unterordnern. Manche Server verweigern
# das; dann bleibt nur Ebene fuer Ebene, und das sagen wir auch.
# Das -join ist NICHT Kosmetik. curl.exe gibt seine Antwort zeilenweise als
# ARRAY zurueck, und -match/-notmatch auf einem Array FILTERT, statt wahr oder
# falsch zu liefern: ein nicht-leeres Ergebnis gilt als wahr, und damit schlug
# die Pruefung unten immer an -- bei einer tadellosen Antwort mit HTTP 207.
$xml = (curl.exe -s -u $auth -X PROPFIND -H 'Depth: infinity' "$wurzel/$Von") -join "`n"
if (-not $xml -or $xml -notmatch '<[dD]:multistatus') {
Schlecht "Kein Verzeichnis gefunden. Antwort: $($xml -replace '\s+', ' ' | Select-Object -First 1)"
Schlecht 'Stimmt der Pfad? Er ist relativ zum eigenen Nextcloud-Ordner, ohne fuehrenden Schraegstrich.'
exit 1
}
# Die Antwort ist XML mit Namensraumpraefix (d: oder D:). Statt einen Parser
# darauf anzusetzen, holen wir die beiden Felder, auf die es ankommt.
$eintraege = @()
foreach ($m in [regex]::Matches($xml, '(?s)<[dD]:response>(.*?)</[dD]:response>')) {
$block = $m.Groups[1].Value
if ($block -match '<[dD]:collection\s*/>') { continue } # Ordner selbst
if ($block -notmatch '<[dD]:href>(.*?)</[dD]:href>') { continue }
$href = [uri]::UnescapeDataString($Matches[1])
$groesse = if ($block -match '<[dD]:getcontentlength>(\d+)<') { [long]$Matches[1] } else { 0 }
# Der href traegt den vollen DAV-Pfad. Wir wollen den Teil UNTERHALB von
# -Von, damit die Unterordner erhalten bleiben.
$praefix = "/remote.php/dav/files/$($nc.benutzer)/$Von/"
if ($href -like "*$praefix*") {
$rel = $href.Substring($href.IndexOf($praefix) + $praefix.Length)
if ($rel) { $eintraege += [pscustomobject]@{ rel = $rel; href = $href; size = $groesse } }
}
}
if ($eintraege.Count -eq 0) { Schlecht 'Keine Dateien gefunden.'; exit 1 }
$summe = ($eintraege | Measure-Object -Property size -Sum).Sum
Gut "$($eintraege.Count) Dateien, $([math]::Round($summe/1MB,1)) MB"
# Nach Zielordner gruppieren: der Dashboard-Weg nimmt EINEN Ordner je Anfrage.
$gruppen = $eintraege | Group-Object {
$teile = $_.rel -split '/'
if ($teile.Count -gt 1) { "$Nach/" + ($teile[0..($teile.Count - 2)] -join '/') } else { $Nach }
}
Write-Host ''
foreach ($g in $gruppen) {
Write-Host " $($g.Name)" -NoNewline -ForegroundColor DarkGray
Write-Host " $($g.Count) Dateien"
}
Write-Host ''
if ($Probe) {
Gut 'Probelauf — nichts hochgeladen. Ohne -Probe geht es wirklich los.'
exit 0
}
# -- Anmelden am Dienst --------------------------------------------------------
$envDatei = Join-Path (Split-Path $PSScriptRoot -Parent) '.env'
if (-not (Test-Path $envDatei)) { Schlecht "Keine .env neben dem Compose gefunden."; exit 1 }
$pw = (Select-String -Path $envDatei -Pattern '^ADMIN_PASSWORD=(.+)$').Matches.Groups[1].Value
$benutzer = 'admin'
$m = Select-String -Path $envDatei -Pattern '^ADMIN_USERNAME=(.+)$'
if ($m) { $benutzer = $m.Matches.Groups[1].Value }
$keksglas = Join-Path $env:TEMP ('keks-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$anmeldung = @{ username = $benutzer; password = $pw } | ConvertTo-Json -Compress
$code = $anmeldung | curl.exe -s -o NUL -w '%{http_code}' -c $keksglas `
-X POST "$Dienst/api/dash/auth/login" -H 'content-type: application/json' -d '@-'
if ($code -ne '200') { Schlecht "Anmeldung am Dienst fehlgeschlagen (HTTP $code)."; exit 1 }
# -- Uebertragen ---------------------------------------------------------------
$tmp = Join-Path $env:TEMP ('uebernahme-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$hoch = 0; $schon = 0; $fehler = 0
foreach ($g in $gruppen) {
$zielordner = $g.Name
Schritt "$zielordner ($($g.Count) Dateien)"
foreach ($stapel in ($g.Group | ForEach-Object -Begin { $i = 0; $puffer = @() } -Process {
$puffer += $_
if ($puffer.Count -ge $ProStapel) { , $puffer; $puffer = @() }
} -End { if ($puffer.Count) { , $puffer } })) {
$dateien = @()
foreach ($e in $stapel) {
$name = ($e.rel -split '/')[-1]
$lokal = Join-Path $tmp $name
curl.exe -s -u $auth -o $lokal "$($nc.basis.TrimEnd('/'))$($e.href)"
if (Test-Path $lokal) { $dateien += Get-Item $lokal }
}
if (-not $dateien) { continue }
# HOCHGELADEN WIRD MIT CURL UND NICHT MIT Invoke-RestMethod -Form.
#
# Nicht aus Geschmack: .NET schreibt im Formular-Rumpf
# "Content-Disposition: form-data; name=file" OHNE Anfuehrungszeichen,
# RFC 7578 verlangt sie, und Nodes Parser besteht darauf. Der Dienst
# antwortet dann mit "Der Formular-Rumpf liess sich nicht lesen".
# Mitgeschnitten und nachgemessen -- curl setzt beides korrekt.
$argumente = @(
'-s', '-b', $keksglas,
'-X', 'POST', "$Dienst/api/dash/upload",
'-F', "folder=$zielordner",
'-F', 'overwrite=false'
)
foreach ($d in $dateien) { $argumente += @('-F', "file=@$($d.FullName)") }
# Den HTTP-Code MIT abfragen. Vorher stand hier nur ConvertFrom-Json,
# und als der Dienst wegbrach, meldete das Skript
# "Conversion from JSON failed ... Unexpected character <" -- also
# einen Parser-Fehler, obwohl die Wahrheit ein 502 vom Proxy war. Eine
# Fehlermeldung, die die Ursache verschweigt, kostet genau die Zeit,
# die sie sparen soll.
$argumente += @('-w', "`n%{http_code}")
$antwort = (& curl.exe @argumente) -join "`n"
$code = ($antwort -split "`n")[-1]
$rumpf = ($antwort -split "`n")[0..(($antwort -split "`n").Count - 2)] -join "`n"
if ($code -ne '200') {
$fehler += $dateien.Count
Schlecht "Stapel fehlgeschlagen: HTTP $code$($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))"
if ($code -eq '502') {
Schlecht 'Der Dienst antwortet nicht. Laeuft der Container? (docker ps)'
Schlecht 'Der Lauf laesst sich danach einfach wiederholen — was da ist, wird uebersprungen.'
break
}
} else {
try {
$res = $rumpf | ConvertFrom-Json
foreach ($r in $res.ergebnisse) {
if ($r.ok) { $hoch++ }
elseif ($r.error -like '*belegt*') { $schon++ }
else { $fehler++; Schlecht "$($r.name): $($r.error)" }
}
Gut "$hoch uebernommen, $schon schon da, $fehler Fehler"
} catch {
$fehler += $dateien.Count
Schlecht "Antwort unlesbar: $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))"
}
}
$dateien | Remove-Item -Force -ErrorAction SilentlyContinue
}
}
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $keksglas -Force -ErrorAction SilentlyContinue
Write-Host ''
Gut "Fertig: $hoch uebernommen, $schon waren schon da, $fehler Fehler"
if ($schon -gt 0) {
Write-Host " (Ein zweiter Lauf laedt nichts doppelt — das ist Absicht.)" -ForegroundColor DarkGray
}
+242
View File
@@ -0,0 +1,242 @@
# Wacht über die Sicherheitslage des Wirts: offene Türen und wichtige Dateien.
#
# pwsh -File tools\waechter.ps1 <- prüfen und ggf. melden
# pwsh -File tools\waechter.ps1 -Uebernehmen <- den JETZIGEN Stand als richtig eintragen
# pwsh -File tools\waechter.ps1 -Still <- prüfen, nur anzeigen, nichts melden
#
# WOZU. In der Nacht zum 13.08.2026 hat jemand über eine offene Tür in den
# Gitea geschrieben (docs/vorfall-2026-08-12-gitea.md). Aufgefallen ist es,
# weil danach ein Push nicht mehr ging -- nicht weil etwas es bemerkt hätte.
#
# DIE STATUSSEITE WAR DIE GANZE ZEIT GRÜN. Sie wäre auch grün geblieben, wenn
# der Miner gelaufen wäre. Es fehlte kein Bildschirm, auf dem man es hätte
# sehen können; es fehlte etwas, das VON SICH AUS Bescheid sagt.
#
# Zwei Prüfungen, und beide hätten den Vorfall doppelt erwischt:
#
# 1. OFFENE TÜREN ZÄHLEN. Port 3000 stand eiskalt elf Tage offen. Diese
# Prüfung hätte am ersten Tag gesagt: "da ist ein Port dazugekommen, den
# niemand eingetragen hat" -- und sie sagt es auch beim nächsten, den
# jemand beim Einrichten aus Bequemlichkeit aufmacht.
#
# 2. WICHTIGE DATEIEN NACHRECHNEN. Der Angriff war eine Datei, die sich um
# 21:13:57 änderte. Eine Prüfsumme über .gitconfig, app.ini, sshd_config
# und die Schlüsseldatei hätte das im nächsten Durchlauf gemeldet.
#
# GEMELDET WIRD NUR BEI ÄNDERUNG. Ein Wächter, der täglich meckert, wird
# stummgeschaltet -- und ist dann schlechter als keiner. Dieselbe Lehre wie bei
# den 900 Fahrzeugbildern.
[CmdletBinding()]
param(
[string]$Container = 'd4rk-media',
[switch]$Uebernehmen,
[switch]$Still
)
$ErrorActionPreference = 'Stop'
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
function Warn($t) { Write-Host " $t" -ForegroundColor Yellow }
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
# Ports, die man auf einem Server aus dem Internet NIE erreichbar haben will --
# unabhängig davon, ob sie beim Übernehmen des Standes schon dastanden. Diese
# Liste warnt also auch dann, wenn der Zustand "schon immer so" war. Genau das
# ist der Punkt: Port 3000 war auch "schon immer so".
$HEIKEL = @{
135 = 'RPC'
139 = 'NetBIOS'
445 = 'SMB-Dateifreigabe'
1433 = 'MS SQL'
3306 = 'MySQL'
3389 = 'RDP'
5432 = 'PostgreSQL'
5985 = 'WinRM (HTTP)'
5986 = 'WinRM (HTTPS)'
6379 = 'Redis'
27017 = 'MongoDB'
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
# übernimmst du sie mit -Uebernehmen -- oder sie sind ein Befund.
$DATEIEN = @(
'C:\ProgramData\ssh\sshd_config'
'C:\ProgramData\ssh\administrators_authorized_keys'
'C:\docker\gitea\docker-compose.yml'
"$PSScriptRoot\..\docker-compose.yml"
)
# Dateien IN Containern, als "container:pfad".
$IM_CONTAINER = @(
'gitea:/data/gitea/conf/app.ini'
'gitea:/data/gitea/home/.gitconfig'
)
# -- Stand erheben --------------------------------------------------------------
Schritt 'Offene Tueren zaehlen'
$offen = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
Where-Object { $_.LocalAddress -in '0.0.0.0', '::' } |
Select-Object -ExpandProperty LocalPort -Unique | Sort-Object)
Gut "$($offen.Count) Ports lauschen auf allen Adressen"
Schritt 'Wichtige Dateien nachrechnen'
$hashes = @{}
foreach ($d in $DATEIEN) {
$p = try { (Resolve-Path $d -ErrorAction Stop).Path } catch { $null }
if ($p) { $hashes[$d] = (Get-FileHash $p -Algorithm SHA256).Hash }
else { $hashes[$d] = 'FEHLT' }
}
foreach ($eintrag in $IM_CONTAINER) {
$c, $pfad = $eintrag -split ':', 2
# sha256sum IM Container: die Datei liegt in einem Volume, das der Wirt
# nicht ohne Weiteres lesen kann -- und ein Umweg ueber docker cp wuerde
# bei jedem Lauf eine Kopie anlegen.
$h = (docker exec $c sh -c "sha256sum '$pfad' 2>/dev/null | cut -d' ' -f1") -join ''
$hashes[$eintrag] = if ($h.Trim()) { $h.Trim().ToUpper() } else { 'FEHLT' }
}
Gut "$($hashes.Count) Dateien"
# -- Mit dem hinterlegten Stand vergleichen -------------------------------------
$basisDatei = Join-Path $PSScriptRoot '..\.waechter-basis.json'
$basis = if (Test-Path $basisDatei) { Get-Content $basisDatei -Raw | ConvertFrom-Json } else { $null }
$jetzt = [ordered]@{
ports = $offen
dateien = $hashes
}
if ($Uebernehmen -or -not $basis) {
$jetzt | ConvertTo-Json -Depth 5 | Set-Content $basisDatei -Encoding UTF8
if (-not $basis) {
Schritt 'Kein hinterlegter Stand -- den jetzigen als richtig eingetragen'
Warn 'Beim ERSTEN Lauf gibt es nichts zu vergleichen. Ab jetzt wird gemeldet,'
Warn 'was sich daran aendert. Sieh die Liste unten einmal durch.'
} else {
Gut 'Stand uebernommen.'
}
}
$basis = Get-Content $basisDatei -Raw | ConvertFrom-Json
$neu = @($offen | Where-Object { $_ -notin $basis.ports })
$weg = @($basis.ports | Where-Object { $_ -notin $offen })
$geaendert = @()
foreach ($k in $hashes.Keys) {
$alt = $basis.dateien.$k
if ($alt -and $alt -ne $hashes[$k]) { $geaendert += $k }
}
$heikelOffen = @($offen | Where-Object { $HEIKEL.ContainsKey([int]$_) })
# -- Anzeigen -------------------------------------------------------------------
Write-Host ''
if ($neu.Count) { Schlecht "NEU offen: $($neu -join ', ')" }
if ($weg.Count) { Gut "nicht mehr offen: $($weg -join ', ')" }
if ($geaendert.Count) { foreach ($g in $geaendert) { Schlecht "GEAENDERT: $g" } }
if (-not $neu.Count -and -not $geaendert.Count) { Gut 'Keine Aenderung gegenueber dem hinterlegten Stand.' }
if ($heikelOffen.Count) {
Write-Host ''
Warn 'Diese lauschen auf allen Adressen und gehoeren nicht ins Internet:'
foreach ($p in $heikelOffen) { Warn (" {0,6} {1}" -f $p, $HEIKEL[[int]$p]) }
Warn 'OB sie von aussen erreichbar sind, sagt nur eine Messung VON AUSSEN --'
Warn 'vom Wirt aus antwortet die eigene Adresse auch dann, wenn die Firewall sperrt.'
}
# -- Melden ---------------------------------------------------------------------
#
# Nur bei AENDERUNG, und nur einmal je Zustand. Dafuer wandert eine Kennung des
# gemeldeten Zustands in die Einstellungen: bleibt sie gleich, bleibt es still.
if ($Still) { Write-Host ''; Gut 'Nur angezeigt (-Still), nichts gemeldet.'; exit 0 }
$befund = @()
if ($neu.Count) { $befund += "NEU offen: " + ($neu -join ', ') }
if ($geaendert.Count) { $befund += "geaendert: " + (($geaendert | ForEach-Object { Split-Path $_ -Leaf }) -join ', ') }
$kennung = if ($befund.Count) { ($befund -join ' | ') } else { '' }
function LiesEinstellung($schluessel) {
$s = @"
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
const r = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel');
console.log(r ? r.value : '');
"@ -replace "`r", ''
(docker exec $Container node -e $s 2>$null) -join ''
}
function SchreibEinstellung($schluessel, $wert) {
$s = @"
let roh = '';
process.stdin.on('data', (d) => (roh += d)).on('end', () => {
const db = require('better-sqlite3')('/data/media.db');
db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' +
'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
.run('$schluessel', roh.trim(), Date.now());
});
"@ -replace "`r", ''
$wert | docker exec -i $Container node -e $s 2>&1 | Out-Null
}
# Bericht fuer die Statusseite -- immer, auch wenn nichts zu melden ist.
$bericht = [ordered]@{
at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
ok = ($befund.Count -eq 0)
ports = $offen.Count
heikel = @($heikelOffen)
befund = @($befund)
}
SchreibEinstellung 'waechter.letzte' ($bericht | ConvertTo-Json -Compress)
$zuletzt = LiesEinstellung 'waechter.merker'
if ($kennung -eq $zuletzt) {
Write-Host ''
Gut 'Nichts Neues -- dieser Zustand wurde schon gemeldet.'
exit 0
}
SchreibEinstellung 'waechter.merker' $kennung
if (-not $kennung) { Write-Host ''; Gut 'Wieder in Ordnung (Merker geleert).'; exit 0 }
$webhook = LiesEinstellung 'discord.webhook'
if (-not $webhook) { Write-Host ''; Warn 'Kein Discord-Webhook hinterlegt -- nur angezeigt.'; exit 0 }
$felder = @()
if ($neu.Count) { $felder += @{ name = 'Neu offen'; value = ($neu -join ', '); inline = $true } }
if ($geaendert.Count) { $felder += @{ name = 'Geaenderte Dateien'; value = (($geaendert | ForEach-Object { '`' + (Split-Path $_ -Leaf) + '`' }) -join "`n"); inline = $false } }
$rumpf = @{
username = 'd4rk_media'
embeds = @(@{
title = 'Die Lage am Server hat sich geaendert'
description = "Etwas ist offen oder anders, was vorher nicht so war.`n" +
"Wenn du das selbst warst: ``pwsh -File tools\waechter.ps1 -Uebernehmen``"
color = 15158332
fields = $felder
footer = @{ text = 'D4RKST3R // WAECHTER' }
timestamp = (Get-Date).ToUniversalTime().ToString('o')
})
} | ConvertTo-Json -Depth 6
$antwort = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST `
-H 'content-type: application/json' --data-binary '@-' $webhook
Write-Host ''
if ($antwort -in '200', '204') { Gut "Gemeldet (HTTP $antwort)." } else { Schlecht "Melden fehlgeschlagen: HTTP $antwort" }

Some files were not shown because too many files have changed in this diff Show More