diff --git a/.env.example b/.env.example index a365087..29f73da 100644 --- a/.env.example +++ b/.env.example @@ -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://fivemanage.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. diff --git a/README.md b/README.md index 99a58b0..981d15e 100644 --- a/README.md +++ b/README.md @@ -1,220 +1,197 @@ # 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://fivemanage.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://fivemanage.d4rkst3r.de/f/vehicles/adder.webp ``` -- Oberfläche: — `admin` und dein `ADMIN_PASSWORD` -- MinIO: — `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` | +| **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 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: +## Starten ```bash -docker compose -f docker-compose.yml -f docker-compose.proxynet.yml up -d +cp .env.example .env # PUBLIC_URL und ADMIN_PASSWORD ausfüllen +docker compose up -d --build ``` -Auf **diesem** Server trifft das nicht zu. +Das war es. Ein Container, ein Volume, kein zweiter Dienst. -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. +**In Portainer:** Stacks → Add stack → Repository, `Compose path: +docker-compose.yml`. Die Werte kommen als Stack-Variablen. -**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`. +> **`Compose path` nimmt genau EINE Datei.** Ergänzungen werden stillschweigend +> übergangen — alle Container gesund, alles 502. Das hat beim Vorgänger einen +> Nachmittag gekostet. ---- - -## Die vier Geheimnisse - -Sie werden **erzeugt**, nicht irgendwo abgeholt. Das Token für die Resourcen -kommt später und woanders her: aus der laufenden Lite-Oberfläche. - -**PowerShell** — schreibt die fertige `.env`: - -```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 -``` - -Zwei Fallen stecken darin, beide schon hineingetreten: - -- `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. - -**Bash / Linux:** +### An der Oberfläche arbeiten ```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 +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. + --- -## PostgreSQL, nicht MySQL +## Was der Dienst kann -`0.1.0-beta.23` geht direkt in den PostgreSQL-Treiber; `DB_DRIVER` wird nicht -ausgewertet. Eine MySQL-DSN endet deshalb in +**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. -``` -panic: pgdriver: invalid scheme: lite -``` +**Ausliefern** — mit ETag, `304`, Bereichsanfragen und `nosniff`. Vorschaubilder +werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden +766 KB. -— der Treiber liest sie als URI und findet als Schema den Benutzernamen. +**Verwalten** — Galerie mit Ordnerbaum, Suche, Filter nach Medienart, +Sortierung, Mehrfachauswahl, Verschieben, Löschen. Betrachter für Bild, Video, +Ton und PDF ohne neuen Tab. -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://…`. +**Nachvollziehen** — ein Verlauf: wer hat wann was abgelegt, ersetzt, +verschoben oder gelöscht. -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: +**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. + +--- + +## 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 -docker volume rm fivemanager_db +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. -## Zwei Adressen, nicht eine +### Zurückspielen -Lite spricht MinIO unter zwei verschiedenen Namen an, und beide muessen -stimmen: +```bash +tar xzf d4rk_media-JJJJ-MM-TT-HHMM.tar.gz +``` -| 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. +Ergibt `media.db` und `files/`. Beides ins Volume legen, während der Container +steht. Die Vorschaubilder sind **nicht** im Archiv — sie lassen sich mit einem +Knopf unter *Speicher* neu rechnen. --- ## 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. diff --git a/server/src/meldung.ts b/server/src/meldung.ts index f81d1e0..065c7cc 100644 --- a/server/src/meldung.ts +++ b/server/src/meldung.ts @@ -9,9 +9,15 @@ // Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen // will, loescht ihn in Discord. // -// Der Dienst schickt HOECHSTENS EINMAL je Anlass. Eine Meldung, die stuendlich -// wiederkommt, wird nach dem dritten Mal weggeklickt -- und dann auch die, die -// zaehlt. +// DER AUSSEHEN FOLGT DEM d4rkbot. Dessen src/embeds.js hat eine zentrale +// Embed-Fabrik: Markenfarbe, Fusszeile "D4RKST3R // ", 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 { getSetting, setSetting } from './settings.js' import { now } from './db.js' @@ -22,8 +28,22 @@ export const MELDUNG_KEYS = { anlaesse: 'discord.anlaesse', /** Womit zuletzt gemeldet wurde -- gegen Wiederholungen. */ 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[] @@ -35,17 +55,28 @@ export function meldungConfig(): MeldungConfig { 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) : ['sicherung', 'verwaiste'], + anlaesse: roh + ? roh.split(',').map((a) => a.trim()).filter(Boolean) + : ['sicherung', 'verwaiste'], } } +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( - text: string, - opt: { anlass?: string; einmalig?: string; farbe?: number } = {}, + titel: string, + opt: { + text?: string + felder?: Feld[] + schwere?: Schwere + anlass?: string + einmalig?: string + tag?: string + } = {}, ): Promise { try { const webhook = getSetting(MELDUNG_KEYS.webhook) @@ -59,6 +90,8 @@ export async function melde( setSetting(MELDUNG_KEYS.zuletzt, opt.einmalig) } + const marke = getSetting(MELDUNG_KEYS.marke) || MARKE_STANDARD + const antwort = await fetch(webhook, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -66,8 +99,14 @@ export async function melde( username: 'd4rk_media', embeds: [ { - description: text, - color: opt.farbe ?? 0x4ea3ff, + 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(), }, ], @@ -87,6 +126,9 @@ export async function melde( } } +const groesse = (n: number) => + n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB` + /** Wacht ueber die Sicherung. * * Der eigentliche Punkt der ganzen Datei. Geprueft wird stuendlich, gemeldet @@ -99,30 +141,73 @@ export async function melde( */ export async function pruefeSicherung(): Promise { const roh = getSetting('sicherung.letzte') + if (!roh) { - await melde( - '⚠️ Es wurde noch **nie** eine Sicherung gemeldet. Läuft die geplante Aufgabe?', - { anlass: 'sicherung', einmalig: 'nie', farbe: 0xd29922 }, - ) + 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; fehler?: string } + 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 = `` if (!letzte.ok) { - await melde( - `🔴 Die letzte Sicherung ist **fehlgeschlagen**.\n${letzte.fehler ?? 'kein Grund gemeldet'}`, - { anlass: 'sicherung', einmalig: `fehler:${letzte.at}`, farbe: 0xf85149 }, - ) + 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 letzte Sicherung ist **${Math.floor(stunden)} Stunden** her. Läuft die geplante Aufgabe noch?`, - { anlass: 'sicherung', einmalig: `alt:${letzte.at}`, farbe: 0xd29922 }, - ) + 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', + }) +} diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 69e6c9f..54ca8b3 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -46,7 +46,7 @@ import { type Vorlage, } from '../settings.js' import { discordRoutes, redirectUri } from './discord.js' -import { MELDUNG_KEYS, melde, meldungConfig } from '../meldung.js' +import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js' import { absolutePath, checkPath, @@ -1526,10 +1526,12 @@ dashRoutes.delete('/tokens/:id', (c) => { * Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall * ausprobiert, ist ein Webhook, der dann nicht funktioniert. */ dashRoutes.post('/maintenance/melden', async (c) => { - const ok = await melde( - `✅ Testnachricht von d4rk_media — ausgelöst von **${c.get('user').username}**.`, - { farbe: 0x3fb950 }, - ) + // Mit echten Zahlen statt "Hallo Welt": so sieht man nicht nur, DASS etwas + // ankommt, sondern auch, ob es lesbar ist. + const b = db + .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size),0) AS bytes FROM media') + .get() as { files: number; bytes: number } + const ok = await testMeldung(c.get('user').username, b) if (!ok) { return c.json( { error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' },