feat: Sicherung, Anmeldebremse, Vorschaubilder -- und Schritt 5
SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe). Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder kommen aus einem NUR LESEND eingehaengten Volume dazu. Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/ Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die nie zurueckgespielt wurde, ist eine Hoffnung. ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text, der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt, startet den Container neu. Durchgemessen bis zur Erholung. VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter /data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes, Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander- laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und nennt Zahlen statt "fertig". SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild darin lag. Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+84
-7
@@ -201,7 +201,7 @@ Grundgerüst war nichts zu ändern; der Bau hat keinen einzigen Befund erzeugt.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PUBLIC_URL=http://localhost:9102 ADMIN_PASSWORD=…
|
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:**
|
**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
|
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.
|
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/<pfad>`. 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
|
### ⬜ Als Nächstes
|
||||||
|
|
||||||
**5 · Umzug.** Root-`docker-compose.yml` wird unsere; die Fivemanage-Dateien
|
**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
|
## Offene Entscheidungen
|
||||||
|
|
||||||
- **Sicherung.** Ein Volume mit Bildern und `media.db`. Restic gegen die
|
- ~~**Sicherung.**~~ **Entschieden und gebaut** — `tools/sichern.ps1`, täglich
|
||||||
Nextcloud? Oder reicht ein `docker cp` vor größeren Änderungen?
|
um 04:30, siehe oben. Weder Restic noch `docker cp`, sondern SQLites eigene
|
||||||
**Was dabei feststeht:** wegen WAL gehören `media.db-wal` und `media.db-shm`
|
Sicherungsschnittstelle plus ein `tar.gz` aus einem nur lesend eingehängten
|
||||||
mit ins Kopieren, sonst fehlen die letzten Uploads. Sauberer wäre
|
Volume. Die Vermutung von damals war richtig: ein blosses Kopieren wäre wegen
|
||||||
`sqlite3 media.db ".backup …"` oder ein `wal_checkpoint(TRUNCATE)` davor —
|
des WAL still unvollständig gewesen.
|
||||||
beides ungeprüft, weil noch nicht entschieden ist, wie gesichert wird.
|
**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
|
- **Bilder aus dem Spiel** (Screenshots, Clips von Spielern) — dafür brauchte
|
||||||
es Größenbegrenzungen je Token und vermutlich eine Warteschlange. Erst
|
es Größenbegrenzungen je Token und vermutlich eine Warteschlange. Erst
|
||||||
planen, wenn es ansteht.
|
planen, wenn es ansteht.
|
||||||
|
|||||||
@@ -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:
|
|
||||||
+79
-115
@@ -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
|
# In Portainer: Stacks -> Add stack -> Repository
|
||||||
# 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:
|
# Repository URL https://git.d4rkst3r.de/D4rkst3r/d4rk_media
|
||||||
|
# Compose path docker-compose.yml
|
||||||
#
|
#
|
||||||
# ClickHouse und Jaeger. Beides ist strukturiertes Logging und Tracing. Fuer
|
# EINE Datei, und das ist wichtig: Portainer nimmt unter "Compose path" genau
|
||||||
# das Ablegen von Bildern braucht es sie nicht, und ClickHouse ist eine
|
# eine. Ergaenzungen werden stillschweigend uebergangen -- alle Container
|
||||||
# schwere Abhaengigkeit. Nachruesten geht spaeter.
|
# gesund, alles 502. Das hat beim Lite-Anlauf einen Nachmittag gekostet.
|
||||||
#
|
#
|
||||||
# Der Weg nach aussen steht HIER DRIN und nicht in einer Ergaenzungsdatei.
|
# Die Werte gehoeren in die STACK-VARIABLEN, nicht in eine Datei im Repo.
|
||||||
# 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:
|
services:
|
||||||
db:
|
media:
|
||||||
image: postgres:16-alpine
|
build:
|
||||||
|
context: ./server
|
||||||
|
image: d4rk-media:latest
|
||||||
|
container_name: d4rk-media
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: fivemanage_lite
|
# Der Ursprung, unter dem der Dienst oeffentlich steht -- OHNE Pfad
|
||||||
POSTGRES_USER: lite
|
# und ohne abschliessenden Schraegstrich:
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?bitte in .env oder als Stack-Variable setzen}
|
# 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:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
# Bilder UND media.db in einem Volume. Eine Sicherung ist damit
|
||||||
networks: [internal]
|
# genau ein Kopiervorgang.
|
||||||
healthcheck:
|
- media-data:/data
|
||||||
# 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:
|
ports:
|
||||||
# Die Dateien, fuer den Reverse Proxy ueber den Host:
|
# Links der Host-Port, rechts der im Container. Nur die LINKE Zahl
|
||||||
# fivecdn.d4rkst3r.de -> http://host.docker.internal:9100
|
# muss auf diesem Server einmalig sein.
|
||||||
#
|
#
|
||||||
# 9100 statt 9000, weil 9000 auf diesem Server an Portainer geht. Im
|
# Belegt sind 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080,
|
||||||
# Container bleibt es 9000; nur die Seite zum Host wandert.
|
# 8090, 9000, 9443, 11000 -- und 9100/9101, solange der Lite-Stack
|
||||||
|
# als Vergleichsmassstab weiterlaeuft. Deshalb 9102.
|
||||||
#
|
#
|
||||||
# An alle Adressen gebunden, und das ist kein Versehen: der Nginx Proxy
|
# Gebunden an ALLE Adressen, nicht an 127.0.0.1: NPM liegt im
|
||||||
# Manager liegt im Standard-Bridge-Netz und kommt ueber die Docker-
|
# Standard-Bridge-Netz und kommt ueber die Docker-Bruecke herein,
|
||||||
# Bruecke herein, nicht ueber Loopback — ein an 127.0.0.1 gebundener
|
# nicht ueber Loopback. Ein an 127.0.0.1 gebundener Port naehme die
|
||||||
# Port nimmt diese Anfrage nicht an. Genauso laufen hier schon d4rkbot
|
# Anfrage nicht an. ZU MACHT IHN DIE FIREWALL, nicht die Bindung --
|
||||||
# (3080) und cdn-files (8090).
|
# 9102 gehoert von aussen dicht, oeffentlich ist nur NPM auf 80/443.
|
||||||
#
|
- '${BIND_ADDR:-0.0.0.0}:${HOST_PORT:-9102}:8080'
|
||||||
# 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:
|
volumes:
|
||||||
pgdata:
|
media-data:
|
||||||
minio:
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
Generated
+616
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"hono": "^4.6.14"
|
"hono": "^4.6.14",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
@@ -19,6 +20,16 @@
|
|||||||
"typescript": "^5.7.2"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
@@ -473,6 +484,554 @@
|
|||||||
"hono": "^4"
|
"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": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
@@ -893,6 +1452,55 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/simple-concat": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
@@ -984,6 +1592,13 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.23.12",
|
"version": "4.23.12",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"hono": "^4.6.14"
|
"hono": "^4.6.14",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Eine Sicherung der Datenbank, die auch stimmt, waehrend geschrieben wird.
|
||||||
|
//
|
||||||
|
// WARUM NICHT EINFACH KOPIEREN. media.db laeuft im WAL-Modus. Was zuletzt
|
||||||
|
// hochgeladen wurde, steht dann noch in media.db-wal und nicht in media.db —
|
||||||
|
// eine Kopie nur der einen Datei ist deshalb still unvollstaendig. Beim ersten
|
||||||
|
// Nachsehen im Container war das WAL nach wenigen Uploads 152 KB gross.
|
||||||
|
//
|
||||||
|
// Und selbst alle drei Dateien zu kopieren reicht nicht sicher: waehrend des
|
||||||
|
// Kopierens kann geschrieben werden, und dann passen sie nicht mehr zueinander.
|
||||||
|
//
|
||||||
|
// Deshalb SQLites eigene Sicherungsschnittstelle. Sie laeuft auf der offenen
|
||||||
|
// Datenbank, nimmt eine in sich stimmige Aufnahme und faengt von vorn an, wenn
|
||||||
|
// dazwischen geschrieben wurde. Das Ergebnis ist EINE Datei ohne WAL daneben.
|
||||||
|
//
|
||||||
|
// Aufruf im Container:
|
||||||
|
//
|
||||||
|
// node dist/backup.js /data/.sicherung/media.db
|
||||||
|
//
|
||||||
|
// Der Rest — Bilder dazupacken, herausholen, pruefen — steht in
|
||||||
|
// tools/sichern.ps1; hier bleibt nur der Teil, der die Datenbank kennt.
|
||||||
|
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import { db } from './db.js'
|
||||||
|
|
||||||
|
const ziel = process.argv[2]
|
||||||
|
|
||||||
|
if (!ziel) {
|
||||||
|
console.error('[sicherung] Kein Ziel angegeben. Aufruf: node dist/backup.js <pfad>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(ziel), { recursive: true })
|
||||||
|
|
||||||
|
const zeilen = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
|
||||||
|
|
||||||
|
db.backup(ziel)
|
||||||
|
.then(() => {
|
||||||
|
// Die Zeilenzahl mit ausgeben: sie ist der Wert, gegen den die
|
||||||
|
// Sicherung hinterher geprueft wird. Eine Sicherung, die niemand
|
||||||
|
// nachzaehlt, ist eine Vermutung.
|
||||||
|
console.log(`[sicherung] ${ziel} geschrieben, ${zeilen.n} Medieneintraege`)
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error(`[sicherung] fehlgeschlagen: ${err instanceof Error ? err.message : err}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
+43
-1
@@ -29,7 +29,7 @@ import { logger } from 'hono/logger'
|
|||||||
import { config } from './config.js'
|
import { config } from './config.js'
|
||||||
import { ensureAdmin } from './auth.js'
|
import { ensureAdmin } from './auth.js'
|
||||||
import { pruneSessions } from './db.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 { uploadRoutes } from './routes/upload.js'
|
||||||
import { dashRoutes } from './routes/dash.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)))
|
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
|
||||||
|
|
||||||
|
/** Die Vorschaubilder, unter /t/.
|
||||||
|
*
|
||||||
|
* Absichtlich NICHT unter dem Dateihost und absichtlich ohne Anmeldung: die
|
||||||
|
* Bilder selbst sind ohnehin oeffentlich, und das Dashboard laedt die
|
||||||
|
* Vorschauen mit gewoehnlichen <img>-Marken — mit Anmeldung braeuchte es
|
||||||
|
* dafuer einen Umweg ueber fetch und Blob-Adressen.
|
||||||
|
*
|
||||||
|
* Fehlt eine Vorschau, kommt 404 und das Dashboard nimmt das Vollbild. Kein
|
||||||
|
* Erzeugen auf Zuruf: eine Route, die bei jeder Anfrage rechnen kann, ist
|
||||||
|
* eine Route, die sich mit 900 gleichzeitigen Anfragen umbringen laesst. */
|
||||||
|
app.get('/t/*', async (c) => {
|
||||||
|
const roh = c.req.path.slice(3)
|
||||||
|
let path: string
|
||||||
|
try {
|
||||||
|
path = checkPath(decodeURIComponent(roh))
|
||||||
|
} catch {
|
||||||
|
return c.text('nicht gefunden', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = thumbPath(path)
|
||||||
|
let info
|
||||||
|
try {
|
||||||
|
info = await stat(absolute)
|
||||||
|
if (!info.isFile()) return c.text('nicht gefunden', 404)
|
||||||
|
} catch {
|
||||||
|
return c.text('nicht gefunden', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
|
||||||
|
if (c.req.header('if-none-match') === etag) return c.body(null, 304)
|
||||||
|
|
||||||
|
return c.body(createReadStream(absolute) as any, 200, {
|
||||||
|
'Content-Type': 'image/webp',
|
||||||
|
'Content-Length': String(info.size),
|
||||||
|
ETag: etag,
|
||||||
|
// Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen
|
||||||
|
// wird, und dann soll das Dashboard es sehen.
|
||||||
|
'Cache-Control': 'public, max-age=60',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Die gebaute Oberflaeche. Alles, was keine API ist und keine Datei, bekommt
|
// 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.
|
// index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router.
|
||||||
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
|
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
|
||||||
|
|||||||
+159
-2
@@ -20,7 +20,17 @@ import {
|
|||||||
userForSession,
|
userForSession,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from '../auth.js'
|
} 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 }
|
type Vars = { user: User }
|
||||||
|
|
||||||
@@ -41,13 +51,104 @@ async function jsonBody<T>(c: Context<any>): Promise<T | null> {
|
|||||||
|
|
||||||
const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
|
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<string, Versuche>()
|
||||||
|
|
||||||
|
/** 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<any>): 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
|
// ---------------------------------------------------------------- Anmeldung
|
||||||
|
|
||||||
dashRoutes.post('/auth/login', async (c) => {
|
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)
|
const body = await jsonBody<{ username?: string; password?: string }>(c)
|
||||||
if (!body) return c.json(KEIN_JSON, 400)
|
if (!body) return c.json(KEIN_JSON, 400)
|
||||||
const { username, password } = body
|
const { username, password } = body
|
||||||
|
|
||||||
|
// Ein leeres Formular ist ein Bedienfehler und kein Rateversuch — der
|
||||||
|
// zaehlt nicht mit.
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return c.json({ error: 'Benutzername und Passwort noetig' }, 400)
|
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
|
// Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches
|
||||||
// Passwort": alles andere verraet, welche Namen es gibt.
|
// Passwort": alles andere verraet, welche Namen es gibt.
|
||||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
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)
|
const session = createSession(user.id)
|
||||||
setCookie(c, SESSION_COOKIE, session.id, {
|
setCookie(c, SESSION_COOKIE, session.id, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -263,3 +374,49 @@ dashRoutes.post('/maintenance/prune-sessions', (c) => {
|
|||||||
pruneSessions()
|
pruneSessions()
|
||||||
return c.json({ ok: true })
|
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 })
|
||||||
|
})
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import {
|
|||||||
deleteFile,
|
deleteFile,
|
||||||
extensionFor,
|
extensionFor,
|
||||||
fileExists,
|
fileExists,
|
||||||
|
kannVorschau,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
publicUrlFor,
|
publicUrlFor,
|
||||||
sha256,
|
sha256,
|
||||||
writeFileAtomic,
|
writeFileAtomic,
|
||||||
|
writeThumb,
|
||||||
} from '../storage.js'
|
} from '../storage.js'
|
||||||
|
|
||||||
type Vars = { token: Token }
|
type Vars = { token: Token }
|
||||||
@@ -165,6 +167,11 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
return c.json({ error: 'Schreiben fehlgeschlagen' }, 500)
|
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()
|
const timestamp = now()
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
|
|||||||
@@ -120,8 +120,65 @@ export async function writeFileAtomic(path: string, data: Buffer): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Vorschaubilder
|
||||||
|
//
|
||||||
|
// WARUM UEBERHAUPT. Ein freigestelltes Fahrzeugbild wiegt rund 124 KB. Eine
|
||||||
|
// Galerieseite zeigt 60 davon — das sind 7,2 MB, nur damit jemand sieht, WELCHE
|
||||||
|
// Bilder da sind. Bei 900 Fahrzeugen liegen 109 MB in der Ablage. Die Vorschau
|
||||||
|
// wiegt ein Fuenfzigstel und reicht fuer eine Kachel voellig.
|
||||||
|
//
|
||||||
|
// WO SIE LIEGEN. Unter /data/thumbs, im selben Baum wie die Bilder, mit
|
||||||
|
// ".webp" hinten dran. Also thumbs/vehicles/adder.webp.webp — haesslich, aber
|
||||||
|
// eindeutig: haengte man die Endung nicht an, kollidierten adder.png und
|
||||||
|
// adder.webp im Vorschauordner.
|
||||||
|
//
|
||||||
|
// KEINE SPALTE IN DER DATENBANK. Ob es eine Vorschau gibt, sagt das
|
||||||
|
// Dateisystem. Eine Spalte waere eine zweite Wahrheit, die auseinanderlaufen
|
||||||
|
// kann — und das Dashboard faellt ohnehin auf das Vollbild zurueck, wenn die
|
||||||
|
// Vorschau fehlt.
|
||||||
|
|
||||||
|
export const THUMB_BREITE = 320
|
||||||
|
|
||||||
|
export const thumbPath = (path: string) => join(config.dataDir, 'thumbs', `${path}.webp`)
|
||||||
|
|
||||||
|
export const kannVorschau = (mime: string) =>
|
||||||
|
/^image\/(png|jpeg|webp|avif|gif)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
|
||||||
|
|
||||||
|
/** Eine Vorschau erzeugen. Schlaegt sie fehl, ist das KEIN Grund, den Upload
|
||||||
|
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
|
||||||
|
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
|
||||||
|
export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
|
||||||
|
const ziel = thumbPath(path)
|
||||||
|
try {
|
||||||
|
// Der Import steht hier drin und nicht oben: sharp zieht eine native
|
||||||
|
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
|
||||||
|
// Bild kommt.
|
||||||
|
const { default: sharp } = await import('sharp')
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
|
||||||
|
const bild = await sharp(data, { animated: false })
|
||||||
|
// withoutEnlargement: ein 64 Pixel breites Bild wird nicht auf 320
|
||||||
|
// aufgeblasen — das kostet Platz und sieht schlechter aus als das
|
||||||
|
// Original.
|
||||||
|
.resize({ width: THUMB_BREITE, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 72 })
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
const temp = `${ziel}.${process.pid}.tmp`
|
||||||
|
await writeFile(temp, bild)
|
||||||
|
await rename(temp, ziel)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteFile(path: string): Promise<void> {
|
export async function deleteFile(path: string): Promise<void> {
|
||||||
await rm(absolutePath(path), { force: true })
|
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<boolean> {
|
export async function fileExists(path: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -137,6 +137,21 @@ export type NewToken = {
|
|||||||
token: string
|
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 = {
|
export const api = {
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
post<{ user: User }>('/api/dash/auth/login', { username, password }),
|
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 }),
|
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
|
||||||
|
|
||||||
stats: () => get<Stats>('/api/dash/stats'),
|
stats: () => get<Stats>('/api/dash/stats'),
|
||||||
|
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
||||||
|
|
||||||
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
||||||
createToken: (name: string, prefix: string, canDelete: boolean) =>
|
createToken: (name: string, prefix: string, canDelete: boolean) =>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} 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 { run, useStore } from '../store'
|
||||||
import { bytes, istBild, zeit } from '../format'
|
import { bytes, istBild, zeit } from '../format'
|
||||||
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||||
@@ -213,7 +213,20 @@ export function Galerie() {
|
|||||||
<div className="flex aspect-square items-center justify-center bg-grund">
|
<div className="flex aspect-square items-center justify-center bg-grund">
|
||||||
{istBild(item.mime) ? (
|
{istBild(item.mime) ? (
|
||||||
<img
|
<img
|
||||||
src={item.url}
|
// Erst die Vorschau: 60 Vollbilder
|
||||||
|
// sind 7,2 MB, nur damit jemand
|
||||||
|
// sieht, WELCHE Bilder da sind.
|
||||||
|
// Fehlt sie (404), nimmt der
|
||||||
|
// Browser das Vollbild — deshalb
|
||||||
|
// braucht es dafuer weder eine
|
||||||
|
// Spalte noch eine Nachfrage.
|
||||||
|
src={vorschauUrl(item.path)}
|
||||||
|
onError={(e) => {
|
||||||
|
const bild = e.currentTarget
|
||||||
|
if (bild.dataset.gefallen) return
|
||||||
|
bild.dataset.gefallen = 'ja'
|
||||||
|
bild.src = item.url
|
||||||
|
}}
|
||||||
alt={item.path}
|
alt={item.path}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { api, type Stats } from '../api'
|
||||||
import { useStore } from '../store'
|
import { run, useStore } from '../store'
|
||||||
import { bytes } from '../format'
|
import { bytes } from '../format'
|
||||||
import { Karte, Leer, useKopieren } from '../components/Bausteine'
|
import { Karte, Knopf, Leer, useKopieren } from '../components/Bausteine'
|
||||||
|
|
||||||
export function Speicher() {
|
export function Speicher() {
|
||||||
const note = useStore((s) => s.note)
|
const note = useStore((s) => s.note)
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [laedt, setLaedt] = useState(true)
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [zieht, setZieht] = useState(false)
|
||||||
const { kopiert, kopieren } = useKopieren()
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,6 +70,37 @@ export function Speicher() {
|
|||||||
</p>
|
</p>
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Wartung">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
wartet={zieht}
|
||||||
|
onClick={async () => {
|
||||||
|
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={
|
||||||
|
<>
|
||||||
|
<Images size={15} />
|
||||||
|
Fehlende Vorschaubilder erzeugen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion
|
||||||
|
hochgeladen wurde, hat noch keine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
<Karte titel="Größte Ordner">
|
<Karte titel="Größte Ordner">
|
||||||
{stats.folders.length === 0 ? (
|
{stats.folders.length === 0 ? (
|
||||||
<Leer text="Noch nichts abgelegt." />
|
<Leer text="Noch nichts abgelegt." />
|
||||||
|
|||||||
@@ -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 <archiv>, dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray
|
||||||
|
Write-Host " waehrend der Container steht. Siehe ROADMAP." -ForegroundColor DarkGray
|
||||||
Reference in New Issue
Block a user