diff --git a/ROADMAP.md b/ROADMAP.md index c5210a0..70b68a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,7 +201,7 @@ Grundgerüst war nichts zu ändern; der Bau hat keinen einzigen Befund erzeugt. ```bash export PUBLIC_URL=http://localhost:9102 ADMIN_PASSWORD=… -docker compose -f docker-compose.media.yml up -d --build +docker compose up -d --build ``` **Was jetzt gemessen ist und vorher nur behauptet war:** @@ -407,6 +407,81 @@ location ^~ /f/ { Dass `^~` nötig ist und ein blankes `location /f/` nicht reicht, ist in einem Wegwerf-nginx nachgemessen: ohne `^~` gewinnt die Regex, mit `^~` das Präfix. +### ✅ Fertig — Sicherung, Anmeldebremse, Vorschaubilder + +**Sicherung.** `tools/sichern.ps1`, täglich um 04:30 als geplante Aufgabe +(nur bei angemeldetem Benutzer — Docker Desktop läuft ohnehin nur dann). + +Die Datenbank wird **nicht kopiert**, sondern über SQLites eigene +Sicherungsschnittstelle herausgeholt (`server/src/backup.ts`, `db.backup()`). +Der Grund steht weiter oben: im WAL-Modus liegt das Zuletzte noch nicht in +`media.db`, und selbst alle drei Dateien zu kopieren ist nicht sicher, wenn +währenddessen geschrieben wird. Die Bilder kommen aus einem **nur lesend** +eingehängten Volume dazu, alles in ein `tar.gz`. + +**Und die Sicherung prüft sich selbst:** sie wird ausgepackt, die Datenbank +geöffnet, Medien, Token und Benutzer gezählt und mit dem laufenden Dienst +verglichen. Stimmt es nicht, endet das Skript mit Fehler. + +**Einmal wirklich zurückgespielt**, nicht nur behauptet: in ein leeres +Wegwerf-Volume ausgepackt, ein zweiter Dienst darauf gestartet, mit dem echten +Passwort angemeldet, 21 Dateien vorgefunden, ein Bild abgerufen — Byte für Byte +identisch mit dem Betrieb. + +Nicht mitgesichert werden die Vorschaubilder: sie sind abgeleitet und lassen +sich mit einem Knopf neu rechnen. + +**Anmeldebremse.** Das Formular steht öffentlich; `scrypt` macht einen Versuch +teuer, aber teuer ist nicht selten. Jetzt: fünf freie Versuche je Adresse, dann +Sperre ab 30 Sekunden mit Verdopplung bis 15 Minuten, `429` samt `Retry-After` +und einem Text, der sagt, wie lange noch. Ein Erfolg setzt den Zähler zurück. +Bewusst nur im Speicher — wer sich aussperrt, startet den Container neu. + +Durchgemessen: Versuche 1–5 geben 401, ab dem sechsten 429 mit „noch 30 +Sekunden warten", nach Ablauf geht das richtige Passwort, und danach gibt ein +Fehlversuch wieder 401 statt 429. + +> Die Adresse kommt aus `X-Real-IP`, den NPM mit `$remote_addr` +> **überschreibt**. Wer den Container direkt auf seinem Port erreicht, kann sie +> sich ausdenken — genau deshalb gehört 9101 hinter die Firewall. + +**Vorschaubilder.** `sharp` erzeugt beim Upload eine 320 Pixel breite +WebP-Fassung unter `/data/thumbs`, ausgeliefert unter `/t/`. Gemessen an +`vehicles/sultan.webp`: **131 502 → 13 078 Bytes, Faktor 10.** Eine +Galerieseite mit 60 Kacheln fällt damit von 7,5 MB auf 766 KB. + +Keine Spalte in der Datenbank: ob es eine Vorschau gibt, sagt das Dateisystem, +und die Galerie fällt bei 404 auf das Vollbild zurück. Eine zweite Wahrheit, +die auseinanderlaufen kann, gibt es damit gar nicht erst. Was vor dieser +Funktion hochgeladen wurde, zieht ein Knopf unter *Speicher* nach — er nennt +Zahlen, nicht nur „fertig" (21 erzeugt, 0 Fehler beim ersten Lauf). + +Das Abbild wächst dadurch von 373 auf 457 MB. `sharp` bringt fertige +Binärdateien für `linux-x64` mit; im Abbild nachgemessen: libvips 8.18.3, +ein 800×600-PNG wird zu einem 320er WebP. + +> **Nebenbefund beim Einbauen:** `npm i sharp` scheitert auf diesem Rechner — +> aber nicht an sharp, sondern an **better-sqlite3**. Der Host läuft auf Node +> 24 (ABI 137), und dafür gibt es keine fertige Binärdatei; `node-gyp` +> übernimmt und findet keine Bauwerkzeuge. Genau die Falle, die im Dockerfile +> steht. Umgehung: `npm i sharp --package-lock-only` trägt es nur ein, gebaut +> wird im Abbild auf Node 22. + +### ✅ Fertig — Schritt 5, der Umzug + +Die Fivemanage-Dateien liegen in `legacy/` mit einer Erklärung daneben; unsere +`docker-compose.media.yml` heißt jetzt schlicht **`docker-compose.yml`** (in +Portainer also `Compose path: docker-compose.yml`). Gelöscht: drei Volumes und +drei Abbilder, rund **670 MB**. Nachgezählt war vorher, dass kein einziges Bild +darin lag. + +**Was noch bei dir liegt** — an NPM komme ich ohne Zugang nicht: + +1. Den Schnipsel `location ^~ /f/ { proxy_cache off; … }` einsetzen (siehe + oben), sonst bleiben überschriebene Bilder 30 Minuten alt. +2. Den Proxy-Host `fivecdn.d4rkst3r.de` löschen — er zeigt auf den toten Port + 9100 und gibt 502. + ### ⬜ Als Nächstes **5 · Umzug.** Root-`docker-compose.yml` wird unsere; die Fivemanage-Dateien @@ -440,12 +515,14 @@ Leergewicht). **Löschen kostet nichts**, es ist nur noch nicht getan. ## Offene Entscheidungen -- **Sicherung.** Ein Volume mit Bildern und `media.db`. Restic gegen die - Nextcloud? Oder reicht ein `docker cp` vor größeren Änderungen? - **Was dabei feststeht:** wegen WAL gehören `media.db-wal` und `media.db-shm` - mit ins Kopieren, sonst fehlen die letzten Uploads. Sauberer wäre - `sqlite3 media.db ".backup …"` oder ein `wal_checkpoint(TRUNCATE)` davor — - beides ungeprüft, weil noch nicht entschieden ist, wie gesichert wird. +- ~~**Sicherung.**~~ **Entschieden und gebaut** — `tools/sichern.ps1`, täglich + um 04:30, siehe oben. Weder Restic noch `docker cp`, sondern SQLites eigene + Sicherungsschnittstelle plus ein `tar.gz` aus einem nur lesend eingehängten + Volume. Die Vermutung von damals war richtig: ein blosses Kopieren wäre wegen + des WAL still unvollständig gewesen. + **Offen bleibt das Ziel:** die Archive liegen auf derselben Platte wie die + Daten. Gegen einen Plattenausfall hilft das nicht — dafür müssten sie in die + Nextcloud oder auf eine andere Maschine. - **Bilder aus dem Spiel** (Screenshots, Clips von Spielern) — dafür brauchte es Größenbegrenzungen je Token und vermutlich eine Warteschlange. Erst planen, wenn es ansteht. diff --git a/docker-compose.media.yml b/docker-compose.media.yml deleted file mode 100644 index 4debd6b..0000000 --- a/docker-compose.media.yml +++ /dev/null @@ -1,94 +0,0 @@ -# d4rk_media — unser eigener Medien-Dienst. EIN Container. -# -# In Portainer: Stacks -> Add stack -> Repository -# -# Repository URL https://git.d4rkst3r.de/D4rkst3r/d4rk_media -# Compose path docker-compose.media.yml -# -# 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. -# -# Die Werte gehoeren in die STACK-VARIABLEN, nicht in eine Datei im Repo. - -services: - media: - build: - context: ./server - image: d4rk-media:latest - container_name: d4rk-media - restart: unless-stopped - - 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} - - # 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} - - # 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:-} - - MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64} - SESSION_DAYS: ${SESSION_DAYS:-30} - - # 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 - - 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: - media-data: diff --git a/docker-compose.yml b/docker-compose.yml index 09f1828..adeb774 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,130 +1,94 @@ -# 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} + + # 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 + + 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: diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..05368d1 --- /dev/null +++ b/legacy/README.md @@ -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. diff --git a/docker-compose.local.yml b/legacy/docker-compose.local.yml similarity index 100% rename from docker-compose.local.yml rename to legacy/docker-compose.local.yml diff --git a/docker-compose.proxynet.yml b/legacy/docker-compose.proxynet.yml similarity index 100% rename from docker-compose.proxynet.yml rename to legacy/docker-compose.proxynet.yml diff --git a/legacy/docker-compose.yml b/legacy/docker-compose.yml new file mode 100644 index 0000000..09f1828 --- /dev/null +++ b/legacy/docker-compose.yml @@ -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: diff --git a/server/package-lock.json b/server/package-lock.json index 1b56466..0cf8bf0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@hono/node-server": "^1.13.7", "better-sqlite3": "^11.7.0", - "hono": "^4.6.14" + "hono": "^4.6.14", + "sharp": "^0.35.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", @@ -19,6 +20,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 +484,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", @@ -893,6 +1452,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 +1592,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", diff --git a/server/package.json b/server/package.json index c3a0397..1e603c5 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "dependencies": { "@hono/node-server": "^1.13.7", "better-sqlite3": "^11.7.0", - "hono": "^4.6.14" + "hono": "^4.6.14", + "sharp": "^0.35.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", diff --git a/server/src/backup.ts b/server/src/backup.ts new file mode 100644 index 0000000..d6ec2ce --- /dev/null +++ b/server/src/backup.ts @@ -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 ') + 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) + }) diff --git a/server/src/index.ts b/server/src/index.ts index 3aad46c..1b45b80 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -29,7 +29,7 @@ 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 { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js' import { uploadRoutes } from './routes/upload.js' import { dashRoutes } from './routes/dash.js' @@ -125,6 +125,48 @@ app.route('/api/dash', dashRoutes) 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 -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 diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 1e776f0..5dbc2a7 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -20,7 +20,17 @@ import { userForSession, verifyPassword, } from '../auth.js' -import { checkPath, deleteFile, publicUrlFor, PathError } from '../storage.js' +import { readFile, stat } from 'node:fs/promises' +import { + absolutePath, + checkPath, + deleteFile, + kannVorschau, + publicUrlFor, + thumbPath, + writeThumb, + PathError, +} from '../storage.js' type Vars = { user: User } @@ -41,13 +51,104 @@ async function jsonBody(c: Context): Promise { const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const +// ------------------------------------------------------------ Die Bremse +// +// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch +// teuer, aber teuer ist nicht selten: ohne Bremse darf jemand beliebig oft +// raten, und irgendwann ist "sommer2024" dran. +// +// Bewusst im Speicher und nicht in der Datenbank: die Sperre soll einen +// Neustart NICHT ueberleben. Wer sich selbst ausgesperrt hat, startet den +// Container neu und kommt rein — bei einem Dienst, den eine Person betreibt, +// ist das die richtige Notbremse. + +const FREI_VERSUCHE = 5 +const GRUNDSPERRE_MS = 30_000 +const HOECHSTSPERRE_MS = 15 * 60_000 + +type Versuche = { fehl: number; gesperrtBis: number } +const versuche = new Map() + +/** Wer da klopft. + * + * X-Real-IP zuerst: NPM setzt den Kopf mit `$remote_addr` und UEBERSCHREIBT + * damit, was der Aufrufer selbst hineinschreibt. Das gilt aber nur fuer den + * Weg durch den Proxy — wer den Container direkt auf seinem Port erreicht, + * kann sich jede Adresse ausdenken. Genau deshalb gehoert dieser Port hinter + * die Firewall, und das steht so in der ROADMAP. */ +function woher(c: Context): string { + const real = c.req.header('x-real-ip')?.trim() + if (real) return real + const weiter = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() + if (weiter) return weiter + // @hono/node-server reicht die Node-Anfrage durch; ohne Proxy ist das die + // einzige Quelle, die nicht vom Aufrufer stammt. + const socket = (c.env as any)?.incoming?.socket?.remoteAddress + return typeof socket === 'string' && socket ? socket : 'unbekannt' +} + +/** Wie lange noch gesperrt, in Sekunden — oder 0. */ +function gesperrt(key: string): number { + const eintrag = versuche.get(key) + if (!eintrag) return 0 + const rest = eintrag.gesperrtBis - Date.now() + return rest > 0 ? Math.ceil(rest / 1000) : 0 +} + +function fehlversuch(key: string): void { + const eintrag = versuche.get(key) ?? { fehl: 0, gesperrtBis: 0 } + eintrag.fehl += 1 + + if (eintrag.fehl > FREI_VERSUCHE) { + // Verdopplung ab dem sechsten Fehlversuch, gedeckelt. Aus 30 Sekunden + // werden schnell Minuten — fuer einen Tippfehler unmerklich, fuer eine + // Liste mit Millionen Passwoertern das Ende. + const stufe = eintrag.fehl - FREI_VERSUCHE - 1 + const dauer = Math.min(GRUNDSPERRE_MS * 2 ** stufe, HOECHSTSPERRE_MS) + eintrag.gesperrtBis = Date.now() + dauer + } + + versuche.set(key, eintrag) +} + +const geschafft = (key: string) => versuche.delete(key) + +// Aufraeumen, damit die Karte nicht endlos waechst. Stuendlich reicht: was +// laenger als eine Stunde nicht mehr geklopft hat, ist kein laufender Angriff. +setInterval( + () => { + const jetzt = Date.now() + for (const [key, eintrag] of versuche) { + if (eintrag.gesperrtBis < jetzt - 3600_000) versuche.delete(key) + } + }, + 3600_000, +).unref() + // ---------------------------------------------------------------- Anmeldung dashRoutes.post('/auth/login', async (c) => { + const key = woher(c) + + // Erst die Sperre, dann alles andere: ein gesperrter Versuch soll nicht + // einmal scrypt kosten. + const rest = gesperrt(key) + if (rest > 0) { + return c.json( + { + error: `Zu viele Fehlversuche. Noch ${rest} Sekunden warten.`, + }, + 429, + { 'Retry-After': String(rest) }, + ) + } + const body = await jsonBody<{ username?: string; password?: string }>(c) if (!body) return c.json(KEIN_JSON, 400) const { username, password } = body + // Ein leeres Formular ist ein Bedienfehler und kein Rateversuch — der + // zaehlt nicht mit. if (!username || !password) { return c.json({ error: 'Benutzername und Passwort noetig' }, 400) } @@ -59,9 +160,19 @@ dashRoutes.post('/auth/login', async (c) => { // Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches // Passwort": alles andere verraet, welche Namen es gibt. if (!user || !verifyPassword(password, user.password_hash)) { - return c.json({ error: 'Benutzername oder Passwort stimmt nicht' }, 401) + fehlversuch(key) + const jetztRest = gesperrt(key) + return c.json( + { + error: jetztRest + ? `Benutzername oder Passwort stimmt nicht. Zu viele Fehlversuche — noch ${jetztRest} Sekunden warten.` + : 'Benutzername oder Passwort stimmt nicht', + }, + jetztRest ? 429 : 401, + ) } + geschafft(key) const session = createSession(user.id) setCookie(c, SESSION_COOKIE, session.id, { httpOnly: true, @@ -263,3 +374,49 @@ dashRoutes.post('/maintenance/prune-sessions', (c) => { pruneSessions() return c.json({ ok: true }) }) + +/** Fehlende Vorschaubilder nachziehen. + * + * Braucht es, weil die Vorschauen erst spaeter dazukamen: was vorher + * hochgeladen wurde, hat keine. Und als Reparatur, falls eine je fehlschlaegt. + * + * Das Ergebnis nennt Zahlen und nicht nur "ok" — ein Knopf, der nach zwanzig + * Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der + * Frage, ob er ueberhaupt etwas getan hat. */ +dashRoutes.post('/maintenance/thumbs', async (c) => { + const rows = db.prepare('SELECT path, mime FROM media').all() as Pick< + Media, + 'path' | 'mime' + >[] + + let erzeugt = 0 + let vorhanden = 0 + let uebersprungen = 0 + let fehler = 0 + + for (const row of rows) { + if (!kannVorschau(row.mime)) { + uebersprungen++ + continue + } + try { + await stat(thumbPath(row.path)) + vorhanden++ + continue + } catch { + /* gibt es noch nicht — gleich */ + } + + try { + const data = await readFile(absolutePath(row.path)) + if (await writeThumb(row.path, data)) erzeugt++ + else fehler++ + } catch { + // Ein Datensatz ohne Datei. Kommt vor, wenn jemand im Volume + // aufgeraeumt hat; hier ist es ein Zaehler und kein Absturz. + fehler++ + } + } + + return c.json({ erzeugt, vorhanden, uebersprungen, fehler, gesamt: rows.length }) +}) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 1e06632..e0b11f4 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -30,10 +30,12 @@ import { deleteFile, extensionFor, fileExists, + kannVorschau, mimeFor, publicUrlFor, sha256, writeFileAtomic, + writeThumb, } from '../storage.js' type Vars = { token: Token } @@ -165,6 +167,11 @@ uploadRoutes.post('/upload', async (c) => { 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, data) + const timestamp = now() if (existing) { db.prepare( diff --git a/server/src/storage.ts b/server/src/storage.ts index 769ae13..8a9277f 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -120,8 +120,65 @@ export async function writeFileAtomic(path: string, data: Buffer): Promise } } +// -------------------------------------------------------------- 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 { + 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 + } +} + export async function deleteFile(path: string): Promise { await rm(absolutePath(path), { force: true }) + // Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild + // unter demselben Pfad sieht man das alte. + await rm(thumbPath(path), { force: true }) } export async function fileExists(path: string): Promise { diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 6752acf..1941956 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -137,6 +137,21 @@ export type NewToken = { token: string } +export type ThumbLauf = { + erzeugt: number + vorhanden: number + uebersprungen: number + fehler: number + gesamt: 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 }), @@ -154,6 +169,7 @@ export const api = { post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }), stats: () => get('/api/dash/stats'), + thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'), createToken: (name: string, prefix: string, canDelete: boolean) => diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index c4d3931..3aa77c4 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -12,7 +12,7 @@ import { Trash2, X, } from 'lucide-react' -import { api, type MediaItem, type MediaPage } from '../api' +import { api, vorschauUrl, type MediaItem, type MediaPage } from '../api' import { run, useStore } from '../store' import { bytes, istBild, zeit } from '../format' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' @@ -213,7 +213,20 @@ export function Galerie() {
{istBild(item.mime) ? ( { + const bild = e.currentTarget + if (bild.dataset.gefallen) return + bild.dataset.gefallen = 'ja' + bild.src = item.url + }} alt={item.path} loading="lazy" className="h-full w-full object-contain" diff --git a/server/ui/src/pages/Speicher.tsx b/server/ui/src/pages/Speicher.tsx index f8633b3..5530239 100644 --- a/server/ui/src/pages/Speicher.tsx +++ b/server/ui/src/pages/Speicher.tsx @@ -1,14 +1,15 @@ import { useEffect, useState } from 'react' -import { Check, Copy, Folder, HardDrive, KeyRound, Loader2 } from 'lucide-react' +import { Check, Copy, Folder, HardDrive, Images, KeyRound, Loader2 } from 'lucide-react' import { api, type Stats } from '../api' -import { useStore } from '../store' +import { run, useStore } from '../store' import { bytes } from '../format' -import { Karte, Leer, useKopieren } from '../components/Bausteine' +import { Karte, Knopf, Leer, useKopieren } from '../components/Bausteine' export function Speicher() { const note = useStore((s) => s.note) const [stats, setStats] = useState(null) const [laedt, setLaedt] = useState(true) + const [zieht, setZieht] = useState(false) const { kopiert, kopieren } = useKopieren() useEffect(() => { @@ -69,6 +70,37 @@ export function Speicher() {

+ +
+ { + setZieht(true) + await run( + () => api.thumbsNachziehen(), + (r) => + r.erzeugt === 0 + ? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.` + : `${r.erzeugt} Vorschauen erzeugt` + + (r.fehler ? `, ${r.fehler} fehlgeschlagen` : '') + + `. ${r.vorhanden} waren schon da.`, + ) + setZieht(false) + }} + kind={ + <> + + Fehlende Vorschaubilder erzeugen + + } + /> +

+ Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion + hochgeladen wurde, hat noch keine. +

+
+
+ {stats.folders.length === 0 ? ( diff --git a/tools/sichern.ps1 b/tools/sichern.ps1 new file mode 100644 index 0000000..11d112e --- /dev/null +++ b/tools/sichern.ps1 @@ -0,0 +1,142 @@ +# 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. Alte Archive fliegen raus, sobald mehr als -Behalten da sind. +# +# 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 +) + +$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 } + +# -- 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)' +$ausgabe = docker exec $Container node dist/backup.js /data/.sicherung/media.db 2>&1 +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' +# Zwei -C: die Bilder kommen als files/... in das Archiv, die Aufnahme als +# media.db daneben. Bewusst das eigene Abbild statt alpine -- dort ist GNU tar +# drin, und das kann mehrere -C. +docker run --rm ` + -v "${Volume}:/data:ro" ` + -v "${Ziel}:/out" ` + --entrypoint sh $Abbild -c "tar czf '/out/$name' -C /data files -C /data/.sicherung media.db" +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)' +$pruefung = docker run --rm ` + -v "${Ziel}:/in:ro" ` + --entrypoint sh $Abbild -c @" +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 +"@ +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.' + +# -- 4. Alte Sicherungen ausduennen -------------------------------------------- + +$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)" + } +} + +Write-Host '' +Gut "Fertig: $archiv" +Write-Host " Zurueckspielen: tar xzf , dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray +Write-Host " waehrend der Container steht. Siehe ROADMAP." -ForegroundColor DarkGray