Compare commits
75
Commits
5f7f17ce7e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06285a3a53 | ||
|
|
c9e5bb3c31 | ||
|
|
8a17b8b974 | ||
|
|
a99e792194 | ||
|
|
a7e9332bad | ||
|
|
a373b962d0 | ||
|
|
0f0097ba3c | ||
|
|
ab7605a6d2 | ||
|
|
0a7d83aee3 | ||
|
|
4af8366517 | ||
|
|
befa393b84 | ||
|
|
9e4132cc7e | ||
|
|
89d0ec2b14 | ||
|
|
0f3f63749c | ||
|
|
6e654e0ef8 | ||
|
|
f138df44a7 | ||
|
|
70260060c9 | ||
|
|
a36fcec57e | ||
|
|
a494f472d1 | ||
|
|
8e3a13b374 | ||
|
|
15f4c7bc3d | ||
|
|
b82d315f8d | ||
|
|
9727eae0f5 | ||
|
|
14fadf5f30 | ||
|
|
4430dc2944 | ||
|
|
ab93e0ae4e | ||
|
|
3a89acaa41 | ||
|
|
6bd1c7a55e | ||
|
|
608b3b3097 | ||
|
|
1baa67c891 | ||
|
|
74e2128afb | ||
|
|
bbb74138c8 | ||
|
|
6a49713821 | ||
|
|
381feade4e | ||
|
|
29e516559a | ||
|
|
a91c4261de | ||
|
|
e8efa2431d | ||
|
|
463a821199 | ||
|
|
72fd330ae6 | ||
|
|
cf2e3d2387 | ||
|
|
3f706b27e1 | ||
|
|
338f8e59cf | ||
|
|
7877c0f7b9 | ||
|
|
914f2fb854 | ||
|
|
458775f9d9 | ||
|
|
06e924daf5 | ||
|
|
92b49a59a5 | ||
|
|
44ad7dc6b3 | ||
|
|
d125c6bf72 | ||
|
|
7b35ef5698 | ||
|
|
aa820c32af | ||
|
|
40c446f859 | ||
|
|
e4a125e1c6 | ||
|
|
5d35381952 | ||
|
|
51071fc292 | ||
|
|
c9cd8eaa84 | ||
|
|
37bf6a68ed | ||
|
|
66c5de1e31 | ||
|
|
28404b78c2 | ||
|
|
67d2f4ec93 | ||
|
|
32d69c9945 | ||
|
|
2efc04b0b2 | ||
|
|
4e613dd40d | ||
|
|
74cf56ce81 | ||
|
|
84cb3ee200 | ||
|
|
4f149e365f | ||
|
|
23405d68d3 | ||
|
|
1c5bf20eb0 | ||
|
|
dffec7714c | ||
|
|
0b295a2302 | ||
|
|
3ddab937e7 | ||
|
|
f7086c231c | ||
|
|
aa2575bdf4 | ||
|
|
6c8ead8e15 | ||
|
|
65828b526c |
+64
-21
@@ -1,31 +1,74 @@
|
|||||||
# Fivemanage Lite — Geheimnisse und Adressen
|
# d4rk_media — die Werte fuer den Betrieb.
|
||||||
#
|
#
|
||||||
# Diese Datei ist die VORLAGE. Kopiere sie nach .env und trag echte Werte ein.
|
# Diese Datei ist die VORLAGE. Kopiere sie nach .env und trag echte Werte ein;
|
||||||
# .env selbst gehoert nicht ins Repo (siehe .gitignore).
|
# .env selbst gehoert nicht ins Repo (siehe .gitignore).
|
||||||
#
|
#
|
||||||
# In Portainer traegst du dieselben Werte stattdessen als Stack-Variablen ein;
|
# In Portainer traegst du dieselben Werte als STACK-VARIABLEN ein — dann
|
||||||
# dann braucht es die Datei dort gar nicht.
|
# braucht es die Datei dort gar nicht.
|
||||||
#
|
#
|
||||||
# Erzeugen:
|
# Nur zwei sind Pflicht. Der Rest hat brauchbare Standards, und der Dienst
|
||||||
# openssl rand -hex 16 DB_PASSWORD, MINIO_ROOT_PASSWORD
|
# BRICHT AB, wenn eine der beiden fehlt: einer, der ohne Konfiguration
|
||||||
# openssl rand -hex 32 API_TOKEN_HMAC_SECRET (muss 32 Byte sein)
|
# hochfaehrt, kostet mehr Zeit als einer, der sich weigert.
|
||||||
# openssl rand -hex 12 ADMIN_PASSWORD
|
|
||||||
|
|
||||||
DB_PASSWORD=
|
# ------------------------------------------------------------------- Pflicht
|
||||||
MINIO_ROOT_PASSWORD=
|
|
||||||
API_TOKEN_HMAC_SECRET=
|
# Der Ursprung, unter dem der Dienst oeffentlich steht — OHNE Pfad und ohne
|
||||||
|
# abschliessenden Schraegstrich.
|
||||||
|
#
|
||||||
|
# Das /f/ haengt der Dienst selbst an. Wer es hier mitschreibt, bekommt
|
||||||
|
# Adressen mit /f/f/, und das faellt erst auf, wenn das erste Bild fehlt.
|
||||||
|
PUBLIC_URL=https://media.d4rkst3r.de
|
||||||
|
|
||||||
|
# Wird beim ERSTEN Start als Benutzer "admin" angelegt. Ein spaeter geaenderter
|
||||||
|
# Wert setzt das bestehende Passwort NICHT zurueck.
|
||||||
|
#
|
||||||
|
# openssl rand -base64 24
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Unter welcher Adresse die Dateien oeffentlich liegen. Ohne sie liefert MinIO
|
# ------------------------------------------------------------------ Optional
|
||||||
# Links auf den internen Containernamen aus — die gehen von aussen ins Leere.
|
|
||||||
PUBLIC_URL=https://fivecdn.d4rkst3r.de
|
|
||||||
|
|
||||||
# Das Docker-Netz, in dem der bestehende Reverse Proxy haengt.
|
# Der Name des Benutzers, der beim ersten Start entsteht.
|
||||||
|
#ADMIN_USERNAME=admin
|
||||||
|
|
||||||
|
# Host-Port. Auf diesem Server zeigt der NPM-Eintrag auf 9101.
|
||||||
#
|
#
|
||||||
# Wird NUR gebraucht, wenn du docker-compose.proxynet.yml dazunimmst. Beim Weg
|
# Belegt sind hier 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080, 8443, 8090,
|
||||||
# ueber host.docker.internal (docker-compose.hostports.yml) ist es
|
# 9000, 9443, 11000. Nur die LINKE Zahl einer Portabbildung muss einmalig sein.
|
||||||
# gegenstandslos.
|
#HOST_PORT=9101
|
||||||
|
|
||||||
|
# An welche Adresse der Port gebunden wird.
|
||||||
#
|
#
|
||||||
# `docker network ls` zeigt die Namen. Auf diesem Server heisst es
|
# 0.0.0.0 und NICHT 127.0.0.1: der Reverse Proxy kommt ueber die Docker-Bruecke
|
||||||
# wahrscheinlich `web` — ein Bridge-Netz ohne Stack.
|
# herein, nicht ueber Loopback. Ein an 127.0.0.1 gebundener Port naehme die
|
||||||
PROXY_NETWORK=web
|
# Anfrage nicht an. Zu macht ihn die Firewall, nicht die Bindung.
|
||||||
|
#BIND_ADDR=0.0.0.0
|
||||||
|
|
||||||
|
# ZWEI NAMEN statt einem: hier den Hostnamen eintragen, unter dem es dann
|
||||||
|
# ausschliesslich Dateien gibt — kein Dashboard, keine API, nichts anzumelden.
|
||||||
|
# Die URLs kommen dann ohne /f/ aus, und PUBLIC_URL ist der Datei-Name. Beide
|
||||||
|
# Namen muessen im Proxy auf diesen Port zeigen.
|
||||||
|
#
|
||||||
|
# Leer (Standard) heisst: EIN Name, Dateien unter /f/.
|
||||||
|
#FILES_HOST=
|
||||||
|
|
||||||
|
# Groesste erlaubte Datei. Ein freigestelltes Fahrzeugbild wiegt rund 130 KB;
|
||||||
|
# 64 MB lassen Luft fuer Videos, ohne dass ein Fehlgriff die Platte fuellt.
|
||||||
|
#
|
||||||
|
# Je Token laesst sich das im Dashboard weiter einschraenken.
|
||||||
|
#MAX_UPLOAD_MB=64
|
||||||
|
|
||||||
|
# Wie lange eine Anmeldung im Dashboard haelt.
|
||||||
|
#SESSION_DAYS=30
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Hinweis
|
||||||
|
#
|
||||||
|
# NODE_ENV steht fest auf "production" im Compose und setzt das Sitzungs-Cookie
|
||||||
|
# auf Secure. Hinter dem Proxy mit TLS ist das richtig.
|
||||||
|
#
|
||||||
|
# WER OHNE HTTPS AUSPROBIERT, SPERRT SICH AUS: die Anmeldung antwortet mit 200,
|
||||||
|
# der Browser verwirft das Cookie still, und die naechste Anfrage ist 401. Die
|
||||||
|
# naheliegende Gegenprobe ueber http://localhost fuehrt in die Irre — das gilt
|
||||||
|
# Browsern als sicherer Kontext und funktioniert.
|
||||||
|
#
|
||||||
|
# Discord-Anmeldung, Sicherung und Meldungen stehen NICHT hier, sondern im
|
||||||
|
# Dashboard unter Einstellungen.
|
||||||
|
|||||||
@@ -5,5 +5,12 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Der Build der Oberflaeche. Entsteht aus server/ui mit `npm run build` und
|
||||||
|
# gehoert so wenig ins Repo wie ein uebersetztes Programm -- im Abbild baut ihn
|
||||||
|
# eine eigene Stufe.
|
||||||
|
server/web/
|
||||||
|
|
||||||
# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo.
|
# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo.
|
||||||
data/
|
data/
|
||||||
|
.waechter-basis.json
|
||||||
|
fehlende-item-bilder.txt
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Arbeitsweise in diesem Projekt
|
c# Arbeitsweise in diesem Projekt
|
||||||
|
|
||||||
## Prüfen statt raten
|
## Prüfen statt raten
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +1,280 @@
|
|||||||
# d4rk_media
|
# d4rk_media
|
||||||
|
|
||||||
Der Medien-Stack: **Fivemanage Lite** mit MySQL und MinIO. Ein Ablageort für
|
Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen,
|
||||||
alle Resourcen — Fahrzeugbilder aus `d4rk_photostudio`, Item-Icons, Marken,
|
ausliefern, in einem Dashboard verwalten. **Ein Container**, SQLite, Dateien auf
|
||||||
was sonst noch anfällt.
|
einer Platte.
|
||||||
|
|
||||||
Bisher liegt das auf dem Nextcloud und wird per rclone von Hand hochgeladen.
|
Läuft unter **https://media.d4rkst3r.de** — Dashboard und API an der
|
||||||
Das trägt, solange ein Mensch hochlädt. Sobald ein Script es tun soll, fehlt
|
Wurzel, Dateien unter `/f/`.
|
||||||
ein Schreibweg über HTTP — und genau den bringt Lite mit, samt Token je
|
|
||||||
Resource und einer Oberfläche zum Nachsehen.
|
|
||||||
|
|
||||||
---
|
```
|
||||||
|
vehicles/adder.webp → https://media.d4rkst3r.de/f/vehicles/adder.webp
|
||||||
## Ausprobieren, bevor es auf den Server geht
|
|
||||||
|
|
||||||
`0.1.0-beta.23` ist früh. Erst auf Docker Desktop:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env # und ausfüllen, siehe unten
|
|
||||||
docker compose -f docker-compose.local.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Oberfläche: <http://localhost:8080> — `admin` und dein `ADMIN_PASSWORD`
|
**Der Pfad ist der Schlüssel.** `vehicles/adder.webp` bleibt
|
||||||
- MinIO: <http://localhost:9001> — `lite` und dein `MINIO_ROOT_PASSWORD`
|
`vehicles/adder.webp`, damit die Vorlage in einem Skript schlicht
|
||||||
|
`{model}.webp` lauten kann. Die Adressen stehen nirgends in der Datenbank — sie
|
||||||
|
werden bei jeder Antwort aus `PUBLIC_URL` gebaut. Ein Umzug auf einen anderen
|
||||||
|
Namen ist eine geänderte Variable, kein Datenbankeingriff.
|
||||||
|
|
||||||
Wenn das trägt, dasselbe auf dem Server.
|
| | |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Auf dem Server, über Portainer
|
|
||||||
|
|
||||||
Portainer kann einen Stack direkt aus diesem Repo ziehen:
|
|
||||||
|
|
||||||
**Stacks → Add stack → Repository**
|
|
||||||
|
|
||||||
| Feld | Wert |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Repository URL | `https://git.d4rkst3r.de/D4rkst3r/d4rk_media` |
|
| **Anleitung für Skripte** | [Wiki](https://git.d4rkst3r.de/D4rkst3r/d4rk_media/wiki) — Quelle ist `docs/` |
|
||||||
| Compose path | `docker-compose.yml` |
|
| **Stand und Reihenfolge** | `ROADMAP.md` |
|
||||||
| Environment variables | die vier Geheimnisse plus `PUBLIC_URL` |
|
| **Arbeitsweise** | `CLAUDE.md` — prüfen statt raten |
|
||||||
|
|
||||||
Die Werte gehören **in die Stack-Variablen**, nicht in eine Datei im Repo.
|
|
||||||
`.env` steht deshalb in `.gitignore`.
|
|
||||||
|
|
||||||
### Wie der Reverse Proxy die Dienste erreicht
|
|
||||||
|
|
||||||
Über den **Host**, und das steht fertig in `docker-compose.yml`. Genauso laufen
|
|
||||||
`d4rkbot` auf 3080 und `cdn-files` auf 8090 schon.
|
|
||||||
|
|
||||||
```
|
|
||||||
fivecdn.d4rkst3r.de -> http://host.docker.internal:9100
|
|
||||||
fivemanage.d4rkst3r.de -> http://host.docker.internal:9101
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nicht 9000 und 8080** — die sind auf diesem Server vergeben (Portainer und
|
|
||||||
nextcloud-aio). Im Container bleibt es bei 9000 und 8080; nur die Seite zum
|
|
||||||
Host wandert. Kollidiert später noch etwas, sind es zwei Variablen:
|
|
||||||
`MINIO_HOST_PORT` und `LITE_HOST_PORT`.
|
|
||||||
|
|
||||||
Die Ports sind an **alle** Adressen gebunden, nicht an `127.0.0.1`. Das ist
|
|
||||||
kein Versehen: NPM liegt im Standard-Bridge-Netz (`172.17.0.3`) und kommt über
|
|
||||||
die Docker-Brücke herein, nicht über Loopback — ein an `127.0.0.1` gebundener
|
|
||||||
Port nimmt diese Anfrage nicht an. **Zu macht sie die Firewall**, nicht die
|
|
||||||
Bindung. 9100 und 9101 gehören von außen dicht; öffentlich soll nur NPM auf
|
|
||||||
80/443 sein.
|
|
||||||
|
|
||||||
#### Wenn der Proxy im selben Docker-Netz hängt
|
|
||||||
|
|
||||||
Dann geht es sauberer, ganz ohne offene Ports — dafür liegt
|
|
||||||
`docker-compose.proxynet.yml` bereit. **Portainer nimmt unter *Compose path*
|
|
||||||
aber nur eine Datei**; eine Ergänzung wird dort stillschweigend ignoriert. Wer
|
|
||||||
diesen Weg will, führt die beiden Dateien zusammen oder startet den Stack von
|
|
||||||
Hand:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.proxynet.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Auf **diesem** Server trifft das nicht zu.
|
|
||||||
|
|
||||||
Die Ports sind an **alle Adressen** gebunden, weil `host.docker.internal` auf
|
|
||||||
diesem Server über die Docker-Brücke kommt und ein `127.0.0.1`-Port die
|
|
||||||
Anfrage nicht annähme — dieselbe Bindung, mit der `d4rkbot` und `cdn-files`
|
|
||||||
schon laufen.
|
|
||||||
|
|
||||||
**Damit macht die Firewall den Port zu, nicht die Bindung.** 9100 und 9101
|
|
||||||
gehören von außen dicht; öffentlich erreichbar soll nur NPM auf 80 und 443
|
|
||||||
sein. Wer weiß, dass Loopback bei ihm erreicht wird, setzt
|
|
||||||
`BIND_ADDR=127.0.0.1`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Die vier Geheimnisse
|
## Starten
|
||||||
|
|
||||||
Sie werden **erzeugt**, nicht irgendwo abgeholt. Das Token für die Resourcen
|
```bash
|
||||||
kommt später und woanders her: aus der laufenden Lite-Oberfläche.
|
cp .env.example .env # PUBLIC_URL und ADMIN_PASSWORD ausfüllen
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
**PowerShell** — schreibt die fertige `.env`:
|
Das war es. Ein Container, ein Volume, kein zweiter Dienst.
|
||||||
|
|
||||||
|
**In Portainer:** Stacks → Add stack → Repository, `Compose path:
|
||||||
|
docker-compose.yml`. Die Werte kommen als Stack-Variablen.
|
||||||
|
|
||||||
|
> **`Compose path` nimmt genau EINE Datei.** Ergänzungen werden stillschweigend
|
||||||
|
> übergangen — alle Container gesund, alles 502. Das hat beim Vorgänger einen
|
||||||
|
> Nachmittag gekostet.
|
||||||
|
|
||||||
|
### An der Oberfläche arbeiten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix server/ui install
|
||||||
|
npm --prefix server/ui run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Läuft auf `localhost:5173` und leitet `/api` und `/f` an den Container weiter.
|
||||||
|
Dass das Sitzungs-Cookie dabei ankommt, liegt an einer Ausnahme: es trägt
|
||||||
|
`Secure`, und ein Browser nimmt so eines über `http` **nur auf localhost** an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was der Dienst kann
|
||||||
|
|
||||||
|
**Hochladen** — über die Skript-API mit Token (drei Rumpfformen: multipart,
|
||||||
|
Base64, rohe Bytes) oder aus dem Dashboard per Ziehen und Ablegen. Bilder,
|
||||||
|
Videos, Ton, PDF. Ein **ZIP wird ausgepackt** statt abgelegt, und die Ordner
|
||||||
|
darin bleiben erhalten — 3670 Bilder einzeln ins Fenster zu ziehen ist keine
|
||||||
|
Bedienung.
|
||||||
|
|
||||||
|
**Ausliefern** — mit ETag, `304`, Bereichsanfragen und `nosniff`. Vorschaubilder
|
||||||
|
werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden
|
||||||
|
766 KB. Bei **Video** ist es ein Standbild aus dem Clip (ein Zehntel hinein, weil
|
||||||
|
viele mit Schwarz beginnen), bei **Ton** das eingebettete Titelbild; die Kachel
|
||||||
|
trägt zusätzlich die Laufzeit.
|
||||||
|
|
||||||
|
**Sparen, ohne Adressen zu ändern** — neben jedem PNG und JPEG liegt eine
|
||||||
|
WebP-Fassung, ausgeliefert unter *derselben* Adresse je nach `Accept` des
|
||||||
|
Aufrufers. Nachgemessen über den ganzen Bestand: **199 MB → 33 MB**, das sind
|
||||||
|
83 %. Für Lua ändert sich nichts — es schickt kein `Accept: image/webp` und
|
||||||
|
bekommt weiterhin das Original.
|
||||||
|
|
||||||
|
**Zurückholen** — Gelöschtes liegt 30 Tage im Papierkorb, danach räumt der
|
||||||
|
Dienst selbst auf. Die Datei ist dann nur nicht mehr öffentlich, nicht weg.
|
||||||
|
|
||||||
|
**Zählen** — wie oft eine Datei geholt wurde und wann zuletzt. Beantwortet die
|
||||||
|
Frage, die ein Aufbewahrungsbericht sonst nicht beantworten kann: *braucht das
|
||||||
|
noch jemand?*
|
||||||
|
|
||||||
|
**Verwalten** — Galerie mit Ordnerbaum, Suche, Filter nach Medienart,
|
||||||
|
Sortierung, Mehrfachauswahl, Verschieben, Löschen.
|
||||||
|
|
||||||
|
**Ansehen ohne neuen Tab** — Bild, Video, Ton, PDF und alles Lesbare:
|
||||||
|
Markdown wird gesetzt, `.lua` und `.json` eingefärbt, CSV als Tabelle,
|
||||||
|
alles andere mit Zeilennummern. Video und Ton mit gemerkter Lautstärke und
|
||||||
|
„weiter zum nächsten Stück"; Leertaste hält an, die Pfeile springen fünf
|
||||||
|
Sekunden, mit Umschalt wechseln sie die Datei.
|
||||||
|
|
||||||
|
Markdown wird **ohne Bibliothek** dargestellt, und das ist kein Geiz: der
|
||||||
|
übliche Weg endet bei `dangerouslySetInnerHTML`, und die Sicherheit hängt dann
|
||||||
|
an einer Filterliste. Hier entsteht nie eine HTML-Zeichenkette — der Text wird
|
||||||
|
zu React-Knoten, ein `<script>` in einer hochgeladenen `.md` erscheint als die
|
||||||
|
acht Zeichen, die es ist.
|
||||||
|
|
||||||
|
**Umbenennen** — Datei und Ordner, mit der Warnung, die dazugehört: die Adresse
|
||||||
|
ändert sich, und der Dialog sagt vorher, wie oft die Datei geholt wurde bzw.
|
||||||
|
wie viele Dateien im Ordner betroffen sind.
|
||||||
|
|
||||||
|
**Nachvollziehen** — ein Verlauf: wer hat wann was abgelegt, ersetzt,
|
||||||
|
verschoben oder gelöscht. Dazu: nach Abrufen sortieren, und je Token sehen, was
|
||||||
|
damit abgelegt wurde.
|
||||||
|
|
||||||
|
**Umziehen von Fivemanage** — der Dienst spricht deren Sprache
|
||||||
|
(`/api/image`, `/api/video`, `/api/audio`, `/api/v3/file`, Schlüssel nackt im
|
||||||
|
`Authorization`-Kopf). Fremde Skripte ziehen um, indem eine Zeile getauscht
|
||||||
|
wird. Siehe `docs/API.md`.
|
||||||
|
|
||||||
|
**Freigeben** — `/s/<schlüssel>` zeigt einen Ordner ohne Anmeldung, etwa einen
|
||||||
|
Fahrzeugkatalog. Freigegeben wird die **Liste**, nicht der Inhalt: die Dateien
|
||||||
|
sind ohnehin öffentlich, ein zurückgezogener Link nimmt nur die Übersicht weg.
|
||||||
|
|
||||||
|
**Anmelden** — über Discord (eine Rolle auf dem Server genügt) oder mit
|
||||||
|
Passwort. Das Passwort bleibt als Notausgang.
|
||||||
|
|
||||||
|
**Sichern** — täglich, geprüft, in die Nextcloud. Und der Dienst meldet nach
|
||||||
|
Discord, wenn die Sicherung ausbleibt. Zurückspielen lässt sich das Ganze mit
|
||||||
|
`tools\zurueckspielen.ps1` — und **üben** lässt es sich auch, in einem
|
||||||
|
Wegwerf-Volume neben dem laufenden Dienst.
|
||||||
|
|
||||||
|
**Beobachten** — `/health` für den Container-Healthcheck (billig, beweist nur,
|
||||||
|
dass der Prozess antwortet) und `/status` für eine Statusseite: das prüft
|
||||||
|
Datenbank, Platte, Ausliefern, Bestand, Platz und Sicherung und antwortet mit
|
||||||
|
**503**, wenn der Dienst sein Geschäft nicht mehr tun kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die eine Regel
|
||||||
|
|
||||||
|
**Jeder Knopf gibt Rückmeldung, auch der erfolgreiche.**
|
||||||
|
|
||||||
|
Das ist der Fehler, an dem der Fivemanage-Anlauf gescheitert ist: dessen
|
||||||
|
`http-util.ts` warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf
|
||||||
|
`instanceof ApiError` — und damit verschwand **jeder** Fehler lautlos. `Create
|
||||||
|
organization` gab weder Erfolg noch Misserfolg zurück; das Ergebnis waren
|
||||||
|
neunzehn gleichnamige Organisationen.
|
||||||
|
|
||||||
|
Deshalb hier:
|
||||||
|
|
||||||
|
- In `server/ui/src/api.ts` steht der **einzige** `fetch` und **genau eine**
|
||||||
|
Fehlerklasse.
|
||||||
|
- In `store.ts` steht `run()`: Aktion rein, Erfolgstext rein — es meldet bei
|
||||||
|
Erfolg diesen und bei Fehlschlag den Text **des Dienstes**. Wer darüber geht,
|
||||||
|
kann keinen stillen Knopf bauen.
|
||||||
|
- Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel.
|
||||||
|
- Fehlermeldungen bleiben stehen, bis jemand sie wegklickt.
|
||||||
|
- Der Dienst hat eine Auffanglinie (`app.onError`): auch ein unerwarteter
|
||||||
|
Fehler bekommt einen Satz statt eines nackten `Internal Server Error`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
### Sicherung
|
||||||
|
|
||||||
|
`tools/sichern.ps1`, täglich 04:30 als geplante Aufgabe.
|
||||||
|
|
||||||
|
Die Datenbank wird **nicht kopiert**, sondern über SQLites eigene
|
||||||
|
Sicherungsschnittstelle herausgeholt — im WAL-Modus liegt das Zuletzte noch
|
||||||
|
nicht in `media.db`. Die Bilder kommen aus einem **nur lesend** eingehängten
|
||||||
|
Volume dazu, alles in ein `tar.gz`.
|
||||||
|
|
||||||
|
**Und sie prüft sich selbst:** auspacken, Datenbank öffnen, Medien, Token und
|
||||||
|
Benutzer zählen, mit dem laufenden Dienst vergleichen. Danach geht sie per
|
||||||
|
WebDAV in die Nextcloud und wird **von dort zurückgeholt und die Prüfsumme
|
||||||
|
verglichen** — ein `PUT`, der 201 sagt, hat nichts bewiesen.
|
||||||
|
|
||||||
|
Zugangsdaten und Aufbewahrung stehen im Panel unter *Einstellungen*.
|
||||||
|
|
||||||
|
### Wiki
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwsh -File tools\wiki.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Trägt `docs/*.md` ins Gitea-Wiki. **Einseitig** — `docs/` ist die Quelle, das
|
||||||
|
Wiki die Kopie. Wer dort tippt, verliert es beim nächsten Lauf.
|
||||||
|
|
||||||
|
### Zurückspielen
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
function New-Secret([int]$n){ $b = New-Object byte[] $n; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); ($b | ForEach-Object { $_.ToString('x2') }) -join '' }
|
pwsh -File tools\zurueckspielen.ps1 # ÜBUNG (Standard)
|
||||||
@("DB_PASSWORD=$(New-Secret 16)","MINIO_ROOT_PASSWORD=$(New-Secret 16)","API_TOKEN_HMAC_SECRET=$(New-Secret 32)","ADMIN_PASSWORD=$(New-Secret 12)","PUBLIC_URL=https://fivecdn.d4rkst3r.de","PROXY_NETWORK=web") | Set-Content -Encoding ascii .env
|
pwsh -File tools\zurueckspielen.ps1 -Echt # der Ernstfall
|
||||||
```
|
```
|
||||||
|
|
||||||
Zwei Fallen stecken darin, beide schon hineingetreten:
|
**Die Übung ist der eigentliche Gewinn.** Sie legt ein Wegwerf-Volume an,
|
||||||
|
spielt das Archiv hinein, startet einen *zweiten* Dienst davor, lässt ihn drei
|
||||||
|
zufällige Dateien ausliefern und vergleicht deren SHA-256 mit dem, was in der
|
||||||
|
Datenbank steht — dann räumt sie alles wieder ab. Der laufende Dienst wird
|
||||||
|
nicht angefasst.
|
||||||
|
|
||||||
- `RandomNumberGenerator::Create()` und **nicht** `::Fill` — letzteres gibt es
|
Das beantwortet die Frage, die `sichern.ps1` **nicht** beantwortet: nicht „ist
|
||||||
erst ab .NET 6, Windows PowerShell 5.1 kennt es nicht.
|
das Archiv heil", sondern „wird daraus wieder ein laufender Dienst". Ein
|
||||||
- `-Encoding ascii` und **nicht** `utf8` — PowerShell 5.1 schreibt sonst ein
|
Backup, das man nie zurückgespielt hat, ist kein Backup, sondern eine Datei.
|
||||||
BOM voran, und dann heißt die erste Variable `DB_PASSWORD`.
|
|
||||||
- `Get-Random` wäre die naheliegende Wahl und die falsche: es ist nicht
|
|
||||||
kryptografisch sicher, und hier wird ein Token-Signaturgeheimnis erzeugt.
|
|
||||||
|
|
||||||
**Bash / Linux:**
|
Zuletzt gelaufen und bestanden:
|
||||||
|
|
||||||
|
```
|
||||||
|
4452 Medieneinträge, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
|
||||||
|
4452 Dateien -- gleich viele wie Einträge
|
||||||
|
ok items/velum2.png 113567 B
|
||||||
|
ok items/flesh-dolphin-2star.png 3623 B
|
||||||
|
ok items/burgermeat.png 4269 B
|
||||||
|
```
|
||||||
|
|
||||||
|
Von Hand ginge es auch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
printf 'DB_PASSWORD=%s
|
tar xzf d4rk_media-JJJJ-MM-TT-HHMM.tar.gz
|
||||||
MINIO_ROOT_PASSWORD=%s
|
|
||||||
API_TOKEN_HMAC_SECRET=%s
|
|
||||||
ADMIN_PASSWORD=%s
|
|
||||||
PUBLIC_URL=https://fivecdn.d4rkst3r.de
|
|
||||||
PROXY_NETWORK=proxy
|
|
||||||
' "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" "$(openssl rand -hex 32)" "$(openssl rand -hex 12)" > .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Ergibt `media.db`, `files/` und `papierkorb/`. Alles drei ins Volume legen,
|
||||||
|
während der Container steht. Vorschaubilder und WebP-Fassungen sind **nicht** im
|
||||||
|
Archiv — beides ist abgeleitet und wird mit zwei Knöpfen unter
|
||||||
|
*Speicher → Wartung* neu gerechnet.
|
||||||
|
|
||||||
## PostgreSQL, nicht MySQL
|
> Der Papierkorb war anfangs **nicht** dabei, und das war ein Fehler: seine
|
||||||
|
> Einträge stehen in `media.db` und waren damit gesichert, die Dateien lagen
|
||||||
`0.1.0-beta.23` geht direkt in den PostgreSQL-Treiber; `DB_DRIVER` wird nicht
|
> daneben und waren es nicht. Nach einem Zurückspielen hätte der Papierkorb
|
||||||
ausgewertet. Eine MySQL-DSN endet deshalb in
|
> Zeilen gezeigt, deren „zurückholen" ins Leere greift. Er kostet fast nichts —
|
||||||
|
> was darin liegt, ist höchstens 30 Tage alt.
|
||||||
```
|
|
||||||
panic: pgdriver: invalid scheme: lite
|
|
||||||
```
|
|
||||||
|
|
||||||
— der Treiber liest sie als URI und findet als Schema den Benutzernamen.
|
|
||||||
|
|
||||||
Die `docker-compose.test.yml` des Projekts zeigt MySQL, benutzt dafür aber
|
|
||||||
`fivemanage/lite:latest` von Docker Hub, und die Reihe steht dort bei
|
|
||||||
`beta.16` still. Das README des Projekts hat recht: `postgres://…`.
|
|
||||||
|
|
||||||
Das Volume heißt deshalb `pgdata` und nicht `db` — wer den Stack schon mit
|
|
||||||
MySQL laufen hatte, bekommt so eine frische Ablage, statt dass PostgreSQL über
|
|
||||||
ein MySQL-Verzeichnis stolpert. Das alte Volume bleibt als Waise liegen und
|
|
||||||
kann weg:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker volume rm fivemanager_db
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zwei Adressen, nicht eine
|
|
||||||
|
|
||||||
Lite spricht MinIO unter zwei verschiedenen Namen an, und beide muessen
|
|
||||||
stimmen:
|
|
||||||
|
|
||||||
| Variable | Wert | Wofuer |
|
|
||||||
|---|---|---|
|
|
||||||
| `AWS_ENDPOINT` | `http://minio:9000` | Wie Lite die Dateien **hinlegt** — Containername im internen Netz |
|
|
||||||
| `BUCKET_DOMAIN` | `$PUBLIC_URL/media` | Woraus die **oeffentliche** Adresse gebaut wird, die Lite nach dem Upload zurueckgibt |
|
|
||||||
|
|
||||||
`BUCKET_DOMAIN` steht nicht im README des Projekts, nur in dessen
|
|
||||||
`.env.template`. Ohne sie kaemen Adressen zurueck, die nur innerhalb von
|
|
||||||
Docker gelten — der Upload saehe erfolgreich aus, und das Bild waere von
|
|
||||||
aussen nicht abrufbar.
|
|
||||||
|
|
||||||
Den Bucket legt Lite beim Start selbst an und setzt dabei die oeffentliche
|
|
||||||
Leserichtlinie (`pkg/storage/s3/s3.go`). Es ueberspringt beides, wenn der
|
|
||||||
Bucket schon existiert — deshalb legt dieser Stack ihn **nicht** vorher an.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zwei Eigenheiten, die Zeit kosten
|
|
||||||
|
|
||||||
**`traces export: … dial tcp [::1]:4318: connect: connection refused`**, alle
|
|
||||||
fünf Minuten. Kein Fehler: Lite schickt Tracing-Daten an einen Collector, den
|
|
||||||
dieser Stack bewusst nicht mitbringt. Abschalten geht in `beta.23` **nicht** —
|
|
||||||
`otlpEndpoint` ist eine Konstante in `pkg/otel/exporter.go`, `ENV` wählt nur
|
|
||||||
zwischen TLS und unverschlüsselt. Weil die Adresse `localhost` ist, würde auch
|
|
||||||
ein Jaeger-Container daneben nichts nützen; er müsste sich den Netzwerk-
|
|
||||||
Namensraum teilen. Ignorieren.
|
|
||||||
|
|
||||||
**„Create organization" tut nichts.** Doch — die Organisation wird angelegt,
|
|
||||||
nur geht die Oberfläche danach nicht weiter: `NewOrganizationRoute.tsx` ruft
|
|
||||||
`mutate(data)` ohne `onSuccess`, ohne Navigation, ohne Meldung. **Seite neu
|
|
||||||
laden**, dann findet die App sie. Wer mehrfach geklickt hat, hat mehrere.
|
|
||||||
|
|
||||||
**Ein Knopf in der Oberfläche tut gar nichts.** Dann steht der Grund im
|
|
||||||
Container-Log, nicht auf dem Bildschirm. `web/src/utils/http-util.ts` wirft
|
|
||||||
jeden Fehler als `new Error(…)` weiter, die aufrufenden Hooks prüfen aber auf
|
|
||||||
`err instanceof ApiError` — das ist danach nie wahr, und der Fehler wird
|
|
||||||
verschluckt. 401, 500 und ein falscher Rumpf sehen deshalb alle gleich aus:
|
|
||||||
nichts passiert.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Was hier bewusst fehlt
|
## Was hier bewusst fehlt
|
||||||
|
|
||||||
**ClickHouse und Jaeger.** Beide stehen in der Entwicklungs-Compose des
|
**Logging.** Genau dafür schleppt Fivemanage ClickHouse mit, und genau da ist
|
||||||
Projekts und sind für strukturiertes Logging und Tracing da. Zum Ablegen von
|
der Wartungsaufwand echt. Der Verlauf im Dashboard beantwortet „wer war das" —
|
||||||
Bildern braucht es sie nicht, und ClickHouse ist eine schwere Abhängigkeit.
|
mehr braucht es hier nicht.
|
||||||
Nachrüsten geht jederzeit — die App findet sie über `CLICKHOUSE_*`.
|
|
||||||
|
|
||||||
**Offene Ports in der Betriebsfassung.** Nur der Reverse Proxy spricht mit der
|
**Mandanten, Rollen, Teams.** Ein Dienst, den eine Person betreibt.
|
||||||
App; Datenbank und MinIO bleiben im internen Netz. Die Entwicklungs-Compose des
|
|
||||||
Projekts macht 5432, 9000, 16686 und mehr auf — dort in Ordnung, hier nicht.
|
**Automatisches Löschen.** Vorbereitet ist der Bericht davor: wie viel ist
|
||||||
|
älter als 7/30/90/365 Tage, was liegt doppelt. Eine Regel zu bauen, bevor man
|
||||||
|
weiß, was da liegt, ist der Weg, wie man Daten verliert.
|
||||||
|
|
||||||
|
**Spielerportal.** Setzt voraus, dass Spieler hochladen. Der richtige Weg wäre
|
||||||
|
dann `presigned URLs` — kurzlebige signierte Adressen, mit denen ein Client
|
||||||
|
hochlädt, ohne je einen Token zu sehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zwei Dinge, die Zeit gekostet haben
|
||||||
|
|
||||||
|
**Der Proxy hielt Bilder fest.** NPMs `assets.conf` greift über eine Regex auf
|
||||||
|
*jede* URL, die auf `.webp`, `.png`, `.js`, `.css` endet, legt die Antwort 30
|
||||||
|
Minuten weg und ignoriert dabei unsere Kopfzeilen. Ein überschriebenes Bild
|
||||||
|
blieb damit eine halbe Stunde alt — und `/api/exists/…webp` wurde
|
||||||
|
zwischengespeichert und danach **auch ohne Token** ausgeliefert.
|
||||||
|
|
||||||
|
Behoben mit drei Blöcken in der *Custom Nginx Configuration* des Proxy-Hosts:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /api/ { proxy_cache off; include conf.d/include/proxy.conf; }
|
||||||
|
location ^~ /f/ { proxy_cache off; include conf.d/include/proxy.conf; }
|
||||||
|
location ^~ /t/ { proxy_cache off; include conf.d/include/proxy.conf; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `^~` ist der ganze Trick: eine gewöhnliche Präfix-Regel **verliert** gegen
|
||||||
|
die Regex. In einem Wegwerf-Nginx nachgemessen.
|
||||||
|
|
||||||
|
**Node 22 ist festgenagelt.** `better-sqlite3` 11.10.0 liefert fertige
|
||||||
|
Binärdateien für ABI 115 (Node 20) und 127 (Node 22) — **Node 24 hat ABI 137
|
||||||
|
und fehlt**. Der Griff zur neuesten Version fällt auf `node-gyp` zurück und
|
||||||
|
braucht python3, make und g++ im Abbild.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Herkunft
|
## Herkunft
|
||||||
|
|
||||||
Zusammengestellt aus [fivemanage/lite](https://github.com/fivemanage/lite):
|
Der Vorgänger war **Fivemanage Lite** mit PostgreSQL, MinIO und ClickHouse. Der
|
||||||
dessen `README.md`, `deployments/docker-compose.yml` (Entwicklung, ohne die
|
Stack lief; woran er gescheitert ist, steht oben unter *Die eine Regel* und
|
||||||
App) und `deployments/docker-compose.test.yml` (zeigt die App-Verdrahtung und
|
ausführlich in `ROADMAP.md`. Von dem, was er über eine Dateiablage hinaus kann,
|
||||||
dass MySQL genügt).
|
haben wir nichts gebraucht und alles mitgeschleppt.
|
||||||
|
|
||||||
**Nicht laufen gelassen.** Rechne mit ein, zwei Anläufen — vor allem beim
|
Seine Compose-Dateien liegen in `legacy/`, als Beleg und wegen der Kommentare —
|
||||||
ersten Login und beim Bucket.
|
nichts davon wird noch gestartet.
|
||||||
|
|||||||
+975
-30
File diff suppressed because it is too large
Load Diff
@@ -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:
|
|
||||||
+96
-115
@@ -1,130 +1,111 @@
|
|||||||
# Fivemanage Lite — fuer den Betrieb, ueber Portainer aus diesem Repo.
|
# d4rk_media — unser eigener Medien-Dienst. EIN Container.
|
||||||
#
|
#
|
||||||
# Abgeleitet aus den beiden Compose-Dateien im Projekt (fivemanage/lite) und
|
# 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:
|
|
||||||
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:
|
environment:
|
||||||
MINIO_ROOT_USER: lite
|
# Der Ursprung, unter dem der Dienst oeffentlich steht -- OHNE Pfad
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?bitte setzen}
|
# und ohne abschliessenden Schraegstrich:
|
||||||
MINIO_SERVER_URL: ${PUBLIC_URL:?bitte 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}
|
||||||
|
|
||||||
|
# Wieviel der Papierkorb hoechstens halten darf, zusaetzlich zu den
|
||||||
|
# 30 Tagen. Wer 200 GB loescht, haelt sie sonst 30 Tage doppelt.
|
||||||
|
# Verstellbar vor allem, damit sich der Deckel PRUEFEN laesst, ohne
|
||||||
|
# 20 GB zu loeschen -- Nachkommastellen sind erlaubt.
|
||||||
|
PAPIERKORB_MAX_MB: ${PAPIERKORB_MAX_MB:-20480}
|
||||||
|
|
||||||
|
# NODE_ENV=production setzt das Sitzungs-Cookie auf "Secure". Hinter
|
||||||
|
# NPM mit TLS ist das richtig.
|
||||||
|
#
|
||||||
|
# WER OHNE HTTPS AUSPROBIERT, SPERRT SICH AUS -- und zwar auf die
|
||||||
|
# unangenehme Art. Nachgemessen im Browser gegen
|
||||||
|
# http://192.168.2.33:8096:
|
||||||
|
#
|
||||||
|
# POST /api/dash/auth/login -> 200, sieht nach Erfolg aus
|
||||||
|
# document.cookie -> leer, still verworfen
|
||||||
|
# GET /api/dash/auth/me -> 401 "nicht angemeldet"
|
||||||
|
#
|
||||||
|
# Die Anmeldung MELDET Erfolg und ist trotzdem keine. Genau die
|
||||||
|
# Sorte Rueckmeldung, wegen der wir hier neu bauen.
|
||||||
|
#
|
||||||
|
# Und die naheliegende Gegenprobe fuehrt in die Irre: ueber
|
||||||
|
# http://localhost geht es, weil Browser localhost als sicheren
|
||||||
|
# Kontext behandeln. Also entweder ueber den Proxy mit HTTPS
|
||||||
|
# testen -- oder zum Ausprobieren NODE_ENV weglassen.
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- minio:/data
|
# Bilder UND media.db in einem Volume. Eine Sicherung ist damit
|
||||||
networks: [internal]
|
# genau ein Kopiervorgang.
|
||||||
|
- media-data:/data
|
||||||
|
|
||||||
|
# Die fertigen Archive, damit man sie sich aus dem Panel holen kann,
|
||||||
|
# ohne sich per Fernwartung auf den Server zu klicken. Genau das war
|
||||||
|
# der Grund, aus dem es lange keine Kopie ausserhalb dieser Maschine
|
||||||
|
# gab: nicht Unwillen, sondern Umstaendlichkeit.
|
||||||
|
#
|
||||||
|
# DAS :ro AM ENDE IST DER WICHTIGE TEIL. Ein Dienst, der seine
|
||||||
|
# eigenen Sicherungen loeschen kann, ist genau das Werkzeug, das man
|
||||||
|
# einem uebernommenen Dienst nicht in die Hand geben will.
|
||||||
|
# Herausgeben reicht.
|
||||||
|
- ${BACKUP_DIR:-C:/backup/d4rk_media}:/sicherungen:ro
|
||||||
|
|
||||||
ports:
|
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:
|
|
||||||
|
|||||||
+523
@@ -0,0 +1,523 @@
|
|||||||
|
# Die API von d4rk_media
|
||||||
|
|
||||||
|
Diese Seite ist für den Fall geschrieben, dass jemand ein Skript anbindet und
|
||||||
|
etwas nicht tut, was es soll. Sie nennt deshalb nicht nur die Endpunkte,
|
||||||
|
sondern auch **was schiefgehen kann und wie die Antwort dann aussieht**.
|
||||||
|
|
||||||
|
Der Dienst läuft unter `https://media.d4rkst3r.de`. Dateien liegen darunter
|
||||||
|
unter `/f/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Grundsatz: der Pfad ist der Schlüssel
|
||||||
|
|
||||||
|
`vehicles/adder.webp` bleibt `vehicles/adder.webp`. Die öffentliche Adresse
|
||||||
|
entsteht daraus, indem der Ursprung davorgesetzt wird:
|
||||||
|
|
||||||
|
```
|
||||||
|
vehicles/adder.webp → https://media.d4rkst3r.de/f/vehicles/adder.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
Deshalb kann die Vorlage in einem Handy-Skript schlicht `{model}.webp` lauten.
|
||||||
|
**Die Adressen stehen nirgends in der Datenbank** — sie werden bei jeder
|
||||||
|
Antwort neu gebaut. Ein Umzug auf einen anderen Namen ist eine geänderte
|
||||||
|
Variable, kein Datenbankeingriff.
|
||||||
|
|
||||||
|
### Erlaubte Pfade
|
||||||
|
|
||||||
|
Jeder Teil zwischen den Schrägstrichen muss mit einem **Buchstaben oder einer
|
||||||
|
Ziffer** beginnen; danach sind zusätzlich Punkt, Strich und Unterstrich
|
||||||
|
erlaubt. Höchstens 8 Ebenen, höchstens 200 Zeichen.
|
||||||
|
|
||||||
|
Damit sind `..`, versteckte Dateien und alles mit Backslash von vornherein
|
||||||
|
draußen. Was nicht passt, wird **abgelehnt und nicht zurechtgebogen** — sonst
|
||||||
|
läge eine Datei unter einem anderen Namen als dem, den der Aufrufer erwartet,
|
||||||
|
und niemand merkte es, bis das Bild fehlt.
|
||||||
|
|
||||||
|
> Ein führender Unterstrich fällt durch. Das ist kein erfundener Fall: das
|
||||||
|
> Fotostudio legt seine Leerbilder als `_plate` ab.
|
||||||
|
|
||||||
|
### Groß- und Kleinschreibung
|
||||||
|
|
||||||
|
Was über das Dashboard oder ein Werkzeug hochgeladen wird, kommt **klein**
|
||||||
|
an: `WEAPON_SMG.png` liegt als `weapon_smg.png`. Der Grund ist, dass ein Name
|
||||||
|
zu einer Adresse wird und Adressen sich nicht auf die Schreibweise eines
|
||||||
|
Dateisystems verlassen sollen.
|
||||||
|
|
||||||
|
Für ein Skript heißt das: **die Vorlage arbeitet mit Kleinbuchstaben.**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item:lower())
|
||||||
|
```
|
||||||
|
|
||||||
|
Über `/api/upload` mit eigenem `X-Path` gilt das nicht — dort steht der Pfad
|
||||||
|
so, wie er geschickt wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dieselbe Adresse, zwei Fassungen
|
||||||
|
|
||||||
|
Neben jedem PNG und JPEG kann eine **WebP-Fassung** liegen. Ausgeliefert wird
|
||||||
|
sie unter **derselben Adresse** — welche der beiden kommt, entscheidet die
|
||||||
|
Kopfzeile `Accept` des Aufrufers:
|
||||||
|
|
||||||
|
```
|
||||||
|
Accept: …image/webp… → Content-Type: image/webp (klein)
|
||||||
|
kein Accept → Content-Type: image/png (das Original)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachgemessen an `probe/probe-webp.png`: 6448 Bytes für `curl`, 2758 Bytes für
|
||||||
|
einen Browser. **Für ein Skript ändert sich nichts** — Lua schickt kein
|
||||||
|
`Accept: image/webp` und bekommt weiterhin Byte für Byte das Original.
|
||||||
|
|
||||||
|
Zwei Dinge, die daran hängen:
|
||||||
|
|
||||||
|
- Die Antwort trägt `Vary: Accept`. Ohne die Zeile legte ein Zwischenspeicher
|
||||||
|
die WebP-Fassung für alle ab, auch für die, die sie nicht lesen können.
|
||||||
|
- Die beiden Fassungen haben **verschiedene ETags** (die WebP-Fassung endet auf
|
||||||
|
`-w`). Sonst bekäme jemand auf seinen `If-None-Match` hin ein 304 für das
|
||||||
|
falsche Bild.
|
||||||
|
|
||||||
|
Erzeugt werden die Fassungen beim Hochladen; für alles, was vorher schon da
|
||||||
|
war, gibt es im Dashboard unter **Speicher → Wartung** den Knopf
|
||||||
|
„Sparsame Fassungen erzeugen". Wo WebP nicht kleiner ausfällt, entsteht keine —
|
||||||
|
dann bleibt es beim Original.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umziehen von Fivemanage — ohne Lua anzufassen
|
||||||
|
|
||||||
|
Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem Dutzend
|
||||||
|
Ressourcen: Kamera, Handy, MDT, Fahrzeugstudio. Sie alle umzuschreiben heißt,
|
||||||
|
fremde Skripte anzufassen — und beim nächsten Update wieder.
|
||||||
|
|
||||||
|
**Der Dienst spricht deshalb ihre Sprache.** Der Umzug ist eine Zeile je Skript:
|
||||||
|
`api.fivemanage.com` → `media.d4rkst3r.de`. Der Schlüssel bleibt derselbe
|
||||||
|
Kopf, die Antwort dieselbe Form. Als Schlüssel setzt du einen **Token dieses
|
||||||
|
Dienstes** ein.
|
||||||
|
|
||||||
|
| Fivemanage | hier | Feld | Antwort |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST /api/image` | ✅ gleich | `image` (oder `file`) | `{ "url": …, "id": …, "path": … }` |
|
||||||
|
| `POST /api/video` | ✅ gleich | `video` | dito |
|
||||||
|
| `POST /api/audio` | ✅ gleich | `audio` | dito |
|
||||||
|
| `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` |
|
||||||
|
|
||||||
|
Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so
|
||||||
|
wie bei Fivemanage. `Bearer …` geht weiterhin auch.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- vorher
|
||||||
|
exports['screenshot-basic']:requestScreenshotUpload(
|
||||||
|
'https://api.fivemanage.com/api/image', 'image',
|
||||||
|
{ headers = { Authorization = apiKey } }, cb)
|
||||||
|
|
||||||
|
-- nachher — eine Zeile
|
||||||
|
exports['screenshot-basic']:requestScreenshotUpload(
|
||||||
|
'https://media.d4rkst3r.de/api/image', 'image',
|
||||||
|
{ headers = { Authorization = apiKey } }, cb)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wo die Dateien landen:** unter dem Präfix des Tokens (sonst `uploads/`), mit
|
||||||
|
dem Anfang des SHA-256 im Namen — `screenshots/shot-841814b4089b.png`. Das ist
|
||||||
|
Absicht: Bildschirmfotos heißen alle `screenshot.png`, und ohne eigenen Namen
|
||||||
|
je Aufruf überschriebe das zweite das erste. Gleicher Inhalt ergibt denselben
|
||||||
|
Namen, verschiedener Inhalt einen anderen — dieselbe Eigenschaft, die
|
||||||
|
Fivemanages ID auch hat.
|
||||||
|
|
||||||
|
**Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt
|
||||||
|
hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser
|
||||||
|
Dienst löscht nichts von selbst. Presigned URLs gibt es nicht: der Upload geht
|
||||||
|
direkt hierher.
|
||||||
|
|
||||||
|
> Beide Formen sind **aus echtem Code abgelesen**, nicht geraten:
|
||||||
|
> [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und
|
||||||
|
> [Awleks/Devm-Camera](https://github.com/Awleks/Devm-Camera) für den älteren
|
||||||
|
> Weg über `screenshot-basic`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für eine Statusseite
|
||||||
|
|
||||||
|
Zwei Adressen, beide öffentlich, beide ohne Anmeldung — und sie beantworten
|
||||||
|
**verschiedene** Fragen.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health -> 200 {"ok":true}
|
||||||
|
GET /status -> 200 | 503 mit Begründung
|
||||||
|
```
|
||||||
|
|
||||||
|
`/health` beweist genau eines: **der Prozess nimmt Anfragen an.** Daran hängt
|
||||||
|
auch der `HEALTHCHECK` des Containers, und dort ist eine billige Prüfung
|
||||||
|
richtig — eine schwere, die bei einer langsamen Platte einmal ausfällt, ließe
|
||||||
|
Docker den Container neu starten, also genau dann, wenn er unter Last steht.
|
||||||
|
|
||||||
|
`/status` sieht wirklich nach:
|
||||||
|
|
||||||
|
| Prüfung | wie |
|
||||||
|
|---|---|
|
||||||
|
| `datenbank` | eine echte Abfrage gegen eine echte Tabelle |
|
||||||
|
| `platte` | schreiben **und wieder löschen** — der einzige Beweis |
|
||||||
|
| `ausliefern` | eine zufällige Datei aus der Datenbank auf der Platte nachmessen |
|
||||||
|
| `bestand` | kein Eintrag, aber Dateien da? Dann stimmt etwas nicht |
|
||||||
|
| `platz` | ab 95 % belegt |
|
||||||
|
| `sicherung` | älter als 26 Stunden oder fehlgeschlagen |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dienst": "d4rk_media", "ok": true, "stand": "gesund", "seit": 4271,
|
||||||
|
"pruefungen": [ { "was": "datenbank", "ok": true }, … ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zwei Stufen, und der Unterschied ist der zwischen „tut es nicht" und „braucht
|
||||||
|
Aufmerksamkeit".** Datenbank, Platte, Ausliefern und Bestand sind das Geschäft
|
||||||
|
dieses Dienstes — fällt eines aus, ist er `gestoert` und die Antwort ist
|
||||||
|
**503**. Eine alte Sicherung oder eine volle Platte machen ihn
|
||||||
|
`beeintraechtigt`: er liefert weiter aus, die Antwort bleibt **200**.
|
||||||
|
|
||||||
|
Wer auch das rot haben will, hängt `?streng=1` an — dann gibt alles außer
|
||||||
|
`gesund` eine 503.
|
||||||
|
|
||||||
|
Die Prüfung `sicherung` sagt zusätzlich, an **wie vielen Orten** das letzte
|
||||||
|
Archiv liegt (`an 3 Orten`, oder `nur an einem Ort`). Eine Sicherung, die es
|
||||||
|
nur einmal gibt, ist grün und trotzdem eine, die ein Plattenausfall mitnimmt.
|
||||||
|
|
||||||
|
### In Uptime Kuma
|
||||||
|
|
||||||
|
```
|
||||||
|
Dienst läuft https://media.d4rkst3r.de/status
|
||||||
|
Braucht Pflege https://media.d4rkst3r.de/status?streng=1
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warum `bestand` dabei ist.** Nachgemessen an genau diesem Dienst: wird eine
|
||||||
|
> Sicherung zurückgespielt, ohne `media.db-wal` daneben zu entfernen, spielt
|
||||||
|
> SQLite das WAL der **alten** Datenbank über die neue. Ergebnis: 0 Einträge,
|
||||||
|
> während 4452 Dateien danebenliegen. Der Dienst kommt hoch, `/health` ist
|
||||||
|
> grün, die Mediathek ist leer.
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> /health sagt: HTTP 200
|
||||||
|
> /status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte
|
||||||
|
> ```
|
||||||
|
|
||||||
|
Was **nicht** hinausgeht: Dateizahlen, Größen, Pfade, Tokennamen, Benutzer. Die
|
||||||
|
einzige Zahl ist die Laufzeit in Sekunden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anmeldung: Token
|
||||||
|
|
||||||
|
Alle Skript-Wege brauchen einen Token im Kopf:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer d4rk_…
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens werden im Dashboard angelegt. Der Klartext wird **genau einmal** gezeigt
|
||||||
|
— danach steht nur noch sein Hash in der Datenbank, und auch der Dienst kann
|
||||||
|
ihn nicht mehr herausgeben.
|
||||||
|
|
||||||
|
Ein Token kann **sechs** Fesseln haben — alle im Dashboard einstellbar, auch
|
||||||
|
nachträglich (`PATCH`, ohne dass sich der Schlüssel ändert):
|
||||||
|
|
||||||
|
| Fessel | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` |
|
||||||
|
| darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 |
|
||||||
|
| größte Datei | schärfer als `MAX_UPLOAD_MB`; darüber 413 |
|
||||||
|
| Kontingent | wieviel dieser Token **insgesamt** halten darf; darüber 413 |
|
||||||
|
| erlaubte Arten | Bilder, Video, Ton, Dokumente, Anderes; sonst 403 |
|
||||||
|
| gültig bis | danach 401 mit dem Datum im Satz |
|
||||||
|
|
||||||
|
> **Das Kontingent ist etwas anderes als die Größe einer Datei.** Ein Token mit
|
||||||
|
> „höchstens 2 MB je Bild" konnte die Platte trotzdem füllen — es brauchte nur
|
||||||
|
> genug Bilder. Gezählt wird über das, was wirklich liegt, nicht über einen
|
||||||
|
> mitlaufenden Zähler:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Kontingent erschoepft: dieser Token haelt 12 KB von 20 KB,
|
||||||
|
> und diese Datei braucht 12 KB.
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### ⚠️ Bilder können kleiner zurückkommen, als sie hingingen
|
||||||
|
|
||||||
|
Ein Token kann eine **größte Kantenlänge** tragen. Ist sie gesetzt und das Bild
|
||||||
|
größer, rechnet der Dienst es herunter, **bevor** er es ablegt:
|
||||||
|
|
||||||
|
```
|
||||||
|
3000 × 2000 hingeschickt → 512 × 341 abgelegt, 82 KB → 3 KB
|
||||||
|
```
|
||||||
|
|
||||||
|
Für ein Skript heißt das zweierlei:
|
||||||
|
|
||||||
|
1. **`size` in der Antwort ist die Größe der abgelegten Datei**, nicht die der
|
||||||
|
geschickten.
|
||||||
|
2. **`sha256` ebenso.** Wer den Hash vor dem Upload selbst rechnet und mit der
|
||||||
|
Antwort vergleicht, bekommt bei gesetzter Kantenlänge einen Unterschied —
|
||||||
|
und der ist kein Fehler. Der Hash gehört zu dem, was liegt; alles andere
|
||||||
|
wäre gelogen.
|
||||||
|
|
||||||
|
Kleinere Bilder werden **nicht** aufgeblasen, und Video, Ton und Dokumente
|
||||||
|
bleiben unberührt. Ohne gesetzte Kantenlänge passiert gar nichts — der
|
||||||
|
Standard ist „ablegen, wie es kommt".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/upload
|
||||||
|
|
||||||
|
Nimmt die Datei in einer von **drei Rumpfformen** entgegen:
|
||||||
|
|
||||||
|
1. `multipart/form-data` mit dem Feld `file`
|
||||||
|
2. Base64-Text, wenn `X-Encoding: base64` gesetzt ist
|
||||||
|
3. rohe Bytes, sonst
|
||||||
|
|
||||||
|
### Kopfzeilen
|
||||||
|
|
||||||
|
| Kopf | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `X-Path` | Wohin die Datei kommt. **Ohne ihn** wird aus dem SHA-256 ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar. |
|
||||||
|
| `X-Encoding` | `base64`, wenn der Rumpf Base64-Text ist. Ein Data-URL-Vorspann (`data:image/webp;base64,`) wird abgeschnitten. |
|
||||||
|
| `X-Overwrite` | `false` verweigert das Überschreiben und antwortet mit 409. Standard ist überschreiben. |
|
||||||
|
| `X-Mime` | Überschreibt die Art. Sonst entscheidet die Endung. |
|
||||||
|
|
||||||
|
### Antwort
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://media.d4rkst3r.de/f/vehicles/adder.webp",
|
||||||
|
"path": "vehicles/adder.webp",
|
||||||
|
"size": 132760,
|
||||||
|
"sha256": "36cb4ddf…",
|
||||||
|
"mime": "image/webp",
|
||||||
|
"replaced": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Trägt der Token eine Kantenlänge und wurde wirklich gerechnet, kommt ein Feld
|
||||||
|
dazu — sonst steht es gar nicht erst da, denn ein Feld, das immer dasteht, sagt
|
||||||
|
nichts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"verkleinert": { "von": "3000x2000", "auf": "512x341" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was schiefgehen kann
|
||||||
|
|
||||||
|
| Antwort | Grund |
|
||||||
|
|---|---|
|
||||||
|
| 401 | Token fehlt oder ist unbekannt |
|
||||||
|
| 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) |
|
||||||
|
| 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 |
|
||||||
|
| 409 | `X-Overwrite: false` und der Pfad ist belegt |
|
||||||
|
| 413 | größer als `MAX_UPLOAD_MB` (Standard 64), **oder** größer als die Grenze dieses Tokens, **oder** sein Kontingent ist erschöpft — welches, steht im Satz |
|
||||||
|
|
||||||
|
Jede Fehlerantwort ist JSON mit einem Feld `error` und einem **Satz auf
|
||||||
|
Deutsch**. Ein nackter 500 ohne Text ist genau der Fehler, wegen dem dieser
|
||||||
|
Dienst gebaut wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aus einer FiveM-Resource
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local TOKEN = 'd4rk_…' -- im Dashboard anlegen
|
||||||
|
|
||||||
|
local function hochladen(pfad, bytes, cb)
|
||||||
|
PerformHttpRequest('https://media.d4rkst3r.de/api/upload',
|
||||||
|
function(status, body)
|
||||||
|
if status ~= 200 then return cb(nil, body) end
|
||||||
|
cb(json.decode(body).url)
|
||||||
|
end, 'POST', B64.encode(bytes), {
|
||||||
|
['Authorization'] = 'Bearer ' .. TOKEN,
|
||||||
|
['X-Path'] = pfad,
|
||||||
|
['X-Encoding'] = 'base64',
|
||||||
|
['Content-Type'] = 'text/plain; charset=utf-8',
|
||||||
|
})
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum Base64 und nicht die rohen Bytes.** `PerformHttpRequest` nimmt den
|
||||||
|
Rumpf als Lua-Zeichenkette, und ob Nullbytes darin unverändert ankommen, hat
|
||||||
|
hier niemand nachgemessen — ein PNG besteht zu gutem Teil daraus. Base64 kostet
|
||||||
|
ein Drittel mehr auf der Leitung und nimmt die Frage aus dem Weg. Nachgemessen
|
||||||
|
ist: Base64 kommt mit demselben SHA-256 an wie die rohen Bytes.
|
||||||
|
|
||||||
|
**Warum vom Server und nicht aus der NUI.** Die NUI läuft unter `https`, und
|
||||||
|
CEF hebt jede `http`-Adresse selbst dorthin an. Ein Upload aus dem Fenster
|
||||||
|
heraus wäre entweder Mixed Content oder ginge ins Leere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/exists?pfad=<pfad>
|
||||||
|
|
||||||
|
Für „nur fehlende" in einem Serienlauf: eine winzige Antwort gegen 130 KB
|
||||||
|
Upload.
|
||||||
|
|
||||||
|
> **Diese Form und nicht mehr `/api/exists/<pfad>`** — der Grund ist
|
||||||
|
> nachgemessen und unangenehm. Die alte Form endet auf `.webp`, und Nginx
|
||||||
|
> Proxy Managers `assets.conf` greift auf **jede** URL mit Bildendung:
|
||||||
|
>
|
||||||
|
> ```nginx
|
||||||
|
> location ~* ^.*\.(css|js|jpe?g|gif|png|webp|...)$ {
|
||||||
|
> proxy_cache public-cache;
|
||||||
|
> proxy_ignore_headers Set-Cookie Cache-Control Expires ...;
|
||||||
|
> proxy_cache_valid any 30m;
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Der Proxy legt die berechtigte Antwort weg und liefert sie danach **an jeden
|
||||||
|
> aus, auch ohne Token** — gemessen: 200 mit voller Auskunft, `Authorization`
|
||||||
|
> gar nicht gesetzt. Unser eigenes `Cache-Control` hilft nicht,
|
||||||
|
> `proxy_ignore_headers` wirft es weg. Und es geht auch andersherum: landet
|
||||||
|
> zuerst ein `401` im Zwischenspeicher, bekommen ihn 30 Minuten lang alle.
|
||||||
|
>
|
||||||
|
> Die Abfrageform endet nie auf eine Bildendung und ist deshalb **von sich aus**
|
||||||
|
> dicht — ohne von einer Zeile in einer fremden Oberfläche abzuhängen. Die alte
|
||||||
|
> Form bleibt, weil Skripte sie benutzen; neu gebaut wird mit dieser.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "exists": true, "path": "vehicles/adder.webp",
|
||||||
|
"url": "…", "size": 132760, "sha256": "…", "updated_at": 1786466462488 }
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Drei Zustände, nicht zwei.** Wer einen Netzfehler als „nicht da" durchgehen
|
||||||
|
> lässt, lädt bei einem Husten alles ein zweites Mal hoch. Ein Aufrufer sollte
|
||||||
|
> zwischen `exists: false` und „ich weiß es nicht" unterscheiden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /api/media/<pfad>
|
||||||
|
|
||||||
|
Braucht einen Token mit dem Recht **darf löschen**.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "deleted": "vehicles/adder.webp" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Löschen ist endgültig: die Datei ist von der Platte weg, und es gibt keinen
|
||||||
|
Papierkorb. Die tägliche Sicherung hilft nur bis zum letzten Lauf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Wege des Dashboards
|
||||||
|
|
||||||
|
Diese brauchen eine **Sitzung** und keinen Token — sie sind für die Oberfläche
|
||||||
|
gedacht. Hier stehen sie, weil man sie auch mit `curl` und einem Cookie
|
||||||
|
benutzen kann, wenn man etwas einmalig braucht.
|
||||||
|
|
||||||
|
| Weg | Wozu |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/dash/upload-url` | Der Dienst holt die Datei selbst von einer Adresse. Siehe unten. |
|
||||||
|
| `POST /api/dash/fehlende` | Muster + Namensliste → was fehlt, was überzählig ist |
|
||||||
|
| `POST /api/dash/media/ersetzen` | Suchen und Ersetzen über Pfade. **Ohne `ausfuehren: true` wird nur gerechnet und gezeigt.** |
|
||||||
|
| `POST /api/dash/media/umbenennen` | Eine Datei; die Antwort nennt ihre Abrufzahl |
|
||||||
|
| `POST /api/dash/folders/umbenennen` | Ein Ordner mit allem darin, in einer Transaktion |
|
||||||
|
| `PATCH /api/dash/tokens/:id` | Grenzen ändern. Weggelassene Felder bleiben, `null` löscht eine Grenze. |
|
||||||
|
| `GET /api/dash/verwaltung` | Wer hat wann was eingestellt |
|
||||||
|
| `GET /api/dash/verlauf/export` | Verlauf als CSV; `?was=verwaltung` für den anderen. Semikolon + CRLF, höchstens 20 000 Zeilen. |
|
||||||
|
| `GET /api/dash/einstellungen/export` | Alle Einstellungen als JSON. **Ohne Geheimnisse**, es sei denn `?geheim=1`. |
|
||||||
|
| `POST /api/dash/einstellungen/import` | Zurück. Leere Werte überschreiben nichts. |
|
||||||
|
| `GET/POST/DELETE /api/dash/freigaben` | Nur-Lesen-Links auf einen Ordner |
|
||||||
|
| `POST /api/dash/maintenance/thumbs` \| `/webp` | Abgeleitetes nachziehen, **in Runden zu 300** |
|
||||||
|
|
||||||
|
### POST /api/dash/upload-url
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "url": "https://…/bild.png", "ordner": "items", "name": "optional.png" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Das ist die vorsichtigste Stelle im ganzen Dienst.** Ein Server, der eine vom
|
||||||
|
Benutzer genannte Adresse abruft, ist ein Angriff mit eigenem Namen — deshalb
|
||||||
|
wird geprüft, und zwar die **aufgelöste IP** und nicht der Name:
|
||||||
|
|
||||||
|
| abgelehnt | Beispiel |
|
||||||
|
|---|---|
|
||||||
|
| alles außer http/https | `file://`, `gopher://` |
|
||||||
|
| andere Anschlüsse als 80/443 | `http://host:9101/` |
|
||||||
|
| Rückschleife und Heimnetz | `127.0.0.1`, `::1`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.x`, `0.0.0.0` |
|
||||||
|
| öffentliche Namen auf private Adressen | `127.0.0.1.nip.io` |
|
||||||
|
|
||||||
|
Der letzte Punkt ist der eigentliche: ein auflösbarer Name, der auf eine private
|
||||||
|
Adresse zeigt, ist der Standardweg um jeden Namensfilter herum. **Und jede
|
||||||
|
Umleitung wird einzeln neu geprüft** — `fetch` folgen zu lassen wäre genau dort
|
||||||
|
die Lücke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Größte Datei | 64 MB (`MAX_UPLOAD_MB`), je Token weiter einschränkbar |
|
||||||
|
| Kontingent je Token | einstellbar, ohne Grenze wenn leer |
|
||||||
|
| Pfadlänge | 200 Zeichen, höchstens 8 Ebenen |
|
||||||
|
| Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) |
|
||||||
|
| Sitzung | 30 Tage |
|
||||||
|
| Papierkorb | 30 Tage **und** höchstens 20 GB (`PAPIERKORB_MAX_MB`) — was zuerst greift, greift |
|
||||||
|
|
||||||
|
Der Papierkorb hat zwei Grenzen, weil eine nicht reicht: wer 200 GB löscht,
|
||||||
|
hält sie dreißig Tage lang doppelt. Beim Ausleeren fliegt erst, was zu alt ist,
|
||||||
|
dann das **Älteste**, bis der Deckel wieder passt. Die Zahl steht in der
|
||||||
|
Antwort von `GET /api/dash/papierkorb` als `maxBytes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meldungen nach Discord
|
||||||
|
|
||||||
|
Vier Anlässe, alle einzeln abschaltbar: `sicherung`, `verwaiste`, `platte`,
|
||||||
|
`upload`.
|
||||||
|
|
||||||
|
**`upload` meldet nicht sofort.** Jeder Upload schiebt eine Frist von zwei
|
||||||
|
Minuten nach hinten; erst wenn es ruhig bleibt, geht **eine** Nachricht raus —
|
||||||
|
mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
|
||||||
|
|
||||||
|
Das ist keine Bequemlichkeit, sondern der Grund, warum die Funktion überhaupt
|
||||||
|
brauchbar ist: ein Serienlauf des Fotostudios legt 900 Bilder ab. 900
|
||||||
|
Nachrichten wären keine Meldung mehr, sondern ein Ausfall des Kanals — Discord
|
||||||
|
drosselt Webhooks, und wer danach eine *echte* Meldung bekommt, sieht sie nicht
|
||||||
|
mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Zwischenspeicher des Proxys
|
||||||
|
|
||||||
|
Vor dem Dienst steht Nginx Proxy Manager. Dessen `assets.conf` greift über eine
|
||||||
|
Regel auf **jede URL, die auf `.webp`, `.png`, `.js`, `.css` … endet** — und
|
||||||
|
legt die Antwort für 30 Minuten weg, dabei ignoriert sie unsere eigenen
|
||||||
|
Kopfzeilen.
|
||||||
|
|
||||||
|
Das hatte zwei sichtbare Folgen, beide nachgemessen und beide behoben:
|
||||||
|
|
||||||
|
- Ein **überschriebenes Bild** blieb bis zu 30 Minuten alt, obwohl Upload und
|
||||||
|
Datenbank „fertig" sagten.
|
||||||
|
- `/api/exists/…webp` wurde mit Antwort zwischengespeichert und danach **auch
|
||||||
|
ohne Token** ausgeliefert.
|
||||||
|
|
||||||
|
Behoben durch drei Blöcke in der *Custom Nginx Configuration* des Proxy-Hosts:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /api/ { proxy_cache off; include conf.d/include/proxy.conf; }
|
||||||
|
location ^~ /f/ { proxy_cache off; include conf.d/include/proxy.conf; }
|
||||||
|
location ^~ /t/ { proxy_cache off; include conf.d/include/proxy.conf; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `^~` ist der ganze Trick: eine gewöhnliche Präfix-Regel **verliert** gegen
|
||||||
|
die Regex aus `assets.conf`. In einem Wegwerf-Nginx nachgemessen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Im Dashboard unter **API** — oder direkt, mit gültiger Sitzung:
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/dash/export?format=json alles, was der Dienst weiß
|
||||||
|
/api/dash/export?format=csv Semikolon und CRLF, für Excel
|
||||||
|
/api/dash/export?format=lua fertige Tabelle Name → Adresse
|
||||||
|
/api/dash/export?format=lua&folder=vehicles
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Lua-Fassung liest man in einer Resource als `Urls.adder` aus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Diese Seite wird aus `docs/` im Repo erzeugt — Änderungen hier gehen beim nächsten Lauf verloren.*
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# d4rk_media
|
||||||
|
|
||||||
|
Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen,
|
||||||
|
ausliefern, in einem Dashboard verwalten.
|
||||||
|
|
||||||
|
**Läuft unter** https://media.d4rkst3r.de — Dashboard und API an der
|
||||||
|
Wurzel, Dateien unter `/f/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wo was steht
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **[API](API)** | Endpunkte, Kopfzeilen, Fehlermeldungen, Grenzen — und was beim Anbinden schiefgeht |
|
||||||
|
| `ROADMAP.md` im Repo | Zuschnitt, Stand, Reihenfolge, offene Entscheidungen |
|
||||||
|
| `CLAUDE.md` im Repo | die Arbeitsweise: prüfen statt raten |
|
||||||
|
|
||||||
|
Das Warum einer Änderung steht in der Commit-Nachricht, nicht nur im Diff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Zuschnitt in vier Sätzen
|
||||||
|
|
||||||
|
**Ein Container.** SQLite statt PostgreSQL, Dateien auf einer Platte statt
|
||||||
|
MinIO. Das ist nicht die kleine Lösung, sondern die, die man nicht pflegt.
|
||||||
|
|
||||||
|
**Der Pfad ist der Schlüssel.** `vehicles/adder.webp` bleibt
|
||||||
|
`vehicles/adder.webp`, damit die Vorlage im Handy `{model}.webp` lauten kann.
|
||||||
|
Die Adressen stehen nirgends in der Datenbank — sie werden bei jeder Antwort
|
||||||
|
neu gebaut.
|
||||||
|
|
||||||
|
**Jeder Knopf gibt Rückmeldung, auch der erfolgreiche.** Das ist der Fehler, an
|
||||||
|
dem der Fivemanage-Anlauf gescheitert ist: ein Knopf ohne Antwort sieht aus wie
|
||||||
|
ein kaputter Knopf, und das Ergebnis waren neunzehn gleichnamige
|
||||||
|
Organisationen.
|
||||||
|
|
||||||
|
**Nicht nachgebaut wird das Logging.** Dafür schleppt Fivemanage ClickHouse
|
||||||
|
mit, und genau da ist der Wartungsaufwand echt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was der Dienst kann
|
||||||
|
|
||||||
|
- **Hochladen** über die Skript-API (Token) oder aus dem Dashboard (Sitzung) —
|
||||||
|
Bilder, Videos, Ton, PDF
|
||||||
|
- **Ausliefern** mit ETag, Bereichsanfragen und Vorschaubildern
|
||||||
|
- **Galerie** mit Ordnerbaum, Suche, Filter nach Medienart, Sortierung,
|
||||||
|
Mehrfachauswahl, Verschieben und Löschen
|
||||||
|
- **Verlauf**: wer hat wann was abgelegt, ersetzt, verschoben oder gelöscht
|
||||||
|
- **Anmeldung** über Discord (Rolle auf dem Server) oder Passwort
|
||||||
|
- **Sicherung** täglich, geprüft, in die Nextcloud
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
1. **Ein Bild ändert sich nicht.** Der Proxy hielt Bilder früher 30 Minuten
|
||||||
|
fest. Behoben — siehe [API](API), Abschnitt *Zwischenspeicher des Proxys*.
|
||||||
|
2. **Die Anmeldung sagt 200, aber man ist nicht angemeldet.** `NODE_ENV=production`
|
||||||
|
setzt das Cookie auf `Secure`; über eine nackte `http`-Adresse verwirft der
|
||||||
|
Browser es still. Über den Proxy mit TLS gibt es das Problem nicht.
|
||||||
|
3. **Ein Upload gibt 400 mit „ist als Pfadteil nicht erlaubt".** Jeder Teil
|
||||||
|
eines Pfades muss mit einem Buchstaben oder einer Ziffer beginnen. Ein
|
||||||
|
führender Unterstrich fällt durch.
|
||||||
|
4. **Ein Fehler ohne Text.** Sollte es nicht mehr geben: der Dienst hat eine
|
||||||
|
Auffanglinie, die auch bei unerwarteten Fehlern einen Satz mitgibt.
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
# Was noch ginge
|
||||||
|
|
||||||
|
Stand 12.08.2026. Diese Liste ist **nicht** eine Sammlung von allem, was
|
||||||
|
denkbar wäre — sie ist das, was beim Bauen und Messen wirklich aufgefallen ist.
|
||||||
|
Was hier steht, hat einen Grund; was keinen hatte, steht am Ende unter
|
||||||
|
„Verworfen, mit Begründung".
|
||||||
|
|
||||||
|
Sortiert nach dem, was ich zuerst täte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Sicher wertvoll
|
||||||
|
|
||||||
|
### ~~Die Oberfläche auf dem Telefon~~ — abgelehnt am 12.08.2026
|
||||||
|
|
||||||
|
> Vom Betreiber verworfen: das Dashboard wird nicht mobil benutzt. Der Rest des
|
||||||
|
> Abschnitts bleibt stehen, damit die Messung nicht verlorengeht, falls sich das
|
||||||
|
> je ändert.
|
||||||
|
|
||||||
|
**Gemessen:** 18 Umbruchpunkte (`sm:`/`lg:`/`xl:`) im ganzen Frontend. Die
|
||||||
|
Galerie ist ein Raster und passt sich an — die **vier Tabellen** in Verlauf,
|
||||||
|
Token, Konten und Papierkorb nicht. Auf einem Telefon laufen sie seitlich
|
||||||
|
heraus.
|
||||||
|
|
||||||
|
Der Umbau ist keine Kosmetik: Karten statt Tabellenzeilen unterhalb einer
|
||||||
|
Breite, so wie es die Galerie schon macht. Betrifft vier Dateien.
|
||||||
|
|
||||||
|
**Lohnt sich nur, wenn du das Dashboard mobil aufmachst.** Sonst ist es
|
||||||
|
Arbeit für einen Bildschirm, den niemand benutzt.
|
||||||
|
|
||||||
|
### Sitzungen beenden können
|
||||||
|
|
||||||
|
**Gemessen:** 86 offene Sitzungen, davon 82 von `admin` — meine Testanmeldungen
|
||||||
|
von heute. Sie laufen nach 30 Tagen aus, und `pruneSessions` räumt stündlich
|
||||||
|
die abgelaufenen weg. Was fehlt, ist ein Knopf **„alle anderen Sitzungen
|
||||||
|
beenden"** im Kontomenü.
|
||||||
|
|
||||||
|
Das ist mehr als Ordnung: wer sich an einem fremden Rechner angemeldet hat und
|
||||||
|
es später merkt, hat heute keine Möglichkeit, das zurückzunehmen — außer das
|
||||||
|
Passwort zu ändern und zu hoffen, dass das die Sitzungen mitnimmt (tut es
|
||||||
|
nicht, sie hängen an einer eigenen Tabelle).
|
||||||
|
|
||||||
|
### Was der Verlauf nicht sieht
|
||||||
|
|
||||||
|
**Gemessen:** `events` kennt `upload`, `replace`, `delete`, `move`, `rename` —
|
||||||
|
alles über **Dateien**. Nichts über **Einstellungen**. Wer den Discord-Webhook
|
||||||
|
geändert hat, wer einen Token angelegt oder gelöscht hat, wer ein Konto
|
||||||
|
entfernt hat: steht nirgends.
|
||||||
|
|
||||||
|
Bei zwei Konten ist das verschmerzbar. Sobald ein drittes dazukommt — und über
|
||||||
|
die Discord-Rolle entsteht es von selbst — ist es die erste Frage, die man
|
||||||
|
stellt, wenn etwas anders ist als gestern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Nützlich, wenn der Fall eintritt
|
||||||
|
|
||||||
|
### Massen-Umbenennen
|
||||||
|
|
||||||
|
Suchen und Ersetzen über Pfade, mit Vorschau vor dem Zuschlagen. Bei 3578 Items
|
||||||
|
ist eine Umbenennung von Hand keine.
|
||||||
|
|
||||||
|
Der Anlass wäre etwa: `items/` nach `inventar/` verschieben, oder eine
|
||||||
|
Namenskonvention ändern. Heute geht das nur Datei für Datei oder gar nicht.
|
||||||
|
|
||||||
|
**Die Warnung wäre dieselbe wie beim Ordner-Umbenennen** — jede alte Adresse
|
||||||
|
gibt danach 404 —, nur mal tausend.
|
||||||
|
|
||||||
|
### Bilder beim Ablegen verkleinern
|
||||||
|
|
||||||
|
Eine Obergrenze für die Kantenlänge, einstellbar je Token. Wer ein 6000 × 4000
|
||||||
|
großes Foto hochlädt, bekommt es auf, sagen wir, 2048 gerechnet.
|
||||||
|
|
||||||
|
**Gemessen und deshalb keine Dringlichkeit:** ein 12000 × 12000 großes PNG hat
|
||||||
|
den Dienst *nicht* in Verlegenheit gebracht — Vorschau in 374 ms, Speicher bei
|
||||||
|
47 MB von 31 GB. libvips arbeitet kachelweise und dekodiert nie das ganze Bild.
|
||||||
|
Es geht also um Plattenplatz und Ladezeit beim Ausliefern, nicht um Stabilität.
|
||||||
|
|
||||||
|
### Fehlende Bilder finden
|
||||||
|
|
||||||
|
Eine Liste hereinreichen (Fahrzeugmodelle, Item-Namen) und beantwortet
|
||||||
|
bekommen: **wozu fehlt ein Bild?** Heute beantwortet das niemand — das
|
||||||
|
Fotostudio weiß, was es aufgenommen hat, der Dienst weiß, was liegt, und die
|
||||||
|
Differenz rechnet niemand aus.
|
||||||
|
|
||||||
|
Das wäre ein Endpunkt, der eine Liste entgegennimmt und die fehlenden
|
||||||
|
zurückgibt. Klein, und die Antwort auf „warum zeigt das Handy bei manchen
|
||||||
|
Autos kein Bild".
|
||||||
|
|
||||||
|
### Einstellungen aus- und einlesen
|
||||||
|
|
||||||
|
Discord-Zugang, Sicherungsziel, Vorlagen, Meldungsanlässe — alles steht in
|
||||||
|
`settings` und ist beim Neuaufsetzen von Hand nachzutragen. Ein Export als JSON
|
||||||
|
(**ohne** die Geheimnisse, oder mit einer ausdrücklichen Ansage) macht aus
|
||||||
|
einer Stunde Klickerei fünf Minuten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Wäre schön, drängt nicht
|
||||||
|
|
||||||
|
### ~~Verlauf als CSV~~ — gebaut am 12.08.2026
|
||||||
|
|
||||||
|
`GET /api/dash/verlauf/export`, wahlweise `?was=verwaltung`. Der Knopf sitzt in
|
||||||
|
der Reiterleiste und holt **den Verlauf, der gerade offen ist** — zwei Knöpfe
|
||||||
|
nebeneinander wären die Frage „welchen von beiden?" an einer Stelle, wo die
|
||||||
|
Antwort schon auf dem Bildschirm steht.
|
||||||
|
|
||||||
|
Semikolon und CRLF wie beim Bestands-Export, und aus demselben Grund: Excel in
|
||||||
|
deutscher Einstellung nimmt bei Komma alles in eine Spalte und bei nacktem LF
|
||||||
|
die halbe Datei in eine Zelle. Gemessen an der ausgelieferten Datei: 5364
|
||||||
|
Zeilen, 5364 CRLF, **kein einziges nacktes LF**.
|
||||||
|
|
||||||
|
### ~~Meldung bei Upload~~ — gebaut am 12.08.2026
|
||||||
|
|
||||||
|
Mit Drossel, und die ist der eigentliche Inhalt. Jeder Upload schiebt eine
|
||||||
|
Frist von zwei Minuten nach hinten; erst nach der Ruhe geht **eine** Nachricht
|
||||||
|
raus, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
|
||||||
|
|
||||||
|
Ohne das wäre die Funktion ein Schaden statt eines Nutzens: 900 Fahrzeugbilder
|
||||||
|
ergäben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte
|
||||||
|
Meldung bekommt, sieht sie nicht mehr. Ein Serienlauf ergibt jetzt eine Zeile,
|
||||||
|
ein einzelnes Bildschirmfoto ergibt eine Zeile — beide sagen dasselbe, nur mit
|
||||||
|
anderen Zahlen.
|
||||||
|
|
||||||
|
Der Anlass heißt `upload` und ist **abschaltbar wie die anderen drei**. Die
|
||||||
|
Liste steht an zwei Stellen (Server und Oberfläche) — genau die Verdopplung,
|
||||||
|
die bei `platte` schon einmal ein Kreuzchen verschluckt hat; deshalb steht
|
||||||
|
jetzt in beiden ein Verweis auf die andere. Nachgemessen: vier geschickt, vier
|
||||||
|
gespeichert.
|
||||||
|
|
||||||
|
### ~~Papierkorb mit Größendeckel~~ — gebaut am 12.08.2026
|
||||||
|
|
||||||
|
20 GB. Beim Ausleeren wird erst nach Alter geräumt (30 Tage) und dann nach
|
||||||
|
Größe, **das Älteste zuerst** — dessen Versehen wäre am ehesten schon
|
||||||
|
aufgefallen. Die Grenze steht in der Antwort von `/api/dash/papierkorb`, damit
|
||||||
|
die Oberfläche sie nicht ein zweites Mal hinschreibt.
|
||||||
|
|
||||||
|
Bei 910 GB frei ist das heute keine Grenze, sondern eine Zusicherung: der
|
||||||
|
Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
|
||||||
|
|
||||||
|
### Ein Ziel AUSSERHALB dieser Maschine — offen, aber der Weg steht fest
|
||||||
|
|
||||||
|
Nachgemessen am 12.08.2026: **es verlässt heute nichts diesen Rechner.** Auch
|
||||||
|
die Nextcloud nicht — sie läuft hier, in denselben Docker-Volumes, auf derselben
|
||||||
|
Platte 0 wie die Daten. Details in der ROADMAP.
|
||||||
|
|
||||||
|
**Wo dieser Rechner steht, ist dabei der entscheidende Punkt**, und er ist mir
|
||||||
|
zuerst durchgerutscht: es ist ein Windows-Root-Server **im Rechenzentrum**. Der
|
||||||
|
Betreiber sitzt zu Hause und ist per mRemoteNG verbunden. Das dreht die
|
||||||
|
Bewertung um — eine Platte zu Hause ist nicht „dasselbe Haus", sondern zwei
|
||||||
|
Gebäude, zwei Netze, zwei Stromkreise, zwei Anbieter. Besser wird ein
|
||||||
|
Sicherungsziel kaum.
|
||||||
|
|
||||||
|
| Weg | deckt ab | Haken |
|
||||||
|
|---|---|---|
|
||||||
|
| **Rechner zu Hause holt ab** | Platte, Rechner, Rechenzentrum, übernommener Server | muss laufen, wenn geholt wird |
|
||||||
|
| **5-TB-Platte am Router**, vom Heimrechner beschrieben | dasselbe | Router-USB ist langsam; für 337 MB je Nacht reicht es |
|
||||||
|
| **Discord-Kanal** (nur der Bot) | dasselbe | 9 MB je Datei — für d4rk_media mit 336 MB unbrauchbar |
|
||||||
|
| 5-TB-Platte **am Server** | nur Plattenausfall | dritte Platte im selben Gehäuse — `D:` kann das schon |
|
||||||
|
|
||||||
|
### Der erste Schritt ist gemacht: von Hand holen
|
||||||
|
|
||||||
|
Einstellungen → **Sicherungen holen** listet die Archive und gibt sie heraus
|
||||||
|
(nur lesend eingehängt, Namensmuster statt Filter, jeder Griff im
|
||||||
|
Verwaltungs-Verlauf). Das ersetzt keine geplante Abholung — aber es macht die
|
||||||
|
Auswärtskopie zu etwas, das man im Vorbeigehen erledigt, statt zu einem
|
||||||
|
Vorhaben. Was noch fehlt, ist, dass es **von allein** passiert.
|
||||||
|
|
||||||
|
### Warum HOLEN und nicht SCHICKEN
|
||||||
|
|
||||||
|
Ein Ziel, in das der Server selbst schreiben darf, ist auch eines, das ein
|
||||||
|
übernommener Server löschen kann. Wer vom anderen Ende abholt, hält **keine
|
||||||
|
Zugangsdaten auf der Maschine, die geschützt werden soll**. Das ist der eine
|
||||||
|
Unterschied, der im Ernstfall zählt.
|
||||||
|
|
||||||
|
### Was dafür schon dasteht
|
||||||
|
|
||||||
|
```
|
||||||
|
sshd Running, Automatic, lauscht auf 0.0.0.0:22 und [::]:22
|
||||||
|
Firewall "OpenSSH SSH Server (sshd)" -> Allow
|
||||||
|
sshd_config Subsystem sftp sftp-server.exe
|
||||||
|
AllowGroups administrators "openssh users" sshusers
|
||||||
|
AuthorizedKeysFile .ssh/authorized_keys
|
||||||
|
je Nacht d4rk_media 335,9 MB + d4rkbot 0,9 MB = 336,9 MB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auf der Serverseite fehlt also nichts** außer einem Konto, das nur lesen darf.
|
||||||
|
Der Rest ist ein Skript auf dem Heimrechner, das per SFTP zieht und N Stände
|
||||||
|
behält.
|
||||||
|
|
||||||
|
### Ein Fund am Rande, der dazugehört
|
||||||
|
|
||||||
|
In `sshd_config` steht **kein** `PasswordAuthentication no`. Der Standard von
|
||||||
|
OpenSSH ist `yes` — auf Port 22, offen ins Internet, auf einem Server, dessen
|
||||||
|
`AllowGroups` die **Administratoren** einschließt. Wer ohnehin ein
|
||||||
|
Sicherungskonto mit Schlüssel anlegt, sollte bei der Gelegenheit auf
|
||||||
|
Schlüssel-only umstellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~Mehrere Sicherungsziele~~ — gebaut am 12.08.2026
|
||||||
|
|
||||||
|
Ein **zweites Laufwerk**, einstellbar im Panel unter Einstellungen → Sicherung.
|
||||||
|
Damit liegt jedes Archiv an drei Orten: `C:ackup`, das zweite Laufwerk und
|
||||||
|
die Nextcloud.
|
||||||
|
|
||||||
|
Die drei sind nicht dreimal dasselbe. Die Nextcloud hilft, wenn der ganze
|
||||||
|
Rechner weg ist — aber wer 335 MB (oder später 100 GB) zurückholen muss, lädt
|
||||||
|
sie über die Leitung. Das zweite Laufwerk ist in Minuten zurückgespielt.
|
||||||
|
|
||||||
|
**Und es warnt, wenn es dieselbe Platte ist.** Das ist der eigentliche Inhalt:
|
||||||
|
ein zweiter Ordner auf C: sieht im Panel genauso grün aus wie eine echte zweite
|
||||||
|
Platte und hilft gegen gar nichts. Verglichen wird die *physische* Platte, nicht
|
||||||
|
der Laufwerksbuchstabe — zwei Partitionen derselben NVMe sterben zusammen.
|
||||||
|
Nachgemessen in beide Richtungen: `C:` gegen `D:` ergibt Nr. 0 gegen Nr. 1 und
|
||||||
|
ein „andere Platte"; `C:` gegen `C:` ergibt zweimal Nr. 0 und die Warnung.
|
||||||
|
|
||||||
|
Ein echter Lauf: 4630 Einträge, 335,9 MB, auf beiden Zielen die Prüfsumme
|
||||||
|
`FFCE4D0A7310…` — identisch. Die Statusseite sagt seitdem
|
||||||
|
`sicherung ok an 3 Orten` statt nur `ok`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verworfen, mit Begründung
|
||||||
|
|
||||||
|
Diese standen auf der Liste und sind **gemessen** wieder heruntergefallen. Sie
|
||||||
|
stehen hier, damit sie nicht beim nächsten Mal wieder aufschlagen.
|
||||||
|
|
||||||
|
| Idee | Warum nicht |
|
||||||
|
|---|---|
|
||||||
|
| Doppelte Dateien zusammenführen | 5 Gruppen, **0,0 MB** verschwendet. Es gibt nichts zu holen. |
|
||||||
|
| Aufräumen nach Alter | Alle vier Altersstufen im Bericht stehen auf **0 Dateien**. Es ist nichts alt. |
|
||||||
|
| Grenze für Bildmaße als *Schutz* | 12000 × 12000 → 374 ms, 47 MB. libvips streamt, die Gefahr gibt es nicht. |
|
||||||
|
| Plattenplatz-Wächter | **Gebaut.** 80/90/95 %, meldet nach Discord. |
|
||||||
|
| Kontingent je Token | **Gebaut**, auf beiden Upload-Wegen geprüft. |
|
||||||
|
| Eigene Statusseite bauen | `/status` reicht — der d4rkbot zeigt sie schon an. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Und was heute noch schnell repariert wurde
|
||||||
|
|
||||||
|
**`pruneEvents` lief nie von selbst.** Die Funktion gibt es seit dem ersten Tag
|
||||||
|
und sie räumt Ereignisse älter als 180 Tage weg — aufgerufen wurde sie aber nur
|
||||||
|
von `/maintenance/prune-sessions`, einem Weg, den nicht einmal die Oberfläche
|
||||||
|
anbietet. Die dokumentierte Aufbewahrung griff damit **nie**, und die Tabelle
|
||||||
|
wuchs für immer.
|
||||||
|
|
||||||
|
Zum Vergleich: `pruneSessions` hängt seit jeher an einem stündlichen Takt. Das
|
||||||
|
eine war verdrahtet, das andere nicht, und der Unterschied fiel niemandem auf,
|
||||||
|
weil beide in derselben Zeile stehen.
|
||||||
|
|
||||||
|
Gemessen: nach **einem** Tag mit Umzug und Serienlauf standen 5355 Zeilen in
|
||||||
|
`events`. Läuft jetzt täglich, mit zwei Minuten Verzögerung nach dem Start.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# d4rk_media — der Block fuer Nginx Proxy Manager
|
||||||
|
#
|
||||||
|
# WOHIN: NPM oeffnen -> Hosts -> Proxy Hosts -> media.d4rkst3r.de
|
||||||
|
# -> Edit -> Reiter "Advanced" -> Feld "Custom Nginx Configuration"
|
||||||
|
#
|
||||||
|
# WAS: den GESAMTEN Inhalt dieser Datei dort hineinkopieren. Was schon drin
|
||||||
|
# steht (der /f/-Block), ist unten enthalten -- also ersetzen, nicht
|
||||||
|
# anhaengen.
|
||||||
|
#
|
||||||
|
# DANACH: "Save" druecken. ERST DANN schreibt NPM seine Datei
|
||||||
|
# /data/nginx/proxy_host/9.conf neu und laedt nginx nach. Ohne Save
|
||||||
|
# passiert nichts, und das ist die wahrscheinlichste Erklaerung dafuer,
|
||||||
|
# dass der Block beim letzten Mal nicht angekommen ist.
|
||||||
|
#
|
||||||
|
# Die Kommentarzeilen duerfen mit hinein -- nginx kennt '#'. Die Syntax ist
|
||||||
|
# gegengeprueft: `nginx -t` in einem Wegwerf-Container, "syntax is ok".
|
||||||
|
#
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# WARUM DAS UEBERHAUPT NOETIG IST
|
||||||
|
#
|
||||||
|
# NPM bindet in jeden Proxy-Host eine assets.conf ein. Deren Regel greift auf
|
||||||
|
# JEDE Adresse, die auf eine Bild- oder Skriptendung endet:
|
||||||
|
#
|
||||||
|
# location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|...)$ {
|
||||||
|
# proxy_cache public-cache;
|
||||||
|
# proxy_cache_key $host$request_uri; <- der Token zaehlt NICHT mit
|
||||||
|
# proxy_ignore_headers Set-Cookie Cache-Control Expires ...;
|
||||||
|
# proxy_cache_valid any 30m;
|
||||||
|
# proxy_hide_header Cache-Control;
|
||||||
|
# proxy_hide_header Vary;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# Drei gemessene Folgen davon:
|
||||||
|
#
|
||||||
|
# 1. /api/exists/vehicles/adder.webp endet auf ".webp" und faellt hinein. Der
|
||||||
|
# Proxy legt die BERECHTIGTE Antwort weg und liefert sie danach an jeden
|
||||||
|
# aus -- nachgemessen: 200 mit voller Auskunft, ganz ohne Token. Und
|
||||||
|
# andersherum: landet zuerst ein 401 im Speicher, bekommen ihn 30 Minuten
|
||||||
|
# lang alle, auch mit gueltigem Token.
|
||||||
|
#
|
||||||
|
# 2. /t/… (die Vorschaubilder) tragen dadurch "Cache-Control: max-age=12799"
|
||||||
|
# statt unserer "public, max-age=60". Nach einem erneuten Upload zeigt die
|
||||||
|
# Galerie deshalb stundenlang die ALTE Vorschau.
|
||||||
|
#
|
||||||
|
# 3. "proxy_hide_header Vary" wuerde unser "Vary: Accept" wegwerfen. Daran
|
||||||
|
# haengt die WebP-Auslieferung: ohne die Zeile bekaeme ein Aufrufer, der
|
||||||
|
# kein WebP versteht, die WebP-Fassung aus dem Zwischenspeicher.
|
||||||
|
#
|
||||||
|
# NACHGEMESSEN AM LAUFENDEN DIENST, damit klar ist, dass das kein
|
||||||
|
# theoretisches Problem ist -- dieselbe Adresse, dreimal:
|
||||||
|
#
|
||||||
|
# GET /api/exists/probe-xyz.webp mit gueltigem Token -> 200
|
||||||
|
# GET /api/exists/probe-xyz.webp OHNE Token -> 200 (!)
|
||||||
|
# GET /api/exists/vehicles/adder.webp mit gueltigem Token -> 401 (!)
|
||||||
|
#
|
||||||
|
# Die zweite Zeile ist die Auskunft ohne Berechtigung. Die dritte ist derselbe
|
||||||
|
# Fehler andersherum: dort lag ein 401 im Speicher, und den bekam auch, wer
|
||||||
|
# einen gueltigen Token hatte.
|
||||||
|
#
|
||||||
|
# Das "^~" ist der ganze Trick: eine gewoehnliche Praefix-Location VERLIERT
|
||||||
|
# gegen eine Regex-Location. Mit "^~" gewinnt sie und die Regex kommt gar nicht
|
||||||
|
# mehr zum Zuge. In einem Wegwerf-nginx nachgemessen.
|
||||||
|
#
|
||||||
|
# NICHT ausgenommen wird /assets/ -- die gebaute Oberflaeche. Deren Dateinamen
|
||||||
|
# tragen einen Hash (index-DCMjxAfP.js), aendern sich also nie unter demselben
|
||||||
|
# Namen. Dort ist der Zwischenspeicher genau richtig.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# AB HIER IST ES KONFIGURATION.
|
||||||
|
|
||||||
|
|
||||||
|
# Die Dateien selbst. Sie koennen sich unter derselben Adresse aendern (ein
|
||||||
|
# neu aufgenommenes Fahrzeugbild ersetzt das alte), und sie tragen "Vary:
|
||||||
|
# Accept" fuer die sparsame WebP-Fassung.
|
||||||
|
location ^~ /f/ {
|
||||||
|
proxy_cache off;
|
||||||
|
include conf.d/include/proxy.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Die Vorschaubilder. Aendern sich, sobald ein Bild neu hochgeladen wird --
|
||||||
|
# und dann soll das Dashboard es sehen und nicht in vier Stunden.
|
||||||
|
location ^~ /t/ {
|
||||||
|
proxy_cache off;
|
||||||
|
include conf.d/include/proxy.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Die API. Hier haengt eine Zugangspruefung dran, und eine zwischengespeicherte
|
||||||
|
# Antwort kennt den Token nicht, der sie erlaubt hat.
|
||||||
|
location ^~ /api/ {
|
||||||
|
proxy_cache off;
|
||||||
|
include conf.d/include/proxy.conf;
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# Einbruch in den Gitea, 12.08.2026
|
||||||
|
|
||||||
|
Kein Ausfall, kein Datenverlust, kein laufender Schadcode — aber ein echter
|
||||||
|
Schreibzugriff von außen. Das hier ist der Hergang, so wie er **gemessen**
|
||||||
|
wurde, und was daraus geändert wurde.
|
||||||
|
|
||||||
|
## Was passiert ist
|
||||||
|
|
||||||
|
```
|
||||||
|
21:13:56 POST /api/internal/manager/add-logger -> 200 OK
|
||||||
|
21:13:57 POST /api/internal/manager/remove-logger/... -> 200 OK
|
||||||
|
21:13:57 /data/gitea/home/.gitconfig neu geschrieben
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Angreifer hat über Giteas **interne Manager-API** einen Datei-Logger auf
|
||||||
|
die `.gitconfig` gelegt, seine Nutzlast als Logzeile hineinschreiben lassen und
|
||||||
|
den Logger eine Sekunde später wieder entfernt. Der Vorgang räumt hinter sich
|
||||||
|
auf; ohne den Nebeneffekt wäre er unbemerkt geblieben.
|
||||||
|
|
||||||
|
Eingeschleust wurde ein `packObjectsHook` — ein Kommando, das Git bei jedem
|
||||||
|
`upload-pack` (also bei jedem Klonen und Ziehen) ausführt. Dekodiert:
|
||||||
|
|
||||||
|
- lädt `xmrig` von `111.90.158.58` bzw. `111.90.139.202`
|
||||||
|
- tarnt ihn als `/var/tmp/.gt_sys_health` mit Konfiguration daneben
|
||||||
|
- schürft gegen `gulf.moneroocean.stream:443`/`:80`, Worker-ID `gitea`,
|
||||||
|
Wallet `89wM69z64AMhAKAY…`
|
||||||
|
- verankert sich per `crontab` (`*/3 * * * *` und `@reboot`) und `/etc/cron.d/gt_sys`
|
||||||
|
- killt konkurrierende Miner und eigene Altfassungen
|
||||||
|
|
||||||
|
## Warum trotzdem nichts lief
|
||||||
|
|
||||||
|
**Die Nutzlast hat sich selbst zerschossen.** Die eingeschleuste Zeile beginnt
|
||||||
|
mit `[uploadpack]\n` — wobei das `\n` als **Text** in der Datei steht und nicht
|
||||||
|
als Zeilenumbruch. Git kann die Datei damit nicht lesen, bricht mit
|
||||||
|
`fatal: bad config line 23` ab, und der Hook wurde nie gültig.
|
||||||
|
|
||||||
|
Nachgemessen, im Container wie auf dem Wirt:
|
||||||
|
|
||||||
|
| | Befund |
|
||||||
|
|---|---|
|
||||||
|
| Miner-Prozess | keiner |
|
||||||
|
| `/var/tmp/.gt_*`, `/tmp/.gt_*` | nichts |
|
||||||
|
| crontab, `/etc/cron.d` | unverändert |
|
||||||
|
| Nutzlast in Repos | keine |
|
||||||
|
| Docker-Socket im Container | **nicht gemountet** — kein Ausbruch auf den Wirt |
|
||||||
|
| Gitea-Konten | eines, das eigene |
|
||||||
|
| Fremde Token oder SSH-Schlüssel | keine |
|
||||||
|
|
||||||
|
Der einzige Schaden war, dass **alle Git-Operationen ausfielen** — inklusive
|
||||||
|
der Pushes aus diesem Projekt. Der Angriff ist nur daran aufgefallen.
|
||||||
|
|
||||||
|
## Der Weg hinein
|
||||||
|
|
||||||
|
Gitea lag mit `0.0.0.0:3000` **direkt im Internet**, am Reverse-Proxy vorbei —
|
||||||
|
`http://88.218.224.10:3000/` antwortete mit 200. Damit war auch
|
||||||
|
`/api/internal/*` von außen erreichbar; geschützt nur durch das
|
||||||
|
`INTERNAL_TOKEN`.
|
||||||
|
|
||||||
|
**Woher der Angreifer das Token hatte, ließ sich nicht klären.** Es steht in
|
||||||
|
keinem Repo, und RDP war es nicht (siehe unten). Bleibt eine Lücke in Gitea
|
||||||
|
1.26.4 oder ein Leck, das hier nicht sichtbar ist. Genau deshalb ist die
|
||||||
|
Antwort nicht „Token tauschen", sondern **den Weg zumachen**.
|
||||||
|
|
||||||
|
### RDP: beschossen, nicht geknackt
|
||||||
|
|
||||||
|
Cloudflare meldete offene RDP-Server. Nachgesehen im Sicherheitsprotokoll,
|
||||||
|
14 Tage:
|
||||||
|
|
||||||
|
```
|
||||||
|
2756 fehlgeschlagene Anmeldungen, verteilt auf viele IPs
|
||||||
|
33 erfolgreiche von aussen -- alle von EINER IP, dem Anschluss des Betreibers
|
||||||
|
```
|
||||||
|
|
||||||
|
RDP war also **nicht** der Einbruchsweg. NLA ist aktiv. Trotzdem gehört der
|
||||||
|
Zugang eingegrenzt — 2756 Versuche sind ein Dauerbeschuss.
|
||||||
|
|
||||||
|
## Was geändert wurde
|
||||||
|
|
||||||
|
1. **`.gitconfig` gesäubert** — die zwei vergifteten Zeilen raus, 22 Zeilen
|
||||||
|
übrig. Git funktioniert wieder beim ersten Versuch.
|
||||||
|
2. **Port 3000 ist weg.** Nicht auf localhost gebunden, sondern **gar nicht
|
||||||
|
mehr veröffentlicht**. Von außen: keine Antwort.
|
||||||
|
3. **Der Proxy geht jetzt durchs interne Netz.** NPM hängt zusätzlich in
|
||||||
|
`gitea_default` und spricht `gitea:3000` an. Dafür musste der nginx-Resolver
|
||||||
|
auf Dockers eigenen DNS (`127.0.0.11`) umgestellt werden — der alte stammte
|
||||||
|
noch aus der Zeit, als NPM nur im `bridge`-Netz hing.
|
||||||
|
4. **`INTERNAL_TOKEN` und `SECRET_KEY` getauscht.** Gefahrlos hier, weil weder
|
||||||
|
2FA noch externe Anmeldequellen eingerichtet sind (beides nachgezählt: 0).
|
||||||
|
5. **`/api/internal/` gibt am Proxy jetzt 404.** Dritte Sperre: selbst wenn das
|
||||||
|
Token erneut leckt, ist der Weg von außen zu. Gitea ruft diese Pfade nur
|
||||||
|
containerintern über localhost.
|
||||||
|
6. **Frisches Abbild.** Der Container lief elf Tage mit dem Stand von damals —
|
||||||
|
`gitea/gitea:latest` heißt nicht, dass das Laufende aktuell ist.
|
||||||
|
|
||||||
|
Gegengeprüft, beides:
|
||||||
|
|
||||||
|
```
|
||||||
|
Port 3000 von aussen -> 000 (zu)
|
||||||
|
/api/internal/manager/add-logger -> 404 (gesperrt)
|
||||||
|
https://git.d4rkst3r.de/ -> 200
|
||||||
|
https://git.d4rkst3r.de/api/v1/version -> 200
|
||||||
|
grosse Uploads (LFS): 3 von 3 Direktiven noch da
|
||||||
|
Push aus diesem Projekt: geht, beim ersten Versuch
|
||||||
|
Repos gegen die lokalen Klone: identisch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was noch offen ist
|
||||||
|
|
||||||
|
- **Alle API-Token und Webhook-Geheimnisse als geleakt behandeln** und
|
||||||
|
tauschen: `GITEA_TOKEN`, das Token `Claude`, Devlog- und Gitea-Webhooks.
|
||||||
|
Nicht weil ein Diebstahl belegt ist, sondern weil er nicht ausgeschlossen
|
||||||
|
werden kann.
|
||||||
|
- **Admin-Passwort ändern.**
|
||||||
|
- ~~RDP eingrenzen~~ — erledigt, siehe unten.
|
||||||
|
- **Beweise** liegen unter `C:\backup\vorfall-2026-08-12\`: die vergiftete
|
||||||
|
`.gitconfig`, die ursprüngliche `app.ini`, das vollständige Gitea-Log
|
||||||
|
(24 MB) und die alte Compose-Datei.
|
||||||
|
|
||||||
|
## Die Lehre, die über diesen Fall hinausgeht
|
||||||
|
|
||||||
|
Der offene Port war **nie nötig**. Der Proxy hätte von Anfang an über das
|
||||||
|
interne Netz gehen können; die Veröffentlichung war Bequemlichkeit beim
|
||||||
|
Einrichten. Sie stand elf Tage, und in dieser Zeit hat jemand sie gefunden.
|
||||||
|
|
||||||
|
Und: aufgefallen ist es nicht, weil etwas überwacht wurde, sondern weil der
|
||||||
|
Angreifer geschludert hat. **Hätte er sein `\n` richtig gesetzt, liefe der
|
||||||
|
Miner noch** — und die einzige Spur wäre eine erhöhte CPU-Last gewesen, die
|
||||||
|
niemandem auffällt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachtrag 13.08.2026: RDP ist zu, der Zugang laeuft ueber einen Tunnel
|
||||||
|
|
||||||
|
`sshd` nimmt seit 00:2x **nur noch Schluessel** an — `PasswordAuthentication no`
|
||||||
|
*und* `KbdInteractiveAuthentication no`, denn unter Windows laeuft die
|
||||||
|
Passwortabfrage sonst ueber den zweiten Weg weiter. Die Zeilen stehen **vor**
|
||||||
|
dem ersten `Match`-Block; weiter unten waeren sie stillschweigend wirkungslos
|
||||||
|
gewesen.
|
||||||
|
|
||||||
|
```
|
||||||
|
Darkster@127.0.0.1: Permission denied (publickey).
|
||||||
|
```
|
||||||
|
|
||||||
|
RDP ist per Firewall gesperrt (TCP und UDP, alle Profile). Der Zugang geht
|
||||||
|
durch einen SSH-Tunnel; `tools/tunnel-einrichten.ps1` richtet ihn auf dem
|
||||||
|
Heimrechner als Hintergrundaufgabe ein — ohne Fenster, mit Neuaufbau bei
|
||||||
|
Abbruch. **Was niemand durchhaelt, wird umgangen, und am Ende steht wieder ein
|
||||||
|
offener Port.**
|
||||||
|
|
||||||
|
Beides gemessen, und zwar von der richtigen Seite:
|
||||||
|
|
||||||
|
```
|
||||||
|
auf dem Server 127.0.0.1:3389 erreichbar (der Tunnel traegt)
|
||||||
|
::1 Typ 7, Darkster (RDP kam durch)
|
||||||
|
|
||||||
|
vom Heimrechner Test-NetConnection 88.218.224.10 -Port 3389
|
||||||
|
TcpTestSucceeded : False (von aussen zu)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Messung vom Server aus taugte dafuer **nicht** — Verkehr an die eigene
|
||||||
|
oeffentliche Adresse durchlaeuft die Eingangsregeln nicht und meldete
|
||||||
|
faelschlich "erreichbar". Zweimal in dieser Nacht in dieselbe Falle getappt,
|
||||||
|
einmal bei Port 3000 und einmal bei 3389.
|
||||||
|
|
||||||
|
Seitdem laufen die Angriffe ins Leere:
|
||||||
|
|
||||||
|
```
|
||||||
|
00:53:12 Connection closed by invalid user root 31.77.227.120 [preauth]
|
||||||
|
alle 12 erfolgreichen Anmeldungen: eine IP, die des Betreibers
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachtrag 13.08.2026, vormittags: die Inventur, und drei Irrtümer von mir
|
||||||
|
|
||||||
|
### Was von außen wirklich offen war
|
||||||
|
|
||||||
|
Erst eine Messung **vom Heimrechner** hat die Lage geklärt — vom Server aus ist
|
||||||
|
sie nicht zu beantworten:
|
||||||
|
|
||||||
|
```
|
||||||
|
vorher nachher
|
||||||
|
80 True (gewollt) 80 True
|
||||||
|
81 True NPM-Verwaltung 81 False
|
||||||
|
135 True Windows-RPC 135 False
|
||||||
|
3306 True MariaDB 3306 False
|
||||||
|
445/3389/5985/9000/9443/11000 durchweg False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die Verwaltungsoberfläche des Reverse-Proxys und eine Datenbank standen offen
|
||||||
|
im Internet.** Beides ist jetzt per Firewall gesperrt; die NPM-Oberfläche
|
||||||
|
erreicht man weiter über `https://proxy.d4rkst3r.de`, weil sie containerintern
|
||||||
|
auf `127.0.0.1:81` läuft.
|
||||||
|
|
||||||
|
### ⚠️ Irrtum 1: „Docker geht an der Windows-Firewall vorbei"
|
||||||
|
|
||||||
|
**Falsch.** Port 81 wird von Docker veröffentlicht, und die Sperrregel greift
|
||||||
|
dort sehr wohl — nachgemessen von außen. Ich hatte das Gegenteil behauptet,
|
||||||
|
nachdem ich es *vom Server aus* geprüft hatte, und deswegen sogar eine Regel
|
||||||
|
wieder gelöscht. Verkehr an die eigene öffentliche Adresse durchläuft die
|
||||||
|
Eingangsregeln nicht; diese Messung kann die Frage gar nicht beantworten.
|
||||||
|
|
||||||
|
### ⚠️ Irrtum 2: „Der MariaDB-root hat kein Passwort"
|
||||||
|
|
||||||
|
**Falsch.** Er hat eines — das FiveM benutzt. Dass ich ohne hineinkam, liegt an
|
||||||
|
der Standardeinrichtung von MariaDB unter Windows:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"plugin": "mysql_native_password",
|
||||||
|
"auth_or": [ { }, { "plugin": "gssapi", "authentication_string": "SID:BA" } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
`auth_or` heißt „oder": Passwort **oder** Windows-Authentifizierung, und
|
||||||
|
`SID:BA` ist die Gruppe der lokalen Administratoren. Ich war als Administrator
|
||||||
|
unterwegs. Ein Angreifer aus dem Internet hat keine Windows-Identität — MySQL
|
||||||
|
fällt als Einbruchsweg aus.
|
||||||
|
|
||||||
|
Meine Prüfung davor war kaputt (`JSON_LENGTH` auf eine Zeichenkette gibt immer
|
||||||
|
`NULL`), und statt das zu bemerken, habe ich das Ergebnis geglaubt.
|
||||||
|
|
||||||
|
### Vergessene Dienste — der Verdacht des Betreibers war richtig
|
||||||
|
|
||||||
|
```
|
||||||
|
Caddy-AutoStart [Ready] beim Systemstart nie gelaufen
|
||||||
|
CloudflaredTunnel [Ready] beim Systemstart nie gelaufen
|
||||||
|
C:\Users\Darkster\.cloudflared\ Tunnel-Zugangsdaten vom 23.03.2026
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide waren **aktiv** und wären beim nächsten Neustart angesprungen. Ein Tunnel
|
||||||
|
ist ein Eingang, der an jeder Firewall vorbeigeht, weil er von innen aufgebaut
|
||||||
|
wird. Umfang war eng (`fivem-api.d4rkst3r.de` → `localhost:30121`, wo nichts
|
||||||
|
lauscht), der Caddyfile leer. Beide Aufgaben sind **abgeschaltet, nicht
|
||||||
|
gelöscht**.
|
||||||
|
|
||||||
|
**Offen für den Betreiber:** den Tunnel `3a4a599b-…` im Cloudflare-Konto
|
||||||
|
löschen. Solange er dort registriert ist, sind die Zugangsdaten auf der Platte
|
||||||
|
gültig.
|
||||||
|
|
||||||
|
Außerdem gefunden: **Azure Arc** (vier Dienste, seit 04.08., Status
|
||||||
|
`Disconnected`, ohne Abonnement) — vermutlich aus dem Anbieter-Abbild. Und
|
||||||
|
**IPBan** läuft, ein Brute-Force-Blocker.
|
||||||
|
|
||||||
|
### Der Wächter
|
||||||
|
|
||||||
|
`tools/waechter.ps1`, alle 15 Minuten. Zählt die Ports, die auf allen Adressen
|
||||||
|
lauschen, und rechnet Prüfsummen über `sshd_config`,
|
||||||
|
`administrators_authorized_keys`, beide Compose-Dateien sowie Giteas `app.ini`
|
||||||
|
und `.gitconfig`. Gemeldet wird **nur bei Änderung**, und die Statusseite zeigt
|
||||||
|
den Stand.
|
||||||
|
|
||||||
|
Vor dem Einrichten an beiden Fällen bewiesen, statt es zu behaupten:
|
||||||
|
|
||||||
|
```
|
||||||
|
Testport 45999 geoeffnet -> "NEU offen: 45999"
|
||||||
|
Datei harmlos geaendert -> Meldung raus, HTTP 204
|
||||||
|
Datei zurueckgestellt -> "Wieder in Ordnung (Merker geleert)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Er prüft zuerst, ob er überhaupt etwas sehen kann, und meldet sonst
|
||||||
|
„ungültig" statt „sauber" — die Lehre aus zwei Suchen, die genau das
|
||||||
|
verwechselt haben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachtrag 13.08.2026 — ein Schaden aus dem Aufräumen selbst
|
||||||
|
|
||||||
|
Beim Einrichten des Panels in `d4rk_gameserver` sollte ein Benutzer im Nginx
|
||||||
|
Proxy Manager angelegt werden. Die Oberfläche antwortete mit **„Internal
|
||||||
|
Error"**. Im Container-Log stand, was sie verschwieg:
|
||||||
|
|
||||||
|
```
|
||||||
|
insert into `user` ... - attempt to write a readonly database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Der Nginx Proxy Manager konnte seit dem 12.08.2026, 22:11 Uhr, nichts mehr
|
||||||
|
speichern.** Der letzte erfolgreiche Schreibvorgang war um 22:06 Uhr.
|
||||||
|
|
||||||
|
### Die Ursache war das Aufräumen
|
||||||
|
|
||||||
|
```
|
||||||
|
lrwx------ 1 root root 64 Aug 11 16:04 18 -> /data/database.sqlite (deleted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Prozess hielt einen Zeiger auf eine **gelöschte** Datei. Beim Aufräumen
|
||||||
|
nach dem Vorfall wurde `/data/database.sqlite` per `docker cp` zurückgeschrieben
|
||||||
|
— und `docker cp` **ersetzt** die Datei, statt sie zu überschreiben. Der seit
|
||||||
|
dem 01.08. laufende Prozess zeigte danach ins Leere. SQLite meldet das als
|
||||||
|
„readonly database", was in die Irre führt: Rechte, Eigentümer und Platz waren
|
||||||
|
alle in Ordnung (root, 905 GB frei, Verzeichnis beschreibbar).
|
||||||
|
|
||||||
|
### Was es fast gekostet hätte
|
||||||
|
|
||||||
|
Nicht den Betrieb — Lesen ging weiter, alle Seiten liefen, fünfzehn Stunden
|
||||||
|
lang fiel nichts auf. Aber:
|
||||||
|
|
||||||
|
| Wann | Was |
|
||||||
|
|---|---|
|
||||||
|
| ab 03.09.2026 | Erste Zertifikatsverlängerung, wäre still gescheitert |
|
||||||
|
| ab 03.10.2026 | `proxy`, `portainer` und `cdn` laufen in TLS-Warnungen |
|
||||||
|
|
||||||
|
Ohne Meldung, ohne Eintrag, ohne dass jemand etwas geändert hätte.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
Container neu gestartet, **4 Sekunden** Ausfall. Danach zeigt der Dateizeiger
|
||||||
|
wieder auf die echte Datei, alle sechs Seiten antworten, und der Benutzer ließ
|
||||||
|
sich anlegen. Die Datenbank liegt vorher als
|
||||||
|
`database.sqlite.vor-neustart-2026-08-13` daneben.
|
||||||
|
|
||||||
|
### Die Lehre
|
||||||
|
|
||||||
|
**Eine Datei unter einem laufenden Dienst zu ersetzen ist etwas anderes, als
|
||||||
|
sie zu überschreiben.** Richtig wäre gewesen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec <container> sh -c 'cat /pfad/neu > /data/database.sqlite' # behält die Inode
|
||||||
|
# oder
|
||||||
|
docker cp neu <container>:/data/database.sqlite && docker restart <container>
|
||||||
|
```
|
||||||
|
|
||||||
|
Und der zweite Teil derselben Lehre: der Fehler war **fünfzehn Stunden lang
|
||||||
|
unsichtbar**, weil nur Schreibvorgänge betroffen waren und niemand welche
|
||||||
|
auslöste. Der Wächter in `tools/waechter.ps1` hätte ihn nicht gefunden — er
|
||||||
|
zählt Ports und rechnet Prüfsummen, er versucht nirgends zu schreiben. Ob eine
|
||||||
|
Schreibprobe je Dienst dazugehört, steht in `ROADMAP.md` als offene Frage.
|
||||||
@@ -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:
|
||||||
@@ -3,6 +3,15 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Dasselbe fuer die Oberflaeche. `node_modules/` oben greift NUR auf oberster
|
||||||
|
# Ebene -- ohne diese Zeile wandern ein paar hundert Megabyte Windows-Pakete in
|
||||||
|
# den Bauzusammenhang und `npm ci` im Abbild baut auf ihnen auf.
|
||||||
|
ui/node_modules/
|
||||||
|
|
||||||
|
# Der Build der Oberflaeche vom Entwicklungsrechner. Im Abbild entsteht er neu
|
||||||
|
# (Stufe "web"); der hier waere nur eine zweite, aeltere Wahrheit.
|
||||||
|
web/
|
||||||
|
|
||||||
# Die Ablage des Dienstes. Gehoert ins Volume, nie ins Abbild.
|
# Die Ablage des Dienstes. Gehoert ins Volume, nie ins Abbild.
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
|||||||
+49
-4
@@ -26,6 +26,21 @@ COPY package.json package-lock.json ./
|
|||||||
# native Binaerdatei fuer diese Node-Version.
|
# native Binaerdatei fuer diese Node-Version.
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Oberflaeche
|
||||||
|
# Eigene Stufe, damit ein geaendertes Dashboard nicht den Server neu uebersetzt
|
||||||
|
# und umgekehrt. Was hier entsteht, ist reines HTML/CSS/JS -- im Betrieb laeuft
|
||||||
|
# davon nichts, es wird nur ausgeliefert.
|
||||||
|
FROM node:22-bookworm-slim AS web
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY ui/package.json ui/package-lock.json ./
|
||||||
|
# Hier bewusst OHNE --omit=dev: vite und tsc sind devDependencies, und ohne sie
|
||||||
|
# gibt es nichts zu bauen.
|
||||||
|
RUN npm ci
|
||||||
|
COPY ui/ ./
|
||||||
|
# vite.config.ts schreibt nach ../web, also /web. Wer das dort aendert, aendert
|
||||||
|
# die Zeile im Betriebsabbild mit.
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# -------------------------------------------------------------------- Bauen
|
# -------------------------------------------------------------------- Bauen
|
||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -40,14 +55,44 @@ FROM node:22-bookworm-slim AS runtime
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ffmpeg -- fuer EIN EINZIGES Bild je Video, und trotzdem die groesste einzelne
|
||||||
|
# Zutat dieses Abbilds.
|
||||||
|
#
|
||||||
|
# DIE ABWAEGUNG, ausgesprochen: eine Kachel ohne Vorschau ist ein Fragezeichen.
|
||||||
|
# Bei 3600 Bildern faellt das nicht auf, bei einer Handvoll Clips ist die
|
||||||
|
# Galerie dann eine Liste von Dateinamen -- und dafuer braucht es keine
|
||||||
|
# Galerie. Ein Standbild kann sharp nicht liefern: libvips dekodiert kein
|
||||||
|
# Video, das ist kein Einstellungsproblem.
|
||||||
|
#
|
||||||
|
# Gerechnet wird NUR beim Hochladen und beim Nachziehen, nie beim Ausliefern.
|
||||||
|
# ffmpeg laeuft also nicht im Anfrageweg.
|
||||||
|
#
|
||||||
|
# WARUM NICHT `apt-get install ffmpeg` -- nachgemessen und wieder verworfen:
|
||||||
|
# Debians Paket schaltet jeden Filter frei und zieht die Abhaengigkeiten dafuer
|
||||||
|
# mit. Das Abbild wuchs von 460 MB auf 1,11 GB, und die groessten Brocken
|
||||||
|
# waren libllvm15 (114 MB), Mesa samt libgl1-mesa-dri (25 MB), libz3 (22 MB)
|
||||||
|
# und libflite1 (27 MB, ein SPRACHSYNTHESIZER). Alles fuer Filter, die ein
|
||||||
|
# Dienst ohne Bildschirm nie anfasst; --no-install-recommends half nicht, denn
|
||||||
|
# es sind harte Abhaengigkeiten.
|
||||||
|
#
|
||||||
|
# Also die statischen Binaerdateien, auf eine Fassung FESTGENAGELT. Der Preis
|
||||||
|
# dafuer ist ehrlich zu benennen: Aktualisierungen kommen nicht mehr ueber apt,
|
||||||
|
# sondern nur, wenn jemand diese Zeile anfasst. Vertretbar, weil ffmpeg hier
|
||||||
|
# nur eigene, bereits abgelegte Dateien anfasst und nicht am Netz haengt.
|
||||||
|
# NUR ffmpeg, NICHT ffprobe: das zweite Werkzeug waegt noch einmal 135 MB und
|
||||||
|
# haette hier eine einzige Aufgabe gehabt -- die Laenge zu nennen. Die steht
|
||||||
|
# auch im Kopf, den ffmpeg beim Oeffnen druckt; nachgemessen an beiden Dateien,
|
||||||
|
# auf zwei Nachkommastellen identisch (siehe medienDauer in storage.ts).
|
||||||
|
COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /usr/local/bin/
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
# Die gebaute Oberflaeche kaeme nach ./web -- der Dienst liefert sie aus, wenn
|
# Die gebaute Oberflaeche. Der Dienst liefert sie aus, wenn sie da ist, und
|
||||||
# sie da ist, und sagt sonst, dass sie fehlt (siehe index.ts). Solange es sie
|
# sagt sonst, dass sie fehlt (siehe index.ts) -- diese Auskunft bleibt als
|
||||||
# nicht gibt, bleibt der Ordner leer statt zu fehlen.
|
# Netz, falls die Stufe oben je leer ausgeht.
|
||||||
RUN mkdir -p /app/web
|
COPY --from=web /web ./web
|
||||||
|
|
||||||
# Ein Ordner fuer Bilder UND media.db, damit eine Sicherung aus genau einem
|
# Ein Ordner fuer Bilder UND media.db, damit eine Sicherung aus genau einem
|
||||||
# Kopiervorgang ueber ein Volume besteht.
|
# Kopiervorgang ueber ein Volume besteht.
|
||||||
|
|||||||
Generated
+623
-1
@@ -10,7 +10,9 @@
|
|||||||
"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"
|
"fflate": "^0.8.3",
|
||||||
|
"hono": "^4.6.14",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
@@ -19,6 +21,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 +485,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",
|
||||||
@@ -667,6 +1227,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -893,6 +1459,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 +1599,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",
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@
|
|||||||
"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"
|
"fflate": "^0.8.3",
|
||||||
|
"hono": "^4.6.14",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
|||||||
+90
-7
@@ -50,6 +50,13 @@ export function createSession(userId: number): { id: string; expiresAt: number }
|
|||||||
return { id, expiresAt }
|
return { id, expiresAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wann jemand zuletzt da war -- hoechstens einmal je Stunde geschrieben.
|
||||||
|
*
|
||||||
|
* Bei jedem Aufruf zu schreiben waere ein UPDATE je Anfrage, und das Dashboard
|
||||||
|
* stellt beim Blaettern durch 3600 Bilder eine Menge davon. Auf die Stunde
|
||||||
|
* genau reicht fuer die Frage "benutzt das noch jemand" vollauf. */
|
||||||
|
const STUNDE = 3600_000
|
||||||
|
|
||||||
export function userForSession(sessionId: string | undefined): User | null {
|
export function userForSession(sessionId: string | undefined): User | null {
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
const row = db
|
const row = db
|
||||||
@@ -59,7 +66,39 @@ export function userForSession(sessionId: string | undefined): User | null {
|
|||||||
WHERE s.id = ? AND s.expires_at > ?`,
|
WHERE s.id = ? AND s.expires_at > ?`,
|
||||||
)
|
)
|
||||||
.get(sessionId, now()) as User | undefined
|
.get(sessionId, now()) as User | undefined
|
||||||
return row ?? null
|
if (!row) return null
|
||||||
|
|
||||||
|
const jetzt = now()
|
||||||
|
if (!row.zuletzt_da || jetzt - row.zuletzt_da > STUNDE) {
|
||||||
|
db.prepare('UPDATE users SET zuletzt_da = ? WHERE id = ?').run(jetzt, row.id)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Sitzungen eines Benutzers beenden -- ausser der laufenden.
|
||||||
|
*
|
||||||
|
* WOZU: wer sich an einem fremden Rechner angemeldet hat und es spaeter merkt,
|
||||||
|
* hatte bisher keine Moeglichkeit, das zurueckzunehmen. Das Passwort zu
|
||||||
|
* aendern half nicht -- die Sitzungen haengen an einer eigenen Tabelle und
|
||||||
|
* ueberleben es.
|
||||||
|
*
|
||||||
|
* Die EIGENE bleibt stehen: wer auf den Knopf drueckt, will sich nicht selbst
|
||||||
|
* aussperren. */
|
||||||
|
export function destroyOtherSessions(userId: number, behalten: string): number {
|
||||||
|
const r = db
|
||||||
|
.prepare('DELETE FROM sessions WHERE user_id = ? AND id <> ?')
|
||||||
|
.run(userId, behalten)
|
||||||
|
return r.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Sitzungen gerade offen sind. Abgelaufene zaehlen nicht mit --
|
||||||
|
* sie sind nur noch nicht weggeraeumt. */
|
||||||
|
export function countSessions(userId: number): number {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND expires_at > ?')
|
||||||
|
.get(userId, now()) as { n: number }
|
||||||
|
).n
|
||||||
}
|
}
|
||||||
|
|
||||||
export function destroySession(sessionId: string) {
|
export function destroySession(sessionId: string) {
|
||||||
@@ -85,12 +124,22 @@ export function tokenHash(token: string): string {
|
|||||||
* bekommt. */
|
* bekommt. */
|
||||||
export function tokenFromHeader(header: string | undefined): Token | null {
|
export function tokenFromHeader(header: string | undefined): Token | null {
|
||||||
if (!header) return null
|
if (!header) return null
|
||||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
|
|
||||||
if (!match?.[1]) return null
|
// "Bearer xyz" ODER nur "xyz" -- und das zweite ist nicht Bequemlichkeit,
|
||||||
|
// sondern Vertraeglichkeit: Fivemanage nimmt den Schluessel NACKT im
|
||||||
|
// Authorization-Kopf entgegen, und genau so steht er in jedem FiveM-Skript,
|
||||||
|
// das dorthin hochlaedt (nachgesehen im SDK und in Devm-Camera). Wer von
|
||||||
|
// dort umzieht, soll nur die Adresse tauschen muessen.
|
||||||
|
//
|
||||||
|
// Ein Risiko ist das keines: geprueft wird ohnehin der Hash gegen die
|
||||||
|
// Tabelle. Ein Kopf, der zu keinem Token gehoert, faellt so oder so durch.
|
||||||
|
const roh = header.trim()
|
||||||
|
const wert = /^Bearer\s+(.+)$/i.exec(roh)?.[1] ?? roh
|
||||||
|
if (!wert) return null
|
||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT * FROM tokens WHERE hash = ?')
|
.prepare('SELECT * FROM tokens WHERE hash = ?')
|
||||||
.get(tokenHash(match[1])) as Token | undefined
|
.get(tokenHash(wert)) as Token | undefined
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
|
||||||
db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id)
|
db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id)
|
||||||
@@ -98,10 +147,44 @@ export function tokenFromHeader(header: string | undefined): Token | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Darf dieser Token auf diesen Pfad schreiben? */
|
/** Darf dieser Token auf diesen Pfad schreiben? */
|
||||||
|
/**
|
||||||
|
* Die Fesseln eines Tokens als Liste.
|
||||||
|
*
|
||||||
|
* MEHRERE STATT EINER, seit dem 23.08.2026. Das Fotostudio schreibt nach
|
||||||
|
* `vehicles/` UND `items/`, und mit einer einzigen Fessel blieb nur die Wahl
|
||||||
|
* zwischen "einer davon" und "darf ueberall". Beides war falsch: das eine
|
||||||
|
* lehnt die Haelfte der Uploads mit 403 ab, das andere erlaubt auch
|
||||||
|
* `sicherungen/`.
|
||||||
|
*
|
||||||
|
* Die Spalte bleibt, was sie war -- ein Textfeld. Mehrere Werte stehen durch
|
||||||
|
* Komma getrennt darin. Damit gilt jeder BESTEHENDE Token unveraendert
|
||||||
|
* weiter: ein Wert ohne Komma ist eine Liste mit einem Eintrag.
|
||||||
|
*/
|
||||||
|
export function tokenPrefixes(token: Pick<Token, 'prefix'>): string[] {
|
||||||
|
return (token.prefix ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim().replace(/^\/+|\/+$/g, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Fesseln als Satzstueck fuer eine Fehlermeldung.
|
||||||
|
*
|
||||||
|
* Aus der rohen Spalte wuerde sonst `"vehicles,items/"` -- ein Ordner, den es
|
||||||
|
* nicht gibt. Wer eine 403 bekommt, soll lesen koennen, wohin er DARF. */
|
||||||
|
export function fesselText(token: Pick<Token, 'prefix'>): string {
|
||||||
|
const f = tokenPrefixes(token)
|
||||||
|
if (f.length === 0) return 'ueberall'
|
||||||
|
if (f.length === 1) return `"${f[0]}/"`
|
||||||
|
return f.map((p) => `"${p}/"`).join(' oder ')
|
||||||
|
}
|
||||||
|
|
||||||
export function tokenAllows(token: Token, path: string): boolean {
|
export function tokenAllows(token: Token, path: string): boolean {
|
||||||
if (!token.prefix) return true
|
const fesseln = tokenPrefixes(token)
|
||||||
const prefix = token.prefix.replace(/\/+$/, '') + '/'
|
// Keine Fessel heisst ueberall -- so war es, so bleibt es.
|
||||||
return path.startsWith(prefix)
|
if (fesseln.length === 0) return true
|
||||||
|
// Das `/` gehoert dazu: ohne es wuerde "vehicles" auch "vehiclesXYZ/a.png"
|
||||||
|
// durchlassen. Stand schon vorher so da und bleibt.
|
||||||
|
return fesseln.some((p) => path.startsWith(p + '/'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------- Start
|
// -------------------------------------------------------------------- Start
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -62,13 +62,274 @@ CREATE TABLE IF NOT EXISTS media (
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC);
|
CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC);
|
||||||
|
|
||||||
|
-- Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht.
|
||||||
|
--
|
||||||
|
-- WARUM EINE EIGENE TABELLE und nicht einfach media.created_at. Drei Gruende,
|
||||||
|
-- und jeder allein wuerde reichen:
|
||||||
|
--
|
||||||
|
-- 1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg --
|
||||||
|
-- samt der Frage, wer es geloescht hat.
|
||||||
|
-- 2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
|
||||||
|
-- Auskunft, wann die Datei urspruenglich kam.
|
||||||
|
-- 3. media.token_id ist bei einem Upload aus dem Dashboard NULL: dort hat
|
||||||
|
-- niemand einen Token, sondern eine Sitzung. Das "von wem" stand also
|
||||||
|
-- nirgends.
|
||||||
|
--
|
||||||
|
-- Das ist NICHT das Logging, das in der ROADMAP abgelehnt wird -- dort geht es
|
||||||
|
-- um Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je
|
||||||
|
-- Upload in derselben SQLite-Datei.
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
at INTEGER NOT NULL,
|
||||||
|
-- 'upload' | 'replace' | 'delete'
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
-- 'token' | 'user' | 'unbekannt'
|
||||||
|
actor_kind TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS events_at ON events(at DESC);
|
||||||
|
|
||||||
|
-- Einstellungen, die zur Laufzeit aenderbar sein sollen.
|
||||||
|
--
|
||||||
|
-- Bewusst NICHT alles: was den Dienst ueberhaupt hochfahren laesst (PUBLIC_URL,
|
||||||
|
-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den
|
||||||
|
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
|
||||||
|
-- verstellbar sein soll -- derzeit die Discord-Anmeldung.
|
||||||
|
-- Der Papierkorb.
|
||||||
|
--
|
||||||
|
-- Loeschen war bis hierhin endgueltig: die Datei war von der Platte weg, und
|
||||||
|
-- die Sicherung half nur bis zum letzten naechtlichen Lauf. Bei einem Knopf
|
||||||
|
-- "alle 3670 waehlen" direkt neben "loeschen" ist das die falsche Haerte.
|
||||||
|
--
|
||||||
|
-- Die Datei wandert deshalb nach /data/papierkorb und der Datensatz hierher.
|
||||||
|
-- Was hier steht, ist alles, was zum Zurueckholen noetig ist -- der Pfad ist
|
||||||
|
-- der Schluessel, und den behalten wir.
|
||||||
|
CREATE TABLE IF NOT EXISTS papierkorb (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
geloescht INTEGER NOT NULL,
|
||||||
|
von TEXT NOT NULL,
|
||||||
|
-- Der Name IM Papierkorb. Nicht der Pfad: zwei Dateien koennen nacheinander
|
||||||
|
-- unter demselben Pfad gelegen haben, und beide sollen zurueckholbar sein.
|
||||||
|
ablage TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
|
||||||
|
|
||||||
|
-- Freigaben: ein Ordner, den man ohne Anmeldung ansehen kann.
|
||||||
|
--
|
||||||
|
-- WOZU. Die Dateien selbst sind ohnehin oeffentlich -- wer die Adresse kennt,
|
||||||
|
-- bekommt das Bild. Was NICHT oeffentlich ist, ist die LISTE: welche Bilder es
|
||||||
|
-- gibt. Genau die will man manchmal zeigen (ein Fahrzeugkatalog fuer Spieler)
|
||||||
|
-- und sonst nicht.
|
||||||
|
--
|
||||||
|
-- Der Schluessel ist eine Zufallszeichenkette in der Adresse und kein Passwort:
|
||||||
|
-- wer den Link hat, sieht die Liste. Das ist dieselbe Zusicherung wie bei einem
|
||||||
|
-- Nextcloud-Freigabelink, und sie steht so auch im Dashboard -- damit niemand
|
||||||
|
-- glaubt, hier haenge eine Anmeldung dran.
|
||||||
|
CREATE TABLE IF NOT EXISTS freigaben (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
schluessel TEXT NOT NULL UNIQUE,
|
||||||
|
ordner TEXT NOT NULL,
|
||||||
|
titel TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
-- Nicht "wie oft geoeffnet" als Selbstzweck: es beantwortet die Frage, ob
|
||||||
|
-- ein alter Link noch benutzt wird, bevor man ihn wegwirft.
|
||||||
|
aufrufe INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zuletzt INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS freigaben_ordner ON freigaben(ordner);
|
||||||
|
|
||||||
|
-- Was jemand VERWALTET hat -- Einstellungen, Token, Konten, Freigaben.
|
||||||
|
--
|
||||||
|
-- EINE EIGENE TABELLE und nicht events, obwohl beides ein Verlauf ist. Der
|
||||||
|
-- Grund steht in den Spalten: events traegt path und size und beantwortet
|
||||||
|
-- "was ist mit den DATEIEN passiert". Wer dort eine Einstellungsaenderung
|
||||||
|
-- hineinschriebe, muesste path mit etwas fuellen, das kein Pfad ist -- und
|
||||||
|
--
|
||||||
|
-- (Und in diesem Kommentar stehen keine Backticks, obwohl sie sich hier
|
||||||
|
-- anboeten: das Schema liegt in einem Template-Literal, und ein Backtick
|
||||||
|
-- beendet es. Der Uebersetzer hat es gefangen, der Browser haette es nicht.)
|
||||||
|
-- danach filtert jede Ansicht, die Dateien zeigen will, den Fremdkoerper wieder
|
||||||
|
-- heraus.
|
||||||
|
--
|
||||||
|
-- WAS HIER NIE HINEINGEHOERT: der WERT einer Einstellung. Unter den
|
||||||
|
-- Schluesseln stehen das Discord-Geheimnis und das Nextcloud-Passwort. Ein
|
||||||
|
-- Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort eine
|
||||||
|
-- Tabelle mit allen Passwoertern, die es je gab. Aufgeschrieben wird deshalb
|
||||||
|
-- WELCHER Schluessel angefasst wurde, nicht womit.
|
||||||
|
CREATE TABLE IF NOT EXISTS verwaltung (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
at INTEGER NOT NULL,
|
||||||
|
wer TEXT NOT NULL,
|
||||||
|
-- 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
|
||||||
|
bereich TEXT NOT NULL,
|
||||||
|
-- 'angelegt' | 'geaendert' | 'entfernt'
|
||||||
|
tat TEXT NOT NULL,
|
||||||
|
ziel TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Kurz und ohne Werte: "webhook, anlaesse" oder "quota_bytes, arten".
|
||||||
|
was TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS verwaltung_zeit ON verwaltung(at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Nachtraegliche Spalten. SQLite kann kein "ADD COLUMN IF NOT EXISTS", also
|
||||||
|
// wird nachgesehen — das ist die ganze Wanderung, die dieses Projekt braucht.
|
||||||
|
{
|
||||||
|
const spalten = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]
|
||||||
|
if (!spalten.some((s) => s.name === 'discord_id')) {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN discord_id TEXT')
|
||||||
|
// Kein UNIQUE in der Spaltendefinition: SQLite erlaubt das bei ADD
|
||||||
|
// COLUMN nicht. Der Index danach leistet dasselbe.
|
||||||
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)')
|
||||||
|
console.log('[db] Spalte users.discord_id angelegt')
|
||||||
|
}
|
||||||
|
// Der Bildstempel, NICHT die fertige Adresse: Discord baut die URL aus ID
|
||||||
|
// und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe
|
||||||
|
// avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des
|
||||||
|
// Schemas eine Baustelle in jeder Zeile.
|
||||||
|
// Grenzen je Token. Sie beantworten die Frage, die "Token-Arten" nur
|
||||||
|
// scheinbar beantwortet: was passiert, wenn DIESER Token abhandenkommt.
|
||||||
|
// Das GESAMTKONTINGENT, nicht die Groesse EINER Datei -- max_bytes gab es
|
||||||
|
// laengst, aber es galt je Upload. Ein Token mit "hoechstens 2 MB je Bild"
|
||||||
|
// konnte trotzdem die Platte fuellen, es brauchte nur genug Bilder.
|
||||||
|
const kSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
|
||||||
|
// Groesste erlaubte Kantenlaenge fuer Bilder ueber diesen Token. Null =
|
||||||
|
// unveraendert ablegen.
|
||||||
|
if (!kSpalten.some((s) => s.name === 'max_kante')) {
|
||||||
|
db.exec('ALTER TABLE tokens ADD COLUMN max_kante INTEGER')
|
||||||
|
console.log('[db] Spalte tokens.max_kante angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kSpalten.some((s) => s.name === 'quota_bytes')) {
|
||||||
|
db.exec('ALTER TABLE tokens ADD COLUMN quota_bytes INTEGER')
|
||||||
|
console.log('[db] Spalte tokens.quota_bytes angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[]
|
||||||
|
if (!tSpalten.some((s) => s.name === 'max_bytes')) {
|
||||||
|
db.exec('ALTER TABLE tokens ADD COLUMN max_bytes INTEGER')
|
||||||
|
// Komma-getrennte Artnamen ('bild,video'). Leer heisst: alles, was der
|
||||||
|
// Dienst kennt.
|
||||||
|
db.exec("ALTER TABLE tokens ADD COLUMN arten TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.exec('ALTER TABLE tokens ADD COLUMN expires_at INTEGER')
|
||||||
|
console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[]
|
||||||
|
if (!eSpalten.some((s) => s.name === 'von')) {
|
||||||
|
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
|
||||||
|
// Spalte stuende im Verlauf nur das Ziel, und die Frage "wo war die
|
||||||
|
// vorher" waere nicht mehr zu beantworten.
|
||||||
|
db.exec('ALTER TABLE events ADD COLUMN von TEXT')
|
||||||
|
console.log('[db] Spalte events.von angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abrufe. Die Frage "was braucht eigentlich noch jemand" laesst sich von
|
||||||
|
// innen nicht beantworten -- von aussen schon: wer wird geholt, wie oft,
|
||||||
|
// wann zuletzt. Das ist die Zahl, die dem Aufbewahrungsbericht fehlt.
|
||||||
|
// Die Laenge in Sekunden -- nur bei Video und Ton gefuellt. Beantwortet
|
||||||
|
// auf der Kachel die Frage, die ein Standbild nicht beantwortet: WIE LANG.
|
||||||
|
const dSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
|
||||||
|
if (!dSpalten.some((s) => s.name === 'dauer')) {
|
||||||
|
db.exec('ALTER TABLE media ADD COLUMN dauer REAL')
|
||||||
|
console.log('[db] Spalte media.dauer angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
|
||||||
|
if (!mSpalten.some((s) => s.name === 'abrufe')) {
|
||||||
|
db.exec('ALTER TABLE media ADD COLUMN abrufe INTEGER NOT NULL DEFAULT 0')
|
||||||
|
db.exec('ALTER TABLE media ADD COLUMN zuletzt_abgerufen INTEGER')
|
||||||
|
console.log('[db] Spalten media.abrufe und zuletzt_abgerufen angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wann war der zuletzt da? Steht sonst nirgends: sessions weiss es, aber
|
||||||
|
// eine abgelaufene Sitzung ist weg, und dann sieht ein Konto, das seit
|
||||||
|
// einem halben Jahr niemand benutzt, aus wie eines von gestern.
|
||||||
|
if (!spalten.some((s) => s.name === 'zuletzt_da')) {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN zuletzt_da INTEGER')
|
||||||
|
console.log('[db] Spalte users.zuletzt_da angelegt')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spalten.some((s) => s.name === 'discord_avatar')) {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT')
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT')
|
||||||
|
console.log('[db] Spalten users.discord_avatar und users.discord_name angelegt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur
|
||||||
|
// wenn er leer ist.
|
||||||
|
//
|
||||||
|
// Ohne das stuende der Tab am ersten Tag leer da, obwohl 46 Bilder in der
|
||||||
|
// Ablage liegen; das sieht aus wie ein kaputter Tab. Die nachgetragenen
|
||||||
|
// Eintraege sind ehrlich als solche gekennzeichnet: sie tragen den
|
||||||
|
// urspruenglichen Zeitpunkt, aber ueber Loeschungen und Ersetzungen von
|
||||||
|
// frueher weiss niemand mehr etwas.
|
||||||
|
{
|
||||||
|
const vorhanden = db.prepare('SELECT COUNT(*) AS n FROM events').get() as { n: number }
|
||||||
|
const medien = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
|
||||||
|
|
||||||
|
if (vorhanden.n === 0 && medien.n > 0) {
|
||||||
|
const nachtragen = db.prepare(
|
||||||
|
`INSERT INTO events (at, kind, path, size, actor_kind, actor)
|
||||||
|
SELECT m.created_at, 'upload', m.path, m.size,
|
||||||
|
CASE WHEN t.name IS NULL THEN 'unbekannt' ELSE 'token' END,
|
||||||
|
COALESCE(t.name, 'vor der Aufzeichnung')
|
||||||
|
FROM media m LEFT JOIN tokens t ON t.id = m.token_id`,
|
||||||
|
)
|
||||||
|
const info = nachtragen.run()
|
||||||
|
console.log(`[db] Verlauf aus dem Bestand nachgetragen: ${info.changes} Eintraege`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
|
zuletzt_da?: number | null
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
password_hash: string
|
password_hash: string
|
||||||
created_at: number
|
created_at: number
|
||||||
|
/** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto
|
||||||
|
* hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */
|
||||||
|
discord_id: string | null
|
||||||
|
/** Der Bildstempel von Discord, nicht die Adresse. Null heisst: kein
|
||||||
|
* eigenes Bild, dann gilt Discords Ersatzbild. */
|
||||||
|
discord_avatar: string | null
|
||||||
|
discord_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Adresse des Discord-Bildes — an EINER Stelle gebaut.
|
||||||
|
*
|
||||||
|
* Faengt der Stempel mit "a_" an, ist es ein bewegtes Bild und muss als .gif
|
||||||
|
* angefragt werden; sonst kommt ein Standbild. Ohne eigenen Stempel gibt es
|
||||||
|
* Discords Ersatzbild, dessen Nummer sich aus der ID ergibt (die neue
|
||||||
|
* Rechnung mit >> 22, nicht die alte ueber den Diskriminator).
|
||||||
|
*
|
||||||
|
* Null nur, wenn gar keine Discord-ID da ist — dann zeigt die Oberflaeche
|
||||||
|
* Initialen. */
|
||||||
|
export function avatarUrl(
|
||||||
|
discordId: string | null,
|
||||||
|
stempel: string | null,
|
||||||
|
groesse = 64,
|
||||||
|
): string | null {
|
||||||
|
if (!discordId) return null
|
||||||
|
if (stempel) {
|
||||||
|
const endung = stempel.startsWith('a_') ? 'gif' : 'png'
|
||||||
|
return `https://cdn.discordapp.com/avatars/${discordId}/${stempel}.${endung}?size=${groesse}`
|
||||||
|
}
|
||||||
|
const nummer = Number((BigInt(discordId) >> 22n) % 6n)
|
||||||
|
return `https://cdn.discordapp.com/embed/avatars/${nummer}.png`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Token = {
|
export type Token = {
|
||||||
@@ -79,6 +340,18 @@ export type Token = {
|
|||||||
can_delete: number
|
can_delete: number
|
||||||
created_at: number
|
created_at: number
|
||||||
last_used_at: number | null
|
last_used_at: number | null
|
||||||
|
/** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des
|
||||||
|
* Dienstes gilt (MAX_UPLOAD_MB). */
|
||||||
|
/** Groesste erlaubte EINZELNE Datei. */
|
||||||
|
max_bytes: number | null
|
||||||
|
/** Wieviel dieser Token INSGESAMT halten darf. Null = so viel er will. */
|
||||||
|
quota_bytes: number | null
|
||||||
|
/** Groesste Kantenlaenge fuer Bilder. Null = ablegen, wie es kommt. */
|
||||||
|
max_kante: number | null
|
||||||
|
/** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */
|
||||||
|
arten: string
|
||||||
|
/** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */
|
||||||
|
expires_at: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Media = {
|
export type Media = {
|
||||||
@@ -90,10 +363,171 @@ export type Media = {
|
|||||||
token_id: number | null
|
token_id: number | null
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
|
/** Wie oft die Datei oeffentlich geholt wurde. */
|
||||||
|
abrufe: number
|
||||||
|
zuletzt_abgerufen: number | null
|
||||||
|
/** Laenge in Sekunden — nur bei Video und Ton, sonst null. */
|
||||||
|
dauer: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Freigabe = {
|
||||||
|
id: number
|
||||||
|
schluessel: string
|
||||||
|
ordner: string
|
||||||
|
titel: string
|
||||||
|
created_at: number
|
||||||
|
aufrufe: number
|
||||||
|
zuletzt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BenutzerZeile = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
created_at: number
|
||||||
|
zuletzt_da: number | null
|
||||||
|
discord_id: string | null
|
||||||
|
discord_name: string | null
|
||||||
|
discord_avatar: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerwaltungZeile = {
|
||||||
|
id: number
|
||||||
|
at: number
|
||||||
|
wer: string
|
||||||
|
bereich: string
|
||||||
|
tat: string
|
||||||
|
ziel: string
|
||||||
|
was: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PapierkorbEintrag = {
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
mime: string
|
||||||
|
geloescht: number
|
||||||
|
von: string
|
||||||
|
ablage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'rename' steht neben 'move', obwohl es technisch dasselbe ist: eine Datei
|
||||||
|
// bekommt einen anderen Pfad. Fuer den, der den Verlauf LIEST, ist es aber
|
||||||
|
// etwas anderes -- verschoben heisst "liegt woanders", umbenannt heisst "heisst
|
||||||
|
// anders, liegt noch da". Ein Verlauf, der beides "verschoben" nennt, laesst
|
||||||
|
// genau die Frage offen, wegen der man ihn aufschlaegt.
|
||||||
|
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
|
||||||
|
|
||||||
|
export type MediaEvent = {
|
||||||
|
id: number
|
||||||
|
at: number
|
||||||
|
kind: EventKind
|
||||||
|
path: string
|
||||||
|
size: number | null
|
||||||
|
actor_kind: 'token' | 'user' | 'unbekannt'
|
||||||
|
actor: string
|
||||||
|
/** Bei 'move' und 'rename': der Pfad, unter dem die Datei vorher lag. */
|
||||||
|
von: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const now = () => Date.now()
|
export const now = () => Date.now()
|
||||||
|
|
||||||
|
const eventEinfuegen = db.prepare(
|
||||||
|
`INSERT INTO events (at, kind, path, size, actor_kind, actor, von)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Einen Eintrag in den Verlauf schreiben.
|
||||||
|
*
|
||||||
|
* Wirft NICHT: ein Verlauf, der einen Upload scheitern laesst, waere die
|
||||||
|
* Buchhaltung, die das Geschaeft verhindert. Wenn hier etwas schiefgeht,
|
||||||
|
* steht es in der Konsole und die Datei liegt trotzdem richtig. */
|
||||||
|
export function logEvent(
|
||||||
|
kind: EventKind,
|
||||||
|
path: string,
|
||||||
|
size: number | null,
|
||||||
|
actorKind: MediaEvent['actor_kind'],
|
||||||
|
actor: string,
|
||||||
|
von?: string,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
eventEinfuegen.run(now(), kind, path, size, actorKind, actor, von ?? null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[events] konnte nicht schreiben:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das
|
||||||
|
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
|
||||||
|
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
|
||||||
|
// -- Abrufe zaehlen, ohne die Auslieferung zu bremsen -------------------------
|
||||||
|
//
|
||||||
|
// Ein UPDATE je Bildabruf waere bei einer Galerieseite mit 120 Kacheln ein
|
||||||
|
// Schreibvorgang je Kachel. Stattdessen wird im Speicher gezaehlt und
|
||||||
|
// gelegentlich weggeschrieben -- wer wissen will, ob eine Datei benutzt wird,
|
||||||
|
// braucht keine Genauigkeit auf die Sekunde.
|
||||||
|
//
|
||||||
|
// Was bei einem Absturz verlorengeht, sind hoechstens 30 Sekunden Zaehlerei.
|
||||||
|
|
||||||
|
const offeneAbrufe = new Map<string, { anzahl: number; zuletzt: number }>()
|
||||||
|
|
||||||
|
export function zaehleAbruf(path: string): void {
|
||||||
|
const bisher = offeneAbrufe.get(path)
|
||||||
|
if (bisher) {
|
||||||
|
bisher.anzahl++
|
||||||
|
bisher.zuletzt = now()
|
||||||
|
} else {
|
||||||
|
offeneAbrufe.set(path, { anzahl: 1, zuletzt: now() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const abrufSchreiben = db.prepare(
|
||||||
|
'UPDATE media SET abrufe = abrufe + ?, zuletzt_abgerufen = ? WHERE path = ?',
|
||||||
|
)
|
||||||
|
|
||||||
|
export function schreibeAbrufe(): void {
|
||||||
|
if (offeneAbrufe.size === 0) return
|
||||||
|
const stapel = [...offeneAbrufe.entries()]
|
||||||
|
offeneAbrufe.clear()
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const [path, w] of stapel) abrufSchreiben.run(w.anzahl, w.zuletzt, path)
|
||||||
|
})()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[abrufe] konnten nicht geschrieben werden:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Verwaltungshandlung festhalten.
|
||||||
|
*
|
||||||
|
* Wirft NIE: ein Verlauf, der die Handlung verhindert, ueber die er berichten
|
||||||
|
* soll, waere die falsche Reihenfolge. Faellt er aus, steht die Zeile eben
|
||||||
|
* nicht da -- die Aenderung selbst ist trotzdem passiert. */
|
||||||
|
export function logVerwaltung(
|
||||||
|
wer: string,
|
||||||
|
bereich: string,
|
||||||
|
tat: string,
|
||||||
|
ziel = '',
|
||||||
|
was = '',
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO verwaltung (at, wer, bereich, tat, ziel, was) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
).run(now(), wer, bereich, tat, ziel, was)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[verwaltung] nicht geschrieben:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dieselbe Frist wie beim Datei-Verlauf. */
|
||||||
|
export function pruneVerwaltung(tage = 180) {
|
||||||
|
return db.prepare('DELETE FROM verwaltung WHERE at < ?').run(now() - tage * 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneEvents(tage = 180) {
|
||||||
|
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich —
|
/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich —
|
||||||
* sonst waechst die Tabelle ewig, und niemand merkt es. */
|
* sonst waechst die Tabelle ewig, und niemand merkt es. */
|
||||||
export function pruneSessions() {
|
export function pruneSessions() {
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Eine Datei von einer fremden Adresse holen.
|
||||||
|
//
|
||||||
|
// WOZU. Ein einzelnes Bild von woanders zu uebernehmen hiess bisher:
|
||||||
|
// herunterladen, im Dateimanager wiederfinden, ins Fenster ziehen. Drei
|
||||||
|
// Schritte fuer etwas, das der Dienst selbst tun kann.
|
||||||
|
//
|
||||||
|
// UND DAS IST DIE GEFAEHRLICHSTE FUNKTION IN DIESEM DIENST, deshalb steht sie
|
||||||
|
// in einer eigenen Datei. Ein Server, der eine vom Benutzer genannte Adresse
|
||||||
|
// abruft, ist ein Angriff mit eigenem Namen: SSRF. Wer ihn dazu bringt,
|
||||||
|
//
|
||||||
|
// http://127.0.0.1:9101/api/dash/... die eigene API von innen
|
||||||
|
// http://169.254.169.254/latest/meta/ die Zugangsdaten der Cloud
|
||||||
|
// http://192.168.2.1/ den Router im Heimnetz
|
||||||
|
//
|
||||||
|
// abzurufen, laesst den Dienst Dinge lesen, die von aussen unerreichbar sind --
|
||||||
|
// und bekommt sie als Datei zurueck, unter einer oeffentlichen Adresse.
|
||||||
|
//
|
||||||
|
// Dass hier eine Anmeldung davorsteht, ist KEIN Ersatz fuer die Pruefung. Es
|
||||||
|
// gibt zwei Konten, eines davon entsteht ueber eine Discord-Rolle, die jemand
|
||||||
|
// anders vergibt.
|
||||||
|
|
||||||
|
import { lookup } from 'node:dns/promises'
|
||||||
|
import { isIP } from 'node:net'
|
||||||
|
|
||||||
|
/** Wie lange der Dienst auf eine fremde Adresse wartet. */
|
||||||
|
const FRIST_MS = 20_000
|
||||||
|
|
||||||
|
/** Wie oft einer Umleitung gefolgt wird.
|
||||||
|
*
|
||||||
|
* Und JEDE davon wird neu geprueft -- das ist der Kern. Ein Server kann mit
|
||||||
|
* 302 auf http://127.0.0.1 zeigen, und `fetch` folgt von sich aus. Deshalb
|
||||||
|
* steht unten `redirect: 'manual'`: die Kette wird selbst gelaufen, damit
|
||||||
|
* jeder Halt durch dieselbe Pruefung geht wie der erste.
|
||||||
|
*/
|
||||||
|
const UMLEITUNGEN_MAX = 3
|
||||||
|
|
||||||
|
export class HolFehler extends Error {}
|
||||||
|
|
||||||
|
/** Adressbereiche, die von hier aus niemanden etwas angehen.
|
||||||
|
*
|
||||||
|
* Geprueft wird die AUFGELOESTE IP und nicht der Name: `localtest.me` zeigt
|
||||||
|
* auf 127.0.0.1, und ein eigener DNS-Eintrag kann auf alles zeigen. Ein
|
||||||
|
* Namensfilter waere ein Filter fuer den, der ihn nicht umgehen will. */
|
||||||
|
function istPrivat(ip: string): boolean {
|
||||||
|
if (isIP(ip) === 6) {
|
||||||
|
const v = ip.toLowerCase()
|
||||||
|
if (v === '::1' || v === '::') return true
|
||||||
|
// fc00::/7 (eindeutig lokal) und fe80::/10 (verbindungslokal)
|
||||||
|
if (/^f[cd]/.test(v) || /^fe[89ab]/.test(v)) return true
|
||||||
|
// IPv4 in IPv6-Schreibweise: ::ffff:127.0.0.1
|
||||||
|
const eingebettet = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v)
|
||||||
|
if (eingebettet?.[1]) return istPrivat(eingebettet[1])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = ip.split('.').map(Number)
|
||||||
|
if (t.length !== 4 || t.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true
|
||||||
|
const [a, b] = t as [number, number, number, number]
|
||||||
|
|
||||||
|
if (a === 0) return true // dieses Netz
|
||||||
|
if (a === 10) return true // privat
|
||||||
|
if (a === 127) return true // Rueckschleife
|
||||||
|
if (a === 169 && b === 254) return true // verbindungslokal UND Cloud-Metadaten
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true // privat
|
||||||
|
if (a === 192 && b === 168) return true // privat
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true // Provider-internes NAT
|
||||||
|
if (a >= 224) return true // Rundruf und reserviert
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Adresse pruefen: Schema, Anschluss, aufgeloeste IP. Wirft HolFehler. */
|
||||||
|
async function pruefeZiel(roh: string): Promise<URL> {
|
||||||
|
let u: URL
|
||||||
|
try {
|
||||||
|
u = new URL(roh)
|
||||||
|
} catch {
|
||||||
|
throw new HolFehler('Das ist keine gueltige Adresse.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||||
|
throw new HolFehler(`Nur http und https — "${u.protocol.replace(':', '')}" nicht.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Anschluss, der nicht der uebliche ist, deutet fast immer auf einen
|
||||||
|
// Dienst im eigenen Netz. Die Ausnahme waere ein oeffentlicher Server auf
|
||||||
|
// einem krummen Port; die ist selten genug, um sie abzulehnen.
|
||||||
|
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
|
||||||
|
if (port !== 80 && port !== 443) {
|
||||||
|
throw new HolFehler(`Nur die ueblichen Anschluesse 80 und 443 — nicht ${port}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = u.hostname.replace(/^\[|\]$/g, '')
|
||||||
|
let adressen: string[]
|
||||||
|
if (isIP(name)) {
|
||||||
|
adressen = [name]
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
adressen = (await lookup(name, { all: true })).map((a) => a.address)
|
||||||
|
} catch {
|
||||||
|
throw new HolFehler(`"${name}" laesst sich nicht aufloesen.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (adressen.length === 0) throw new HolFehler(`"${name}" hat keine Adresse.`)
|
||||||
|
|
||||||
|
// JEDE aufgeloeste Adresse muss oeffentlich sein. Ein Name mit zwei
|
||||||
|
// Eintraegen -- einer oeffentlich, einer auf 127.0.0.1 -- waere sonst ein
|
||||||
|
// Wuerfelspiel darueber, welchen Node nimmt.
|
||||||
|
for (const ip of adressen) {
|
||||||
|
if (istPrivat(ip)) {
|
||||||
|
throw new HolFehler(
|
||||||
|
`"${name}" zeigt auf ${ip} und damit ins eigene Netz. Das geht von hier aus nicht.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Geholt = { data: Buffer; mime: string; name: string; von: string }
|
||||||
|
|
||||||
|
/** Die Datei holen. `hoechstens` ist die Grenze in Bytes.
|
||||||
|
*
|
||||||
|
* Der Rumpf wird STUECKWEISE gelesen und abgebrochen, sobald die Grenze
|
||||||
|
* reisst. `content-length` allein zu glauben waere naiv -- die Angabe kommt
|
||||||
|
* vom fremden Server und muss nicht stimmen. */
|
||||||
|
export async function holeVonAdresse(roh: string, hoechstens: number): Promise<Geholt> {
|
||||||
|
let ziel = await pruefeZiel(roh)
|
||||||
|
|
||||||
|
let antwort: Response | null = null
|
||||||
|
for (let sprung = 0; sprung <= UMLEITUNGEN_MAX; sprung++) {
|
||||||
|
const res = await fetch(ziel, {
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(FRIST_MS),
|
||||||
|
headers: { 'User-Agent': 'd4rk_media/1.0 (+holen)' },
|
||||||
|
}).catch((err) => {
|
||||||
|
throw new HolFehler(
|
||||||
|
`Nicht erreichbar: ${err instanceof Error ? (err.cause as { code?: string })?.code ?? err.name : 'unbekannt'}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status >= 300 && res.status < 400) {
|
||||||
|
const wohin = res.headers.get('location')
|
||||||
|
await res.body?.cancel().catch(() => {})
|
||||||
|
if (!wohin) throw new HolFehler(`Umleitung ohne Ziel (HTTP ${res.status}).`)
|
||||||
|
if (sprung === UMLEITUNGEN_MAX) {
|
||||||
|
throw new HolFehler(`Mehr als ${UMLEITUNGEN_MAX} Umleitungen.`)
|
||||||
|
}
|
||||||
|
// Neu pruefen. GENAU HIER waere die Luecke, wenn man `fetch` selbst
|
||||||
|
// folgen liesse.
|
||||||
|
ziel = await pruefeZiel(new URL(wohin, ziel).href)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
await res.body?.cancel().catch(() => {})
|
||||||
|
throw new HolFehler(`Die Gegenstelle antwortet mit HTTP ${res.status}.`)
|
||||||
|
}
|
||||||
|
antwort = res
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!antwort) throw new HolFehler('Keine Antwort.')
|
||||||
|
|
||||||
|
const angekuendigt = Number(antwort.headers.get('content-length') ?? NaN)
|
||||||
|
if (Number.isFinite(angekuendigt) && angekuendigt > hoechstens) {
|
||||||
|
await antwort.body?.cancel().catch(() => {})
|
||||||
|
throw new HolFehler(`Zu gross: ${angekuendigt} > ${hoechstens} Bytes.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
let gesamt = 0
|
||||||
|
const leser = antwort.body?.getReader()
|
||||||
|
if (!leser) throw new HolFehler('Die Antwort hat keinen Rumpf.')
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await leser.read()
|
||||||
|
if (done) break
|
||||||
|
gesamt += value.byteLength
|
||||||
|
if (gesamt > hoechstens) {
|
||||||
|
await leser.cancel().catch(() => {})
|
||||||
|
throw new HolFehler(
|
||||||
|
`Zu gross: die Gegenstelle schickt mehr als ${hoechstens} Bytes.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stuecke.push(Buffer.from(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesamt === 0) throw new HolFehler('Die Gegenstelle hat nichts geschickt.')
|
||||||
|
|
||||||
|
// Der Name aus dem Pfad, sonst aus dem Hostnamen. Zurechtgebogen wird er
|
||||||
|
// erst beim Ablegen -- hier soll stehen, was dort stand.
|
||||||
|
const ausPfad = decodeURIComponent(ziel.pathname.split('/').pop() ?? '')
|
||||||
|
return {
|
||||||
|
data: Buffer.concat(stuecke),
|
||||||
|
mime: (antwort.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '',
|
||||||
|
name: ausPfad || ziel.hostname,
|
||||||
|
von: ziel.href,
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
-6
@@ -28,15 +28,64 @@ import { Hono } from 'hono'
|
|||||||
import { logger } from 'hono/logger'
|
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 { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js'
|
||||||
import { absolutePath, checkPath, mimeFor } from './storage.js'
|
import { pruefePlatte, pruefeSicherung } from './meldung.js'
|
||||||
|
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
|
||||||
import { uploadRoutes } from './routes/upload.js'
|
import { uploadRoutes } from './routes/upload.js'
|
||||||
import { dashRoutes } from './routes/dash.js'
|
import { fivemanageRoutes } from './routes/fivemanage.js'
|
||||||
|
import { freigabeRoutes } from './routes/freigabe.js'
|
||||||
|
import { statusRoutes } from './routes/status.js'
|
||||||
|
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
if (config.dev) app.use('*', logger())
|
if (config.dev) app.use('*', logger())
|
||||||
|
|
||||||
|
// Ein unerwarteter Fehler bekommt einen TEXT, keinen nackten 500.
|
||||||
|
//
|
||||||
|
// Genau das ist der Fehler, wegen dem dieses Projekt existiert: bei Fivemanage
|
||||||
|
// endete jeder Fehlschlag als "Internal Server Error" ohne ein Wort dazu. Wir
|
||||||
|
// haben das an den bekannten Stellen einzeln abgefangen -- und prompt hat ein
|
||||||
|
// Tippfehler in einer SQL-Zeile beim Export genau diese nackte Antwort
|
||||||
|
// erzeugt. Eine Auffanglinie an EINER Stelle schliesst die Luecke fuer alles,
|
||||||
|
// woran heute niemand denkt.
|
||||||
|
//
|
||||||
|
// Die Meldung geht mit hinaus. Das ist eine bewusste Abwaegung: dieses
|
||||||
|
// Dashboard hat einen Benutzerkreis von einer Person, und ein Fehler, den man
|
||||||
|
// nicht lesen kann, kostet mehr als er verbirgt. Die Spur bleibt zusaetzlich
|
||||||
|
// in der Konsole.
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error('[fehler]', c.req.method, c.req.path, err)
|
||||||
|
const text = err instanceof Error ? err.message : String(err)
|
||||||
|
if (c.req.path.startsWith('/api/')) {
|
||||||
|
return c.json({ error: text }, 500)
|
||||||
|
}
|
||||||
|
return c.text(`Serverfehler: ${text}`, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ein Fehler AUSSERHALB jedes try/catch darf den Dienst nicht umbringen.
|
||||||
|
//
|
||||||
|
// Genau das ist beim Uebernehmen von 3670 Dateien passiert: undici warf
|
||||||
|
// "ReadableStream is already closed" aus einem Microtask -- also an einer
|
||||||
|
// Stelle, an der kein try/catch der Welt steht. Node beendet den Prozess
|
||||||
|
// daraufhin, Docker startet ihn neu, die naechste Anfrage kippt ihn wieder:
|
||||||
|
// eine Neustartschleife, und nach aussen 502.
|
||||||
|
//
|
||||||
|
// DIE ABWAEGUNG, ausgesprochen: einen unerwarteten Fehler zu ueberleben ist
|
||||||
|
// nicht in jedem Fall richtig -- der Prozess KANN danach in einem kaputten
|
||||||
|
// Zustand sein. Hier ueberwiegt trotzdem das Weiterlaufen: dieser Dienst haelt
|
||||||
|
// keinen Zustand im Speicher, der verderben koennte (alles steht in SQLite und
|
||||||
|
// auf der Platte), und ein Dienst, der wegen EINER Anfrage fuer alle weg ist,
|
||||||
|
// ist der schlechtere Tausch.
|
||||||
|
//
|
||||||
|
// Laut wird es trotzdem: die Spur steht vollstaendig in der Konsole.
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('[unerwartet] uncaughtException — der Dienst laeuft weiter:', err)
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', (grund) => {
|
||||||
|
console.error('[unerwartet] unhandledRejection — der Dienst laeuft weiter:', grund)
|
||||||
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------- Dateien ausliefern
|
// -------------------------------------------------------- Dateien ausliefern
|
||||||
|
|
||||||
/** Liefert eine Datei aus, mit ETag und Bereichsanfragen.
|
/** Liefert eine Datei aus, mit ETag und Bereichsanfragen.
|
||||||
@@ -51,7 +100,7 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
return c.text('nicht gefunden', 404)
|
return c.text('nicht gefunden', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolute = absolutePath(path)
|
let absolute = absolutePath(path)
|
||||||
let info
|
let info
|
||||||
try {
|
try {
|
||||||
info = await stat(absolute)
|
info = await stat(absolute)
|
||||||
@@ -60,10 +109,45 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
return c.text('nicht gefunden', 404)
|
return c.text('nicht gefunden', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
|
// DIE SPARSAME FASSUNG, unter DERSELBEN Adresse.
|
||||||
|
//
|
||||||
|
// Der Browser sagt mit `Accept`, was er versteht. Versteht er WebP und
|
||||||
|
// liegt eine Fassung daneben, bekommt er sie. Die Adresse bleibt
|
||||||
|
// items/foo.png, und kein Skript merkt etwas davon. Lua, curl und alles
|
||||||
|
// ohne diese Ansage bekommen unveraendert das Original.
|
||||||
|
//
|
||||||
|
// Der Vergleich ist absichtlich stumpf: `Accept` traegt Gewichte, und ein
|
||||||
|
// "image/webp;q=0" hiesse eigentlich "bitte nicht". Das schickt in der
|
||||||
|
// Praxis niemand -- und faellt jemand doch darauf herein, bekommt er ein
|
||||||
|
// gueltiges Bild, nur in einem anderen Format. Der Preis eines Fehlers ist
|
||||||
|
// hier klein genug, dass die einfache Pruefung die richtige ist.
|
||||||
|
let typ = mimeFor(path)
|
||||||
|
let sparsam = false
|
||||||
|
if (lohntWebp(typ) && (c.req.header('accept') ?? '').includes('image/webp')) {
|
||||||
|
try {
|
||||||
|
const wInfo = await stat(webpPfad(path))
|
||||||
|
if (wInfo.isFile()) {
|
||||||
|
absolute = webpPfad(path)
|
||||||
|
info = wInfo
|
||||||
|
typ = 'image/webp'
|
||||||
|
sparsam = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keine sparsame Fassung -- dann eben das Original */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Kennzeichen gehoert IN den ETag: sonst traegt das Original und die
|
||||||
|
// sparsame Fassung dieselbe Marke, sobald beide zufaellig gleich gross und
|
||||||
|
// gleich alt sind, und ein Aufrufer bekaeme auf seinen 304 hin das falsche
|
||||||
|
// Bild aus seinem eigenen Zwischenspeicher.
|
||||||
|
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}${sparsam ? '-w' : ''}"`
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': mimeFor(path),
|
'Content-Type': typ,
|
||||||
'Cache-Control': 'public, max-age=300',
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
// OHNE DIESE ZEILE legt ein Zwischenspeicher die WebP-Fassung fuer ALLE
|
||||||
|
// ab -- auch fuer die, die sie nicht lesen koennen.
|
||||||
|
Vary: 'Accept',
|
||||||
ETag: etag,
|
ETag: etag,
|
||||||
'Last-Modified': info.mtime.toUTCString(),
|
'Last-Modified': info.mtime.toUTCString(),
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
@@ -72,6 +156,13 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
'X-Content-Type-Options': 'nosniff',
|
'X-Content-Type-Options': 'nosniff',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
|
||||||
|
//
|
||||||
|
// Auch der 304 zaehlt: der Aufrufer WOLLTE die Datei, er hatte sie nur
|
||||||
|
// schon. Fuer die Frage "braucht das noch jemand" ist das dasselbe.
|
||||||
|
// HEAD zaehlt nicht -- das fragt nach der Datei, nicht nach dem Inhalt.
|
||||||
|
if (c.req.method !== 'HEAD') zaehleAbruf(path)
|
||||||
|
|
||||||
if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers)
|
if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers)
|
||||||
if (c.req.method === 'HEAD') {
|
if (c.req.method === 'HEAD') {
|
||||||
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
|
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
|
||||||
@@ -118,13 +209,77 @@ app.use('*', async (c, next) => {
|
|||||||
|
|
||||||
// ------------------------------------------------------- Dashboard und API
|
// ------------------------------------------------------- Dashboard und API
|
||||||
|
|
||||||
|
// Der HEALTHCHECK des Containers haengt hier dran, und deshalb bleibt es
|
||||||
|
// billig: eine schwere Pruefung, die bei einer langsamen Platte einmal
|
||||||
|
// ausfaellt, liesse Docker den Container neu starten -- also genau dann, wenn
|
||||||
|
// er unter Last steht.
|
||||||
|
//
|
||||||
|
// Was diese Zeile beweist, ist genau eines: der Prozess nimmt Anfragen an.
|
||||||
|
// Fuer eine STATUSSEITE ist das zu duenn -- dafuer gibt es /status daneben,
|
||||||
|
// das wirklich nachsieht.
|
||||||
app.get('/health', (c) => c.json({ ok: true }))
|
app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
|
||||||
|
// Fuer die Statusseite. Oeffentlich wie /health, aber es prueft Datenbank,
|
||||||
|
// Platte, Ausliefern und die Sicherung -- und antwortet mit 503, wenn der
|
||||||
|
// Dienst sein Geschaeft nicht mehr tun kann.
|
||||||
|
app.route('/status', statusRoutes)
|
||||||
|
|
||||||
app.route('/api', uploadRoutes)
|
app.route('/api', uploadRoutes)
|
||||||
|
// NACH uploadRoutes: unsere eigenen Wege haben Vorrang, falls je ein Name
|
||||||
|
// zusammenfaellt. Derzeit tut er das nicht (/upload, /exists, /media gegen
|
||||||
|
// /image, /video, /audio, /file, /v3/file), aber die Reihenfolge sagt, was
|
||||||
|
// gelten soll, wenn es einmal so kommt.
|
||||||
|
app.route('/api', fivemanageRoutes)
|
||||||
app.route('/api/dash', dashRoutes)
|
app.route('/api/dash', dashRoutes)
|
||||||
|
|
||||||
|
// Die Freigaben. OHNE Anmeldung und mit Absicht kurz: /s/<schluessel> laesst
|
||||||
|
// sich vorlesen und abtippen.
|
||||||
|
app.route('/api/s', freigabeRoutes)
|
||||||
|
|
||||||
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
|
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
|
||||||
@@ -169,6 +324,10 @@ function decodeSafely(raw: string): string {
|
|||||||
* Stylesheet noch Modul. */
|
* Stylesheet noch Modul. */
|
||||||
const WEB_MIME: Record<string, string> = {
|
const WEB_MIME: Record<string, string> = {
|
||||||
html: 'text/html; charset=utf-8',
|
html: 'text/html; charset=utf-8',
|
||||||
|
// webmanifest steht hier und nicht in der geteilten Tabelle: es ist eine
|
||||||
|
// Datei der Oberflaeche. Ohne den Eintrag kaeme sie als
|
||||||
|
// application/octet-stream, und der Browser wuerde sie verwerfen.
|
||||||
|
webmanifest: 'application/manifest+json; charset=utf-8',
|
||||||
css: 'text/css; charset=utf-8',
|
css: 'text/css; charset=utf-8',
|
||||||
js: 'text/javascript; charset=utf-8',
|
js: 'text/javascript; charset=utf-8',
|
||||||
mjs: 'text/javascript; charset=utf-8',
|
mjs: 'text/javascript; charset=utf-8',
|
||||||
@@ -232,6 +391,55 @@ ensureAdmin()
|
|||||||
pruneSessions()
|
pruneSessions()
|
||||||
setInterval(pruneSessions, 3600_000).unref()
|
setInterval(pruneSessions, 3600_000).unref()
|
||||||
|
|
||||||
|
// UND DIE EREIGNISSE, taeglich. Das stand bisher NUR an einem Wartungsweg
|
||||||
|
// (/maintenance/prune-sessions), den nicht einmal die Oberflaeche anbietet --
|
||||||
|
// die dokumentierten 180 Tage Aufbewahrung griffen damit nie, und die Tabelle
|
||||||
|
// wuchs fuer immer. Nach einem einzigen Tag mit Umzug und Serienlauf standen
|
||||||
|
// dort 5355 Zeilen.
|
||||||
|
//
|
||||||
|
// Nicht stuendlich wie die Sitzungen: ein DELETE ueber eine Zeitspanne von
|
||||||
|
// einem halben Jahr hat es nicht eilig, und 24 Stunden Verzoegerung beim
|
||||||
|
// Wegraeumen fallen niemandem auf.
|
||||||
|
setTimeout(() => {
|
||||||
|
pruneEvents()
|
||||||
|
pruneVerwaltung()
|
||||||
|
}, 120_000).unref()
|
||||||
|
setInterval(() => {
|
||||||
|
pruneEvents()
|
||||||
|
pruneVerwaltung()
|
||||||
|
}, 86_400_000).unref()
|
||||||
|
|
||||||
|
// Der Wachhund fuer die Sicherung. Beim Start mit Verzoegerung (der Dienst
|
||||||
|
// soll erst stehen) und danach stuendlich.
|
||||||
|
//
|
||||||
|
// DAS IST DER PUNKT DER GANZEN MELDUNGSFUNKTION: eine Sicherung, die aufhoert
|
||||||
|
// zu laufen, faellt sonst erst auf, wenn man sie braucht.
|
||||||
|
setTimeout(() => void pruefeSicherung(), 60_000).unref()
|
||||||
|
setInterval(() => void pruefeSicherung(), 3600_000).unref()
|
||||||
|
|
||||||
|
// Und der Platz auf der Platte. Derselbe Takt und derselbe Grund: eine volle
|
||||||
|
// Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und
|
||||||
|
// nichts mehr gesichert wird.
|
||||||
|
setTimeout(() => void pruefePlatte(), 75_000).unref()
|
||||||
|
setInterval(() => void pruefePlatte(), 3600_000).unref()
|
||||||
|
|
||||||
|
// Die gezaehlten Abrufe wegschreiben. Alle 30 Sekunden reicht: wer wissen
|
||||||
|
// will, ob eine Datei benutzt wird, braucht keine Genauigkeit auf die Sekunde.
|
||||||
|
setInterval(schreibeAbrufe, 30_000).unref()
|
||||||
|
|
||||||
|
// Und beim Herunterfahren, damit die letzten nicht verlorengehen.
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
schreibeAbrufe()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den Papierkorb ausleeren, was ueberfaellig ist. Beim Start mit Verzoegerung,
|
||||||
|
// danach stuendlich.
|
||||||
|
setTimeout(() => void papierkorbAusleeren(), 90_000).unref()
|
||||||
|
setInterval(() => void papierkorbAusleeren(), 3600_000).unref()
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
console.log(`[d4rk_media] Port ${info.port}`)
|
console.log(`[d4rk_media] Port ${info.port}`)
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// Meldungen nach Discord.
|
||||||
|
//
|
||||||
|
// WOZU. Die Sicherung laeuft nachts um halb fuenf, und wenn sie aufhoert zu
|
||||||
|
// laufen, merkt es niemand -- das Panel zeigt es nur dem, der hinschaut. Genau
|
||||||
|
// dafuer ist das hier: nicht fuer Jubelmeldungen, sondern fuer die zwei, drei
|
||||||
|
// Faelle, in denen jemand etwas tun muss.
|
||||||
|
//
|
||||||
|
// Ein WEBHOOK und keine Bot-Anbindung: eine Adresse, kein Token, keine
|
||||||
|
// Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen
|
||||||
|
// will, loescht ihn in Discord.
|
||||||
|
//
|
||||||
|
// DER AUSSEHEN FOLGT DEM d4rkbot. Dessen src/embeds.js hat eine zentrale
|
||||||
|
// Embed-Fabrik: Markenfarbe, Fusszeile "D4RKST3R // <TAG>", Zeitstempel. Zwei
|
||||||
|
// Dienste desselben Hauses sollen in einem Kanal nicht wie zwei Fremde
|
||||||
|
// aussehen, also uebernehmen wir das Muster statt ein eigenes zu erfinden.
|
||||||
|
//
|
||||||
|
// EINE ABWEICHUNG, mit Absicht: der Bot faerbt alles in der Markenfarbe, hier
|
||||||
|
// faerbt die SCHWERE. Eine Warnung, die aussieht wie jede andere Nachricht,
|
||||||
|
// ist eine Warnung, die man ueberliest -- und diese Meldungen gibt es nur,
|
||||||
|
// weil jemand sie sehen soll.
|
||||||
|
|
||||||
|
import { statfs } from 'node:fs/promises'
|
||||||
|
import { config } from './config.js'
|
||||||
|
import { getSetting, setSetting } from './settings.js'
|
||||||
|
import { now } from './db.js'
|
||||||
|
|
||||||
|
/** Wofuer es Meldungen gibt -- an EINER Stelle.
|
||||||
|
*
|
||||||
|
* Die Liste stand zweimal da: hier als Standard, und in dash.ts noch einmal
|
||||||
|
* als Weissliste beim Speichern. Beim Einbau des Plattenwaechters habe ich nur
|
||||||
|
* die erste angefasst -- Ergebnis: das Kaestchen liess sich ankreuzen, der
|
||||||
|
* Server warf den Wert beim Speichern weg, und beim naechsten Laden war es
|
||||||
|
* wieder aus. Ohne ein Wort dazu.
|
||||||
|
*
|
||||||
|
* Nachgemessen am laufenden Dienst, bevor es hier stand:
|
||||||
|
* geschickt ["sicherung","verwaiste","platte"]
|
||||||
|
* gespeichert ["sicherung","verwaiste"]
|
||||||
|
*
|
||||||
|
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
|
||||||
|
* ist. Jetzt ist es eine.
|
||||||
|
*/
|
||||||
|
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte', 'upload'] as const
|
||||||
|
|
||||||
|
export const MELDUNG_KEYS = {
|
||||||
|
webhook: 'discord.webhook',
|
||||||
|
/** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste', 'platte'. */
|
||||||
|
anlaesse: 'discord.anlaesse',
|
||||||
|
/** Womit zuletzt gemeldet wurde -- gegen Wiederholungen.
|
||||||
|
*
|
||||||
|
* ALS PRAEFIX, nicht als ein Schluessel: es gibt inzwischen ZWEI
|
||||||
|
* Wachhunde (Sicherung und Plattenplatz), und mit einem gemeinsamen
|
||||||
|
* Merker haetten sie sich gegenseitig ueberschrieben. Der eine meldet,
|
||||||
|
* der andere haelt daraufhin seinen eigenen Zustand fuer neu und meldet
|
||||||
|
* auch -- oder umgekehrt schweigt einer, obwohl sich etwas geaendert hat. */
|
||||||
|
zuletzt: 'discord.zuletzt',
|
||||||
|
/** Der Name in der Fusszeile. Wie beim Bot einstellbar, mit demselben
|
||||||
|
* Standard. */
|
||||||
|
marke: 'marke.name',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Dieselben Werte wie im d4rkbot (src/runtime-settings.js). */
|
||||||
|
const MARKE_STANDARD = 'D4RKST3R'
|
||||||
|
const FARBEN = {
|
||||||
|
marke: 0xf5c518,
|
||||||
|
gut: 0x3fb950,
|
||||||
|
warnung: 0xd29922,
|
||||||
|
schlecht: 0xf85149,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Schwere = keyof typeof FARBEN
|
||||||
|
|
||||||
|
export type MeldungConfig = {
|
||||||
|
hatWebhook: boolean
|
||||||
|
anlaesse: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meldungConfig(): MeldungConfig {
|
||||||
|
const roh = getSetting(MELDUNG_KEYS.anlaesse)
|
||||||
|
return {
|
||||||
|
hatWebhook: Boolean(getSetting(MELDUNG_KEYS.webhook)),
|
||||||
|
// Ohne Eintrag gilt: alles, was es gibt. Wer einen Webhook eintraegt,
|
||||||
|
// will Meldungen -- sonst haette er es gelassen.
|
||||||
|
anlaesse: roh
|
||||||
|
? roh.split(',').map((a) => a.trim()).filter(Boolean)
|
||||||
|
: [...ANLAESSE],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Feld = { name: string; value: string; inline?: boolean }
|
||||||
|
|
||||||
|
/** Eine Nachricht schicken. Wirft nie.
|
||||||
|
*
|
||||||
|
* `einmalig` ist der Schluessel gegen Wiederholungen: dieselbe Zeichenkette
|
||||||
|
* wird nur einmal gemeldet, bis sich der Zustand aendert. */
|
||||||
|
export async function melde(
|
||||||
|
titel: string,
|
||||||
|
opt: {
|
||||||
|
text?: string
|
||||||
|
felder?: Feld[]
|
||||||
|
schwere?: Schwere
|
||||||
|
anlass?: string
|
||||||
|
einmalig?: string
|
||||||
|
tag?: string
|
||||||
|
} = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const webhook = getSetting(MELDUNG_KEYS.webhook)
|
||||||
|
if (!webhook) return false
|
||||||
|
|
||||||
|
if (opt.anlass && !meldungConfig().anlaesse.includes(opt.anlass)) return false
|
||||||
|
|
||||||
|
if (opt.einmalig) {
|
||||||
|
const merker = `${MELDUNG_KEYS.zuletzt}.${opt.anlass ?? 'allgemein'}`
|
||||||
|
const zuletzt = getSetting(merker)
|
||||||
|
if (zuletzt === opt.einmalig) return false
|
||||||
|
setSetting(merker, opt.einmalig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const marke = getSetting(MELDUNG_KEYS.marke) || MARKE_STANDARD
|
||||||
|
|
||||||
|
const antwort = await fetch(webhook, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: 'd4rk_media',
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: titel,
|
||||||
|
description: opt.text,
|
||||||
|
color: FARBEN[opt.schwere ?? 'marke'],
|
||||||
|
// Felder statt Fliesstext, wo es Zahlen sind: "228
|
||||||
|
// Dateien / 11,5 MB / vor 3 Stunden" liest sich in
|
||||||
|
// einer Zeile schlechter als nebeneinander.
|
||||||
|
fields: opt.felder,
|
||||||
|
footer: { text: `${marke} // ${opt.tag ?? 'MEDIA'}` },
|
||||||
|
timestamp: new Date(now()).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
// Discord antwortet auf einen Webhook mit 204.
|
||||||
|
if (!antwort.ok) {
|
||||||
|
console.error('[discord] Webhook antwortete mit', antwort.status)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
// Eine Meldung, die den Dienst mitreisst, waere die Buchhaltung, die
|
||||||
|
// das Geschaeft verhindert.
|
||||||
|
console.error('[discord] Meldung fehlgeschlagen:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die unterste Stufe ist neu: bisher meldete das hier nur Sicherungsarchive,
|
||||||
|
// und die sind nie klein. Ein 200-Byte-Bildschirmfoto wurde zu "0 KB" -- eine
|
||||||
|
// Zahl, die aussieht, als waere etwas kaputt.
|
||||||
|
export const groesse = (n: number): string =>
|
||||||
|
n < 1024
|
||||||
|
? `${n} Bytes`
|
||||||
|
: n < 1024 * 1024
|
||||||
|
? `${Math.round(n / 1024)} KB`
|
||||||
|
: n < 1024 * 1024 * 1024
|
||||||
|
? `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||||
|
|
||||||
|
/** Wacht ueber die Sicherung.
|
||||||
|
*
|
||||||
|
* Der eigentliche Punkt der ganzen Datei. Geprueft wird stuendlich, gemeldet
|
||||||
|
* hoechstens einmal je Zustand:
|
||||||
|
*
|
||||||
|
* - der letzte Lauf ist gescheitert
|
||||||
|
* - der letzte Lauf ist laenger als 26 Stunden her (taeglich um 04:30 plus
|
||||||
|
* Luft; 24 waeren zu knapp, 48 zu spaet)
|
||||||
|
* - es gab noch nie einen
|
||||||
|
*/
|
||||||
|
export async function pruefeSicherung(): Promise<void> {
|
||||||
|
const roh = getSetting('sicherung.letzte')
|
||||||
|
|
||||||
|
if (!roh) {
|
||||||
|
await melde('Noch keine Sicherung gemeldet', {
|
||||||
|
text: 'Es wurde noch **nie** eine Sicherung eingetragen. Läuft die geplante Aufgabe?',
|
||||||
|
schwere: 'warnung',
|
||||||
|
anlass: 'sicherung',
|
||||||
|
einmalig: 'nie',
|
||||||
|
tag: 'SICHERUNG',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const letzte = JSON.parse(roh) as {
|
||||||
|
at: number
|
||||||
|
ok: boolean
|
||||||
|
dateien?: number
|
||||||
|
groesse?: number
|
||||||
|
hoch?: boolean
|
||||||
|
fehler?: string
|
||||||
|
}
|
||||||
|
const stunden = (now() - letzte.at) / 3_600_000
|
||||||
|
const wann = `<t:${Math.floor(letzte.at / 1000)}:R>`
|
||||||
|
|
||||||
|
if (!letzte.ok) {
|
||||||
|
await melde('Die Sicherung ist fehlgeschlagen', {
|
||||||
|
text: letzte.fehler ?? 'Es wurde kein Grund gemeldet.',
|
||||||
|
felder: [
|
||||||
|
{ name: 'Wann', value: wann, inline: true },
|
||||||
|
{
|
||||||
|
name: 'Zuletzt gut',
|
||||||
|
value: letzte.dateien ? `${letzte.dateien} Dateien` : '—',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schwere: 'schlecht',
|
||||||
|
anlass: 'sicherung',
|
||||||
|
einmalig: `fehler:${letzte.at}`,
|
||||||
|
tag: 'SICHERUNG',
|
||||||
|
})
|
||||||
|
} else if (stunden > 26) {
|
||||||
|
await melde('Die Sicherung ist überfällig', {
|
||||||
|
text: 'Läuft die geplante Aufgabe noch? Erwartet wird sie täglich um 04:30.',
|
||||||
|
felder: [
|
||||||
|
{ name: 'Letzter Lauf', value: wann, inline: true },
|
||||||
|
{ name: 'Das ist', value: `${Math.floor(stunden)} Stunden her`, inline: true },
|
||||||
|
],
|
||||||
|
schwere: 'warnung',
|
||||||
|
anlass: 'sicherung',
|
||||||
|
einmalig: `alt:${letzte.at}`,
|
||||||
|
tag: 'SICHERUNG',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* kaputter Eintrag -- beim naechsten Lauf wieder */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Testnachricht. Zeigt bewusst, WIE eine echte aussieht — sonst prueft
|
||||||
|
* man nur, ob irgendetwas ankommt, und nicht, ob es lesbar ist. */
|
||||||
|
export async function testMeldung(wer: string, bestand: { files: number; bytes: number }) {
|
||||||
|
return melde('Testnachricht', {
|
||||||
|
text: `Ausgelöst von **${wer}**. So sieht eine Meldung von hier aus.`,
|
||||||
|
felder: [
|
||||||
|
{ name: 'Bestand', value: `${bestand.files} Dateien`, inline: true },
|
||||||
|
{ name: 'Belegt', value: groesse(bestand.bytes), inline: true },
|
||||||
|
],
|
||||||
|
schwere: 'gut',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Plattenplatz
|
||||||
|
//
|
||||||
|
// WOZU. Der Dienst schreibt so lange Bilder, wie die Platte sie annimmt -- und
|
||||||
|
// wenn sie voll ist, faellt das nicht beim Hochladen auf, sondern bei der
|
||||||
|
// naechsten SICHERUNG, die dann auch nicht mehr schreiben kann. Eine volle
|
||||||
|
// Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und
|
||||||
|
// nichts mehr gerettet wird.
|
||||||
|
//
|
||||||
|
// Gemeldet wird nach STUFEN und nicht bei jedem Prozentpunkt: `einmalig`
|
||||||
|
// bekommt die Stufe als Wert, es kommt also je Stufe genau eine Meldung. Faellt
|
||||||
|
// der Platz weiter, meldet die naechste Stufe; wird wieder Platz frei, faellt
|
||||||
|
// der Zustand auf "gut" und der naechste Engpass meldet erneut.
|
||||||
|
|
||||||
|
// Die Schweregrade heissen hier so wie ueberall im Dienst -- 'schlecht',
|
||||||
|
// 'warnung', 'marke'. Ich hatte zuerst 'fehler' und 'hinweis' geschrieben, was
|
||||||
|
// naheliegt und falsch ist; der Uebersetzer hat es gefangen.
|
||||||
|
const STUFEN = [
|
||||||
|
{ ab: 0.95, name: 'kritisch' as const, schwere: 'schlecht' as const },
|
||||||
|
{ ab: 0.9, name: 'eng' as const, schwere: 'warnung' as const },
|
||||||
|
{ ab: 0.8, name: 'knapp' as const, schwere: 'marke' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function pruefePlatte(): Promise<void> {
|
||||||
|
let frei: number
|
||||||
|
let gesamt: number
|
||||||
|
try {
|
||||||
|
// statfs gibt es in node:fs/promises seit 18.15 -- hier laeuft 22.
|
||||||
|
const s = await statfs(config.dataDir)
|
||||||
|
gesamt = s.blocks * s.bsize
|
||||||
|
// bavail und NICHT bfree: ein Teil ist fuer root reserviert, und der
|
||||||
|
// Dienst laeuft nicht als root -- bfree waere eine Zahl, an die wir gar
|
||||||
|
// nicht herankommen.
|
||||||
|
//
|
||||||
|
// DAS HEISST: WIR MELDEN FRUEHER ALS `df`. Auf dieser Platte gemessen:
|
||||||
|
//
|
||||||
|
// gesamt 1006,9 GB
|
||||||
|
// frei (bavail) 909,5 GB -> 9,7 % belegt <- was hier zaehlt
|
||||||
|
// frei (bfree) 960,7 GB -> 4,6 % belegt
|
||||||
|
// df sagt 5 %
|
||||||
|
//
|
||||||
|
// Die Luecke ist die ext4-Reserve. Der Unterschied ist gewollt: wer
|
||||||
|
// gewarnt werden will, bevor es eng wird, will die Zahl, die IHN
|
||||||
|
// betrifft. Wer spaeter den Unterschied zu `df` sucht, findet ihn hier.
|
||||||
|
frei = s.bavail * s.bsize
|
||||||
|
} catch (err) {
|
||||||
|
// Kein Grund zu laermen: auf manchen Dateisystemen gibt es das nicht.
|
||||||
|
console.error('[platte] liess sich nicht messen:', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gesamt) return
|
||||||
|
const belegt = 1 - frei / gesamt
|
||||||
|
const stufe = STUFEN.find((s) => belegt >= s.ab)
|
||||||
|
|
||||||
|
const gb = (n: number) => `${(n / 1073741824).toFixed(1)} GB`
|
||||||
|
|
||||||
|
if (!stufe) {
|
||||||
|
// Wieder Luft. Den Merker zuruecksetzen, damit der naechste Engpass
|
||||||
|
// wieder meldet -- ohne das bliebe es nach einem einmaligen Volllaufen
|
||||||
|
// fuer immer still.
|
||||||
|
setSetting(`${MELDUNG_KEYS.zuletzt}.platte`, 'gut')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await melde(`Der Platz wird ${stufe.name}`, {
|
||||||
|
text:
|
||||||
|
`Unter \`${config.dataDir}\` sind noch **${gb(frei)}** von ${gb(gesamt)} frei ` +
|
||||||
|
`(${(belegt * 100).toFixed(1)} % belegt).\n\n` +
|
||||||
|
'Ist die Platte voll, nimmt der Dienst nichts mehr an **und die Sicherung ' +
|
||||||
|
'kann nicht mehr schreiben** — beides zugleich.',
|
||||||
|
felder: [
|
||||||
|
{ name: 'Frei', value: gb(frei), inline: true },
|
||||||
|
{ name: 'Gesamt', value: gb(gesamt), inline: true },
|
||||||
|
{ name: 'Belegt', value: `${(belegt * 100).toFixed(1)} %`, inline: true },
|
||||||
|
],
|
||||||
|
schwere: stufe.schwere,
|
||||||
|
anlass: 'platte',
|
||||||
|
einmalig: stufe.name,
|
||||||
|
tag: 'PLATTE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Neues ist da
|
||||||
|
//
|
||||||
|
// WOZU. Etwas landet hier und niemand erfaehrt es. Eine Meldung darueber ist
|
||||||
|
// genau dann nuetzlich, wenn sie selten ist.
|
||||||
|
//
|
||||||
|
// UND GENAU DAS IST DAS PROBLEM: ein Serienlauf des Fotostudios legt 900
|
||||||
|
// Fahrzeugbilder ab. Neunhundert Nachrichten waeren keine Meldung mehr, sondern
|
||||||
|
// ein Ausfall des Kanals -- Discord drosselt Webhooks, und der Rest ginge
|
||||||
|
// verloren. Wer danach eine ECHTE Meldung bekommt, sieht sie nicht mehr.
|
||||||
|
//
|
||||||
|
// NUR TOKEN-UPLOADS, ABSICHTLICH. Was ueber das Dashboard hereinkommt, hat
|
||||||
|
// gerade jemand selbst hochgeladen und auf dem Bildschirm bestaetigt bekommen --
|
||||||
|
// eine Meldung darueber erzaehlt ihm, was er selbst getan hat. Und das
|
||||||
|
// Zurueckholen aus dem Papierkorb oder das Aufnehmen verwaister Dateien meldet
|
||||||
|
// gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.
|
||||||
|
//
|
||||||
|
// DESHALB WIRD GESAMMELT. Nach der Ruhe kommt EINE Nachricht: wieviel, von wem,
|
||||||
|
// wohin, mit ein paar Beispielen. Ein Serienlauf ergibt eine Meldung, ein
|
||||||
|
// einzelnes Bild ergibt eine Meldung -- beide sagen dasselbe, nur mit anderen
|
||||||
|
// Zahlen.
|
||||||
|
|
||||||
|
/** Wie lange nach dem letzten Upload gewartet wird, bevor gemeldet wird.
|
||||||
|
*
|
||||||
|
* Zwei Minuten: lang genug, dass ein Serienlauf mit Pausen zwischen den
|
||||||
|
* Bildern als EIN Vorgang durchgeht, kurz genug, dass ein einzelnes Bild
|
||||||
|
* nicht gefuehlt verlorengeht. */
|
||||||
|
const SAMMEL_MS = 120_000
|
||||||
|
|
||||||
|
type Gesammelt = { anzahl: number; bytes: number; wer: Set<string>; pfade: string[] }
|
||||||
|
let gesammelt: Gesammelt | null = null
|
||||||
|
let zeitgeber: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
/** Einen Upload vormerken. Meldet NICHT sofort, sondern nach der Ruhe. */
|
||||||
|
export function meldeUpload(pfad: string, bytes: number, wer: string): void {
|
||||||
|
// Frueh raus, wenn niemand Upload-Meldungen will: sonst sammelte der
|
||||||
|
// Dienst 900 Pfade zusammen, um sie am Ende wegzuwerfen.
|
||||||
|
if (!meldungConfig().anlaesse.includes('upload')) return
|
||||||
|
|
||||||
|
if (!gesammelt) gesammelt = { anzahl: 0, bytes: 0, wer: new Set(), pfade: [] }
|
||||||
|
gesammelt.anzahl++
|
||||||
|
gesammelt.bytes += bytes
|
||||||
|
gesammelt.wer.add(wer)
|
||||||
|
// Nur die ersten paar Pfade behalten: bei 900 Dateien waere die Liste
|
||||||
|
// groesser als die Nachricht, die sie beschreiben soll.
|
||||||
|
if (gesammelt.pfade.length < 5) gesammelt.pfade.push(pfad)
|
||||||
|
|
||||||
|
// JEDER Upload schiebt die Frist nach hinten. So wird aus einem Lauf mit
|
||||||
|
// Pausen ein Vorgang und nicht zwanzig.
|
||||||
|
if (zeitgeber) clearTimeout(zeitgeber)
|
||||||
|
zeitgeber = setTimeout(() => void sammelMelden(), SAMMEL_MS)
|
||||||
|
// Ohne unref() haelt ein offener Zeitgeber den Prozess am Leben und das
|
||||||
|
// Herunterfahren wartet zwei Minuten auf nichts.
|
||||||
|
zeitgeber.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sammelMelden(): Promise<void> {
|
||||||
|
const g = gesammelt
|
||||||
|
// ERST leeren, dann melden: waehrend des Netzaufrufs kann der naechste
|
||||||
|
// Upload kommen, und der gehoert in die naechste Runde, nicht in diese.
|
||||||
|
gesammelt = null
|
||||||
|
zeitgeber = null
|
||||||
|
if (!g) return
|
||||||
|
|
||||||
|
const einzeln = g.anzahl === 1
|
||||||
|
|
||||||
|
// Auch im Log, nicht nur im Kanal: wer spaeter fragt „warum kam da nur
|
||||||
|
// EINE Meldung fuer 900 Bilder", findet hier die Antwort mit Zahl.
|
||||||
|
console.log(
|
||||||
|
`[upload] ${g.anzahl} Datei(en), ${groesse(g.bytes)}, von ${[...g.wer].join(', ')} -- eine Meldung`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await melde(einzeln ? 'Etwas Neues ist da' : `${g.anzahl} neue Dateien`, {
|
||||||
|
text:
|
||||||
|
g.pfade.map((p) => `\`${p}\``).join('\n') +
|
||||||
|
(g.anzahl > g.pfade.length ? `\n… und ${g.anzahl - g.pfade.length} weitere` : ''),
|
||||||
|
felder: [
|
||||||
|
{ name: 'Zusammen', value: groesse(g.bytes), inline: true },
|
||||||
|
{ name: 'Von', value: [...g.wer].join(', ').slice(0, 100) || '—', inline: true },
|
||||||
|
],
|
||||||
|
schwere: 'gut',
|
||||||
|
anlass: 'upload',
|
||||||
|
tag: 'UPLOAD',
|
||||||
|
})
|
||||||
|
}
|
||||||
+3319
-20
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
// Anmeldung ueber Discord.
|
||||||
|
//
|
||||||
|
// DER ABLAUF, in drei Schritten und ohne Bibliothek:
|
||||||
|
//
|
||||||
|
// 1. /api/dash/auth/discord → wir schicken den Browser zu Discord
|
||||||
|
// 2. Discord fragt den Benutzer → und schickt ihn mit einem `code` zurueck
|
||||||
|
// 3. /…/discord/callback → wir tauschen den Code gegen ein Zugangs-
|
||||||
|
// merkmal, holen Benutzer UND Rolle, und
|
||||||
|
// legen bei Erfolg eine Sitzung an
|
||||||
|
//
|
||||||
|
// WARUM OHNE BOT-TOKEN. Die Rolle wird mit dem Merkmal des BENUTZERS geholt
|
||||||
|
// (`guilds.members.read` → /users/@me/guilds/<server>/member). Damit braucht
|
||||||
|
// dieser Dienst keinen Bot, keine Bot-Berechtigung und kein zweites Geheimnis,
|
||||||
|
// das ablaufen kann.
|
||||||
|
//
|
||||||
|
// WAS SCHIEFGEHEN KANN, geht sichtbar schief: jeder Abbruch landet wieder auf
|
||||||
|
// der Anmeldeseite, mit dem Grund im Adressfeld. Ein OAuth-Weg, der bei einem
|
||||||
|
// Fehler eine weisse Seite zeigt, ist der Grund, warum niemand ihn debuggen
|
||||||
|
// mag.
|
||||||
|
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { db, now, type User } from '../db.js'
|
||||||
|
import { SESSION_COOKIE, createSession } from '../auth.js'
|
||||||
|
import { discordConfig } from '../settings.js'
|
||||||
|
|
||||||
|
export const discordRoutes = new Hono()
|
||||||
|
|
||||||
|
/** Muss ZEICHEN FUER ZEICHEN mit dem uebereinstimmen, was im Discord-Portal
|
||||||
|
* eingetragen ist — daran scheitert der erste Anlauf fast immer. Deshalb
|
||||||
|
* zeigt das Panel genau diese Zeichenkette an. */
|
||||||
|
export const redirectUri = () => `${config.publicUrl}/api/dash/auth/discord/callback`
|
||||||
|
|
||||||
|
const STATE_COOKIE = 'dstate'
|
||||||
|
|
||||||
|
/** Zurueck zur Anmeldeseite, mit dem Grund im Adressfeld. */
|
||||||
|
const zurueck = (c: any, grund: string) =>
|
||||||
|
c.redirect(`/?discord=${encodeURIComponent(grund)}`, 302)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Schritt 1
|
||||||
|
|
||||||
|
discordRoutes.get('/discord', (c) => {
|
||||||
|
const d = discordConfig()
|
||||||
|
if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.')
|
||||||
|
|
||||||
|
// Der Zustandswert schuetzt gegen untergeschobene Rueckleitungen: nur wer
|
||||||
|
// diesen Weg begonnen hat, hat das Cookie dazu.
|
||||||
|
const state = randomBytes(24).toString('base64url')
|
||||||
|
setCookie(c, STATE_COOKIE, state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !config.dev,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 600,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = new URL('https://discord.com/oauth2/authorize')
|
||||||
|
url.searchParams.set('client_id', d.clientId)
|
||||||
|
url.searchParams.set('response_type', 'code')
|
||||||
|
url.searchParams.set('redirect_uri', redirectUri())
|
||||||
|
// identify: wer es ist. guilds.members.read: welche Rollen er auf DIESEM
|
||||||
|
// Server hat. Mehr wird nicht erfragt — was man nicht hat, kann man nicht
|
||||||
|
// verlieren.
|
||||||
|
url.searchParams.set('scope', 'identify guilds.members.read')
|
||||||
|
url.searchParams.set('state', state)
|
||||||
|
|
||||||
|
return c.redirect(url.toString(), 302)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Schritt 3
|
||||||
|
|
||||||
|
discordRoutes.get('/discord/callback', async (c) => {
|
||||||
|
const d = discordConfig()
|
||||||
|
if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.')
|
||||||
|
|
||||||
|
// Discord meldet Ablehnungen selbst — etwa wenn jemand auf "Abbrechen"
|
||||||
|
// klickt. Das ist kein Fehler, sondern eine Antwort.
|
||||||
|
const fehler = c.req.query('error_description') ?? c.req.query('error')
|
||||||
|
if (fehler) return zurueck(c, `Discord: ${fehler}`)
|
||||||
|
|
||||||
|
const state = c.req.query('state')
|
||||||
|
const erwartet = getCookie(c, STATE_COOKIE)
|
||||||
|
deleteCookie(c, STATE_COOKIE, { path: '/' })
|
||||||
|
if (!state || !erwartet || state !== erwartet) {
|
||||||
|
return zurueck(c, 'Der Anmeldeversuch passt nicht zu diesem Browser. Noch einmal.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = c.req.query('code')
|
||||||
|
if (!code) return zurueck(c, 'Discord hat keinen Code mitgeschickt.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// -- Code gegen Zugangsmerkmal --------------------------------------
|
||||||
|
const tokenAntwort = await fetch('https://discord.com/api/oauth2/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: d.clientId,
|
||||||
|
client_secret: d.clientSecret,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenAntwort.ok) {
|
||||||
|
const text = (await tokenAntwort.text()).slice(0, 200)
|
||||||
|
// Der haeufigste Fall steht hier zuerst, weil er sonst eine
|
||||||
|
// Viertelstunde kostet.
|
||||||
|
return zurueck(
|
||||||
|
c,
|
||||||
|
`Discord lehnt den Tausch ab (${tokenAntwort.status}). Stimmt die Redirect-URI im Portal genau? ${text}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { access_token } = (await tokenAntwort.json()) as { access_token?: string }
|
||||||
|
if (!access_token) return zurueck(c, 'Discord hat kein Zugangsmerkmal geliefert.')
|
||||||
|
|
||||||
|
const mitMerkmal = { headers: { authorization: `Bearer ${access_token}` } }
|
||||||
|
|
||||||
|
// -- Wer ist das ----------------------------------------------------
|
||||||
|
const meAntwort = await fetch('https://discord.com/api/users/@me', mitMerkmal)
|
||||||
|
if (!meAntwort.ok) return zurueck(c, 'Discord gibt den Benutzer nicht heraus.')
|
||||||
|
const me = (await meAntwort.json()) as {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
global_name?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Und welche Rolle hat er hier -----------------------------------
|
||||||
|
const memberAntwort = await fetch(
|
||||||
|
`https://discord.com/api/users/@me/guilds/${d.guildId}/member`,
|
||||||
|
mitMerkmal,
|
||||||
|
)
|
||||||
|
if (memberAntwort.status === 404) {
|
||||||
|
return zurueck(c, 'Du bist nicht auf diesem Discord-Server.')
|
||||||
|
}
|
||||||
|
if (!memberAntwort.ok) {
|
||||||
|
return zurueck(
|
||||||
|
c,
|
||||||
|
`Die Mitgliedschaft liess sich nicht pruefen (${memberAntwort.status}). Stimmt die Server-ID?`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (await memberAntwort.json()) as { roles?: string[] }
|
||||||
|
// EINE der eingetragenen Rollen reicht. Mehrere Rollen sind der
|
||||||
|
// Normalfall, sobald mehr als eine Person Zugang haben soll — Admin
|
||||||
|
// und Fotograf sind selten dieselbe Rolle.
|
||||||
|
const hatRolle =
|
||||||
|
Array.isArray(member.roles) && member.roles.some((r) => d.roleIds.includes(r))
|
||||||
|
if (!hatRolle) {
|
||||||
|
// Bewusst ohne die Namen der Rollen: wer keine hat, muss auch nicht
|
||||||
|
// erfahren, welche es gaebe.
|
||||||
|
return zurueck(c, 'Dir fehlt eine der noetigen Rollen auf dem Server.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Konto finden oder anlegen --------------------------------------
|
||||||
|
let user = db.prepare('SELECT * FROM users WHERE discord_id = ?').get(me.id) as
|
||||||
|
| User
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Der Name muss eindeutig sein; zwei Leute koennen denselben
|
||||||
|
// Anzeigenamen haben. Bei Kollision haengt die Discord-ID dran.
|
||||||
|
const belegt = db.prepare('SELECT 1 FROM users WHERE username = ?').get(anzeige)
|
||||||
|
const username = belegt ? `${anzeige} (${me.id})` : anzeige
|
||||||
|
|
||||||
|
// KEIN brauchbares Passwort: verifyPassword scheitert an einem
|
||||||
|
// String, der nicht dem scrypt-Format entspricht — dieses Konto
|
||||||
|
// kommt also ausschliesslich ueber Discord herein.
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (username, password_hash, created_at, discord_id,
|
||||||
|
discord_avatar, discord_name)
|
||||||
|
VALUES (?, 'nur-ueber-discord', ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(username, now(), me.id, me.avatar ?? null, anzeige)
|
||||||
|
|
||||||
|
user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as User
|
||||||
|
console.log(`[discord] Konto angelegt: ${username} (${me.id})`)
|
||||||
|
} else {
|
||||||
|
// BEI JEDER ANMELDUNG nachziehen. Wer sein Bild wechselt, sieht
|
||||||
|
// sonst monatelang das alte — und wundert sich, ob er im richtigen
|
||||||
|
// Konto sitzt. Der ANZEIGENAME im Dashboard bleibt dagegen, wie er
|
||||||
|
// ist: an ihm haengen die Eintraege im Verlauf.
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE users SET discord_avatar = ?, discord_name = ? WHERE id = ?',
|
||||||
|
).run(me.avatar ?? null, anzeige, user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createSession(user.id)
|
||||||
|
setCookie(c, SESSION_COOKIE, session.id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !config.dev,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
path: '/',
|
||||||
|
expires: new Date(session.expiresAt),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.redirect('/', 302)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[discord]', err)
|
||||||
|
return zurueck(
|
||||||
|
c,
|
||||||
|
`Der Weg ueber Discord brach ab: ${err instanceof Error ? err.message : 'unbekannt'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// Der Umzugsweg: sprechen wie Fivemanage.
|
||||||
|
//
|
||||||
|
// WOZU. Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem
|
||||||
|
// Dutzend Ressourcen -- Kamera, Handy, MDT, Fahrzeugstudio, jede mit ihrem
|
||||||
|
// eigenen Autor und ihrer eigenen Fassung. Sie alle auf unsere Schnittstelle
|
||||||
|
// umzuschreiben heisst: ein Dutzend fremde Skripte anfassen, jedes davon
|
||||||
|
// beim naechsten Update wieder. Das tut niemand, und deshalb bliebe der
|
||||||
|
// Dienst ungenutzt neben dem alten stehen.
|
||||||
|
//
|
||||||
|
// SPRICHT DER DIENST DAGEGEN IHRE SPRACHE, ist der Umzug EINE Zeile je Skript:
|
||||||
|
// die Adresse. Der Schluessel bleibt derselbe Kopf, die Antwort dieselbe Form.
|
||||||
|
//
|
||||||
|
// NACHGESEHEN, NICHT GERATEN. Beide Formen stammen aus echtem Code:
|
||||||
|
//
|
||||||
|
// v1 github.com/Awleks/Devm-Camera, client/client.lua:
|
||||||
|
// requestScreenshotUpload('https://api.fivemanage.com/api/image',
|
||||||
|
// 'image', { headers = { Authorization = apiKey } })
|
||||||
|
// -> die Antwort wird als resp.url gelesen
|
||||||
|
//
|
||||||
|
// v3 github.com/fivemanage/sdk, features/images/server/main.ts:
|
||||||
|
// POST https://api.fivemanage.com/api/v3/file
|
||||||
|
// Felder: file, metadata?, path?, filename?, retentionExempt?
|
||||||
|
// -> { status, data: { id, url } }
|
||||||
|
//
|
||||||
|
// Der Schluessel steht bei beiden NACKT im Authorization-Kopf, ohne "Bearer".
|
||||||
|
// Das nimmt tokenFromHeader inzwischen an.
|
||||||
|
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import type { Context, Next } from 'hono'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { meldeUpload } from '../meldung.js'
|
||||||
|
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
||||||
|
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
|
import { kontingentUeberschritten } from './upload.js'
|
||||||
|
import {
|
||||||
|
checkPath,
|
||||||
|
kannBewegtVorschau,
|
||||||
|
kannVorschau,
|
||||||
|
lohntWebp,
|
||||||
|
mimeFor,
|
||||||
|
publicUrlFor,
|
||||||
|
safeFilename,
|
||||||
|
sha256,
|
||||||
|
writeBewegtThumb,
|
||||||
|
writeFileAtomic,
|
||||||
|
writeThumb,
|
||||||
|
writeWebp,
|
||||||
|
} from '../storage.js'
|
||||||
|
|
||||||
|
type Vars = { token: Token }
|
||||||
|
|
||||||
|
export const fivemanageRoutes = new Hono<{ Variables: Vars }>()
|
||||||
|
|
||||||
|
const wache = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
||||||
|
const token = tokenFromHeader(c.req.header('authorization'))
|
||||||
|
if (!token) {
|
||||||
|
// Die Form der FEHLERANTWORT ist auch Teil der Vertraeglichkeit: ein
|
||||||
|
// Skript, das auf `status` prueft, soll hier nicht ins Leere greifen.
|
||||||
|
return c.json({ status: 'error', message: 'Token fehlt oder ist unbekannt' }, 401)
|
||||||
|
}
|
||||||
|
if (token.expires_at && token.expires_at < now()) {
|
||||||
|
return c.json({ status: 'error', message: 'Token ist abgelaufen' }, 401)
|
||||||
|
}
|
||||||
|
c.set('token', token)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AN DIE EIGENEN PFADE, NICHT AN '*'.
|
||||||
|
//
|
||||||
|
// Mit '*' galt die Wache fuer alles unterhalb des Einhaengepunkts -- und der
|
||||||
|
// ist /api, also auch fuer /api/dash daneben. Das Dashboard bekam daraufhin
|
||||||
|
// 401 auf die ANMELDUNG, obwohl es nie einen Token haben kann.
|
||||||
|
//
|
||||||
|
// Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
|
||||||
|
// schon einmal passiert ist. Ich bin trotzdem hineingelaufen; gemerkt hat es
|
||||||
|
// der Gegentest, nicht der Kopf.
|
||||||
|
for (const weg of ['/image', '/video', '/audio', '/file', '/v3/file']) {
|
||||||
|
fivemanageRoutes.use(weg, wache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wo eine Datei landet, wenn der Aufrufer nichts sagt.
|
||||||
|
*
|
||||||
|
* UND SIE UEBERSCHREIBT NIE. Das ist der Unterschied zu /api/upload, wo der
|
||||||
|
* Pfad die Absicht ist: hier kommen Bildschirmfotos, die alle "screenshot.png"
|
||||||
|
* heissen. Ohne einen eigenen Namen je Aufruf ueberschriebe das zweite Foto
|
||||||
|
* das erste, und niemand merkte es -- der Aufrufer bekaeme sogar eine gueltige
|
||||||
|
* Adresse zurueck.
|
||||||
|
*
|
||||||
|
* Der Hash macht den Namen: gleicher Inhalt ergibt denselben Namen (zweimal
|
||||||
|
* dasselbe Bild kostet keinen zweiten Platz), verschiedener Inhalt einen
|
||||||
|
* anderen. Genau die Eigenschaft, die Fivemanages ID auch hat. */
|
||||||
|
function pfadFuer(token: Token, mime: string, digest: string, name?: string): string {
|
||||||
|
const basis = token.prefix ? token.prefix.replace(/\/+$/, '') : 'uploads'
|
||||||
|
const kurz = digest.slice(0, 12)
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const sauber = safeFilename(name, kurz)
|
||||||
|
const punkt = sauber.lastIndexOf('.')
|
||||||
|
const stamm = punkt > 0 ? sauber.slice(0, punkt) : sauber
|
||||||
|
const endung = punkt > 0 ? sauber.slice(punkt + 1) : endungFuer(mime)
|
||||||
|
return `${basis}/${stamm}-${kurz}.${endung}`
|
||||||
|
}
|
||||||
|
return `${basis}/${kurz}.${endungFuer(mime)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDUNGEN: Record<string, string> = {
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/wav': 'wav',
|
||||||
|
}
|
||||||
|
|
||||||
|
const endungFuer = (mime: string) => ENDUNGEN[mime.split(';')[0]?.trim().toLowerCase() ?? ''] ?? 'bin'
|
||||||
|
|
||||||
|
/** Die Datei aus dem Formular holen.
|
||||||
|
*
|
||||||
|
* DER FELDNAME IST NICHT FEST. screenshot-basic bekommt ihn als Argument
|
||||||
|
* uebergeben ('image' bei Devm-Camera), das SDK schickt 'file', und andere
|
||||||
|
* Skripte nehmen 'video' oder 'audio'. Ein Dienst, der nur eines davon
|
||||||
|
* annimmt, lehnt die Haelfte der Aufrufer mit "Feld fehlt" ab -- und der
|
||||||
|
* Aufrufer sucht den Fehler bei sich. */
|
||||||
|
const FELDER = ['file', 'image', 'video', 'audio', 'data', 'media'] as const
|
||||||
|
|
||||||
|
async function dateiAus(
|
||||||
|
c: Context<{ Variables: Vars }>,
|
||||||
|
): Promise<{ datei: File; pfad?: string; name?: string }> {
|
||||||
|
const typ = c.req.header('content-type') ?? ''
|
||||||
|
if (!typ.includes('multipart/form-data')) {
|
||||||
|
throw new Error('Erwartet wird multipart/form-data')
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBody und NICHT c.req.raw.formData(): letzteres nimmt Hono den Rumpf
|
||||||
|
// weg, und beim Aufraeumen schliesst dann jemand einen Strom, der schon zu
|
||||||
|
// ist. Das hat diesen Dienst schon einmal in eine Neustartschleife
|
||||||
|
// geschickt (siehe dash.ts).
|
||||||
|
const roh = await c.req.parseBody()
|
||||||
|
|
||||||
|
for (const feld of FELDER) {
|
||||||
|
const wert = roh[feld]
|
||||||
|
if (wert && typeof wert !== 'string') {
|
||||||
|
return {
|
||||||
|
datei: wert as unknown as File,
|
||||||
|
pfad: typeof roh['path'] === 'string' ? roh['path'] : undefined,
|
||||||
|
name: typeof roh['filename'] === 'string' ? roh['filename'] : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Keine Datei dabei — erwartet wird eines der Felder: ${FELDER.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was beim Ablegen herauskam: entweder die fertige Adresse, oder eine
|
||||||
|
* Antwort, die der Aufrufer so bekommen soll.
|
||||||
|
*
|
||||||
|
* Ein Entweder-Oder und kein "hat das Ergebnis ein status-Feld?" -- die
|
||||||
|
* Unterscheidung gehoert in den Typ und nicht in eine Ratefunktion. */
|
||||||
|
type Abgelegt =
|
||||||
|
| { ok: true; path: string; url: string; id: string }
|
||||||
|
| { ok: false; antwort: Response }
|
||||||
|
|
||||||
|
/** Der gemeinsame Weg. `art` schraenkt ein, was durchgeht -- /api/image nimmt
|
||||||
|
* eben nur Bilder, so wie es dort auch heisst. */
|
||||||
|
async function ablegen(
|
||||||
|
c: Context<{ Variables: Vars }>,
|
||||||
|
art?: 'image' | 'video' | 'audio',
|
||||||
|
): Promise<Abgelegt> {
|
||||||
|
const token = c.get('token')
|
||||||
|
|
||||||
|
let gefunden: Awaited<ReturnType<typeof dateiAus>>
|
||||||
|
try {
|
||||||
|
gefunden = await dateiAus(c)
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Rumpf unlesbar',
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { datei } = gefunden
|
||||||
|
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
if (datei.size > grenze) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{ status: 'error', message: `zu gross: ${datei.size} > ${grenze} Bytes` },
|
||||||
|
413,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await datei.arrayBuffer())
|
||||||
|
if (data.length === 0) {
|
||||||
|
return { ok: false, antwort: c.json({ status: 'error', message: 'leere Datei' }, 400) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dasselbe Kontingent wie auf dem eigenen Weg -- eine Grenze, die nur an
|
||||||
|
// einer von zwei Tueren haengt, ist keine.
|
||||||
|
const zuViel = kontingentUeberschritten(token, data.length)
|
||||||
|
if (zuViel) {
|
||||||
|
return { ok: false, antwort: c.json({ status: 'error', message: zuViel }, 413) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = sha256(data)
|
||||||
|
const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? ''
|
||||||
|
// Der Name entscheidet, wenn der Absender nur mit den Schultern zuckt --
|
||||||
|
// dieselbe Regel wie beim Dashboard-Upload.
|
||||||
|
const mime =
|
||||||
|
gemeldet && gemeldet !== 'application/octet-stream'
|
||||||
|
? gemeldet
|
||||||
|
: mimeFor(gefunden.name ?? datei.name ?? '')
|
||||||
|
|
||||||
|
if (art && !mime.startsWith(`${art}/`)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: `Hier gehen nur ${art}-Dateien hinein, das hier ist ${mime}`,
|
||||||
|
},
|
||||||
|
415,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein ausdruecklicher Pfad (v3 kennt das Feld) hat Vorrang -- aber er wird
|
||||||
|
// geprueft wie jeder andere, nicht zurechtgebogen.
|
||||||
|
let path: string
|
||||||
|
try {
|
||||||
|
path = gefunden.pfad
|
||||||
|
? checkPath(gefunden.pfad.replace(/^\/+|\/+$/g, ''))
|
||||||
|
: checkPath(pfadFuer(token, mime, digest, gefunden.name ?? datei.name))
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json({ status: 'error', message: (err as Error).message }, 400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenAllows(token, path)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: `dieser Token darf nur unter ${fesselText(token)} schreiben`,
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.arten) {
|
||||||
|
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
|
||||||
|
const meine = mime.startsWith('image/')
|
||||||
|
? 'bild'
|
||||||
|
: mime.startsWith('video/')
|
||||||
|
? 'video'
|
||||||
|
: mime.startsWith('audio/')
|
||||||
|
? 'ton'
|
||||||
|
: 'andere'
|
||||||
|
if (!erlaubt.includes(meine)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
antwort: c.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen`,
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
|
||||||
|
| Media
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
await writeFileAtomic(path, data)
|
||||||
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
||||||
|
if (lohntWebp(mime)) await writeWebp(path, data)
|
||||||
|
let dauer: number | null = null
|
||||||
|
if (kannBewegtVorschau(mime)) dauer = (await writeBewegtThumb(path, mime)).dauer
|
||||||
|
|
||||||
|
const zeit = now()
|
||||||
|
if (vorhanden) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(data.length, digest, mime, dauer, token.id, zeit, vorhanden.id)
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(path, data.length, digest, mime, dauer, token.id, zeit, zeit)
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name)
|
||||||
|
meldeUpload(path, data.length, token.name)
|
||||||
|
|
||||||
|
return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Die Wege
|
||||||
|
//
|
||||||
|
// v1: eine Adresse je Art, Antwort NUR { url, id }. Genau das liest
|
||||||
|
// Devm-Camera (resp.url), und genau das lesen die meisten aelteren
|
||||||
|
// Skripte. Zusaetzliche Felder schaden nicht, fehlende schon.
|
||||||
|
|
||||||
|
for (const [weg, art] of [
|
||||||
|
['/image', 'image'],
|
||||||
|
['/video', 'video'],
|
||||||
|
['/audio', 'audio'],
|
||||||
|
] as const) {
|
||||||
|
fivemanageRoutes.post(weg, async (c) => {
|
||||||
|
const r = await ablegen(c, art)
|
||||||
|
if (!r.ok) return r.antwort
|
||||||
|
return c.json({ url: r.url, id: r.id, path: r.path })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ohne Einschraenkung der Art -- der Sammelweg. */
|
||||||
|
fivemanageRoutes.post('/file', async (c) => {
|
||||||
|
const r = await ablegen(c)
|
||||||
|
if (!r.ok) return r.antwort
|
||||||
|
return c.json({ url: r.url, id: r.id, path: r.path })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** v3: dieselbe Arbeit, andere Verpackung.
|
||||||
|
*
|
||||||
|
* { status: "ok", data: { id, url } } -- so prueft es das SDK mit valibot,
|
||||||
|
* und ein fehlendes Feld laesst es die Antwort verwerfen. */
|
||||||
|
fivemanageRoutes.post('/v3/file', async (c) => {
|
||||||
|
const r = await ablegen(c)
|
||||||
|
if (!r.ok) return r.antwort
|
||||||
|
return c.json({ status: 'ok', data: { id: r.id, url: r.url } })
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Freigaben: einen Ordner ansehen, ohne sich anzumelden.
|
||||||
|
//
|
||||||
|
// WAS HIER WIRKLICH FREIGEGEBEN WIRD -- und das ist der Satz, den das Dashboard
|
||||||
|
// auch anzeigt: die DATEIEN sind ohnehin oeffentlich. Wer die Adresse eines
|
||||||
|
// Bildes kennt, bekommt es, mit oder ohne Freigabe. Neu ist die LISTE: welche
|
||||||
|
// Bilder es in einem Ordner gibt. Die war bisher nur hinter der Anmeldung zu
|
||||||
|
// haben.
|
||||||
|
//
|
||||||
|
// Der Schluessel in der Adresse ist KEIN Passwort. Wer den Link hat, sieht die
|
||||||
|
// Liste -- dieselbe Zusicherung wie bei einem Nextcloud-Freigabelink. Was hier
|
||||||
|
// NICHT geht: hochladen, loeschen, umbenennen, einen anderen Ordner sehen.
|
||||||
|
//
|
||||||
|
// Und was hier bewusst FEHLT: eine eigene Oberflaeche mit eigenem Aufbau. Die
|
||||||
|
// Freigabe liefert JSON, und die gebaute Oberflaeche zeigt es an -- eine zweite
|
||||||
|
// Galerie waere eine zweite, die beim naechsten Mal anders aussieht.
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { db, now, type Freigabe, type Media } from '../db.js'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { getSetting, MARKE_KEY } from '../settings.js'
|
||||||
|
import { publicUrlFor } from '../storage.js'
|
||||||
|
|
||||||
|
export const freigabeRoutes = new Hono()
|
||||||
|
|
||||||
|
/** Der Schluessel: 16 Zeichen aus 32 moeglichen, also rund 80 Bit.
|
||||||
|
*
|
||||||
|
* Zu viel, um ihn zu raten, und kurz genug, um ihn vorzulesen. Ohne i, l, o
|
||||||
|
* und die Null -- ein Link, den jemand aus einem Bildschirmfoto abtippt, soll
|
||||||
|
* nicht an einer Verwechslung scheitern. */
|
||||||
|
const ZEICHEN = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||||
|
|
||||||
|
export function neuerSchluessel(): string {
|
||||||
|
const roh = randomBytes(16)
|
||||||
|
return Array.from(roh, (b) => ZEICHEN[b % ZEICHEN.length]).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wieviele Dateien liegen unter diesem Ordner -- die WAHRE Zahl.
|
||||||
|
*
|
||||||
|
* Dieselbe Bedingung wie die Liste darunter, nur ohne Grenze. Zwei Abfragen
|
||||||
|
* statt einer, weil `zeilen.length` nach einem `LIMIT` eben nicht die Anzahl
|
||||||
|
* ist, sondern die Anzahl der geholten Zeilen. */
|
||||||
|
const GEZAEHLT = `SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`
|
||||||
|
|
||||||
|
/** Die Liste eines freigegebenen Ordners.
|
||||||
|
*
|
||||||
|
* OHNE ANMELDUNG, und deshalb wird hier genau abgemessen, was hinausgeht:
|
||||||
|
* Pfad, Groesse, Art, Adresse, Laenge. NICHT: Hash, Token, Zeitpunkte,
|
||||||
|
* Abrufzahlen, IDs. Nichts davon braucht jemand, der einen Katalog ansieht,
|
||||||
|
* und jedes davon waere eine Auskunft ueber den Betrieb dieses Dienstes. */
|
||||||
|
freigabeRoutes.get('/:schluessel', (c) => {
|
||||||
|
const eintrag = db
|
||||||
|
.prepare('SELECT * FROM freigaben WHERE schluessel = ?')
|
||||||
|
.get(c.req.param('schluessel')) as Freigabe | undefined
|
||||||
|
|
||||||
|
// Dieselbe Antwort fuer "gibt es nicht" und "gab es mal": ein Unterschied
|
||||||
|
// waere eine Auskunft an jemanden, der Schluessel durchprobiert.
|
||||||
|
if (!eintrag) return c.json({ error: 'nicht gefunden' }, 404)
|
||||||
|
|
||||||
|
db.prepare('UPDATE freigaben SET aufrufe = aufrufe + 1, zuletzt = ? WHERE id = ?').run(
|
||||||
|
now(),
|
||||||
|
eintrag.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
const maskiert = eintrag.ordner.replace(/[\\%_]/g, (z) => `\\${z}`)
|
||||||
|
// WIEVIELE ES WIRKLICH SIND -- getrennt gezaehlt, nicht aus der Liste
|
||||||
|
// abgeleitet.
|
||||||
|
//
|
||||||
|
// GEMESSEN am 22.08.2026: der Ordner "items" enthaelt 3685 Dateien, die
|
||||||
|
// Freigabeseite meldete 2000. Der Grund stand eine Zeile weiter unten --
|
||||||
|
// `LIMIT 2000` -- und `anzahl: zeilen.length` machte aus der Kappung eine
|
||||||
|
// Aussage: die Seite behauptete eine Vollstaendigkeit, die sie nicht hatte.
|
||||||
|
//
|
||||||
|
// Genau der Fall aus CLAUDE.md: eine Auskunft, die nicht alles sehen kann,
|
||||||
|
// meldet nicht die halbe Zahl als ganze, sondern sagt, dass sie kappt.
|
||||||
|
const gesamt = (
|
||||||
|
db
|
||||||
|
.prepare(GEZAEHLT)
|
||||||
|
.get(`${maskiert}/%`) as { n: number }
|
||||||
|
).n
|
||||||
|
|
||||||
|
// Die Grenze bleibt -- eine Freigabe auf einen Ordner mit 200000 Dateien
|
||||||
|
// soll den Dienst nicht in die Knie zwingen. Sie ist nur nicht mehr
|
||||||
|
// stillschweigend: was darueber liegt, wird unten benannt.
|
||||||
|
const GRENZE = 20000
|
||||||
|
const zeilen = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT path, size, mime, dauer FROM media
|
||||||
|
WHERE path LIKE ? ESCAPE '\\' ORDER BY path COLLATE NOCASE LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(`${maskiert}/%`, GRENZE) as Pick<Media, 'path' | 'size' | 'mime' | 'dauer'>[]
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
// Auch hier der Name aus derselben Einstellung -- die Freigabeseite ist
|
||||||
|
// oeffentlich und holt sonst nichts, was ihn kennen koennte.
|
||||||
|
marke: getSetting(MARKE_KEY) || 'D4RKST3R',
|
||||||
|
titel: eintrag.titel || eintrag.ordner,
|
||||||
|
ordner: eintrag.ordner,
|
||||||
|
// Der Praefix, den die Oberflaeche abschneidet -- damit dort
|
||||||
|
// "adder.webp" steht und nicht "vehicles/adder.webp". Wer den ganzen
|
||||||
|
// Pfad sehen will, sieht ihn in der Adresse.
|
||||||
|
// Die WIRKLICHE Zahl, nicht die der ausgelieferten Zeilen.
|
||||||
|
anzahl: gesamt,
|
||||||
|
// Und wieviele davon hier stehen. Sind beide gleich, ist die Liste
|
||||||
|
// vollstaendig; sonst weiss die Oberflaeche, dass sie es sagen muss.
|
||||||
|
gezeigt: zeilen.length,
|
||||||
|
items: zeilen.map((r) => ({
|
||||||
|
name: r.path.slice(eintrag.ordner.length + 1),
|
||||||
|
size: r.size,
|
||||||
|
mime: r.mime,
|
||||||
|
dauer: r.dauer,
|
||||||
|
url: publicUrlFor(r.path),
|
||||||
|
// Die Vorschau liegt unter /t/ und braucht keine Anmeldung -- das
|
||||||
|
// war schon vorher so, sonst zeigte das Dashboard keine Kacheln.
|
||||||
|
thumb: `${config.publicUrl}/t/${r.path}`,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// Der Endpunkt fuer die Statusseite.
|
||||||
|
//
|
||||||
|
// WARUM NICHT /health. Das gibt es und bleibt, wie es ist:
|
||||||
|
//
|
||||||
|
// app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
//
|
||||||
|
// Diese Zeile beweist genau eines -- der Prozess nimmt HTTP-Anfragen an. Sie
|
||||||
|
// haengt am HEALTHCHECK im Dockerfile, und DORT ist eine billige Pruefung
|
||||||
|
// richtig: eine schwere, die bei einer langsamen Platte einmal ausfaellt,
|
||||||
|
// liesse Docker den Container neu starten. Der Dienst kippte dann genau dann,
|
||||||
|
// wenn er unter Last steht.
|
||||||
|
//
|
||||||
|
// Fuer eine Statusseite ist sie aber zu duenn. Sie stuende auf Gruen, waehrend
|
||||||
|
// die Platte voll ist und kein Upload mehr angenommen wird.
|
||||||
|
//
|
||||||
|
// HIER WIRD DESHALB WIRKLICH NACHGESEHEN, und zwar an den vier Stellen, an
|
||||||
|
// denen dieser Dienst scheitern kann:
|
||||||
|
//
|
||||||
|
// Datenbank eine echte Abfrage, nicht "die Datei ist da"
|
||||||
|
// Platte schreiben UND wieder loeschen -- der einzige Beweis
|
||||||
|
// Ausliefern eine Datei aus der Datenbank suchen und nachmessen
|
||||||
|
// Sicherung wie alt ist der letzte Lauf
|
||||||
|
//
|
||||||
|
// WAS NICHT HINAUSGEHT: Dateizahlen, Groessen, Pfade, Tokennamen, Benutzer.
|
||||||
|
// Ein oeffentlicher Endpunkt sagt "gesund" oder "krank" -- nicht, wie gross
|
||||||
|
// der Bestand ist. Die einzige Zahl, die herausgeht, ist die Laufzeit, und die
|
||||||
|
// steht in jedem Serverkopf ohnehin naeher dran, als es hier stuende.
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { readdir, rm, stat, statfs, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { db, now, type Media } from '../db.js'
|
||||||
|
import { getSetting } from '../settings.js'
|
||||||
|
import { absolutePath } from '../storage.js'
|
||||||
|
|
||||||
|
export const statusRoutes = new Hono()
|
||||||
|
|
||||||
|
type Pruefung = { was: string; ok: boolean; hinweis?: string }
|
||||||
|
|
||||||
|
/** Wie alt eine Sicherung sein darf, bevor es auffaellt. 26 Stunden -- die
|
||||||
|
* gleiche Schwelle wie beim Wachhund in meldung.ts, damit nicht zwei Stellen
|
||||||
|
* verschiedener Meinung darueber sind, was "aktuell" heisst. */
|
||||||
|
const SICHERUNG_STUNDEN = 26
|
||||||
|
|
||||||
|
// -- Nicht bei jeder Anfrage rechnen -----------------------------------------
|
||||||
|
//
|
||||||
|
// Der Endpunkt ist OEFFENTLICH und schreibt auf die Platte. Ohne Bremse waere
|
||||||
|
// er ein Verstaerker: wer ihn im Sekundentakt abruft, erzeugt im Sekundentakt
|
||||||
|
// Schreibvorgaenge. Ein Ergebnis von vor zehn Sekunden ist fuer eine
|
||||||
|
// Statusseite so gut wie ein frisches -- Monitore fragen ohnehin im
|
||||||
|
// Minutentakt.
|
||||||
|
const FRISCHE_MS = 10_000
|
||||||
|
let letzte: { zeit: number; antwort: Antwort } | null = null
|
||||||
|
|
||||||
|
type Antwort = {
|
||||||
|
dienst: string
|
||||||
|
ok: boolean
|
||||||
|
stand: 'gesund' | 'beeintraechtigt' | 'gestoert'
|
||||||
|
seit: number
|
||||||
|
pruefungen: Pruefung[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefen(): Promise<Antwort> {
|
||||||
|
const pruefungen: Pruefung[] = []
|
||||||
|
|
||||||
|
// -- Datenbank ------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Eine ECHTE Abfrage gegen eine echte Tabelle. `SELECT 1` bewiese nur, dass
|
||||||
|
// SQLite antwortet; eine beschaedigte Datei faellt erst auf, wenn jemand
|
||||||
|
// eine Seite davon liest.
|
||||||
|
let medien = 0
|
||||||
|
try {
|
||||||
|
medien = (db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }).n
|
||||||
|
pruefungen.push({ was: 'datenbank', ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'datenbank',
|
||||||
|
ok: false,
|
||||||
|
hinweis: err instanceof Error ? err.message : 'nicht lesbar',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Platte ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// SCHREIBEN und wieder loeschen. Ein `stat` auf den Ordner bewiese nur, dass
|
||||||
|
// es ihn gibt -- eine volle oder schreibgeschuetzte Platte sieht dabei
|
||||||
|
// genauso aus wie eine gesunde. Die Probe liegt im Datenordner und NICHT
|
||||||
|
// unter files/: dort wuerde sie der Verwaisten-Sucher finden.
|
||||||
|
const probe = join(config.dataDir, `.status-${process.pid}-${randomBytes(4).toString('hex')}`)
|
||||||
|
try {
|
||||||
|
await writeFile(probe, 'probe')
|
||||||
|
await rm(probe, { force: true })
|
||||||
|
pruefungen.push({ was: 'platte', ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
await rm(probe, { force: true }).catch(() => {})
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'platte',
|
||||||
|
ok: false,
|
||||||
|
hinweis: err instanceof Error ? err.message : 'nicht beschreibbar',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Ausliefern -----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Die Probe aufs Exempel: eine Datei aus der Datenbank holen und nachsehen,
|
||||||
|
// ob sie mit der eingetragenen Groesse dort liegt. Damit haengen Datenbank
|
||||||
|
// UND Platte UND die Pfadaufloesung gemeinsam mit im Test -- das ist der
|
||||||
|
// Unterschied zwischen "beide Teile leben" und "sie passen zueinander".
|
||||||
|
//
|
||||||
|
// Ein leerer Bestand ist kein Fehler: ein frisch aufgesetzter Dienst ist
|
||||||
|
// gesund und hat nichts auszuliefern.
|
||||||
|
if (medien > 0) {
|
||||||
|
try {
|
||||||
|
const zeile = db
|
||||||
|
.prepare('SELECT path, size FROM media ORDER BY RANDOM() LIMIT 1')
|
||||||
|
.get() as Pick<Media, 'path' | 'size'>
|
||||||
|
const info = await stat(absolutePath(zeile.path))
|
||||||
|
if (info.size === zeile.size) {
|
||||||
|
pruefungen.push({ was: 'ausliefern', ok: true })
|
||||||
|
} else {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'ausliefern',
|
||||||
|
ok: false,
|
||||||
|
hinweis: 'Groesse auf der Platte weicht vom Eintrag ab',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Der Pfad steht ABSICHTLICH nicht in der Meldung: das hier ist
|
||||||
|
// oeffentlich.
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'ausliefern',
|
||||||
|
ok: false,
|
||||||
|
hinweis: 'eine eingetragene Datei liegt nicht auf der Platte',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// KEIN EINTRAG, ABER DATEIEN AUF DER PLATTE -- der eine Zustand, in dem
|
||||||
|
// alles gesund AUSSIEHT und trotzdem alles weg ist.
|
||||||
|
//
|
||||||
|
// Nachgemessen, und zwar an genau diesem Dienst: wird eine Sicherung
|
||||||
|
// zurueckgespielt, ohne media.db-wal daneben zu entfernen, spielt SQLite
|
||||||
|
// das WAL der ALTEN Datenbank ueber die neue. Ergebnis: 0 Zeilen,
|
||||||
|
// waehrend 4452 Dateien danebenliegen. Der Dienst kommt hoch, /health
|
||||||
|
// ist gruen, die Mediathek ist leer.
|
||||||
|
//
|
||||||
|
// Ein frisch aufgesetzter Dienst hat weder Eintraege noch Dateien und
|
||||||
|
// ist voellig gesund. Erst der WIDERSPRUCH aus beidem ist das Zeichen.
|
||||||
|
try {
|
||||||
|
const drin = await readdir(config.filesDir)
|
||||||
|
if (drin.length > 0) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'bestand',
|
||||||
|
ok: false,
|
||||||
|
hinweis: 'kein Eintrag in der Datenbank, aber Dateien auf der Platte',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* kein Dateiordner -- bei einem frischen Dienst normal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Platz ----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Nur als Beeintraechtigung, nicht als Stoerung: bei 95 % laeuft alles
|
||||||
|
// noch, aber nicht mehr lange. Prozent statt Gigabyte -- die absolute Zahl
|
||||||
|
// waere eine Auskunft ueber die Maschine.
|
||||||
|
try {
|
||||||
|
const s = await statfs(config.dataDir)
|
||||||
|
const belegt = 1 - (s.bavail * s.bsize) / (s.blocks * s.bsize)
|
||||||
|
if (belegt >= 0.95) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'platz',
|
||||||
|
ok: false,
|
||||||
|
hinweis: `${Math.round(belegt * 100)} % belegt`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pruefungen.push({ was: 'platz', ok: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Auf manchen Dateisystemen gibt es statfs nicht. Kein Grund, den
|
||||||
|
// Dienst deswegen krank zu nennen.
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sicherung ------------------------------------------------------------
|
||||||
|
const roh = getSetting('sicherung.letzte')
|
||||||
|
if (!roh) {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'noch keine gemeldet' })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const l = JSON.parse(roh) as {
|
||||||
|
at: number
|
||||||
|
ok: boolean
|
||||||
|
hoch?: boolean
|
||||||
|
zweit?: boolean
|
||||||
|
}
|
||||||
|
const stunden = (now() - l.at) / 3_600_000
|
||||||
|
if (!l.ok) {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'letzte fehlgeschlagen' })
|
||||||
|
} else if (stunden > SICHERUNG_STUNDEN) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'sicherung',
|
||||||
|
ok: false,
|
||||||
|
hinweis: `letzte vor ${Math.round(stunden)} Stunden`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// WIEVIELE ORTE, nicht nur "gelaufen". Eine Sicherung, die es
|
||||||
|
// nur einmal gibt, ist gruen und trotzdem eine, die ein
|
||||||
|
// Plattenausfall mitnimmt -- das gehoert auf die Statusseite,
|
||||||
|
// wo jemand hinschaut, und nicht nur ins Panel.
|
||||||
|
const orte = 1 + (l.zweit ? 1 : 0) + (l.hoch ? 1 : 0)
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'sicherung',
|
||||||
|
ok: true,
|
||||||
|
hinweis: orte === 1 ? 'nur an einem Ort' : `an ${orte} Orten`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'Eintrag unlesbar' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Der Waechter ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Geschrieben von tools/waechter.ps1 auf dem WIRT -- der Dienst im Container
|
||||||
|
// sieht weder die Ports der Maschine noch ihre Dateien. Hier steht nur, was
|
||||||
|
// der Waechter zuletzt gefunden hat.
|
||||||
|
//
|
||||||
|
// FEHLT DER EINTRAG GANZ, ist der Waechter nicht eingerichtet. Das ist kein
|
||||||
|
// Fehler des Dienstes, aber es gehoert gesagt: eine Statusseite, die
|
||||||
|
// schweigt, weil niemand hinsieht, ist genau das Problem, gegen das der
|
||||||
|
// Waechter gebaut wurde.
|
||||||
|
const wRoh = getSetting('waechter.letzte')
|
||||||
|
if (!wRoh) {
|
||||||
|
pruefungen.push({ was: 'waechter', ok: true, hinweis: 'nicht eingerichtet' })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const w = JSON.parse(wRoh) as {
|
||||||
|
at: number
|
||||||
|
ok: boolean
|
||||||
|
ports?: number
|
||||||
|
heikel?: number[]
|
||||||
|
befund?: string[]
|
||||||
|
}
|
||||||
|
const stunden = (now() - w.at) / 3_600_000
|
||||||
|
if (stunden > 2) {
|
||||||
|
// Er laeuft alle 15 Minuten. Zwei Stunden Schweigen heisst: er
|
||||||
|
// laeuft nicht mehr -- und ein Waechter, von dem man glaubt,
|
||||||
|
// dass er wacht, ist schlimmer als keiner.
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'waechter',
|
||||||
|
ok: false,
|
||||||
|
hinweis: `meldet sich seit ${Math.round(stunden)} Stunden nicht`,
|
||||||
|
})
|
||||||
|
} else if (!w.ok) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'waechter',
|
||||||
|
ok: false,
|
||||||
|
hinweis: (w.befund ?? []).join('; ') || 'Befund ohne Text',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const heikel = w.heikel?.length
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'waechter',
|
||||||
|
ok: true,
|
||||||
|
hinweis: heikel
|
||||||
|
? `${w.ports} Ports offen, davon ${heikel} heikel`
|
||||||
|
: `${w.ports} Ports offen`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pruefungen.push({ was: 'waechter', ok: false, hinweis: 'Eintrag unlesbar' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Das Urteil -----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// ZWEI STUFEN, und der Unterschied ist der zwischen "tut es nicht" und
|
||||||
|
// "braucht Aufmerksamkeit". Datenbank, Platte und Ausliefern sind das
|
||||||
|
// Geschaeft dieses Dienstes -- faellt eines aus, ist er gestoert. Eine alte
|
||||||
|
// Sicherung oder eine volle Platte tun heute noch nicht weh und morgen
|
||||||
|
// sehr.
|
||||||
|
const HART = ['datenbank', 'platte', 'ausliefern', 'bestand']
|
||||||
|
const gestoert = pruefungen.some((p) => HART.includes(p.was) && !p.ok)
|
||||||
|
const schwach = pruefungen.some((p) => !p.ok)
|
||||||
|
|
||||||
|
return {
|
||||||
|
dienst: 'd4rk_media',
|
||||||
|
ok: !gestoert,
|
||||||
|
stand: gestoert ? 'gestoert' : schwach ? 'beeintraechtigt' : 'gesund',
|
||||||
|
// Auf Sekunden gerundet -- eine Statusseite zeigt "seit 3 Tagen" an,
|
||||||
|
// keine Millisekunden.
|
||||||
|
seit: Math.round(process.uptime()),
|
||||||
|
pruefungen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Endpunkt.
|
||||||
|
*
|
||||||
|
* `?streng=1` laesst auch eine Beeintraechtigung mit 503 antworten. Ohne den
|
||||||
|
* Schalter ist eine alte Sicherung kein "down" -- der Dienst liefert ja aus.
|
||||||
|
* WELCHES von beiden richtig ist, weiss nur, wer die Statusseite betreibt:
|
||||||
|
* deshalb ein Schalter und keine Entscheidung meinerseits. */
|
||||||
|
statusRoutes.get('/', async (c) => {
|
||||||
|
if (!letzte || now() - letzte.zeit > FRISCHE_MS) {
|
||||||
|
letzte = { zeit: now(), antwort: await pruefen() }
|
||||||
|
}
|
||||||
|
const a = letzte.antwort
|
||||||
|
const streng = c.req.query('streng') === '1'
|
||||||
|
const schlecht = streng ? a.stand !== 'gesund' : !a.ok
|
||||||
|
|
||||||
|
return c.json(a, schlecht ? 503 : 200, {
|
||||||
|
// Eine zwischengespeicherte Statusmeldung ist keine. Der Proxy greift
|
||||||
|
// hier zwar nicht zu (die Adresse endet auf keine Bildendung), aber das
|
||||||
|
// ist eine Einstellung und keine Zusicherung.
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
})
|
||||||
|
})
|
||||||
+218
-24
@@ -22,18 +22,26 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import type { Context, Next } from 'hono'
|
import type { Context, Next } from 'hono'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { db, now, type Media, type Token } from '../db.js'
|
import { meldeUpload } from '../meldung.js'
|
||||||
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
||||||
|
import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
import {
|
import {
|
||||||
PathError,
|
PathError,
|
||||||
checkPath,
|
checkPath,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
extensionFor,
|
extensionFor,
|
||||||
fileExists,
|
fileExists,
|
||||||
|
kannBewegtVorschau,
|
||||||
|
vielleichtKleiner,
|
||||||
|
kannVorschau,
|
||||||
|
lohntWebp,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
publicUrlFor,
|
publicUrlFor,
|
||||||
sha256,
|
sha256,
|
||||||
writeFileAtomic,
|
writeFileAtomic,
|
||||||
|
writeBewegtThumb,
|
||||||
|
writeThumb,
|
||||||
|
writeWebp,
|
||||||
} from '../storage.js'
|
} from '../storage.js'
|
||||||
|
|
||||||
type Vars = { token: Token }
|
type Vars = { token: Token }
|
||||||
@@ -50,12 +58,90 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
|
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
|
||||||
}
|
}
|
||||||
|
// Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es:
|
||||||
|
// sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token.
|
||||||
|
if (token.expires_at && token.expires_at < now()) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.`,
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
}
|
||||||
c.set('token', token)
|
c.set('token', token)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prueft das Gesamtkontingent. Gibt einen SATZ zurueck, wenn es reisst.
|
||||||
|
*
|
||||||
|
* Die Meldung nennt Zahlen: "belegt 1,9 GB von 2 GB, diese Datei braucht 130
|
||||||
|
* KB" beantwortet die Frage, die man danach hat. Ein blosses "Kontingent
|
||||||
|
* erschoepft" beantwortet sie nicht.
|
||||||
|
*
|
||||||
|
* Ein ERSETZEN zaehlt hier grosszuegig als voller Neuzugang: der genaue Wert
|
||||||
|
* waere (neu - alt), aber dafuer muesste hier schon feststehen, unter welchem
|
||||||
|
* Pfad die Datei landet -- und das steht erst nach dem Lesen des Rumpfes fest.
|
||||||
|
* Die Ungenauigkeit geht zugunsten der Grenze, nicht darueber hinaus. */
|
||||||
|
export function kontingentUeberschritten(token: Token, kommt: number): string | null {
|
||||||
|
if (!token.quota_bytes) return null
|
||||||
|
|
||||||
|
const belegt = (
|
||||||
|
db
|
||||||
|
.prepare('SELECT COALESCE(SUM(size),0) AS b FROM media WHERE token_id = ?')
|
||||||
|
.get(token.id) as { b: number }
|
||||||
|
).b
|
||||||
|
|
||||||
|
if (belegt + kommt <= token.quota_bytes) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
`Kontingent erschoepft: dieser Token haelt ${groesse(belegt)} von ` +
|
||||||
|
`${groesse(token.quota_bytes)}, und diese Datei braucht ${groesse(kommt)}.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes lesbar -- mit MITWACHSENDER Einheit.
|
||||||
|
*
|
||||||
|
* Erst stand hier ein festes "MB", und die Meldung lautete daraufhin
|
||||||
|
* "haelt 0.0 MB von 0.0 MB, und diese Datei braucht 0.0 MB". Formal richtig,
|
||||||
|
* praktisch eine Meldung, die genau die Frage nicht beantwortet, wegen der man
|
||||||
|
* sie liest. Bei einem Kontingent von 20 KB muss dort KB stehen.
|
||||||
|
*
|
||||||
|
* 1024 und nicht 1000: dieselbe Rechnung wie in der Oberflaeche, sonst nennt
|
||||||
|
* der Dienst eine andere Zahl als das Dashboard daneben. */
|
||||||
|
function groesse(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
const einheiten = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let wert = n / 1024
|
||||||
|
let i = 0
|
||||||
|
while (wert >= 1024 && i < einheiten.length - 1) {
|
||||||
|
wert /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Eine Nachkommastelle nur, wo sie etwas sagt: "1,5 GB" ja, "512,0 KB" nein.
|
||||||
|
return `${wert < 10 ? wert.toFixed(1) : Math.round(wert)} ${einheiten[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Medienart ist das? Dieselben Namen wie im Dashboard.
|
||||||
|
*
|
||||||
|
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
|
||||||
|
* Galerie nicht auseinanderlaufen koennen. */
|
||||||
|
function artVon(mime: string): string {
|
||||||
|
const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||||
|
if (m.startsWith('image/')) return 'bild'
|
||||||
|
if (m.startsWith('video/')) return 'video'
|
||||||
|
if (m.startsWith('audio/')) return 'ton'
|
||||||
|
if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json'))
|
||||||
|
return 'dokument'
|
||||||
|
return 'andere'
|
||||||
|
}
|
||||||
|
|
||||||
uploadRoutes.use('/upload', requireToken)
|
uploadRoutes.use('/upload', requireToken)
|
||||||
uploadRoutes.use('/media/*', requireToken)
|
uploadRoutes.use('/media/*', requireToken)
|
||||||
|
// BEIDE Formen hinter der Wache. Die Abfrageform steht als eigener Eintrag da:
|
||||||
|
// '/exists/*' trifft sie NICHT, weil dort kein Schraegstrich mehr folgt -- und
|
||||||
|
// eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen
|
||||||
|
// soll.
|
||||||
|
uploadRoutes.use('/exists', requireToken)
|
||||||
uploadRoutes.use('/exists/*', requireToken)
|
uploadRoutes.use('/exists/*', requireToken)
|
||||||
|
|
||||||
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
|
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
|
||||||
@@ -94,14 +180,24 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
|
|
||||||
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
|
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
|
||||||
// abgewiesen wird, BEVOR er im Speicher liegt.
|
// abgewiesen wird, BEVOR er im Speicher liegt.
|
||||||
|
// Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses
|
||||||
|
// Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB.
|
||||||
|
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
|
||||||
const announced = Number(c.req.header('content-length') ?? 0)
|
const announced = Number(c.req.header('content-length') ?? 0)
|
||||||
if (announced > config.maxUploadBytes) {
|
if (announced > grenze) {
|
||||||
return c.json(
|
return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413)
|
||||||
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
|
|
||||||
413,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DAS GESAMTKONTINGENT, geprueft BEVOR der Rumpf im Speicher liegt.
|
||||||
|
//
|
||||||
|
// max_bytes daneben gilt je Datei -- ein Token mit "hoechstens 2 MB je
|
||||||
|
// Bild" konnte die Platte trotzdem fuellen, es brauchte nur genug Bilder.
|
||||||
|
// Gezaehlt wird ueber media.token_id, also ueber das, was WIRKLICH liegt,
|
||||||
|
// und nicht ueber einen mitlaufenden Zaehler, der auseinanderlaufen kann.
|
||||||
|
const zuViel = kontingentUeberschritten(token, announced)
|
||||||
|
if (zuViel) return c.json({ error: zuViel }, 413)
|
||||||
|
|
||||||
let body: Awaited<ReturnType<typeof readBody>>
|
let body: Awaited<ReturnType<typeof readBody>>
|
||||||
try {
|
try {
|
||||||
body = await readBody(c)
|
body = await readBody(c)
|
||||||
@@ -111,12 +207,13 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
|
|
||||||
const { data } = body
|
const { data } = body
|
||||||
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
|
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
|
||||||
if (data.length > config.maxUploadBytes) {
|
if (data.length > grenze) {
|
||||||
return c.json(
|
return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413)
|
||||||
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
|
|
||||||
413,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// Noch einmal mit der WIRKLICHEN Groesse: content-length ist eine Ansage
|
||||||
|
// des Aufrufers, keine Tatsache.
|
||||||
|
const zuViel2 = kontingentUeberschritten(token, data.length)
|
||||||
|
if (zuViel2) return c.json({ error: zuViel2 }, 413)
|
||||||
|
|
||||||
const digest = sha256(data)
|
const digest = sha256(data)
|
||||||
|
|
||||||
@@ -139,7 +236,7 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
|
|
||||||
if (!tokenAllows(token, path)) {
|
if (!tokenAllows(token, path)) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` },
|
{ error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
|
||||||
403,
|
403,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,33 +255,85 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
|
|
||||||
const mime = c.req.header('x-mime') ?? mimeFor(path)
|
const mime = c.req.header('x-mime') ?? mimeFor(path)
|
||||||
|
|
||||||
|
if (token.arten) {
|
||||||
|
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
|
||||||
|
const art = artVon(mime)
|
||||||
|
if (!erlaubt.includes(art)) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen — das hier ist "${art}" (${mime}).`,
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VOR dem Schreiben verkleinern, wenn der Token es verlangt. Danach waere
|
||||||
|
// es zu spaet: dann laege das grosse Bild schon auf der Platte, und der
|
||||||
|
// Hash im Datensatz gehoerte zu einer anderen Datei als der abgelegten.
|
||||||
|
let daten = data
|
||||||
|
let verkleinert: false | { von: string; auf: string } = false
|
||||||
|
if (token.max_kante) {
|
||||||
|
const r = await vielleichtKleiner(data, mime, token.max_kante)
|
||||||
|
daten = r.data
|
||||||
|
verkleinert = r.verkleinert
|
||||||
|
}
|
||||||
|
|
||||||
|
// DER HASH GEHOERT ZU DEM, WAS WIRKLICH LIEGT. Weiter oben wird er aus dem
|
||||||
|
// Original gerechnet -- der braucht ihn fuer den Ersatzpfad, wenn kein
|
||||||
|
// X-Path kam. Wird das Bild verkleinert, ist das eine ANDERE Datei, und ein
|
||||||
|
// Datensatz mit dem Hash des Originals waere schlicht falsch: der
|
||||||
|
// Verwaisten-Sucher und jede spaetere Gegenprobe verglichen dann gegen
|
||||||
|
// etwas, das nirgends liegt.
|
||||||
|
const digestAbgelegt = verkleinert ? sha256(daten) : digest
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeFileAtomic(path, data)
|
await writeFileAtomic(path, daten)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[upload] Schreiben fehlgeschlagen:', err)
|
console.error('[upload] Schreiben fehlgeschlagen:', err)
|
||||||
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, daten)
|
||||||
|
if (lohntWebp(mime)) await writeWebp(path, daten)
|
||||||
|
|
||||||
|
// Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und
|
||||||
|
// deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge faellt
|
||||||
|
// beim selben Blick in die Datei mit ab.
|
||||||
|
let dauer: number | null = null
|
||||||
|
if (kannBewegtVorschau(mime)) {
|
||||||
|
dauer = (await writeBewegtThumb(path, mime)).dauer
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = now()
|
const timestamp = now()
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE media SET size = ?, sha256 = ?, mime = ?, token_id = ?, updated_at = ?
|
`UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
).run(data.length, digest, mime, token.id, timestamp, existing.id)
|
).run(daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, existing.id)
|
||||||
} else {
|
} else {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
|
`INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
|
).run(path, daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name)
|
||||||
|
meldeUpload(path, daten.length, token.name)
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
url: publicUrlFor(path),
|
url: publicUrlFor(path),
|
||||||
path,
|
path,
|
||||||
size: data.length,
|
size: daten.length,
|
||||||
sha256: digest,
|
sha256: digestAbgelegt,
|
||||||
mime,
|
mime,
|
||||||
replaced: Boolean(existing),
|
replaced: Boolean(existing),
|
||||||
|
// Nur dann, wenn wirklich gerechnet wurde -- ein Feld, das immer
|
||||||
|
// dasteht, sagt nichts.
|
||||||
|
...(verkleinert ? { verkleinert } : {}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,17 +363,54 @@ uploadRoutes.delete('/media/:pfad{.+}', async (c) => {
|
|||||||
}
|
}
|
||||||
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
|
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
|
||||||
|
|
||||||
|
// Die Groesse VOR dem Loeschen holen: danach steht sie nirgends mehr, und
|
||||||
|
// ein Verlaufseintrag ohne Groesse beantwortet die Frage "wie viel Platz
|
||||||
|
// wurde da frei" nicht.
|
||||||
|
const zeile = db.prepare('SELECT size FROM media WHERE path = ?').get(path) as
|
||||||
|
| { size: number }
|
||||||
|
| undefined
|
||||||
|
|
||||||
await deleteFile(path)
|
await deleteFile(path)
|
||||||
db.prepare('DELETE FROM media WHERE path = ?').run(path)
|
db.prepare('DELETE FROM media WHERE path = ?').run(path)
|
||||||
|
logEvent('delete', path, zeile?.size ?? null, 'token', token.name)
|
||||||
return c.json({ deleted: path })
|
return c.json({ deleted: path })
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
|
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
|
||||||
* beantworten kann, ohne 900 Bilder hochzuladen. */
|
* beantworten kann, ohne 900 Bilder hochzuladen.
|
||||||
uploadRoutes.get('/exists/:pfad{.+}', (c) => {
|
*
|
||||||
|
* ZWEI FORMEN, UND DIE ZWEITE IST DIE WICHTIGE:
|
||||||
|
*
|
||||||
|
* GET /api/exists/vehicles/adder.webp die alte, mit Pfad
|
||||||
|
* GET /api/exists?pfad=vehicles/adder.webp die neue, mit Abfrage
|
||||||
|
*
|
||||||
|
* Der Grund ist NACHGEMESSEN und unangenehm. Nginx Proxy Manager bringt eine
|
||||||
|
* `assets.conf` mit, deren Regel auf JEDE URL greift, die auf .webp, .png, .js
|
||||||
|
* ... endet:
|
||||||
|
*
|
||||||
|
* location ~* ^.*\.(css|js|jpe?g|gif|png|webp|...)$ {
|
||||||
|
* proxy_cache public-cache;
|
||||||
|
* proxy_ignore_headers Set-Cookie Cache-Control Expires ...;
|
||||||
|
* proxy_cache_valid any 30m;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Die ERSTE Form endet auf ".webp" und faellt damit hinein. Der Proxy legt die
|
||||||
|
* Antwort weg -- und liefert sie danach an JEDEN aus, auch ohne Token.
|
||||||
|
* Gemessen: 200 mit vollstaendiger Auskunft, Authorization-Kopf gar nicht
|
||||||
|
* gesetzt. Unser eigenes `Cache-Control` hilft nicht, `proxy_ignore_headers`
|
||||||
|
* wirft es weg.
|
||||||
|
*
|
||||||
|
* Ein `location ^~ /api/`-Block im Proxy schliesst das -- aber eine
|
||||||
|
* Zugangspruefung, die an einer Zeile in einer fremden Oberflaeche haengt, ist
|
||||||
|
* keine. Die ZWEITE Form endet nie auf eine Bildendung (die Abfrage zaehlt
|
||||||
|
* fuer die Regel nicht mit) und ist deshalb von sich aus dicht.
|
||||||
|
*
|
||||||
|
* Die alte Form bleibt, weil Skripte sie benutzen -- aber die Dokumentation
|
||||||
|
* nennt nur noch die neue. */
|
||||||
|
function existsAntwort(c: Context<{ Variables: Vars }>, roh: string) {
|
||||||
let path: string
|
let path: string
|
||||||
try {
|
try {
|
||||||
path = checkPath(c.req.param('pfad'))
|
path = checkPath(roh)
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ exists: false }, 200)
|
return c.json({ exists: false }, 200)
|
||||||
}
|
}
|
||||||
@@ -237,4 +423,12 @@ uploadRoutes.get('/exists/:pfad{.+}', (c) => {
|
|||||||
? { exists: true, path, url: publicUrlFor(path), ...row }
|
? { exists: true, path, url: publicUrlFor(path), ...row }
|
||||||
: { exists: false, path },
|
: { exists: false, path },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadRoutes.get('/exists', (c) => {
|
||||||
|
const roh = c.req.query('pfad') ?? c.req.query('path') ?? ''
|
||||||
|
if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400)
|
||||||
|
return existsAntwort(c, roh)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
uploadRoutes.get('/exists/:pfad{.+}', (c) => existsAntwort(c, c.req.param('pfad')))
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// Einstellungen, die zur Laufzeit verstellbar sind.
|
||||||
|
//
|
||||||
|
// WAS HIER LIEGT UND WAS NICHT. In der Umgebung bleibt, was den Dienst
|
||||||
|
// ueberhaupt erreichbar macht: PUBLIC_URL, PORT, DATA_DIR, ADMIN_PASSWORD. Wer
|
||||||
|
// die per Formular verstellen koennte, koennte sich mit einem Klick
|
||||||
|
// aussperren, und die Reparatur waere ein Eingriff in die Datenbank.
|
||||||
|
//
|
||||||
|
// Hier liegt der Rest — derzeit die Discord-Anmeldung. Der Gewinn ist nicht
|
||||||
|
// nur Bequemlichkeit: das Panel kann die Redirect-URI gleich zum Kopieren
|
||||||
|
// hinlegen, und die ist die Stelle, an der ein OAuth-Anlauf am haeufigsten
|
||||||
|
// scheitert.
|
||||||
|
//
|
||||||
|
// DAS GEHEIMNIS LIEGT DAMIT IN media.db, und die steckt in jeder Sicherung
|
||||||
|
// unter C:\backup. Die .env ist dort nicht drin. Beides liegt unverschluesselt
|
||||||
|
// auf derselben Platte, der Unterschied ist die Zahl der Kopien — wer den
|
||||||
|
// Client-Schluessel zurueckziehen will, tut das im Discord-Portal, nicht hier.
|
||||||
|
|
||||||
|
import { db, now } from './db.js'
|
||||||
|
|
||||||
|
const lesen = db.prepare('SELECT value FROM settings WHERE key = ?')
|
||||||
|
const schreiben = db.prepare(
|
||||||
|
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Ein Wert aus der Datenbank — oder, wenn dort nichts steht, aus der
|
||||||
|
* Umgebung. Die Reihenfolge ist Absicht: wer schon Stack-Variablen gesetzt
|
||||||
|
* hat, behaelt sie, und wer das Panel benutzt, uebersteuert sie. */
|
||||||
|
export function getSetting(key: string, envName?: string): string {
|
||||||
|
const zeile = lesen.get(key) as { value: string } | undefined
|
||||||
|
if (zeile && zeile.value !== '') return zeile.value
|
||||||
|
return (envName ? (process.env[envName] ?? '') : '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key: string, value: string): void {
|
||||||
|
schreiben.run(key, value, now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob ein Wert aus der Datenbank kommt (true) oder aus der Umgebung (false).
|
||||||
|
* Die Oberflaeche sagt das dazu — sonst wundert sich jemand, warum ein
|
||||||
|
* geleertes Feld weiterhin wirkt. */
|
||||||
|
export function ausDatenbank(key: string): boolean {
|
||||||
|
const zeile = lesen.get(key) as { value: string } | undefined
|
||||||
|
return Boolean(zeile && zeile.value !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Adressvorlagen
|
||||||
|
//
|
||||||
|
// Eine Vorlage ist der Bauplan einer oeffentlichen Adresse:
|
||||||
|
//
|
||||||
|
// vehicles/{model}.webp -> https://…/f/vehicles/adder.webp
|
||||||
|
//
|
||||||
|
// Wozu speichern? Weil genau diese Zeichenkette an der anderen Seite gebraucht
|
||||||
|
// wird -- im Handy, im Skript, im Fotostudio -- und weil sie sich jemand sonst
|
||||||
|
// aus Ordnername plus Endung zusammenreimt. Einmal falsch zusammengereimt
|
||||||
|
// heisst: das Bild fehlt, und gesucht wird an der falschen Stelle.
|
||||||
|
//
|
||||||
|
// Als JSON in EINER Zeile der Einstellungstabelle. Eine eigene Tabelle fuer
|
||||||
|
// eine Handvoll Zeilen waere eine Wanderung mehr, die niemand braucht.
|
||||||
|
|
||||||
|
/** Wie der Dienst sich nennt.
|
||||||
|
*
|
||||||
|
* DIESELBE Einstellung, die auch in der Fusszeile der Discord-Meldungen steht
|
||||||
|
* (`D4RKST3R // MEDIA`). Ein zweiter Name fuer die Oberflaeche waere ein
|
||||||
|
* zweiter, der beim naechsten Mal anders lautet als der erste -- und dann
|
||||||
|
* heisst dasselbe Ding an zwei Stellen verschieden.
|
||||||
|
*
|
||||||
|
* Der Standard steht in meldung.ts; hier wird er nur weitergereicht. */
|
||||||
|
export const MARKE_KEY = 'marke.name'
|
||||||
|
|
||||||
|
export const VORLAGEN_KEY = 'vorlagen'
|
||||||
|
|
||||||
|
export type Vorlage = { name: string; muster: string }
|
||||||
|
|
||||||
|
export function vorlagen(): Vorlage[] {
|
||||||
|
try {
|
||||||
|
const roh = getSetting(VORLAGEN_KEY)
|
||||||
|
if (!roh) return []
|
||||||
|
const daten = JSON.parse(roh)
|
||||||
|
if (!Array.isArray(daten)) return []
|
||||||
|
return daten.filter(
|
||||||
|
(v): v is Vorlage =>
|
||||||
|
v && typeof v.name === 'string' && typeof v.muster === 'string',
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Ein kaputter Eintrag darf die Einstellungsseite nicht mitreissen.
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setzeVorlagen(liste: Vorlage[]): void {
|
||||||
|
setSetting(VORLAGEN_KEY, JSON.stringify(liste))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Sicherung
|
||||||
|
//
|
||||||
|
// Die Werte fuer die Sicherung stehen hier, obwohl das Skript sie braucht und
|
||||||
|
// nicht der Dienst. Der Grund ist die Rueckmeldung: eine Textdatei auf der
|
||||||
|
// Platte sagt niemandem, ob die Sicherung heute Nacht gelaufen ist. Ein
|
||||||
|
// Eintrag in der Datenbank kann das Panel anzeigen.
|
||||||
|
//
|
||||||
|
// tools/sichern.ps1 liest sie ueber `docker exec` DIREKT aus media.db und
|
||||||
|
// nicht ueber die API: so verlaesst das Passwort den Rechner nie -- ausser
|
||||||
|
// beim Eintragen im Browser, und das geht ueber TLS.
|
||||||
|
|
||||||
|
export const SICHERUNG_KEYS = {
|
||||||
|
basis: 'sicherung.basis',
|
||||||
|
benutzer: 'sicherung.benutzer',
|
||||||
|
passwort: 'sicherung.passwort',
|
||||||
|
ordner: 'sicherung.ordner',
|
||||||
|
behalten: 'sicherung.behalten',
|
||||||
|
/** Ein ZWEITER lokaler Ablageort, moeglichst auf einer anderen Platte.
|
||||||
|
*
|
||||||
|
* WOZU, wenn es doch schon die Nextcloud gibt: die ist weit weg. Wer
|
||||||
|
* 100 GB zurueckholen muss, laedt sie ueber die Leitung -- das ist die
|
||||||
|
* Sicherung fuer den Fall, dass das Haus abbrennt, nicht fuer den Fall,
|
||||||
|
* dass eine Platte stirbt. Fuer den zweiten will man die Kopie IM Rechner
|
||||||
|
* haben, nur eben nicht auf derselben Platte.
|
||||||
|
*
|
||||||
|
* Der Pfad gilt auf dem HOST, nicht im Container -- ihn prueft deshalb
|
||||||
|
* nicht der Dienst, sondern tools/sichern.ps1. */
|
||||||
|
zweitziel: 'sicherung.zweitziel',
|
||||||
|
/** Schreibt das Skript nach jedem Lauf. JSON, damit das Panel es anzeigen
|
||||||
|
* kann, ohne dass hier eine weitere Tabelle noetig waere. */
|
||||||
|
letzte: 'sicherung.letzte',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SicherungConfig = {
|
||||||
|
basis: string
|
||||||
|
benutzer: string
|
||||||
|
ordner: string
|
||||||
|
behalten: number
|
||||||
|
zweitziel: string
|
||||||
|
hatPasswort: boolean
|
||||||
|
bereit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sicherungConfig(): SicherungConfig {
|
||||||
|
const basis = getSetting(SICHERUNG_KEYS.basis)
|
||||||
|
const benutzer = getSetting(SICHERUNG_KEYS.benutzer)
|
||||||
|
const passwort = getSetting(SICHERUNG_KEYS.passwort)
|
||||||
|
const ordner = getSetting(SICHERUNG_KEYS.ordner) || 'Sicherungen/d4rk_media'
|
||||||
|
const behalten = Number(getSetting(SICHERUNG_KEYS.behalten)) || 14
|
||||||
|
|
||||||
|
return {
|
||||||
|
basis,
|
||||||
|
benutzer,
|
||||||
|
ordner,
|
||||||
|
behalten,
|
||||||
|
zweitziel: getSetting(SICHERUNG_KEYS.zweitziel),
|
||||||
|
hatPasswort: Boolean(passwort),
|
||||||
|
bereit: Boolean(basis && benutzer && passwort),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Discord
|
||||||
|
|
||||||
|
export const DISCORD_KEYS = {
|
||||||
|
clientId: 'discord.client_id',
|
||||||
|
clientSecret: 'discord.client_secret',
|
||||||
|
guildId: 'discord.guild_id',
|
||||||
|
/** MEHRERE Rollen, mit Komma getrennt. Der alte Schluessel mit genau einer
|
||||||
|
* Rolle wird weiter gelesen (siehe rollen()) — wer schon eine eingetragen
|
||||||
|
* hatte, soll sie nicht verlieren, nur weil hier eine Liste daraus wurde. */
|
||||||
|
roleIds: 'discord.role_ids',
|
||||||
|
roleIdAlt: 'discord.role_id',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type DiscordConfig = {
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
guildId: string
|
||||||
|
/** Wer EINE davon hat, darf herein. Leere Liste heisst: nicht eingerichtet. */
|
||||||
|
roleIds: string[]
|
||||||
|
/** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance.
|
||||||
|
* Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist
|
||||||
|
* schlimmer als ein Knopf, den es nicht gibt. */
|
||||||
|
bereit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Rollen, aus welcher Quelle sie auch kommen.
|
||||||
|
*
|
||||||
|
* Reihenfolge: die neue Liste, dann der alte Einzelwert, dann die Umgebung.
|
||||||
|
* Getrennt wird an Komma, Semikolon, Leerzeichen und Zeilenumbruch — wer eine
|
||||||
|
* Liste aus Discord kopiert, soll sie nicht erst von Hand aufraeumen. */
|
||||||
|
export function rollen(): string[] {
|
||||||
|
const roh =
|
||||||
|
getSetting(DISCORD_KEYS.roleIds) ||
|
||||||
|
getSetting(DISCORD_KEYS.roleIdAlt) ||
|
||||||
|
(process.env.DISCORD_ROLE_IDS ?? process.env.DISCORD_ROLE_ID ?? '')
|
||||||
|
|
||||||
|
return [...new Set(roh.split(/[\s,;]+/).map((t) => t.trim()).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discordConfig(): DiscordConfig {
|
||||||
|
const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID')
|
||||||
|
const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET')
|
||||||
|
const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID')
|
||||||
|
const roleIds = rollen()
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
guildId,
|
||||||
|
roleIds,
|
||||||
|
bereit: Boolean(clientId && clientSecret && guildId && roleIds.length > 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
// unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand
|
// unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand
|
||||||
// merkt es, bis das Bild fehlt.
|
// merkt es, bis das Bild fehlt.
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import { dirname, join, resolve, sep } from 'node:path'
|
import { dirname, join, resolve, sep } from 'node:path'
|
||||||
@@ -81,6 +82,28 @@ const MIME: Record<string, string> = {
|
|||||||
pdf: 'application/pdf',
|
pdf: 'application/pdf',
|
||||||
json: 'application/json',
|
json: 'application/json',
|
||||||
txt: 'text/plain; charset=utf-8',
|
txt: 'text/plain; charset=utf-8',
|
||||||
|
|
||||||
|
// Lesbares. ALLES ALS text/plain, auch das Markdown -- und das ist die
|
||||||
|
// Stelle, an der man sich vertun kann: eine eigene Art wie text/markdown
|
||||||
|
// brächte nichts, aber text/html brächte eine Luecke. Wer einen
|
||||||
|
// Upload-Token hat, koennte damit eine Seite unter unserem Namen
|
||||||
|
// veroeffentlichen; als text/plain zeigt der Browser den Quelltext an und
|
||||||
|
// fuehrt nichts aus.
|
||||||
|
//
|
||||||
|
// Deshalb stehen html, css und js hier NICHT, und das soll auch so
|
||||||
|
// bleiben. Die Oberflaeche hat dafuer ihre eigene Tabelle in index.ts.
|
||||||
|
md: 'text/plain; charset=utf-8',
|
||||||
|
markdown: 'text/plain; charset=utf-8',
|
||||||
|
csv: 'text/plain; charset=utf-8',
|
||||||
|
lua: 'text/plain; charset=utf-8',
|
||||||
|
xml: 'text/plain; charset=utf-8',
|
||||||
|
yml: 'text/plain; charset=utf-8',
|
||||||
|
yaml: 'text/plain; charset=utf-8',
|
||||||
|
toml: 'text/plain; charset=utf-8',
|
||||||
|
ini: 'text/plain; charset=utf-8',
|
||||||
|
cfg: 'text/plain; charset=utf-8',
|
||||||
|
sql: 'text/plain; charset=utf-8',
|
||||||
|
log: 'text/plain; charset=utf-8',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mimeFor(path: string): string {
|
export function mimeFor(path: string): string {
|
||||||
@@ -88,6 +111,79 @@ export function mimeFor(path: string): string {
|
|||||||
return MIME[ext] ?? 'application/octet-stream'
|
return MIME[ext] ?? 'application/octet-stream'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aus einem Dateinamen von der Platte einen zulaessigen Pfadteil machen.
|
||||||
|
*
|
||||||
|
* Der Weg vom Dashboard ist ein anderer als der vom Skript: dort schickt
|
||||||
|
* jemand `X-Path` und weiss genau, was er will. Hier zieht jemand
|
||||||
|
* "Mein Video (final) 2.MP4" ins Fenster, und `checkPath` wuerde das mit
|
||||||
|
* Recht ablehnen — Klammern, Leerzeichen, alles nicht erlaubt.
|
||||||
|
*
|
||||||
|
* Deshalb wird der Name hier zurechtgebogen, und zwar VORHERSAGBAR:
|
||||||
|
* Kleinbuchstaben, alles Fremde wird zum Strich, Striche werden
|
||||||
|
* zusammengefasst. Aus obigem wird "mein-video-final-2.mp4". Wer den
|
||||||
|
* Dateinamen exakt behalten will, benennt ihn vorher um — das ist ehrlicher,
|
||||||
|
* als so zu tun, als koennte jeder Name eine URL werden.
|
||||||
|
*
|
||||||
|
* `eindeutig` ist der Rettungsanker fuer den Fall, dass vom Namen NICHTS
|
||||||
|
* uebrig bleibt — bei "ЖУРНАЛ.txt" oder " .png". Ohne ihn hiessen beide
|
||||||
|
* "datei.txt" bzw. "datei.png", und die zweite Datei ueberschriebe die erste
|
||||||
|
* stillschweigend. Genau das ist beim Messen passiert. Ueblicherweise gibt
|
||||||
|
* der Aufrufer die ersten Stellen des SHA-256 mit: gleicher Inhalt ergibt
|
||||||
|
* denselben Namen, verschiedener Inhalt verschiedene. */
|
||||||
|
export function safeFilename(name: string, eindeutig?: string): string {
|
||||||
|
const punkt = name.lastIndexOf('.')
|
||||||
|
const roh = punkt > 0 ? name.slice(0, punkt) : name
|
||||||
|
const endung = punkt > 0 ? name.slice(punkt + 1) : ''
|
||||||
|
|
||||||
|
// DIE SCHREIBWEISE BLEIBT, seit dem 22.08.2026.
|
||||||
|
//
|
||||||
|
// Hier stand `.toLowerCase()` mit der Begruendung "vorhersagbar". Das war
|
||||||
|
// fuer die Zeichen richtig und fuer die GROSSSCHREIBUNG falsch, und es hat
|
||||||
|
// etwas kaputtgemacht, das niemand mit dem Hochladen in Verbindung brachte:
|
||||||
|
//
|
||||||
|
// ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab. Seine
|
||||||
|
// Waffen heissen WEAPON_COMBATPISTOL. Der Ordner lag auf einem
|
||||||
|
// Linux-Wirt, dort ist Gross- und Kleinschreibung ein Unterschied --
|
||||||
|
// also 404, also leeres Feld im Inventar. Gemessen am 22.08.2026:
|
||||||
|
// 109 von 226 Items betroffen, und ox_inventory liefert seine eigenen
|
||||||
|
// Bilder genau deshalb GROSS aus (113 von 198).
|
||||||
|
//
|
||||||
|
// Der Grund fuer das Zaehmen bleibt richtig -- Leerzeichen, Klammern und
|
||||||
|
// Umlaute haben in einer Adresse nichts verloren. Nur die Schreibweise
|
||||||
|
// gehoerte nie dazu: `checkPath` erlaubt `[A-Za-z0-9]` seit jeher, und in
|
||||||
|
// einer URL ist ein Grossbuchstabe kein Problem.
|
||||||
|
//
|
||||||
|
// Umlaute werden weiterhin ersetzt, jetzt in beiden Schreibweisen -- sonst
|
||||||
|
// wuerde aus "Gruen.png" ein "gr-n.png", und das waere dieselbe stille
|
||||||
|
// Verstuemmelung eine Ebene tiefer.
|
||||||
|
const zaehmen = (s: string) =>
|
||||||
|
s
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/Ä/g, 'Ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/Ö/g, 'Oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/Ü/g, 'Ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||||
|
.replace(/-{2,}/g, '-')
|
||||||
|
.replace(/^[^A-Za-z0-9]+/, '')
|
||||||
|
.replace(/[-.]+$/, '')
|
||||||
|
|
||||||
|
const stamm = zaehmen(roh)
|
||||||
|
// DIE ENDUNG BLEIBT KLEIN. Am Stamm haengt die Bedeutung -- an der Endung
|
||||||
|
// haengt nur der Dateityp, und die schreibt alle Welt klein. Wer
|
||||||
|
// "Bild.PNG" hochlaedt, will kein zweites Format, sondern ein PNG; ein
|
||||||
|
// ".PNG" waere eine Adresse, die niemand so tippt.
|
||||||
|
const ende = zaehmen(endung).toLowerCase()
|
||||||
|
|
||||||
|
// Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das
|
||||||
|
// kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und
|
||||||
|
// sieht im Ergebnis, was daraus geworden ist.
|
||||||
|
const basis = stamm || (eindeutig ? `datei-${eindeutig}` : 'datei')
|
||||||
|
return ende ? `${basis}.${ende}` : basis
|
||||||
|
}
|
||||||
|
|
||||||
export function extensionFor(mime: string): string {
|
export function extensionFor(mime: string): string {
|
||||||
const base = mime.split(';')[0]?.trim().toLowerCase()
|
const base = mime.split(';')[0]?.trim().toLowerCase()
|
||||||
for (const [ext, value] of Object.entries(MIME)) {
|
for (const [ext, value] of Object.entries(MIME)) {
|
||||||
@@ -100,6 +196,57 @@ export const sha256 = (data: Buffer) => createHash('sha256').update(data).digest
|
|||||||
|
|
||||||
export const absolutePath = (path: string) => join(config.filesDir, path)
|
export const absolutePath = (path: string) => join(config.filesDir, path)
|
||||||
|
|
||||||
|
/** Ein Bild auf eine Hoechstkante rechnen -- oder es lassen.
|
||||||
|
*
|
||||||
|
* WOFUER NICHT: als Schutz vor riesigen Bildern. Das waere gemessen falsch --
|
||||||
|
* ein 12000 x 12000 grosses PNG kostet 374 ms und 47 MB, weil libvips
|
||||||
|
* kachelweise arbeitet und nie das ganze Bild dekodiert.
|
||||||
|
*
|
||||||
|
* WOFUER DANN: Plattenplatz und Ladezeit. Ein Bildschirmfoto aus dem Spiel ist
|
||||||
|
* 3840 breit; auf einem Handy wird es auf 400 gezeigt. Die restlichen 3440
|
||||||
|
* liegen auf der Platte und gehen bei jedem Abruf ueber die Leitung.
|
||||||
|
*
|
||||||
|
* `withoutEnlargement` ist entscheidend: ein 200 Pixel breites Icon wird NICHT
|
||||||
|
* auf 2048 aufgeblasen. Sonst machte die Einstellung kleine Dateien groesser.
|
||||||
|
*
|
||||||
|
* Gibt den neuen Puffer zurueck, oder den alten, wenn nichts zu tun war. Wirft
|
||||||
|
* nie -- ein Bild, das sich nicht rechnen laesst, wird abgelegt wie es kam. */
|
||||||
|
export async function vielleichtKleiner(
|
||||||
|
data: Buffer,
|
||||||
|
mime: string,
|
||||||
|
maxKante: number | null,
|
||||||
|
): Promise<{ data: Buffer; verkleinert: false | { von: string; auf: string } }> {
|
||||||
|
if (!maxKante || maxKante < 16) return { data, verkleinert: false }
|
||||||
|
if (!kannVorschau(mime)) return { data, verkleinert: false }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: sharp } = await import('sharp')
|
||||||
|
const bild = sharp(data, { animated: false })
|
||||||
|
const vorher = await bild.metadata()
|
||||||
|
const groesste = Math.max(vorher.width ?? 0, vorher.height ?? 0)
|
||||||
|
if (!groesste || groesste <= maxKante) return { data, verkleinert: false }
|
||||||
|
|
||||||
|
// IM URSPRUNGSFORMAT bleiben: aus einem PNG ein WebP zu machen aendert
|
||||||
|
// die Endung nicht mit, und dann liegt eine .png da, die keine ist.
|
||||||
|
// Die sparsame Fassung entsteht ohnehin daneben.
|
||||||
|
const neu = await bild
|
||||||
|
.resize({ width: maxKante, height: maxKante, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
const nachher = await sharp(neu).metadata()
|
||||||
|
return {
|
||||||
|
data: neu,
|
||||||
|
verkleinert: {
|
||||||
|
von: `${vorher.width}x${vorher.height}`,
|
||||||
|
auf: `${nachher.width}x${nachher.height}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[kleiner] ${err instanceof Error ? err.message : err}`)
|
||||||
|
return { data, verkleinert: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Schreibt die Datei.
|
/** Schreibt die Datei.
|
||||||
*
|
*
|
||||||
* Erst daneben, dann umbenennen: ein Ueberschreiben ist damit entweder ganz
|
* Erst daneben, dann umbenennen: ein Ueberschreiben ist damit entweder ganz
|
||||||
@@ -120,8 +267,384 @@ export async function writeFileAtomic(path: string, data: Buffer): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Vorschaubilder
|
||||||
|
//
|
||||||
|
// WARUM UEBERHAUPT. Ein freigestelltes Fahrzeugbild wiegt rund 124 KB. Eine
|
||||||
|
// Galerieseite zeigt 60 davon — das sind 7,2 MB, nur damit jemand sieht, WELCHE
|
||||||
|
// Bilder da sind. Bei 900 Fahrzeugen liegen 109 MB in der Ablage. Die Vorschau
|
||||||
|
// wiegt ein Fuenfzigstel und reicht fuer eine Kachel voellig.
|
||||||
|
//
|
||||||
|
// WO SIE LIEGEN. Unter /data/thumbs, im selben Baum wie die Bilder, mit
|
||||||
|
// ".webp" hinten dran. Also thumbs/vehicles/adder.webp.webp — haesslich, aber
|
||||||
|
// eindeutig: haengte man die Endung nicht an, kollidierten adder.png und
|
||||||
|
// adder.webp im Vorschauordner.
|
||||||
|
//
|
||||||
|
// KEINE SPALTE IN DER DATENBANK. Ob es eine Vorschau gibt, sagt das
|
||||||
|
// Dateisystem. Eine Spalte waere eine zweite Wahrheit, die auseinanderlaufen
|
||||||
|
// kann — und das Dashboard faellt ohnehin auf das Vollbild zurueck, wenn die
|
||||||
|
// Vorschau fehlt.
|
||||||
|
|
||||||
|
export const THUMB_BREITE = 320
|
||||||
|
|
||||||
|
export const thumbPath = (path: string) => join(config.dataDir, 'thumbs', `${path}.webp`)
|
||||||
|
|
||||||
|
export const kannVorschau = (mime: string) =>
|
||||||
|
/^image\/(png|jpeg|webp|avif|gif)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
|
||||||
|
|
||||||
|
/** Eine Vorschau erzeugen. Schlaegt sie fehl, ist das KEIN Grund, den Upload
|
||||||
|
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
|
||||||
|
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
|
||||||
|
export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
|
||||||
|
const ziel = thumbPath(path)
|
||||||
|
try {
|
||||||
|
// Der Import steht hier drin und nicht oben: sharp zieht eine native
|
||||||
|
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
|
||||||
|
// Bild kommt.
|
||||||
|
const { default: sharp } = await import('sharp')
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
|
||||||
|
const bild = await sharp(data, { animated: false })
|
||||||
|
// withoutEnlargement: ein 64 Pixel breites Bild wird nicht auf 320
|
||||||
|
// aufgeblasen — das kostet Platz und sieht schlechter aus als das
|
||||||
|
// Original.
|
||||||
|
.resize({ width: THUMB_BREITE, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 72 })
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
const temp = `${ziel}.${process.pid}.tmp`
|
||||||
|
await writeFile(temp, bild)
|
||||||
|
await rename(temp, ziel)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------- Vorschau fuer Video und Ton
|
||||||
|
//
|
||||||
|
// Bis hierhin bekam alles, was kein Bild ist, ein Fragezeichen auf die Kachel.
|
||||||
|
// Bei 3600 Bildern faellt das nicht auf; bei einer Handvoll Clips ist die
|
||||||
|
// Galerie dann eine Liste von Dateinamen.
|
||||||
|
//
|
||||||
|
// VIDEO: ein Standbild. sharp kann das nicht -- libvips dekodiert kein Video.
|
||||||
|
// Also ffmpeg, und zwar auf die Datei AUF DER PLATTE und nicht auf den Puffer:
|
||||||
|
// ein Container wie MP4 hat seine Inhaltsangabe je nach Kodierer am Ende, und
|
||||||
|
// ohne Springen findet ffmpeg sie nicht.
|
||||||
|
//
|
||||||
|
// TON: das eingebettete Titelbild, wenn eines da ist. Fuer ffmpeg ist das
|
||||||
|
// schlicht die Videospur der Datei -- derselbe Aufruf, andere Zeitmarke.
|
||||||
|
// Ist keines da, entsteht keine Vorschau, und die Kachel zeigt ein Notenzeichen
|
||||||
|
// statt eines leeren Rahmens.
|
||||||
|
|
||||||
|
export const kannBewegtVorschau = (mime: string) =>
|
||||||
|
/^(video|audio)\//.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
|
||||||
|
|
||||||
|
/** Wie lange wir ffmpeg zugestehen.
|
||||||
|
*
|
||||||
|
* Ein beschaedigter Container kann einen Dekodierer beliebig lange
|
||||||
|
* beschaeftigen. 20 Sekunden reichen fuer ein Standbild aus jeder gesunden
|
||||||
|
* Datei -- was laenger braucht, bekommt eben keine Vorschau. */
|
||||||
|
const FFMPEG_MS = 20_000
|
||||||
|
|
||||||
|
/** Was in der Datei steckt: wie lang, und ob es ueberhaupt ein Bild gibt.
|
||||||
|
*
|
||||||
|
* BEIDES AUS EINEM AUFRUF. `ffmpeg -i datei` ohne Ausgabeziel bricht mit einem
|
||||||
|
* Fehler ab ("At least one output file must be specified") -- seinen Kopf hat
|
||||||
|
* es da aber laengst gedruckt, und darin steht alles, was wir brauchen. Der
|
||||||
|
* Rueckgabewert wird deshalb bewusst ignoriert und nur stderr gelesen. Kein
|
||||||
|
* `-f null -`: das dekodierte die ganze Datei, um am Ende dasselbe zu wissen.
|
||||||
|
*
|
||||||
|
* OHNE ffprobe, und das ist eine gemessene Entscheidung: das Werkzeug haette
|
||||||
|
* als statische Binaerdatei 135 MB ins Abbild gebracht. Gegengeprueft an
|
||||||
|
* beiden Dateien, die hier liegen, auf zwei Nachkommastellen identisch:
|
||||||
|
*
|
||||||
|
* ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77
|
||||||
|
* ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64
|
||||||
|
*
|
||||||
|
* Fuer eine Beschriftung wie "0:29" ist das genauer als noetig.
|
||||||
|
*
|
||||||
|
* `hatBild` WIRD AUS DER SPURENLISTE GELESEN und nicht aus einer
|
||||||
|
* Fehlermeldung. Genau daran ist der erste Anlauf gescheitert: er erkannte
|
||||||
|
* "keine Bildspur" am Wortlaut, und der ist zwischen den Fassungen anders --
|
||||||
|
*
|
||||||
|
* ffmpeg 5.1 "Stream map '0:v:0' matches no streams."
|
||||||
|
* ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'"
|
||||||
|
*
|
||||||
|
* -- worauf eine voellig gesunde MP3 als "1 fehlgeschlagen" gezaehlt wurde.
|
||||||
|
* Die Zeile "Stream #0:1: Video:" dagegen steht seit jeher so da. */
|
||||||
|
export type MedienInfo = { dauer: number | null; hatBild: boolean }
|
||||||
|
|
||||||
|
export async function medienInfo(path: string): Promise<MedienInfo> {
|
||||||
|
const { stderr } = await ffmpegRoh(['-i', absolutePath(path)])
|
||||||
|
|
||||||
|
// "Duration: 00:10:45.64," -- bei Stroemen ohne Laengenangabe "Duration: N/A".
|
||||||
|
const treffer = /Duration:\s*(\d+):(\d\d):(\d\d(?:\.\d+)?)/.exec(stderr)
|
||||||
|
const sekunden = treffer
|
||||||
|
? Number(treffer[1]) * 3600 + Number(treffer[2]) * 60 + Number(treffer[3])
|
||||||
|
: NaN
|
||||||
|
|
||||||
|
return {
|
||||||
|
dauer: Number.isFinite(sekunden) && sekunden > 0 ? sekunden : null,
|
||||||
|
// Ein eingebettetes Titelbild ist fuer ffmpeg eine Bildspur:
|
||||||
|
// "Stream #0:1: Video: mjpeg ... (attached pic)".
|
||||||
|
hatBild: /Stream #\d+:\d+[^\n]*:\s*Video:/.test(stderr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was bei einem Versuch herauskam.
|
||||||
|
*
|
||||||
|
* DREI Zustaende und nicht zwei, und das ist nachgemessen: eine MP3 OHNE
|
||||||
|
* eingebettetes Titelbild ist voellig in Ordnung -- sie hat schlicht keine
|
||||||
|
* Bildspur. Mit einem blossen true/false zaehlte der Nachzieh-Knopf sie als
|
||||||
|
* "1 fehlgeschlagen", und dann sucht jemand einen Fehler, den es nicht gibt.
|
||||||
|
* Genau so ist es beim ersten Lauf passiert. */
|
||||||
|
export type BewegtErgebnis = 'ok' | 'keinBild' | 'fehler'
|
||||||
|
|
||||||
|
/** Ein Standbild bzw. Titelbild als Vorschau, samt Laenge.
|
||||||
|
*
|
||||||
|
* Gibt BEIDES zurueck, weil beides aus demselben Blick in die Datei kommt: ein
|
||||||
|
* zweiter Aufruf nur fuer die Sekundenzahl waere die doppelte Arbeit.
|
||||||
|
*
|
||||||
|
* Wie writeThumb: ein Fehlschlag ist KEIN Grund, den Upload scheitern zu
|
||||||
|
* lassen. */
|
||||||
|
export async function writeBewegtThumb(
|
||||||
|
path: string,
|
||||||
|
mime: string,
|
||||||
|
): Promise<{ ergebnis: BewegtErgebnis; dauer: number | null }> {
|
||||||
|
const ziel = thumbPath(path)
|
||||||
|
const quelle = absolutePath(path)
|
||||||
|
const istVideo = mime.startsWith('video')
|
||||||
|
|
||||||
|
const info = await medienInfo(path)
|
||||||
|
|
||||||
|
// Keine Bildspur: bei Ton der Normalfall (die meisten MP3 tragen kein
|
||||||
|
// Titelbild). Hier wird ffmpeg gar nicht erst ein zweites Mal bemueht.
|
||||||
|
if (!info.hatBild) return { ergebnis: 'keinBild', dauer: info.dauer }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
|
||||||
|
// Bei Video NICHT den Anfang nehmen, wenn sich das vermeiden laesst:
|
||||||
|
// viele Clips beginnen mit Schwarz oder einem Einblender, und eine
|
||||||
|
// schwarze Kachel ist so aussagekraeftig wie gar keine. Ein Zehntel
|
||||||
|
// hinein ist fast immer Bild.
|
||||||
|
//
|
||||||
|
// Bei Ton NICHT springen: das eingebettete Titelbild ist ein einzelnes
|
||||||
|
// Standbild, und ein -ss darauf findet nichts mehr.
|
||||||
|
const start = istVideo && info.dauer ? Math.min(info.dauer * 0.1, 10) : 0
|
||||||
|
|
||||||
|
const temp = `${ziel}.${process.pid}.tmp.webp`
|
||||||
|
await ffmpeg([
|
||||||
|
// -ss VOR -i: dann springt ffmpeg, statt bis dorthin zu dekodieren.
|
||||||
|
...(start > 0 ? ['-ss', start.toFixed(2)] : []),
|
||||||
|
'-i', quelle,
|
||||||
|
// Nur die Bildspur, genau ein Bild. Bei einer MP3 ist das
|
||||||
|
// eingebettete Titelbild genau diese Spur.
|
||||||
|
'-map', '0:v:0',
|
||||||
|
'-frames:v', '1',
|
||||||
|
// Nicht aufblasen: ein 64 Pixel breites Titelbild bleibt klein.
|
||||||
|
'-vf', `scale='min(${THUMB_BREITE},iw)':-2`,
|
||||||
|
'-f', 'webp',
|
||||||
|
'-y', temp,
|
||||||
|
])
|
||||||
|
|
||||||
|
await rename(temp, ziel)
|
||||||
|
return { ergebnis: 'ok', dauer: info.dauer }
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
|
||||||
|
return { ergebnis: 'fehler', dauer: info.dauer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ffmpeg aufrufen, OHNE zu urteilen: gibt zurueck, was herauskam, samt
|
||||||
|
* Rueckgabewert. Fuer den Fall, dass ein Fehlschlag die Antwort IST -- siehe
|
||||||
|
* medienDauer.
|
||||||
|
*
|
||||||
|
* execFile und NICHT exec: ohne Shell dazwischen gibt es keine Zeichenkette,
|
||||||
|
* in der ein Dateiname etwas anderes bedeuten koennte. Der Pfad ist zwar durch
|
||||||
|
* checkPath gegangen, aber diese Abwehr kostet nichts. */
|
||||||
|
function ffmpegRoh(args: string[]): Promise<{ code: number | null; stderr: string }> {
|
||||||
|
return new Promise((erfuellen) => {
|
||||||
|
execFile(
|
||||||
|
'ffmpeg',
|
||||||
|
args,
|
||||||
|
{ timeout: FFMPEG_MS, maxBuffer: 4 * 1024 * 1024 },
|
||||||
|
(fehler, _stdout, stderr) => {
|
||||||
|
erfuellen({
|
||||||
|
code: fehler ? ((fehler as { code?: number }).code ?? 1) : 0,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dasselbe, aber ein Fehlschlag ist ein Fehlschlag. */
|
||||||
|
async function ffmpeg(args: string[]): Promise<void> {
|
||||||
|
const { code, stderr } = await ffmpegRoh(args)
|
||||||
|
if (code !== 0) {
|
||||||
|
// Die letzte Zeile von ffmpeg ist die, die den Grund nennt.
|
||||||
|
throw new Error(stderr.trim().split('\n').pop() ?? `ffmpeg endete mit ${code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Datei samt Vorschau an einen anderen Pfad legen.
|
||||||
|
*
|
||||||
|
* DER PFAD IST DER SCHLUESSEL — deshalb ist Verschieben hier kein Kopieren
|
||||||
|
* mit anschliessendem Loeschen, sondern ein `rename`: das ist auf derselben
|
||||||
|
* Platte unteilbar. Ein abgebrochenes Kopieren hinterliesse die Datei zweimal
|
||||||
|
* oder gar nicht.
|
||||||
|
*
|
||||||
|
* Die Vorschau zieht mit. Bliebe sie liegen, zeigte die Galerie am neuen Ort
|
||||||
|
* kein Bild und am alten Ort das eines Bildes, das dort nicht mehr liegt. */
|
||||||
|
export async function moveFile(von: string, nach: string): Promise<void> {
|
||||||
|
const ziel = absolutePath(nach)
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
await rename(absolutePath(von), ziel)
|
||||||
|
|
||||||
|
const abgeleitet: [string, string][] = [
|
||||||
|
[thumbPath(von), thumbPath(nach)],
|
||||||
|
[webpPfad(von), webpPfad(nach)],
|
||||||
|
]
|
||||||
|
for (const [vonPfad, nachPfad] of abgeleitet) {
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(nachPfad), { recursive: true })
|
||||||
|
await rename(vonPfad, nachPfad)
|
||||||
|
} catch {
|
||||||
|
// Nicht vorhanden — kein Fehler. Beides ist abgeleitet und laesst
|
||||||
|
// sich jederzeit neu rechnen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- Die sparsame Fassung
|
||||||
|
//
|
||||||
|
// DAS PROBLEM. Die uebernommenen Item-Bilder sind PNG. Als WebP waeren sie ein
|
||||||
|
// Bruchteil davon. Nur: DER PFAD IST DIE ADRESSE -- aus items/foo.png darf
|
||||||
|
// nicht items/foo.webp werden, sonst zeigt jedes Skript ins Leere, das den
|
||||||
|
// Namen schon kennt.
|
||||||
|
//
|
||||||
|
// DIE LOESUNG steht im Protokoll selbst. Der Browser sagt mit `Accept`, was er
|
||||||
|
// versteht. Wir legen die sparsame Fassung DANEBEN und liefern sie unter
|
||||||
|
// DERSELBEN Adresse aus, wenn der Aufrufer sie mag. Wer sie nicht mag -- Lua,
|
||||||
|
// curl, ein aelteres Werkzeug -- bekommt unveraendert das Original. Niemand
|
||||||
|
// muss etwas umstellen.
|
||||||
|
//
|
||||||
|
// Dazu gehoert `Vary: Accept` in der Antwort. Ohne die Zeile legt ein
|
||||||
|
// Zwischenspeicher die WebP-Fassung fuer ALLE ab, auch fuer die, die sie nicht
|
||||||
|
// lesen koennen. Unser Proxy speichert /f/ zwar gar nicht zwischen -- aber das
|
||||||
|
// ist eine Einstellung, die jemand aendern kann, und darauf soll die
|
||||||
|
// Richtigkeit nicht beruhen.
|
||||||
|
|
||||||
|
export const webpPfad = (path: string) => join(config.dataDir, 'webp', `${path}.webp`)
|
||||||
|
|
||||||
|
/** Fuer welche Arten sich eine sparsame Fassung lohnt.
|
||||||
|
*
|
||||||
|
* WebP selbst NICHT: das waere ein Umkodieren ohne Gewinn. GIF auch nicht --
|
||||||
|
* dort braeuchte es animiertes WebP, und der Gewinn ist klein gegen das
|
||||||
|
* Risiko, dass die Bewegung dabei verlorengeht. */
|
||||||
|
export const lohntWebp = (mime: string) =>
|
||||||
|
/^image\/(png|jpeg)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
|
||||||
|
|
||||||
|
/** Die sparsame Fassung erzeugen. Gibt ihre Groesse zurueck, oder null.
|
||||||
|
*
|
||||||
|
* Schlaegt es fehl, ist das KEIN Fehler fuer den Aufrufer: dann wird eben
|
||||||
|
* weiter das Original ausgeliefert, so wie bisher. */
|
||||||
|
export async function writeWebp(path: string, data: Buffer): Promise<number | null> {
|
||||||
|
const ziel = webpPfad(path)
|
||||||
|
try {
|
||||||
|
const { default: sharp } = await import('sharp')
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
|
||||||
|
// OHNE Groessenaenderung -- anders als bei der Vorschau. Es geht um die
|
||||||
|
// Kodierung, nicht um die Aufloesung: wer ein 4K-Bild hochlaedt, will
|
||||||
|
// ein 4K-Bild, nur kleiner in Bytes.
|
||||||
|
const bild = await sharp(data, { animated: false }).webp({ quality: 82 }).toBuffer()
|
||||||
|
|
||||||
|
// NUR wenn es sich lohnt. Bei kleinen oder schon dichten PNG faellt
|
||||||
|
// WebP durchaus groesser aus -- dann waere die "sparsame" Fassung eine
|
||||||
|
// Verschwendung mit zusaetzlicher Verwaltung obendrauf. Eine alte
|
||||||
|
// Fassung, die nun nicht mehr lohnt, muss dabei weg: sonst liefert der
|
||||||
|
// Dienst weiter die groessere aus.
|
||||||
|
if (bild.length >= data.length) {
|
||||||
|
await rm(ziel, { force: true })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const temp = `${ziel}.${process.pid}.tmp`
|
||||||
|
await writeFile(temp, bild)
|
||||||
|
await rename(temp, ziel)
|
||||||
|
return bild.length
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[webp] ${path}: ${err instanceof Error ? err.message : err}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen ganzen Ordner an einen anderen Platz legen.
|
||||||
|
*
|
||||||
|
* NICHT dasselbe wie moveFile mit einem Ordner darin, und das ist ein
|
||||||
|
* Fallstrick, der beim Schreiben fast durchgerutscht waere: `thumbPath` haengt
|
||||||
|
* ".webp" an. Fuer eine Datei ist das richtig (vehicles/adder.png ->
|
||||||
|
* thumbs/vehicles/adder.png.webp), fuer einen ORDNER waere es Unsinn -- der
|
||||||
|
* Vorschauordner heisst thumbs/vehicles und nicht thumbs/vehicles.webp.
|
||||||
|
*
|
||||||
|
* Deshalb hier die Ordnerfassung: dieselben drei Baeume, aber ohne Endung. */
|
||||||
|
export async function moveFolder(von: string, nach: string): Promise<void> {
|
||||||
|
await mkdir(dirname(join(config.filesDir, nach)), { recursive: true })
|
||||||
|
await rename(join(config.filesDir, von), join(config.filesDir, nach))
|
||||||
|
|
||||||
|
for (const ordner of ['thumbs', 'webp']) {
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(join(config.dataDir, ordner, nach)), { recursive: true })
|
||||||
|
await rename(
|
||||||
|
join(config.dataDir, ordner, von),
|
||||||
|
join(config.dataDir, ordner, nach),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Gibt es nicht — kein Fehler. Beides ist abgeleitet und laesst
|
||||||
|
// sich mit einem Knopf neu rechnen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteFile(path: string): Promise<void> {
|
export async function deleteFile(path: string): Promise<void> {
|
||||||
await rm(absolutePath(path), { force: true })
|
await rm(absolutePath(path), { force: true })
|
||||||
|
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
|
||||||
|
// beim naechsten Bild unter demselben Pfad sieht man das alte.
|
||||||
|
await rm(thumbPath(path), { force: true })
|
||||||
|
await rm(webpPfad(path), { force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Papierkorb
|
||||||
|
//
|
||||||
|
// Verschieben statt Loeschen. Der Ablagename traegt eine laufende Nummer, weil
|
||||||
|
// unter demselben Pfad nacheinander verschiedene Dateien gelegen haben koennen
|
||||||
|
// -- und beide sollen zurueckholbar sein.
|
||||||
|
|
||||||
|
export const papierkorbPfad = (ablage: string) => join(config.dataDir, 'papierkorb', ablage)
|
||||||
|
|
||||||
|
/** Die Datei in den Papierkorb legen. Gibt den Ablagenamen zurueck.
|
||||||
|
*
|
||||||
|
* Die VORSCHAU wandert NICHT mit: sie laesst sich aus dem Bild neu rechnen,
|
||||||
|
* und ein Papierkorb, der Abgeleitetes mitschleppt, ist doppelt so gross wie
|
||||||
|
* noetig. */
|
||||||
|
export async function inDenPapierkorb(path: string, id: number): Promise<string> {
|
||||||
|
const ablage = `${id}-${path.replace(/[\\/]/g, '_')}`
|
||||||
|
const ziel = papierkorbPfad(ablage)
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
await rename(absolutePath(path), ziel)
|
||||||
|
await rm(thumbPath(path), { force: true })
|
||||||
|
await rm(webpPfad(path), { force: true })
|
||||||
|
return ablage
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zurueck an ihren Platz. */
|
||||||
|
export async function ausDemPapierkorb(ablage: string, path: string): Promise<void> {
|
||||||
|
const ziel = absolutePath(path)
|
||||||
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
|
await rename(papierkorbPfad(ablage), ziel)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fileExists(path: string): Promise<boolean> {
|
export async function fileExists(path: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>D4RKST3R Media</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
|
<!-- Ohne diese vier Zeilen fragt jeder Browser /favicon.ico ab, und die
|
||||||
|
SPA-Rueckfallroute antwortet mit index.html: 200, Inhaltstyp
|
||||||
|
text/html, ein leeres Blatt im Tab und 2 KB umsonst bei jedem
|
||||||
|
Aufruf. Nachgemessen, bevor es hier stand.
|
||||||
|
|
||||||
|
SVG zuerst: es bleibt in jeder Groesse scharf. Das 32er PNG ist
|
||||||
|
fuer die Browser daneben, das 180er fuer das Symbol auf dem
|
||||||
|
Startbildschirm eines Telefons. Alle drei stammen aus DERSELBEN
|
||||||
|
SVG-Datei -- gerechnet, nicht zweimal gezeichnet. -->
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon-180.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#0b0d10" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wurzel"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2825
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "d4rk-media-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Dashboard fuer d4rk_media",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/react": "^18.3.31",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^5.4.21"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="D4RKST3R Media">
|
||||||
|
<!--
|
||||||
|
Dieselbe Form wie in der Kopfzeile: eine Platte. Der Dienst ist eine
|
||||||
|
Ablage, und das Zeichen sagt genau das.
|
||||||
|
|
||||||
|
Die Farben stammen aus ui/src/index.css (Grund #0b0d10, Akzent #4ea3ff)
|
||||||
|
und sind nicht danebengeraten.
|
||||||
|
|
||||||
|
Ein SVG und keine .ico: es bleibt in jeder Groesse scharf und wiegt
|
||||||
|
weniger als ein einziges Pixelformat. Fuer die zwei Stellen, die kein
|
||||||
|
SVG nehmen, liegen daneben PNG-Fassungen, die AUS DIESER DATEI gerechnet
|
||||||
|
werden. Eine Quelle, kein zweites Zeichnen, das beim naechsten Mal
|
||||||
|
anders aussieht.
|
||||||
|
|
||||||
|
Und im Kommentar steht kein doppelter Bindestrich: XML verbietet ihn,
|
||||||
|
und die Datei war damit fuer jeden Parser kaputt. Aufgefallen beim
|
||||||
|
Rechnen der PNG, nicht erst im Browser.
|
||||||
|
-->
|
||||||
|
<rect width="64" height="64" rx="14" fill="#0b0d10"/>
|
||||||
|
<g fill="none" stroke="#4ea3ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="12" y="20" width="40" height="24" rx="5"/>
|
||||||
|
<path d="M12 32h40"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="43" cy="38" r="3" fill="#4ea3ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "D4RKST3R Media",
|
||||||
|
"short_name": "Media",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b0d10",
|
||||||
|
"theme_color": "#0b0d10",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon-180.png", "sizes": "180x180", "type": "image/png" },
|
||||||
|
{ "src": "/favicon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
Gauge,
|
||||||
|
HardDrive,
|
||||||
|
History,
|
||||||
|
Images,
|
||||||
|
KeyRound,
|
||||||
|
PieChart,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useStore } from './store'
|
||||||
|
import { Anmelden } from './pages/Anmelden'
|
||||||
|
import { Start } from './pages/Start'
|
||||||
|
import { Galerie } from './pages/Galerie'
|
||||||
|
import { Speicher } from './pages/Speicher'
|
||||||
|
import { Tokens } from './pages/Tokens'
|
||||||
|
import { Konto } from './pages/Konto'
|
||||||
|
import { Einstellungen } from './pages/Einstellungen'
|
||||||
|
import { Verlauf } from './pages/Verlauf'
|
||||||
|
import { Api } from './pages/Api'
|
||||||
|
import { Papierkorb } from './pages/Papierkorb'
|
||||||
|
import { Freigabe } from './pages/Freigabe'
|
||||||
|
import { Meldungen } from './components/Meldungen'
|
||||||
|
import { gehZu, usePfad } from './router'
|
||||||
|
import { Kontomenue } from './components/Kontomenue'
|
||||||
|
import { dienstName, useMarke } from './marke'
|
||||||
|
|
||||||
|
/** Die Breite des Inhalts — an EINER Stelle.
|
||||||
|
*
|
||||||
|
* Sie stand vorher zweimal da (Kopfzeile und Inhalt), und zwei Seiten setzten
|
||||||
|
* zusätzlich ihre eigene: dadurch waren Konto und Einstellungen schmaler als
|
||||||
|
* der Rest, und die Kopfzeile lief über beiden anders. Formulare werden
|
||||||
|
* stattdessen INNERHALB ihrer Karte begrenzt.
|
||||||
|
*/
|
||||||
|
const BREITE = 'mx-auto w-full max-w-7xl px-4'
|
||||||
|
|
||||||
|
const seiten = [
|
||||||
|
{ pfad: '/', name: 'Start', kind: Gauge, Inhalt: Start },
|
||||||
|
{ pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
||||||
|
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
|
||||||
|
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
||||||
|
{ pfad: '/papierkorb', name: 'Papierkorb', kind: Trash2, Inhalt: Papierkorb },
|
||||||
|
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||||
|
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
|
||||||
|
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Seiten OHNE Reiter. Sie sind ueber die Adresse und ueber das Kontomenue
|
||||||
|
* erreichbar -- ein Lesezeichen auf /konto soll nicht ins Leere fuehren, nur
|
||||||
|
* weil der Reiter verschwunden ist. */
|
||||||
|
const versteckt = [{ pfad: '/konto', Inhalt: Konto }] as const
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
// Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne
|
||||||
|
// Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das
|
||||||
|
// ganze Dashboard neu — bei 60 Kacheln mit Bildern ist das spürbar.
|
||||||
|
const user = useStore((s) => s.user)
|
||||||
|
const checking = useStore((s) => s.checking)
|
||||||
|
const checkSession = useStore((s) => s.checkSession)
|
||||||
|
const pfad = usePfad()
|
||||||
|
const marke = useMarke()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void checkSession()
|
||||||
|
}, [checkSession])
|
||||||
|
|
||||||
|
// EINE FREIGABE GEHT VOR ALLEM ANDEREN -- vor dem Prüfen der Sitzung, vor
|
||||||
|
// dem Anmeldeformular, vor der Kopfzeile. Wer einen Link bekommen hat, ist
|
||||||
|
// kein Benutzer dieses Dashboards; ihm ein Anmeldeformular zu zeigen wäre
|
||||||
|
// die falsche Antwort auf "sieh dir das an".
|
||||||
|
//
|
||||||
|
// Und deshalb steht das hier VOR dem `checking`-Zweig: sonst blitzt für
|
||||||
|
// einen Moment das Rädchen der Sitzungsprüfung auf, die es gar nicht
|
||||||
|
// braucht.
|
||||||
|
if (pfad.startsWith('/s/')) {
|
||||||
|
return <Freigabe schluessel={decodeURIComponent(pfad.slice(3))} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solange nicht feststeht, ob eine Sitzung besteht, wird NICHTS gezeigt.
|
||||||
|
// Sonst blitzt bei jedem Neuladen kurz das Anmeldeformular auf, obwohl man
|
||||||
|
// angemeldet ist — und das sieht aus wie ein Fehler.
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center">
|
||||||
|
<HardDrive className="animate-pulse text-schrift-leise" />
|
||||||
|
<Meldungen />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Anmelden />
|
||||||
|
<Meldungen />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ohneReiter = versteckt.find((s) => s.pfad === pfad)
|
||||||
|
const aktiv = seiten.find((s) => s.pfad === pfad) ?? seiten[0]
|
||||||
|
const Inhalt = ohneReiter ? ohneReiter.Inhalt : aktiv.Inhalt
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col">
|
||||||
|
<header className="sticky top-0 z-40 border-b border-rand bg-flaeche/80 backdrop-blur">
|
||||||
|
<div className={`${BREITE} flex flex-wrap items-center gap-x-6 gap-y-2 py-2.5`}>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
gehZu('/')
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<HardDrive size={18} className="text-akzent" />
|
||||||
|
{dienstName(marke)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 gap-1">
|
||||||
|
{seiten.map((s) => {
|
||||||
|
const an = s.pfad === aktiv.pfad
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={s.pfad}
|
||||||
|
href={s.pfad}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
gehZu(s.pfad)
|
||||||
|
}}
|
||||||
|
className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-1.5
|
||||||
|
text-sm transition-colors ${
|
||||||
|
an
|
||||||
|
? 'bg-flaeche-hoch text-schrift after:absolute after:inset-x-2.5 after:-bottom-[9px] after:h-0.5 after:rounded-full after:bg-akzent'
|
||||||
|
: 'text-schrift-leise hover:bg-flaeche-hoch/40 hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<s.kind size={15} />
|
||||||
|
{s.name}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Kontomenue />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main key={aktiv.pfad} className={`auftauchen ${BREITE} flex-1 py-5`}>
|
||||||
|
<Inhalt />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Meldungen />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
// Der EINZIGE Ort, an dem fetch steht.
|
||||||
|
//
|
||||||
|
// Und die Stelle, an der der Fivemanage-Anlauf gescheitert ist: dessen
|
||||||
|
// http-util.ts warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf
|
||||||
|
// `instanceof ApiError` — und damit wurde jeder Fehler lautlos verschluckt.
|
||||||
|
// Deshalb gibt es hier genau eine Fehlerklasse, und `request` wirft
|
||||||
|
// ausschließlich diese. Wer sie fängt, hat immer einen Text zum Anzeigen.
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kein Netz, kein Server, DNS tot — alles, was nie beim Dienst ankam. */
|
||||||
|
export class NetworkError extends ApiError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 0)
|
||||||
|
this.name = 'NetworkError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(path, {
|
||||||
|
// Das Sitzungs-Cookie ist httpOnly; ohne diese Zeile schickt der
|
||||||
|
// Browser es nicht mit, und jede Antwort wäre 401.
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init.body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// fetch wirft nur, wenn die Anfrage gar nicht stattgefunden hat. Das
|
||||||
|
// ist ein anderer Fall als "der Dienst sagt nein", und er verdient
|
||||||
|
// einen anderen Satz.
|
||||||
|
throw new NetworkError(
|
||||||
|
`Der Dienst ist nicht erreichbar (${err instanceof Error ? err.message : 'unbekannt'})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
let data: unknown = null
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
// Der Dienst antwortet auf allen API-Wegen mit JSON. Kommt hier
|
||||||
|
// etwas anderes an, ist der Proxy davor oder die Sitzung schuld —
|
||||||
|
// dann steht der Anfang der Antwort in der Meldung, statt dass
|
||||||
|
// "unerwarteter Fehler" auf dem Bildschirm steht.
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
`HTTP ${response.status}: ${text.slice(0, 140)}`,
|
||||||
|
response.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new ApiError('Die Antwort war kein JSON', response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
data && typeof data === 'object' && typeof (data as any).error === 'string'
|
||||||
|
? (data as any).error
|
||||||
|
: `HTTP ${response.status}`
|
||||||
|
throw new ApiError(message, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = <T,>(path: string) => request<T>(path)
|
||||||
|
const post = <T,>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
|
||||||
|
const del = <T,>(path: string) => request<T>(path, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// -- Die Formen, die der Dienst wirklich liefert -------------------------------
|
||||||
|
//
|
||||||
|
// Abgeschrieben aus routes/dash.ts, nicht erfunden. Wo der Server
|
||||||
|
// Datenbankspalten durchreicht, heißen sie hier genauso (can_delete,
|
||||||
|
// created_at) — ein hübscherer Name auf halbem Weg wäre eine Übersetzung, die
|
||||||
|
// beim nächsten Feld vergessen wird.
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
/** Kam dieses Konto ueber Discord herein? */
|
||||||
|
discord: boolean
|
||||||
|
discordName: string | null
|
||||||
|
/** Fertige Adresse vom Dienst — das URL-Schema von Discord steht dort und
|
||||||
|
* nicht hier. Null, wenn es kein Discord-Konto ist. */
|
||||||
|
avatar: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaItem = {
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
mime: string
|
||||||
|
token_id: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
url: string
|
||||||
|
/** Wie oft die Datei oeffentlich geholt wurde. */
|
||||||
|
abrufe: number
|
||||||
|
zuletzt_abgerufen: number | null
|
||||||
|
/** Laenge in Sekunden — nur bei Video und Ton, sonst null. */
|
||||||
|
dauer: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaPage = {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
items: MediaItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Stats = {
|
||||||
|
files: number
|
||||||
|
bytes: number
|
||||||
|
tokens: number
|
||||||
|
folders: { folder: string; files: number; bytes: number }[]
|
||||||
|
publicUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PapierkorbEintrag = {
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
mime: string
|
||||||
|
geloescht: number
|
||||||
|
von: string
|
||||||
|
/** Liegt die Datei wirklich noch im Papierkorb? Der Eintrag steht in der
|
||||||
|
* Datenbank, die Datei daneben auf der Platte — zwei Wahrheiten, die
|
||||||
|
* auseinanderlaufen koennen. */
|
||||||
|
dateiDa: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PapierkorbSeite = {
|
||||||
|
items: PapierkorbEintrag[]
|
||||||
|
n: number
|
||||||
|
bytes: number
|
||||||
|
haltbarTage: number
|
||||||
|
/** Eintraege ohne Datei. Sollte 0 sein. */
|
||||||
|
verwaist: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Speicher = {
|
||||||
|
nachArt: { art: string; files: number; bytes: number }[]
|
||||||
|
groesste: MediaItem[]
|
||||||
|
aelteste: MediaItem[]
|
||||||
|
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
|
||||||
|
verschwendet: number
|
||||||
|
stufen: { tage: number; files: number; bytes: number }[]
|
||||||
|
/** Was seit dem Einbau des Zaehlers NIE geholt wurde. */
|
||||||
|
nieGeholt: { n: number; bytes: number }
|
||||||
|
beliebteste: MediaItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenRow = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
prefix: string
|
||||||
|
can_delete: number
|
||||||
|
created_at: number
|
||||||
|
last_used_at: number | null
|
||||||
|
/** Groesste erlaubte EINZELNE Datei. Null = die Grenze des Dienstes. */
|
||||||
|
max_bytes: number | null
|
||||||
|
/** Wieviel dieser Token INSGESAMT halten darf. Null = unbegrenzt. */
|
||||||
|
quota_bytes: number | null
|
||||||
|
/** Groesste Kantenlaenge fuer Bilder. Null = ablegen wie es kommt. */
|
||||||
|
max_kante: number | null
|
||||||
|
/** Erlaubte Arten, mit Komma. Leer = alle. */
|
||||||
|
arten: string
|
||||||
|
expires_at: number | null
|
||||||
|
abgelaufen: boolean
|
||||||
|
/** Wieviel mit DIESEM Token abgelegt wurde. */
|
||||||
|
dateien: number
|
||||||
|
bytes: number
|
||||||
|
/** Wann zuletzt etwas dabei herauskam — anders als `last_used_at`, das
|
||||||
|
* auch ein Anklopfen ohne Ergebnis mitzaehlt. */
|
||||||
|
zuletztAbgelegt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
|
||||||
|
* Hash in der Datenbank, und auch der Dienst kann ihn nicht mehr zeigen. */
|
||||||
|
export type NewToken = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
prefix: string
|
||||||
|
canDelete: boolean
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Ordner mit KUMULATIVEN Zahlen: "vehicles" enthält, was in
|
||||||
|
* "vehicles/showroom" liegt. `tiefe` ist die Einrückung im Baum. */
|
||||||
|
export type FolderRow = { folder: string; files: number; bytes: number; tiefe: number }
|
||||||
|
|
||||||
|
export type Folders = {
|
||||||
|
items: FolderRow[]
|
||||||
|
gesamt: { files: number; bytes: number }
|
||||||
|
wurzel: { files: number; bytes: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der billige Fuehler fuer die automatische Auffrischung. */
|
||||||
|
export type Stand = { medien: number; neuest: number; ereignis: number }
|
||||||
|
|
||||||
|
export type Uebersicht = {
|
||||||
|
files: number
|
||||||
|
bytes: number
|
||||||
|
heuteHoch: number
|
||||||
|
wocheHoch: number
|
||||||
|
publicUrl: string
|
||||||
|
filePrefix: string
|
||||||
|
letzte: MediaEvent[]
|
||||||
|
bilder: MediaItem[]
|
||||||
|
ordner: { folder: string; files: number; bytes: number }[]
|
||||||
|
tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[]
|
||||||
|
discord: boolean
|
||||||
|
vorlagen: Vorlage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Sicherung = {
|
||||||
|
eingehaengt: boolean
|
||||||
|
ordner: string
|
||||||
|
bytes: number
|
||||||
|
items: { name: string; size: number; at: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vorlage = { name: string; muster: string }
|
||||||
|
|
||||||
|
/** Ein Konto. `weg` sagt, worueber jemand hereinkommt — die wichtigere Auskunft
|
||||||
|
* als der Name, denn ein Discord-Konto haengt an einer Rolle, die jemand
|
||||||
|
* anders vergibt. */
|
||||||
|
export type BenutzerRow = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
created_at: number
|
||||||
|
zuletzt_da: number | null
|
||||||
|
weg: 'discord' | 'passwort'
|
||||||
|
discordName: string | null
|
||||||
|
avatar: string | null
|
||||||
|
sitzungen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann.
|
||||||
|
*
|
||||||
|
* Freigegeben ist die LISTE, nicht der Inhalt — die Dateien sind ohnehin
|
||||||
|
* oeffentlich. Das steht so auch in der Karte, weil das Gegenteil zu glauben
|
||||||
|
* gefaehrlich waere. */
|
||||||
|
export type FreigabeRow = {
|
||||||
|
id: number
|
||||||
|
schluessel: string
|
||||||
|
ordner: string
|
||||||
|
titel: string
|
||||||
|
created_at: number
|
||||||
|
aufrufe: number
|
||||||
|
zuletzt: number | null
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerschiebeErgebnis = {
|
||||||
|
verschoben: number
|
||||||
|
fehlgeschlagen: number
|
||||||
|
ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere'
|
||||||
|
export type Arten = Record<string, number>
|
||||||
|
|
||||||
|
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' | 'abrufe' | 'geholt'
|
||||||
|
export type Richtung = 'auf' | 'ab'
|
||||||
|
|
||||||
|
export type Verwaiste = {
|
||||||
|
dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
|
||||||
|
datensatzOhneDatei: { anzahl: number; beispiele: string[] }
|
||||||
|
/** Vorschauen UND sparsame Fassungen, deren Original es nicht mehr gibt.
|
||||||
|
* Die Beispiele tragen den Ordner vorn — thumbs/… oder webp/…. */
|
||||||
|
abgeleitetOhneBild: { anzahl: number; beispiele: string[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was EIN STAPEL „sparsame Fassungen nachziehen" zurueckmeldet.
|
||||||
|
*
|
||||||
|
* `fertig` ist der Punkt: der Server rechnet hoechstens ein paar hundert
|
||||||
|
* Stueck je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Solange `fertig`
|
||||||
|
* falsch ist, steht in `offen`, wieviele noch fehlen. */
|
||||||
|
export type WebpLauf = {
|
||||||
|
erzeugt: number
|
||||||
|
vorhanden: number
|
||||||
|
uebersprungen: number
|
||||||
|
lohntNicht: number
|
||||||
|
fehler: number
|
||||||
|
gesamt: number
|
||||||
|
offen: number
|
||||||
|
fertig: boolean
|
||||||
|
vorher: number
|
||||||
|
nachher: number
|
||||||
|
gespart: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */
|
||||||
|
export type LetzteSicherung = {
|
||||||
|
at: number
|
||||||
|
ok: boolean
|
||||||
|
dateien?: number
|
||||||
|
groesse?: number
|
||||||
|
/** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */
|
||||||
|
hoch?: boolean
|
||||||
|
/** true = auch auf der zweiten Platte. undefined heisst „kein zweites Ziel
|
||||||
|
* eingerichtet" und nicht „hat nicht geklappt" -- schiefgegangen waere ein
|
||||||
|
* Grund fuer ok:false, nicht fuer ein stilles false. */
|
||||||
|
zweit?: boolean
|
||||||
|
fehler?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
export type Einstellungen = {
|
||||||
|
vorlagen: Vorlage[]
|
||||||
|
meldungen: { hatWebhook: boolean; anlaesse: string[] }
|
||||||
|
sicherung: {
|
||||||
|
basis: string
|
||||||
|
benutzer: string
|
||||||
|
ordner: string
|
||||||
|
behalten: number
|
||||||
|
zweitziel: string
|
||||||
|
hatPasswort: boolean
|
||||||
|
bereit: boolean
|
||||||
|
letzte: LetzteSicherung
|
||||||
|
}
|
||||||
|
discord: {
|
||||||
|
clientId: string
|
||||||
|
guildId: string
|
||||||
|
/** Wer EINE davon hat, darf herein. */
|
||||||
|
roleIds: string[]
|
||||||
|
/** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */
|
||||||
|
hatGeheimnis: boolean
|
||||||
|
bereit: boolean
|
||||||
|
redirectUri: string
|
||||||
|
ausDerUmgebung: {
|
||||||
|
clientId: boolean
|
||||||
|
clientSecret: boolean
|
||||||
|
guildId: boolean
|
||||||
|
roleIds: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile aus dem Verwaltungs-Verlauf.
|
||||||
|
*
|
||||||
|
* `was` traegt NIE einen Wert, nur Schluesselnamen — unter den Einstellungen
|
||||||
|
* liegen das Discord-Geheimnis und das Nextcloud-Passwort. */
|
||||||
|
export type VerwaltungRow = {
|
||||||
|
id: number
|
||||||
|
at: number
|
||||||
|
wer: string
|
||||||
|
bereich: 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
|
||||||
|
tat: 'angelegt' | 'geaendert' | 'entfernt'
|
||||||
|
ziel: string
|
||||||
|
was: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
|
||||||
|
|
||||||
|
export type MediaEvent = {
|
||||||
|
id: number
|
||||||
|
at: number
|
||||||
|
kind: EventKind
|
||||||
|
path: string
|
||||||
|
size: number | null
|
||||||
|
actor_kind: 'token' | 'user' | 'unbekannt'
|
||||||
|
actor: string
|
||||||
|
/** Nur bei 'move': wo die Datei vorher lag. */
|
||||||
|
von: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventsSeite = {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
items: MediaEvent[]
|
||||||
|
akteure: {
|
||||||
|
actor: string
|
||||||
|
actor_kind: string
|
||||||
|
n: number
|
||||||
|
zuletzt: number
|
||||||
|
avatar: string | null
|
||||||
|
}[]
|
||||||
|
/** Ab wann aufgezeichnet wird — null, solange nichts drinsteht. */
|
||||||
|
seit: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadErgebnis = {
|
||||||
|
name: string
|
||||||
|
ok: boolean
|
||||||
|
path?: string
|
||||||
|
url?: string
|
||||||
|
size?: number
|
||||||
|
replaced?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThumbLauf = {
|
||||||
|
erzeugt: number
|
||||||
|
vorhanden: number
|
||||||
|
uebersprungen: number
|
||||||
|
fehler: number
|
||||||
|
gesamt: number
|
||||||
|
/** Wieviele noch fehlen. Siehe WebpLauf — derselbe Grund. */
|
||||||
|
offen: number
|
||||||
|
fertig: boolean
|
||||||
|
/** Ton ohne eingebettetes Titelbild. KEIN Fehler — der Normalfall. */
|
||||||
|
ohneBild: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Adresse der Vorschau zu einem Pfad.
|
||||||
|
*
|
||||||
|
* Ob es sie gibt, weiss nur das Dateisystem — deshalb faellt die Galerie bei
|
||||||
|
* 404 auf das Vollbild zurueck, statt hier zu fragen. */
|
||||||
|
export const vorschauUrl = (path: string) =>
|
||||||
|
'/t/' + path.split('/').map(encodeURIComponent).join('/')
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
post<{ user: User }>('/api/dash/auth/login', { username, password }),
|
||||||
|
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
|
||||||
|
/** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */
|
||||||
|
authConfig: () => get<{ discord: boolean; marke: string }>('/api/dash/auth/config'),
|
||||||
|
me: () => get<{ user: User; sitzungen?: number }>('/api/dash/auth/me'),
|
||||||
|
changePassword: (current: string, next: string) =>
|
||||||
|
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
|
||||||
|
|
||||||
|
media: (
|
||||||
|
query: string,
|
||||||
|
folder: string,
|
||||||
|
art: Art,
|
||||||
|
limit: number,
|
||||||
|
offset: number,
|
||||||
|
sort: Sortierung = 'geaendert',
|
||||||
|
dir: Richtung = 'ab',
|
||||||
|
) =>
|
||||||
|
get<MediaPage>(
|
||||||
|
`/api/dash/media?query=${encodeURIComponent(query)}` +
|
||||||
|
`&folder=${encodeURIComponent(folder)}&art=${art}` +
|
||||||
|
`&limit=${limit}&offset=${offset}&sort=${sort}&dir=${dir}`,
|
||||||
|
),
|
||||||
|
/** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern.
|
||||||
|
* Nimmt DIESELBEN Einschraenkungen wie die Liste. */
|
||||||
|
medienIds: (query: string, folder: string, art: Art) =>
|
||||||
|
get<{ ids: number[]; gekappt: boolean }>(
|
||||||
|
`/api/dash/media/ids?query=${encodeURIComponent(query)}` +
|
||||||
|
`&folder=${encodeURIComponent(folder)}&art=${art}`,
|
||||||
|
),
|
||||||
|
arten: (query: string, folder: string) =>
|
||||||
|
get<Arten>(
|
||||||
|
`/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
|
||||||
|
),
|
||||||
|
folders: () => get<Folders>('/api/dash/folders'),
|
||||||
|
verschieben: (ids: number[], ziel: string) =>
|
||||||
|
post<VerschiebeErgebnis>('/api/dash/media/move', { ids, ziel }),
|
||||||
|
ordnerAnlegen: (pfad: string) => post<{ ok: true; pfad: string }>('/api/dash/folders', { pfad }),
|
||||||
|
ordnerEntfernen: (pfad: string) =>
|
||||||
|
del<{ ok: true }>('/api/dash/folders/' + pfad.split('/').map(encodeURIComponent).join('/')),
|
||||||
|
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
|
||||||
|
deleteMediaBulk: (ids: number[]) =>
|
||||||
|
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
|
||||||
|
|
||||||
|
events: (
|
||||||
|
kind: string,
|
||||||
|
actor: string,
|
||||||
|
query: string,
|
||||||
|
limit: number,
|
||||||
|
offset: number,
|
||||||
|
) =>
|
||||||
|
get<EventsSeite>(
|
||||||
|
`/api/dash/events?kind=${encodeURIComponent(kind)}` +
|
||||||
|
`&actor=${encodeURIComponent(actor)}` +
|
||||||
|
`&query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
|
||||||
|
sicherungen: () => get<Sicherung>('/api/dash/sicherungen'),
|
||||||
|
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
|
||||||
|
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
|
||||||
|
meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) =>
|
||||||
|
post<{ ok: true; meldungen: { hatWebhook: boolean; anlaesse: string[] } }>(
|
||||||
|
'/api/dash/settings',
|
||||||
|
{ meldungen },
|
||||||
|
),
|
||||||
|
meldenTesten: () => post<{ ok: true }>('/api/dash/maintenance/melden'),
|
||||||
|
vorlagenSpeichern: (liste: Vorlage[]) =>
|
||||||
|
post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }),
|
||||||
|
einstellungenSpeichern: (discord: Record<string, string>) =>
|
||||||
|
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
|
||||||
|
|
||||||
|
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
|
||||||
|
stand: () => get<Stand>('/api/dash/stand'),
|
||||||
|
speicher: () => get<Speicher>('/api/dash/speicher'),
|
||||||
|
papierkorb: () => get<PapierkorbSeite>('/api/dash/papierkorb'),
|
||||||
|
papierkorbZurueck: (ids: number[]) =>
|
||||||
|
post<{
|
||||||
|
zurueck: number
|
||||||
|
fehlgeschlagen: number
|
||||||
|
ergebnisse: { path: string; ok: boolean; error?: string }[]
|
||||||
|
}>('/api/dash/papierkorb/zurueck', { ids }),
|
||||||
|
papierkorbLeeren: (ids?: number[]) =>
|
||||||
|
post<{ geleert: number }>('/api/dash/papierkorb/leeren', ids ? { ids } : {}),
|
||||||
|
stats: () => get<Stats>('/api/dash/stats'),
|
||||||
|
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
||||||
|
webpNachziehen: () => post<WebpLauf>('/api/dash/maintenance/webp'),
|
||||||
|
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
|
||||||
|
verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
|
||||||
|
post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
|
||||||
|
'/api/dash/maintenance/orphans',
|
||||||
|
{ aktion },
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Wozu fehlt ein Bild — und was liegt da, ohne in der Liste zu stehen. */
|
||||||
|
fehlende: (muster: string, namen: string) =>
|
||||||
|
post<{
|
||||||
|
muster: string
|
||||||
|
geprueft: number
|
||||||
|
da: number
|
||||||
|
fehlt: { name: string; pfad: string }[]
|
||||||
|
ueberzaehlig: string[]
|
||||||
|
ueberzaehligGesamt: number
|
||||||
|
unbrauchbar: { name: string; grund: string }[]
|
||||||
|
}>('/api/dash/fehlende', { muster, namen }),
|
||||||
|
|
||||||
|
/** Ohne `ausfuehren` wird nur gerechnet und gezeigt — angefasst wird nichts. */
|
||||||
|
ersetzen: (suchen: string, ersetzen: string, ordner: string, ausfuehren = false) =>
|
||||||
|
post<{
|
||||||
|
vorschau?: true
|
||||||
|
treffer?: number
|
||||||
|
plan?: { alt: string; neu: string; abrufe: number }[]
|
||||||
|
planGesamt?: number
|
||||||
|
probleme?: { alt: string; grund: string }[]
|
||||||
|
abrufeGesamt?: number
|
||||||
|
umbenannt?: number
|
||||||
|
gescheitert?: { alt: string; grund: string }[]
|
||||||
|
beispiele?: { alt: string; neu: string }[]
|
||||||
|
}>('/api/dash/media/ersetzen', { suchen, ersetzen, ordner, ausfuehren }),
|
||||||
|
|
||||||
|
einstellungenImport: (werte: Record<string, string>, ueberschreiben: boolean) =>
|
||||||
|
post<{ gesetzt: string[]; uebersprungen: string[] }>('/api/dash/einstellungen/import', {
|
||||||
|
werte,
|
||||||
|
ueberschreiben,
|
||||||
|
}),
|
||||||
|
|
||||||
|
verwaltung: (limit = 100, offset = 0) =>
|
||||||
|
get<{ items: VerwaltungRow[]; total: number; limit: number; offset: number }>(
|
||||||
|
`/api/dash/verwaltung?limit=${limit}&offset=${offset}`,
|
||||||
|
),
|
||||||
|
sitzungenBeenden: () =>
|
||||||
|
post<{ beendet: number; offen: number }>('/api/dash/auth/sitzungen/beenden'),
|
||||||
|
|
||||||
|
benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'),
|
||||||
|
benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`),
|
||||||
|
|
||||||
|
freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'),
|
||||||
|
freigabeAnlegen: (ordner: string, titel: string) =>
|
||||||
|
post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>(
|
||||||
|
'/api/dash/freigaben',
|
||||||
|
{ ordner, titel },
|
||||||
|
),
|
||||||
|
freigabeWeg: (id: number) => del<{ ok: true }>(`/api/dash/freigaben/${id}`),
|
||||||
|
|
||||||
|
tokenAendern: (
|
||||||
|
id: number,
|
||||||
|
daten: {
|
||||||
|
name?: string
|
||||||
|
prefix?: string
|
||||||
|
canDelete?: boolean
|
||||||
|
maxMb?: string | null
|
||||||
|
quotaMb?: string | null
|
||||||
|
maxKante?: string | null
|
||||||
|
arten?: string[] | null
|
||||||
|
gueltigTage?: string | null
|
||||||
|
},
|
||||||
|
) => request<TokenRow & { geaendert: string[] }>(`/api/dash/tokens/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(daten),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Der Dienst holt die Datei selbst. Die Adressprüfung steht im Server
|
||||||
|
* (holen.ts) — hier kommt nur der Satz zurück, wenn sie greift. */
|
||||||
|
hochladenVonAdresse: (url: string, ordner: string, name?: string) =>
|
||||||
|
post<{
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
replaced: boolean
|
||||||
|
von: string
|
||||||
|
}>('/api/dash/upload-url', { url, ordner, name }),
|
||||||
|
|
||||||
|
tokens: () =>
|
||||||
|
get<{ items: TokenRow[]; ohneToken: { dateien: number; bytes: number } }>(
|
||||||
|
'/api/dash/tokens',
|
||||||
|
),
|
||||||
|
umbenennen: (id: number, name: string) =>
|
||||||
|
post<{ alt: string; neu: string; url: string; abrufe: number }>(
|
||||||
|
'/api/dash/media/umbenennen',
|
||||||
|
{ id, name },
|
||||||
|
),
|
||||||
|
ordnerUmbenennen: (pfad: string, name: string) =>
|
||||||
|
post<{ alt: string; neu: string; dateien: number }>('/api/dash/folders/umbenennen', {
|
||||||
|
pfad,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
createToken: (daten: {
|
||||||
|
name: string
|
||||||
|
prefix: string
|
||||||
|
canDelete: boolean
|
||||||
|
maxMb?: string
|
||||||
|
maxKante?: string
|
||||||
|
quotaMb?: string
|
||||||
|
arten?: string[]
|
||||||
|
gueltigTage?: string
|
||||||
|
}) => post<NewToken>('/api/dash/tokens', daten),
|
||||||
|
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type Stand } from './api'
|
||||||
|
import { useMerker } from './merker'
|
||||||
|
|
||||||
|
// Automatisch auffrischen — aber nicht, indem man alle fünf Sekunden alles neu
|
||||||
|
// lädt.
|
||||||
|
//
|
||||||
|
// Gefragt wird ein billiger Fühler (/api/dash/stand: drei Zahlen). Erst wenn
|
||||||
|
// der sich ändert, gibt es überhaupt etwas zu tun. Das hält den Verkehr klein
|
||||||
|
// genug, dass ein offener Tab neben einem Serienlauf niemanden stört.
|
||||||
|
//
|
||||||
|
// UND ES ZIEHT NICHTS WEG. Wer gerade etwas ausgewählt hat oder ein Bild offen
|
||||||
|
// hat, bekommt keinen Neuaufbau unter den Händen, sondern einen Hinweis mit
|
||||||
|
// Knopf. Eine Liste, die sich beim Klicken selbst neu sortiert, ist der Grund,
|
||||||
|
// warum Leute Auto-Refresh abschalten.
|
||||||
|
|
||||||
|
const TAKT_MS = 5000
|
||||||
|
|
||||||
|
const gleich = (a: Stand | null, b: Stand | null) =>
|
||||||
|
a !== null &&
|
||||||
|
b !== null &&
|
||||||
|
a.medien === b.medien &&
|
||||||
|
a.neuest === b.neuest &&
|
||||||
|
a.ereignis === b.ereignis
|
||||||
|
|
||||||
|
export function useAuffrischen({
|
||||||
|
laden,
|
||||||
|
blockiert,
|
||||||
|
}: {
|
||||||
|
/** Was zu tun ist, wenn sich etwas geändert hat. */
|
||||||
|
laden: () => void
|
||||||
|
/** Solange true, wird nicht von selbst nachgeladen — es gibt nur den
|
||||||
|
* Hinweis. Etwa: eine Auswahl steht, oder ein Bild ist offen. */
|
||||||
|
blockiert: boolean
|
||||||
|
}) {
|
||||||
|
const [an, setAn] = useMerker('auffrischen', true)
|
||||||
|
const [wartet, setWartet] = useState(false)
|
||||||
|
const bekannt = useRef<Stand | null>(null)
|
||||||
|
const blockiertRef = useRef(blockiert)
|
||||||
|
blockiertRef.current = blockiert
|
||||||
|
|
||||||
|
/** Nach eigenem Zutun (Löschen, Hochladen) gleichzuziehen — sonst meldet
|
||||||
|
* der Fühler die eigene Änderung als „neu". */
|
||||||
|
const merken = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
bekannt.current = await api.stand()
|
||||||
|
setWartet(false)
|
||||||
|
} catch {
|
||||||
|
/* beim nächsten Takt wieder */
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const jetztLaden = useCallback(() => {
|
||||||
|
setWartet(false)
|
||||||
|
laden()
|
||||||
|
void merken()
|
||||||
|
}, [laden, merken])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!an) return
|
||||||
|
let lebt = true
|
||||||
|
|
||||||
|
const takt = async () => {
|
||||||
|
// Ein Tab im Hintergrund braucht nichts zu wissen. Das spart bei
|
||||||
|
// einem Bildschirm voller offener Tabs mehr, als es kostet.
|
||||||
|
if (document.visibilityState === 'hidden') return
|
||||||
|
try {
|
||||||
|
const stand = await api.stand()
|
||||||
|
if (!lebt) return
|
||||||
|
if (bekannt.current === null) {
|
||||||
|
bekannt.current = stand
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (gleich(bekannt.current, stand)) return
|
||||||
|
|
||||||
|
if (blockiertRef.current) {
|
||||||
|
setWartet(true)
|
||||||
|
} else {
|
||||||
|
bekannt.current = stand
|
||||||
|
laden()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ein Aussetzer im Netz ist kein Grund für eine Fehlermeldung
|
||||||
|
// alle fünf Sekunden. Beim nächsten Takt noch einmal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void takt()
|
||||||
|
const uhr = setInterval(takt, TAKT_MS)
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
clearInterval(uhr)
|
||||||
|
}
|
||||||
|
}, [an, laden])
|
||||||
|
|
||||||
|
return { an, setAn, wartet, jetztLaden, merken }
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
FileQuestion,
|
||||||
|
PenLine,
|
||||||
|
Repeat,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { MediaItem } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { useMerker } from '../merker'
|
||||||
|
import { useKopieren } from './Bausteine'
|
||||||
|
import { Player } from './Player'
|
||||||
|
import { istLesbar, Textansicht } from './Textansicht'
|
||||||
|
|
||||||
|
/** Eine Datei ansehen, ohne die Seite zu verlassen.
|
||||||
|
*
|
||||||
|
* Bilder, Videos, Ton, PDF und alles Lesbare (Markdown, Code, CSV, Klartext)
|
||||||
|
* werden HIER gezeigt; alles andere bekommt einen ehrlichen Hinweis samt
|
||||||
|
* Herunterladen-Knopf. Ein Betrachter, der bei einer .zip ein leeres
|
||||||
|
* schwarzes Feld zeigt, sieht aus wie ein kaputter Betrachter.
|
||||||
|
*
|
||||||
|
* Gezeigt wird IMMER die Vollfassung und nie die Vorschau: 320 Pixel breit
|
||||||
|
* reichen für eine Kachel, aber nicht dafür, ein Bild zu beurteilen — und
|
||||||
|
* genau dazu klickt man darauf. */
|
||||||
|
export function Ansehen({
|
||||||
|
item,
|
||||||
|
naechstes,
|
||||||
|
hatVorher,
|
||||||
|
hatNachher,
|
||||||
|
onVorher,
|
||||||
|
onNachher,
|
||||||
|
onSchliessen,
|
||||||
|
onLoeschen,
|
||||||
|
onUmbenennen,
|
||||||
|
}: {
|
||||||
|
item: MediaItem
|
||||||
|
/** Was danach kaeme — gebraucht fuer "weiter zum naechsten Stueck": nur
|
||||||
|
* wenn das auch etwas Abspielbares ist, wird weitergesprungen. */
|
||||||
|
naechstes?: MediaItem
|
||||||
|
hatVorher: boolean
|
||||||
|
hatNachher: boolean
|
||||||
|
onVorher: () => void
|
||||||
|
onNachher: () => void
|
||||||
|
onSchliessen: () => void
|
||||||
|
onLoeschen: () => void
|
||||||
|
/** Fehlt in Ansichten, in denen Umbenennen keinen Sinn ergibt. */
|
||||||
|
onUmbenennen?: () => void
|
||||||
|
}) {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
const [weiter, setWeiter] = useMerker('player.weiter', true)
|
||||||
|
|
||||||
|
const art = item.mime.split('/')[0]
|
||||||
|
const spielt = art === 'video' || art === 'audio'
|
||||||
|
const element = useRef<HTMLMediaElement | null>(null)
|
||||||
|
|
||||||
|
// Tastatur: EINE Stelle fuer alles. Escape zu, Pfeile weiter — wer 900
|
||||||
|
// Bilder durchsieht, will nicht 900 Mal zielen.
|
||||||
|
//
|
||||||
|
// BEI VIDEO UND TON SPRINGEN DIE PFEILE ABER IN DER DATEI, nicht in der
|
||||||
|
// Liste. Das ist der Griff, den jeder aus jedem Player kennt, und "in der
|
||||||
|
// Aufnahme fuenf Sekunden zurueck" ist dort haeufiger als "eine Datei
|
||||||
|
// zurueck". Zum Blaettern kommt die Umschalttaste dazu — und die Pfeile am
|
||||||
|
// Bildrand tun es ohnehin, die sieht man.
|
||||||
|
//
|
||||||
|
// Die Leertaste haelt an und laesst weiterlaufen. Sie steht HIER und nicht
|
||||||
|
// im Player: sonst rollt der Browser damit die Seite darunter.
|
||||||
|
useEffect(() => {
|
||||||
|
const taste = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onSchliessen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = element.current
|
||||||
|
if (spielt && el) {
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (el.paused) void el.play()
|
||||||
|
else el.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const ziel = el.currentTime + (e.key === 'ArrowLeft' ? -5 : 5)
|
||||||
|
// Number.isFinite: solange nichts geladen ist, ist duration
|
||||||
|
// NaN — und eine Zuweisung von NaN an currentTime wirft.
|
||||||
|
el.currentTime = Number.isFinite(el.duration)
|
||||||
|
? Math.min(Math.max(0, ziel), el.duration)
|
||||||
|
: Math.max(0, ziel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowLeft' && hatVorher) onVorher()
|
||||||
|
else if (e.key === 'ArrowRight' && hatNachher) onNachher()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', taste)
|
||||||
|
return () => window.removeEventListener('keydown', taste)
|
||||||
|
}, [hatVorher, hatNachher, onVorher, onNachher, onSchliessen, spielt])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm"
|
||||||
|
onClick={onSchliessen}
|
||||||
|
>
|
||||||
|
{/* Kopfzeile */}
|
||||||
|
<header
|
||||||
|
className="flex shrink-0 items-center gap-3 border-b border-white/10 px-4 py-2.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-mono text-sm text-schrift" title={item.path}>
|
||||||
|
{item.path}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
{bytes(item.size)} · {item.mime} · {zeit(item.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await kopieren(item.url, 'url')))
|
||||||
|
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 rounded border border-white/15 px-2.5 py-1.5
|
||||||
|
text-xs text-schrift-leise hover:bg-white/10 hover:text-schrift"
|
||||||
|
>
|
||||||
|
{kopiert === 'url' ? (
|
||||||
|
<>
|
||||||
|
<Check size={13} className="text-gut" /> kopiert
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={13} /> Adresse
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Nur bei Abspielbarem — ein Schalter ohne Wirkung ist ein
|
||||||
|
Schalter, an dem jemand herumdrueckt und sich fragt, warum
|
||||||
|
nichts passiert. */}
|
||||||
|
{spielt && (
|
||||||
|
<button
|
||||||
|
onClick={() => setWeiter((w) => !w)}
|
||||||
|
title={
|
||||||
|
weiter
|
||||||
|
? 'Nach dem Ende weiter zum nächsten Stück'
|
||||||
|
: 'Am Ende stehen bleiben'
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-xs ${
|
||||||
|
weiter
|
||||||
|
? 'border-akzent/50 bg-akzent/15 text-akzent'
|
||||||
|
: 'border-white/15 text-schrift-leise hover:bg-white/10 hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Repeat size={13} />
|
||||||
|
{weiter ? 'weiter' : 'einzeln'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||||
|
hover:bg-white/10 hover:text-schrift"
|
||||||
|
aria-label="In neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{onUmbenennen && (
|
||||||
|
<button
|
||||||
|
onClick={onUmbenennen}
|
||||||
|
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||||
|
hover:bg-white/10 hover:text-schrift"
|
||||||
|
aria-label="Umbenennen"
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
<PenLine size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onLoeschen}
|
||||||
|
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||||
|
hover:bg-schlecht/20 hover:text-schlecht"
|
||||||
|
aria-label="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSchliessen}
|
||||||
|
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||||
|
hover:bg-white/10 hover:text-schrift"
|
||||||
|
aria-label="Schließen (Esc)"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Der Inhalt */}
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-4">
|
||||||
|
{hatVorher && (
|
||||||
|
<Pfeil links onClick={onVorher} />
|
||||||
|
)}
|
||||||
|
{hatNachher && <Pfeil onClick={onNachher} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 max-h-full w-full max-w-full items-center justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{art === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt={item.path}
|
||||||
|
className="max-h-[calc(100vh-9rem)] max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : spielt ? (
|
||||||
|
<Player
|
||||||
|
item={item}
|
||||||
|
elementRef={element}
|
||||||
|
onEnde={() => {
|
||||||
|
// Nur weiter, wenn das Naechste auch etwas zum
|
||||||
|
// Abspielen ist. Sonst landet man nach einem
|
||||||
|
// Lied ungefragt bei einem Bild.
|
||||||
|
if (!weiter || !hatNachher) return
|
||||||
|
const naechsteArt = naechstes?.mime.split('/')[0]
|
||||||
|
if (naechsteArt === 'video' || naechsteArt === 'audio') {
|
||||||
|
onNachher()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : item.mime === 'application/pdf' ? (
|
||||||
|
<iframe
|
||||||
|
src={item.url}
|
||||||
|
title={item.path}
|
||||||
|
className="h-[calc(100vh-9rem)] w-full max-w-4xl rounded border border-white/15 bg-white"
|
||||||
|
/>
|
||||||
|
) : istLesbar(item) ? (
|
||||||
|
<Textansicht item={item} />
|
||||||
|
) : (
|
||||||
|
// Der ehrliche Fall: wir können es nicht zeigen, und
|
||||||
|
// sagen das, statt ein leeres Feld hinzustellen.
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-white/15 bg-flaeche px-10 py-8 text-center">
|
||||||
|
<FileQuestion size={32} className="text-schrift-leise" />
|
||||||
|
<p className="text-sm text-schrift-leise">
|
||||||
|
Diese Art lässt sich hier nicht anzeigen
|
||||||
|
<br />
|
||||||
|
<code className="font-mono text-xs">{item.mime}</code>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 rounded-md border border-rand px-3 py-1.5
|
||||||
|
text-sm text-schrift hover:bg-flaeche-hoch"
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
Herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Die Tastenbelegung DAZUSCHREIBEN. Dass die Pfeile bei einem
|
||||||
|
Video springen statt zu blättern, ist die richtige Wahl —
|
||||||
|
aber sie ist unsichtbar, und eine unsichtbare Sonderregel
|
||||||
|
fühlt sich an wie ein Fehler. Eine Zeile Text kostet nichts. */}
|
||||||
|
<p
|
||||||
|
className="shrink-0 text-center text-[11px] text-schrift-leise/60"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{spielt ? (
|
||||||
|
<>
|
||||||
|
<Taste>Leer</Taste> Wiedergabe · <Taste>←</Taste>
|
||||||
|
<Taste>→</Taste> 5 s springen · <Taste>Umschalt</Taste>+
|
||||||
|
<Taste>←</Taste>
|
||||||
|
<Taste>→</Taste> Datei wechseln · <Taste>Esc</Taste> schließen
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Taste>←</Taste>
|
||||||
|
<Taste>→</Taste> blättern · <Taste>Esc</Taste> schließen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Taste = ({ children }: { children: ReactNode }) => (
|
||||||
|
<kbd className="mx-0.5 rounded border border-white/15 px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
|
||||||
|
function Pfeil({ links, onClick }: { links?: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
|
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full border border-white/15
|
||||||
|
bg-black/50 p-2 text-schrift-leise hover:bg-black/80 hover:text-schrift
|
||||||
|
${links ? 'left-4' : 'right-4'}`}
|
||||||
|
aria-label={links ? 'Vorheriges' : 'Nächstes'}
|
||||||
|
>
|
||||||
|
{links ? <ChevronLeft size={20} /> : <ChevronRight size={20} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { KeyRound } from 'lucide-react'
|
||||||
|
|
||||||
|
/** Ein Bild zu einem Namen — oder ein ehrlicher Ersatz.
|
||||||
|
*
|
||||||
|
* Drei Fälle, und jeder sieht anders aus:
|
||||||
|
*
|
||||||
|
* Bild da → das Bild von Discords CDN
|
||||||
|
* kein Bild → die Initialen auf farbigem Grund
|
||||||
|
* kein Mensch → ein Schlüssel, denn ein Token hat kein Gesicht
|
||||||
|
*
|
||||||
|
* Der Fehlerfall ist mitgedacht: lädt das Bild nicht (CDN nicht erreichbar,
|
||||||
|
* Stempel veraltet), fällt es auf die Initialen zurück statt ein kaputtes
|
||||||
|
* Bildsymbol zu zeigen. */
|
||||||
|
export function Avatar({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
groesse = 24,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
url?: string | null
|
||||||
|
token?: boolean
|
||||||
|
groesse?: number
|
||||||
|
}) {
|
||||||
|
const [gefallen, setGefallen] = useState(false)
|
||||||
|
const stil = { width: groesse, height: groesse }
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center justify-center rounded border border-rand bg-flaeche-hoch text-schrift-leise"
|
||||||
|
style={stil}
|
||||||
|
title="Token"
|
||||||
|
>
|
||||||
|
<KeyRound size={Math.round(groesse * 0.55)} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && !gefallen) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
title={name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setGefallen(true)}
|
||||||
|
className="shrink-0 rounded-full object-cover"
|
||||||
|
style={stil}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Initialen. Die Farbe ergibt sich aus dem Namen, damit derselbe Name
|
||||||
|
// immer dieselbe Farbe bekommt — sonst springt sie bei jedem Neuladen.
|
||||||
|
const initialen = name
|
||||||
|
.split(/[\s._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((t) => t[0]?.toUpperCase() ?? '')
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
let summe = 0
|
||||||
|
for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-full font-medium text-white"
|
||||||
|
style={{
|
||||||
|
...stil,
|
||||||
|
backgroundColor: `hsl(${summe} 45% 40%)`,
|
||||||
|
fontSize: Math.round(groesse * 0.42),
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
{initialen || '?'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
// -- Knopf ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
type KnopfArt = 'haupt' | 'still' | 'gefahr'
|
||||||
|
|
||||||
|
const arten: Record<KnopfArt, string> = {
|
||||||
|
haupt: 'bg-akzent text-grund hover:bg-akzent/85 border-akzent',
|
||||||
|
still: 'bg-flaeche-hoch text-schrift hover:bg-rand border-rand',
|
||||||
|
gefahr: 'bg-schlecht/15 text-schlecht hover:bg-schlecht/25 border-schlecht/40',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Knopf, der beim Arbeiten aussieht, als arbeite er.
|
||||||
|
*
|
||||||
|
* `wartet` sperrt ihn und zeigt den Kreisel. Ohne das ist der zweite Klick
|
||||||
|
* einen Wimpernschlag später eine zweite Anfrage — bei "Organisation anlegen"
|
||||||
|
* war genau das der Weg zu neunzehn gleichnamigen Einträgen. */
|
||||||
|
export function Knopf({
|
||||||
|
art = 'still',
|
||||||
|
wartet = false,
|
||||||
|
kind,
|
||||||
|
className = '',
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
art?: KnopfArt
|
||||||
|
wartet?: boolean
|
||||||
|
kind?: ReactNode
|
||||||
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
disabled={disabled || wartet}
|
||||||
|
className={`inline-flex items-center justify-center gap-2 rounded-md border px-3 py-1.5
|
||||||
|
text-sm font-medium transition-all active:scale-[0.97]
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100
|
||||||
|
${arten[art]} ${className}`}
|
||||||
|
>
|
||||||
|
{wartet && <Loader2 size={15} className="animate-spin" />}
|
||||||
|
{kind}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Eingabefeld ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Feld({
|
||||||
|
label,
|
||||||
|
hinweis,
|
||||||
|
className = '',
|
||||||
|
...rest
|
||||||
|
}: { label?: string; hinweis?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
{label && (
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">{label}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
className={`w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
|
||||||
|
text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none ${className}`}
|
||||||
|
/>
|
||||||
|
{hinweis && <span className="mt-1 block text-xs text-schrift-leise">{hinweis}</span>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Karte ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Karte({
|
||||||
|
titel,
|
||||||
|
rechts,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titel?: string
|
||||||
|
rechts?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-rand bg-flaeche shadow-sm shadow-black/20">
|
||||||
|
{(titel || rechts) && (
|
||||||
|
<header className="flex items-center justify-between gap-3 border-b border-rand px-4 py-2.5">
|
||||||
|
<h2 className="text-sm font-semibold">{titel}</h2>
|
||||||
|
{rechts}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Nachfrage vor dem Löschen -------------------------------------------------
|
||||||
|
|
||||||
|
/** Ein Dialog, der sagt, WAS gelöscht wird — nicht "Sind Sie sicher?".
|
||||||
|
*
|
||||||
|
* Löschen ist die einzige Stelle hier, die sich nicht rückgängig machen
|
||||||
|
* lässt: die Datei ist danach von der Platte weg, und es gibt keine
|
||||||
|
* Sicherung, aus der sie zurückkäme. */
|
||||||
|
export function Nachfrage({
|
||||||
|
offen,
|
||||||
|
titel,
|
||||||
|
text,
|
||||||
|
bestaetigung,
|
||||||
|
wartet,
|
||||||
|
gesperrt,
|
||||||
|
onJa,
|
||||||
|
onNein,
|
||||||
|
}: {
|
||||||
|
offen: boolean
|
||||||
|
titel: string
|
||||||
|
text: ReactNode
|
||||||
|
bestaetigung: string
|
||||||
|
wartet?: boolean
|
||||||
|
/** Sperrt den Bestaetigungsknopf, solange die Eingabe unvollstaendig ist.
|
||||||
|
* Gebraucht, seit die Nachfrage auch Felder enthaelt (Umbenennen) — ein
|
||||||
|
* Knopf, der bei leerem Feld gedrueckt werden kann, erzeugt einen Fehler,
|
||||||
|
* den der Dialog vorher schon kannte. */
|
||||||
|
gesperrt?: boolean
|
||||||
|
onJa: () => void
|
||||||
|
onNein: () => void
|
||||||
|
}) {
|
||||||
|
// Escape schließt. Ein Dialog, aus dem man nur mit der Maus herauskommt,
|
||||||
|
// ist ein Dialog, den man versehentlich bestätigt.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!offen) return
|
||||||
|
const ab = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onNein()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', ab)
|
||||||
|
return () => window.removeEventListener('keydown', ab)
|
||||||
|
}, [offen, onNein])
|
||||||
|
|
||||||
|
if (!offen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onNein}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg border border-rand bg-flaeche shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="border-b border-rand px-4 py-3 text-sm font-semibold">{titel}</h3>
|
||||||
|
<div className="px-4 py-4 text-sm text-schrift-leise">{text}</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
|
||||||
|
<Knopf onClick={onNein} kind="Abbrechen" />
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
wartet={wartet}
|
||||||
|
disabled={gesperrt}
|
||||||
|
onClick={onJa}
|
||||||
|
kind={bestaetigung}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Leerzustand ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Leer({ text, kind }: { text: string; kind?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-schrift-leise">
|
||||||
|
{kind}
|
||||||
|
<p>{text}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- In die Zwischenablage -----------------------------------------------------
|
||||||
|
|
||||||
|
/** Kopieren MIT Rückmeldung. Ein Kopierknopf ohne sichtbare Antwort ist der
|
||||||
|
* Urtyp des kaputt aussehenden Knopfes: man klickt zweimal, weil man nicht
|
||||||
|
* weiß, ob der erste Klick etwas getan hat. */
|
||||||
|
export function useKopieren() {
|
||||||
|
const [kopiert, setKopiert] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kopiert === null) return
|
||||||
|
const t = setTimeout(() => setKopiert(null), 1500)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [kopiert])
|
||||||
|
|
||||||
|
async function kopieren(text: string, marke: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setKopiert(marke)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// navigator.clipboard gibt es nur im sicheren Kontext. Über https
|
||||||
|
// ist das gegeben; wer den Dienst über eine nackte LAN-Adresse
|
||||||
|
// aufruft, bekommt hier ein ehrliches Nein statt eines Knopfes,
|
||||||
|
// der scheinbar nichts tut.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kopiert, kopieren }
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { KeyRound, Trash2, UserRound } from 'lucide-react'
|
||||||
|
import { api, type BenutzerRow } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { zeit } from '../format'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { Karte, Nachfrage } from './Bausteine'
|
||||||
|
|
||||||
|
/** Wer ein Konto hat.
|
||||||
|
*
|
||||||
|
* WOZU ÜBERHAUPT. Es gab zwei Konten und keine Stelle, an der man sie sehen
|
||||||
|
* konnte. Und weil jeder mit der Rolle auf dem Discord-Server beim ersten
|
||||||
|
* Anmelden **still** ein Konto bekommt, wären aus zwei unbemerkt zwanzig
|
||||||
|
* geworden — man erfährt es nicht, und wegnehmen ging auch nicht.
|
||||||
|
*
|
||||||
|
* Die Spalte „Weg" ist dabei die wichtigere Auskunft als der Name: ein
|
||||||
|
* Discord-Konto hängt an einer Rolle, die jemand anders vergibt. Wer die Rolle
|
||||||
|
* verliert, behält trotzdem sein Konto hier — geprüft wird die Rolle nur beim
|
||||||
|
* Anmelden. */
|
||||||
|
export function BenutzerKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [items, setItems] = useState<BenutzerRow[] | null>(null)
|
||||||
|
const [ich, setIch] = useState(0)
|
||||||
|
const [weg, setWeg] = useState<BenutzerRow | null>(null)
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const r = await api.benutzer()
|
||||||
|
setItems(r.items)
|
||||||
|
setIch(r.ich)
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!items) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Konten">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Wer sich hier anmelden kann. Ein Konto über Discord entsteht{' '}
|
||||||
|
<strong className="text-schrift">von selbst</strong>, sobald sich jemand mit der
|
||||||
|
passenden Rolle zum ersten Mal anmeldet — deshalb steht diese Liste hier.
|
||||||
|
Entfernen wirft auch die offenen Sitzungen weg, der Zugang ist also sofort zu.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-schrift-leise">
|
||||||
|
<th className="pb-2 font-medium">Name</th>
|
||||||
|
<th className="pb-2 font-medium">Weg</th>
|
||||||
|
<th className="pb-2 font-medium">Angelegt</th>
|
||||||
|
<th className="pb-2 font-medium">Zuletzt da</th>
|
||||||
|
<th className="pb-2 text-right font-medium">Sitzungen</th>
|
||||||
|
<th className="pb-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((u) => (
|
||||||
|
<tr key={u.id} className="border-t border-rand">
|
||||||
|
<td className="py-2">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Avatar url={u.avatar} name={u.username} groesse={22} />
|
||||||
|
<span className="font-medium">{u.username}</span>
|
||||||
|
{u.id === ich && (
|
||||||
|
<span className="rounded bg-akzent/15 px-1.5 py-0.5 text-[11px] text-akzent">
|
||||||
|
das bist du
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{u.weg === 'discord' ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-schrift-leise">
|
||||||
|
<UserRound size={12} /> Discord
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-schrift-leise">
|
||||||
|
<KeyRound size={12} /> Passwort
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-schrift-leise">{zeit(u.created_at)}</td>
|
||||||
|
<td className="py-2 text-schrift-leise">
|
||||||
|
{u.zuletzt_da ? (
|
||||||
|
zeit(u.zuletzt_da)
|
||||||
|
) : (
|
||||||
|
// Nicht „nie": gezählt wird erst, seit es
|
||||||
|
// die Spalte gibt. Eine Null zu behaupten,
|
||||||
|
// wo nichts gemessen wurde, wäre gelogen.
|
||||||
|
<span title="Gezählt wird erst seit dem Einbau dieser Spalte.">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right tabular-nums text-schrift-leise">
|
||||||
|
{u.sitzungen}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setWeg(u)}
|
||||||
|
disabled={u.id === ich || items.length <= 1}
|
||||||
|
title={
|
||||||
|
u.id === ich
|
||||||
|
? 'Das eigene Konto lässt sich hier nicht entfernen'
|
||||||
|
: items.length <= 1
|
||||||
|
? 'Das letzte Konto bleibt'
|
||||||
|
: 'Konto entfernen'
|
||||||
|
}
|
||||||
|
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-schlecht/15 hover:text-schlecht
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-30
|
||||||
|
disabled:hover:bg-transparent disabled:hover:text-schrift-leise"
|
||||||
|
aria-label={`${u.username} entfernen`}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={weg !== null}
|
||||||
|
titel="Konto entfernen?"
|
||||||
|
bestaetigung="Entfernen"
|
||||||
|
onNein={() => setWeg(null)}
|
||||||
|
onJa={async () => {
|
||||||
|
const u = weg
|
||||||
|
setWeg(null)
|
||||||
|
if (!u) return
|
||||||
|
await run(() => api.benutzerWeg(u.id), (r) => `${r.username} entfernt.`)
|
||||||
|
void laden()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<p>
|
||||||
|
<code className="font-mono">{weg?.username}</code> kommt danach nicht mehr
|
||||||
|
herein, und die {weg?.sitzungen ?? 0} offenen Sitzungen enden sofort.
|
||||||
|
{weg?.weg === 'discord' && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<strong className="text-schrift">Aber:</strong> hat diese Person
|
||||||
|
weiterhin die Rolle auf deinem Discord-Server, legt die nächste
|
||||||
|
Anmeldung ein neues Konto an. Wer dauerhaft draußen bleiben soll,
|
||||||
|
verliert die Rolle.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Check, Copy } from 'lucide-react'
|
||||||
|
import { useKopieren } from './Bausteine'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
|
||||||
|
/** Code mit Farbe — ohne Bibliothek.
|
||||||
|
*
|
||||||
|
* Ein Hervorheber wie highlight.js wiegt mehr als diese ganze Oberfläche und
|
||||||
|
* kann fünfzig Sprachen, von denen hier drei vorkommen. Was man wirklich
|
||||||
|
* braucht, um Code lesbar zu machen, sind drei Unterscheidungen: Kommentar,
|
||||||
|
* Zeichenkette, Schlüsselwort. Der Rest ist Zierde.
|
||||||
|
*
|
||||||
|
* Deshalb ein Tokenizer in einem Ausdruck. Er ist bewusst einfach und kennt
|
||||||
|
* seine Grenzen — verschachtelte Sonderfälle färbt er falsch. Bei einem
|
||||||
|
* Beispielblock von zehn Zeilen ist das der richtige Tausch. */
|
||||||
|
|
||||||
|
export type Sprache = 'lua' | 'bash' | 'json'
|
||||||
|
|
||||||
|
const MUSTER: Record<Sprache, RegExp> = {
|
||||||
|
// Reihenfolge zählt: Kommentare zuerst, sonst frisst eine Zeichenkette
|
||||||
|
// das `--` einer Kommentarzeile.
|
||||||
|
lua: /(--[^\n]*)|('[^'\n]*'|"[^"\n]*")|(\b(?:local|function|end|if|then|else|elseif|return|for|in|do|while|nil|true|false|and|or|not)\b)|(\b\d+\b)/g,
|
||||||
|
bash: /(#[^\n]*)|('[^'\n]*'|"[^"\n]*")|(\b(?:curl|echo|export|cd|if|then|fi|for|do|done)\b)|(-{1,2}[A-Za-z][\w-]*)/g,
|
||||||
|
json: /()("(?:[^"\\]|\\.)*")|(\b(?:true|false|null)\b)|(-?\b\d+(?:\.\d+)?\b)/g,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FARBEN = ['text-schrift-leise/70 italic', 'text-gut', 'text-akzent', 'text-warn']
|
||||||
|
|
||||||
|
/** Wird auch von der Textansicht benutzt: EIN Tokenizer fuer den Schnipsel auf
|
||||||
|
* der API-Seite und fuer die geoeffnete .lua im Betrachter. Zwei waeren zwei,
|
||||||
|
* die auseinanderlaufen. */
|
||||||
|
export function faerben(text: string, sprache: Sprache) {
|
||||||
|
const muster = new RegExp(MUSTER[sprache].source, 'g')
|
||||||
|
const stuecke: React.ReactNode[] = []
|
||||||
|
let letzte = 0
|
||||||
|
let treffer: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((treffer = muster.exec(text)) !== null) {
|
||||||
|
// Ein leerer Treffer wuerde die Schleife anhalten — bei json steht
|
||||||
|
// eine leere Gruppe fuer "keine Kommentare".
|
||||||
|
if (treffer[0] === '') {
|
||||||
|
muster.lastIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (treffer.index > letzte) stuecke.push(text.slice(letzte, treffer.index))
|
||||||
|
|
||||||
|
const gruppe = [1, 2, 3, 4].find((i) => treffer![i] !== undefined)
|
||||||
|
stuecke.push(
|
||||||
|
<span key={treffer.index} className={FARBEN[(gruppe ?? 1) - 1]}>
|
||||||
|
{treffer[0]}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
letzte = treffer.index + treffer[0].length
|
||||||
|
}
|
||||||
|
if (letzte < text.length) stuecke.push(text.slice(letzte))
|
||||||
|
return stuecke
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code({
|
||||||
|
text,
|
||||||
|
sprache = 'bash',
|
||||||
|
marke,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
sprache?: Sprache
|
||||||
|
marke: string
|
||||||
|
}) {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative">
|
||||||
|
<pre className="overflow-x-auto rounded-md border border-rand bg-grund p-3 pr-12 font-mono text-xs leading-relaxed">
|
||||||
|
<code>{faerben(text, sprache)}</code>
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await kopieren(text, marke)))
|
||||||
|
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 rounded border border-rand bg-flaeche px-2 py-1
|
||||||
|
text-schrift-leise opacity-0 transition-opacity group-hover:opacity-100
|
||||||
|
focus-visible:opacity-100 hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Kopieren"
|
||||||
|
>
|
||||||
|
{kopiert === marke ? <Check size={13} className="text-gut" /> : <Copy size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Check, Copy, ExternalLink, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
|
||||||
|
import { api, type Einstellungen } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { Feld, Karte, Knopf, useKopieren } from '../components/Bausteine'
|
||||||
|
|
||||||
|
/** Die Discord-Anmeldung einrichten — im Panel statt in der .env.
|
||||||
|
*
|
||||||
|
* Der eigentliche Gewinn steht ganz oben in der Karte: die Redirect-URI zum
|
||||||
|
* Kopieren. Sie muss ZEICHEN FÜR ZEICHEN mit dem übereinstimmen, was im
|
||||||
|
* Discord-Portal steht, und daran scheitert der erste Anlauf fast immer. */
|
||||||
|
export function DiscordKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<Einstellungen | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [speichert, setSpeichert] = useState(false)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState('')
|
||||||
|
const [geheimnis, setGeheimnis] = useState('')
|
||||||
|
const [guildId, setGuildId] = useState('')
|
||||||
|
const [rollen, setRollen] = useState('')
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
setLaedt(true)
|
||||||
|
try {
|
||||||
|
const e = await api.einstellungen()
|
||||||
|
setDaten(e)
|
||||||
|
setClientId(e.discord.clientId)
|
||||||
|
setGuildId(e.discord.guildId)
|
||||||
|
setRollen(e.discord.roleIds.join('\n'))
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function speichern() {
|
||||||
|
setSpeichert(true)
|
||||||
|
const ergebnis = await run(
|
||||||
|
() =>
|
||||||
|
api.einstellungenSpeichern({
|
||||||
|
clientId,
|
||||||
|
// Leer heißt „nicht anfassen" — sonst würde jedes Speichern
|
||||||
|
// das Geheimnis wegwerfen, weil es nie im Formular steht.
|
||||||
|
...(geheimnis ? { clientSecret: geheimnis } : {}),
|
||||||
|
guildId,
|
||||||
|
roleIds: rollen,
|
||||||
|
}),
|
||||||
|
(r) =>
|
||||||
|
r.bereit
|
||||||
|
? 'Gespeichert. Die Discord-Anmeldung ist eingerichtet.'
|
||||||
|
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
|
||||||
|
)
|
||||||
|
setSpeichert(false)
|
||||||
|
if (ergebnis) {
|
||||||
|
setGeheimnis('')
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (laedt) {
|
||||||
|
return (
|
||||||
|
<Karte titel="Anmeldung über Discord">
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!daten) return null
|
||||||
|
|
||||||
|
const d = daten.discord
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Anmeldung über Discord"
|
||||||
|
rechts={
|
||||||
|
d.bereit ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gut">
|
||||||
|
<ShieldCheck size={14} /> eingerichtet ·{' '}
|
||||||
|
{d.roleIds.length === 1 ? '1 Rolle' : `${d.roleIds.length} Rollen`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||||
|
<ShieldX size={14} /> nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-schrift-leise">
|
||||||
|
Diese Adresse muss im Discord-Portal unter OAuth2 → Redirects stehen —
|
||||||
|
Zeichen für Zeichen:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
|
||||||
|
{d.redirectUri}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await kopieren(d.redirectUri, 'uri')))
|
||||||
|
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Adresse kopieren"
|
||||||
|
>
|
||||||
|
{kopiert === 'uri' ? (
|
||||||
|
<Check size={14} className="text-gut" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://discord.com/developers/applications"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Discord-Portal öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
label="Client-ID"
|
||||||
|
value={clientId}
|
||||||
|
placeholder="17 bis 20 Ziffern"
|
||||||
|
hinweis={d.ausDerUmgebung.clientId ? 'kommt derzeit aus der Umgebung' : undefined}
|
||||||
|
onChange={(e) => setClientId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Client-Geheimnis"
|
||||||
|
type="password"
|
||||||
|
value={geheimnis}
|
||||||
|
placeholder={d.hatGeheimnis ? '•••••••• (hinterlegt)' : 'noch keines'}
|
||||||
|
hinweis={
|
||||||
|
d.ausDerUmgebung.clientSecret
|
||||||
|
? 'kommt derzeit aus der Umgebung'
|
||||||
|
: 'leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
|
||||||
|
}
|
||||||
|
onChange={(e) => setGeheimnis(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Server-ID"
|
||||||
|
value={guildId}
|
||||||
|
placeholder="Rechtsklick auf den Server → ID kopieren"
|
||||||
|
hinweis={d.ausDerUmgebung.guildId ? 'kommt derzeit aus der Umgebung' : undefined}
|
||||||
|
onChange={(e) => setGuildId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Rollen-IDs — eine je Zeile
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={rollen}
|
||||||
|
onChange={(e) => setRollen(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'143902xxxxxxxxxxxxx\n143903xxxxxxxxxxxxx'}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 font-mono
|
||||||
|
text-sm text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-xs text-schrift-leise">
|
||||||
|
{d.ausDerUmgebung.roleIds
|
||||||
|
? 'kommt derzeit aus der Umgebung'
|
||||||
|
: 'Wer EINE davon hat, darf herein. Komma, Semikolon oder Zeilenumbruch trennen — kopier sie einfach hinein.'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Die IDs bekommst du in Discord über Einstellungen → Erweitert →
|
||||||
|
Entwicklermodus, danach Rechtsklick → ID kopieren. Es wird nur{' '}
|
||||||
|
<code className="font-mono">identify</code> und{' '}
|
||||||
|
<code className="font-mono">guilds.members.read</code> erfragt — kein Bot,
|
||||||
|
keine Nachrichten, nichts weiter. Das Passwort bleibt als Notausgang
|
||||||
|
bestehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertTriangle, Eye, Replace } from 'lucide-react'
|
||||||
|
import { api, type Folders } from '../api'
|
||||||
|
import { run } from '../store'
|
||||||
|
import { Karte, Knopf, Nachfrage } from './Bausteine'
|
||||||
|
|
||||||
|
type Vorschau = Awaited<ReturnType<typeof api.ersetzen>>
|
||||||
|
|
||||||
|
/** Suchen und Ersetzen über viele Pfade.
|
||||||
|
*
|
||||||
|
* DIE VORSCHAU IST DER EIGENTLICHE TEIL, nicht die Bequemlichkeit dahinter.
|
||||||
|
* Was hier passiert, ist dieselbe Warnung wie beim Umbenennen eines Ordners —
|
||||||
|
* **jede alte Adresse gibt danach 404** —, nur mal tausend. Wer das ohne
|
||||||
|
* Gegenlesen auslöst, hat keine Gelegenheit mehr, es zu bemerken.
|
||||||
|
*
|
||||||
|
* Deshalb gibt es hier keinen Knopf, der es sofort tut. Erst zeigen, dann
|
||||||
|
* fragen, dann machen. */
|
||||||
|
export function ErsetzenKarte({
|
||||||
|
ordnerDaten,
|
||||||
|
fertig,
|
||||||
|
}: {
|
||||||
|
ordnerDaten: Folders | null
|
||||||
|
fertig: () => void
|
||||||
|
}) {
|
||||||
|
const [suchen, setSuchen] = useState('')
|
||||||
|
const [ersetzen, setErsetzen] = useState('')
|
||||||
|
const [ordner, setOrdner] = useState('')
|
||||||
|
const [vorschau, setVorschau] = useState<Vorschau | null>(null)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fragt, setFragt] = useState(false)
|
||||||
|
|
||||||
|
const bereit = suchen.trim().length > 0
|
||||||
|
const machbar =
|
||||||
|
vorschau?.vorschau === true &&
|
||||||
|
(vorschau.probleme?.length ?? 0) === 0 &&
|
||||||
|
(vorschau.planGesamt ?? 0) > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Suchen und Ersetzen in Pfaden">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Für den Fall, dass sich eine Namenskonvention ändert oder ein Ordner anders heißen
|
||||||
|
soll. <strong className="text-schrift">Erst wird gezeigt, was passieren würde</strong>{' '}
|
||||||
|
— angefasst wird nichts, bevor du es gelesen hast.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Suchen
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={suchen}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSuchen(e.target.value)
|
||||||
|
setVorschau(null)
|
||||||
|
}}
|
||||||
|
placeholder="weapon_"
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
|
||||||
|
font-mono text-sm text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Ersetzen durch
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={ersetzen}
|
||||||
|
onChange={(e) => {
|
||||||
|
setErsetzen(e.target.value)
|
||||||
|
setVorschau(null)
|
||||||
|
}}
|
||||||
|
placeholder="leer = entfernen"
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
|
||||||
|
font-mono text-sm text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Nur im Ordner
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={ordner}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOrdner(e.target.value)
|
||||||
|
setVorschau(null)
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||||
|
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">(überall)</option>
|
||||||
|
{(ordnerDaten?.items ?? []).map((o) => (
|
||||||
|
<option key={o.folder} value={o.folder}>
|
||||||
|
{' '.repeat(o.tiefe * 2)}
|
||||||
|
{o.folder.split('/').pop()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Knopf
|
||||||
|
wartet={laeuft && !fragt}
|
||||||
|
disabled={!bereit}
|
||||||
|
onClick={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.ersetzen(suchen, ersetzen, ordner, false),
|
||||||
|
(x) =>
|
||||||
|
(x.probleme?.length ?? 0) > 0
|
||||||
|
? `${x.planGesamt} möglich, ${x.probleme?.length} nicht — siehe unten.`
|
||||||
|
: `${x.planGesamt} Datei(en) würden umbenannt.`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
setVorschau(r)
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Eye size={15} />
|
||||||
|
Zeigen, was passieren würde
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{machbar && (
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
onClick={() => setFragt(true)}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Replace size={15} />
|
||||||
|
{vorschau?.planGesamt} umbenennen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vorschau?.vorschau && (
|
||||||
|
<div className="mt-4 flex flex-col gap-3 border-t border-rand pt-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
<strong>{vorschau.treffer}</strong> Treffer ·{' '}
|
||||||
|
<strong className="text-akzent">{vorschau.planGesamt}</strong> umbenennbar ·{' '}
|
||||||
|
<strong className={vorschau.probleme?.length ? 'text-schlecht' : ''}>
|
||||||
|
{vorschau.probleme?.length ?? 0}
|
||||||
|
</strong>{' '}
|
||||||
|
Probleme
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(vorschau.abrufeGesamt ?? 0) > 0 && (
|
||||||
|
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Die betroffenen Dateien wurden zusammen{' '}
|
||||||
|
<strong>{vorschau.abrufeGesamt}×</strong> geholt. Jede alte Adresse
|
||||||
|
gibt danach 404 — und keine davon meldet sich von selbst.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(vorschau.probleme?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-schlecht">
|
||||||
|
Das geht nicht — und deshalb wird gar nichts angefasst
|
||||||
|
</p>
|
||||||
|
<pre className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px]">
|
||||||
|
{vorschau.probleme?.map((p) => `${p.alt}\n → ${p.grund}`).join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(vorschau.plan?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-schrift-leise">
|
||||||
|
So sähe es aus
|
||||||
|
{(vorschau.planGesamt ?? 0) > (vorschau.plan?.length ?? 0) &&
|
||||||
|
` — erste ${vorschau.plan?.length} von ${vorschau.planGesamt}`}
|
||||||
|
</p>
|
||||||
|
<pre className="max-h-56 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px] leading-relaxed">
|
||||||
|
{vorschau.plan?.map((p) => `${p.alt}\n → ${p.neu}`).join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={fragt}
|
||||||
|
titel={`${vorschau?.planGesamt} Dateien umbenennen?`}
|
||||||
|
bestaetigung="Umbenennen"
|
||||||
|
wartet={laeuft}
|
||||||
|
onNein={() => setFragt(false)}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.ersetzen(suchen, ersetzen, ordner, true),
|
||||||
|
(x) =>
|
||||||
|
`${x.umbenannt} umbenannt` +
|
||||||
|
((x.gescheitert?.length ?? 0) > 0
|
||||||
|
? `, ${x.gescheitert?.length} nicht`
|
||||||
|
: '.'),
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
setFragt(false)
|
||||||
|
if (r) {
|
||||||
|
setVorschau(null)
|
||||||
|
setSuchen('')
|
||||||
|
setErsetzen('')
|
||||||
|
fertig()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
|
||||||
|
<span>
|
||||||
|
<code className="font-mono">{suchen}</code> wird zu{' '}
|
||||||
|
<code className="font-mono">{ersetzen || '(nichts)'}</code>, in{' '}
|
||||||
|
{vorschau?.planGesamt} Datei(en).{' '}
|
||||||
|
<strong className="text-schrift">
|
||||||
|
Alle bisherigen Adressen dieser Dateien geben danach 404.
|
||||||
|
</strong>{' '}
|
||||||
|
Rückgängig geht das nur, indem du es andersherum noch einmal machst —
|
||||||
|
und das findet die Dateien nur, solange niemand sonst etwas geändert
|
||||||
|
hat.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Check, ListChecks } from 'lucide-react'
|
||||||
|
import { api, type Vorlage } from '../api'
|
||||||
|
import { run } from '../store'
|
||||||
|
import { Karte, Knopf } from './Bausteine'
|
||||||
|
|
||||||
|
/** Wozu fehlt ein Bild?
|
||||||
|
*
|
||||||
|
* DIE FRAGE, DIE BISHER NIEMAND BEANTWORTET HAT. Das Fotostudio weiß, was es
|
||||||
|
* aufgenommen hat. Dieser Dienst weiß, was liegt. Die Differenz rechnet
|
||||||
|
* keiner aus — und deshalb fällt „das Handy zeigt bei manchen Autos kein Bild"
|
||||||
|
* erst im Spiel auf.
|
||||||
|
*
|
||||||
|
* Das Muster kommt aus den **Adressvorlagen**, damit hier nicht dieselbe
|
||||||
|
* Zeichenkette ein zweites Mal getippt wird. Wer keine angelegt hat, tippt
|
||||||
|
* sie — aber der Vorschlag steht daneben. */
|
||||||
|
export function FehlendeKarte() {
|
||||||
|
const [vorlagen, setVorlagen] = useState<Vorlage[]>([])
|
||||||
|
const [muster, setMuster] = useState('vehicles/{model}.webp')
|
||||||
|
const [namen, setNamen] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [ergebnis, setErgebnis] = useState<Awaited<
|
||||||
|
ReturnType<typeof api.fehlende>
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.einstellungen()
|
||||||
|
.then((e) => {
|
||||||
|
setVorlagen(e.vorlagen)
|
||||||
|
if (e.vorlagen[0]) setMuster(e.vorlagen[0].muster)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* dann eben ohne Vorschläge — das Feld ist trotzdem benutzbar */
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const anzahl = namen.split(/[\r\n,;]+/).filter((n) => n.trim()).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Wozu fehlt ein Bild?">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Eine Liste von Namen hineingeben — Fahrzeugmodelle, Item-Namen — und beantwortet
|
||||||
|
bekommen, wozu <strong className="text-schrift">kein Bild</strong> daliegt. Und
|
||||||
|
umgekehrt: was daliegt, ohne in der Liste zu stehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Muster — genau ein Platzhalter
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={muster}
|
||||||
|
onChange={(e) => setMuster(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
|
||||||
|
font-mono text-sm text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
{vorlagen.length > 0 && (
|
||||||
|
<span className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.muster}
|
||||||
|
onClick={() => setMuster(v.muster)}
|
||||||
|
className="rounded border border-rand px-1.5 py-0.5 font-mono
|
||||||
|
text-[11px] text-schrift-leise hover:bg-flaeche-hoch
|
||||||
|
hover:text-schrift"
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Namen — einer je Zeile {anzahl > 0 && `(${anzahl})`}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={namen}
|
||||||
|
onChange={(e) => setNamen(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder={'adder\nzentorno\nkuruma'}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
|
||||||
|
font-mono text-xs text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={laeuft}
|
||||||
|
disabled={!muster.trim() || anzahl === 0}
|
||||||
|
className="self-start"
|
||||||
|
onClick={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.fehlende(muster.trim(), namen),
|
||||||
|
(x) =>
|
||||||
|
x.fehlt.length === 0
|
||||||
|
? `Alle ${x.geprueft} sind da.`
|
||||||
|
: `${x.fehlt.length} von ${x.geprueft} fehlen.`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) setErgebnis(r)
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<ListChecks size={15} />
|
||||||
|
Nachsehen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ergebnis && (
|
||||||
|
<div className="mt-4 flex flex-col gap-3 border-t border-rand pt-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
<strong className="text-gut">{ergebnis.da}</strong> da ·{' '}
|
||||||
|
<strong className={ergebnis.fehlt.length ? 'text-schlecht' : ''}>
|
||||||
|
{ergebnis.fehlt.length}
|
||||||
|
</strong>{' '}
|
||||||
|
fehlen · <strong>{ergebnis.ueberzaehligGesamt}</strong> überzählig
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{ergebnis.fehlt.length > 0 && (
|
||||||
|
<Liste
|
||||||
|
titel="Fehlt — dafür gibt es kein Bild"
|
||||||
|
farbe="text-schlecht"
|
||||||
|
zeilen={ergebnis.fehlt.map((f) => `${f.name} → ${f.pfad}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis.ueberzaehligGesamt > 0 && (
|
||||||
|
<Liste
|
||||||
|
titel={`Liegt da, steht aber nicht in der Liste${
|
||||||
|
ergebnis.ueberzaehligGesamt > ergebnis.ueberzaehlig.length
|
||||||
|
? ` — erste ${ergebnis.ueberzaehlig.length} von ${ergebnis.ueberzaehligGesamt}`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
farbe="text-warn"
|
||||||
|
zeilen={ergebnis.ueberzaehlig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis.unbrauchbar.length > 0 && (
|
||||||
|
<Liste
|
||||||
|
titel="Als Pfad nicht zulässig"
|
||||||
|
farbe="text-schrift-leise"
|
||||||
|
zeilen={ergebnis.unbrauchbar.map((u: { name: string; grund: string }) => `${u.name} — ${u.grund}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis.fehlt.length === 0 && ergebnis.ueberzaehligGesamt === 0 && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-gut">
|
||||||
|
<Check size={15} /> Liste und Bestand stimmen überein.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Liste({ titel, farbe, zeilen }: { titel: string; farbe: string; zeilen: string[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className={`mb-1 text-xs font-medium ${farbe}`}>{titel}</p>
|
||||||
|
<pre className="max-h-48 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px] leading-relaxed">
|
||||||
|
{zeilen.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Check, Copy, ExternalLink, Link2, Share2, Trash2 } from 'lucide-react'
|
||||||
|
import { api, type Folders, type FreigabeRow } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { zeit } from '../format'
|
||||||
|
import { Karte, Knopf, Nachfrage, useKopieren } from './Bausteine'
|
||||||
|
|
||||||
|
/** Freigaben: ein Ordner zum Ansehen, ohne Anmeldung.
|
||||||
|
*
|
||||||
|
* WAS HIER WIRKLICH FREIGEGEBEN WIRD, steht groß dabei — sonst entsteht ein
|
||||||
|
* gefährliches Missverständnis in beide Richtungen: die DATEIEN sind ohnehin
|
||||||
|
* öffentlich (wer die Adresse eines Bildes kennt, bekommt es), neu ist nur die
|
||||||
|
* LISTE. Und umgekehrt: einen Link zurückzuziehen macht die Bilder nicht
|
||||||
|
* wieder privat.
|
||||||
|
*
|
||||||
|
* Wer das nicht weiß, zieht einen Link zurück und glaubt, damit sei etwas
|
||||||
|
* verschwunden. */
|
||||||
|
export function FreigabenKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [items, setItems] = useState<FreigabeRow[] | null>(null)
|
||||||
|
const [ordner, setOrdner] = useState<Folders | null>(null)
|
||||||
|
const [wahl, setWahl] = useState('')
|
||||||
|
const [titel, setTitel] = useState('')
|
||||||
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
|
const [weg, setWeg] = useState<FreigabeRow | null>(null)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const [f, o] = await Promise.all([api.freigaben(), api.folders()])
|
||||||
|
setItems(f.items)
|
||||||
|
setOrdner(o)
|
||||||
|
if (!wahl && o.items[0]) setWahl(o.items[0].folder)
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// Einmal beim Öffnen. Freigaben ändern sich nicht von selbst.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!items) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Freigaben">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Ein Link auf einen Ordner, den jeder ohne Anmeldung ansehen kann — etwa ein
|
||||||
|
Fahrzeugkatalog für Spieler.{' '}
|
||||||
|
<strong className="text-schrift">
|
||||||
|
Freigegeben wird die Liste, nicht der Inhalt:
|
||||||
|
</strong>{' '}
|
||||||
|
die Dateien selbst sind ohnehin öffentlich, wer eine Adresse kennt, bekommt das
|
||||||
|
Bild. Ein zurückgezogener Link macht sie deshalb nicht wieder privat — er nimmt
|
||||||
|
nur die Übersicht weg.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-end gap-2">
|
||||||
|
<label className="min-w-40 flex-1">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Ordner
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={wahl}
|
||||||
|
onChange={(e) => setWahl(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||||
|
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
{(ordner?.items ?? []).map((o) => (
|
||||||
|
<option key={o.folder} value={o.folder}>
|
||||||
|
{' '.repeat(o.tiefe * 2)}
|
||||||
|
{o.folder.split('/').pop()} ({o.files})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-40 flex-1">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Überschrift (freiwillig)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={titel}
|
||||||
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
|
placeholder="Fahrzeugkatalog"
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5 text-sm
|
||||||
|
text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={legtAn}
|
||||||
|
disabled={!wahl}
|
||||||
|
onClick={async () => {
|
||||||
|
setLegtAn(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.freigabeAnlegen(wahl, titel),
|
||||||
|
(x) => `Link für ${x.ordner} steht (${x.dateien} Dateien).`,
|
||||||
|
)
|
||||||
|
setLegtAn(false)
|
||||||
|
if (r) {
|
||||||
|
setTitel('')
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Share2 size={15} />
|
||||||
|
Link erzeugen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-schrift-leise">Noch keine Freigabe.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{items.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-md border border-rand
|
||||||
|
bg-grund px-3 py-2"
|
||||||
|
>
|
||||||
|
<Link2 size={14} className="shrink-0 text-schrift-leise" />
|
||||||
|
<span className="w-40 shrink-0 truncate text-sm" title={f.ordner}>
|
||||||
|
{f.titel || f.ordner}
|
||||||
|
</span>
|
||||||
|
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
|
||||||
|
{f.url}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{/* Wird der Link noch benutzt? Genau die Frage, die
|
||||||
|
man sich stellt, bevor man ihn wegwirft. */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[11px] text-schrift-leise tabular-nums"
|
||||||
|
title={f.zuletzt ? `zuletzt ${zeit(f.zuletzt)}` : 'nie geöffnet'}
|
||||||
|
>
|
||||||
|
{f.aufrufe}× geöffnet
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await kopieren(f.url, String(f.id))))
|
||||||
|
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Link kopieren"
|
||||||
|
>
|
||||||
|
{kopiert === String(f.id) ? (
|
||||||
|
<Check size={13} className="text-gut" />
|
||||||
|
) : (
|
||||||
|
<Copy size={13} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={f.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setWeg(f)}
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-schlecht/15 hover:text-schlecht"
|
||||||
|
aria-label="Zurückziehen"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={weg !== null}
|
||||||
|
titel="Link zurückziehen?"
|
||||||
|
bestaetigung="Zurückziehen"
|
||||||
|
onNein={() => setWeg(null)}
|
||||||
|
onJa={async () => {
|
||||||
|
const f = weg
|
||||||
|
setWeg(null)
|
||||||
|
if (!f) return
|
||||||
|
await run(() => api.freigabeWeg(f.id), 'Link zurückgezogen.')
|
||||||
|
void laden()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<p>
|
||||||
|
Der Link auf <code className="font-mono">{weg?.ordner}</code> führt danach
|
||||||
|
ins Leere.{' '}
|
||||||
|
<strong className="text-schrift">
|
||||||
|
Die Dateien bleiben öffentlich erreichbar
|
||||||
|
</strong>{' '}
|
||||||
|
— wer eine ihrer Adressen kennt oder gespeichert hat, kommt weiterhin
|
||||||
|
heran. Weg ist nur die Übersicht.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { CheckCircle2, FolderPlus, Upload, X, XCircle } from 'lucide-react'
|
||||||
|
import { api, type Folders, type UploadErgebnis } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes } from '../format'
|
||||||
|
import { Knopf } from './Bausteine'
|
||||||
|
import { VonAdresse } from './VonAdresse'
|
||||||
|
|
||||||
|
/** Hochladen aus dem Dashboard.
|
||||||
|
*
|
||||||
|
* Bilder, Videos, Ton, PDF — was der Dienst kennt, steht in der MIME-Tabelle
|
||||||
|
* in storage.ts. Alles andere landet als application/octet-stream und wird
|
||||||
|
* heruntergeladen statt angezeigt; das ist kein Fehler, sondern die sichere
|
||||||
|
* Voreinstellung.
|
||||||
|
*
|
||||||
|
* JEDE Datei bekommt ihre eigene Zeile mit Ergebnis. "3 von 5 hochgeladen"
|
||||||
|
* ohne die Namen der zwei anderen ist genau die Sorte Rückmeldung, wegen der
|
||||||
|
* wir hier neu bauen.
|
||||||
|
*
|
||||||
|
* DER ZIELORDNER WIRD HIER GEWÄHLT und nicht nur nebenan angezeigt. Vorher
|
||||||
|
* galt stillschweigend der in der Galerie angeklickte Ordner — das ist
|
||||||
|
* richtig, solange man es weiß, und ein Fallstrick, sobald man es nicht weiß:
|
||||||
|
* wer nach einer Suche hochlädt, steht in „Alle" und legt an der Wurzel ab.
|
||||||
|
* Der angeklickte Ordner ist deshalb weiterhin die VOREINSTELLUNG, aber eben
|
||||||
|
* eine sichtbare und änderbare. */
|
||||||
|
export function Hochladen({
|
||||||
|
ordner,
|
||||||
|
ordnerDaten,
|
||||||
|
fertig,
|
||||||
|
}: {
|
||||||
|
ordner: string
|
||||||
|
ordnerDaten: Folders | null
|
||||||
|
fertig: () => void
|
||||||
|
}) {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [ziel, setZiel] = useState(ordner)
|
||||||
|
const [neu, setNeu] = useState<string | null>(null)
|
||||||
|
const [ueber, setUeber] = useState(false)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fortschritt, setFortschritt] = useState(0)
|
||||||
|
const [ergebnisse, setErgebnisse] = useState<UploadErgebnis[] | null>(null)
|
||||||
|
const feld = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Wechselt der Ordner in der Galerie, wechselt das Ziel mit. Sonst lädt
|
||||||
|
// jemand nach einem Ordnerwechsel weiter in den vorherigen.
|
||||||
|
useEffect(() => setZiel(ordner), [ordner])
|
||||||
|
|
||||||
|
async function ordnerAnlegen() {
|
||||||
|
const name = (neu ?? '').trim()
|
||||||
|
if (!name) return
|
||||||
|
const pfad = ziel ? `${ziel}/${name}` : name
|
||||||
|
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
|
||||||
|
if (r) {
|
||||||
|
setNeu(null)
|
||||||
|
setZiel(r.pfad)
|
||||||
|
// Damit der neue Ordner auch in der Liste steht -- sonst zeigt die
|
||||||
|
// Auswahl gleich darauf einen Namen, den sie nicht kennt.
|
||||||
|
fertig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schicken(dateien: FileList | File[]) {
|
||||||
|
const liste = [...dateien]
|
||||||
|
if (liste.length === 0) return
|
||||||
|
|
||||||
|
setLaeuft(true)
|
||||||
|
setFortschritt(0)
|
||||||
|
setErgebnisse(null)
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
for (const d of liste) form.append('file', d)
|
||||||
|
if (ziel) form.append('folder', ziel)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// XMLHttpRequest und nicht fetch: nur der kennt upload.onprogress.
|
||||||
|
// Bei einem 40-MB-Video ist ein Balken der Unterschied zwischen
|
||||||
|
// "es tut sich was" und "ist es abgestürzt?".
|
||||||
|
const antwort = await new Promise<{ status: number; text: string }>(
|
||||||
|
(auf, ab) => {
|
||||||
|
const x = new XMLHttpRequest()
|
||||||
|
x.open('POST', '/api/dash/upload')
|
||||||
|
x.withCredentials = true
|
||||||
|
x.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) setFortschritt(e.loaded / e.total)
|
||||||
|
}
|
||||||
|
x.onload = () => auf({ status: x.status, text: x.responseText })
|
||||||
|
x.onerror = () => ab(new Error('Die Verbindung brach ab'))
|
||||||
|
x.send(form)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const daten = JSON.parse(antwort.text)
|
||||||
|
if (antwort.status !== 200) {
|
||||||
|
throw new Error(daten?.error ?? `HTTP ${antwort.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setErgebnisse(daten.ergebnisse)
|
||||||
|
if (daten.fehlgeschlagen === 0) {
|
||||||
|
note('ok', `${daten.hochgeladen} hochgeladen.`)
|
||||||
|
} else {
|
||||||
|
note(
|
||||||
|
'fehler',
|
||||||
|
`${daten.hochgeladen} hochgeladen, ${daten.fehlgeschlagen} nicht — siehe Liste.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (daten.hochgeladen > 0) fertig()
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false)
|
||||||
|
if (feld.current) feld.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Die Ordnerwahl STEHT ÜBER dem Ablegefeld und nicht darunter:
|
||||||
|
sie ist die Entscheidung, die man VOR dem Ziehen trifft. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="shrink-0 text-xs text-schrift-leise">Ziel</label>
|
||||||
|
<select
|
||||||
|
value={ziel}
|
||||||
|
onChange={(e) => setZiel(e.target.value)}
|
||||||
|
disabled={laeuft}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-rand bg-grund px-2 py-1.5
|
||||||
|
font-mono text-xs text-schrift outline-none focus:border-akzent
|
||||||
|
disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">(Wurzel)</option>
|
||||||
|
{(ordnerDaten?.items ?? []).map((o) => (
|
||||||
|
<option key={o.folder} value={o.folder}>
|
||||||
|
{/* Die Einrückung mit festen Leerzeichen: ein
|
||||||
|
<option> nimmt kein Layout an, aber es nimmt
|
||||||
|
Zeichen. */}
|
||||||
|
{'\u00a0'.repeat(o.tiefe * 2)}
|
||||||
|
{o.folder.split('/').pop()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => setNeu(neu === null ? '' : null)}
|
||||||
|
disabled={laeuft}
|
||||||
|
title="Neuen Ordner darin anlegen"
|
||||||
|
className="shrink-0 rounded-md border border-rand p-1.5 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift disabled:opacity-50"
|
||||||
|
aria-label="Neuen Ordner anlegen"
|
||||||
|
>
|
||||||
|
<FolderPlus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{neu !== null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="shrink-0 font-mono text-xs text-schrift-leise">
|
||||||
|
{ziel ? `${ziel}/` : ''}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={neu}
|
||||||
|
onChange={(e) => setNeu(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void ordnerAnlegen()
|
||||||
|
else if (e.key === 'Escape') setNeu(null)
|
||||||
|
}}
|
||||||
|
placeholder="name"
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-rand bg-grund px-2 py-1.5
|
||||||
|
font-mono text-xs text-schrift outline-none focus:border-akzent"
|
||||||
|
/>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
onClick={() => void ordnerAnlegen()}
|
||||||
|
kind="Anlegen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setUeber(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setUeber(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setUeber(false)
|
||||||
|
void schicken(e.dataTransfer.files)
|
||||||
|
}}
|
||||||
|
onClick={() => feld.current?.click()}
|
||||||
|
className={`flex cursor-pointer flex-col items-center gap-1 rounded-lg border
|
||||||
|
border-dashed px-4 py-5 text-center text-sm transition-colors ${
|
||||||
|
ueber
|
||||||
|
? 'border-akzent bg-akzent/10 text-schrift'
|
||||||
|
: 'border-rand text-schrift-leise hover:border-akzent/50 hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>
|
||||||
|
Dateien hierher ziehen oder <span className="text-akzent">auswählen</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
nach <code className="font-mono">{ziel ? `${ziel}/` : '(Wurzel)'}</code> ·
|
||||||
|
Bilder, Videos, Ton, PDF, Text — ein ZIP wird ausgepackt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={feld}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => e.target.files && void schicken(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{laeuft && (
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-flaeche-hoch">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-akzent transition-[width]"
|
||||||
|
style={{ width: `${Math.round(fortschritt * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnisse && (
|
||||||
|
<div className="rounded-lg border border-rand bg-flaeche">
|
||||||
|
<div className="flex items-center justify-between border-b border-rand px-3 py-1.5">
|
||||||
|
<span className="text-xs font-semibold">Ergebnis</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setErgebnisse(null)}
|
||||||
|
className="text-schrift-leise hover:text-schrift"
|
||||||
|
aria-label="Liste schließen"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-52 overflow-auto p-2 text-xs">
|
||||||
|
{ergebnisse.map((e, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 py-0.5">
|
||||||
|
{e.ok ? (
|
||||||
|
<CheckCircle2 size={13} className="mt-0.5 shrink-0 text-gut" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={13} className="mt-0.5 shrink-0 text-schlecht" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-mono">{e.path ?? e.name}</span>
|
||||||
|
{e.ok ? (
|
||||||
|
<span className="text-schrift-leise">
|
||||||
|
{' '}
|
||||||
|
— {bytes(e.size ?? 0)}
|
||||||
|
{e.replaced && ' (ersetzt)'}
|
||||||
|
{/* Der zurechtgebogene Name wird
|
||||||
|
GEZEIGT: wer "Mein Video.MP4"
|
||||||
|
zieht, soll sehen, dass daraus
|
||||||
|
"mein-video.mp4" wurde. */}
|
||||||
|
{e.path && !e.path.endsWith(e.name) && (
|
||||||
|
<span className="text-schrift-leise/70">
|
||||||
|
{' '}
|
||||||
|
· aus „{e.name}"
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-schlecht"> — {e.error}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VonAdresse ordner={ziel} ordnerDaten={ordnerDaten} fertig={fertig} />
|
||||||
|
|
||||||
|
{ergebnisse === null && !laeuft && (
|
||||||
|
<Knopf
|
||||||
|
className="self-start lg:hidden"
|
||||||
|
onClick={() => feld.current?.click()}
|
||||||
|
kind="Dateien wählen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown, LogOut, UserCog , ShieldOff } from 'lucide-react'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { gehZu } from '../router'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
/** Das Konto hinter dem eigenen Namen.
|
||||||
|
*
|
||||||
|
* Vorher war es ein eigener Reiter neben Galerie und Verlauf — und stand damit
|
||||||
|
* gleichrangig neben Dingen, die man täglich benutzt, obwohl man sein Passwort
|
||||||
|
* zweimal im Jahr ändert. Hinter dem Namen ist der Platz, an dem jeder danach
|
||||||
|
* sucht.
|
||||||
|
*
|
||||||
|
* Der Pfad /konto bleibt bestehen: ein Lesezeichen darauf soll nicht ins Leere
|
||||||
|
* führen, nur weil der Reiter verschwunden ist. */
|
||||||
|
export function Kontomenue() {
|
||||||
|
const user = useStore((s) => s.user)
|
||||||
|
const logout = useStore((s) => s.logout)
|
||||||
|
const sitzungen = useStore((s) => s.sitzungen)
|
||||||
|
const checkSession = useStore((s) => s.checkSession)
|
||||||
|
const [offen, setOffen] = useState(false)
|
||||||
|
const huelle = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Klick daneben und Escape schließen. Ein Menü, das offen bleibt, bis man
|
||||||
|
// genau den Knopf wieder trifft, ist ein Menü, das im Weg steht.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!offen) return
|
||||||
|
const daneben = (e: MouseEvent) => {
|
||||||
|
if (!huelle.current?.contains(e.target as Node)) setOffen(false)
|
||||||
|
}
|
||||||
|
const taste = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOffen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', daneben)
|
||||||
|
window.addEventListener('keydown', taste)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', daneben)
|
||||||
|
window.removeEventListener('keydown', taste)
|
||||||
|
}
|
||||||
|
}, [offen])
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const eintrag =
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-schrift-leise ' +
|
||||||
|
'transition-colors hover:bg-flaeche-hoch hover:text-schrift'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={huelle} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOffen((o) => !o)}
|
||||||
|
aria-expanded={offen}
|
||||||
|
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm text-schrift-leise
|
||||||
|
transition-colors hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
>
|
||||||
|
<Avatar name={user.username} url={user.avatar} groesse={26} />
|
||||||
|
<span className="hidden max-w-32 truncate sm:inline">{user.username}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${offen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 z-50 mt-1 w-56 overflow-hidden rounded-lg border
|
||||||
|
border-rand bg-flaeche shadow-xl shadow-black/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-rand px-3 py-2.5">
|
||||||
|
<Avatar name={user.username} url={user.avatar} groesse={32} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{user.username}</p>
|
||||||
|
<p className="text-[11px] text-schrift-leise">
|
||||||
|
{user.discord ? 'über Discord' : 'mit Passwort'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={eintrag}
|
||||||
|
onClick={() => {
|
||||||
|
setOffen(false)
|
||||||
|
gehZu('/konto')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserCog size={15} />
|
||||||
|
Konto und Passwort
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Nur wenn es ueberhaupt eine zweite gibt. Ein Knopf, der
|
||||||
|
"0 andere Sitzungen beendet" meldet, ist ein Knopf, der
|
||||||
|
nichts tut -- und beim naechsten Mal drueckt man ihn
|
||||||
|
wieder, weil man es vergessen hat. */}
|
||||||
|
{sitzungen > 1 && (
|
||||||
|
<button
|
||||||
|
className={`${eintrag} border-t border-rand`}
|
||||||
|
onClick={async () => {
|
||||||
|
setOffen(false)
|
||||||
|
await run(
|
||||||
|
() => api.sitzungenBeenden(),
|
||||||
|
(r) =>
|
||||||
|
`${r.beendet} andere Sitzung(en) beendet. Diese hier bleibt.`,
|
||||||
|
)
|
||||||
|
void checkSession()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldOff size={15} />
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
Andere Sitzungen beenden
|
||||||
|
<span className="block text-[11px] text-schrift-leise">
|
||||||
|
{sitzungen} offen — an fremden Rechnern auch
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${eintrag} border-t border-rand hover:text-schlecht`}
|
||||||
|
onClick={() => {
|
||||||
|
setOffen(false)
|
||||||
|
void run(logout, 'Abgemeldet.')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut size={15} />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Markdown darstellen — ohne Bibliothek, und das ist hier kein Geiz.
|
||||||
|
*
|
||||||
|
* Der übliche Weg heißt `marked` plus `DOMPurify`: der erste macht aus
|
||||||
|
* Markdown eine HTML-Zeichenkette, der zweite versucht, das Gefährliche wieder
|
||||||
|
* herauszuschneiden, und am Ende steht ein `dangerouslySetInnerHTML`. Damit
|
||||||
|
* hängt die Sicherheit dieser Seite an der Frage, ob die Filterliste
|
||||||
|
* vollständig ist.
|
||||||
|
*
|
||||||
|
* HIER ENTSTEHT NIE EINE HTML-ZEICHENKETTE. Der Text wird zu React-Knoten, und
|
||||||
|
* React setzt alles als Text ein. Ein `<script>` in einer hochgeladenen
|
||||||
|
* `.md` erscheint als die acht Zeichen, die es ist. Es gibt kein
|
||||||
|
* `dangerouslySetInnerHTML` in dieser Datei, und das ist der ganze Punkt —
|
||||||
|
* die Dateien hier kommen von außen, jeder mit einem Upload-Token kann eine
|
||||||
|
* ablegen.
|
||||||
|
*
|
||||||
|
* Was er kann: Überschriften, Absätze, fett, kursiv, durchgestrichen, Code
|
||||||
|
* am Stück und im Fluss, Listen (auch nummeriert), Zitate, Tabellen, Linien,
|
||||||
|
* Links, Bilder.
|
||||||
|
*
|
||||||
|
* Was er NICHT kann, damit niemand danach sucht: verschachtelte Listen,
|
||||||
|
* Fußnoten, Aufgabenlisten, eingebettetes HTML (siehe oben), Zeilenumbruch
|
||||||
|
* durch zwei Leerzeichen. Das ist die Grenze, und für eine README neben ein
|
||||||
|
* paar Bildern reicht sie. */
|
||||||
|
export function Markdown({ text }: { text: string }) {
|
||||||
|
return <div className="flex flex-col gap-3">{bloecke(text)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Blockebene ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function bloecke(text: string): ReactNode[] {
|
||||||
|
const zeilen = text.replace(/\r\n?/g, '\n').split('\n')
|
||||||
|
const raus: ReactNode[] = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
const schluessel = () => `b${i}`
|
||||||
|
|
||||||
|
while (i < zeilen.length) {
|
||||||
|
const zeile = zeilen[i] ?? ''
|
||||||
|
|
||||||
|
// Leerzeile: nichts zu tun, sie trennt bereits.
|
||||||
|
if (!zeile.trim()) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code am Stück. Ein nicht geschlossener Block läuft bis zum Ende --
|
||||||
|
// besser als der Rest der Datei in Kursiv.
|
||||||
|
const zaun = /^```(\w*)\s*$/.exec(zeile)
|
||||||
|
if (zaun) {
|
||||||
|
const start = i + 1
|
||||||
|
let ende = start
|
||||||
|
while (ende < zeilen.length && !/^```\s*$/.test(zeilen[ende] ?? '')) ende++
|
||||||
|
raus.push(
|
||||||
|
<pre
|
||||||
|
key={schluessel()}
|
||||||
|
className="overflow-x-auto rounded-md border border-rand bg-grund p-3 font-mono text-xs leading-relaxed"
|
||||||
|
>
|
||||||
|
<code>{zeilen.slice(start, ende).join('\n')}</code>
|
||||||
|
</pre>,
|
||||||
|
)
|
||||||
|
i = ende + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linie. Vor der Liste geprüft, sonst frisst "***" die Kursivschrift.
|
||||||
|
if (/^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(zeile)) {
|
||||||
|
raus.push(<hr key={schluessel()} className="border-rand" />)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const ueber = /^(#{1,6})\s+(.*)$/.exec(zeile)
|
||||||
|
if (ueber) {
|
||||||
|
const stufe = ueber[1]!.length
|
||||||
|
const groesse = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm'][
|
||||||
|
stufe - 1
|
||||||
|
]
|
||||||
|
raus.push(
|
||||||
|
<p
|
||||||
|
key={schluessel()}
|
||||||
|
className={`${groesse} font-semibold text-schrift ${stufe <= 2 ? 'mt-2 border-b border-rand pb-1' : ''}`}
|
||||||
|
>
|
||||||
|
{inline(ueber[2] ?? '')}
|
||||||
|
</p>,
|
||||||
|
)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabelle: Kopf, Trennzeile aus Strichen, dann Zeilen.
|
||||||
|
if (zeile.includes('|') && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(zeilen[i + 1] ?? '')) {
|
||||||
|
const kopf = spalten(zeile)
|
||||||
|
const zeilenRaus: string[][] = []
|
||||||
|
let j = i + 2
|
||||||
|
while (j < zeilen.length && (zeilen[j] ?? '').includes('|')) {
|
||||||
|
zeilenRaus.push(spalten(zeilen[j] ?? ''))
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
raus.push(
|
||||||
|
<div key={schluessel()} className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-rand text-left">
|
||||||
|
{kopf.map((z, k) => (
|
||||||
|
<th key={k} className="px-3 py-1.5 font-medium">
|
||||||
|
{inline(z)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilenRaus.map((r, k) => (
|
||||||
|
<tr key={k} className="border-b border-rand/50">
|
||||||
|
{r.map((z, l) => (
|
||||||
|
<td key={l} className="px-3 py-1.5">
|
||||||
|
{inline(z)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^ {0,3}>\s?/.test(zeile)) {
|
||||||
|
const teile: string[] = []
|
||||||
|
while (i < zeilen.length && /^ {0,3}>\s?/.test(zeilen[i] ?? '')) {
|
||||||
|
teile.push((zeilen[i] ?? '').replace(/^ {0,3}>\s?/, ''))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
raus.push(
|
||||||
|
<blockquote
|
||||||
|
key={schluessel()}
|
||||||
|
className="border-l-2 border-akzent/50 pl-3 text-schrift-leise"
|
||||||
|
>
|
||||||
|
{inline(teile.join(' '))}
|
||||||
|
</blockquote>,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const punkte = /^ {0,3}([-*+]|\d+\.)\s+/.exec(zeile)
|
||||||
|
if (punkte) {
|
||||||
|
const nummeriert = /\d/.test(punkte[1] ?? '')
|
||||||
|
const eintraege: string[] = []
|
||||||
|
while (i < zeilen.length) {
|
||||||
|
const m = /^ {0,3}([-*+]|\d+\.)\s+(.*)$/.exec(zeilen[i] ?? '')
|
||||||
|
if (!m) break
|
||||||
|
if (/\d/.test(m[1] ?? '') !== nummeriert) break
|
||||||
|
eintraege.push(m[2] ?? '')
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const Liste = nummeriert ? 'ol' : 'ul'
|
||||||
|
raus.push(
|
||||||
|
<Liste
|
||||||
|
key={schluessel()}
|
||||||
|
className={`ml-5 flex list-outside flex-col gap-1 text-sm ${
|
||||||
|
nummeriert ? 'list-decimal' : 'list-disc'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{eintraege.map((e, k) => (
|
||||||
|
<li key={k}>{inline(e)}</li>
|
||||||
|
))}
|
||||||
|
</Liste>,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absatz: alles bis zur nächsten Leerzeile.
|
||||||
|
const teile: string[] = []
|
||||||
|
while (i < zeilen.length && (zeilen[i] ?? '').trim() && !istBlockanfang(zeilen[i] ?? '')) {
|
||||||
|
teile.push(zeilen[i] ?? '')
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Kein Fortschritt heißt Endlosschleife — lieber die Zeile roh
|
||||||
|
// ausgeben als den Browser einfrieren.
|
||||||
|
if (teile.length === 0) {
|
||||||
|
teile.push(zeile)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
raus.push(
|
||||||
|
<p key={schluessel()} className="text-sm leading-relaxed">
|
||||||
|
{inline(teile.join(' '))}
|
||||||
|
</p>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raus
|
||||||
|
}
|
||||||
|
|
||||||
|
const istBlockanfang = (z: string) =>
|
||||||
|
/^```/.test(z) || /^#{1,6}\s/.test(z) || /^ {0,3}>/.test(z) || /^ {0,3}([-*+]|\d+\.)\s/.test(z)
|
||||||
|
|
||||||
|
const spalten = (zeile: string) =>
|
||||||
|
zeile
|
||||||
|
.trim()
|
||||||
|
.replace(/^\|/, '')
|
||||||
|
.replace(/\|$/, '')
|
||||||
|
.split('|')
|
||||||
|
.map((z) => z.trim())
|
||||||
|
|
||||||
|
// -- Im Fluss -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Nur Adressen, die nichts ausführen.
|
||||||
|
*
|
||||||
|
* `[klick](javascript:…)` ist der eine Weg, auf dem hier trotz React-Knoten
|
||||||
|
* noch Code hereinkäme — href wird von React NICHT geprüft. Erlaubt sind
|
||||||
|
* deshalb http, https, mailto und relative Ziele; alles andere wird zu
|
||||||
|
* gewöhnlichem Text. */
|
||||||
|
function sichereAdresse(roh: string): string | null {
|
||||||
|
const ziel = roh.trim()
|
||||||
|
if (/^(https?:|mailto:)/i.test(ziel)) return ziel
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(ziel)) return null
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
const MUSTER =
|
||||||
|
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(!?\[[^\]\n]*\]\([^)\s]+\))/g
|
||||||
|
|
||||||
|
function inline(text: string): ReactNode[] {
|
||||||
|
const raus: ReactNode[] = []
|
||||||
|
const muster = new RegExp(MUSTER.source, 'g')
|
||||||
|
let letzte = 0
|
||||||
|
let treffer: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((treffer = muster.exec(text)) !== null) {
|
||||||
|
if (treffer.index > letzte) raus.push(text.slice(letzte, treffer.index))
|
||||||
|
const roh = treffer[0]
|
||||||
|
const k = treffer.index
|
||||||
|
|
||||||
|
if (treffer[1]) {
|
||||||
|
raus.push(
|
||||||
|
<code key={k} className="rounded bg-grund px-1 py-0.5 font-mono text-[0.85em]">
|
||||||
|
{roh.slice(1, -1)}
|
||||||
|
</code>,
|
||||||
|
)
|
||||||
|
} else if (treffer[2]) {
|
||||||
|
raus.push(
|
||||||
|
<strong key={k} className="font-semibold text-schrift">
|
||||||
|
{roh.slice(2, -2)}
|
||||||
|
</strong>,
|
||||||
|
)
|
||||||
|
} else if (treffer[3]) {
|
||||||
|
raus.push(
|
||||||
|
<s key={k} className="text-schrift-leise">
|
||||||
|
{roh.slice(2, -2)}
|
||||||
|
</s>,
|
||||||
|
)
|
||||||
|
} else if (treffer[4]) {
|
||||||
|
raus.push(<em key={k}>{roh.slice(1, -1)}</em>)
|
||||||
|
} else {
|
||||||
|
const m = /^(!?)\[([^\]]*)\]\(([^)\s]+)\)$/.exec(roh)!
|
||||||
|
const ziel = sichereAdresse(m[3] ?? '')
|
||||||
|
if (!ziel) {
|
||||||
|
// Abgelehnt — als Text stehen lassen, damit sichtbar bleibt,
|
||||||
|
// dass da etwas stand.
|
||||||
|
raus.push(roh)
|
||||||
|
} else if (m[1]) {
|
||||||
|
raus.push(
|
||||||
|
<img
|
||||||
|
key={k}
|
||||||
|
src={ziel}
|
||||||
|
alt={m[2] ?? ''}
|
||||||
|
className="my-2 max-h-96 max-w-full rounded border border-rand"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
raus.push(
|
||||||
|
<a
|
||||||
|
key={k}
|
||||||
|
href={ziel}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="text-akzent underline underline-offset-2 hover:text-akzent/80"
|
||||||
|
>
|
||||||
|
{m[2] ?? ziel}
|
||||||
|
</a>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
letzte = k + roh.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (letzte < text.length) raus.push(text.slice(letzte))
|
||||||
|
return raus
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
|
||||||
|
import { useStore, type NoteKind } from '../store'
|
||||||
|
|
||||||
|
const gestalt: Record<NoteKind, { farbe: string; kind: typeof Info }> = {
|
||||||
|
ok: { farbe: 'border-gut/40 bg-gut/10 text-gut', kind: CheckCircle2 },
|
||||||
|
fehler: { farbe: 'border-schlecht/40 bg-schlecht/10 text-schlecht', kind: AlertTriangle },
|
||||||
|
hinweis: { farbe: 'border-akzent/40 bg-akzent/10 text-akzent', kind: Info },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Rückmeldungen, unten rechts.
|
||||||
|
*
|
||||||
|
* Erfolge verschwinden nach vier Sekunden von allein, Fehler bleiben stehen,
|
||||||
|
* bis jemand sie wegklickt — das entscheidet der Laden, nicht diese Ansicht. */
|
||||||
|
export function Meldungen() {
|
||||||
|
const notes = useStore((s) => s.notes)
|
||||||
|
const dismiss = useStore((s) => s.dismiss)
|
||||||
|
|
||||||
|
if (notes.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed right-4 bottom-4 z-60 flex w-[min(26rem,calc(100vw-2rem))] flex-col gap-2"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{notes.map((n) => {
|
||||||
|
const { farbe, kind: Kind } = gestalt[n.kind]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm
|
||||||
|
shadow-lg backdrop-blur ${farbe}`}
|
||||||
|
>
|
||||||
|
<Kind size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<span className="flex-1 break-words text-schrift">{n.text}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(n.id)}
|
||||||
|
className="shrink-0 rounded p-0.5 text-schrift-leise hover:text-schrift"
|
||||||
|
aria-label="Meldung schließen"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Bell, BellOff, Send } from 'lucide-react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { Feld, Karte, Knopf } from './Bausteine'
|
||||||
|
|
||||||
|
// ACHTUNG, DIESE LISTE HAT EINE ZWILLINGSSCHWESTER: `ANLAESSE` in
|
||||||
|
// server/src/meldung.ts. Der Server verwirft beim Speichern alles, was dort
|
||||||
|
// nicht steht -- eine Zeile, die nur hier steht, ergibt ein Kreuzchen, das sich
|
||||||
|
// setzen laesst und beim naechsten Laden wieder weg ist. Genau das ist mit
|
||||||
|
// 'platte' schon einmal passiert: gesendet 3, gespeichert 2. Wer hier etwas
|
||||||
|
// ergaenzt, ergaenzt es DORT auch.
|
||||||
|
const ANLAESSE = [
|
||||||
|
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
|
||||||
|
['verwaiste', 'Verwaiste Dateien gefunden'],
|
||||||
|
['platte', 'Der Platz auf der Platte wird knapp'],
|
||||||
|
['upload', 'Neue Dateien sind angekommen'],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Meldungen nach Discord.
|
||||||
|
*
|
||||||
|
* Der Grund ist nicht Discord, sondern eine Lücke: die Sicherung läuft nachts
|
||||||
|
* um halb fünf, und wenn sie aufhört zu laufen, merkt es niemand — das Panel
|
||||||
|
* zeigt es nur dem, der hinschaut. Ein Webhook schließt genau das.
|
||||||
|
*
|
||||||
|
* Deshalb steht der Testknopf gleich daneben. Ein Webhook, den man einträgt
|
||||||
|
* und erst in drei Wochen im Fehlerfall ausprobiert, ist ein Webhook, der dann
|
||||||
|
* nicht funktioniert. */
|
||||||
|
export function MeldungenKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [hatWebhook, setHatWebhook] = useState(false)
|
||||||
|
const [anlaesse, setAnlaesse] = useState<string[]>([])
|
||||||
|
const [webhook, setWebhook] = useState('')
|
||||||
|
const [speichert, setSpeichert] = useState(false)
|
||||||
|
const [testet, setTestet] = useState(false)
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const e = await api.einstellungen()
|
||||||
|
setHatWebhook(e.meldungen.hatWebhook)
|
||||||
|
setAnlaesse(e.meldungen.anlaesse)
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Meldungen nach Discord"
|
||||||
|
rechts={
|
||||||
|
hatWebhook ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gut">
|
||||||
|
<Bell size={14} /> eingerichtet
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||||
|
<BellOff size={14} /> aus
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Nicht für Jubelmeldungen, sondern für die zwei Fälle, in denen jemand etwas
|
||||||
|
tun muss. Der wichtigste: <strong>die Sicherung läuft nachts und niemand
|
||||||
|
merkt, wenn sie aufhört.</strong> Der Dienst sieht stündlich nach und meldet
|
||||||
|
höchstens einmal je Anlass.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
label="Webhook-Adresse"
|
||||||
|
type="password"
|
||||||
|
value={webhook}
|
||||||
|
placeholder={
|
||||||
|
hatWebhook ? '•••••••• (hinterlegt)' : 'https://discord.com/api/webhooks/…'
|
||||||
|
}
|
||||||
|
hinweis={
|
||||||
|
'In Discord: Serverkanal → Bearbeiten → Integrationen → Webhooks. ' +
|
||||||
|
'Leer lassen heißt unverändert, ein „-" entfernt ihn.'
|
||||||
|
}
|
||||||
|
onChange={(e) => setWebhook(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Wann gemeldet wird
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{ANLAESSE.map(([wert, text]) => (
|
||||||
|
<label key={wert} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={anlaesse.includes(wert)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAnlaesse((a) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...a, wert]
|
||||||
|
: a.filter((x) => x !== wert),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="accent-akzent"
|
||||||
|
/>
|
||||||
|
{text}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={speichert}
|
||||||
|
onClick={async () => {
|
||||||
|
setSpeichert(true)
|
||||||
|
const r = await run(
|
||||||
|
() =>
|
||||||
|
api.meldungenSpeichern({
|
||||||
|
...(webhook ? { webhook } : {}),
|
||||||
|
anlaesse,
|
||||||
|
}),
|
||||||
|
(x) =>
|
||||||
|
x.meldungen.hatWebhook
|
||||||
|
? 'Gespeichert. Jetzt den Testknopf drücken.'
|
||||||
|
: 'Gespeichert — ohne Webhook wird nichts gemeldet.',
|
||||||
|
)
|
||||||
|
setSpeichert(false)
|
||||||
|
if (r) {
|
||||||
|
setWebhook('')
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
kind="Speichern"
|
||||||
|
/>
|
||||||
|
<Knopf
|
||||||
|
wartet={testet}
|
||||||
|
disabled={!hatWebhook}
|
||||||
|
onClick={async () => {
|
||||||
|
setTestet(true)
|
||||||
|
await run(
|
||||||
|
() => api.meldenTesten(),
|
||||||
|
'Testnachricht geschickt — steht sie in Discord?',
|
||||||
|
)
|
||||||
|
setTestet(false)
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Send size={15} />
|
||||||
|
Testnachricht
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { AlertTriangle, Download, Upload } from 'lucide-react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { Karte, Knopf, Nachfrage } from './Bausteine'
|
||||||
|
|
||||||
|
/** Einstellungen mitnehmen.
|
||||||
|
*
|
||||||
|
* Discord-Zugang, Sicherungsziel, Adressvorlagen, Meldungsanlässe, Marke —
|
||||||
|
* alles steht in einer Tabelle und wäre beim Neuaufsetzen von Hand
|
||||||
|
* nachzutragen. Eine Stunde Klickerei, bei der man genau die eine Zeile
|
||||||
|
* vergisst, die man erst drei Wochen später braucht.
|
||||||
|
*
|
||||||
|
* GEHEIMNISSE GEHEN STANDARDMÄSSIG NICHT MIT. Eine Datei, die man „mal eben"
|
||||||
|
* exportiert und in einen Chat zieht, ist genau der Weg, auf dem ein Passwort
|
||||||
|
* abhandenkommt. Wer sie braucht, sagt es ausdrücklich — und der Hinweis steht
|
||||||
|
* dann **in** der Datei, nicht nur hier auf dem Bildschirm, von dem er beim
|
||||||
|
* Weiterreichen abfällt. */
|
||||||
|
export function MitnehmenKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [fragt, setFragt] = useState(false)
|
||||||
|
const [ueberschreiben, setUeberschreiben] = useState(false)
|
||||||
|
const feld = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function holen(mitGeheim: boolean) {
|
||||||
|
// Über einen Link und nicht über fetch: so bekommt die Datei den Namen
|
||||||
|
// aus dem Content-Disposition und landet im Download-Ordner statt im
|
||||||
|
// Speicher des Browsers.
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/api/dash/einstellungen/export${mitGeheim ? '?geheim=1' : ''}`
|
||||||
|
a.rel = 'noreferrer'
|
||||||
|
a.click()
|
||||||
|
setFragt(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function einlesen(datei: File) {
|
||||||
|
let daten: { werte?: Record<string, string> }
|
||||||
|
try {
|
||||||
|
daten = JSON.parse(await datei.text())
|
||||||
|
} catch {
|
||||||
|
note('fehler', 'Das ist keine gültige JSON-Datei.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!daten.werte || typeof daten.werte !== 'object') {
|
||||||
|
note('fehler', 'In der Datei fehlt das Feld „werte" — ist das ein Export von hier?')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await run(
|
||||||
|
() => api.einstellungenImport(daten.werte as Record<string, string>, ueberschreiben),
|
||||||
|
(r) =>
|
||||||
|
r.gesetzt.length === 0
|
||||||
|
? `Nichts gesetzt — ${r.uebersprungen.length} übersprungen. ${
|
||||||
|
ueberschreiben ? '' : 'Vorhandenes bleibt stehen, solange der Haken aus ist.'
|
||||||
|
}`
|
||||||
|
: `${r.gesetzt.length} gesetzt, ${r.uebersprungen.length} übersprungen.`,
|
||||||
|
)
|
||||||
|
if (feld.current) feld.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Einstellungen mitnehmen">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Alles, was unter Einstellungen steht, als eine Datei — für einen Umzug oder als
|
||||||
|
Notiz daneben. <strong className="text-schrift">Ohne Geheimnisse</strong>: das
|
||||||
|
Discord-Client-Geheimnis und das Nextcloud-Passwort bleiben hier und sind nach
|
||||||
|
einem Einspielen von Hand nachzutragen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Knopf
|
||||||
|
onClick={() => holen(false)}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Download size={15} />
|
||||||
|
Herunterladen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
onClick={() => setFragt(true)}
|
||||||
|
kind="mit Geheimnissen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-rand" />
|
||||||
|
|
||||||
|
<Knopf
|
||||||
|
onClick={() => feld.current?.click()}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Upload size={15} />
|
||||||
|
Einspielen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ueberschreiben}
|
||||||
|
onChange={(e) => setUeberschreiben(e.target.checked)}
|
||||||
|
className="accent-akzent"
|
||||||
|
/>
|
||||||
|
Vorhandenes überschreiben
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={feld}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => e.target.files?.[0] && void einlesen(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-[11px] text-schrift-leise">
|
||||||
|
Beim Einspielen überschreiben <strong>leere Werte nichts</strong> — eine Datei ohne
|
||||||
|
Geheimnisse darf ein vorhandenes Passwort nicht löschen. Und ohne den Haken bleibt
|
||||||
|
stehen, was schon dasteht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={fragt}
|
||||||
|
titel="Export mit Geheimnissen?"
|
||||||
|
bestaetigung="Trotzdem herunterladen"
|
||||||
|
onNein={() => setFragt(false)}
|
||||||
|
onJa={() => holen(true)}
|
||||||
|
text={
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
|
||||||
|
<span>
|
||||||
|
Die Datei enthält dann das <strong>Discord-Client-Geheimnis</strong> und
|
||||||
|
das <strong>Nextcloud-Passwort</strong> im Klartext. Nicht in einen Chat
|
||||||
|
ziehen, nicht in ein Repository legen. Dass sie es enthält, steht auch in
|
||||||
|
der Datei selbst — dieser Hinweis hier fällt beim Weiterreichen ab.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Der Download wird im Verwaltungs-Verlauf vermerkt.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, Trash2, X } from 'lucide-react'
|
||||||
|
import { api, type Folders } from '../api'
|
||||||
|
import { bytes } from '../format'
|
||||||
|
import { run } from '../store'
|
||||||
|
|
||||||
|
/** Die Ordnerübersicht neben der Galerie.
|
||||||
|
*
|
||||||
|
* Kein aufklappbarer Baum, sondern eine flache Liste mit Einrückung. Bei
|
||||||
|
* einer Handvoll Ordner ist ein Aufklappmechanismus mehr Bedienung als
|
||||||
|
* Nutzen — und was man nicht aufklappen muss, kann man auch nicht zugeklappt
|
||||||
|
* übersehen.
|
||||||
|
*
|
||||||
|
* HIER STEHT AUCH "NEUER ORDNER", und das ist eine Korrektur: anlegen ging
|
||||||
|
* bisher NUR im Verschieben-Dialog. Wer einen leeren Ordner wollte, musste
|
||||||
|
* also erst Dateien auswählen, „Verschieben" drücken und dort das Feld
|
||||||
|
* finden — eine Funktion, die man nur über eine andere Funktion erreicht, ist
|
||||||
|
* für den, der sie sucht, nicht vorhanden. */
|
||||||
|
export function Ordner({
|
||||||
|
daten,
|
||||||
|
gewaehlt,
|
||||||
|
onWahl,
|
||||||
|
onAngelegt,
|
||||||
|
onUmbenennen,
|
||||||
|
onEntfernt,
|
||||||
|
}: {
|
||||||
|
daten: Folders | null
|
||||||
|
gewaehlt: string
|
||||||
|
onWahl: (ordner: string) => void
|
||||||
|
/** Damit die Liste den neuen Ordner auch zeigt. */
|
||||||
|
onAngelegt: () => void
|
||||||
|
onUmbenennen: (pfad: string) => void
|
||||||
|
/** Nach dem Entfernen muss die Liste neu geladen werden -- sonst steht
|
||||||
|
* dort ein Ordner, den es nicht mehr gibt. */
|
||||||
|
onEntfernt: () => void
|
||||||
|
}) {
|
||||||
|
const [neu, setNeu] = useState<string | null>(null)
|
||||||
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
|
|
||||||
|
if (!daten) return null
|
||||||
|
|
||||||
|
async function entfernen(pfad: string) {
|
||||||
|
// Nachfragen, und dabei sagen, was NICHT passiert: der Ordner geht,
|
||||||
|
// die Dateien darin gehen nicht -- der Dienst laesst ihn ohnehin nur
|
||||||
|
// leer entfernen.
|
||||||
|
if (!confirm(`Ordner "${pfad}" entfernen?
|
||||||
|
|
||||||
|
Er muss leer sein; Dateien darin werden nicht angetastet.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = await run(() => api.ordnerEntfernen(pfad), `Ordner ${pfad} entfernt.`)
|
||||||
|
if (r) onEntfernt()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anlegen() {
|
||||||
|
const name = (neu ?? '').trim()
|
||||||
|
if (!name) return
|
||||||
|
setLegtAn(true)
|
||||||
|
// Relativ zum GEWÄHLTEN Ordner. Wer in "items" steht und "waffen"
|
||||||
|
// tippt, meint items/waffen -- alles andere wäre eine Überraschung.
|
||||||
|
const pfad = gewaehlt ? `${gewaehlt}/${name}` : name
|
||||||
|
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
|
||||||
|
setLegtAn(false)
|
||||||
|
if (r) {
|
||||||
|
setNeu(null)
|
||||||
|
onAngelegt()
|
||||||
|
onWahl(r.pfad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeile = (
|
||||||
|
aktiv: boolean,
|
||||||
|
kind: React.ReactNode,
|
||||||
|
name: string,
|
||||||
|
anzahl: number,
|
||||||
|
groesse: number,
|
||||||
|
tiefe = 0,
|
||||||
|
onClick?: () => void,
|
||||||
|
pfad?: string,
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
key={name + tiefe}
|
||||||
|
className={`group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm
|
||||||
|
transition-colors ${
|
||||||
|
aktiv
|
||||||
|
? 'bg-flaeche-hoch text-schrift'
|
||||||
|
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
|
||||||
|
>
|
||||||
|
<button onClick={onClick} className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||||
|
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Erst beim Darueberfahren. Eine Zeile mit drei Zahlen und einem
|
||||||
|
dauerhaft sichtbaren Knopf waere Laerm — und bei einem Dutzend
|
||||||
|
Ordnern zwoelfmal derselbe. */}
|
||||||
|
{pfad !== undefined && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onUmbenennen(pfad)
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
|
||||||
|
group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100"
|
||||||
|
aria-label={`${pfad} umbenennen`}
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
<PenLine size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ORDNER ENTFERNEN. Den Weg gab es im Dienst und in `api.ts`
|
||||||
|
laengst -- es fehlte allein der Knopf, und damit war die
|
||||||
|
Funktion fuer den, der sie sucht, nicht vorhanden. Dieselbe
|
||||||
|
Lehre wie beim "Neuer Ordner" ein paar Zeilen weiter oben.
|
||||||
|
|
||||||
|
DIE PRUEFUNG BLEIBT BEIM DIENST: er lehnt einen Ordner mit
|
||||||
|
Inhalt ab und sagt, wieviele Dateien darin liegen. Diese Regel
|
||||||
|
hier zu wiederholen hiesse, sie zweimal zu pflegen -- und die
|
||||||
|
zweite Fassung ist irgendwann die falsche. */}
|
||||||
|
{pfad !== undefined && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void entfernen(pfad)
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
|
||||||
|
group-hover:opacity-100 hover:text-schlecht focus-visible:opacity-100"
|
||||||
|
aria-label={`${pfad} entfernen`}
|
||||||
|
title="Ordner entfernen (muss leer sein)"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
|
||||||
|
{anzahl}
|
||||||
|
</span>
|
||||||
|
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
|
||||||
|
{bytes(groesse)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-full shrink-0 self-start rounded-lg border border-rand bg-flaeche p-2 lg:w-60">
|
||||||
|
<h2 className="px-2 py-1 text-xs font-semibold text-schrift-leise">Ordner</h2>
|
||||||
|
|
||||||
|
{zeile(
|
||||||
|
gewaehlt === '',
|
||||||
|
<Layers size={14} />,
|
||||||
|
'Alle',
|
||||||
|
daten.gesamt.files,
|
||||||
|
daten.gesamt.bytes,
|
||||||
|
0,
|
||||||
|
() => onWahl(''),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{daten.items.map((o) =>
|
||||||
|
zeile(
|
||||||
|
gewaehlt === o.folder,
|
||||||
|
gewaehlt === o.folder ? <FolderOpen size={14} /> : <Folder size={14} />,
|
||||||
|
// Nur das letzte Stück anzeigen — der Weg dorthin steht
|
||||||
|
// schon in der Einrückung. Der volle Pfad hängt im title.
|
||||||
|
o.folder.split('/').pop() ?? o.folder,
|
||||||
|
o.files,
|
||||||
|
o.bytes,
|
||||||
|
o.tiefe,
|
||||||
|
() => onWahl(o.folder),
|
||||||
|
o.folder,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{daten.wurzel.files > 0 && (
|
||||||
|
<p className="mt-2 px-2 text-[11px] text-schrift-leise">
|
||||||
|
{daten.wurzel.files} Datei(en) liegen ohne Ordner direkt an der Wurzel — zu
|
||||||
|
sehen unter „Alle".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 border-t border-rand pt-2">
|
||||||
|
{neu === null ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setNeu('')}
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
|
||||||
|
text-xs text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift"
|
||||||
|
>
|
||||||
|
<FolderPlus size={14} />
|
||||||
|
Neuer Ordner
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5 px-1">
|
||||||
|
{/* WO er entsteht, steht dabei. Ohne diese Zeile tippt
|
||||||
|
jemand "waffen", landet in "items/waffen" und sucht
|
||||||
|
danach an der Wurzel. */}
|
||||||
|
<p className="px-1 text-[11px] text-schrift-leise">
|
||||||
|
in{' '}
|
||||||
|
<code className="font-mono">{gewaehlt || '(Wurzel)'}</code>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={neu}
|
||||||
|
onChange={(e) => setNeu(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void anlegen()
|
||||||
|
else if (e.key === 'Escape') setNeu(null)
|
||||||
|
}}
|
||||||
|
placeholder="name"
|
||||||
|
className="min-w-0 flex-1 rounded border border-rand bg-grund px-2 py-1
|
||||||
|
font-mono text-xs text-schrift outline-none
|
||||||
|
focus:border-akzent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void anlegen()}
|
||||||
|
disabled={legtAn || !neu.trim()}
|
||||||
|
className="rounded border border-rand p-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-gut disabled:opacity-40"
|
||||||
|
aria-label="Anlegen"
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setNeu(null)}
|
||||||
|
className="rounded border border-rand p-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Abbrechen"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="px-1 text-[11px] text-schrift-leise">
|
||||||
|
Buchstabe oder Ziffer zuerst, danach auch Punkt, Strich, Unterstrich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react'
|
||||||
|
import { Music } from 'lucide-react'
|
||||||
|
import type { MediaItem } from '../api'
|
||||||
|
import { useMerker } from '../merker'
|
||||||
|
|
||||||
|
/** Video und Ton abspielen.
|
||||||
|
*
|
||||||
|
* Die Bedienelemente sind die des Browsers, und das ist eine Entscheidung:
|
||||||
|
* ein selbstgebauter Schieber sieht in jedem Browser anders falsch aus,
|
||||||
|
* kennt keine Tastatursteuerung und keine Untertitel. Was der Browser NICHT
|
||||||
|
* mitbringt, steht hier drumherum:
|
||||||
|
*
|
||||||
|
* LAUTSTÄRKE ÜBER DATEIEN HINWEG. Das `<video>`-Element setzt sie bei jedem
|
||||||
|
* neuen Element auf 1 zurück. Wer sich durch zwanzig Clips klickt, stellt sie
|
||||||
|
* sonst zwanzigmal leise — und beim einundzwanzigsten fährt sie ungefragt
|
||||||
|
* wieder hoch, wenn jemand danebensteht.
|
||||||
|
*
|
||||||
|
* WEITER ZUM NÄCHSTEN. Ohne das ist es kein Player, sondern eine Vorschau,
|
||||||
|
* die nach jedem Stück stehen bleibt. Abschaltbar, denn beim Durchsehen von
|
||||||
|
* Aufnahmen will man das Gegenteil.
|
||||||
|
*
|
||||||
|
* Beides wird gemerkt. Es sind Ansichtssachen im Sinne von `merker.ts`: sie
|
||||||
|
* formen die Ansicht, sie schränken nichts ein. */
|
||||||
|
export function Player({
|
||||||
|
item,
|
||||||
|
elementRef,
|
||||||
|
onEnde,
|
||||||
|
}: {
|
||||||
|
item: MediaItem
|
||||||
|
elementRef: RefObject<HTMLMediaElement | null>
|
||||||
|
onEnde: () => void
|
||||||
|
}) {
|
||||||
|
const [ton, setTon] = useMerker('player.ton', { laut: 1, still: false })
|
||||||
|
const istVideo = item.mime.startsWith('video')
|
||||||
|
|
||||||
|
// Die gemerkte Lautstärke auf das NEUE Element übertragen. Ohne diesen
|
||||||
|
// Effekt gilt sie nur für das erste, danach fängt jedes bei 1 an.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = elementRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.volume = ton.laut
|
||||||
|
el.muted = ton.still
|
||||||
|
}, [elementRef, item.url, ton.laut, ton.still])
|
||||||
|
|
||||||
|
const merken = () => {
|
||||||
|
const el = elementRef.current
|
||||||
|
if (!el) return
|
||||||
|
// Nur schreiben, wenn sich wirklich etwas geändert hat: sonst löst der
|
||||||
|
// eigene Effekt oben ein volumechange aus, das wieder schreibt.
|
||||||
|
if (el.volume !== ton.laut || el.muted !== ton.still) {
|
||||||
|
setTon({ laut: el.volume, still: el.muted })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (istVideo) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
// Der Schlüssel zwingt React, beim Wechsel ein NEUES Element zu
|
||||||
|
// bauen. Ohne ihn behält das alte seine Abspielposition, und
|
||||||
|
// der nächste Clip startet mitten im Bild.
|
||||||
|
key={item.url}
|
||||||
|
ref={elementRef as RefObject<HTMLVideoElement>}
|
||||||
|
src={item.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
onVolumeChange={merken}
|
||||||
|
onEnded={onEnde}
|
||||||
|
className="max-h-[calc(100vh-9rem)] max-w-full rounded"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-lg rounded-lg border border-white/15 bg-flaeche p-6">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-akzent/15">
|
||||||
|
<Music size={22} className="text-akzent" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{/* Der Dateiname, gross. Bei Ton gibt es kein Bild, an dem
|
||||||
|
man erkennt, was gerade laeuft. */}
|
||||||
|
<p className="truncate text-sm text-schrift" title={item.path}>
|
||||||
|
{item.path.split('/').pop()}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-schrift-leise">
|
||||||
|
{item.path.split('/').slice(0, -1).join('/') || '(Wurzel)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
key={item.url}
|
||||||
|
ref={elementRef as RefObject<HTMLAudioElement>}
|
||||||
|
src={item.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
onVolumeChange={merken}
|
||||||
|
onEnded={onEnde}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Download, HardDriveDownload, Loader2 } from 'lucide-react'
|
||||||
|
import { api, type Sicherung } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Karte } from './Bausteine'
|
||||||
|
|
||||||
|
/** Die fertigen Archive herunterladen.
|
||||||
|
*
|
||||||
|
* WOZU: die Sicherung lief längst, aber sie lag ausschließlich auf **dieser**
|
||||||
|
* Maschine — auf zwei Platten, und die dritte Kopie in der Nextcloud läuft
|
||||||
|
* auch hier. Wer sie woanders haben wollte, musste sich per Fernwartung auf
|
||||||
|
* den Server klicken und sie von Hand ziehen. Das ist der Grund, aus dem es
|
||||||
|
* keine Kopie außerhalb gab: nicht Unwillen, sondern Umständlichkeit.
|
||||||
|
*
|
||||||
|
* Ein Knopf hier ersetzt keine geplante Abholung — aber er macht die
|
||||||
|
* Auswärtskopie zu etwas, das man im Vorbeigehen erledigt, statt zu einem
|
||||||
|
* Vorhaben. */
|
||||||
|
export function SicherungenKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<Sicherung | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.sicherungen()
|
||||||
|
.then(setDaten)
|
||||||
|
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||||
|
.finally(() => setLaedt(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (laedt) {
|
||||||
|
return (
|
||||||
|
<Karte titel="Sicherungen holen">
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Sicherungen holen"
|
||||||
|
rechts={
|
||||||
|
daten?.items.length ? (
|
||||||
|
<span className="text-xs text-schrift-leise">
|
||||||
|
{daten.items.length} Stück · {bytes(daten.bytes)}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!daten?.eingehaengt ? (
|
||||||
|
<p className="text-sm text-schrift-leise">
|
||||||
|
Der Sicherungsordner ist nicht eingehängt. In der{' '}
|
||||||
|
<code className="font-mono text-xs">docker-compose.yml</code> braucht der
|
||||||
|
Dienst{' '}
|
||||||
|
<code className="font-mono text-xs">
|
||||||
|
{daten?.ordner ?? '/sicherungen'}
|
||||||
|
</code>{' '}
|
||||||
|
— nur lesend.
|
||||||
|
</p>
|
||||||
|
) : daten.items.length === 0 ? (
|
||||||
|
<p className="text-sm text-schrift-leise">
|
||||||
|
Noch keine Archive in <code className="font-mono text-xs">{daten.ordner}</code>.
|
||||||
|
Die Sicherung läuft täglich um 04:30.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 flex items-start gap-2 text-xs text-schrift-leise">
|
||||||
|
<HardDriveDownload size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Alle Kopien liegen auf <strong className="text-schrift">dieser</strong>{' '}
|
||||||
|
Maschine — auch die in der Nextcloud, die hier mitläuft. Ein Archiv,
|
||||||
|
das du herunterlädst, ist das erste, das den Server verlässt.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="flex flex-col divide-y divide-rand">
|
||||||
|
{daten.items.map((s) => (
|
||||||
|
<li key={s.name} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-mono text-xs text-schrift">
|
||||||
|
{s.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
{zeit(s.at)} · {bytes(s.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Ein <a download> und kein fetch: 336 MB gehören nicht
|
||||||
|
durch den Speicher des Browsers, und der Name kommt
|
||||||
|
aus dem Content-Disposition. */}
|
||||||
|
<a
|
||||||
|
href={`/api/dash/sicherungen/${s.name}`}
|
||||||
|
onClick={() => note('ok', `${s.name} wird geladen — ${bytes(s.size)}.`)}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-md border
|
||||||
|
border-rand px-2.5 py-1.5 text-xs text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
Holen
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FileQuestion, FileText, Film, Image, Loader2, Music } from 'lucide-react'
|
||||||
|
import { api, type Speicher } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Karte, Leer } from './Bausteine'
|
||||||
|
|
||||||
|
const ARTEN: Record<string, { text: string; kind: typeof Image; farbe: string }> = {
|
||||||
|
bild: { text: 'Bilder', kind: Image, farbe: 'bg-akzent' },
|
||||||
|
video: { text: 'Video', kind: Film, farbe: 'bg-warn' },
|
||||||
|
ton: { text: 'Ton', kind: Music, farbe: 'bg-gut' },
|
||||||
|
dokument: { text: 'Dokumente', kind: FileText, farbe: 'bg-schrift-leise' },
|
||||||
|
andere: { text: 'Anderes', kind: FileQuestion, farbe: 'bg-schlecht' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was liegt da eigentlich — nach Art, nach Größe, nach Alter.
|
||||||
|
*
|
||||||
|
* ERST SEHEN, DANN ENTSCHEIDEN. Eine Aufbewahrungsregel („alles älter als 90
|
||||||
|
* Tage weg") ist eine Behauptung, solange niemand weiß, wie viel das wäre.
|
||||||
|
* Diese Karten löschen nichts; sie liefern die Zahlen, mit denen man
|
||||||
|
* entscheiden kann. */
|
||||||
|
export function SpeicherKarten() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<Speicher | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.speicher()
|
||||||
|
.then(setDaten)
|
||||||
|
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
if (!daten) {
|
||||||
|
return (
|
||||||
|
<Karte titel="Nach Medienart">
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gesamt = daten.nachArt.reduce((s, a) => s + a.bytes, 0) || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Karte titel="Nach Medienart">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Ein Balken für alles, damit die Verhältnisse auf einen
|
||||||
|
Blick stimmen — Zahlen untereinander liest man, Anteile
|
||||||
|
sieht man. */}
|
||||||
|
<div className="flex h-2.5 overflow-hidden rounded-full bg-grund">
|
||||||
|
{daten.nachArt
|
||||||
|
.filter((a) => a.bytes > 0)
|
||||||
|
.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.art}
|
||||||
|
className={ARTEN[a.art]?.farbe ?? 'bg-rand'}
|
||||||
|
style={{ width: `${(a.bytes / gesamt) * 100}%` }}
|
||||||
|
title={`${ARTEN[a.art]?.text}: ${bytes(a.bytes)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{daten.nachArt.map((a) => {
|
||||||
|
const g = ARTEN[a.art]
|
||||||
|
const Kind = g?.kind ?? FileQuestion
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={a.art}
|
||||||
|
className={`border-t border-rand first:border-0 ${
|
||||||
|
a.files === 0 ? 'text-schrift-leise/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 rounded-full ${g?.farbe ?? 'bg-rand'}`}
|
||||||
|
/>
|
||||||
|
<Kind size={14} />
|
||||||
|
{g?.text ?? a.art}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">
|
||||||
|
{a.files}
|
||||||
|
</td>
|
||||||
|
<td className="w-24 py-1.5 text-right tabular-nums">
|
||||||
|
{bytes(a.bytes)}
|
||||||
|
</td>
|
||||||
|
<td className="w-16 py-1.5 text-right text-xs text-schrift-leise tabular-nums">
|
||||||
|
{gesamt > 1 ? `${Math.round((a.bytes / gesamt) * 100)} %` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Karte titel="Die größten Dateien">
|
||||||
|
{daten.groesste.length === 0 ? (
|
||||||
|
<Leer text="Noch nichts da." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 text-sm">
|
||||||
|
{daten.groesste.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate font-mono text-xs"
|
||||||
|
title={m.path}
|
||||||
|
>
|
||||||
|
{m.path}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs tabular-nums">
|
||||||
|
{bytes(m.size)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Doppelt abgelegt">
|
||||||
|
{daten.doppelte.length === 0 ? (
|
||||||
|
<p className="py-2 text-sm text-schrift-leise">
|
||||||
|
Nichts doppelt — jeder Inhalt liegt genau einmal.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Derselbe Inhalt unter mehreren Pfaden. Kein Fehler — aber{' '}
|
||||||
|
<strong className="text-warn">{bytes(daten.verschwendet)}</strong>{' '}
|
||||||
|
Platz, den niemand gewählt hat.
|
||||||
|
</p>
|
||||||
|
<ul className="flex max-h-48 flex-col gap-1 overflow-auto text-xs">
|
||||||
|
{daten.doppelte.map((d) => (
|
||||||
|
<li key={d.sha256} className="rounded border border-rand p-1.5">
|
||||||
|
<span className="text-schrift-leise">
|
||||||
|
{d.n}× · {bytes(d.bytes / d.n)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate font-mono">
|
||||||
|
{d.pfade.replaceAll('|', ' · ')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Karte titel="Wird das überhaupt geholt?">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Die Frage, die eine Aufbewahrungsregel wirklich braucht — und die sich von
|
||||||
|
innen nicht beantworten lässt. Gezählt wird seit dem Einbau des Zählers;
|
||||||
|
was davor lag, steht hier als „nie geholt", auch wenn es täglich benutzt
|
||||||
|
wurde. Nach ein paar Wochen stimmt die Zahl.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-rand bg-grund p-3">
|
||||||
|
<p className="text-xs text-schrift-leise">Nie geholt</p>
|
||||||
|
<p className="text-xl font-semibold">
|
||||||
|
{daten.nieGeholt.n}{' '}
|
||||||
|
<span className="text-sm font-normal text-schrift-leise">
|
||||||
|
({bytes(daten.nieGeholt.bytes)})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-rand bg-grund p-3">
|
||||||
|
<p className="mb-1 text-xs text-schrift-leise">Am häufigsten geholt</p>
|
||||||
|
{daten.beliebteste.length === 0 ? (
|
||||||
|
<p className="text-sm text-schrift-leise">noch nichts gezählt</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-0.5 text-xs">
|
||||||
|
{daten.beliebteste.slice(0, 5).map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono" title={m.path}>
|
||||||
|
{m.path}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-schrift-leise">
|
||||||
|
{m.abrufe}×
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Wie alt der Bestand ist">
|
||||||
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
|
Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage
|
||||||
|
löschen" ist eine Behauptung, solange niemand weiß, wie viel das wäre —
|
||||||
|
hier steht es. <strong>Gelöscht wird hier nichts.</strong>
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{daten.stufen.map((st) => (
|
||||||
|
<tr key={st.tage} className="border-t border-rand first:border-0">
|
||||||
|
<td className="py-1.5">älter als {st.tage} Tage</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">
|
||||||
|
{st.files} Dateien
|
||||||
|
</td>
|
||||||
|
<td className="w-24 py-1.5 text-right tabular-nums">
|
||||||
|
{bytes(st.bytes)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{daten.aelteste[0] && (
|
||||||
|
<p className="mt-3 text-xs text-schrift-leise">
|
||||||
|
Das älteste ist{' '}
|
||||||
|
<code className="font-mono">{daten.aelteste[0].path}</code> vom{' '}
|
||||||
|
{zeit(daten.aelteste[0].created_at)}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
|
||||||
|
import type { MediaItem } from '../api'
|
||||||
|
import { bytes } from '../format'
|
||||||
|
import { faerben, type Sprache } from './Code'
|
||||||
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
|
/** Lesbare Dateien im Betrachter zeigen: Markdown, Code, CSV, Klartext.
|
||||||
|
*
|
||||||
|
* Bis hierhin landete eine `.md` im Zweig „lässt sich hier nicht anzeigen" —
|
||||||
|
* ausgerechnet die Art, die man am ehesten nur lesen und nicht herunterladen
|
||||||
|
* will.
|
||||||
|
*
|
||||||
|
* ES WIRD NICHT ALLES GEHOLT. Die Größe steht in der Datenbank, also wissen
|
||||||
|
* wir vorher, worauf wir uns einlassen. Über der Grenze wird per
|
||||||
|
* Bereichsanfrage nur der Anfang geladen und das auch gesagt — ein Betrachter,
|
||||||
|
* der bei einem 400-MB-Protokoll den Tab abschießt, ist schlimmer als einer,
|
||||||
|
* der die Datei gar nicht erst öffnet. */
|
||||||
|
|
||||||
|
/** 512 KB. Das sind rund 10 000 Zeilen — mehr liest ohnehin niemand am Stück,
|
||||||
|
* und der Herunterladen-Knopf steht daneben. */
|
||||||
|
const HOECHSTENS = 512 * 1024
|
||||||
|
|
||||||
|
type Darstellung = 'markdown' | 'tabelle' | 'code' | 'klartext'
|
||||||
|
|
||||||
|
const endung = (pfad: string) => pfad.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
|
||||||
|
/** Ob wir diese Datei überhaupt anzeigen können.
|
||||||
|
*
|
||||||
|
* ENTSCHIEDEN WIRD NACH DER ENDUNG und erst danach nach der gemeldeten Art.
|
||||||
|
* Der Grund steht in der Datenbank: alles, was vor der erweiterten
|
||||||
|
* Typtabelle hochgeladen wurde, trägt dort `application/octet-stream`. Wer
|
||||||
|
* nur auf die Art schaut, zeigt eine alte README nicht an und eine neue schon
|
||||||
|
* — und sucht den Unterschied an der falschen Stelle. */
|
||||||
|
export function istLesbar(item: MediaItem): boolean {
|
||||||
|
return (
|
||||||
|
LESBAR.has(endung(item.path)) ||
|
||||||
|
item.mime.startsWith('text/') ||
|
||||||
|
item.mime === 'application/json'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LESBAR = new Set([
|
||||||
|
'md', 'markdown', 'txt', 'log', 'csv', 'json', 'lua', 'xml', 'yml', 'yaml',
|
||||||
|
'toml', 'ini', 'cfg', 'sql', 'js', 'ts', 'css', 'html', 'sh', 'ps1',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SPRACHE: Record<string, Sprache> = {
|
||||||
|
lua: 'lua',
|
||||||
|
json: 'json',
|
||||||
|
sh: 'bash',
|
||||||
|
ps1: 'bash',
|
||||||
|
}
|
||||||
|
|
||||||
|
function darstellungFuer(pfad: string): Darstellung {
|
||||||
|
const e = endung(pfad)
|
||||||
|
if (e === 'md' || e === 'markdown') return 'markdown'
|
||||||
|
if (e === 'csv') return 'tabelle'
|
||||||
|
if (e in SPRACHE) return 'code'
|
||||||
|
return 'klartext'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textansicht({ item }: { item: MediaItem }) {
|
||||||
|
const [text, setText] = useState<string | null>(null)
|
||||||
|
const [fehler, setFehler] = useState('')
|
||||||
|
const [gekappt, setGekappt] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lebt = true
|
||||||
|
setText(null)
|
||||||
|
setFehler('')
|
||||||
|
setGekappt(false)
|
||||||
|
|
||||||
|
const zuGross = item.size > HOECHSTENS
|
||||||
|
fetch(item.url, zuGross ? { headers: { Range: `bytes=0-${HOECHSTENS - 1}` } } : undefined)
|
||||||
|
.then(async (r) => {
|
||||||
|
// Ein Dienst, der Bereichsanfragen NICHT beherrscht, antwortet
|
||||||
|
// mit 200 und der ganzen Datei. Unserer kann es -- aber die
|
||||||
|
// Prüfung kostet nichts und der Fehlschlag wäre still.
|
||||||
|
if (!r.ok && r.status !== 206) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const roh = await r.text()
|
||||||
|
if (!lebt) return
|
||||||
|
setGekappt(zuGross)
|
||||||
|
setText(zuGross ? roh.slice(0, HOECHSTENS) : roh)
|
||||||
|
})
|
||||||
|
.catch((err) => lebt && setFehler(err instanceof Error ? err.message : 'unbekannt'))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
}
|
||||||
|
}, [item.url, item.size])
|
||||||
|
|
||||||
|
if (fehler) {
|
||||||
|
return (
|
||||||
|
<Rahmen>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-schlecht">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
Die Datei ließ sich nicht laden: {fehler}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
download
|
||||||
|
className="mt-3 inline-flex items-center gap-2 rounded-md border border-rand px-3 py-1.5 text-sm hover:bg-flaeche-hoch"
|
||||||
|
>
|
||||||
|
<Download size={15} /> Herunterladen
|
||||||
|
</a>
|
||||||
|
</Rahmen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === null) {
|
||||||
|
return (
|
||||||
|
<Rahmen>
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
</Rahmen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const art = darstellungFuer(item.path)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rahmen>
|
||||||
|
{gekappt && (
|
||||||
|
<p className="mb-3 flex items-center gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="shrink-0" />
|
||||||
|
Nur die ersten {bytes(HOECHSTENS)} von {bytes(item.size)} — der Rest steht im
|
||||||
|
Herunterladen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{art === 'markdown' ? (
|
||||||
|
<Markdown text={text} />
|
||||||
|
) : art === 'tabelle' ? (
|
||||||
|
<Tabelle text={text} />
|
||||||
|
) : (
|
||||||
|
<Zeilen text={text} sprache={SPRACHE[endung(item.path)]} />
|
||||||
|
)}
|
||||||
|
</Rahmen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rahmen({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="max-h-[calc(100vh-9rem)] w-full max-w-4xl overflow-auto rounded-lg border border-white/15 bg-flaeche p-5 text-left"
|
||||||
|
// Ohne das rollt der Klick zum Hintergrund durch und schließt den
|
||||||
|
// Betrachter mitten im Lesen.
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klartext und Code mit Zeilennummern.
|
||||||
|
*
|
||||||
|
* Die Nummern liegen in einer eigenen Spalte statt am Zeilenanfang: so nimmt
|
||||||
|
* ein Markieren mit der Maus sie NICHT mit, und ein kopierter Ausschnitt lässt
|
||||||
|
* sich ohne Nacharbeit einfügen. */
|
||||||
|
function Zeilen({ text, sprache }: { text: string; sprache?: Sprache }) {
|
||||||
|
const zeilen = text.split('\n')
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 font-mono text-xs leading-relaxed">
|
||||||
|
<pre className="shrink-0 text-right text-schrift-leise/40 select-none">
|
||||||
|
{zeilen.map((_, i) => i + 1).join('\n')}
|
||||||
|
</pre>
|
||||||
|
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-words">
|
||||||
|
<code>{sprache ? faerben(text, sprache) : text}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV als Tabelle.
|
||||||
|
*
|
||||||
|
* Mit Anführungszeichen und doppelten Anführungszeichen darin — das ist der
|
||||||
|
* Teil, den eine Zerlegung mit `split(';')` falsch macht, und CSV aus Excel
|
||||||
|
* hat ihn regelmäßig. Trennzeichen wird aus der Kopfzeile geraten: wo mehr
|
||||||
|
* Semikolons als Kommata stehen, ist es ein Semikolon (deutsches Excel). */
|
||||||
|
function Tabelle({ text }: { text: string }) {
|
||||||
|
const zeilen = text.replace(/\r\n?/g, '\n').split('\n').filter((z) => z.length > 0)
|
||||||
|
const kopf = zeilen[0] ?? ''
|
||||||
|
const trenner = (kopf.match(/;/g)?.length ?? 0) > (kopf.match(/,/g)?.length ?? 0) ? ';' : ','
|
||||||
|
const zerlegt = zeilen.slice(0, 2000).map((z) => csvZeile(z, trenner))
|
||||||
|
const breite = Math.max(...zerlegt.map((z) => z.length), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-rand text-left">
|
||||||
|
{Array.from({ length: breite }, (_, i) => (
|
||||||
|
<th key={i} className="px-2 py-1 font-medium whitespace-nowrap">
|
||||||
|
{zerlegt[0]?.[i] ?? ''}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zerlegt.slice(1).map((z, k) => (
|
||||||
|
<tr key={k} className="border-b border-rand/40">
|
||||||
|
{Array.from({ length: breite }, (_, i) => (
|
||||||
|
<td key={i} className="px-2 py-1 font-mono">
|
||||||
|
{z[i] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{zeilen.length > 2000 && (
|
||||||
|
<p className="mt-2 text-xs text-schrift-leise">
|
||||||
|
Es werden 2000 von {zeilen.length} Zeilen gezeigt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvZeile(zeile: string, trenner: string): string[] {
|
||||||
|
const raus: string[] = []
|
||||||
|
let feld = ''
|
||||||
|
let inAnfuehrung = false
|
||||||
|
|
||||||
|
for (let i = 0; i < zeile.length; i++) {
|
||||||
|
const z = zeile[i]
|
||||||
|
if (inAnfuehrung) {
|
||||||
|
if (z === '"') {
|
||||||
|
// Zwei Anführungszeichen sind eines im Feld.
|
||||||
|
if (zeile[i + 1] === '"') {
|
||||||
|
feld += '"'
|
||||||
|
i++
|
||||||
|
} else inAnfuehrung = false
|
||||||
|
} else feld += z
|
||||||
|
} else if (z === '"') inAnfuehrung = true
|
||||||
|
else if (z === trenner) {
|
||||||
|
raus.push(feld)
|
||||||
|
feld = ''
|
||||||
|
} else feld += z
|
||||||
|
}
|
||||||
|
raus.push(feld)
|
||||||
|
return raus
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { KeyRound } from 'lucide-react'
|
||||||
|
import { api, type TokenRow } from '../api'
|
||||||
|
import { run } from '../store'
|
||||||
|
import { Feld, Nachfrage } from './Bausteine'
|
||||||
|
|
||||||
|
const ARTEN = [
|
||||||
|
['bild', 'Bilder'],
|
||||||
|
['video', 'Video'],
|
||||||
|
['ton', 'Ton'],
|
||||||
|
['dokument', 'Dokumente'],
|
||||||
|
['andere', 'Anderes'],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Die Grenzen eines Tokens nachträglich ändern.
|
||||||
|
*
|
||||||
|
* WARUM ES DAS BRAUCHT: Kontingent, Arten, Ablauf und Präfix ließen sich nur
|
||||||
|
* beim **Anlegen** setzen. Wer einem bestehenden Token nachträglich eine
|
||||||
|
* Grenze geben wollte, musste ihn neu anlegen — und damit den Schlüssel in
|
||||||
|
* jedem Skript tauschen, das ihn benutzt. Für eine Zahl in einer Tabelle ist
|
||||||
|
* das der falsche Preis.
|
||||||
|
*
|
||||||
|
* DER SCHLÜSSEL BLEIBT UNBERÜHRT, und das ist keine Vorsicht: in der Datenbank
|
||||||
|
* steht nur sein Hash. Er ließe sich gar nicht ändern, ohne einen neuen
|
||||||
|
* auszugeben — und das wäre dann kein Bearbeiten mehr. Steht auch im Dialog,
|
||||||
|
* damit niemand danach sucht. */
|
||||||
|
export function TokenBearbeiten({
|
||||||
|
token,
|
||||||
|
onAbbruch,
|
||||||
|
onFertig,
|
||||||
|
}: {
|
||||||
|
token: TokenRow
|
||||||
|
onAbbruch: () => void
|
||||||
|
onFertig: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(token.name)
|
||||||
|
const [prefix, setPrefix] = useState(token.prefix)
|
||||||
|
const [canDelete, setCanDelete] = useState(Boolean(token.can_delete))
|
||||||
|
const [maxMb, setMaxMb] = useState(
|
||||||
|
token.max_bytes ? String(Math.round((token.max_bytes / 1048576) * 10) / 10) : '',
|
||||||
|
)
|
||||||
|
const [quotaMb, setQuotaMb] = useState(
|
||||||
|
token.quota_bytes ? String(Math.round(token.quota_bytes / 1048576)) : '',
|
||||||
|
)
|
||||||
|
const [maxKante, setMaxKante] = useState(token.max_kante ? String(token.max_kante) : '')
|
||||||
|
const [arten, setArten] = useState<string[]>(
|
||||||
|
token.arten ? token.arten.split(',').map((a) => a.trim()).filter(Boolean) : [],
|
||||||
|
)
|
||||||
|
const [gueltigTage, setGueltigTage] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nachfrage
|
||||||
|
offen
|
||||||
|
titel={`Token „${token.name}" bearbeiten`}
|
||||||
|
bestaetigung="Speichern"
|
||||||
|
wartet={laeuft}
|
||||||
|
gesperrt={!name.trim()}
|
||||||
|
onNein={onAbbruch}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() =>
|
||||||
|
api.tokenAendern(token.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
prefix,
|
||||||
|
canDelete,
|
||||||
|
// Leer heißt ausdrücklich „keine Grenze" und nicht
|
||||||
|
// „nicht angefasst" — sonst ließe sich eine einmal
|
||||||
|
// gesetzte Grenze nie wieder loswerden.
|
||||||
|
maxMb: maxMb.trim() || null,
|
||||||
|
quotaMb: quotaMb.trim() || null,
|
||||||
|
maxKante: maxKante.trim() || null,
|
||||||
|
arten,
|
||||||
|
// Nur schicken, wenn etwas dasteht: ein leeres Feld
|
||||||
|
// soll den Ablauf NICHT löschen, sondern ihn
|
||||||
|
// unverändert lassen. Zum Löschen gibt es den Knopf
|
||||||
|
// darunter.
|
||||||
|
...(gueltigTage.trim() ? { gueltigTage: gueltigTage.trim() } : {}),
|
||||||
|
}),
|
||||||
|
(x) => `${x.name}: ${x.geaendert.length} Angabe(n) geändert.`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) onFertig()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
label="Präfix (leer = überall)"
|
||||||
|
value={prefix}
|
||||||
|
onChange={(e) => setPrefix(e.target.value)}
|
||||||
|
placeholder="vehicles"
|
||||||
|
className="font-mono"
|
||||||
|
hinweis="Gilt nur für künftige Uploads — was schon liegt, bleibt liegen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
label="Größte Datei (MB)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.5"
|
||||||
|
value={maxMb}
|
||||||
|
placeholder="leer = Grenze des Dienstes"
|
||||||
|
onChange={(e) => setMaxMb(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Kontingent insgesamt (MB)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={quotaMb}
|
||||||
|
placeholder="leer = unbegrenzt"
|
||||||
|
onChange={(e) => setQuotaMb(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
label="Bilder verkleinern auf (Pixel)"
|
||||||
|
type="number"
|
||||||
|
min={16}
|
||||||
|
step="64"
|
||||||
|
value={maxKante}
|
||||||
|
placeholder="leer = ablegen wie es kommt"
|
||||||
|
hinweis="Größte Kantenlänge. Kleinere Bilder werden nicht aufgeblasen."
|
||||||
|
onChange={(e) => setMaxKante(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={canDelete}
|
||||||
|
onChange={(e) => setCanDelete(e.target.checked)}
|
||||||
|
className="mt-0.5 accent-akzent"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
darf auch <strong className="text-schrift">löschen</strong>
|
||||||
|
<span className="block text-xs text-schrift-leise">
|
||||||
|
Ohne dieses Recht antwortet <code className="font-mono">DELETE</code>{' '}
|
||||||
|
mit 403. Das Fotostudio braucht es nicht.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Erlaubte Arten (nichts angekreuzt = alle)
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ARTEN.map(([wert, text]) => (
|
||||||
|
<label
|
||||||
|
key={wert}
|
||||||
|
className="flex items-center gap-1.5 rounded border border-rand
|
||||||
|
px-2 py-1 text-xs text-schrift-leise"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={arten.includes(wert)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setArten((alt) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...alt, wert]
|
||||||
|
: alt.filter((a) => a !== wert),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="accent-akzent"
|
||||||
|
/>
|
||||||
|
{text}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
label="Gültigkeit verlängern (Tage ab heute)"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={gueltigTage}
|
||||||
|
placeholder={
|
||||||
|
token.expires_at
|
||||||
|
? 'leer = so lassen'
|
||||||
|
: 'leer = weiterhin unbefristet'
|
||||||
|
}
|
||||||
|
onChange={(e) => setGueltigTage(e.target.value)}
|
||||||
|
hinweis={
|
||||||
|
token.expires_at
|
||||||
|
? 'Zählt ab heute, nicht ab dem Anlegen.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="flex items-start gap-2 rounded border border-rand bg-grund px-3 py-2 text-xs text-schrift-leise">
|
||||||
|
<KeyRound size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-schrift">Der Schlüssel bleibt derselbe.</strong>{' '}
|
||||||
|
In der Datenbank steht nur sein Hash — er ließe sich gar nicht ändern,
|
||||||
|
ohne einen neuen auszugeben. Deine Skripte müssen also nichts
|
||||||
|
anfassen.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertTriangle, PenLine } from 'lucide-react'
|
||||||
|
import { api, type MediaItem } from '../api'
|
||||||
|
import { run } from '../store'
|
||||||
|
import { Feld, Nachfrage } from './Bausteine'
|
||||||
|
|
||||||
|
/** Eine Datei umbenennen.
|
||||||
|
*
|
||||||
|
* DER PFAD IST DIE ADRESSE — das ist der Satz, an dem dieses ganze Projekt
|
||||||
|
* haengt, und beim Umbenennen ist er eine Warnung: die alte Adresse steht
|
||||||
|
* womoeglich in einem Skript, das niemand mehr im Kopf hat, und sie meldet
|
||||||
|
* sich nicht, wenn sie ins Leere zeigt.
|
||||||
|
*
|
||||||
|
* Deshalb steht die Zahl der ABRUFE gross daneben. Eine Datei, die noch nie
|
||||||
|
* jemand geholt hat, umzubenennen ist harmlos; eine mit viertausend Abrufen
|
||||||
|
* ist eine Entscheidung. Der Dienst ist die einzige Stelle, die den
|
||||||
|
* Unterschied kennt — also sagt er ihn. */
|
||||||
|
export function Umbenennen({
|
||||||
|
datei,
|
||||||
|
onAbbruch,
|
||||||
|
onFertig,
|
||||||
|
}: {
|
||||||
|
datei: MediaItem
|
||||||
|
onAbbruch: () => void
|
||||||
|
onFertig: () => void
|
||||||
|
}) {
|
||||||
|
const alt = datei.path.split('/').pop() ?? datei.path
|
||||||
|
const ordner = datei.path.split('/').slice(0, -1).join('/')
|
||||||
|
const [name, setName] = useState(alt)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
|
||||||
|
const geaendert = name.trim() !== alt && name.trim().length > 0
|
||||||
|
|
||||||
|
// Die Endung mitzuaendern ist erlaubt, aber selten gewollt: sie entscheidet
|
||||||
|
// ueber den Dateityp, den der Dienst meldet.
|
||||||
|
const endungAlt = alt.includes('.') ? alt.split('.').pop() : ''
|
||||||
|
const endungNeu = name.includes('.') ? name.split('.').pop() : ''
|
||||||
|
const endungWeg = endungAlt !== endungNeu
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nachfrage
|
||||||
|
offen
|
||||||
|
titel="Umbenennen"
|
||||||
|
bestaetigung="Umbenennen"
|
||||||
|
wartet={laeuft}
|
||||||
|
gesperrt={!geaendert}
|
||||||
|
onNein={onAbbruch}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.umbenennen(datei.id, name.trim()),
|
||||||
|
(x) => `Heisst jetzt ${x.neu}.`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) onFertig()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Neuer Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Der Ordner bleibt:{' '}
|
||||||
|
<code className="font-mono">{ordner ? `${ordner}/` : '(Wurzel)'}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Was daraus wird, STEHT DA — vollstaendig und zum Lesen.
|
||||||
|
Wer eine Adresse aendert, soll die neue sehen, bevor er
|
||||||
|
drueckt, und nicht danach. */}
|
||||||
|
{geaendert && (
|
||||||
|
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
|
||||||
|
{ordner ? `${ordner}/` : ''}
|
||||||
|
<span className="text-akzent">{name.trim()}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{datei.abrufe > 0 && (
|
||||||
|
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Diese Datei wurde <strong>{datei.abrufe}×</strong> geholt. Die
|
||||||
|
alte Adresse gibt danach 404 — wer sie in einem Skript stehen
|
||||||
|
hat, merkt es erst, wenn das Bild fehlt.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{geaendert && endungWeg && (
|
||||||
|
<p className="flex items-start gap-2 text-xs text-schrift-leise">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />
|
||||||
|
<span>
|
||||||
|
Die Endung aendert sich von{' '}
|
||||||
|
<code className="font-mono">.{endungAlt || '—'}</code> auf{' '}
|
||||||
|
<code className="font-mono">.{endungNeu || '—'}</code>. Damit
|
||||||
|
aendert sich auch der Typ, den der Dienst meldet.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Ordner umbenennen — derselbe Vorgang, anderer Hebel.
|
||||||
|
*
|
||||||
|
* Hier aendern sich auf einen Schlag ALLE Adressen darin. Deshalb steht die
|
||||||
|
* Zahl der betroffenen Dateien in der Nachfrage, und zwar bevor gedrueckt
|
||||||
|
* wird. */
|
||||||
|
export function OrdnerUmbenennen({
|
||||||
|
pfad,
|
||||||
|
dateien,
|
||||||
|
onAbbruch,
|
||||||
|
onFertig,
|
||||||
|
}: {
|
||||||
|
pfad: string
|
||||||
|
dateien: number
|
||||||
|
onAbbruch: () => void
|
||||||
|
onFertig: (neu: string) => void
|
||||||
|
}) {
|
||||||
|
const alt = pfad.split('/').pop() ?? pfad
|
||||||
|
const oben = pfad.split('/').slice(0, -1).join('/')
|
||||||
|
const [name, setName] = useState(alt)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const geaendert = name.trim() !== alt && name.trim().length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nachfrage
|
||||||
|
offen
|
||||||
|
titel="Ordner umbenennen"
|
||||||
|
bestaetigung="Umbenennen"
|
||||||
|
wartet={laeuft}
|
||||||
|
gesperrt={!geaendert}
|
||||||
|
onNein={onAbbruch}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.ordnerUmbenennen(pfad, name.trim()),
|
||||||
|
(x) => `${x.alt} heisst jetzt ${x.neu} (${x.dateien} Dateien).`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) onFertig(r.neu)
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Neuer Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{geaendert && (
|
||||||
|
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
|
||||||
|
{oben ? `${oben}/` : ''}
|
||||||
|
<span className="text-akzent">{name.trim()}</span>/
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dateien > 0 && (
|
||||||
|
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>{dateien} Dateien</strong> bekommen damit eine neue
|
||||||
|
Adresse. Jede alte gibt danach 404 — und keine davon meldet sich
|
||||||
|
von selbst.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UmbenennenZeichen = PenLine
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
|
||||||
|
import { api, type Folders, type MediaItem } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { Feld, Knopf } from './Bausteine'
|
||||||
|
|
||||||
|
/** Dateien in einen anderen Ordner legen.
|
||||||
|
*
|
||||||
|
* DIE WARNUNG STEHT OBEN UND NICHT IM KLEINGEDRUCKTEN. Der Pfad IST die
|
||||||
|
* öffentliche Adresse: wer ein Bild verschiebt, macht jede Stelle ungültig,
|
||||||
|
* die die alte URL benutzt — im Handy, in einer Vorlage, in einem
|
||||||
|
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
|
||||||
|
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
|
||||||
|
export function Verschieben({
|
||||||
|
ids,
|
||||||
|
namen,
|
||||||
|
ordner,
|
||||||
|
onFertig,
|
||||||
|
onAbbruch,
|
||||||
|
}: {
|
||||||
|
/** ALLE Gewaehlten. */
|
||||||
|
ids: number[]
|
||||||
|
/** Nur die, deren Pfad bekannt ist -- die der offenen Seite. Fuer die
|
||||||
|
* Anzeige, nicht fuer die Handlung. */
|
||||||
|
namen: MediaItem[]
|
||||||
|
ordner: Folders | null
|
||||||
|
onFertig: () => void
|
||||||
|
onAbbruch: () => void
|
||||||
|
}) {
|
||||||
|
const [ziel, setZiel] = useState('')
|
||||||
|
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fortschritt, setFortschritt] = useState(0)
|
||||||
|
|
||||||
|
const beispiel = namen[0]
|
||||||
|
const name = beispiel?.path.split('/').pop() ?? ''
|
||||||
|
const neuerPfad = ziel ? `${ziel}/${name}` : name
|
||||||
|
|
||||||
|
async function verschieben() {
|
||||||
|
setLaeuft(true)
|
||||||
|
const { note } = useStore.getState()
|
||||||
|
|
||||||
|
// IN HAEPPCHEN, wie beim Loeschen: der Dienst verschiebt jede Datei
|
||||||
|
// einzeln, und tausende in einer Anfrage laufen in die Zeitgrenze des
|
||||||
|
// Proxys. 200 je Runde, und der Knopf sagt, wie weit es ist.
|
||||||
|
const PORTION = 200
|
||||||
|
let bewegt = 0
|
||||||
|
let daneben: string[] = []
|
||||||
|
let abbruch: string | null = null
|
||||||
|
|
||||||
|
for (let i = 0; i < ids.length; i += PORTION) {
|
||||||
|
try {
|
||||||
|
const x = await api.verschieben(ids.slice(i, i + PORTION), ziel)
|
||||||
|
bewegt += x.verschoben
|
||||||
|
daneben.push(
|
||||||
|
...x.ergebnisse.filter((e) => !e.ok).map((e) => `${e.path} (${e.error})`),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
abbruch = e instanceof Error ? e.message : 'Unbekannt'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setFortschritt(bewegt)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLaeuft(false)
|
||||||
|
if (abbruch) {
|
||||||
|
note('fehler', `${bewegt} von ${ids.length} verschoben, dann brach es ab: ${abbruch}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (daneben.length === 0) {
|
||||||
|
note('ok', `${bewegt} verschoben.`)
|
||||||
|
} else {
|
||||||
|
// Was NICHT ging, wird benannt -- ein "verschoben" ueber einer
|
||||||
|
// halben Bewegung waere die Sorte Rueckmeldung, die hier nicht
|
||||||
|
// vorkommt.
|
||||||
|
note(
|
||||||
|
'hinweis',
|
||||||
|
`${bewegt} verschoben, ${daneben.length} nicht: ${daneben.slice(0, 3).join('; ')}` +
|
||||||
|
(daneben.length > 3 ? ` … und ${daneben.length - 3} weitere` : ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onFertig()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ordnerAnlegen() {
|
||||||
|
const pfad = neuerOrdner.trim()
|
||||||
|
if (!pfad) return
|
||||||
|
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
|
||||||
|
if (r) {
|
||||||
|
setZiel(r.pfad)
|
||||||
|
setNeuerOrdner('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onAbbruch}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
|
||||||
|
<FolderInput size={16} className="text-akzent" />
|
||||||
|
{ids.length === 1 ? 'Datei verschieben' : `${ids.length} Dateien verschieben`}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-4">
|
||||||
|
<p className="flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-warn" />
|
||||||
|
<span className="text-schrift">
|
||||||
|
Der Pfad <strong>ist</strong> die öffentliche Adresse. Nach dem
|
||||||
|
Verschieben führt die alte URL ins Leere — überall, wo sie steht.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Zielordner
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={ziel}
|
||||||
|
onChange={(e) => setZiel(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
|
||||||
|
text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">(Wurzel)</option>
|
||||||
|
{ordner?.items.map((o) => (
|
||||||
|
<option key={o.folder} value={o.folder}>
|
||||||
|
{o.folder}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Feld
|
||||||
|
label="oder neuen Ordner anlegen"
|
||||||
|
value={neuerOrdner}
|
||||||
|
placeholder="z. B. archiv/2026"
|
||||||
|
className="flex-1"
|
||||||
|
onChange={(e) => setNeuerOrdner(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void ordnerAnlegen()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Knopf
|
||||||
|
disabled={!neuerOrdner.trim()}
|
||||||
|
onClick={() => void ordnerAnlegen()}
|
||||||
|
kind={<Plus size={15} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zeigen, WAS daraus wird — nicht nur, wohin es geht. */}
|
||||||
|
{beispiel && (
|
||||||
|
<div className="rounded border border-rand bg-grund p-2 font-mono text-xs">
|
||||||
|
<div className="text-schrift-leise line-through">{beispiel.path}</div>
|
||||||
|
<div className="text-gut">{neuerPfad}</div>
|
||||||
|
{ids.length > 1 && (
|
||||||
|
<div className="mt-1 text-schrift-leise">
|
||||||
|
… und {ids.length - 1} weitere
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
|
||||||
|
<Knopf onClick={onAbbruch} kind="Abbrechen" />
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={laeuft}
|
||||||
|
onClick={() => void verschieben()}
|
||||||
|
kind={
|
||||||
|
// Bei tausenden Dateien dauert es; Stillstand ist
|
||||||
|
// sonst von Haengen nicht zu unterscheiden.
|
||||||
|
laeuft && fortschritt > 0
|
||||||
|
? `${fortschritt} von ${ids.length} …`
|
||||||
|
: 'Verschieben'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { KeyRound, Link2, Loader2, Settings, UserRound } from 'lucide-react'
|
||||||
|
import { api, type VerwaltungRow } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { zeit } from '../format'
|
||||||
|
import { Leer } from './Bausteine'
|
||||||
|
|
||||||
|
/** Wer hat wann was EINGESTELLT.
|
||||||
|
*
|
||||||
|
* Der Datei-Verlauf daneben beantwortet „was ist mit den Dateien passiert".
|
||||||
|
* Diese Liste beantwortet die andere Frage, die man stellt, wenn etwas anders
|
||||||
|
* ist als gestern: **wer hat daran gedreht?**
|
||||||
|
*
|
||||||
|
* Bei zwei Konten ist das verschmerzbar. Sobald ein drittes dazukommt — und
|
||||||
|
* über die Discord-Rolle entsteht es von selbst — ist es die erste Frage.
|
||||||
|
*
|
||||||
|
* WERTE STEHEN HIER NIE, nur Namen von Schlüsseln und Zielen. Unter den
|
||||||
|
* Einstellungen liegen das Discord-Geheimnis und das Nextcloud-Passwort; ein
|
||||||
|
* Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort
|
||||||
|
* eine Tabelle mit allen, die es je gab. */
|
||||||
|
const BEREICHE = {
|
||||||
|
einstellung: { text: 'Einstellung', kind: Settings, farbe: 'text-akzent' },
|
||||||
|
token: { text: 'Token', kind: KeyRound, farbe: 'text-warn' },
|
||||||
|
benutzer: { text: 'Konto', kind: UserRound, farbe: 'text-akzent' },
|
||||||
|
freigabe: { text: 'Freigabe', kind: Link2, farbe: 'text-gut' },
|
||||||
|
ordner: { text: 'Ordner', kind: Settings, farbe: 'text-schrift-leise' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const TATEN = {
|
||||||
|
angelegt: { text: 'angelegt', farbe: 'text-gut' },
|
||||||
|
geaendert: { text: 'geändert', farbe: 'text-warn' },
|
||||||
|
entfernt: { text: 'entfernt', farbe: 'text-schlecht' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function VerwaltungListe() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<VerwaltungRow[] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lebt = true
|
||||||
|
api.verwaltung(200)
|
||||||
|
.then((r) => lebt && setDaten(r.items))
|
||||||
|
.catch((err) => lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
}
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
if (!daten) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daten.length === 0) {
|
||||||
|
return (
|
||||||
|
<Leer
|
||||||
|
kind={<Settings size={26} className="text-schrift-leise/50" />}
|
||||||
|
text="Noch nichts verwaltet — oder alles, bevor es diese Liste gab."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-36" />
|
||||||
|
<col className="w-28" />
|
||||||
|
<col className="w-24" />
|
||||||
|
<col />
|
||||||
|
<col className="w-32" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-schrift-leise">
|
||||||
|
<th className="px-3 pb-2 font-medium">Wann</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Bereich</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Was</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Ziel</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Wer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{daten.map((z) => {
|
||||||
|
const b = BEREICHE[z.bereich] ?? BEREICHE.ordner
|
||||||
|
const t = TATEN[z.tat] ?? TATEN.geaendert
|
||||||
|
const Zeichen = b.kind
|
||||||
|
return (
|
||||||
|
<tr key={z.id} className="border-t border-rand">
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
|
||||||
|
{zeit(z.at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className={`flex items-center gap-1.5 text-xs ${b.farbe}`}>
|
||||||
|
<Zeichen size={13} />
|
||||||
|
{b.text}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-1.5 text-xs ${t.farbe}`}>{t.text}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className="font-mono text-xs">{z.ziel || '—'}</span>
|
||||||
|
{z.was && (
|
||||||
|
<span className="block text-[11px] text-schrift-leise">
|
||||||
|
{z.was}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="truncate px-3 py-1.5 text-schrift-leise">{z.wer}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link2, Loader2 } from 'lucide-react'
|
||||||
|
import { api, type Folders } from '../api'
|
||||||
|
import { run } from '../store'
|
||||||
|
import { bytes } from '../format'
|
||||||
|
import { Knopf } from './Bausteine'
|
||||||
|
|
||||||
|
/** Eine Datei holen, statt sie hochzuladen.
|
||||||
|
*
|
||||||
|
* Der Dienst ruft die Adresse selbst ab. Das spart den Umweg über die
|
||||||
|
* Festplatte — herunterladen, im Dateimanager wiederfinden, ins Fenster
|
||||||
|
* ziehen —, und bei einem einzelnen Bild sind das drei Schritte für nichts.
|
||||||
|
*
|
||||||
|
* DASS DAS EINE HEIKLE FUNKTION IST, steht im Server (`holen.ts`): eine
|
||||||
|
* Adresse, die auf `127.0.0.1` oder ins Heimnetz zeigt, ließe den Dienst Dinge
|
||||||
|
* lesen, die von außen unerreichbar sind. Geprüft wird dort — hier kommt nur
|
||||||
|
* der Satz zurück, wenn die Prüfung greift, und der ist zum Lesen gemacht. */
|
||||||
|
export function VonAdresse({
|
||||||
|
ordner,
|
||||||
|
ordnerDaten,
|
||||||
|
fertig,
|
||||||
|
}: {
|
||||||
|
ordner: string
|
||||||
|
ordnerDaten: Folders | null
|
||||||
|
fertig: () => void
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = useState(false)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [ziel, setZiel] = useState(ordner)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setZiel(ordner)
|
||||||
|
setOffen(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 self-start text-xs text-schrift-leise
|
||||||
|
hover:text-schrift"
|
||||||
|
>
|
||||||
|
<Link2 size={13} />
|
||||||
|
oder von einer Adresse holen
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function holen() {
|
||||||
|
if (!url.trim()) return
|
||||||
|
setLaeuft(true)
|
||||||
|
const r = await run(
|
||||||
|
() => api.hochladenVonAdresse(url.trim(), ziel, name.trim() || undefined),
|
||||||
|
(x) =>
|
||||||
|
`${x.path} geholt (${bytes(x.size)})` +
|
||||||
|
(x.replaced ? ' — ersetzt' : '') +
|
||||||
|
// Nach einer Umleitung ist das nicht mehr die Adresse, die
|
||||||
|
// eingetippt wurde. Das gehört gesagt.
|
||||||
|
(x.von !== url.trim() ? `, von ${x.von}` : ''),
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
if (r) {
|
||||||
|
setUrl('')
|
||||||
|
setName('')
|
||||||
|
fertig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-rand bg-flaeche p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-medium text-schrift">
|
||||||
|
<Link2 size={13} /> Von einer Adresse holen
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setOffen(false)}
|
||||||
|
className="text-xs text-schrift-leise hover:text-schrift"
|
||||||
|
>
|
||||||
|
zu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !laeuft && void holen()}
|
||||||
|
placeholder="https://…/bild.png"
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5 text-sm
|
||||||
|
text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="min-w-32 flex-1">
|
||||||
|
<span className="mb-1 block text-[11px] text-schrift-leise">Ziel</span>
|
||||||
|
<select
|
||||||
|
value={ziel}
|
||||||
|
onChange={(e) => setZiel(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||||
|
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">(Wurzel)</option>
|
||||||
|
{(ordnerDaten?.items ?? []).map((o) => (
|
||||||
|
<option key={o.folder} value={o.folder}>
|
||||||
|
{' '.repeat(o.tiefe * 2)}
|
||||||
|
{o.folder.split('/').pop()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-32 flex-1">
|
||||||
|
<span className="mb-1 block text-[11px] text-schrift-leise">
|
||||||
|
Name (freiwillig)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="aus der Adresse"
|
||||||
|
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||||
|
font-mono text-xs text-schrift placeholder:text-schrift-leise/60
|
||||||
|
focus:border-akzent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
disabled={!url.trim() || laeuft}
|
||||||
|
onClick={() => void holen()}
|
||||||
|
kind={
|
||||||
|
laeuft ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" /> holt …
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Holen'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-schrift-leise">
|
||||||
|
Nur <code className="font-mono">http</code> und{' '}
|
||||||
|
<code className="font-mono">https</code>, nur die Anschlüsse 80 und 443, und die
|
||||||
|
Adresse darf nicht ins eigene Netz zeigen — auch nicht über eine Umleitung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link2, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { api, type Vorlage } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { Feld, Karte, Knopf } from './Bausteine'
|
||||||
|
|
||||||
|
/** Adressvorlagen verwalten.
|
||||||
|
*
|
||||||
|
* Eine Vorlage ist der Bauplan einer öffentlichen Adresse:
|
||||||
|
* `vehicles/{model}.webp` wird zu `https://…/f/vehicles/adder.webp`.
|
||||||
|
*
|
||||||
|
* Wozu speichern? Weil genau diese Zeichenkette auf der anderen Seite
|
||||||
|
* gebraucht wird — im Handy, im Skript, im Fotostudio — und weil sie sich
|
||||||
|
* sonst jemand aus Ordnername plus Endung zusammenreimt. Einmal falsch
|
||||||
|
* zusammengereimt heißt: das Bild fehlt, und gesucht wird an der falschen
|
||||||
|
* Stelle. */
|
||||||
|
export function VorlagenKarte({ publicUrl, filePrefix }: { publicUrl: string; filePrefix: string }) {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [liste, setListe] = useState<Vorlage[] | null>(null)
|
||||||
|
const [speichert, setSpeichert] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [muster, setMuster] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.einstellungen()
|
||||||
|
.then((e) => setListe(e.vorlagen))
|
||||||
|
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
async function sichern(neu: Vorlage[]) {
|
||||||
|
setSpeichert(true)
|
||||||
|
const r = await run(() => api.vorlagenSpeichern(neu), 'Vorlagen gespeichert.')
|
||||||
|
setSpeichert(false)
|
||||||
|
if (r) setListe(r.vorlagen)
|
||||||
|
return Boolean(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!liste) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Adressvorlagen">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
<p className="text-sm text-schrift-leise">
|
||||||
|
Noch keine. Eine Vorlage sagt, wie die öffentliche Adresse aussieht —
|
||||||
|
etwa <code className="font-mono">vehicles/{model}.webp</code>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{liste.map((v, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-rand bg-grund px-3 py-2"
|
||||||
|
>
|
||||||
|
<Link2 size={14} className="shrink-0 text-schrift-leise" />
|
||||||
|
<span className="w-32 shrink-0 truncate text-sm">{v.name}</span>
|
||||||
|
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
|
||||||
|
{publicUrl}
|
||||||
|
{filePrefix}/{v.muster}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void sichern(liste.filter((_, j) => j !== i))
|
||||||
|
}
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise hover:bg-schlecht/15 hover:text-schlecht"
|
||||||
|
aria-label={`${v.name} entfernen`}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2 border-t border-rand pt-3">
|
||||||
|
<Feld
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
placeholder="Fahrzeuge"
|
||||||
|
className="w-40"
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Muster"
|
||||||
|
value={muster}
|
||||||
|
placeholder="vehicles/{model}.webp"
|
||||||
|
hinweis="Ein Platzhalter in geschweiften Klammern ist Pflicht."
|
||||||
|
className="min-w-56 flex-1"
|
||||||
|
onChange={(e) => setMuster(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={speichert}
|
||||||
|
disabled={!name.trim() || !muster.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
await sichern([
|
||||||
|
...liste,
|
||||||
|
{ name: name.trim(), muster: muster.trim() },
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
setName('')
|
||||||
|
setMuster('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Plus size={15} />
|
||||||
|
Hinzufügen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** Bytes so, wie ein Mensch sie liest. 1024er-Stufen, weil der Rest der
|
||||||
|
* Werkzeugkette (du, ls, Explorer) das auch tut. */
|
||||||
|
export function bytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
const stufen = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let wert = n / 1024
|
||||||
|
let i = 0
|
||||||
|
while (wert >= 1024 && i < stufen.length - 1) {
|
||||||
|
wert /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${wert.toFixed(wert < 10 ? 1 : 0)} ${stufen[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitstempel des Dienstes sind Millisekunden aus Date.now(). */
|
||||||
|
export function zeit(ms: number | null): string {
|
||||||
|
if (!ms) return '—'
|
||||||
|
return new Date(ms).toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const istBild = (mime: string) => mime.startsWith('image/')
|
||||||
|
|
||||||
|
/** Sekunden als 1:07 oder 1:02:03.
|
||||||
|
*
|
||||||
|
* Stunden nur, wenn es welche gibt: "0:01:07" auf einer Kachel ist drei
|
||||||
|
* Zeichen Laerm fuer eine Auskunft, die niemand gestellt hat. */
|
||||||
|
export function dauerText(sekunden: number): string {
|
||||||
|
const ganz = Math.round(sekunden)
|
||||||
|
const s = ganz % 60
|
||||||
|
const m = Math.floor(ganz / 60) % 60
|
||||||
|
const h = Math.floor(ganz / 3600)
|
||||||
|
const zwei = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return h > 0 ? `${h}:${zwei(m)}:${zwei(s)}` : `${m}:${zwei(s)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Tailwind 4 ist CSS-first: die Gestaltung steht hier, nicht in einer
|
||||||
|
tailwind.config.js. Was unter @theme steht, wird zu Hilfsklassen — aus
|
||||||
|
--color-grund wird bg-grund, text-grund, border-grund. */
|
||||||
|
@theme {
|
||||||
|
--color-grund: #0b0d10;
|
||||||
|
--color-flaeche: #14181d;
|
||||||
|
--color-flaeche-hoch: #1c222a;
|
||||||
|
--color-rand: #2a323c;
|
||||||
|
--color-schrift: #e6eaf0;
|
||||||
|
--color-schrift-leise: #93a1b1;
|
||||||
|
|
||||||
|
--color-akzent: #4ea3ff;
|
||||||
|
--color-akzent-tief: #1b4f85;
|
||||||
|
|
||||||
|
--color-gut: #3fb950;
|
||||||
|
--color-schlecht: #f85149;
|
||||||
|
--color-warn: #d29922;
|
||||||
|
|
||||||
|
--font-sans:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
--font-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#wurzel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-grund);
|
||||||
|
color: var(--color-schrift);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Fokusrahmen bleibt sichtbar. Ein Dashboard, das sich nur mit der Maus
|
||||||
|
bedienen lässt, ist bei 900 Kacheln kein Vergnügen. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-akzent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-akzent-tief);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein ruhiger Verlauf statt einer flachen Flaeche. Er kostet nichts und nimmt
|
||||||
|
dem Dunkel die Pappigkeit — auf einem grossen Monitor sieht ein einfarbiger
|
||||||
|
Hintergrund aus wie ein Ladefehler. */
|
||||||
|
body {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
1200px 600px at 50% -10%,
|
||||||
|
color-mix(in oklab, var(--color-akzent) 7%, transparent),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bildlaufleisten im Ton der Oberflaeche. Die hellen Standardleisten von
|
||||||
|
Windows reissen in einem dunklen Fenster jedes Mal ein Loch. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-rand) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Was neu hereinkommt, blendet auf. Ohne das springen 60 Kacheln auf einmal
|
||||||
|
ins Bild und man sucht die Stelle, an der man war. */
|
||||||
|
@keyframes auftauchen {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auftauchen {
|
||||||
|
animation: auftauchen 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wer das nicht will, bekommt es nicht. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { App } from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const wurzel = document.getElementById('wurzel')
|
||||||
|
if (!wurzel) throw new Error('Der Aufhängepunkt #wurzel fehlt in index.html')
|
||||||
|
|
||||||
|
createRoot(wurzel).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
/** Wie der Dienst sich nennt — an EINER Stelle.
|
||||||
|
*
|
||||||
|
* Vorher stand „d4rk_media" viermal fest im Quelltext: in der Kopfzeile, auf
|
||||||
|
* der Anmeldeseite, unter einer Freigabe und im Tab-Titel. Das ist der
|
||||||
|
* Repository-Name, nicht der Name des Dienstes — und wer ihn ändern wollte,
|
||||||
|
* musste vier Stellen finden und eine davon übersehen.
|
||||||
|
*
|
||||||
|
* Er kommt jetzt vom Server, aus derselben Einstellung, die auch in der
|
||||||
|
* Fußzeile der Discord-Meldungen steht (`D4RKST3R // MEDIA`). Ein zweiter
|
||||||
|
* Name für die Oberfläche wäre ein zweiter, der beim nächsten Mal anders
|
||||||
|
* lautet.
|
||||||
|
*
|
||||||
|
* DER STANDARD STEHT HIER TROTZDEM: bis die Antwort da ist, muss etwas in der
|
||||||
|
* Kopfzeile stehen, und ein leerer Platz, der eine Zehntelsekunde später
|
||||||
|
* zuspringt, sieht aus wie ein Fehler. */
|
||||||
|
export const MARKE_STANDARD = 'D4RKST3R'
|
||||||
|
|
||||||
|
/** Aus der Marke den Namen des Dienstes: „D4RKST3R" → „D4RKST3R Media".
|
||||||
|
*
|
||||||
|
* Die Marke allein wäre falsch — sie steht über allem, was hier läuft
|
||||||
|
* (Bot, Hub, Gitea). Dieser Dienst ist ihr Medienteil, und genau so heißt er
|
||||||
|
* auch in der Discord-Fußzeile. */
|
||||||
|
export const dienstName = (marke: string) => `${marke} Media`
|
||||||
|
|
||||||
|
/** Der Name für die angemeldete Oberfläche. Holt ihn einmal und merkt ihn sich
|
||||||
|
* für die Lebensdauer der Seite — er ändert sich nicht zwischen zwei Klicks. */
|
||||||
|
export function useMarke(): string {
|
||||||
|
const [marke, setMarke] = useState(MARKE_STANDARD)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lebt = true
|
||||||
|
api.authConfig()
|
||||||
|
.then((c) => lebt && c.marke && setMarke(c.marke))
|
||||||
|
.catch(() => {
|
||||||
|
/* dann eben der Standard — dafür ist er da */
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Der Tab-Titel steht in index.html fest und kann die Einstellung nicht
|
||||||
|
// kennen. Hier nachzuziehen ist die einzige Stelle, an der das geht.
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = dienstName(marke)
|
||||||
|
}, [marke])
|
||||||
|
|
||||||
|
return marke
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** Ein Zustand, der einen Neuladevorgang überlebt.
|
||||||
|
*
|
||||||
|
* Für ANSICHTSSACHEN gedacht: wie viele Kacheln je Seite, wonach sortiert
|
||||||
|
* wird. Wer 120 pro Seite einstellt, meint das dauerhaft und nicht bis zum
|
||||||
|
* nächsten F5.
|
||||||
|
*
|
||||||
|
* BEWUSST NICHT für Filter. Ein gemerkter Ordner- oder Artfilter führt zu der
|
||||||
|
* Frage „warum sehe ich nichts", und die Antwort steht dann in einer Leiste,
|
||||||
|
* auf die niemand schaut, weil er sie nicht selbst gesetzt hat. Was die
|
||||||
|
* Ansicht formt, wird gemerkt; was die Menge einschränkt, nicht.
|
||||||
|
*
|
||||||
|
* localStorage kann werfen — im privaten Fenster mancher Browser, oder wenn
|
||||||
|
* der Speicher voll ist. Dann verhält sich der Haken wie ein gewöhnlicher
|
||||||
|
* Zustand, statt die Seite mitzureißen. */
|
||||||
|
export function useMerker<T>(schluessel: string, standard: T) {
|
||||||
|
// ABSICHTLICH der alte, feste Name und NICHT die Marke: das hier ist der
|
||||||
|
// Schluessel im Speicher des Browsers. Wer ihn umbenennt, wirft still alle
|
||||||
|
// gemerkten Ansichtseinstellungen weg -- Seitengroesse, Sortierung -- und
|
||||||
|
// niemand versteht, warum die Galerie ploetzlich wieder bei 60 Kacheln
|
||||||
|
// steht.
|
||||||
|
const voll = `d4rk_media.${schluessel}`
|
||||||
|
|
||||||
|
const [wert, setWert] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const roh = localStorage.getItem(voll)
|
||||||
|
return roh === null ? standard : (JSON.parse(roh) as T)
|
||||||
|
} catch {
|
||||||
|
return standard
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(voll, JSON.stringify(wert))
|
||||||
|
} catch {
|
||||||
|
/* dann eben nur für diese Sitzung */
|
||||||
|
}
|
||||||
|
}, [voll, wert])
|
||||||
|
|
||||||
|
return [wert, setWert] as const
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { HardDrive } from 'lucide-react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { Feld, Knopf } from '../components/Bausteine'
|
||||||
|
import { dienstName, MARKE_STANDARD } from '../marke'
|
||||||
|
|
||||||
|
export function Anmelden() {
|
||||||
|
const login = useStore((s) => s.login)
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [name, setName] = useState('admin')
|
||||||
|
const [passwort, setPasswort] = useState('')
|
||||||
|
const [wartet, setWartet] = useState(false)
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
const [discord, setDiscord] = useState(false)
|
||||||
|
const [marke, setMarke] = useState(MARKE_STANDARD)
|
||||||
|
|
||||||
|
// Ob der Discord-Knopf ueberhaupt Sinn hat, weiss nur der Dienst. Ein
|
||||||
|
// Knopf, der zu einer Fehlerseite fuehrt, weil nichts eingerichtet ist,
|
||||||
|
// ist genau die Sorte Knopf, die wir hier nicht bauen.
|
||||||
|
useEffect(() => {
|
||||||
|
api.authConfig()
|
||||||
|
.then((c) => {
|
||||||
|
setDiscord(c.discord)
|
||||||
|
if (c.marke) setMarke(c.marke)
|
||||||
|
})
|
||||||
|
.catch(() => setDiscord(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Der Weg ueber Discord endet bei einem Abbruch wieder hier, mit dem Grund
|
||||||
|
// im Adressfeld. Ohne diese Zeilen laege er dort und niemand saehe ihn.
|
||||||
|
useEffect(() => {
|
||||||
|
const grund = new URLSearchParams(window.location.search).get('discord')
|
||||||
|
if (!grund) return
|
||||||
|
setFehler(grund)
|
||||||
|
note('fehler', grund)
|
||||||
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
async function absenden(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFehler(null)
|
||||||
|
setWartet(true)
|
||||||
|
try {
|
||||||
|
await login(name, passwort)
|
||||||
|
} catch (err) {
|
||||||
|
// Der Fehler steht AM FORMULAR und nicht nur in einer Meldung, die
|
||||||
|
// nach vier Sekunden geht: hier schaut man hin, wenn etwas nicht
|
||||||
|
// klappt. Der Text kommt vom Dienst ("Benutzername oder Passwort
|
||||||
|
// stimmt nicht"), nicht von hier.
|
||||||
|
const text = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
setFehler(text)
|
||||||
|
note('fehler', text)
|
||||||
|
} finally {
|
||||||
|
setWartet(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-full items-center justify-center overflow-hidden p-4">
|
||||||
|
{/* Zwei weiche Lichter im Hintergrund. Sie kosten nichts, brauchen
|
||||||
|
kein Bild und machen aus dem leeren Dunkel eine Seite. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -top-40 left-1/2 h-96 w-[36rem] -translate-x-1/2
|
||||||
|
rounded-full bg-akzent/20 blur-[100px]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -bottom-48 right-1/4 h-80 w-96 rounded-full
|
||||||
|
bg-akzent-tief/30 blur-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={absenden}
|
||||||
|
className="relative w-full max-w-sm rounded-xl border border-rand bg-flaeche/80
|
||||||
|
p-7 shadow-2xl shadow-black/40 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2.5">
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-akzent/15 text-akzent">
|
||||||
|
<HardDrive size={20} />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg font-semibold">{dienstName(marke)}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mb-6 text-xs text-schrift-leise">
|
||||||
|
Bilder, Videos und Ton für die eigenen Ressourcen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Benutzername"
|
||||||
|
value={name}
|
||||||
|
autoComplete="username"
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
value={passwort}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(e) => setPasswort(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="mt-3 rounded-md border border-schlecht/40 bg-schlecht/10 px-3 py-2 text-sm text-schlecht">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
type="submit"
|
||||||
|
wartet={wartet}
|
||||||
|
disabled={!name || !passwort}
|
||||||
|
className="mt-5 w-full"
|
||||||
|
kind="Anmelden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-[11px] text-schrift-leise">
|
||||||
|
Nach fünf Fehlversuchen wird kurz gesperrt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{discord && (
|
||||||
|
<>
|
||||||
|
<div className="my-4 flex items-center gap-3 text-xs text-schrift-leise">
|
||||||
|
<span className="h-px flex-1 bg-rand" />
|
||||||
|
oder
|
||||||
|
<span className="h-px flex-1 bg-rand" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ein echtes <a> und kein fetch: der Weg fuehrt zu
|
||||||
|
Discord und wieder zurueck, das ist eine
|
||||||
|
Seitennavigation und keine Anfrage im Hintergrund. */}
|
||||||
|
<a
|
||||||
|
href="/api/dash/auth/discord"
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-md
|
||||||
|
border border-[#5865F2] bg-[#5865F2] px-3 py-1.5 text-sm
|
||||||
|
font-medium text-white transition-colors hover:bg-[#4752c4]"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 127 96" className="h-4 w-4" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M107 8A105 105 0 0 0 81 0l-3 7a97 97 0 0 0-29 0l-4-7a105 105 0 0 0-26 8C2 33-1 57 1 81a106 106 0 0 0 32 16l7-11c-4-1-8-3-11-5l3-2a75 75 0 0 0 64 0l3 2c-4 2-8 4-12 5l7 11a106 106 0 0 0 32-16c3-28-2-52-19-73ZM43 66c-6 0-11-6-11-13s5-13 11-13 12 6 11 13c0 7-5 13-11 13Zm42 0c-6 0-11-6-11-13s5-13 11-13 11 6 11 13c0 7-5 13-11 13Z" />
|
||||||
|
</svg>
|
||||||
|
Mit Discord anmelden
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Check, Copy, Download, FileJson, FileSpreadsheet, FileCode2 } from 'lucide-react'
|
||||||
|
import { api, type Uebersicht } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { Karte, useKopieren } from '../components/Bausteine'
|
||||||
|
import { Code } from '../components/Code'
|
||||||
|
import { BookOpen, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
|
/** Anleitung und Export für die eigenen Skripte.
|
||||||
|
*
|
||||||
|
* Der Punkt dieser Seite: die Beispiele stehen hier MIT DER ECHTEN ADRESSE
|
||||||
|
* drin, nicht mit `https://example.com`. Wer etwas abschreibt, schreibt damit
|
||||||
|
* auch keinen Tippfehler ab — und das ist die häufigste Fehlerquelle beim
|
||||||
|
* Anbinden eines Dienstes.
|
||||||
|
*
|
||||||
|
* Der Token steht bewusst NICHT hier. Er ist nach dem Anlegen nicht mehr
|
||||||
|
* lesbar, auch nicht für den Dienst; im Beispiel steht deshalb ein
|
||||||
|
* Platzhalter. */
|
||||||
|
export function Api() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<Uebersicht | null>(null)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.uebersicht()
|
||||||
|
.then(setDaten)
|
||||||
|
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
if (!daten) return null
|
||||||
|
|
||||||
|
const B = daten.publicUrl
|
||||||
|
const F = `${daten.publicUrl}${daten.filePrefix}`
|
||||||
|
|
||||||
|
async function kopiereBlock(text: string, marke: string) {
|
||||||
|
if (!(await kopieren(text, marke))) note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Die ausfuehrliche Fassung liegt im Wiki. HIER steht das, was man
|
||||||
|
beim Anbinden in der Hand haben muss — dort das Drumherum, das
|
||||||
|
sich in einer Karte nicht lesen laesst. */}
|
||||||
|
<a
|
||||||
|
href="https://git.d4rkst3r.de/D4rkst3r/d4rk_media/wiki"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-rand bg-flaeche px-4 py-3
|
||||||
|
text-sm transition-colors hover:border-akzent"
|
||||||
|
>
|
||||||
|
<BookOpen size={18} className="shrink-0 text-akzent" />
|
||||||
|
<span className="flex-1">
|
||||||
|
Die ausführliche Anleitung im Wiki
|
||||||
|
<span className="block text-xs text-schrift-leise">
|
||||||
|
Alle Endpunkte, Fehlermeldungen, Grenzen — und was beim Anbinden
|
||||||
|
schiefgehen kann.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ExternalLink size={15} className="shrink-0 text-schrift-leise" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Karte titel="Export">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['json', 'JSON', FileJson, 'Alles, was der Dienst weiß'],
|
||||||
|
['csv', 'CSV', FileSpreadsheet, 'für Tabellen (Semikolon, für Excel)'],
|
||||||
|
['lua', 'Lua', FileCode2, 'fertige Tabelle Name → Adresse'],
|
||||||
|
] as const
|
||||||
|
).map(([f, text, Symbol, hinweis]) => (
|
||||||
|
<a
|
||||||
|
key={f}
|
||||||
|
href={`/api/dash/export?format=${f}`}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-rand px-3 py-1.5
|
||||||
|
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
title={hinweis}
|
||||||
|
>
|
||||||
|
<Symbol size={15} />
|
||||||
|
{text}
|
||||||
|
<Download size={13} className="opacity-60" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
{daten.files} Einträge. Die Lua-Fassung liest man in einer Resource als{' '}
|
||||||
|
<code className="font-mono">Urls.adder</code> aus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Hochladen aus einer FiveM-Resource">
|
||||||
|
<p className="mb-2 text-xs text-schrift-leise">
|
||||||
|
Server-Seite, nicht NUI: die NUI läuft unter https und dürfte eine
|
||||||
|
http-Adresse gar nicht aufrufen. Base64, weil{' '}
|
||||||
|
<code className="font-mono">PerformHttpRequest</code> den Rumpf als
|
||||||
|
Lua-Zeichenkette nimmt und ein Bild voller Nullbytes ist.
|
||||||
|
</p>
|
||||||
|
<Code
|
||||||
|
sprache="lua"
|
||||||
|
marke="lua-upload"
|
||||||
|
text={`local TOKEN = 'd4rk_…' -- im Dashboard unter Token anlegen
|
||||||
|
|
||||||
|
local function hochladen(pfad, bytes, cb)
|
||||||
|
PerformHttpRequest('${B}/api/upload', function(status, body)
|
||||||
|
if status ~= 200 then return cb(nil, body) end
|
||||||
|
cb(json.decode(body).url)
|
||||||
|
end, 'POST', b64(bytes), {
|
||||||
|
['Authorization'] = 'Bearer ' .. TOKEN,
|
||||||
|
['X-Path'] = pfad, -- z. B. vehicles/adder.webp
|
||||||
|
['X-Encoding'] = 'base64',
|
||||||
|
['Content-Type'] = 'text/plain; charset=utf-8',
|
||||||
|
})
|
||||||
|
end`}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Die drei Wege der Skript-API">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Code
|
||||||
|
sprache="bash"
|
||||||
|
marke="curl-upload"
|
||||||
|
text={`# Hochladen — rohe Bytes, X-Path bestimmt die spätere Adresse
|
||||||
|
curl -X POST ${B}/api/upload \\
|
||||||
|
-H "Authorization: Bearer d4rk_…" \\
|
||||||
|
-H "X-Path: vehicles/adder.webp" \\
|
||||||
|
--data-binary @adder.webp
|
||||||
|
|
||||||
|
# → {"url":"${F}/vehicles/adder.webp","size":…,"sha256":…,"replaced":false}`}
|
||||||
|
/>
|
||||||
|
<Code
|
||||||
|
sprache="bash"
|
||||||
|
marke="curl-exists"
|
||||||
|
text={`# Gibt es das schon? — für "nur fehlende" in einem Serienlauf
|
||||||
|
curl -H "Authorization: Bearer d4rk_…" \\
|
||||||
|
${B}/api/exists?pfad=vehicles/adder.webp
|
||||||
|
|
||||||
|
# → {"exists":true,"url":"${F}/vehicles/adder.webp","size":…}`}
|
||||||
|
/>
|
||||||
|
<Code
|
||||||
|
sprache="bash"
|
||||||
|
marke="curl-delete"
|
||||||
|
text={`# Löschen — der Token braucht dafür das Recht "darf löschen"
|
||||||
|
curl -X DELETE -H "Authorization: Bearer d4rk_…" \\
|
||||||
|
${B}/api/media/vehicles/adder.webp`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Kopfzeilen beim Hochladen">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-36" />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['X-Path', 'Wohin die Datei kommt. Ohne ihn wird aus dem Hash ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar.'],
|
||||||
|
['X-Encoding', '„base64", wenn der Rumpf base64-Text ist. Data-URL-Vorspann wird abgeschnitten.'],
|
||||||
|
['X-Overwrite', '„false" verweigert das Überschreiben und antwortet mit 409.'],
|
||||||
|
['X-Mime', 'Überschreibt die Art. Sonst entscheidet die Endung.'],
|
||||||
|
] as const
|
||||||
|
).map(([k, t]) => (
|
||||||
|
<tr key={k} className="border-t border-rand first:border-0">
|
||||||
|
<td className="py-2 pr-3 align-top font-mono text-xs">{k}</td>
|
||||||
|
<td className="py-2 text-schrift-leise">{t}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Adressen">
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
{daten.vorlagen.length === 0 && (
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Noch keine Vorlage angelegt — das geht unter Einstellungen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{daten.vorlagen.map((v, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
|
||||||
|
{v.name}
|
||||||
|
</span>
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded border border-rand bg-grund px-2 py-1 font-mono text-xs">
|
||||||
|
{F}/{v.muster}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => void kopiereBlock(`${F}/${v.muster}`, 'vl' + i)}
|
||||||
|
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Kopieren"
|
||||||
|
>
|
||||||
|
{kopiert === 'vl' + i ? (
|
||||||
|
<Check size={13} className="text-gut" />
|
||||||
|
) : (
|
||||||
|
<Copy size={13} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
CloudUpload,
|
||||||
|
HardDrive,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldX,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api, type Einstellungen as EinstellungenDaten } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||||
|
import { DiscordKarte } from '../components/DiscordKarte'
|
||||||
|
import { VorlagenKarte } from '../components/VorlagenKarte'
|
||||||
|
import { SicherungenKarte } from '../components/SicherungenKarte'
|
||||||
|
import { FreigabenKarte } from '../components/FreigabenKarte'
|
||||||
|
import { BenutzerKarte } from '../components/BenutzerKarte'
|
||||||
|
import { MitnehmenKarte } from '../components/MitnehmenKarte'
|
||||||
|
import { MeldungenKarte } from '../components/MeldungenKarte'
|
||||||
|
|
||||||
|
/** Alles, was sich einstellen lässt, an einer Stelle.
|
||||||
|
*
|
||||||
|
* Bisher lag die Discord-Karte beim Konto und die Zugangsdaten der Sicherung
|
||||||
|
* in einer Textdatei auf der Platte. Beides ist jetzt hier — und das ist mehr
|
||||||
|
* als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht
|
||||||
|
* gelaufen ist. Diese Seite tut es. */
|
||||||
|
export function Einstellungen() {
|
||||||
|
const [urls, setUrls] = useState({ publicUrl: '', filePrefix: '' })
|
||||||
|
|
||||||
|
// Die Vorlagenkarte zeigt die FERTIGE Adresse und nicht nur das Muster —
|
||||||
|
// dafuer braucht sie den Ursprung, und der steht in der Uebersicht.
|
||||||
|
useEffect(() => {
|
||||||
|
api.uebersicht()
|
||||||
|
.then((u) => setUrls({ publicUrl: u.publicUrl, filePrefix: u.filePrefix }))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
|
||||||
|
|
||||||
|
<FreigabenKarte />
|
||||||
|
|
||||||
|
<BenutzerKarte />
|
||||||
|
|
||||||
|
<MitnehmenKarte />
|
||||||
|
<SicherungKarte />
|
||||||
|
<SicherungenKarte />
|
||||||
|
<MeldungenKarte />
|
||||||
|
<DiscordKarte />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SicherungKarte() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<EinstellungenDaten | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [speichert, setSpeichert] = useState(false)
|
||||||
|
|
||||||
|
const [basis, setBasis] = useState('')
|
||||||
|
const [benutzer, setBenutzer] = useState('')
|
||||||
|
const [passwort, setPasswort] = useState('')
|
||||||
|
const [ordner, setOrdner] = useState('')
|
||||||
|
const [behalten, setBehalten] = useState('14')
|
||||||
|
const [zweitziel, setZweitziel] = useState('')
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
setLaedt(true)
|
||||||
|
try {
|
||||||
|
const e = await api.einstellungen()
|
||||||
|
setDaten(e)
|
||||||
|
setBasis(e.sicherung.basis)
|
||||||
|
setBenutzer(e.sicherung.benutzer)
|
||||||
|
setOrdner(e.sicherung.ordner)
|
||||||
|
setBehalten(String(e.sicherung.behalten))
|
||||||
|
setZweitziel(e.sicherung.zweitziel)
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function speichern() {
|
||||||
|
setSpeichert(true)
|
||||||
|
const ergebnis = await run(
|
||||||
|
() =>
|
||||||
|
api.sicherungSpeichern({
|
||||||
|
basis,
|
||||||
|
benutzer,
|
||||||
|
...(passwort ? { passwort } : {}),
|
||||||
|
ordner,
|
||||||
|
behalten: Number(behalten),
|
||||||
|
zweitziel,
|
||||||
|
}),
|
||||||
|
(r) =>
|
||||||
|
r.bereit
|
||||||
|
? 'Gespeichert. Die nächste Sicherung geht in die Nextcloud.'
|
||||||
|
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
|
||||||
|
)
|
||||||
|
setSpeichert(false)
|
||||||
|
if (ergebnis) {
|
||||||
|
setPasswort('')
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (laedt) {
|
||||||
|
return (
|
||||||
|
<Karte titel="Sicherung">
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!daten) return null
|
||||||
|
|
||||||
|
const s = daten.sicherung
|
||||||
|
const l = s.letzte
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Sicherung"
|
||||||
|
rechts={
|
||||||
|
s.bereit ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gut">
|
||||||
|
<ShieldCheck size={14} /> eingerichtet
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||||
|
<ShieldX size={14} /> nur lokal
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Das Wichtigste zuerst: LIEF sie? Eine Sicherung, die still
|
||||||
|
aufhoert zu laufen, ist der eigentliche Schaden. */}
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||||
|
!l
|
||||||
|
? 'border-rand bg-grund text-schrift-leise'
|
||||||
|
: l.ok
|
||||||
|
? 'border-gut/40 bg-gut/10'
|
||||||
|
: 'border-schlecht/40 bg-schlecht/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!l ? (
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
) : l.ok ? (
|
||||||
|
<CheckCircle2 size={16} className="mt-0.5 shrink-0 text-gut" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
|
||||||
|
)}
|
||||||
|
<span className="text-schrift">
|
||||||
|
{!l ? (
|
||||||
|
'Noch keine Sicherung gemeldet. Sie läuft täglich um 04:30 als geplante Aufgabe.'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Zuletzt {zeit(l.at)} — {l.ok ? 'erfolgreich' : 'FEHLGESCHLAGEN'}
|
||||||
|
{l.dateien != null && `, ${l.dateien} Dateien`}
|
||||||
|
{l.groesse != null && `, ${bytes(l.groesse)}`}
|
||||||
|
{l.hoch === true && ', in der Nextcloud'}
|
||||||
|
{l.hoch === false && ', nur lokal'}
|
||||||
|
{l.zweit === true && ', auf der zweiten Platte'}
|
||||||
|
{l.fehler && (
|
||||||
|
<span className="block text-xs text-schlecht">{l.fehler}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
label="Nextcloud-Adresse"
|
||||||
|
value={basis}
|
||||||
|
placeholder="https://cdn.d4rkst3r.de"
|
||||||
|
onChange={(e) => setBasis(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Benutzername"
|
||||||
|
value={benutzer}
|
||||||
|
placeholder="wie beim Anmelden in der Nextcloud"
|
||||||
|
onChange={(e) => setBenutzer(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="App-Passwort"
|
||||||
|
type="password"
|
||||||
|
value={passwort}
|
||||||
|
placeholder={s.hatPasswort ? '•••••••• (hinterlegt)' : 'noch keines'}
|
||||||
|
hinweis='leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
|
||||||
|
onChange={(e) => setPasswort(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Ordner"
|
||||||
|
value={ordner}
|
||||||
|
placeholder="Sicherungen/d4rk_media"
|
||||||
|
onChange={(e) => setOrdner(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Wie viele behalten"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={behalten}
|
||||||
|
hinweis="gilt überall gleich"
|
||||||
|
onChange={(e) => setBehalten(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Zweites Ziel (andere Platte)"
|
||||||
|
value={zweitziel}
|
||||||
|
placeholder={'D:\\backup\\d4rk_media'}
|
||||||
|
className="font-mono"
|
||||||
|
hinweis="Pfad auf dem Server, leer lassen heißt: nur ein lokales Ziel."
|
||||||
|
onChange={(e) => setZweitziel(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex items-start gap-2 text-xs text-schrift-leise">
|
||||||
|
<HardDrive size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-schrift">
|
||||||
|
Die zwei Ziele beantworten zwei verschiedene Fragen.
|
||||||
|
</strong>{' '}
|
||||||
|
Die Nextcloud hilft, wenn der ganze Rechner weg ist — aber wer 100 GB
|
||||||
|
zurückholen muss, lädt sie über die Leitung. Das zweite Ziel liegt im
|
||||||
|
Rechner und ist in Minuten zurückgespielt; es hilft nur, wenn es auf einer{' '}
|
||||||
|
<strong className="text-schrift">anderen Platte</strong> liegt als die
|
||||||
|
Daten. Ein zweiter Ordner auf derselben Platte sieht aus wie eine
|
||||||
|
Sicherung und ist keine.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="flex items-start gap-2 text-xs text-schrift-leise">
|
||||||
|
<CloudUpload size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Nimm ein <strong>App-Passwort</strong>, nicht dein echtes: Nextcloud →
|
||||||
|
Einstellungen → Sicherheit → „Neues App-Passwort erstellen". Es lässt
|
||||||
|
sich einzeln zurückziehen. Das Skript liest die Werte direkt aus der
|
||||||
|
Datenbank des Dienstes — über HTTP geht das Passwort nur hier beim
|
||||||
|
Eintragen, und das über TLS.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FileQuestion, Film, Image, Loader2, Music, Play } from 'lucide-react'
|
||||||
|
import { bytes, dauerText, istBild } from '../format'
|
||||||
|
import { dienstName, MARKE_STANDARD } from '../marke'
|
||||||
|
|
||||||
|
/** Ein freigegebener Ordner — ohne Anmeldung.
|
||||||
|
*
|
||||||
|
* DIESE SEITE STEHT AUSSERHALB der angemeldeten Oberfläche und weiß nichts von
|
||||||
|
* ihr: kein Store, keine Reiter, kein Kontomenü. Wer hierherkommt, hat einen
|
||||||
|
* Link bekommen und will Bilder sehen.
|
||||||
|
*
|
||||||
|
* Sie zeigt ABSICHTLICH weniger als die Galerie: keine Pfade, keine Zeitpunkte,
|
||||||
|
* keine Abrufzahlen, keine Knöpfe. Was ein Katalog braucht, ist das Bild, der
|
||||||
|
* Name und die Adresse zum Kopieren. */
|
||||||
|
|
||||||
|
type Eintrag = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
dauer: number | null
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Seite = { marke: string; titel: string; ordner: string; anzahl: number; items: Eintrag[] }
|
||||||
|
|
||||||
|
export function Freigabe({ schluessel }: { schluessel: string }) {
|
||||||
|
const [daten, setDaten] = useState<Seite | null>(null)
|
||||||
|
const [fehler, setFehler] = useState('')
|
||||||
|
const [gross, setGross] = useState<Eintrag | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lebt = true
|
||||||
|
fetch(`/api/s/${encodeURIComponent(schluessel)}`)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(r.status === 404 ? 'nicht gefunden' : `HTTP ${r.status}`)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then((d) => lebt && setDaten(d))
|
||||||
|
.catch((e) => lebt && setFehler(e instanceof Error ? e.message : 'unbekannt'))
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
}
|
||||||
|
}, [schluessel])
|
||||||
|
|
||||||
|
if (fehler) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-grund p-6">
|
||||||
|
<div className="max-w-sm text-center">
|
||||||
|
<FileQuestion size={34} className="mx-auto text-schrift-leise/50" />
|
||||||
|
<p className="mt-3 text-sm text-schrift">Diesen Link gibt es nicht.</p>
|
||||||
|
<p className="mt-1 text-xs text-schrift-leise">
|
||||||
|
Er wurde zurückgezogen, oder er war nie einer. Beides sieht von hier
|
||||||
|
gleich aus — und das ist Absicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!daten) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-grund">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-grund">
|
||||||
|
<header className="border-b border-rand bg-flaeche">
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-4 py-4">
|
||||||
|
<h1 className="text-lg font-semibold text-schrift">{daten.titel}</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-schrift-leise">
|
||||||
|
{daten.anzahl} {daten.anzahl === 1 ? 'Datei' : 'Dateien'} ·{' '}
|
||||||
|
{bytes(daten.items.reduce((s, i) => s + i.size, 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-7xl px-4 py-5">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
|
||||||
|
{daten.items.map((i) => (
|
||||||
|
<figure
|
||||||
|
key={i.url}
|
||||||
|
className="overflow-hidden rounded-lg border border-rand bg-flaeche"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setGross(i)}
|
||||||
|
className="block w-full"
|
||||||
|
aria-label={`${i.name} groß ansehen`}
|
||||||
|
>
|
||||||
|
<div className="relative flex aspect-square items-center justify-center bg-grund">
|
||||||
|
<Kachel eintrag={i} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<figcaption className="border-t border-rand p-2">
|
||||||
|
<p
|
||||||
|
className="truncate font-mono text-[11px] text-schrift"
|
||||||
|
title={i.name}
|
||||||
|
>
|
||||||
|
{i.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-schrift-leise">{bytes(i.size)}</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-[11px] text-schrift-leise">
|
||||||
|
Bereitgestellt mit {dienstName(daten.marke || MARKE_STANDARD)}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{gross && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/85 p-4"
|
||||||
|
onClick={() => setGross(null)}
|
||||||
|
>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="max-h-full">
|
||||||
|
{gross.mime.startsWith('video') ? (
|
||||||
|
<video
|
||||||
|
src={gross.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="max-h-[80vh] max-w-full rounded"
|
||||||
|
/>
|
||||||
|
) : gross.mime.startsWith('audio') ? (
|
||||||
|
<audio src={gross.url} controls autoPlay className="w-80" />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={gross.url}
|
||||||
|
alt={gross.name}
|
||||||
|
className="max-h-[80vh] max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-xs text-schrift-leise">{gross.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dieselbe Regel wie in der Galerie: erst die Vorschau, dann ein Zeichen, das
|
||||||
|
* die Art benennt. Ein Fragezeichen auf einer gesunden MP4 sieht aus wie ein
|
||||||
|
* Fehler — und hier sieht es ein Gast. */
|
||||||
|
function Kachel({ eintrag }: { eintrag: Eintrag }) {
|
||||||
|
const [weg, setWeg] = useState(false)
|
||||||
|
const bewegt = eintrag.mime.startsWith('video') || eintrag.mime.startsWith('audio')
|
||||||
|
|
||||||
|
if (!weg) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={eintrag.thumb}
|
||||||
|
onError={(e) => {
|
||||||
|
const bild = e.currentTarget
|
||||||
|
if (istBild(eintrag.mime) && !bild.dataset.gefallen) {
|
||||||
|
bild.dataset.gefallen = 'ja'
|
||||||
|
bild.src = eintrag.url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setWeg(true)
|
||||||
|
}}
|
||||||
|
alt={eintrag.name}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
{bewegt && (
|
||||||
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="rounded-full bg-black/55 p-2">
|
||||||
|
<Play size={14} className="translate-x-[1px] fill-white text-white" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{eintrag.dauer !== null && eintrag.dauer > 0 && (
|
||||||
|
<span className="pointer-events-none absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[11px] tabular-nums text-white">
|
||||||
|
{dauerText(eintrag.dauer)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Zeichen = eintrag.mime.startsWith('video')
|
||||||
|
? Film
|
||||||
|
: eintrag.mime.startsWith('audio')
|
||||||
|
? Music
|
||||||
|
: istBild(eintrag.mime)
|
||||||
|
? Image
|
||||||
|
: FileQuestion
|
||||||
|
return <Zeichen size={26} className="text-schrift-leise/40" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
FileQuestion,
|
||||||
|
ArrowDownUp,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
FolderInput,
|
||||||
|
Layers,
|
||||||
|
Music,
|
||||||
|
ImageOff,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
vorschauUrl,
|
||||||
|
type Art,
|
||||||
|
type Arten,
|
||||||
|
type Folders,
|
||||||
|
type MediaItem,
|
||||||
|
type MediaPage,
|
||||||
|
type Richtung,
|
||||||
|
type Sortierung,
|
||||||
|
} from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes, dauerText, istBild, zeit } from '../format'
|
||||||
|
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||||
|
import { Ordner } from '../components/Ordner'
|
||||||
|
import { Hochladen } from '../components/Hochladen'
|
||||||
|
import { Ansehen } from '../components/Ansehen'
|
||||||
|
import { Verschieben } from '../components/Verschieben'
|
||||||
|
import { OrdnerUmbenennen, Umbenennen } from '../components/Umbenennen'
|
||||||
|
import { useAuffrischen } from '../auffrischen'
|
||||||
|
import { useMerker } from '../merker'
|
||||||
|
|
||||||
|
const SEITENGROESSEN = [30, 60, 120, 200] as const
|
||||||
|
|
||||||
|
export function Galerie() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [suche, setSuche] = useState('')
|
||||||
|
const [ordner, setOrdner] = useState('')
|
||||||
|
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
|
||||||
|
const [seite, setSeite] = useState(0)
|
||||||
|
const [art, setArt] = useState<Art>('')
|
||||||
|
const [arten, setArten] = useState<Arten | null>(null)
|
||||||
|
// Ansichtssachen ueberleben das Neuladen -- wer 120 einstellt, meint das
|
||||||
|
// dauerhaft und nicht bis zum naechsten F5.
|
||||||
|
const [proSeite, setProSeite] = useMerker('galerie.proSeite', 60)
|
||||||
|
const [sortierung, setSortierung] = useMerker<Sortierung>('galerie.sort', 'geaendert')
|
||||||
|
const [richtung, setRichtung] = useMerker<Richtung>('galerie.dir', 'ab')
|
||||||
|
const [daten, setDaten] = useState<MediaPage | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
|
/** Was geloescht werden soll.
|
||||||
|
*
|
||||||
|
* `ids` sind ALLE Gewaehlten, `namen` nur die, deren Pfad wir kennen --
|
||||||
|
* also die der offenen Seite. Vorher war das eine einzige Liste, und
|
||||||
|
* genau daran ist es gescheitert: der Knopf zaehlte die Auswahl, geloescht
|
||||||
|
* wurde die Schnittmenge mit der SICHTBAREN Seite. Bei 3565 gewaehlten
|
||||||
|
* Bildern verschwanden 60. */
|
||||||
|
const [loeschen, setLoeschen] = useState<{ ids: number[]; namen: MediaItem[] } | null>(null)
|
||||||
|
/** Wie weit das Loeschen ist -- bei tausenden Dateien dauert es. */
|
||||||
|
const [loeschFortschritt, setLoeschFortschritt] = useState(0)
|
||||||
|
const [loescht, setLoescht] = useState(false)
|
||||||
|
// Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt:
|
||||||
|
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
|
||||||
|
const [ansehen, setAnsehen] = useState<number | null>(null)
|
||||||
|
/** Dieselbe Trennung wie beim Loeschen -- und aus demselben Grund: hier
|
||||||
|
* stand `items.filter(...)`, also die offene Seite, und von 3565
|
||||||
|
* gewaehlten Bildern wanderten 60. */
|
||||||
|
const [verschieben, setVerschieben] = useState<{ ids: number[]; namen: MediaItem[] } | null>(null)
|
||||||
|
const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null)
|
||||||
|
const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
|
||||||
|
// eine Anfrage, und bei "adder" sind das fünf, von denen vier veraltet
|
||||||
|
// ankommen können.
|
||||||
|
const [sucheAktiv, setSucheAktiv] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setSucheAktiv(suche)
|
||||||
|
setSeite(0)
|
||||||
|
}, 250)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [suche])
|
||||||
|
|
||||||
|
// Welche Anfrage die neueste ist. Eine ältere, die später ankommt, darf
|
||||||
|
// die neuere nicht überschreiben — sonst steht nach schnellem Tippen das
|
||||||
|
// Ergebnis von vorletzter Eingabe auf dem Schirm.
|
||||||
|
const lauf = useRef(0)
|
||||||
|
|
||||||
|
const laden = useCallback(async () => {
|
||||||
|
const meine = ++lauf.current
|
||||||
|
setLaedt(true)
|
||||||
|
try {
|
||||||
|
const seiteDaten = await api.media(
|
||||||
|
sucheAktiv,
|
||||||
|
ordner,
|
||||||
|
art,
|
||||||
|
proSeite,
|
||||||
|
seite * proSeite,
|
||||||
|
sortierung,
|
||||||
|
richtung,
|
||||||
|
)
|
||||||
|
if (meine !== lauf.current) return
|
||||||
|
setDaten(seiteDaten)
|
||||||
|
} catch (err) {
|
||||||
|
if (meine !== lauf.current) return
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||||
|
} finally {
|
||||||
|
if (meine === lauf.current) setLaedt(false)
|
||||||
|
}
|
||||||
|
}, [sucheAktiv, ordner, art, seite, proSeite, sortierung, richtung, note])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
}, [laden])
|
||||||
|
|
||||||
|
// Die Ordnerzahlen aendern sich beim Loeschen und beim Hochladen. Sie
|
||||||
|
// haengen deshalb am selben Auslöser wie die Liste -- eine Uebersicht, die
|
||||||
|
// "21 Dateien" behauptet, waehrend daneben 19 stehen, ist schlimmer als
|
||||||
|
// keine.
|
||||||
|
// Die Zahlen an den Knoepfen haengen an Suche und Ordner, aber NICHT an
|
||||||
|
// der gewaehlten Art: sonst zeigte "Video (3)" nach dem Klick "Video (3)"
|
||||||
|
// und alle anderen 0.
|
||||||
|
const artenLaden = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setArten(await api.arten(sucheAktiv, ordner))
|
||||||
|
} catch {
|
||||||
|
// Die Zahlen sind Beiwerk. Faellt der Aufruf aus, bleiben die
|
||||||
|
// Knoepfe ohne Zahl statt die Seite mit einem Fehler zu fluten.
|
||||||
|
}
|
||||||
|
}, [sucheAktiv, ordner])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void artenLaden()
|
||||||
|
}, [artenLaden])
|
||||||
|
|
||||||
|
const ordnerLaden = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setOrdnerDaten(await api.folders())
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||||
|
}
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ordnerLaden()
|
||||||
|
}, [ordnerLaden])
|
||||||
|
|
||||||
|
const allesLaden = useCallback(() => {
|
||||||
|
void laden()
|
||||||
|
void ordnerLaden()
|
||||||
|
void artenLaden()
|
||||||
|
}, [laden, ordnerLaden, artenLaden])
|
||||||
|
|
||||||
|
// Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt
|
||||||
|
// es nur den Hinweis und keinen Neuaufbau unter den Haenden.
|
||||||
|
const frisch = useAuffrischen({
|
||||||
|
laden: allesLaden,
|
||||||
|
blockiert: gewaehlt.size > 0 || ansehen !== null || loeschen !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = daten?.items ?? []
|
||||||
|
const gesamt = daten?.total ?? 0
|
||||||
|
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
|
||||||
|
|
||||||
|
function umschalten(id: number) {
|
||||||
|
setGewaehlt((alt) => {
|
||||||
|
const neu = new Set(alt)
|
||||||
|
if (neu.has(id)) neu.delete(id)
|
||||||
|
else neu.add(id)
|
||||||
|
return neu
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
|
||||||
|
|
||||||
|
async function alleTrefferWaehlen() {
|
||||||
|
const antwort = await run(
|
||||||
|
() => api.medienIds(sucheAktiv, ordner, art),
|
||||||
|
(r) =>
|
||||||
|
r.gekappt
|
||||||
|
? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.`
|
||||||
|
: `${r.ids.length} gewählt.`,
|
||||||
|
)
|
||||||
|
if (antwort) setGewaehlt(new Set(antwort.ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
function alleUmschalten() {
|
||||||
|
setGewaehlt((alt) => {
|
||||||
|
const neu = new Set(alt)
|
||||||
|
if (alleGewaehlt) items.forEach((i) => neu.delete(i.id))
|
||||||
|
else items.forEach((i) => neu.add(i.id))
|
||||||
|
return neu
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschenAusfuehren() {
|
||||||
|
if (!loeschen) return
|
||||||
|
setLoescht(true)
|
||||||
|
setLoeschFortschritt(0)
|
||||||
|
|
||||||
|
// IN HAEPPCHEN, nicht in einem Zug.
|
||||||
|
//
|
||||||
|
// Der Dienst verschiebt jede Datei einzeln in den Papierkorb. Bei 3565
|
||||||
|
// Bildern ist das eine Anfrage, die Minuten laeuft -- und die faellt
|
||||||
|
// beim Proxy in die Zeitgrenze, lange bevor sie fertig ist. Dann waere
|
||||||
|
// die Haelfte geloescht und die Oberflaeche meldete einen Fehler.
|
||||||
|
//
|
||||||
|
// 200 je Runde: gross genug, dass es nicht an der Anzahl der Anfragen
|
||||||
|
// haengt, klein genug fuer eine Antwort in Sekunden. Und nach jeder
|
||||||
|
// Runde steht im Knopf, wie weit es ist -- bei etwas, das eine Minute
|
||||||
|
// dauert, ist Stillstand nicht von Haengen zu unterscheiden.
|
||||||
|
const { note } = useStore.getState()
|
||||||
|
const PORTION = 200
|
||||||
|
const alle = loeschen.ids
|
||||||
|
let geloescht = 0
|
||||||
|
let abbruch: string | null = null
|
||||||
|
|
||||||
|
for (let i = 0; i < alle.length; i += PORTION) {
|
||||||
|
const teil = alle.slice(i, i + PORTION)
|
||||||
|
try {
|
||||||
|
const r = teil.length === 1
|
||||||
|
? await api.deleteMedia(teil[0]!).then(() => ({ count: 1 }))
|
||||||
|
: await api.deleteMediaBulk(teil)
|
||||||
|
geloescht += r.count
|
||||||
|
} catch (e) {
|
||||||
|
// NICHT WEITERMACHEN. Wenn eine Runde scheitert, scheitern die
|
||||||
|
// naechsten vermutlich auch, und tausend Fehlermeldungen sagen
|
||||||
|
// weniger als eine mit der Zahl, die schon durch ist.
|
||||||
|
abbruch = e instanceof Error ? e.message : 'Unbekannt'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setLoeschFortschritt(geloescht)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abbruch) {
|
||||||
|
note('fehler', `${geloescht} von ${alle.length} gelöscht, dann brach es ab: ${abbruch}`)
|
||||||
|
} else if (geloescht === alle.length) {
|
||||||
|
note('ok', `${geloescht} ${geloescht === 1 ? 'Datei' : 'Dateien'} in den Papierkorb verschoben.`)
|
||||||
|
} else {
|
||||||
|
// Der Dienst ueberspringt, was er nicht findet. Diese Luecke zu
|
||||||
|
// verschweigen waere genau die Sorte Rueckmeldung, die hier nicht
|
||||||
|
// vorkommt.
|
||||||
|
note('hinweis', `${geloescht} von ${alle.length} gelöscht — der Rest war schon weg.`)
|
||||||
|
}
|
||||||
|
const ergebnis = !abbruch
|
||||||
|
|
||||||
|
setLoescht(false)
|
||||||
|
setLoeschen(null)
|
||||||
|
if (ergebnis) {
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
void laden()
|
||||||
|
void ordnerLaden()
|
||||||
|
void frisch.merken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adresseKopieren(item: MediaItem) {
|
||||||
|
const ok = await kopieren(item.url, String(item.id))
|
||||||
|
if (!ok) note('fehler', 'Die Zwischenablage ist gesperrt — nur über https erreichbar.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Ab 2xl (1536 px) rueckt die Ordnerspalte nach LINKS AUS dem
|
||||||
|
// Hauptbereich heraus: 15rem Spalte + 1rem Abstand = die 16rem, um die
|
||||||
|
// hier nach links gezogen wird. Damit steht das Raster auf genau
|
||||||
|
// derselben Breite wie jede andere Seite.
|
||||||
|
//
|
||||||
|
// Darunter stapelt es wie bisher — auf einem 1280er Schirm waere kein
|
||||||
|
// Rand da, in den etwas ruecken koennte.
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row 2xl:-ml-64">
|
||||||
|
<Ordner
|
||||||
|
daten={ordnerDaten}
|
||||||
|
gewaehlt={ordner}
|
||||||
|
onAngelegt={() => void ordnerLaden()}
|
||||||
|
onEntfernt={() => void ordnerLaden()}
|
||||||
|
onUmbenennen={setOrdnerUmbenennen}
|
||||||
|
onWahl={(o) => {
|
||||||
|
setOrdner(o)
|
||||||
|
setSeite(0)
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
{/* Nach Art filtern. Die Zahlen stehen dabei — ein Knopf "Video",
|
||||||
|
hinter dem nichts liegt, kostet einen Klick, um das
|
||||||
|
herauszufinden. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['', 'Alles', Layers],
|
||||||
|
['bild', 'Bilder', ImageOff],
|
||||||
|
['video', 'Video', Film],
|
||||||
|
['ton', 'Ton', Music],
|
||||||
|
['dokument', 'Dokumente', FileText],
|
||||||
|
['andere', 'Anderes', FileQuestion],
|
||||||
|
] as const
|
||||||
|
).map(([wert, text, Symbol]) => {
|
||||||
|
const n = arten?.[wert || 'alle']
|
||||||
|
const an = art === wert
|
||||||
|
// Leere Arten bleiben stehen statt zu verschwinden: eine
|
||||||
|
// Leiste, die ihre Knöpfe wechselt, trifft man nicht.
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={wert}
|
||||||
|
onClick={() => {
|
||||||
|
setArt(wert as Art)
|
||||||
|
setSeite(0)
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
}}
|
||||||
|
disabled={n === 0 && wert !== ''}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
|
||||||
|
transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
|
an
|
||||||
|
? 'border-akzent bg-akzent/15 text-schrift'
|
||||||
|
: 'border-rand text-schrift-leise hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Symbol size={13} />
|
||||||
|
{text}
|
||||||
|
{n !== undefined && (
|
||||||
|
<span className="tabular-nums opacity-70">{n}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kopfzeile: Suche, Auswahl, Löschen */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-56 flex-1">
|
||||||
|
<Search
|
||||||
|
size={15}
|
||||||
|
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={suche}
|
||||||
|
onChange={(e) => setSuche(e.target.value)}
|
||||||
|
placeholder="Pfad durchsuchen, z. B. vehicles/"
|
||||||
|
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
|
||||||
|
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
|
||||||
|
focus:outline-none"
|
||||||
|
/>
|
||||||
|
{suche && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSuche('')}
|
||||||
|
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
|
||||||
|
aria-label="Suche leeren"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm text-schrift-leise">
|
||||||
|
{laedt ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'}
|
||||||
|
{sucheAktiv && ' gefunden'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{frisch.wartet && (
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
onClick={frisch.jetztLaden}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Neues da — laden
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise"
|
||||||
|
title="Alle fünf Sekunden nachsehen, ob sich etwas geändert hat"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={frisch.an}
|
||||||
|
onChange={(e) => frisch.setAn(e.target.checked)}
|
||||||
|
className="accent-akzent"
|
||||||
|
/>
|
||||||
|
automatisch
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||||
|
<ArrowDownUp size={13} />
|
||||||
|
<select
|
||||||
|
value={sortierung}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortierung(e.target.value as Sortierung)
|
||||||
|
setSeite(0)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
|
||||||
|
text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="geaendert">zuletzt geändert</option>
|
||||||
|
<option value="angelegt">zuerst hochgeladen</option>
|
||||||
|
<option value="pfad">Pfad</option>
|
||||||
|
<option value="groesse">Größe</option>
|
||||||
|
{/* Gezählt wurde längst — ordnen konnte man danach
|
||||||
|
nicht, und damit beantwortete die Zahl die Frage
|
||||||
|
„was wird eigentlich gebraucht" nur für die zehn
|
||||||
|
im Speicherbericht. */}
|
||||||
|
<option value="abrufe">Abrufe</option>
|
||||||
|
<option value="geholt">zuletzt geholt</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={proSeite}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProSeite(Number(e.target.value))
|
||||||
|
setSeite(0)
|
||||||
|
}}
|
||||||
|
title="wie viele Kacheln je Seite"
|
||||||
|
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
|
||||||
|
text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
{SEITENGROESSEN.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n} / Seite
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRichtung(richtung === 'ab' ? 'auf' : 'ab')
|
||||||
|
setSeite(0)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-rand px-2 py-1.5 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
title={richtung === 'ab' ? 'absteigend' : 'aufsteigend'}
|
||||||
|
>
|
||||||
|
{richtung === 'ab' ? '↓' : '↑'}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Knopf
|
||||||
|
onClick={alleUmschalten}
|
||||||
|
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Erst anbieten, wenn es ueberhaupt mehr als eine Seite gibt --
|
||||||
|
sonst steht dort ein Knopf, der dasselbe tut wie der davor. */}
|
||||||
|
{gesamt > items.length && (
|
||||||
|
<Knopf
|
||||||
|
onClick={() => void alleTrefferWaehlen()}
|
||||||
|
kind={`Alle ${gesamt} wählen`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gewaehlt.size > 0 && (
|
||||||
|
<Knopf
|
||||||
|
onClick={() =>
|
||||||
|
setVerschieben({
|
||||||
|
ids: [...gewaehlt],
|
||||||
|
namen: items.filter((i) => gewaehlt.has(i.id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<FolderInput size={15} />
|
||||||
|
verschieben
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gewaehlt.size > 0 && (
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
onClick={() =>
|
||||||
|
setLoeschen({
|
||||||
|
// ALLE Gewaehlten, nicht nur die sichtbaren.
|
||||||
|
ids: [...gewaehlt],
|
||||||
|
namen: items.filter((i) => gewaehlt.has(i.id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
{/* WAEHREND ES LAEUFT die Zahl, die schon
|
||||||
|
durch ist. Bei tausenden Dateien dauert es
|
||||||
|
eine Minute, und Stillstand ist von Haengen
|
||||||
|
nicht zu unterscheiden. */}
|
||||||
|
{loescht && loeschFortschritt > 0
|
||||||
|
? `${loeschFortschritt} von ${gewaehlt.size} …`
|
||||||
|
: `${gewaehlt.size} löschen`}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Hochladen
|
||||||
|
ordner={ordner}
|
||||||
|
ordnerDaten={ordnerDaten}
|
||||||
|
fertig={() => {
|
||||||
|
void laden()
|
||||||
|
void ordnerLaden()
|
||||||
|
void frisch.merken()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Das Raster */}
|
||||||
|
{items.length === 0 && !laedt ? (
|
||||||
|
<Leer
|
||||||
|
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
|
||||||
|
text={
|
||||||
|
sucheAktiv
|
||||||
|
? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.`
|
||||||
|
: ordner
|
||||||
|
? `In ${ordner}/ liegt nichts.`
|
||||||
|
: 'Noch keine Datei hochgeladen.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const an = gewaehlt.has(item.id)
|
||||||
|
return (
|
||||||
|
<figure
|
||||||
|
key={item.id}
|
||||||
|
className={`group relative overflow-hidden rounded-lg border bg-flaeche
|
||||||
|
transition-all hover:-translate-y-0.5 hover:shadow-lg
|
||||||
|
hover:shadow-black/30 ${
|
||||||
|
an
|
||||||
|
? 'border-akzent ring-1 ring-akzent/40'
|
||||||
|
: 'border-rand hover:border-schrift-leise/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Klick auf die Kachel ÖFFNET, Klick auf das
|
||||||
|
Kästchen WÄHLT AUS. Vorher tat der Klick
|
||||||
|
beides nicht gleichzeitig — er wählte nur
|
||||||
|
aus, und zum Ansehen musste man einen
|
||||||
|
neuen Tab aufmachen. */}
|
||||||
|
<button
|
||||||
|
onClick={() => setAnsehen(item.id)}
|
||||||
|
className="block w-full"
|
||||||
|
aria-label={`${item.path} ansehen`}
|
||||||
|
>
|
||||||
|
<div className="relative flex aspect-square items-center justify-center bg-grund">
|
||||||
|
<Vorschau item={item} />
|
||||||
|
|
||||||
|
{/* Das Abspielzeichen liegt ÜBER dem
|
||||||
|
Standbild. Ohne es sieht ein Video
|
||||||
|
aus wie ein Bild, und man klickt es
|
||||||
|
in der Erwartung an, ein Bild zu
|
||||||
|
sehen. */}
|
||||||
|
{(item.mime.startsWith('video') ||
|
||||||
|
item.mime.startsWith('audio')) && (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 flex
|
||||||
|
items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="rounded-full bg-black/55 p-2">
|
||||||
|
<Play
|
||||||
|
size={16}
|
||||||
|
className="translate-x-[1px] fill-white text-white"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.dauer !== null && item.dauer > 0 && (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-1.5 bottom-1.5
|
||||||
|
rounded bg-black/70 px-1.5 py-0.5 text-[11px]
|
||||||
|
tabular-nums text-white"
|
||||||
|
>
|
||||||
|
{dauerText(item.dauer)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Immer sichtbar, wenn gewählt; sonst erst
|
||||||
|
beim Darüberfahren. Ein Kästchen, das man
|
||||||
|
erst suchen muss, ist keines — eines, das
|
||||||
|
bei 60 Kacheln immer leuchtet, ist Lärm. */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
umschalten(item.id)
|
||||||
|
}}
|
||||||
|
aria-pressed={an}
|
||||||
|
aria-label={`${item.path} auswählen`}
|
||||||
|
className={`absolute top-2 left-2 rounded border p-1 transition-opacity ${
|
||||||
|
an
|
||||||
|
? 'border-akzent bg-akzent text-grund opacity-100'
|
||||||
|
: 'border-rand bg-grund/80 text-schrift-leise opacity-0 group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<figcaption className="border-t border-rand p-2">
|
||||||
|
<p
|
||||||
|
className="truncate font-mono text-xs text-schrift"
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
{item.path}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-schrift-leise">
|
||||||
|
{/* Wonach sortiert wird, STEHT AUCH DA.
|
||||||
|
Nach einer Zahl zu ordnen, die man
|
||||||
|
nirgends sieht, ist eine Reihenfolge
|
||||||
|
ohne Begründung — man sieht nur, dass
|
||||||
|
sich etwas geändert hat. */}
|
||||||
|
{sortierung === 'abrufe' || sortierung === 'geholt' ? (
|
||||||
|
<>
|
||||||
|
{item.abrufe}× geholt ·{' '}
|
||||||
|
{item.zuletzt_abgerufen
|
||||||
|
? zeit(item.zuletzt_abgerufen)
|
||||||
|
: 'nie'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{bytes(item.size)} · {zeit(item.updated_at)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => void adresseKopieren(item)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-1 rounded border
|
||||||
|
border-rand py-1 text-[11px] text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
>
|
||||||
|
{kopiert === String(item.id) ? (
|
||||||
|
<>
|
||||||
|
<Check size={11} className="text-gut" />
|
||||||
|
kopiert
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={11} />
|
||||||
|
Adresse
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="In neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setLoeschen({ ids: [item.id], namen: [item] })}
|
||||||
|
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-schlecht/15 hover:text-schlecht"
|
||||||
|
aria-label={`${item.path} löschen`}
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blättern */}
|
||||||
|
{seiten > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
disabled={seite === 0}
|
||||||
|
onClick={() => setSeite((s) => s - 1)}
|
||||||
|
kind={<ChevronLeft size={15} />}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-schrift-leise">
|
||||||
|
Seite {seite + 1} von {seiten}
|
||||||
|
</span>
|
||||||
|
<Knopf
|
||||||
|
disabled={seite + 1 >= seiten}
|
||||||
|
onClick={() => setSeite((s) => s + 1)}
|
||||||
|
kind={<ChevronRight size={15} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
if (ansehen === null) return null
|
||||||
|
const i = items.findIndex((x) => x.id === ansehen)
|
||||||
|
// Die Datei kann zwischenzeitlich verschwunden sein -- etwa
|
||||||
|
// weil daneben geloescht wurde. Dann schliesst sich der
|
||||||
|
// Betrachter, statt auf ein Loch zu zeigen.
|
||||||
|
if (i === -1) return null
|
||||||
|
const item = items[i]!
|
||||||
|
return (
|
||||||
|
<Ansehen
|
||||||
|
item={item}
|
||||||
|
naechstes={items[i + 1]}
|
||||||
|
hatVorher={i > 0}
|
||||||
|
hatNachher={i < items.length - 1}
|
||||||
|
onVorher={() => setAnsehen(items[i - 1]!.id)}
|
||||||
|
onNachher={() => setAnsehen(items[i + 1]!.id)}
|
||||||
|
onSchliessen={() => setAnsehen(null)}
|
||||||
|
onLoeschen={() => {
|
||||||
|
setAnsehen(null)
|
||||||
|
setLoeschen({ ids: [item.id], namen: [item] })
|
||||||
|
}}
|
||||||
|
onUmbenennen={() => {
|
||||||
|
setAnsehen(null)
|
||||||
|
setUmbenennen(item)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{umbenennen && (
|
||||||
|
<Umbenennen
|
||||||
|
datei={umbenennen}
|
||||||
|
onAbbruch={() => setUmbenennen(null)}
|
||||||
|
onFertig={() => {
|
||||||
|
setUmbenennen(null)
|
||||||
|
allesLaden()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ordnerUmbenennen !== null && (
|
||||||
|
<OrdnerUmbenennen
|
||||||
|
pfad={ordnerUmbenennen}
|
||||||
|
dateien={
|
||||||
|
ordnerDaten?.items.find((o) => o.folder === ordnerUmbenennen)?.files ?? 0
|
||||||
|
}
|
||||||
|
onAbbruch={() => setOrdnerUmbenennen(null)}
|
||||||
|
onFertig={(neu) => {
|
||||||
|
setOrdnerUmbenennen(null)
|
||||||
|
// Steht die Ansicht IM umbenannten Ordner, muss sie
|
||||||
|
// mitwandern -- sonst zeigt sie auf einen Pfad, den es
|
||||||
|
// nicht mehr gibt, und der Inhalt ist leer.
|
||||||
|
if (ordner === ordnerUmbenennen || ordner.startsWith(ordnerUmbenennen + '/')) {
|
||||||
|
setOrdner(neu + ordner.slice(ordnerUmbenennen.length))
|
||||||
|
}
|
||||||
|
allesLaden()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verschieben && (
|
||||||
|
<Verschieben
|
||||||
|
ids={verschieben.ids}
|
||||||
|
namen={verschieben.namen}
|
||||||
|
ordner={ordnerDaten}
|
||||||
|
onAbbruch={() => setVerschieben(null)}
|
||||||
|
onFertig={() => {
|
||||||
|
setVerschieben(null)
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
void laden()
|
||||||
|
void ordnerLaden()
|
||||||
|
void artenLaden()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={loeschen !== null}
|
||||||
|
titel={
|
||||||
|
loeschen?.ids.length === 1
|
||||||
|
? 'Datei löschen?'
|
||||||
|
: `${loeschen?.ids.length ?? 0} Dateien löschen?`
|
||||||
|
}
|
||||||
|
bestaetigung="In den Papierkorb"
|
||||||
|
wartet={loescht}
|
||||||
|
onNein={() => setLoeschen(null)}
|
||||||
|
onJa={() => void loeschenAusfuehren()}
|
||||||
|
text={
|
||||||
|
<>
|
||||||
|
{/* DER SATZ HIER WAR FALSCH und stand aus der Zeit
|
||||||
|
vor dem Papierkorb: "Es gibt keine Sicherung, aus
|
||||||
|
der sie zurueckkommt." Es gibt eine -- die Datei
|
||||||
|
wird verschoben, nicht geloescht, und liegt 30 Tage
|
||||||
|
im Papierkorb. Eine Warnung, die schlimmer klingt
|
||||||
|
als die Lage, haelt vom Aufraeumen ab und ist damit
|
||||||
|
genauso schaedlich wie eine, die zu harmlos klingt. */}
|
||||||
|
<p className="mb-2">
|
||||||
|
Verschiebt in den <b>Papierkorb</b> — dort liegt alles noch 30 Tage und
|
||||||
|
lässt sich zurückholen. Erst danach ist es endgültig weg.
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
|
||||||
|
{loeschen?.namen.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
|
||||||
|
{/* Benennen laesst sich nur, was auf der offenen
|
||||||
|
Seite liegt -- der Rest ist gewaehlt, aber
|
||||||
|
nicht geladen. Das steht hier so, statt eine
|
||||||
|
Vollstaendigkeit vorzutaeuschen. */}
|
||||||
|
{loeschen && loeschen.ids.length > loeschen.namen.slice(0, 12).length && (
|
||||||
|
<li className="text-schrift-leise">
|
||||||
|
… und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was auf der Kachel steht.
|
||||||
|
*
|
||||||
|
* ERST DIE VORSCHAU, immer: 60 Vollbilder sind 7,2 MB, nur damit jemand sieht,
|
||||||
|
* WELCHE Dateien da sind. Video und Ton haben seit ffmpeg ebenfalls eine —
|
||||||
|
* ein Standbild aus dem Clip, bei einer MP3 das eingebettete Titelbild.
|
||||||
|
*
|
||||||
|
* FEHLT SIE, wird nicht geraten. Bei einem Bild nimmt der Browser über
|
||||||
|
* `onError` das Vollbild; bei allem anderen kommt ein Zeichen, das die ART
|
||||||
|
* benennt. Vorher stand dort für jede Nicht-Bild-Datei ein Fragezeichen —
|
||||||
|
* also ausgerechnet das Zeichen für „unbekannt" auf einer völlig gesunden
|
||||||
|
* MP4. */
|
||||||
|
function Vorschau({ item }: { item: MediaItem }) {
|
||||||
|
const [gescheitert, setGescheitert] = useState(false)
|
||||||
|
const bewegt = item.mime.startsWith('video') || item.mime.startsWith('audio')
|
||||||
|
|
||||||
|
// Sobald die Datei wechselt, ist ein früherer Fehlschlag hinfällig.
|
||||||
|
useEffect(() => setGescheitert(false), [item.id])
|
||||||
|
|
||||||
|
if (!gescheitert) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={vorschauUrl(item.path)}
|
||||||
|
onError={(e) => {
|
||||||
|
// Bei einem Bild lohnt der zweite Versuch mit dem Vollbild:
|
||||||
|
// die Vorschau ist abgeleitet und kann fehlen, das Bild
|
||||||
|
// selbst liegt da. Bei Video und Ton wäre derselbe Griff
|
||||||
|
// grober Unfug — er lüde den ganzen Clip in ein <img>.
|
||||||
|
const bild = e.currentTarget
|
||||||
|
if (istBild(item.mime) && !bild.dataset.gefallen) {
|
||||||
|
bild.dataset.gefallen = 'ja'
|
||||||
|
bild.src = item.url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGescheitert(true)
|
||||||
|
}}
|
||||||
|
alt={item.path}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Zeichen = item.mime.startsWith('video')
|
||||||
|
? Film
|
||||||
|
: item.mime.startsWith('audio')
|
||||||
|
? Music
|
||||||
|
: item.mime === 'application/pdf' ||
|
||||||
|
item.mime.startsWith('text/') ||
|
||||||
|
item.mime.startsWith('application/json')
|
||||||
|
? FileText
|
||||||
|
: istBild(item.mime)
|
||||||
|
? ImageOff
|
||||||
|
: FileQuestion
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Zeichen
|
||||||
|
size={28}
|
||||||
|
className={bewegt ? 'text-schrift-leise/30' : 'text-schrift-leise/50'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
|
export function Konto() {
|
||||||
|
const user = useStore((s) => s.user)
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [aktuell, setAktuell] = useState('')
|
||||||
|
const [neu, setNeu] = useState('')
|
||||||
|
const [wieder, setWieder] = useState('')
|
||||||
|
const [wartet, setWartet] = useState(false)
|
||||||
|
|
||||||
|
const zuKurz = neu.length > 0 && neu.length < 8
|
||||||
|
const ungleich = wieder.length > 0 && neu !== wieder
|
||||||
|
|
||||||
|
async function wechseln(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Die Wiederholung prüft nur diese Seite — der Dienst kennt sie nicht.
|
||||||
|
// Deshalb hier ein eigener Satz statt einer Anfrage, die nichts
|
||||||
|
// bewirken kann.
|
||||||
|
if (neu !== wieder) {
|
||||||
|
note('fehler', 'Die beiden neuen Passwörter stimmen nicht überein.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setWartet(true)
|
||||||
|
const ok = await run(
|
||||||
|
() => api.changePassword(aktuell, neu),
|
||||||
|
'Passwort geändert. Alle anderen Anmeldungen wurden beendet.',
|
||||||
|
)
|
||||||
|
setWartet(false)
|
||||||
|
if (ok) {
|
||||||
|
setAktuell('')
|
||||||
|
setNeu('')
|
||||||
|
setWieder('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Discord-Name steht nur daneben, wenn er vom Anzeigenamen abweicht —
|
||||||
|
// sonst stuende dort zweimal dasselbe.
|
||||||
|
const herkunft =
|
||||||
|
user?.discordName && user.discordName !== user.username
|
||||||
|
? `über Discord angemeldet — dort „${user.discordName}"`
|
||||||
|
: 'über Discord angemeldet'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Karte titel="Konto">
|
||||||
|
<div className="mb-4 flex items-center gap-3 border-b border-rand pb-4">
|
||||||
|
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={44} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{user?.username ?? '—'}</p>
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
{user?.discord ? herkunft : 'mit Passwort angemeldet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.discord && (
|
||||||
|
<p className="mb-4 rounded-md border border-rand bg-grund px-3 py-2 text-xs text-schrift-leise">
|
||||||
|
Dieses Konto hat kein Passwort — es kommt ausschließlich über Discord
|
||||||
|
herein. Das Formular unten wirkt nur für Konten mit Passwort.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={wechseln} className="flex max-w-md flex-col gap-3">
|
||||||
|
<Feld
|
||||||
|
label="Aktuelles Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={aktuell}
|
||||||
|
onChange={(e) => setAktuell(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Neues Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={neu}
|
||||||
|
onChange={(e) => setNeu(e.target.value)}
|
||||||
|
hinweis={zuKurz ? 'Mindestens 8 Zeichen.' : undefined}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Neues Passwort wiederholen"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={wieder}
|
||||||
|
onChange={(e) => setWieder(e.target.value)}
|
||||||
|
hinweis={ungleich ? 'Stimmt nicht mit dem neuen Passwort überein.' : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Ein Wechsel beendet alle anderen Sitzungen. Diese hier bleibt bestehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
type="submit"
|
||||||
|
wartet={wartet}
|
||||||
|
disabled={!aktuell || neu.length < 8 || neu !== wieder}
|
||||||
|
kind="Passwort ändern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { AlertTriangle, Loader2, RotateCcw, Trash2 } from 'lucide-react'
|
||||||
|
import { api, type PapierkorbSeite, type PapierkorbEintrag } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Karte, Knopf, Leer, Nachfrage } from '../components/Bausteine'
|
||||||
|
|
||||||
|
/** Der Papierkorb.
|
||||||
|
*
|
||||||
|
* Löschen war bis vor Kurzem endgültig — die Sicherung half nur bis zum
|
||||||
|
* letzten nächtlichen Lauf. Bei einem Knopf „alle 3670 wählen" direkt neben
|
||||||
|
* „löschen" ist das die falsche Härte.
|
||||||
|
*
|
||||||
|
* Was hier steht, liegt weiterhin auf der Platte, nur nicht mehr unter seiner
|
||||||
|
* öffentlichen Adresse. Nach 30 Tagen räumt der Dienst selbst auf. */
|
||||||
|
export function Papierkorb() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<PapierkorbSeite | null>(null)
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fragt, setFragt] = useState<PapierkorbEintrag[] | 'alles' | null>(null)
|
||||||
|
|
||||||
|
const laden = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDaten(await api.papierkorb())
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
}
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
}, [laden])
|
||||||
|
|
||||||
|
if (!daten) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = daten.items
|
||||||
|
const auswahl = items.filter((i) => gewaehlt.has(i.id))
|
||||||
|
|
||||||
|
async function zurueck(liste: PapierkorbEintrag[]) {
|
||||||
|
setLaeuft(true)
|
||||||
|
await run(
|
||||||
|
() => api.papierkorbZurueck(liste.map((e) => e.id)),
|
||||||
|
(r) =>
|
||||||
|
r.fehlgeschlagen === 0
|
||||||
|
? `${r.zurueck} zurückgeholt.`
|
||||||
|
: `${r.zurueck} zurückgeholt, ${r.fehlgeschlagen} nicht: ` +
|
||||||
|
r.ergebnisse
|
||||||
|
.filter((e) => !e.ok)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((e) => `${e.path} (${e.error})`)
|
||||||
|
.join('; '),
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm text-schrift-leise">
|
||||||
|
{daten.n} {daten.n === 1 ? 'Eintrag' : 'Einträge'} · {bytes(daten.bytes)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{auswahl.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={laeuft}
|
||||||
|
onClick={() => void zurueck(auswahl)}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
{auswahl.length} zurückholen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
onClick={() => setFragt(auswahl)}
|
||||||
|
kind={`${auswahl.length} endgültig löschen`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => setFragt('alles')}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
Papierkorb leeren
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{daten.verwaist > 0 && (
|
||||||
|
<p className="flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10
|
||||||
|
px-3 py-2 text-xs text-warn">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Bei <strong>{daten.verwaist}</strong> {daten.verwaist === 1 ? 'Eintrag' : 'Einträgen'}{' '}
|
||||||
|
fehlt die Datei auf der Platte — zurückholen geht dort nicht. Das kommt
|
||||||
|
vor, wenn eine Sicherung zurückgespielt wurde, die den Papierkorb nicht
|
||||||
|
enthielt. Sie stehen unten durchgestrichen; „endgültig löschen" räumt sie
|
||||||
|
auf.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Leer
|
||||||
|
kind={<Trash2 size={26} className="text-schrift-leise/50" />}
|
||||||
|
text="Der Papierkorb ist leer."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full table-fixed text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-10" />
|
||||||
|
<col />
|
||||||
|
<col className="w-24" />
|
||||||
|
<col className="w-40" />
|
||||||
|
<col className="w-32" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-schrift-leise">
|
||||||
|
<th className="px-3 pb-2" />
|
||||||
|
<th className="px-3 pb-2 font-medium">Pfad</th>
|
||||||
|
<th className="px-3 pb-2 text-right font-medium">Größe</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Gelöscht</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Von</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((e) => (
|
||||||
|
<tr
|
||||||
|
key={e.id}
|
||||||
|
className={`border-t border-rand ${e.dateiDa ? '' : 'opacity-50'}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={gewaehlt.has(e.id)}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setGewaehlt((alt) => {
|
||||||
|
const neu = new Set(alt)
|
||||||
|
if (ev.target.checked) neu.add(e.id)
|
||||||
|
else neu.delete(e.id)
|
||||||
|
return neu
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="accent-akzent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`max-w-0 truncate px-3 py-1.5 font-mono text-xs ${
|
||||||
|
e.dateiDa ? '' : 'line-through'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
e.dateiDa
|
||||||
|
? e.path
|
||||||
|
: `${e.path} — die Datei fehlt auf der Platte`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{e.path}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||||
|
{bytes(e.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
|
||||||
|
{zeit(e.geloescht)}
|
||||||
|
</td>
|
||||||
|
<td className="truncate px-3 py-1.5 text-schrift-leise">
|
||||||
|
{e.von}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Einträge werden nach {daten.haltbarTage} Tagen von selbst endgültig entfernt.
|
||||||
|
Solange sie hier stehen, liegen sie weiterhin auf der Platte — nur nicht mehr
|
||||||
|
unter ihrer öffentlichen Adresse.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={fragt !== null}
|
||||||
|
titel={fragt === 'alles' ? 'Papierkorb leeren?' : 'Endgültig löschen?'}
|
||||||
|
bestaetigung="Endgültig löschen"
|
||||||
|
wartet={laeuft}
|
||||||
|
onNein={() => setFragt(null)}
|
||||||
|
onJa={async () => {
|
||||||
|
setLaeuft(true)
|
||||||
|
await run(
|
||||||
|
() =>
|
||||||
|
api.papierkorbLeeren(
|
||||||
|
fragt === 'alles' ? undefined : (fragt ?? []).map((e) => e.id),
|
||||||
|
),
|
||||||
|
(r) => `${r.geleert} endgültig entfernt.`,
|
||||||
|
)
|
||||||
|
setLaeuft(false)
|
||||||
|
setFragt(null)
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
void laden()
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
|
||||||
|
<span>
|
||||||
|
{fragt === 'alles'
|
||||||
|
? `Alle ${daten.n} Einträge werden von der Platte entfernt.`
|
||||||
|
: `${(fragt as PapierkorbEintrag[])?.length} Einträge werden von der Platte entfernt.`}{' '}
|
||||||
|
Danach hilft nur noch die Sicherung — und die ist bis zu 24 Stunden
|
||||||
|
alt.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
FileArchive,
|
||||||
|
Folder,
|
||||||
|
HardDrive,
|
||||||
|
Images,
|
||||||
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
SearchCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api, type Stats } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes } from '../format'
|
||||||
|
import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||||
|
import { type Folders, type Verwaiste } from '../api'
|
||||||
|
import { SpeicherKarten } from '../components/SpeicherKarten'
|
||||||
|
import { FehlendeKarte } from '../components/FehlendeKarte'
|
||||||
|
import { ErsetzenKarte } from '../components/ErsetzenKarte'
|
||||||
|
import { api as apiFolders } from '../api'
|
||||||
|
|
||||||
|
export function Speicher() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [zieht, setZieht] = useState(false)
|
||||||
|
const [spart, setSpart] = useState(false)
|
||||||
|
const [fortschritt, setFortschritt] = useState({ thumbs: '', webp: '' })
|
||||||
|
// Die Ordnerliste brauchen zwei Karten hier — geholt wird sie einmal.
|
||||||
|
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
apiFolders.folders().then(setOrdnerDaten).catch(() => {
|
||||||
|
/* die Auswahl bleibt dann bei "(überall)" — kein Grund zu lärmen */
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
|
||||||
|
const [sucht, setSucht] = useState(false)
|
||||||
|
const [handelt, setHandelt] = useState(false)
|
||||||
|
const [fragt, setFragt] = useState<'aufnehmen' | 'aufraeumen' | null>(null)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lebt = true
|
||||||
|
api.stats()
|
||||||
|
.then((s) => lebt && setStats(s))
|
||||||
|
.catch(
|
||||||
|
(err) =>
|
||||||
|
lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'),
|
||||||
|
)
|
||||||
|
.finally(() => lebt && setLaedt(false))
|
||||||
|
return () => {
|
||||||
|
lebt = false
|
||||||
|
}
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
if (laedt) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!stats) return <Leer text="Die Statistik ließ sich nicht laden." />
|
||||||
|
|
||||||
|
const groesster = Math.max(1, ...stats.folders.map((f) => f.bytes))
|
||||||
|
|
||||||
|
/** Eine Wartungsarbeit, die in RUNDEN läuft — für beide Knöpfe hier.
|
||||||
|
*
|
||||||
|
* WARUM ÜBERHAUPT RUNDEN: der Server rechnet höchstens ein paar hundert
|
||||||
|
* Stück je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Der erste Lauf
|
||||||
|
* über 3861 Dateien lief genau da hinein — der Anwender sah einen 504,
|
||||||
|
* während die Arbeit im Hintergrund weiterlief. Ein Knopf, der Erfolg als
|
||||||
|
* Fehler meldet, ist schlimmer als einer ohne Rückmeldung.
|
||||||
|
*
|
||||||
|
* Der Umbau kostet nichts und bringt etwas: statt eines Rädchens, das
|
||||||
|
* minutenlang schweigt, steht eine Zahl da, die sich bewegt.
|
||||||
|
*
|
||||||
|
* Ein Fehler bricht ab, statt weiterzuschleifen — sonst rennt die Schleife
|
||||||
|
* gegen dieselbe Wand und wiederholt dieselbe Meldung. */
|
||||||
|
async function runden<T extends { erzeugt: number; offen: number; fertig: boolean }>(
|
||||||
|
setzeWartet: (b: boolean) => void,
|
||||||
|
welche: 'thumbs' | 'webp',
|
||||||
|
lauf: () => Promise<T>,
|
||||||
|
fertigText: (erzeugt: number, letzte: T) => string,
|
||||||
|
) {
|
||||||
|
setzeWartet(true)
|
||||||
|
let erzeugt = 0
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
let r: T
|
||||||
|
try {
|
||||||
|
r = await lauf()
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
erzeugt += r.erzeugt
|
||||||
|
|
||||||
|
if (r.fertig) {
|
||||||
|
note('ok', fertigText(erzeugt, r))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setFortschritt((alt) => ({
|
||||||
|
...alt,
|
||||||
|
[welche]: `${erzeugt} erzeugt, noch ${r.offen} offen …`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
setzeWartet(false)
|
||||||
|
setFortschritt((alt) => ({ ...alt, [welche]: '' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Zahl kind={<HardDrive size={16} />} was="Dateien" wert={String(stats.files)} />
|
||||||
|
<Zahl kind={<Folder size={16} />} was="Belegt" wert={bytes(stats.bytes)} />
|
||||||
|
<Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpeicherKarten />
|
||||||
|
|
||||||
|
<Karte titel="Öffentliche Adresse">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm">
|
||||||
|
{stats.publicUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => void kopieren(stats.publicUrl, 'url')}
|
||||||
|
className="rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label="Adresse kopieren"
|
||||||
|
>
|
||||||
|
{kopiert === 'url' ? (
|
||||||
|
<Check size={14} className="text-gut" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-schrift-leise">
|
||||||
|
Dateien liegen darunter unter <code className="font-mono">/f/</code>. Diese
|
||||||
|
Adresse steht nirgends in der Datenbank — sie wird bei jeder Antwort aus{' '}
|
||||||
|
<code className="font-mono">PUBLIC_URL</code> gebaut, ein Umzug ist deshalb
|
||||||
|
eine geänderte Variable.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<FehlendeKarte />
|
||||||
|
|
||||||
|
<ErsetzenKarte ordnerDaten={ordnerDaten} fertig={() => setOrdnerDaten(null)} />
|
||||||
|
|
||||||
|
<Karte titel="Wartung">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
wartet={zieht}
|
||||||
|
onClick={() =>
|
||||||
|
void runden(
|
||||||
|
setZieht,
|
||||||
|
'thumbs',
|
||||||
|
api.thumbsNachziehen,
|
||||||
|
(erzeugt, r) =>
|
||||||
|
(erzeugt === 0
|
||||||
|
? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.`
|
||||||
|
: `${erzeugt} Vorschauen erzeugt.`) +
|
||||||
|
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Images size={15} />
|
||||||
|
Fehlende Vorschaubilder erzeugen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fortschritt.thumbs ? (
|
||||||
|
<span className="text-sm tabular-nums">{fortschritt.thumbs}</span>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Die Galerie lädt Vorschauen statt Vollbilder. Bei Video ist es ein
|
||||||
|
Standbild aus dem Clip, bei Ton das eingebettete Titelbild — und
|
||||||
|
dass die meisten MP3 keines haben, ist kein Fehler.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-rand pt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
wartet={spart}
|
||||||
|
onClick={() =>
|
||||||
|
void runden(
|
||||||
|
setSpart,
|
||||||
|
'webp',
|
||||||
|
api.webpNachziehen,
|
||||||
|
(erzeugt, r) =>
|
||||||
|
(erzeugt === 0
|
||||||
|
? 'Nichts zu tun — alle Fassungen sind schon da.'
|
||||||
|
: `${erzeugt} Fassungen erzeugt.`) +
|
||||||
|
` Insgesamt ${bytes(r.vorher)} → ${bytes(r.nachher)}, spart ${bytes(r.gespart)}.` +
|
||||||
|
(r.lohntNicht ? ` Bei ${r.lohntNicht} lohnt WebP nicht.` : '') +
|
||||||
|
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<FileArchive size={15} />
|
||||||
|
Sparsame Fassungen erzeugen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fortschritt.webp ? (
|
||||||
|
<span className="text-sm tabular-nums">{fortschritt.webp}</span>
|
||||||
|
) : (
|
||||||
|
<p className="max-w-md text-xs text-schrift-leise">
|
||||||
|
Legt neben jedes PNG und JPEG eine WebP-Fassung. Die Adresse
|
||||||
|
ändert sich <strong className="text-schrift">nicht</strong> — wer
|
||||||
|
WebP versteht, bekommt die kleine Datei, alle anderen unverändert
|
||||||
|
das Original. Bei vielen Bildern läuft das in mehreren Runden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-rand pt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
wartet={sucht}
|
||||||
|
onClick={async () => {
|
||||||
|
setSucht(true)
|
||||||
|
const v = await run(
|
||||||
|
() => api.verwaiste(),
|
||||||
|
(r) =>
|
||||||
|
r.dateiOhneDatensatz.anzahl +
|
||||||
|
r.datensatzOhneDatei.anzahl +
|
||||||
|
r.abgeleitetOhneBild.anzahl ===
|
||||||
|
0
|
||||||
|
? 'Alles passt zusammen.'
|
||||||
|
: 'Abweichungen gefunden — siehe unten.',
|
||||||
|
)
|
||||||
|
setSucht(false)
|
||||||
|
if (v) setVerwaiste(v)
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<SearchCheck size={15} />
|
||||||
|
Verwaiste suchen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Dateien ohne Datensatz, Datensätze ohne Datei, Abgeleitetes ohne
|
||||||
|
Bild. Sucht nur — verändert nichts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verwaiste && (
|
||||||
|
<div className="mt-3 flex flex-col gap-2 text-sm">
|
||||||
|
<Befund
|
||||||
|
titel="Dateien ohne Datensatz"
|
||||||
|
daten={verwaiste.dateiOhneDatensatz}
|
||||||
|
hinweis="Liegen da und werden ausgeliefert, fehlen aber in der Liste."
|
||||||
|
knopf={
|
||||||
|
verwaiste.dateiOhneDatensatz.anzahl > 0 ? (
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
wartet={handelt}
|
||||||
|
onClick={() => setFragt('aufnehmen')}
|
||||||
|
kind="In die Liste aufnehmen"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Befund
|
||||||
|
titel="Datensätze ohne Datei"
|
||||||
|
daten={verwaiste.datensatzOhneDatei}
|
||||||
|
hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild."
|
||||||
|
/>
|
||||||
|
<Befund
|
||||||
|
titel="Abgeleitetes ohne Bild"
|
||||||
|
daten={verwaiste.abgeleitetOhneBild}
|
||||||
|
hinweis="Vorschauen und sparsame Fassungen, deren Original weg ist. Belegen Platz, sonst harmlos."
|
||||||
|
/>
|
||||||
|
{(verwaiste.datensatzOhneDatei.anzahl > 0 ||
|
||||||
|
verwaiste.abgeleitetOhneBild.anzahl > 0) && (
|
||||||
|
<div>
|
||||||
|
<Knopf
|
||||||
|
art="gefahr"
|
||||||
|
wartet={handelt}
|
||||||
|
onClick={() => setFragt('aufraeumen')}
|
||||||
|
kind="Leere Datensätze und Abgeleitetes entfernen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={fragt !== null}
|
||||||
|
titel={fragt === 'aufnehmen' ? 'In die Liste aufnehmen?' : 'Aufräumen?'}
|
||||||
|
bestaetigung={fragt === 'aufnehmen' ? 'Aufnehmen' : 'Entfernen'}
|
||||||
|
wartet={handelt}
|
||||||
|
onNein={() => setFragt(null)}
|
||||||
|
onJa={async () => {
|
||||||
|
if (!fragt) return
|
||||||
|
setHandelt(true)
|
||||||
|
await run(
|
||||||
|
() => api.verwaisteBehandeln(fragt),
|
||||||
|
(r) =>
|
||||||
|
fragt === 'aufnehmen'
|
||||||
|
? `${r.aufgenommen} Dateien in die Liste aufgenommen.`
|
||||||
|
: `${r.datensaetze} Datensätze und ${r.vorschauen} Vorschauen entfernt.`,
|
||||||
|
)
|
||||||
|
setHandelt(false)
|
||||||
|
setFragt(null)
|
||||||
|
const v = await run(() => api.verwaiste(), 'Neu gezählt.')
|
||||||
|
if (v) setVerwaiste(v)
|
||||||
|
}}
|
||||||
|
text={
|
||||||
|
fragt === 'aufnehmen' ? (
|
||||||
|
<p>
|
||||||
|
Die gefundenen Dateien bekommen einen Datensatz und erscheinen in der
|
||||||
|
Galerie. Es wird nichts gelöscht und nichts überschrieben.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Datensätze, deren Datei fehlt, werden entfernt — die Dateien sind
|
||||||
|
ohnehin weg. Verwaiste Vorschauen werden gelöscht; sie lassen sich
|
||||||
|
jederzeit neu rechnen.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte titel="Größte Ordner">
|
||||||
|
{stats.folders.length === 0 ? (
|
||||||
|
<Leer text="Noch nichts abgelegt." />
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-schrift-leise">
|
||||||
|
<th className="pb-2 font-medium">Ordner</th>
|
||||||
|
<th className="pb-2 text-right font-medium">Dateien</th>
|
||||||
|
<th className="pb-2 text-right font-medium">Belegt</th>
|
||||||
|
<th className="w-1/3 pb-2 pl-4 font-medium">Anteil</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.folders.map((f) => (
|
||||||
|
<tr key={f.folder} className="border-t border-rand">
|
||||||
|
<td className="py-2 font-mono">{f.folder}</td>
|
||||||
|
<td className="py-2 text-right text-schrift-leise">
|
||||||
|
{f.files}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">{bytes(f.bytes)}</td>
|
||||||
|
<td className="py-2 pl-4">
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-grund">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-akzent"
|
||||||
|
style={{
|
||||||
|
width: `${(f.bytes / groesster) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Befund({
|
||||||
|
titel,
|
||||||
|
daten,
|
||||||
|
hinweis,
|
||||||
|
knopf,
|
||||||
|
}: {
|
||||||
|
titel: string
|
||||||
|
daten: { anzahl: number; beispiele: string[] }
|
||||||
|
hinweis: string
|
||||||
|
knopf?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-rand bg-grund p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={daten.anzahl > 0 ? 'font-medium text-warn' : 'text-schrift-leise'}>
|
||||||
|
{daten.anzahl}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{titel}</span>
|
||||||
|
<span className="flex-1 text-xs text-schrift-leise">{hinweis}</span>
|
||||||
|
{knopf}
|
||||||
|
</div>
|
||||||
|
{daten.beispiele.length > 0 && (
|
||||||
|
<ul className="mt-2 max-h-28 overflow-auto font-mono text-[11px] text-schrift-leise">
|
||||||
|
{daten.beispiele.map((b) => (
|
||||||
|
<li key={b}>{b}</li>
|
||||||
|
))}
|
||||||
|
{daten.anzahl > daten.beispiele.length && (
|
||||||
|
<li>… und {daten.anzahl - daten.beispiele.length} weitere</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rand bg-flaeche p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-schrift-leise">
|
||||||
|
{kind}
|
||||||
|
{was}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{wert}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Folder,
|
||||||
|
FolderInput,
|
||||||
|
HardDrive,
|
||||||
|
Images,
|
||||||
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
TrendingUp,
|
||||||
|
Upload,
|
||||||
|
PenLine,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Karte, Leer, useKopieren } from '../components/Bausteine'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
|
import { useAuffrischen } from '../auffrischen'
|
||||||
|
import { gehZu } from '../router'
|
||||||
|
|
||||||
|
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
|
||||||
|
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
|
||||||
|
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
|
||||||
|
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
|
||||||
|
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
|
||||||
|
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich.
|
||||||
|
*
|
||||||
|
* Bisher landete man in der Galerie — mit 228 Kacheln und ohne Antwort auf
|
||||||
|
* die erste Frage, die man beim Öffnen hat: läuft alles? Diese Seite
|
||||||
|
* beantwortet sie in einem Blick und verlinkt den Rest. */
|
||||||
|
export function Start() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const user = useStore((s) => s.user)
|
||||||
|
const [daten, setDaten] = useState<Uebersicht | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
const laden = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDaten(await api.uebersicht())
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}, [note])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
}, [laden])
|
||||||
|
|
||||||
|
// Hier zieht nichts weg: die Seite hat keine Auswahl und kein offenes
|
||||||
|
// Fenster, sie darf sich jederzeit erneuern.
|
||||||
|
useAuffrischen({ laden: () => void laden(), blockiert: false })
|
||||||
|
|
||||||
|
if (laedt) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!daten) return <Leer text="Die Übersicht ließ sich nicht laden." />
|
||||||
|
|
||||||
|
// Die eingetragenen Vorlagen — und solange keine da ist, die eine, die
|
||||||
|
// dieses Projekt ohnehin benutzt. Eine leere Karte waere hier nur ein
|
||||||
|
// Loch.
|
||||||
|
const vorlagen =
|
||||||
|
daten.vorlagen.length > 0
|
||||||
|
? daten.vorlagen
|
||||||
|
: [{ name: 'Fahrzeuge', muster: 'vehicles/{model}.webp' }]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={38} />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
Hallo {user?.discordName ?? user?.username ?? ''}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
{daten.files} Dateien · {bytes(daten.bytes)} belegt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel
|
||||||
|
kind={<Images size={16} />}
|
||||||
|
was="Dateien"
|
||||||
|
wert={String(daten.files)}
|
||||||
|
onClick={() => gehZu('/galerie')}
|
||||||
|
/>
|
||||||
|
<Kachel kind={<HardDrive size={16} />} was="Belegt" wert={bytes(daten.bytes)} />
|
||||||
|
<Kachel
|
||||||
|
kind={<TrendingUp size={16} />}
|
||||||
|
was="Neu heute"
|
||||||
|
wert={String(daten.heuteHoch)}
|
||||||
|
unten={`${daten.wocheHoch} in sieben Tagen`}
|
||||||
|
onClick={() => gehZu('/verlauf')}
|
||||||
|
/>
|
||||||
|
<Kachel
|
||||||
|
kind={<KeyRound size={16} />}
|
||||||
|
was="Token"
|
||||||
|
wert={String(daten.tokens.length)}
|
||||||
|
onClick={() => gehZu('/tokens')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */}
|
||||||
|
<Karte
|
||||||
|
titel={vorlagen.length === 1 ? 'Adressvorlage' : 'Adressvorlagen'}
|
||||||
|
rechts={
|
||||||
|
<button
|
||||||
|
onClick={() => gehZu('/einstellungen')}
|
||||||
|
className="text-xs text-schrift-leise hover:text-schrift"
|
||||||
|
>
|
||||||
|
verwalten
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{vorlagen.map((v, i) => {
|
||||||
|
const voll = `${daten.publicUrl}${daten.filePrefix}/${v.muster}`
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
|
||||||
|
{v.name}
|
||||||
|
</span>
|
||||||
|
<code className="min-w-0 flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
|
||||||
|
{voll}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await kopieren(voll, 'v' + i)))
|
||||||
|
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label={`${v.name} kopieren`}
|
||||||
|
>
|
||||||
|
{kopiert === 'v' + i ? (
|
||||||
|
<Check size={14} className="text-gut" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Karte titel="Zuletzt hochgeladen">
|
||||||
|
{daten.bilder.length === 0 ? (
|
||||||
|
<Leer text="Noch nichts da." />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
|
||||||
|
{daten.bilder.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => gehZu('/galerie')}
|
||||||
|
title={b.path}
|
||||||
|
className="aspect-square overflow-hidden rounded border border-rand bg-grund"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={vorschauUrl(b.path)}
|
||||||
|
onError={(e) => {
|
||||||
|
const i = e.currentTarget
|
||||||
|
if (i.dataset.gefallen) return
|
||||||
|
i.dataset.gefallen = 'ja'
|
||||||
|
i.src = b.url
|
||||||
|
}}
|
||||||
|
alt={b.path}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Zuletzt passiert">
|
||||||
|
{daten.letzte.length === 0 ? (
|
||||||
|
<Leer text="Noch nichts passiert." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{daten.letzte.map((e) => {
|
||||||
|
const a = arten[e.kind]
|
||||||
|
const Kind = a.kind
|
||||||
|
return (
|
||||||
|
<li key={e.id} className="flex items-center gap-2">
|
||||||
|
<Kind size={13} className={`shrink-0 ${a.farbe}`} />
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate font-mono text-xs"
|
||||||
|
title={e.path}
|
||||||
|
>
|
||||||
|
{e.path}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-schrift-leise">
|
||||||
|
{zeit(e.at)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Karte titel="Größte Ordner" rechts={<Folder size={14} className="text-schrift-leise" />}>
|
||||||
|
{daten.ordner.length === 0 ? (
|
||||||
|
<Leer text="Noch nichts abgelegt." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{daten.ordner.map((o) => (
|
||||||
|
<li key={o.folder} className="flex items-center gap-2">
|
||||||
|
<span className="flex-1 truncate font-mono text-xs">
|
||||||
|
{o.folder}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-schrift-leise">
|
||||||
|
{o.files} Dateien
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-right text-xs">
|
||||||
|
{bytes(o.bytes)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Zugänge">
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{daten.tokens.map((t) => (
|
||||||
|
<li key={t.id} className="flex items-center gap-2">
|
||||||
|
<KeyRound size={13} className="shrink-0 text-schrift-leise" />
|
||||||
|
<span className="flex-1 truncate">{t.name}</span>
|
||||||
|
<span className="text-xs text-schrift-leise">
|
||||||
|
{t.last_used_at ? `zuletzt ${zeit(t.last_used_at)}` : 'nie benutzt'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="mt-1 flex items-center gap-2 border-t border-rand pt-2 text-xs">
|
||||||
|
<ShieldCheck
|
||||||
|
size={13}
|
||||||
|
className={daten.discord ? 'text-gut' : 'text-schrift-leise'}
|
||||||
|
/>
|
||||||
|
Discord-Anmeldung {daten.discord ? 'eingerichtet' : 'nicht eingerichtet'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kachel({
|
||||||
|
kind,
|
||||||
|
was,
|
||||||
|
wert,
|
||||||
|
unten,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
kind: React.ReactNode
|
||||||
|
was: string
|
||||||
|
wert: string
|
||||||
|
unten?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}) {
|
||||||
|
const inhalt = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-schrift-leise">
|
||||||
|
{kind}
|
||||||
|
{was}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{wert}</p>
|
||||||
|
{unten && <p className="text-[11px] text-schrift-leise">{unten}</p>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nur was wirklich irgendwohin fuehrt, wird ein Knopf. Eine Kachel, die
|
||||||
|
// aussieht wie ein Knopf und nichts tut, ist der Anfang des Misstrauens.
|
||||||
|
return onClick ? (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="rounded-lg border border-rand bg-flaeche p-4 text-left transition-colors hover:border-akzent"
|
||||||
|
>
|
||||||
|
{inhalt}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-rand bg-flaeche p-4">{inhalt}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { AlertTriangle, Check, Copy, KeyRound, Loader2, PenLine, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { api, type NewToken, type TokenRow } from '../api'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||||
|
import { TokenBearbeiten } from '../components/TokenBearbeiten'
|
||||||
|
|
||||||
|
export function Tokens() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [rows, setRows] = useState<TokenRow[] | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [prefix, setPrefix] = useState('')
|
||||||
|
const [darfLoeschen, setDarfLoeschen] = useState(false)
|
||||||
|
const [maxMb, setMaxMb] = useState('')
|
||||||
|
const [quotaMb, setQuotaMb] = useState('')
|
||||||
|
const [maxKante, setMaxKante] = useState('')
|
||||||
|
const [gueltigTage, setGueltigTage] = useState('')
|
||||||
|
const [arten, setArten] = useState<string[]>([])
|
||||||
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
|
|
||||||
|
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
||||||
|
const [weg, setWeg] = useState<TokenRow | null>(null)
|
||||||
|
const [bearbeiten, setBearbeiten] = useState<TokenRow | null>(null)
|
||||||
|
const [loescht, setLoescht] = useState(false)
|
||||||
|
const [ohneToken, setOhneToken] = useState<{ dateien: number; bytes: number } | null>(null)
|
||||||
|
const { kopiert, kopieren } = useKopieren()
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
setLaedt(true)
|
||||||
|
try {
|
||||||
|
const antwort = await api.tokens()
|
||||||
|
setRows(antwort.items)
|
||||||
|
setOhneToken(antwort.ohneToken)
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function anlegen(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLegtAn(true)
|
||||||
|
const neu = await run(
|
||||||
|
() =>
|
||||||
|
api.createToken({
|
||||||
|
name: name.trim(),
|
||||||
|
prefix: prefix.trim(),
|
||||||
|
canDelete: darfLoeschen,
|
||||||
|
maxMb: maxMb.trim(),
|
||||||
|
quotaMb: quotaMb.trim(),
|
||||||
|
maxKante: maxKante.trim(),
|
||||||
|
arten,
|
||||||
|
gueltigTage: gueltigTage.trim(),
|
||||||
|
}),
|
||||||
|
(t) => `Token "${t.name}" angelegt.`,
|
||||||
|
)
|
||||||
|
setLegtAn(false)
|
||||||
|
if (neu) {
|
||||||
|
setFrisch(neu)
|
||||||
|
setName('')
|
||||||
|
setPrefix('')
|
||||||
|
setDarfLoeschen(false)
|
||||||
|
setMaxMb('')
|
||||||
|
setGueltigTage('')
|
||||||
|
setArten([])
|
||||||
|
void laden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschen() {
|
||||||
|
if (!weg) return
|
||||||
|
setLoescht(true)
|
||||||
|
const ok = await run(() => api.deleteToken(weg.id), `Token "${weg.name}" gelöscht.`)
|
||||||
|
setLoescht(false)
|
||||||
|
setWeg(null)
|
||||||
|
if (ok) void laden()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Karte titel="Neuen Token anlegen">
|
||||||
|
<form onSubmit={anlegen} className="flex flex-col gap-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
placeholder="d4rk_photostudio"
|
||||||
|
hinweis="Nur zur Wiedererkennung in dieser Liste."
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Ordner (optional)"
|
||||||
|
value={prefix}
|
||||||
|
placeholder="vehicles, items"
|
||||||
|
hinweis="Nur unter diesen Ordnern darf der Token schreiben. Mehrere durch Komma. Leer = überall."
|
||||||
|
onChange={(e) => setPrefix(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
label="Größte Datei (MB, optional)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.5"
|
||||||
|
value={maxMb}
|
||||||
|
placeholder="leer = Grenze des Dienstes"
|
||||||
|
hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB."
|
||||||
|
onChange={(e) => setMaxMb(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Kontingent insgesamt (MB, optional)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="1"
|
||||||
|
value={quotaMb}
|
||||||
|
placeholder="leer = unbegrenzt"
|
||||||
|
hinweis="Die Grenze links gilt je Datei. Diese hier gilt für alles zusammen — ohne sie kann ein Token die Platte füllen, es braucht nur genug kleine Dateien."
|
||||||
|
onChange={(e) => setQuotaMb(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Bilder verkleinern auf (Pixel, optional)"
|
||||||
|
type="number"
|
||||||
|
min={16}
|
||||||
|
step="64"
|
||||||
|
value={maxKante}
|
||||||
|
placeholder="leer = ablegen wie es kommt"
|
||||||
|
hinweis="Größte Kantenlänge. Kleinere Bilder werden NICHT aufgeblasen. Spart Platz und Ladezeit — als Schutz braucht es das nicht."
|
||||||
|
onChange={(e) => setMaxKante(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
label="Gültig für (Tage, optional)"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={gueltigTage}
|
||||||
|
placeholder="leer = unbefristet"
|
||||||
|
hinweis="Danach antwortet der Dienst mit einer Meldung, die das sagt."
|
||||||
|
onChange={(e) => setGueltigTage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||||
|
Erlaubte Arten (optional)
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['bild', 'Bilder'],
|
||||||
|
['video', 'Video'],
|
||||||
|
['ton', 'Ton'],
|
||||||
|
['dokument', 'Dokumente'],
|
||||||
|
['andere', 'Anderes'],
|
||||||
|
] as const
|
||||||
|
).map(([wert, text]) => {
|
||||||
|
const an = arten.includes(wert)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={wert}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setArten((a) =>
|
||||||
|
an ? a.filter((x) => x !== wert) : [...a, wert],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
an
|
||||||
|
? 'border-akzent bg-akzent/15 text-schrift'
|
||||||
|
: 'border-rand text-schrift-leise hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 block text-xs text-schrift-leise">
|
||||||
|
Nichts gewählt heißt: alles erlaubt. Ein Token, der nur Bilder darf,
|
||||||
|
kann kein Video hochladen — auch wenn er abhandenkommt.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={darfLoeschen}
|
||||||
|
onChange={(e) => setDarfLoeschen(e.target.checked)}
|
||||||
|
className="mt-0.5 accent-akzent"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
darf löschen
|
||||||
|
<span className="block text-xs text-schrift-leise">
|
||||||
|
Ohne dieses Häkchen kann der Token nur schreiben. Ein Werkzeug,
|
||||||
|
das nur Bilder ablegt, braucht es nicht.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
type="submit"
|
||||||
|
wartet={legtAn}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<Plus size={15} />
|
||||||
|
Anlegen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Vorhandene Token">
|
||||||
|
{laedt ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="animate-spin text-schrift-leise" />
|
||||||
|
</div>
|
||||||
|
) : !rows || rows.length === 0 ? (
|
||||||
|
<Leer
|
||||||
|
kind={<KeyRound size={26} className="text-schrift-leise/50" />}
|
||||||
|
text="Noch kein Token angelegt."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-schrift-leise">
|
||||||
|
<th className="pb-2 font-medium">Name</th>
|
||||||
|
<th className="pb-2 font-medium">Ordner</th>
|
||||||
|
<th className="pb-2 font-medium">Rechte</th>
|
||||||
|
<th className="pb-2 font-medium">Grenzen</th>
|
||||||
|
{/* Was mit dem Token wirklich abgelegt wurde.
|
||||||
|
`token_id` steht seit dem ersten Tag in jeder
|
||||||
|
Zeile und war bis hierhin NIRGENDS zu sehen —
|
||||||
|
dabei ist es die Auskunft, die vor dem
|
||||||
|
Löschen zählt. */}
|
||||||
|
<th className="pb-2 text-right font-medium">Abgelegt</th>
|
||||||
|
<th className="pb-2 font-medium">Angelegt</th>
|
||||||
|
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
||||||
|
<th className="pb-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((t) => (
|
||||||
|
<tr
|
||||||
|
key={t.id}
|
||||||
|
className={`border-t border-rand ${t.abgelaufen ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="py-2 font-medium">
|
||||||
|
{t.name}
|
||||||
|
{t.abgelaufen && (
|
||||||
|
<span className="ml-2 rounded bg-schlecht/15 px-1.5 py-0.5 text-[11px] text-schlecht">
|
||||||
|
abgelaufen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 font-mono text-schrift-leise">
|
||||||
|
{t.prefix
|
||||||
|
? t.prefix
|
||||||
|
.split(',')
|
||||||
|
.map((p) => `${p.trim()}/`)
|
||||||
|
.join(' · ')
|
||||||
|
: '— (überall)'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{t.can_delete ? (
|
||||||
|
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
|
||||||
|
schreiben + löschen
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-schrift-leise">
|
||||||
|
nur schreiben
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-schrift-leise">
|
||||||
|
{[
|
||||||
|
t.max_bytes ? `max ${bytes(t.max_bytes)}/Datei` : null,
|
||||||
|
t.quota_bytes
|
||||||
|
? `${bytes(t.bytes)} von ${bytes(t.quota_bytes)}`
|
||||||
|
: null,
|
||||||
|
t.max_kante ? `max ${t.max_kante} px` : null,
|
||||||
|
t.arten ? t.arten.replaceAll(',', ', ') : null,
|
||||||
|
t.expires_at
|
||||||
|
? `bis ${zeit(t.expires_at).slice(0, 10)}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right whitespace-nowrap">
|
||||||
|
{t.dateien === 0 ? (
|
||||||
|
<span className="text-xs text-schrift-leise">—</span>
|
||||||
|
) : (
|
||||||
|
<span title={
|
||||||
|
t.zuletztAbgelegt
|
||||||
|
? `zuletzt ${zeit(t.zuletztAbgelegt)}`
|
||||||
|
: undefined
|
||||||
|
}>
|
||||||
|
<span className="tabular-nums">{t.dateien}</span>
|
||||||
|
<span className="text-xs text-schrift-leise">
|
||||||
|
{' '}
|
||||||
|
· {bytes(t.bytes)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-schrift-leise">
|
||||||
|
{zeit(t.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-schrift-leise">
|
||||||
|
{zeit(t.last_used_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setBearbeiten(t)}
|
||||||
|
className="mr-1 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
aria-label={`Token ${t.name} bearbeiten`}
|
||||||
|
title="Grenzen bearbeiten"
|
||||||
|
>
|
||||||
|
<PenLine size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setWeg(t)}
|
||||||
|
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||||
|
hover:bg-schlecht/15 hover:text-schlecht"
|
||||||
|
aria-label={`Token ${t.name} löschen`}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ohneToken && ohneToken.dateien > 0 && (
|
||||||
|
<p className="mt-3 border-t border-rand pt-3 text-xs text-schrift-leise">
|
||||||
|
Dazu kommen <strong className="text-schrift">{ohneToken.dateien}</strong>{' '}
|
||||||
|
Dateien ({bytes(ohneToken.bytes)}) <strong>ohne</strong> Token — aus dem
|
||||||
|
Dashboard hochgeladen. Dort gibt es eine Sitzung und keinen Token;
|
||||||
|
ohne diese Zeile ginge die Summe nicht auf.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{/* Der Klartext — genau einmal.
|
||||||
|
Deshalb ein Dialog, den man bewusst schließen muss, und kein
|
||||||
|
Zettel, der nach vier Sekunden verschwindet. */}
|
||||||
|
{frisch && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl">
|
||||||
|
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
|
||||||
|
<KeyRound size={16} className="text-akzent" />
|
||||||
|
Token „{frisch.name}" angelegt
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
<p className="mb-3 flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm text-warn">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<span className="text-schrift">
|
||||||
|
Dies ist das einzige Mal, dass der Token im Klartext zu sehen
|
||||||
|
ist. Danach steht nur noch sein Hash in der Datenbank — auch
|
||||||
|
der Dienst kann ihn nicht mehr zeigen.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-2 font-mono text-sm">
|
||||||
|
{frisch.token}
|
||||||
|
</code>
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
onClick={async () => {
|
||||||
|
const ok = await kopieren(frisch.token, 'neu')
|
||||||
|
if (!ok)
|
||||||
|
note(
|
||||||
|
'fehler',
|
||||||
|
'Die Zwischenablage ist gesperrt — bitte von Hand markieren.',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
kind={
|
||||||
|
kopiert === 'neu' ? (
|
||||||
|
<>
|
||||||
|
<Check size={15} />
|
||||||
|
kopiert
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={15} />
|
||||||
|
Kopieren
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-schrift-leise">
|
||||||
|
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
|
||||||
|
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-rand px-4 py-3">
|
||||||
|
<Knopf
|
||||||
|
onClick={() => setFrisch(null)}
|
||||||
|
kind="Habe ich mir notiert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bearbeiten && (
|
||||||
|
<TokenBearbeiten
|
||||||
|
token={bearbeiten}
|
||||||
|
onAbbruch={() => setBearbeiten(null)}
|
||||||
|
onFertig={() => {
|
||||||
|
setBearbeiten(null)
|
||||||
|
void laden()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Nachfrage
|
||||||
|
offen={weg !== null}
|
||||||
|
titel="Token löschen?"
|
||||||
|
bestaetigung="Token löschen"
|
||||||
|
wartet={loescht}
|
||||||
|
onNein={() => setWeg(null)}
|
||||||
|
onJa={() => void loeschen()}
|
||||||
|
text={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
„{weg?.name}" wird sofort ungültig. Was ihn benutzt — etwa der
|
||||||
|
Fotostudio — bekommt ab dann 401 und lädt nichts mehr hoch.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">Hochgeladene Dateien bleiben.</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
History,
|
||||||
|
Loader2,
|
||||||
|
PenLine,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
FolderInput,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api, type EventKind, type EventsSeite } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { bytes, zeit } from '../format'
|
||||||
|
import { Karte, Knopf, Leer } from '../components/Bausteine'
|
||||||
|
import { useAuffrischen } from '../auffrischen'
|
||||||
|
import { useMerker } from '../merker'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
|
import { VerwaltungListe } from '../components/VerwaltungListe'
|
||||||
|
|
||||||
|
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
||||||
|
|
||||||
|
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
|
||||||
|
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
|
||||||
|
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
|
||||||
|
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
|
||||||
|
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
|
||||||
|
// Eigene Art, obwohl es technisch ein Verschieben ist: verschoben heisst
|
||||||
|
// „liegt woanders", umbenannt heisst „heisst anders, liegt noch da". Wer
|
||||||
|
// den Verlauf aufschlägt, will genau diesen Unterschied wissen.
|
||||||
|
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Verlauf() {
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
const [daten, setDaten] = useState<EventsSeite | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(true)
|
||||||
|
const [seite, setSeite] = useState(0)
|
||||||
|
const [art, setArt] = useState<EventKind | ''>('')
|
||||||
|
const [wer, setWer] = useState('')
|
||||||
|
const [proSeite, setProSeite] = useMerker('verlauf.proSeite', 50)
|
||||||
|
const [suche, setSuche] = useState('')
|
||||||
|
// Zwei Verläufe, eine Seite. Der eine beantwortet „was ist mit den Dateien
|
||||||
|
// passiert", der andere „wer hat daran gedreht" — dieselbe Frage aus zwei
|
||||||
|
// Richtungen, und beide gehören dorthin, wo man Verlauf sucht.
|
||||||
|
const [reiter, setReiter] = useState<'dateien' | 'verwaltung'>('dateien')
|
||||||
|
|
||||||
|
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
|
||||||
|
// "vehicles" eine Anfrage je Buchstabe raus.
|
||||||
|
const [sucheAktiv, setSucheAktiv] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setSucheAktiv(suche)
|
||||||
|
setSeite(0)
|
||||||
|
}, 250)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [suche])
|
||||||
|
|
||||||
|
const laden = useCallback(async () => {
|
||||||
|
setLaedt(true)
|
||||||
|
try {
|
||||||
|
setDaten(await api.events(art, wer, sucheAktiv, proSeite, seite * proSeite))
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}, [art, wer, sucheAktiv, proSeite, seite, note])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void laden()
|
||||||
|
}, [laden])
|
||||||
|
|
||||||
|
// Im Verlauf ist das Mitlesen der eigentliche Zweck: waehrend ein
|
||||||
|
// Serienlauf laeuft, sieht man hier zu. Blockiert wird nur, wenn man
|
||||||
|
// geblaettert hat -- eine Seite drei, die unter den Haenden zu Seite eins
|
||||||
|
// wird, ist aergerlich.
|
||||||
|
const frisch = useAuffrischen({ laden: () => void laden(), blockiert: seite > 0 })
|
||||||
|
|
||||||
|
const items = daten?.items ?? []
|
||||||
|
// Die Bilder stehen bei den Akteuren und nicht an jedem Eintrag — sonst
|
||||||
|
// schleppte jede der 200 Zeilen dieselbe Adresse mit.
|
||||||
|
const bilder = new Map((daten?.akteure ?? []).map((a) => [a.actor, a.avatar]))
|
||||||
|
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
|
||||||
|
|
||||||
|
if (reiter === 'verwaltung') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Reiter reiter={reiter} setReiter={setReiter} />
|
||||||
|
<Karte>
|
||||||
|
<VerwaltungListe />
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Reiter reiter={reiter} setReiter={setReiter} />
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Filter an={art === ''} onClick={() => { setArt(''); setSeite(0) }} text="Alles" />
|
||||||
|
{(Object.keys(arten) as EventKind[]).map((k) => (
|
||||||
|
<Filter
|
||||||
|
key={k}
|
||||||
|
an={art === k}
|
||||||
|
onClick={() => { setArt(k); setSeite(0) }}
|
||||||
|
text={arten[k].text}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="mx-1 h-4 w-px bg-rand" />
|
||||||
|
|
||||||
|
<Filter an={wer === ''} onClick={() => { setWer(''); setSeite(0) }} text="alle" />
|
||||||
|
{daten?.akteure.map((a) => (
|
||||||
|
<Filter
|
||||||
|
key={a.actor}
|
||||||
|
an={wer === a.actor}
|
||||||
|
onClick={() => { setWer(a.actor); setSeite(0) }}
|
||||||
|
text={`${a.actor} (${a.n})`}
|
||||||
|
kind={
|
||||||
|
a.actor_kind === 'unbekannt' ? null : (
|
||||||
|
<Avatar
|
||||||
|
name={a.actor}
|
||||||
|
url={a.avatar}
|
||||||
|
token={a.actor_kind === 'token'}
|
||||||
|
groesse={16}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{frisch.wartet && (
|
||||||
|
<Knopf
|
||||||
|
art="haupt"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={frisch.jetztLaden}
|
||||||
|
kind={
|
||||||
|
<>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Neues da — laden
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise ${
|
||||||
|
frisch.wartet ? '' : 'ml-auto'
|
||||||
|
}`}
|
||||||
|
title="Alle fünf Sekunden nachsehen, ob etwas dazugekommen ist"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={frisch.an}
|
||||||
|
onChange={(e) => frisch.setAn(e.target.checked)}
|
||||||
|
className="accent-akzent"
|
||||||
|
/>
|
||||||
|
automatisch
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span className="text-sm text-schrift-leise">
|
||||||
|
{laedt ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
`${daten?.total ?? 0} Einträge`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-56 flex-1">
|
||||||
|
<Search
|
||||||
|
size={15}
|
||||||
|
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={suche}
|
||||||
|
onChange={(e) => setSuche(e.target.value)}
|
||||||
|
placeholder="Pfad durchsuchen, z. B. vehicles/adder"
|
||||||
|
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
|
||||||
|
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
|
||||||
|
focus:outline-none"
|
||||||
|
/>
|
||||||
|
{suche && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSuche('')}
|
||||||
|
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
|
||||||
|
aria-label="Suche leeren"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-schrift-leise">
|
||||||
|
pro Seite
|
||||||
|
<select
|
||||||
|
value={proSeite}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProSeite(Number(e.target.value))
|
||||||
|
setSeite(0)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
|
||||||
|
text-schrift focus:border-akzent focus:outline-none"
|
||||||
|
>
|
||||||
|
{SEITENGROESSEN.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Karte>
|
||||||
|
{items.length === 0 && !laedt ? (
|
||||||
|
<Leer
|
||||||
|
kind={<History size={26} className="text-schrift-leise/50" />}
|
||||||
|
text="Für diese Auswahl gibt es nichts."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full table-fixed text-sm">
|
||||||
|
{/* Die Spaltenbreiten stehen HIER und nicht verteilt an
|
||||||
|
den Zellen: vorher hatten Kopf und Koerper
|
||||||
|
verschiedene Abstaende, und der Pfad wurde
|
||||||
|
abgeschnitten, obwohl daneben Platz frei war. */}
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-40" />
|
||||||
|
<col className="w-32" />
|
||||||
|
<col />
|
||||||
|
<col className="w-24" />
|
||||||
|
<col className="w-44" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-schrift-leise">
|
||||||
|
<th className="px-3 pb-2 font-medium">Wann</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Was</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Pfad</th>
|
||||||
|
<th className="px-3 pb-2 text-right font-medium">Größe</th>
|
||||||
|
<th className="px-3 pb-2 font-medium">Von wem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((e) => {
|
||||||
|
const a = arten[e.kind]
|
||||||
|
const Kind = a.kind
|
||||||
|
return (
|
||||||
|
<tr key={e.id} className="border-t border-rand">
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
|
||||||
|
{zeit(e.at)}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-1.5 whitespace-nowrap ${a.farbe}`}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Kind size={13} />
|
||||||
|
{a.text}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="max-w-0 truncate px-3 py-1.5 font-mono text-xs"
|
||||||
|
title={e.von ? `${e.von} → ${e.path}` : e.path}
|
||||||
|
>
|
||||||
|
{e.von && (
|
||||||
|
<span className="text-schrift-leise">
|
||||||
|
{e.von} →{' '}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{e.path}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
|
||||||
|
{e.size === null ? '—' : bytes(e.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{e.actor_kind !== 'unbekannt' && (
|
||||||
|
<Avatar
|
||||||
|
name={e.actor}
|
||||||
|
url={bilder.get(e.actor)}
|
||||||
|
token={e.actor_kind === 'token'}
|
||||||
|
groesse={18}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
e.actor_kind === 'unbekannt'
|
||||||
|
? 'text-schrift-leise italic'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{e.actor}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{seiten > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
disabled={seite === 0}
|
||||||
|
onClick={() => setSeite((s) => s - 1)}
|
||||||
|
kind={<ChevronLeft size={15} />}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-schrift-leise">
|
||||||
|
Seite {seite + 1} von {seiten}
|
||||||
|
</span>
|
||||||
|
<Knopf
|
||||||
|
disabled={seite + 1 >= seiten}
|
||||||
|
onClick={() => setSeite((s) => s + 1)}
|
||||||
|
kind={<ChevronRight size={15} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Was der Verlauf NICHT weiß, gehört dazugesagt. Ein Tab, der so
|
||||||
|
tut, als wüsste er alles, ist schlimmer als einer, der seine
|
||||||
|
Grenze nennt. */}
|
||||||
|
<p className="text-xs text-schrift-leise">
|
||||||
|
Aufgezeichnet wird seit {daten?.seit ? zeit(daten.seit) : '—'}. Einträge mit
|
||||||
|
„vor der Aufzeichnung" wurden nachträglich aus dem Bestand erschlossen — ihr
|
||||||
|
Zeitpunkt stimmt, aber wer sie abgelegt hat, weiß niemand mehr. Der Verlauf
|
||||||
|
behält ein halbes Jahr.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Filter({
|
||||||
|
an,
|
||||||
|
onClick,
|
||||||
|
text,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
an: boolean
|
||||||
|
onClick: () => void
|
||||||
|
text: string
|
||||||
|
kind?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
|
||||||
|
transition-colors ${
|
||||||
|
an
|
||||||
|
? 'border-akzent bg-akzent/15 text-schrift'
|
||||||
|
: 'border-rand text-schrift-leise hover:text-schrift'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kind}
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die zwei Verläufe nebeneinander.
|
||||||
|
*
|
||||||
|
* Als Reiter und nicht als zweite Seite im Menü: es ist beides ein Verlauf,
|
||||||
|
* und wer einen sucht, sucht nicht zwei Menüeinträge ab. */
|
||||||
|
function Reiter({
|
||||||
|
reiter,
|
||||||
|
setReiter,
|
||||||
|
}: {
|
||||||
|
reiter: 'dateien' | 'verwaltung'
|
||||||
|
setReiter: (r: 'dateien' | 'verwaltung') => void
|
||||||
|
}) {
|
||||||
|
// Jeder Knopf gibt Rueckmeldung, auch der erfolgreiche: ein Download, der
|
||||||
|
// nur im Browser-Balken auftaucht, sieht aus wie ein Knopf, der nichts tut.
|
||||||
|
const note = useStore((s) => s.note)
|
||||||
|
|
||||||
|
const stil = (an: boolean) =>
|
||||||
|
`rounded-md px-3 py-1.5 text-sm ${
|
||||||
|
an
|
||||||
|
? 'bg-flaeche-hoch text-schrift'
|
||||||
|
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
|
||||||
|
}`
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex gap-1 rounded-lg border border-rand bg-flaeche p-1">
|
||||||
|
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
|
||||||
|
Dateien
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={stil(reiter === 'verwaltung')}
|
||||||
|
onClick={() => setReiter('verwaltung')}
|
||||||
|
>
|
||||||
|
Verwaltung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Der Knopf holt IMMER den Verlauf, der gerade offen ist. Zwei
|
||||||
|
Knoepfe nebeneinander waeren die Frage „welchen von beiden?" an
|
||||||
|
einer Stelle, wo die Antwort schon auf dem Bildschirm steht. */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Ueber einen Link und nicht ueber fetch: so bekommt die
|
||||||
|
// Datei den Namen aus dem Content-Disposition und landet im
|
||||||
|
// Download-Ordner statt im Speicher des Browsers.
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/api/dash/verlauf/export?was=${reiter}`
|
||||||
|
a.rel = 'noreferrer'
|
||||||
|
a.click()
|
||||||
|
note('ok', `Der ${reiter === 'dateien' ? 'Datei' : 'Verwaltungs'}-Verlauf wird heruntergeladen.`)
|
||||||
|
}}
|
||||||
|
title="Als CSV herunterladen — Semikolon und CRLF, damit Excel es direkt aufmacht"
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-rand px-3 py-1.5
|
||||||
|
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// Ein winziger Router über die History-API statt einer Bibliothek.
|
||||||
|
//
|
||||||
|
// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut
|
||||||
|
// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein
|
||||||
|
// Neuladen auf /tokens im Router landet und nicht im 404.
|
||||||
|
//
|
||||||
|
// `gehZu` steht hier als MODULFUNKTION und nicht als Eigenschaft der Seiten.
|
||||||
|
// Der Unterschied ist mehr als Geschmack: sobald eine einzige Seite eine
|
||||||
|
// Navigationsfunktion durchgereicht bekäme, hätten alle Seiten verschiedene
|
||||||
|
// Signaturen — und die gemeinsame Liste in App.tsx ginge nicht mehr. Genau
|
||||||
|
// daran ist der erste Versuch gescheitert.
|
||||||
|
|
||||||
|
/** Woanders hin, ohne die Seite neu zu laden. */
|
||||||
|
export function gehZu(pfad: string) {
|
||||||
|
if (window.location.pathname === pfad) return
|
||||||
|
window.history.pushState(null, '', pfad)
|
||||||
|
// pushState löst KEIN popstate aus — das tut nur der Zurück-Knopf. Ohne
|
||||||
|
// dieses Ereignis bliebe die Ansicht stehen, obwohl die Adresse wechselt.
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePfad(): string {
|
||||||
|
const [pfad, setPfad] = useState(window.location.pathname)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const zurueck = () => setPfad(window.location.pathname)
|
||||||
|
window.addEventListener('popstate', zurueck)
|
||||||
|
return () => window.removeEventListener('popstate', zurueck)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return pfad
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { api, ApiError, type User } from './api'
|
||||||
|
|
||||||
|
// -- Rückmeldungen -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// DIE EINE REGEL: jeder Knopf gibt Rückmeldung, auch der erfolgreiche. Deshalb
|
||||||
|
// steht die Meldungsliste hier im Laden und nicht in irgendeiner Seite — sie
|
||||||
|
// gehört zu jeder von ihnen.
|
||||||
|
|
||||||
|
export type NoteKind = 'ok' | 'fehler' | 'hinweis'
|
||||||
|
export type Note = { id: number; kind: NoteKind; text: string }
|
||||||
|
|
||||||
|
let nextNoteId = 1
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
user: User | null
|
||||||
|
/** Solange true, weiß noch niemand, ob eine Sitzung besteht — in dieser
|
||||||
|
* Zeit darf weder das Anmeldeformular noch das Dashboard aufblitzen. */
|
||||||
|
checking: boolean
|
||||||
|
notes: Note[]
|
||||||
|
|
||||||
|
/** Wie viele Sitzungen dieses Konto offen hat. 0 = noch nicht geladen. */
|
||||||
|
sitzungen: number
|
||||||
|
checkSession: () => Promise<void>
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
|
||||||
|
note: (kind: NoteKind, text: string) => void
|
||||||
|
dismiss: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<State>((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
checking: true,
|
||||||
|
sitzungen: 0,
|
||||||
|
notes: [],
|
||||||
|
|
||||||
|
async checkSession() {
|
||||||
|
try {
|
||||||
|
const { user, sitzungen } = await api.me()
|
||||||
|
set({ user, sitzungen: sitzungen ?? 0, checking: false })
|
||||||
|
} catch (err) {
|
||||||
|
// 401 ist hier kein Fehler, sondern die Antwort "noch nicht
|
||||||
|
// angemeldet". Alles andere ist einer und wird gesagt.
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
set({ user: null, checking: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ user: null, checking: false })
|
||||||
|
get().note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(username, password) {
|
||||||
|
const { user } = await api.login(username, password)
|
||||||
|
set({ user })
|
||||||
|
get().note('ok', `Angemeldet als ${user.username}.`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await api.logout()
|
||||||
|
set({ user: null })
|
||||||
|
// Die Erfolgsmeldung kommt hier NICHT: der Aufrufer geht über `run`,
|
||||||
|
// und der meldet. Beides hieße zwei Zettel für einen Klick.
|
||||||
|
},
|
||||||
|
|
||||||
|
note(kind, text) {
|
||||||
|
const id = nextNoteId++
|
||||||
|
set((s) => ({ notes: [...s.notes, { id, kind, text }] }))
|
||||||
|
// Fehler bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der von
|
||||||
|
// allein verschwindet, ist ein Fehler, den niemand gelesen hat.
|
||||||
|
if (kind !== 'fehler') {
|
||||||
|
setTimeout(() => get().dismiss(id), 4000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss(id) {
|
||||||
|
set((s) => ({ notes: s.notes.filter((n) => n.id !== id) }))
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Eine Aktion ausführen und IMMER etwas dazu sagen.
|
||||||
|
*
|
||||||
|
* Der Erfolgstext wird mitgegeben, der Fehlertext kommt vom Dienst. Wer
|
||||||
|
* darüber geht, kann keinen stillen Knopf bauen — und genau darum geht es. */
|
||||||
|
export async function run<T>(
|
||||||
|
action: () => Promise<T>,
|
||||||
|
erfolg: string | ((result: T) => string),
|
||||||
|
): Promise<T | null> {
|
||||||
|
const { note } = useStore.getState()
|
||||||
|
try {
|
||||||
|
const result = await action()
|
||||||
|
note('ok', typeof erfolg === 'function' ? erfolg(result) : erfolg)
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||||
|
// Eine abgelaufene Sitzung ist kein gewöhnlicher Fehler: sie führt
|
||||||
|
// zurück aufs Anmeldeformular, statt jeden weiteren Knopf mit
|
||||||
|
// "nicht angemeldet" zu quittieren.
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
useStore.setState({ user: null })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
// EIN tsconfig, kein Composite-Projekt. Das spart die tsbuildinfo-Dateien
|
||||||
|
// und die Frage, warum vite.config.js plötzlich neben vite.config.ts liegt.
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwind from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// @vitejs/plugin-react ist auf ^4 festgenagelt, und das ist kein Zufall: die 6
|
||||||
|
// verlangt Vite ^8. Vite 5 bleibt (so steht es in der ROADMAP), und die 4 ist
|
||||||
|
// die letzte Fassung, die dazu passt — nachgesehen in den peerDependencies,
|
||||||
|
// nicht geraten.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwind()],
|
||||||
|
|
||||||
|
build: {
|
||||||
|
// Der Dienst liefert die gebaute Oberflaeche aus ../web aus; der Pfad
|
||||||
|
// steht in index.ts als `new URL('../web/', import.meta.url)`,
|
||||||
|
// ausgehend von dist/. Wer das hier aendert, aendert ihn dort mit.
|
||||||
|
outDir: '../web',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
// Im Entwicklungsbetrieb laeuft die Oberflaeche unter
|
||||||
|
// http://localhost:5173 und der Dienst im Container auf 9101. Ohne
|
||||||
|
// diesen Umweg gaebe es zwei Ursprunge und das Sitzungs-Cookie kaeme
|
||||||
|
// nie an.
|
||||||
|
//
|
||||||
|
// Dass das ueberhaupt geht, liegt an einer Ausnahme: der Container
|
||||||
|
// setzt das Cookie mit `Secure`, und ein Browser nimmt so eines ueber
|
||||||
|
// http NUR auf localhost an. Hier hilft die Ausnahme — beim Pruefen
|
||||||
|
// einer LAN-Adresse fuehrt genau sie in die Irre (siehe ROADMAP).
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://127.0.0.1:9101' },
|
||||||
|
'/f': { target: 'http://127.0.0.1:9101' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Holt die Sicherungen aus dem synchronisierten Nextcloud-Ordner in eine
|
||||||
|
# EIGENE Ablage — und bricht damit die Rückkopplung.
|
||||||
|
#
|
||||||
|
# LÄUFT AUF DEM HEIMRECHNER, nicht auf dem Server.
|
||||||
|
#
|
||||||
|
# pwsh -File heim-abholen.ps1 -Quelle "D:\Nextcloud\Sicherungen\d4rk_media" -Ziel "D:\Archiv\d4rk_media"
|
||||||
|
# pwsh -File heim-abholen.ps1 ... -Einrichten <- als tägliche Aufgabe eintragen
|
||||||
|
# pwsh -File heim-abholen.ps1 ... -Webhook "https://discord.com/api/webhooks/..."
|
||||||
|
#
|
||||||
|
# WOZU, wo der Nextcloud-Client doch schon synchronisiert.
|
||||||
|
#
|
||||||
|
# WEIL ER IN BEIDE RICHTUNGEN ABGLEICHT. Verschwinden die Archive auf dem
|
||||||
|
# Server — durch einen Verschlüsselungstrojaner, einen Fehlgriff, einen
|
||||||
|
# kaputten Auftrag —, löscht der Client sie auch hier. Der synchronisierte
|
||||||
|
# Ordner ist deshalb eine Kopie, aber keine Sicherung.
|
||||||
|
#
|
||||||
|
# Dieses Skript kopiert daraus in einen zweiten Ordner, den der Client NICHT
|
||||||
|
# kennt. Was einmal hier liegt, bleibt liegen, bis die Aufbewahrungsfrist es
|
||||||
|
# wegräumt. Damit ist die Kette gebrochen: der Server kann diese Kopien nicht
|
||||||
|
# mehr erreichen.
|
||||||
|
#
|
||||||
|
# UND ES MELDET SICH, WENN NICHTS MEHR ANKOMMT. Ein Kopierauftrag, der still
|
||||||
|
# aufhört zu laufen, ist genau der Schaden, gegen den er gebaut wurde: man
|
||||||
|
# glaubt, man hätte eine Sicherung, und merkt es an dem Tag, an dem man sie
|
||||||
|
# braucht. Deshalb prüft er das Alter des neuesten Archivs und schlägt Alarm.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
# Der Ordner, den der Nextcloud-Client synchronisiert.
|
||||||
|
[Parameter(Mandatory = $true)][string]$Quelle,
|
||||||
|
# Die eigene Ablage. MUSS ausserhalb des synchronisierten Ordners liegen --
|
||||||
|
# sonst schiebt der Client die Kopien wieder zum Server hoch, und die
|
||||||
|
# Rueckkopplung, die wir brechen wollen, ist wieder da.
|
||||||
|
[Parameter(Mandatory = $true)][string]$Ziel,
|
||||||
|
[int]$Behalten = 30,
|
||||||
|
# Ab wann "es kommt nichts mehr" gemeldet wird. Die Sicherung laeuft
|
||||||
|
# taeglich; zwei Tage lassen Raum fuer einen ausgefallenen Lauf, drei waeren
|
||||||
|
# schon zwei verpasste.
|
||||||
|
[int]$AlarmNachTagen = 2,
|
||||||
|
[string]$Webhook = '',
|
||||||
|
[switch]$Einrichten
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
|
||||||
|
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
||||||
|
function Warn($t) { Write-Host " $t" -ForegroundColor Yellow }
|
||||||
|
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
|
||||||
|
|
||||||
|
$MUSTER = @('*.tar.gz', '*.db.gz')
|
||||||
|
|
||||||
|
# Das Datum aus dem NAMEN lesen, nicht aus dem Zeitstempel der Datei.
|
||||||
|
#
|
||||||
|
# WARUM: der Nextcloud-Client setzt LastWriteTime auf den Zeitpunkt des
|
||||||
|
# HERUNTERLADENS. Ein Archiv vom 11. August, das heute ankommt, sieht damit
|
||||||
|
# taufrisch aus -- und der Alarm "es kommt nichts mehr an" würde nie
|
||||||
|
# auslösen, weil alles immer neu wirkt. Genau das ist beim ersten echten Lauf
|
||||||
|
# passiert: "d4rk_media-2026-08-11-2307.tar.gz, 0 Tage alt".
|
||||||
|
#
|
||||||
|
# Der Name trägt das Datum der SICHERUNG, und der überlebt jedes Kopieren:
|
||||||
|
# d4rk_media-2026-08-13-0929.tar.gz
|
||||||
|
# d4rkbot-2026-08-13.db.gz
|
||||||
|
function ArchivDatum($datei) {
|
||||||
|
if ($datei.Name -match '(\d{4})-(\d{2})-(\d{2})(?:-(\d{2})(\d{2}))?') {
|
||||||
|
$std = if ($Matches[4]) { [int]$Matches[4] } else { 0 }
|
||||||
|
$min = if ($Matches[5]) { [int]$Matches[5] } else { 0 }
|
||||||
|
try {
|
||||||
|
return Get-Date -Year ([int]$Matches[1]) -Month ([int]$Matches[2]) -Day ([int]$Matches[3]) `
|
||||||
|
-Hour $std -Minute $min -Second 0
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
# Kein Datum im Namen -- dann bleibt nur der Zeitstempel, mit allen
|
||||||
|
# Nachteilen. Besser als gar keine Reihenfolge.
|
||||||
|
return $datei.LastWriteTime
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Als Aufgabe eintragen ------------------------------------------------------
|
||||||
|
|
||||||
|
if ($Einrichten) {
|
||||||
|
$pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $pwsh) { $pwsh = (Get-Command powershell.exe).Source }
|
||||||
|
$arg = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$PSCommandPath`"" +
|
||||||
|
" -Quelle `"$Quelle`" -Ziel `"$Ziel`" -Behalten $Behalten -AlarmNachTagen $AlarmNachTagen"
|
||||||
|
if ($Webhook) { $arg += " -Webhook `"$Webhook`"" }
|
||||||
|
|
||||||
|
# Ueber einen VBS-Starter, damit kein Fenster aufgeht -- wie beim Tunnel.
|
||||||
|
# Ein Fenster, das man zumachen kann, wird irgendwann zugemacht.
|
||||||
|
$ordner = Join-Path $env:LOCALAPPDATA 'Sicherungs-Abholung'
|
||||||
|
New-Item -ItemType Directory -Force -Path $ordner | Out-Null
|
||||||
|
$starter = Join-Path $ordner 'start.vbs'
|
||||||
|
# Die Anfuehrungszeichen im Argumenttext muessen fuer VBS verdoppelt werden.
|
||||||
|
# Als Here-String, weil die verschachtelte Variante beim ersten Anlauf ein
|
||||||
|
# unlesbares Knaeuel war -- und prompt nicht geparst hat.
|
||||||
|
$vbsArg = $arg -replace '"', '""'
|
||||||
|
@"
|
||||||
|
' Erzeugt von heim-abholen.ps1 -- startet die Abholung OHNE Fenster.
|
||||||
|
Set sh = CreateObject("WScript.Shell")
|
||||||
|
sh.Run """$pwsh"" $vbsArg", 0, False
|
||||||
|
"@ | Set-Content $starter -Encoding ASCII
|
||||||
|
|
||||||
|
Unregister-ScheduledTask -TaskName 'Sicherungen abholen' -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
$aktion = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "`"$starter`""
|
||||||
|
# ZWEI Ausloeser: taeglich mittags, UND beim Anmelden. Der zweite ist der
|
||||||
|
# wichtige -- ein Rechner, der nachts aus ist, holt sonst nie etwas.
|
||||||
|
$t1 = New-ScheduledTaskTrigger -Daily -At 12:00
|
||||||
|
$t2 = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"
|
||||||
|
$prinzipal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
||||||
|
$optionen = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||||
|
Register-ScheduledTask -TaskName 'Sicherungen abholen' -Action $aktion -Trigger @($t1, $t2) `
|
||||||
|
-Principal $prinzipal -Settings $optionen `
|
||||||
|
-Description "Kopiert Sicherungen aus $Quelle nach $Ziel und haelt $Behalten Staende." | Out-Null
|
||||||
|
Gut 'Als Aufgabe eingetragen: taeglich 12:00 und bei jeder Anmeldung.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Prüfen ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Nachsehen'
|
||||||
|
# JEDER Schritt sagt VORHER, was er tut. Beim ersten Anlauf blieb das Skript
|
||||||
|
# nach "Nachsehen" stehen, und von aussen war nicht zu sehen, WORAN --
|
||||||
|
# Test-Path, New-Item und Resolve-Path koennen alle drei minutenlang
|
||||||
|
# blockieren, wenn ein Pfad auf eine Netzfreigabe oder eine schlafende Platte
|
||||||
|
# zeigt. Eine Ausgabe, die erst NACH dem Blockieren kommt, hilft genau dann
|
||||||
|
# nicht, wenn man sie braucht.
|
||||||
|
Write-Host " Quelle pruefen: $Quelle ..." -NoNewline
|
||||||
|
if (-not (Test-Path $Quelle)) {
|
||||||
|
Write-Host ''
|
||||||
|
Schlecht "Die Quelle gibt es nicht: $Quelle"
|
||||||
|
Schlecht 'Synchronisiert der Nextcloud-Client diesen Ordner wirklich?'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host ' da.'
|
||||||
|
|
||||||
|
# Das Ziel darf NICHT im synchronisierten Ordner liegen -- sonst laedt der
|
||||||
|
# Client die Kopien wieder hoch und die Rueckkopplung ist zurueck.
|
||||||
|
$qVoll = (Resolve-Path $Quelle).Path.TrimEnd('\')
|
||||||
|
Write-Host " Ziel anlegen/pruefen: $Ziel ..." -NoNewline
|
||||||
|
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||||
|
$zVoll = (Resolve-Path $Ziel).Path.TrimEnd('\')
|
||||||
|
Write-Host ' da.'
|
||||||
|
if ($zVoll.StartsWith($qVoll, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
Schlecht 'Das Ziel liegt IM synchronisierten Ordner. Dann waere alles umsonst:'
|
||||||
|
Schlecht 'der Client wuerde die Kopien wieder hochladen und mitloeschen.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut "aus $qVoll"
|
||||||
|
Gut "nach $zVoll"
|
||||||
|
|
||||||
|
# -- Holen ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# robocopy OHNE /MIR, und das ist der ganze Punkt: /MIR wuerde das Ziel dem
|
||||||
|
# Quellordner angleichen und dort geloeschte Dateien auch hier loeschen. Genau
|
||||||
|
# das soll nicht passieren.
|
||||||
|
|
||||||
|
# -- Sind es ueberhaupt Dateien, oder nur Platzhalter? --------------------------
|
||||||
|
#
|
||||||
|
# DAS HAT EINEN RECHNER LAHMGELEGT. Der Nextcloud-Client kann "virtuelle
|
||||||
|
# Dateien" anlegen: im Ordner steht dann ein Verweis mit der richtigen GROESSE,
|
||||||
|
# aber ohne Inhalt. Wer so eine Datei anfasst, loest einen Download aus -- und
|
||||||
|
# robocopy fasst alle an. Bei fuenf Archiven waren das 1,7 GB auf einmal,
|
||||||
|
# waehrend Windows blockierte.
|
||||||
|
#
|
||||||
|
# Erkennbar an zwei Attributen:
|
||||||
|
# 0x001000 OFFLINE -- Inhalt liegt nicht hier
|
||||||
|
# 0x400000 RECALL_ON_DATA_ACCESS -- wird beim Zugriff nachgeladen
|
||||||
|
#
|
||||||
|
# Lieber abbrechen und es sagen, als die Maschine in die Knie zwingen.
|
||||||
|
|
||||||
|
Schritt 'Sind es echte Dateien?'
|
||||||
|
$platzhalter = @(Get-ChildItem $qVoll -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -match '\.(tar|db)\.gz$' -and ($_.Attributes -band 0x401000) })
|
||||||
|
if ($platzhalter.Count) {
|
||||||
|
Schlecht "$($platzhalter.Count) von $(@(Get-ChildItem $qVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count) Archiven sind nur PLATZHALTER."
|
||||||
|
Write-Host ''
|
||||||
|
Warn 'Der Nextcloud-Client hat "virtuelle Dateien" aktiviert: im Ordner steht'
|
||||||
|
Warn 'ein Verweis mit der richtigen Groesse, aber ohne Inhalt. Sie zu kopieren'
|
||||||
|
Warn 'wuerde alles auf einmal herunterladen und den Rechner blockieren.'
|
||||||
|
Write-Host ''
|
||||||
|
Warn 'Im Explorer sieht man es am WOLKEN-Symbol neben der Datei; ein gruener'
|
||||||
|
Warn 'Haken heisst "liegt wirklich hier". Windows 11 zeigt ausserdem an, wenn'
|
||||||
|
Warn 'es gerade etwas nachlaedt.'
|
||||||
|
Write-Host ''
|
||||||
|
Warn 'Im Nextcloud-Client: Einstellungen -> Konto -> Drei-Punkte-Menue'
|
||||||
|
Warn ' -> "Virtuelle Dateien deaktivieren"'
|
||||||
|
Warn 'Danach laedt er im Hintergrund alles herunter -- mit Fortschritt und'
|
||||||
|
Warn 'unterbrechbar. Erst wenn das durch ist, hat diese Abholung Sinn.'
|
||||||
|
Write-Host ''
|
||||||
|
Schlecht 'Abgebrochen. Es wurde nichts kopiert und nichts geloescht.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut 'ja, alle Archive liegen wirklich lokal'
|
||||||
|
|
||||||
|
Schritt 'Kopieren (nur neue, nichts loeschen)'
|
||||||
|
$zaehlen = { @(Get-ChildItem $zVoll -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count }
|
||||||
|
$vorher = & $zaehlen
|
||||||
|
robocopy $qVoll $zVoll @MUSTER /XO /R:2 /W:5 /NP /NJH /NJS /NDL /NC /NS | Out-Null
|
||||||
|
# robocopy meldet ueber den Rueckgabewert: alles unter 8 ist Erfolg.
|
||||||
|
if ($LASTEXITCODE -ge 8) { Schlecht "robocopy meldet Fehler ($LASTEXITCODE)"; exit 1 }
|
||||||
|
$nachher = & $zaehlen
|
||||||
|
$inQuelle = @(Get-ChildItem $qVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count
|
||||||
|
Gut "$($nachher - $vorher) neu, $nachher in der Ablage, $inQuelle in der Quelle"
|
||||||
|
|
||||||
|
# -- Ausdünnen ------------------------------------------------------------------
|
||||||
|
|
||||||
|
$alle = @(Get-ChildItem $zVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' } |
|
||||||
|
Sort-Object @{ Expression = { ArchivDatum $_ } } -Descending)
|
||||||
|
if ($alle.Count -gt $Behalten) {
|
||||||
|
Schritt "Alte Staende entfernen (mehr als $Behalten)"
|
||||||
|
$alle | Select-Object -Skip $Behalten | ForEach-Object {
|
||||||
|
Remove-Item $_.FullName -Force
|
||||||
|
Gut "entfernt: $($_.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Kommt überhaupt noch etwas an? ---------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Alter der neuesten Sicherung'
|
||||||
|
$neuestes = $alle | Select-Object -First 1
|
||||||
|
if (-not $neuestes) {
|
||||||
|
$text = 'In der Ablage liegt KEINE einzige Sicherung.'
|
||||||
|
Schlecht $text
|
||||||
|
} else {
|
||||||
|
$tage = [math]::Round(((Get-Date) - (ArchivDatum $neuestes)).TotalDays, 1)
|
||||||
|
if ($tage -gt $AlarmNachTagen) {
|
||||||
|
$text = "Die neueste Sicherung ist $tage Tage alt ($($neuestes.Name)). Es kommt nichts mehr an."
|
||||||
|
Schlecht $text
|
||||||
|
} else {
|
||||||
|
$text = $null
|
||||||
|
Gut "$($neuestes.Name), $tage Tage alt, $([math]::Round($neuestes.Length/1MB)) MB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Melden, falls gewünscht ----------------------------------------------------
|
||||||
|
#
|
||||||
|
# Nur im Alarmfall und nur bei ZUSTANDSWECHSEL. Eine tägliche Meldung "alles in
|
||||||
|
# Ordnung" liest nach einer Woche niemand mehr, und dann auch die eine nicht,
|
||||||
|
# auf die es ankommt.
|
||||||
|
|
||||||
|
$merker = Join-Path $zVoll '.letzter-alarm.txt'
|
||||||
|
$zuletzt = if (Test-Path $merker) { (Get-Content $merker -Raw).Trim() } else { '' }
|
||||||
|
$jetzt = if ($text) { $text } else { '' }
|
||||||
|
|
||||||
|
# DER MERKER WIRD ERST GESETZT, WENN DIE MELDUNG WIRKLICH RAUSGING.
|
||||||
|
#
|
||||||
|
# Vorher stand er ganz oben und wurde bei jedem Zustandswechsel geschrieben --
|
||||||
|
# auch wenn gar kein Webhook konfiguriert war oder das Senden fehlschlug. Beim
|
||||||
|
# ersten echten Test ist genau das passiert: ein Lauf OHNE -Webhook hat den
|
||||||
|
# Alarm als "gemeldet" abgehakt, und der naechste Lauf MIT Webhook waere still
|
||||||
|
# geblieben.
|
||||||
|
#
|
||||||
|
# Ein Merker bedeutet "dieser Zustand wurde berichtet". Wenn nichts berichtet
|
||||||
|
# wurde, darf er nicht gesetzt werden -- sonst verschluckt genau der Fehlerfall
|
||||||
|
# die Meldung, fuer die das Ganze da ist.
|
||||||
|
|
||||||
|
if (-not $jetzt) {
|
||||||
|
# Alles in Ordnung: Entwarnung braucht keine Nachricht, aber der Merker
|
||||||
|
# muss weg, damit derselbe Zustand spaeter wieder gemeldet werden kann.
|
||||||
|
if ($zuletzt) { Set-Content $merker '' -Encoding UTF8; Gut 'Wieder in Ordnung.' }
|
||||||
|
} elseif ($jetzt -eq $zuletzt) {
|
||||||
|
Warn 'Schon gemeldet -- bleibt still, bis sich etwas aendert.'
|
||||||
|
} elseif (-not $Webhook) {
|
||||||
|
Write-Host ''
|
||||||
|
Warn 'Kein -Webhook angegeben -- es wurde NICHTS gemeldet.'
|
||||||
|
Warn 'Der Merker bleibt deshalb leer: beim naechsten Lauf mit Webhook'
|
||||||
|
Warn 'geht die Meldung dann wirklich raus.'
|
||||||
|
} else {
|
||||||
|
$rumpf = @{
|
||||||
|
username = 'Sicherungs-Abholung'
|
||||||
|
embeds = @(@{
|
||||||
|
title = 'Bei dir zu Hause kommt keine Sicherung mehr an'
|
||||||
|
description = $jetzt
|
||||||
|
color = 15158332
|
||||||
|
fields = @(@{ name = 'Ablage'; value = "``$zVoll``"; inline = $false })
|
||||||
|
footer = @{ text = 'D4RKST3R // ABHOLUNG' }
|
||||||
|
})
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
$code = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST `
|
||||||
|
-H 'content-type: application/json' --data-binary '@-' $Webhook
|
||||||
|
Write-Host ''
|
||||||
|
if ($code -in '200', '204') {
|
||||||
|
Gut "Gemeldet (HTTP $code)."
|
||||||
|
Set-Content $merker $jetzt -Encoding UTF8
|
||||||
|
} else {
|
||||||
|
# NICHT merken: beim naechsten Lauf noch einmal versuchen. Eine
|
||||||
|
# Meldung, die im Netz haengengeblieben ist, ist keine Meldung.
|
||||||
|
Schlecht "Melden fehlgeschlagen: HTTP $code -- wird beim naechsten Lauf erneut versucht."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Welche Quelldateien beim Uebernehmen KEINEN eigenen Platz bekommen haben.
|
||||||
|
#
|
||||||
|
# Beim Import der Item-Bilder kamen 3669 Dateien an, aber nur 3576 Eintraege
|
||||||
|
# heraus. Die Differenz sind KEINE Fehler -- es sind Namen, die nach der
|
||||||
|
# Zaehmung (safeFilename: Kleinbuchstaben, Fremdes wird zum Strich) auf
|
||||||
|
# denselben Pfad fallen:
|
||||||
|
#
|
||||||
|
# WEAPON_SMG.png -> weapon_smg.png
|
||||||
|
# weapon_smg.png -> weapon_smg.png <- derselbe Platz
|
||||||
|
#
|
||||||
|
# Der Import laedt mit overwrite=false, hat also NICHTS ueberschrieben. Die
|
||||||
|
# zweite Datei blieb schlicht liegen.
|
||||||
|
#
|
||||||
|
# DIESES SKRIPT AENDERT NICHTS. Es liest die Quelle, rechnet dieselbe Zaehmung
|
||||||
|
# nach und zeigt, welche Namen zusammenfallen -- damit die Entscheidung darueber
|
||||||
|
# auf Zahlen steht und nicht auf einer Vermutung.
|
||||||
|
#
|
||||||
|
# pwsh -File tools\kollisionen.ps1 -Von 'CDN/items'
|
||||||
|
# pwsh -File tools\kollisionen.ps1 -Von 'CDN/items' -Csv befund.csv
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Von,
|
||||||
|
[string]$Csv = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ------------------------------------------------- Zugang aus dem Dashboard
|
||||||
|
#
|
||||||
|
# Dieselbe Quelle wie Sicherung und Import: die Werte stehen im Panel unter
|
||||||
|
# Einstellungen und damit in media.db -- so verlaesst das Passwort den Rechner
|
||||||
|
# nicht.
|
||||||
|
function hol($schluessel) {
|
||||||
|
docker exec d4rk-media node -e @"
|
||||||
|
const db = require('better-sqlite3')('/data/media.db', { readonly: true })
|
||||||
|
const z = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel')
|
||||||
|
process.stdout.write(z ? z.value : '')
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
$nc = @{
|
||||||
|
basis = hol 'sicherung.basis'
|
||||||
|
benutzer = hol 'sicherung.benutzer'
|
||||||
|
passwort = hol 'sicherung.passwort'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not ($nc.basis -and $nc.benutzer -and $nc.passwort)) {
|
||||||
|
Write-Host 'Die Nextcloud-Werte fehlen. Dashboard -> Einstellungen -> Sicherung.' -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$auth = "$($nc.benutzer):$($nc.passwort)"
|
||||||
|
$wurzel = "$($nc.basis.TrimEnd('/'))/remote.php/dav/files/$($nc.benutzer)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Quelle lesen
|
||||||
|
Write-Host "Lese $Von ..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$antwort = curl.exe -s -u $auth -X PROPFIND -H 'Depth: infinity' "$wurzel/$Von/"
|
||||||
|
# -join, NICHT direkt auf das Zeilen-Array: curl.exe gibt unter PowerShell ein
|
||||||
|
# Array zurueck, und -match darauf FILTERT, statt zu pruefen. Zweimal darauf
|
||||||
|
# hereingefallen, deshalb steht es hier.
|
||||||
|
$roh = $antwort -join "`n"
|
||||||
|
|
||||||
|
if ($roh -notmatch '<d:multistatus|<D:multistatus') {
|
||||||
|
Write-Host 'Keine brauchbare Antwort von der Nextcloud:' -ForegroundColor Red
|
||||||
|
Write-Host $roh.Substring(0, [Math]::Min(300, $roh.Length))
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$praefix = "/remote.php/dav/files/$($nc.benutzer)/$Von/"
|
||||||
|
$dateien = [regex]::Matches($roh, '<d:href>([^<]+)</d:href>') |
|
||||||
|
ForEach-Object { [uri]::UnescapeDataString($_.Groups[1].Value) } |
|
||||||
|
Where-Object { $_ -notmatch '/$' } |
|
||||||
|
ForEach-Object { $_ -replace [regex]::Escape($praefix), '' }
|
||||||
|
|
||||||
|
Write-Host " $($dateien.Count) Dateien" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# --------------------------------------------- Dieselbe Zaehmung nachrechnen
|
||||||
|
#
|
||||||
|
# Wort fuer Wort aus server/src/storage.ts (safeFilename). Weicht das je
|
||||||
|
# auseinander, zeigt dieses Skript etwas anderes an als der Dienst tut -- und
|
||||||
|
# das waere schlimmer als kein Skript.
|
||||||
|
function Zaehmen([string]$s) {
|
||||||
|
$s = $s.ToLowerInvariant()
|
||||||
|
$s = $s -replace 'ä', 'ae' -replace 'ö', 'oe' -replace 'ü', 'ue' -replace 'ß', 'ss'
|
||||||
|
$s = $s -replace '[^a-z0-9._-]+', '-'
|
||||||
|
$s = $s -replace '-{2,}', '-'
|
||||||
|
$s = $s -replace '^[^a-z0-9]+', ''
|
||||||
|
$s = $s -replace '[-.]+$', ''
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
function SichererName([string]$name) {
|
||||||
|
$punkt = $name.LastIndexOf('.')
|
||||||
|
$stamm = if ($punkt -gt 0) { $name.Substring(0, $punkt) } else { $name }
|
||||||
|
$endung = if ($punkt -gt 0) { $name.Substring($punkt + 1) } else { '' }
|
||||||
|
|
||||||
|
$basis = Zaehmen $stamm
|
||||||
|
$ende = Zaehmen $endung
|
||||||
|
if (-not $basis) { $basis = 'datei' }
|
||||||
|
if ($ende) { return "$basis.$ende" } else { return $basis }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Gruppieren
|
||||||
|
$gruppen = @{}
|
||||||
|
foreach ($d in $dateien) {
|
||||||
|
# Unterordner bleiben erhalten, jedes Stueck einzeln gezaehmt -- genau wie
|
||||||
|
# beim Import.
|
||||||
|
$ziel = ($d -split '/' | ForEach-Object { SichererName $_ }) -join '/'
|
||||||
|
if (-not $gruppen.ContainsKey($ziel)) { $gruppen[$ziel] = @() }
|
||||||
|
$gruppen[$ziel] += $d
|
||||||
|
}
|
||||||
|
|
||||||
|
$kollisionen = $gruppen.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 } | Sort-Object Name
|
||||||
|
|
||||||
|
$verloren = ($kollisionen | ForEach-Object { $_.Value.Count - 1 } | Measure-Object -Sum).Sum
|
||||||
|
if (-not $verloren) { $verloren = 0 }
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Quelldateien : $($dateien.Count)"
|
||||||
|
Write-Host "Verschiedene Ziele: $($gruppen.Count)"
|
||||||
|
Write-Host "Kollisionsgruppen : $($kollisionen.Count)"
|
||||||
|
Write-Host "Ohne eigenen Platz: $verloren" -ForegroundColor $(if ($verloren) { 'Yellow' } else { 'Green' })
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
if ($kollisionen.Count -eq 0) {
|
||||||
|
Write-Host 'Nichts faellt zusammen.' -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Zeigen
|
||||||
|
#
|
||||||
|
# GLEICHER INHALT ODER NICHT -- das ist die Frage, an der die Entscheidung
|
||||||
|
# haengt. Deshalb steht die Groesse dabei: gleiche Groesse heisst mit hoher
|
||||||
|
# Wahrscheinlichkeit dieselbe Datei unter anderer Schreibweise, und dann fehlt
|
||||||
|
# nichts.
|
||||||
|
$groessen = @{}
|
||||||
|
foreach ($m in [regex]::Matches($roh, '(?s)<d:response>.*?</d:response>')) {
|
||||||
|
$t = $m.Value
|
||||||
|
if ($t -match '<d:href>([^<]+)</d:href>' ) {
|
||||||
|
$href = [uri]::UnescapeDataString($matches[1])
|
||||||
|
if ($t -match '<d:getcontentlength>(\d+)</d:getcontentlength>') {
|
||||||
|
$groessen[($href -replace [regex]::Escape($praefix), '')] = [int]$matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zeilen = foreach ($k in $kollisionen) {
|
||||||
|
$gleich = ($k.Value | ForEach-Object { $groessen[$_] } | Select-Object -Unique).Count -eq 1
|
||||||
|
foreach ($q in $k.Value) {
|
||||||
|
[pscustomobject]@{
|
||||||
|
Ziel = $k.Name
|
||||||
|
Quelle = $q
|
||||||
|
Bytes = $groessen[$q]
|
||||||
|
GleicheGroesse = $gleich
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zeilen | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
|
||||||
|
|
||||||
|
$gleicheGruppen = ($kollisionen | Where-Object {
|
||||||
|
($_.Value | ForEach-Object { $groessen[$_] } | Select-Object -Unique).Count -eq 1
|
||||||
|
}).Count
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Davon Gruppen mit DURCHWEG GLEICHER Groesse: $gleicheGruppen von $($kollisionen.Count)"
|
||||||
|
Write-Host 'Gleiche Groesse heisst fast immer: dieselbe Datei, andere Schreibweise. Dann fehlt nichts.'
|
||||||
|
|
||||||
|
if ($Csv) {
|
||||||
|
$zeilen | Export-Csv -Path $Csv -NoTypeInformation -Encoding utf8 -Delimiter ';'
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Geschrieben nach $Csv" -ForegroundColor Green
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Sitzt der NPM-Block? Eine Antwort, keine Vermutung.
|
||||||
|
#
|
||||||
|
# pwsh -File tools\proxy-pruefen.ps1
|
||||||
|
#
|
||||||
|
# Nach dem Einfuegen von docs\npm-advanced.conf und "Save" in NPM ausfuehren.
|
||||||
|
# Das Skript AENDERT NICHTS -- es misst nur, was ueber die Leitung kommt.
|
||||||
|
#
|
||||||
|
# WARUM ES DAS GIBT: der Block wurde schon einmal fuer eingefuegt gehalten und
|
||||||
|
# war es nicht. Die Kopfzeile sagt es eindeutig, aber man muss sie eben ansehen.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Basis = 'https://media.d4rkst3r.de',
|
||||||
|
# Fuer die API-Messung. Ohne ihn wird sie uebersprungen -- sie braucht einen
|
||||||
|
# gueltigen Token, um "mit" und "ohne" unterscheiden zu koennen.
|
||||||
|
[string]$Token = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$fehler = 0
|
||||||
|
|
||||||
|
function Kopf([string]$url, [hashtable]$header = @{}) {
|
||||||
|
try {
|
||||||
|
$a = Invoke-WebRequest -Uri $url -Method Head -Headers $header -SkipHttpErrorCheck `
|
||||||
|
-MaximumRedirection 0 -ErrorAction Stop
|
||||||
|
return @{ code = [int]$a.StatusCode; kopf = $a.Headers }
|
||||||
|
} catch {
|
||||||
|
return @{ code = 0; kopf = @{}; fehler = $_.Exception.Message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wert($kopf, [string]$name) {
|
||||||
|
foreach ($k in $kopf.Keys) {
|
||||||
|
if ($k -ieq $name) { return ($kopf[$k] -join ', ') }
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zeile([string]$was, [bool]$gut, [string]$gemessen) {
|
||||||
|
$zeichen = if ($gut) { ' [ok] ' } else { ' [!!] ' }
|
||||||
|
$farbe = if ($gut) { 'Green' } else { 'Red' }
|
||||||
|
Write-Host ($zeichen + $was.PadRight(42) + $gemessen) -ForegroundColor $farbe
|
||||||
|
if (-not $gut) { $script:fehler++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Messe $Basis" -ForegroundColor Cyan
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
# -- 1. Die Dateien ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Unsere Vorgabe ist "public, max-age=300". Kommt etwas anderes an, hat
|
||||||
|
# assets.conf sie ueberschrieben -- dann fehlt der Block.
|
||||||
|
$f = Kopf "$Basis/f/vehicles/adder.webp"
|
||||||
|
$cc = Wert $f.kopf 'Cache-Control'
|
||||||
|
Zeile '/f/ Cache-Control' ($cc -match 'max-age=300') $(if ($cc) { $cc } else { '(keine)' })
|
||||||
|
|
||||||
|
# -- 2. Die Vorschaubilder -----------------------------------------------------
|
||||||
|
$t = Kopf "$Basis/t/vehicles/adder.webp"
|
||||||
|
$cc = Wert $t.kopf 'Cache-Control'
|
||||||
|
Zeile '/t/ Cache-Control' ($cc -match 'max-age=60\b') $(if ($cc) { $cc } else { '(keine)' })
|
||||||
|
|
||||||
|
# -- 3. Die API, und das ist die entscheidende ---------------------------------
|
||||||
|
#
|
||||||
|
# EIN EINZELNER AUFRUF TAUGT HIER NICHT, und das ist beim Schreiben dieses
|
||||||
|
# Skripts aufgefallen: "ohne Token -> 401" sah nach Erfolg aus, war aber nur
|
||||||
|
# ein zwischengespeicherter 401, den der Proxy an ALLE ausliefert -- gemessen
|
||||||
|
# gab er ihn sogar Aufrufern MIT gueltigem Token.
|
||||||
|
#
|
||||||
|
# Verlaesslich ist nur das Paar auf einer FRISCHEN Adresse, die noch in keinem
|
||||||
|
# Zwischenspeicher liegt:
|
||||||
|
#
|
||||||
|
# mit Token -> 200 (der Dienst antwortet)
|
||||||
|
# ohne Token -> 401 (der Proxy hat NICHT weggelegt) <- gut
|
||||||
|
# ohne Token -> 200 (er hat weggelegt) <- Block fehlt
|
||||||
|
#
|
||||||
|
# Die Adresse endet auf ".webp", damit sie ueberhaupt in die assets.conf-Regel
|
||||||
|
# faellt -- ohne diese Endung wuerde die Messung nichts messen.
|
||||||
|
if (-not $Token) {
|
||||||
|
Write-Host ' [--] /api/ (uebersprungen) kein -Token uebergeben' -ForegroundColor DarkGray
|
||||||
|
Write-Host ' Dashboard -> Token -> einen anlegen, dann:' -ForegroundColor DarkGray
|
||||||
|
Write-Host ' pwsh -File tools\proxy-pruefen.ps1 -Token d4rk_...' -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
$zufall = -join ((1..12) | ForEach-Object { 'abcdefghijklmnopqrstuvwxyz0123456789'[(Get-Random -Max 36)] })
|
||||||
|
$probe = "$Basis/api/exists/probe-$zufall.webp"
|
||||||
|
|
||||||
|
$mit = Kopf $probe @{ Authorization = "Bearer $Token" }
|
||||||
|
Zeile '/api/ mit Token -> 200' ($mit.code -eq 200) "HTTP $($mit.code)"
|
||||||
|
|
||||||
|
$ohne = Kopf $probe
|
||||||
|
Zeile '/api/ dieselbe Adresse ohne Token -> 401' ($ohne.code -eq 401) `
|
||||||
|
$(if ($ohne.code -eq 200) { 'HTTP 200 — der Proxy gibt sie weiter!' } else { "HTTP $($ohne.code)" })
|
||||||
|
}
|
||||||
|
|
||||||
|
# Die Abfrageform ist von sich aus dicht -- sie muss auch OHNE den Block 401
|
||||||
|
# geben. Faellt SIE durch, stimmt etwas am Dienst und nicht am Proxy.
|
||||||
|
$b = Kopf "$Basis/api/exists?pfad=vehicles/adder.webp"
|
||||||
|
Zeile '/api/exists?pfad ohne Token -> 401' ($b.code -eq 401) "HTTP $($b.code)"
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
if ($fehler -eq 0) {
|
||||||
|
Write-Host ' Der Block sitzt. Nichts weiter zu tun.' -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " $fehler Messung(en) sagen: der Block ist NICHT wirksam." -ForegroundColor Red
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' Naechste Schritte:' -ForegroundColor Yellow
|
||||||
|
Write-Host ' 1. In NPM den Proxy-Host oeffnen -> Advanced -> Inhalt von'
|
||||||
|
Write-Host ' docs\npm-advanced.conf einfuegen -> SAVE druecken.'
|
||||||
|
Write-Host ' 2. Gegenpruefen, was NPM daraus gemacht hat:'
|
||||||
|
Write-Host ' docker exec npm sh -c "grep -n ''location'' /data/nginx/proxy_host/9.conf"'
|
||||||
|
Write-Host ' Stehen dort ^~ /f/, ^~ /t/ und ^~ /api/, ist gespeichert worden.'
|
||||||
|
Write-Host ' 3. Dieses Skript erneut ausfuehren.'
|
||||||
|
}
|
||||||
|
Write-Host ''
|
||||||
|
exit $(if ($fehler -eq 0) { 0 } else { 1 })
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Ein Rundgang durch den LAUFENDEN Dienst.
|
||||||
|
|
||||||
|
python tools/rundgang.py <- gegen PUBLIC_URL aus .env
|
||||||
|
python tools/rundgang.py http://localhost:8096
|
||||||
|
|
||||||
|
WOZU, wo doch jede Aenderung einzeln gemessen wird: weil sie einzeln gemessen
|
||||||
|
wird. An einem Tag mit sechs Neustarts fasst niemand mehr alles an, und die
|
||||||
|
Sachen, die keiner mehr anfasst, sind genau die, die kaputtgehen -- ein WebP,
|
||||||
|
das nach einem Umbau nicht mehr ausgehandelt wird, meldet sich nicht.
|
||||||
|
|
||||||
|
WAS ER NICHT IST: ein Ersatz fuer die Tests. Er prueft nicht, ob eine Funktion
|
||||||
|
das Richtige rechnet, sondern ob der Dienst, so wie er gerade laeuft, die
|
||||||
|
Sachen noch tut, die er koennen muss. Deshalb geht er ueber HTTP und nicht am
|
||||||
|
Code vorbei.
|
||||||
|
|
||||||
|
Er braucht das Admin-Passwort aus der .env, liest sie aber nur -- und laeuft
|
||||||
|
NUR LESEND: kein Upload, kein Loeschen, keine Einstellung wird angefasst.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
WURZEL = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ENV = dict(re.findall(r'^([A-Z_]+)=(.*)$',
|
||||||
|
open(os.path.join(WURZEL, '.env'), encoding='utf-8').read(), re.M))
|
||||||
|
BASIS = (sys.argv[1] if len(sys.argv) > 1 else ENV.get('PUBLIC_URL', '')).rstrip('/')
|
||||||
|
if not BASIS:
|
||||||
|
sys.exit('Keine Adresse: weder als Argument noch als PUBLIC_URL in der .env.')
|
||||||
|
|
||||||
|
ABLAGE = tempfile.mkdtemp(prefix='rundgang-')
|
||||||
|
KEKS = os.path.join(ABLAGE, 'keks.txt')
|
||||||
|
RUMPF = os.path.join(ABLAGE, 'rumpf.bin')
|
||||||
|
|
||||||
|
CRLF = b'\r\n'
|
||||||
|
|
||||||
|
gut, schlecht = 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
def curl(*args, keks=True):
|
||||||
|
"""Der Rumpf geht in eine DATEI und nicht durch die Standardausgabe: ein PNG
|
||||||
|
durch einen Textkanal zu ziehen, endet unter Windows an cp1252 -- und der
|
||||||
|
Rundgang stirbt dann an der Datei, die er gerade erfolgreich geholt hat."""
|
||||||
|
befehl = ['curl', '-s', '-o', RUMPF, '-w', '%{http_code}|%{size_download}|%{content_type}']
|
||||||
|
if keks:
|
||||||
|
befehl += ['-b', KEKS]
|
||||||
|
r = subprocess.run(befehl + list(args), capture_output=True, text=True, timeout=90)
|
||||||
|
code, groesse, typ = (r.stdout.split('|') + ['', '', ''])[:3]
|
||||||
|
try:
|
||||||
|
text = open(RUMPF, 'rb').read().decode('utf-8')
|
||||||
|
except (UnicodeDecodeError, FileNotFoundError):
|
||||||
|
text = ''
|
||||||
|
return code, int(groesse or 0), typ, text
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe(name, bedingung, gemessen):
|
||||||
|
global gut, schlecht
|
||||||
|
if bedingung:
|
||||||
|
gut += 1
|
||||||
|
print(f' ok {name:<44} {gemessen}')
|
||||||
|
else:
|
||||||
|
schlecht += 1
|
||||||
|
print(f' NEIN {name:<44} {gemessen}')
|
||||||
|
|
||||||
|
|
||||||
|
print(f'Rundgang gegen {BASIS}\n')
|
||||||
|
|
||||||
|
# -- Anmelden -----------------------------------------------------------------
|
||||||
|
r = subprocess.run(
|
||||||
|
['curl', '-s', '-c', KEKS, '-X', 'POST', f'{BASIS}/api/dash/auth/login',
|
||||||
|
'-H', 'content-type: application/json', '--data-binary', '@-',
|
||||||
|
'-o', os.devnull, '-w', '%{http_code}'],
|
||||||
|
input=json.dumps({'username': ENV.get('ADMIN_USERNAME', 'admin'),
|
||||||
|
'password': ENV['ADMIN_PASSWORD']}),
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
pruefe('Anmelden', r.stdout == '200', f'HTTP {r.stdout}')
|
||||||
|
if r.stdout != '200':
|
||||||
|
sys.exit('Ohne Anmeldung hat der Rest keinen Sinn.')
|
||||||
|
|
||||||
|
# -- Wer nicht angemeldet ist, kommt nicht rein --------------------------------
|
||||||
|
#
|
||||||
|
# Steht ZUERST, weil es die Sorte Fehler ist, die man nicht sieht: eine Route,
|
||||||
|
# die versehentlich vor dem Waechter registriert wird, funktioniert praechtig --
|
||||||
|
# fuer alle. Genau das ist hier schon zweimal passiert.
|
||||||
|
print('\n-- Was ohne Anmeldung dicht sein muss ' + '-' * 32)
|
||||||
|
for weg in ('/api/dash/media', '/api/dash/tokens', '/api/dash/verlauf/export',
|
||||||
|
'/api/dash/papierkorb', '/api/dash/benutzer', '/api/dash/settings',
|
||||||
|
'/api/dash/einstellungen/export', '/api/dash/verwaltung',
|
||||||
|
'/api/dash/sicherungen'):
|
||||||
|
code, *_ = curl(f'{BASIS}{weg}', keks=False)
|
||||||
|
pruefe(weg, code == '401', f'HTTP {code}')
|
||||||
|
|
||||||
|
print('\n-- Was ohne Anmeldung offen sein muss ' + '-' * 32)
|
||||||
|
for weg in ('/health', '/status'):
|
||||||
|
code, *_ = curl(f'{BASIS}{weg}', keks=False)
|
||||||
|
pruefe(weg, code == '200', f'HTTP {code}')
|
||||||
|
|
||||||
|
# -- Der Bestand ---------------------------------------------------------------
|
||||||
|
print('\n-- Der Bestand ' + '-' * 55)
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/api/dash/uebersicht')
|
||||||
|
u = json.loads(rumpf) if code == '200' else {}
|
||||||
|
pruefe('Uebersicht', code == '200' and u.get('files', 0) > 0, f"{u.get('files')} Dateien")
|
||||||
|
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/api/dash/media?limit=1')
|
||||||
|
erstes = ((json.loads(rumpf).get('items') if code == '200' else None) or [{}])[0]
|
||||||
|
pruefe('Mediathek blaettert', bool(erstes.get('path')), erstes.get('path', '-'))
|
||||||
|
|
||||||
|
# AUSDRUECKLICH EIN PNG, und das ist zweimal danebengegangen:
|
||||||
|
#
|
||||||
|
# 1. Ohne Filter kam "vehicles/asbo.webp" -- da vergleicht der Rundgang WebP
|
||||||
|
# mit WebP, findet gleiche Groessen und meldet einen Fehler, den der Dienst
|
||||||
|
# nicht hat.
|
||||||
|
# 2. Der Filter hiess dann "q=" statt "query=". Unbekannte Parameter
|
||||||
|
# ignoriert der Dienst, liefert also ALLES -- was wie ein kaputter Filter
|
||||||
|
# aussieht und ein kaputter Aufruf ist.
|
||||||
|
#
|
||||||
|
# Ein Test, der das Falsche misst, ist schlimmer als keiner: er wird geglaubt.
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/api/dash/media?query=.png&limit=5')
|
||||||
|
png = next((i for i in (json.loads(rumpf).get('items') or [])
|
||||||
|
if i['path'].lower().endswith('.png')), {})
|
||||||
|
pruefe('Ein PNG zum Vergleichen', bool(png.get('path')), png.get('path', 'keines im Bestand'))
|
||||||
|
|
||||||
|
# -- Ausliefern ----------------------------------------------------------------
|
||||||
|
print('\n-- Eine Datei wirklich ausliefern ' + '-' * 36)
|
||||||
|
if png.get('path'):
|
||||||
|
ziel = f"{BASIS}/f/{png['path']}"
|
||||||
|
code, groesse, typ, _ = curl(ziel, keks=False)
|
||||||
|
pruefe('/f/ liefert aus', code == '200' and groesse > 0, f'{groesse} B, {typ}')
|
||||||
|
|
||||||
|
# WebP an DERSELBEN Adresse -- der Kern der Aushandlung, und die Stelle, an
|
||||||
|
# der der Zwischenspeicher des Proxys schon zweimal danebengelegen hat.
|
||||||
|
code, gw, tw, _ = curl('-H', 'Accept: image/webp', ziel, keks=False)
|
||||||
|
pruefe('WebP an derselben Adresse', code == '200' and tw == 'image/webp', f'{gw} B, {tw}')
|
||||||
|
pruefe('WebP ist kleiner', 0 < gw < groesse, f'{gw} B statt {groesse} B')
|
||||||
|
|
||||||
|
r = subprocess.run(['curl', '-s', '-D', '-', '-o', os.devnull,
|
||||||
|
'-H', 'Accept: image/webp', ziel], capture_output=True, text=True)
|
||||||
|
hat = 'vary: accept' in r.stdout.lower()
|
||||||
|
pruefe('Vary: Accept gesetzt', hat, 'ja' if hat else 'FEHLT -- der Proxy mischt die Fassungen')
|
||||||
|
|
||||||
|
# -- Verlauf als CSV -----------------------------------------------------------
|
||||||
|
print('\n-- Der Verlauf als CSV ' + '-' * 47)
|
||||||
|
for anhang, name in (('', 'dateien'), ('?was=verwaltung', 'verwaltung')):
|
||||||
|
datei = os.path.join(ABLAGE, f'{name}.csv')
|
||||||
|
r = subprocess.run(['curl', '-s', '-b', KEKS, '-D', '-', '-o', datei,
|
||||||
|
f'{BASIS}/api/dash/verlauf/export{anhang}'],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
roh = open(datei, 'rb').read()
|
||||||
|
nackte = roh.count(b'\n') - roh.count(b'\r\n')
|
||||||
|
# Nackte LF sind der Punkt: Excel packt bei ihnen die halbe Datei in eine
|
||||||
|
# Zelle, und das faellt erst dem auf, der die Datei braucht.
|
||||||
|
pruefe(f'CSV {name}', name in r.stdout and nackte == 0,
|
||||||
|
f'{roh.count(CRLF)} Zeilen, {nackte} nackte LF')
|
||||||
|
|
||||||
|
# -- Die Archive zum Mitnehmen -------------------------------------------------
|
||||||
|
print(chr(10) + '-- Die Archive zum Mitnehmen ' + '-' * 40)
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/api/dash/sicherungen')
|
||||||
|
sich = json.loads(rumpf)
|
||||||
|
pruefe('Sicherungsordner eingehaengt', sich.get('eingehaengt') is True,
|
||||||
|
f"{len(sich.get('items', []))} Archive in {sich.get('ordner')}")
|
||||||
|
|
||||||
|
# Der Weg nach draussen ist der, den man pruefen MUSS: dort liegt der ganze
|
||||||
|
# Bestand als eine Datei, und ein Muster, das zu viel durchlaesst, gaebe jede
|
||||||
|
# Datei des Servers heraus. Geprueft wird deshalb, was NICHT gehen darf.
|
||||||
|
for boese, was in (('../../etc/passwd', 'Pfad hoch'),
|
||||||
|
('..%2f..%2fdata%2fmedia.db', 'kodiert hoch'),
|
||||||
|
('media.db', 'andere Datei'),
|
||||||
|
('d4rk_media-2026-01-01-0000.tar.gz.evil', 'Anhang drangehaengt')):
|
||||||
|
code, *_ = curl(f'{BASIS}/api/dash/sicherungen/{boese}')
|
||||||
|
pruefe(f'abgewiesen: {was}', code in ('400', '404'), f'HTTP {code}')
|
||||||
|
|
||||||
|
# -- Papierkorb und Sicherung --------------------------------------------------
|
||||||
|
print('\n-- Papierkorb und Sicherung ' + '-' * 42)
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/api/dash/papierkorb')
|
||||||
|
pk = json.loads(rumpf)
|
||||||
|
pruefe('Deckel steht in der Antwort', pk.get('maxBytes', 0) > 0,
|
||||||
|
f"{round(pk.get('maxBytes', 0) / 1024 ** 3, 1)} GB")
|
||||||
|
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/api/dash/settings')
|
||||||
|
einst = json.loads(rumpf)
|
||||||
|
sic, letzte = einst['sicherung'], (einst['sicherung'].get('letzte') or {})
|
||||||
|
pruefe('Zweites Ziel eingetragen', bool(sic.get('zweitziel')),
|
||||||
|
sic.get('zweitziel') or 'keines -- die Sicherung liegt auf EINER Platte')
|
||||||
|
pruefe('Letzte Sicherung gut', letzte.get('ok') is True,
|
||||||
|
f"ok={letzte.get('ok')}, zweit={letzte.get('zweit')}, hoch={letzte.get('hoch')}")
|
||||||
|
|
||||||
|
code, _, _, rumpf = curl(f'{BASIS}/status?streng=1', keks=False)
|
||||||
|
st = json.loads(rumpf)
|
||||||
|
schief = [p['was'] for p in st['pruefungen'] if not p['ok']]
|
||||||
|
pruefe('Alle Pruefungen der Statusseite gruen', not schief, ', '.join(schief) or 'alle')
|
||||||
|
for p in st['pruefungen']:
|
||||||
|
if p.get('hinweis'):
|
||||||
|
print(f" {p['was']}: {p['hinweis']}")
|
||||||
|
|
||||||
|
print('\n' + '=' * 70)
|
||||||
|
print(f' {gut} in Ordnung, {schlecht} nicht.')
|
||||||
|
sys.exit(1 if schlecht else 0)
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
# Sicherung von d4rk_media: Datenbank und Bilder in EIN Archiv.
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# pwsh -File tools\sichern.ps1
|
||||||
|
# pwsh -File tools\sichern.ps1 -Ziel D:\woanders -Behalten 30
|
||||||
|
#
|
||||||
|
# Was passiert, und warum in dieser Reihenfolge:
|
||||||
|
#
|
||||||
|
# 1. Die Datenbank wird IM laufenden Container ueber SQLites eigene
|
||||||
|
# Sicherungsschnittstelle als eine in sich stimmige Datei abgelegt
|
||||||
|
# (dist/backup.js). Ein blosses Kopieren waere wegen des WAL still
|
||||||
|
# unvollstaendig.
|
||||||
|
# 2. Ein zweiter, kurzlebiger Container packt Bilder und diese Aufnahme in
|
||||||
|
# ein tar.gz. Er haengt das Volume NUR LESEND ein — eine Sicherung, die
|
||||||
|
# schreiben kann, kann auch kaputtmachen.
|
||||||
|
# 3. Das Archiv wird GEPRUEFT: ausgepackt, die Datenbank geoeffnet, die
|
||||||
|
# Eintraege gezaehlt und mit dem laufenden Dienst verglichen. Eine
|
||||||
|
# Sicherung, die niemand nachzaehlt, ist eine Vermutung.
|
||||||
|
# 4. Das Archiv geht auf ein ZWEITES LAUFWERK, wenn im Panel eines steht
|
||||||
|
# (Einstellungen → Sicherung → "Zweites Ziel"). Auch hier wird die
|
||||||
|
# Pruefsumme verglichen — eine Kopie, die niemand nachzaehlt, ist eine
|
||||||
|
# Vermutung.
|
||||||
|
#
|
||||||
|
# WOZU, wo es doch die Nextcloud gibt: die beiden beantworten zwei
|
||||||
|
# verschiedene Fragen. Die Nextcloud hilft, wenn der ganze Rechner weg
|
||||||
|
# ist — aber wer 100 GB zurueckholen muss, laedt sie ueber die Leitung.
|
||||||
|
# Das zweite Laufwerk ist in Minuten zurueckgespielt. Es hilft nur, wenn
|
||||||
|
# es eine ANDERE PLATTE ist: ein zweiter Ordner auf derselben sieht aus
|
||||||
|
# wie eine Sicherung und ist keine.
|
||||||
|
#
|
||||||
|
# 4a. Das Archiv geht in die NEXTCLOUD, wenn im Panel Zugangsdaten stehen
|
||||||
|
# (Einstellungen → Sicherung). Bis dahin lag die Sicherung auf derselben
|
||||||
|
# Platte wie die Daten — gegen einen Plattenausfall half sie gar nicht.
|
||||||
|
# 4c. Und der Lauf MELDET SICH ZURUECK: das Ergebnis landet in der Datenbank
|
||||||
|
# und steht danach im Panel. Eine Sicherung, die still aufhoert zu laufen,
|
||||||
|
# ist der eigentliche Schaden — nicht die eine Nacht, in der sie fehlt.
|
||||||
|
# 5. Alte Archive fliegen raus, an ALLEN drei Orten, sobald mehr als
|
||||||
|
# -Behalten da sind.
|
||||||
|
#
|
||||||
|
# WARUM localhost UND NICHT DER OEFFENTLICHE NAME. Am 13.08.2026 scheiterte der
|
||||||
|
# naechtliche Upload mit HTTP 413. Nachgemessen mit drei Groessen:
|
||||||
|
#
|
||||||
|
# 1 KB -> 201 | 60 MB -> 201 | 340 MB -> 413, "Server: cloudflare"
|
||||||
|
#
|
||||||
|
# cdn.d4rkst3r.de laeuft ueber Cloudflare, und das deckelt Uploads im
|
||||||
|
# Gratistarif bei 100 MB. Das Archiv ist 336 MB.
|
||||||
|
#
|
||||||
|
# Der Umweg war ohnehin unnoetig: die Nextcloud laeuft auf DERSELBEN Maschine.
|
||||||
|
# Ueber http://localhost:11000 geht der Verkehr gar nicht erst hinaus -- kein
|
||||||
|
# Cloudflare, kein Proxy, kein Zertifikat, und nebenbei fuenfzehnmal schneller:
|
||||||
|
#
|
||||||
|
# ueber 192.168.100.1:11000 340 MB in 59 s
|
||||||
|
# ueber localhost:11000 340 MB in 4 s
|
||||||
|
#
|
||||||
|
# Dass dabei kein TLS im Spiel ist, ist in Ordnung: die Verbindung verlaesst die
|
||||||
|
# Rueckschleife nicht. "localhost" steht bereits in Nextclouds trusted_domains.
|
||||||
|
#
|
||||||
|
# WICHTIG, DAMIT NIEMAND ES FALSCH LIEST: diese Kopie ist KEINE Auswaertskopie.
|
||||||
|
# Die Nextcloud liegt im selben Docker-Volume-Bereich auf derselben Platte wie
|
||||||
|
# die Daten. Sie ist die dritte Kopie, nicht der Weg aus dem Haus -- der fehlt
|
||||||
|
# weiterhin.
|
||||||
|
#
|
||||||
|
# WARUM CURL UND NICHT RCLONE. Nextcloud spricht WebDAV, curl spricht WebDAV,
|
||||||
|
# und curl ist ohnehin da. rclone waere ein weiteres Programm, das installiert,
|
||||||
|
# konfiguriert und aktuell gehalten werden will — fuer PUT, PROPFIND und DELETE
|
||||||
|
# lohnt das nicht.
|
||||||
|
#
|
||||||
|
# NICHT MIT DRIN sind die Vorschaubilder unter /data/thumbs, und das ist
|
||||||
|
# Absicht: sie lassen sich aus den Bildern jederzeit neu rechnen (Dashboard →
|
||||||
|
# Speicher → "Fehlende Vorschaubilder erzeugen"). Sie mitzusichern hiesse, jede
|
||||||
|
# Nacht Abgeleitetes mitzuschleppen. Wer zurueckspielt, drueckt den Knopf.
|
||||||
|
#
|
||||||
|
# Der Dienst laeuft die ganze Zeit weiter. Nichts hier haelt ihn an.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Ziel = 'C:\backup\d4rk_media',
|
||||||
|
[string]$Container = 'd4rk-media',
|
||||||
|
[string]$Volume = 'd4rk_media_media-data',
|
||||||
|
[string]$Abbild = 'd4rk-media:latest',
|
||||||
|
[int]$Behalten = 14,
|
||||||
|
# Leer heisst: aus den Einstellungen im Panel holen. Ein Wert hier hat
|
||||||
|
# Vorrang -- fuer den Fall, dass jemand einmalig woanders hinsichern will.
|
||||||
|
[string]$Zweitziel = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
|
||||||
|
function Gut($text) { Write-Host " $text" -ForegroundColor Green }
|
||||||
|
function Schlecht($text) { Write-Host " $text" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# Das Ergebnis in die Datenbank des Dienstes schreiben, damit es im Panel
|
||||||
|
# steht. Schlaegt DAS fehl, ist es kein Grund, die Sicherung als gescheitert zu
|
||||||
|
# behandeln — das Archiv liegt dann trotzdem richtig.
|
||||||
|
$inDerWolke = $false
|
||||||
|
$aufZweiter = $null
|
||||||
|
|
||||||
|
function Melde($ok, $dateien, $groesse, $hoch, $fehler) {
|
||||||
|
$eintrag = @{
|
||||||
|
at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
|
||||||
|
ok = [bool]$ok
|
||||||
|
dateien = $dateien
|
||||||
|
groesse = $groesse
|
||||||
|
hoch = [bool]$hoch
|
||||||
|
}
|
||||||
|
# $null heisst "kein zweites Ziel eingerichtet" und steht dann GAR NICHT im
|
||||||
|
# Bericht -- ein Feld, das immer "false" sagt, liest sich wie "hat nicht
|
||||||
|
# geklappt", und das waere eine Falschmeldung.
|
||||||
|
if ($null -ne $script:aufZweiter) { $eintrag['zweit'] = [bool]$script:aufZweiter }
|
||||||
|
if ($fehler) { $eintrag['fehler'] = "$fehler" }
|
||||||
|
$json = ($eintrag | ConvertTo-Json -Compress)
|
||||||
|
|
||||||
|
# Ueber die Standardeingabe und nicht als Argument: der JSON-Text enthaelt
|
||||||
|
# Anfuehrungszeichen, und die ueberleben den Weg durch zwei Shells nicht.
|
||||||
|
$skript = @"
|
||||||
|
let roh = '';
|
||||||
|
process.stdin.on('data', (d) => (roh += d)).on('end', () => {
|
||||||
|
const db = require('better-sqlite3')('/data/media.db');
|
||||||
|
db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
|
||||||
|
.run('sicherung.letzte', roh.trim(), Date.now());
|
||||||
|
});
|
||||||
|
"@ -replace "`r", ''
|
||||||
|
$json | docker exec -i $Container node -e $skript 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Vorbedingungen ------------------------------------------------------------
|
||||||
|
|
||||||
|
$laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}'
|
||||||
|
if ($laeuft -ne $Container) {
|
||||||
|
Schlecht "Der Container '$Container' laeuft nicht. Ohne ihn gibt es keine stimmige Aufnahme der Datenbank."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||||
|
$stempel = Get-Date -Format 'yyyy-MM-dd-HHmm'
|
||||||
|
$name = "d4rk_media-$stempel.tar.gz"
|
||||||
|
$archiv = Join-Path $Ziel $name
|
||||||
|
|
||||||
|
# -- 1. Datenbank stimmig herausholen ------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Datenbank sichern (SQLite-Sicherungsschnittstelle im Container)'
|
||||||
|
# Auch hier zusammenfuegen: die Ausgabe kaeme sonst als Array, und -match
|
||||||
|
# darauf filtert, statt zu pruefen (siehe uebernehmen.ps1). Solange es eine
|
||||||
|
# Zeile ist, faellt das nicht auf -- bei zweien schon.
|
||||||
|
$ausgabe = (docker exec $Container node dist/backup.js /data/.sicherung/media.db 2>&1) -join "`n"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Schlecht "Fehlgeschlagen: $ausgabe"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut $ausgabe
|
||||||
|
# Die erwartete Zeilenzahl steht in der Meldung -- sie ist der Massstab fuer
|
||||||
|
# Schritt 3.
|
||||||
|
$erwartet = if ($ausgabe -match '(\d+) Medieneintraege') { [int]$Matches[1] } else { -1 }
|
||||||
|
|
||||||
|
# -- 2. Alles in ein Archiv ----------------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Bilder und Datenbank packen'
|
||||||
|
# Drei -C: die Bilder kommen als files/... in das Archiv, die Aufnahme als
|
||||||
|
# media.db daneben, und der Papierkorb als papierkorb/. Bewusst das eigene
|
||||||
|
# Abbild statt alpine -- dort ist GNU tar drin, und das kann mehrere -C.
|
||||||
|
#
|
||||||
|
# DER PAPIERKORB MUSS MIT, und das ist nachgerechnet und nicht Vorsicht: seine
|
||||||
|
# EINTRAEGE stehen in media.db und waren damit laengst gesichert -- die DATEIEN
|
||||||
|
# lagen unter /data/papierkorb und waren es nicht. Nach einem Zurueckspielen
|
||||||
|
# haette der Papierkorb also Zeilen gezeigt, deren "zurueckholen" ins Leere
|
||||||
|
# greift. Ein Papierkorb, der ein Versprechen gibt, das er nach einer
|
||||||
|
# Wiederherstellung nicht halten kann, ist schlimmer als keiner.
|
||||||
|
#
|
||||||
|
# Er kostet fast nichts: was darin liegt, ist hoechstens 30 Tage alt, danach
|
||||||
|
# raeumt der Dienst selbst auf.
|
||||||
|
#
|
||||||
|
# Die Abfrage davor ist noetig: hat noch nie jemand etwas geloescht, gibt es den
|
||||||
|
# Ordner gar nicht, und tar braeche mit "Cannot stat" ab. Eine Sicherung, die
|
||||||
|
# daran scheitert, dass nichts im Papierkorb liegt, waere absurd. Das Volume
|
||||||
|
# haengt NUR LESEND -- ein `mkdir` als Ausweg ginge hier also ohnehin nicht.
|
||||||
|
$packen = "if [ -d /data/papierkorb ]; then tar czf '/out/$name' -C /data files -C /data/.sicherung media.db -C /data papierkorb; else tar czf '/out/$name' -C /data files -C /data/.sicherung media.db; fi"
|
||||||
|
docker run --rm `
|
||||||
|
-v "${Volume}:/data:ro" `
|
||||||
|
-v "${Ziel}:/out" `
|
||||||
|
--entrypoint sh $Abbild -c $packen
|
||||||
|
if ($LASTEXITCODE -ne 0) { Schlecht 'Packen fehlgeschlagen.'; exit 1 }
|
||||||
|
|
||||||
|
# Die Zwischenaufnahme im Volume wieder weg: sie gehoert nicht zum Bestand und
|
||||||
|
# wuerde bei der naechsten Sicherung mitgezaehlt.
|
||||||
|
docker exec $Container rm -rf /data/.sicherung | Out-Null
|
||||||
|
|
||||||
|
$groesse = (Get-Item $archiv).Length
|
||||||
|
Gut "$name — $([math]::Round($groesse/1MB, 1)) MB"
|
||||||
|
|
||||||
|
# -- 3. Nachzaehlen ------------------------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Sicherung pruefen (auspacken, Datenbank oeffnen, zaehlen)'
|
||||||
|
|
||||||
|
# Die Wagenruecklaeufe muessen raus. Diese Datei liegt unter Windows und wird
|
||||||
|
# je nach Werkzeug mit CRLF gespeichert; die Shell im Container liest dann
|
||||||
|
# "set -e\r" und antwortet mit "Illegal option -". Der Fehler sieht aus wie ein
|
||||||
|
# Tippfehler im Skript und ist keiner — genau einmal reingefallen.
|
||||||
|
$skript = (@"
|
||||||
|
set -e
|
||||||
|
mkdir -p /tmp/p && cd /tmp/p
|
||||||
|
tar xzf '/in/$name'
|
||||||
|
# Aus /app heraus starten und nicht aus /tmp/p: Node sucht node_modules vom
|
||||||
|
# Arbeitsverzeichnis aufwaerts, und ueber /tmp liegt kein better-sqlite3.
|
||||||
|
cd /app
|
||||||
|
node -e "
|
||||||
|
const db = require('better-sqlite3')('/tmp/p/media.db', { readonly: true, fileMustExist: true });
|
||||||
|
const m = db.prepare('SELECT COUNT(*) AS n FROM media').get().n;
|
||||||
|
const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n;
|
||||||
|
const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||||
|
console.log(JSON.stringify({ media: m, tokens: t, users: u }));
|
||||||
|
"
|
||||||
|
find /tmp/p/files -type f | wc -l
|
||||||
|
"@) -replace "`r", ''
|
||||||
|
|
||||||
|
$pruefung = docker run --rm -v "${Ziel}:/in:ro" --entrypoint sh $Abbild -c $skript
|
||||||
|
if ($LASTEXITCODE -ne 0) { Schlecht "Pruefung fehlgeschlagen: $pruefung"; exit 1 }
|
||||||
|
|
||||||
|
$zeilen = @($pruefung -split "`n" | Where-Object { $_.Trim() })
|
||||||
|
$zahlen = $zeilen[0] | ConvertFrom-Json
|
||||||
|
$dateien = [int]$zeilen[1].Trim()
|
||||||
|
|
||||||
|
Gut "in der Sicherung: $($zahlen.media) Medieneintraege, $dateien Dateien, $($zahlen.tokens) Token, $($zahlen.users) Benutzer"
|
||||||
|
|
||||||
|
if ($erwartet -ge 0 -and $zahlen.media -ne $erwartet) {
|
||||||
|
Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Eine Datei mehr oder weniger als Eintraege ist ein echter Befund und kein
|
||||||
|
# Rundungsfehler: dann liegt eine Datei ohne Datensatz herum oder umgekehrt.
|
||||||
|
if ($dateien -ne $zahlen.media) {
|
||||||
|
Write-Host " Hinweis: $dateien Dateien zu $($zahlen.media) Eintraegen — das sollte gleich sein." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.'
|
||||||
|
|
||||||
|
# -- Wohin ueberhaupt? Zugangsdaten und Ziele lesen ----------------------------
|
||||||
|
#
|
||||||
|
# Beides kommt aus derselben Quelle und wird deshalb an EINER Stelle geholt: die
|
||||||
|
# Nextcloud-Zugangsdaten und der Pfad des zweiten Laufwerks. Was danach kommt,
|
||||||
|
# sind die drei Ziele in der Reihenfolge, in der sie im Ernstfall gebraucht
|
||||||
|
# werden -- erst die Platte nebenan, dann die Wolke.
|
||||||
|
#
|
||||||
|
# Die Zugangsdaten liegen NEBEN den Sicherungen und nicht im Repo: sie gehoeren
|
||||||
|
# zu dieser Maschine, nicht zum Code. Fehlt die Datei, wird das einmal gesagt
|
||||||
|
# und lokal weitergesichert — eine Sicherung, die wegen eines fehlenden
|
||||||
|
# Passworts GAR NICHT stattfindet, waere der schlechtere Tausch.
|
||||||
|
#
|
||||||
|
# Aufbau der Datei (eine Zuweisung je Zeile, # ist ein Kommentar):
|
||||||
|
#
|
||||||
|
# basis = https://cdn.d4rkst3r.de
|
||||||
|
# benutzer = darkster
|
||||||
|
# passwort = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx <- APP-PASSWORT, nicht das echte
|
||||||
|
# ordner = Sicherungen/d4rk_media
|
||||||
|
#
|
||||||
|
# Das App-Passwort steht in Nextcloud unter Einstellungen → Sicherheit →
|
||||||
|
# "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne
|
||||||
|
# dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie.
|
||||||
|
|
||||||
|
# Die Werte kommen aus der Datenbank des Dienstes, eingetragen im Panel unter
|
||||||
|
# Einstellungen → Sicherung. Gelesen wird DIREKT ueber `docker exec` und nicht
|
||||||
|
# ueber die API: so verlaesst das Passwort den Rechner nie.
|
||||||
|
#
|
||||||
|
# nextcloud.txt neben den Sicherungen gilt weiterhin und hat VORRANG — wer sie
|
||||||
|
# angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird.
|
||||||
|
|
||||||
|
$z = @{}
|
||||||
|
$zugangDatei = Join-Path $Ziel 'nextcloud.txt'
|
||||||
|
|
||||||
|
if (Test-Path $zugangDatei) {
|
||||||
|
foreach ($zeile in Get-Content $zugangDatei) {
|
||||||
|
if ($zeile -match '^\s*#') { continue }
|
||||||
|
if ($zeile -match '^\s*([a-z]+)\s*=\s*(.+?)\s*$') { $z[$Matches[1]] = $Matches[2] }
|
||||||
|
}
|
||||||
|
Gut "Zugang aus $zugangDatei"
|
||||||
|
} else {
|
||||||
|
$roh = docker exec $Container node -e @"
|
||||||
|
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
|
||||||
|
const hol = (k) => { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(k); return r ? r.value : ''; };
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
basis: hol('sicherung.basis'),
|
||||||
|
benutzer: hol('sicherung.benutzer'),
|
||||||
|
passwort: hol('sicherung.passwort'),
|
||||||
|
ordner: hol('sicherung.ordner'),
|
||||||
|
behalten: hol('sicherung.behalten'),
|
||||||
|
zweitziel: hol('sicherung.zweitziel'),
|
||||||
|
}));
|
||||||
|
"@
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $roh) {
|
||||||
|
$j = $roh | ConvertFrom-Json
|
||||||
|
foreach ($k in 'basis', 'benutzer', 'passwort', 'ordner', 'behalten', 'zweitziel') {
|
||||||
|
if ($j.$k) { $z[$k] = $j.$k }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($z['behalten']) { $Behalten = [int]$z['behalten'] }
|
||||||
|
if (-not $Zweitziel -and $z['zweitziel']) { $Zweitziel = $z['zweitziel'] }
|
||||||
|
|
||||||
|
# -- 4. Auf die zweite Platte --------------------------------------------------
|
||||||
|
#
|
||||||
|
# Das billigste Ziel von allen -- ein Kopiervorgang im selben Rechner -- und das
|
||||||
|
# einzige, aus dem sich hundert Gigabyte in Minuten zurueckholen lassen.
|
||||||
|
#
|
||||||
|
# GEPRUEFT WIRD MIT DER PRUEFSUMME und nicht mit der Dateigroesse: eine
|
||||||
|
# abgebrochene Kopie auf eine volle Platte hat oft genau die richtige Laenge und
|
||||||
|
# trotzdem Nullen am Ende.
|
||||||
|
#
|
||||||
|
# UND ES WIRD GEWARNT, WENN ES DIESELBE PLATTE IST -- das ist der eigentliche
|
||||||
|
# Fehler, gegen den das hier gebaut ist. Ein zweiter Ordner auf C: sieht im
|
||||||
|
# Panel genauso gruen aus wie eine echte zweite Platte und hilft gegen gar
|
||||||
|
# nichts. Verglichen wird die PHYSISCHE Platte und nicht der Laufwerksbuchstabe:
|
||||||
|
# zwei Partitionen derselben NVMe sterben zusammen.
|
||||||
|
|
||||||
|
function PlattenNummer($pfad) {
|
||||||
|
# $null heisst "kein Urteil moeglich" (Netzfreigabe, ungewoehnliche
|
||||||
|
# Einhaengung) und ist ausdruecklich nicht dasselbe wie "andere Platte".
|
||||||
|
try {
|
||||||
|
$wurzel = [System.IO.Path]::GetPathRoot((Resolve-Path $pfad).Path)
|
||||||
|
$buchstabe = $wurzel.TrimEnd('\', ':')
|
||||||
|
if ($buchstabe.Length -ne 1) { return $null }
|
||||||
|
return (Get-Partition -DriveLetter $buchstabe -ErrorAction Stop).DiskNumber
|
||||||
|
} catch { return $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Zweitziel) {
|
||||||
|
Write-Host ' Kein zweites Ziel eingerichtet (Panel -> Einstellungen -> Sicherung).' -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Schritt "Auf die zweite Platte: $Zweitziel"
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Zweitziel -ErrorAction Stop | Out-Null
|
||||||
|
} catch {
|
||||||
|
Schlecht "Das zweite Ziel ist nicht erreichbar: $($_.Exception.Message)"
|
||||||
|
$aufZweiter = $false
|
||||||
|
Melde $false $zahlen.media $groesse $inDerWolke "Zweites Ziel: $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$hier = PlattenNummer $Ziel
|
||||||
|
$dort = PlattenNummer $Zweitziel
|
||||||
|
if ($null -ne $hier -and $hier -eq $dort) {
|
||||||
|
Write-Host " ACHTUNG: $Zweitziel liegt auf DERSELBEN Platte (Nr. $hier) wie $Ziel." -ForegroundColor Yellow
|
||||||
|
Write-Host ' Gegen einen Plattenausfall hilft das nicht -- ein anderes Laufwerk waehlen.' -ForegroundColor Yellow
|
||||||
|
} elseif ($null -ne $hier -and $null -ne $dort) {
|
||||||
|
Gut "andere Platte: Nr. $dort statt Nr. $hier"
|
||||||
|
}
|
||||||
|
|
||||||
|
$zweitDatei = Join-Path $Zweitziel $name
|
||||||
|
try {
|
||||||
|
Copy-Item $archiv $zweitDatei -Force -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
Schlecht "Kopieren fehlgeschlagen: $($_.Exception.Message)"
|
||||||
|
$aufZweiter = $false
|
||||||
|
Melde $false $zahlen.media $groesse $inDerWolke "Zweites Ziel: $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash
|
||||||
|
$dortHash = (Get-FileHash $zweitDatei -Algorithm SHA256).Hash
|
||||||
|
if ($hierHash -ne $dortHash) {
|
||||||
|
Schlecht 'Die Kopie auf der zweiten Platte ist NICHT identisch.'
|
||||||
|
Remove-Item $zweitDatei -Force -ErrorAction SilentlyContinue
|
||||||
|
$aufZweiter = $false
|
||||||
|
Melde $false $zahlen.media $groesse $inDerWolke 'Pruefsumme der zweiten Kopie weicht ab'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut "kopiert und verglichen: identisch ($($hierHash.Substring(0,12))...)"
|
||||||
|
$aufZweiter = $true
|
||||||
|
|
||||||
|
# Auch dort ausduennen, sonst laeuft die zweite Platte still voll.
|
||||||
|
$dortAlle = Get-ChildItem $Zweitziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending
|
||||||
|
if ($dortAlle.Count -gt $Behalten) {
|
||||||
|
$dortAlle | Select-Object -Skip $Behalten | ForEach-Object {
|
||||||
|
Remove-Item $_.FullName -Force
|
||||||
|
Gut "auf der zweiten Platte entfernt: $($_.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- 4a. In die Nextcloud ------------------------------------------------------
|
||||||
|
|
||||||
|
if (-not ($z['basis'] -and $z['benutzer'] -and $z['passwort'])) {
|
||||||
|
Write-Host ' Keine Nextcloud eingerichtet (Panel → Einstellungen → Sicherung) — die Sicherung bleibt lokal.' -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' }
|
||||||
|
|
||||||
|
$basis = $z['basis'].TrimEnd('/')
|
||||||
|
$wurzel = "$basis/remote.php/dav/files/$($z['benutzer'])"
|
||||||
|
$auth = "$($z['benutzer']):$($z['passwort'])"
|
||||||
|
|
||||||
|
Schritt "In die Nextcloud: $($z['ordner'])"
|
||||||
|
|
||||||
|
# Die Ordner Ebene fuer Ebene anlegen. MKCOL legt NUR EINE an; auf einen
|
||||||
|
# fehlenden Zwischenschritt antwortet es mit 409, und dann sucht man den
|
||||||
|
# Fehler beim Passwort.
|
||||||
|
$pfadBisher = ''
|
||||||
|
foreach ($teil in $z['ordner'].Split('/')) {
|
||||||
|
if (-not $teil) { continue }
|
||||||
|
$pfadBisher = if ($pfadBisher) { "$pfadBisher/$teil" } else { $teil }
|
||||||
|
# 201 = angelegt, 405 = gab es schon. Beides ist in Ordnung.
|
||||||
|
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X MKCOL "$wurzel/$pfadBisher"
|
||||||
|
if ($code -notin @('201', '405')) {
|
||||||
|
$fehlertext = "MKCOL $pfadBisher -> HTTP $code"
|
||||||
|
if ($code -eq '401') { $fehlertext += ' (Benutzer oder App-Passwort stimmt nicht)' }
|
||||||
|
Schlecht $fehlertext
|
||||||
|
Melde $false $zahlen.media $groesse $false $fehlertext
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fern = "$wurzel/$($z['ordner'])/$name"
|
||||||
|
$code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $fern
|
||||||
|
if ($code -notin @('201', '204')) {
|
||||||
|
Schlecht "Hochladen fehlgeschlagen -> HTTP $code"
|
||||||
|
Melde $false $zahlen.media $groesse $false "Hochladen -> HTTP $code"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut "hochgeladen ($code)"
|
||||||
|
|
||||||
|
# NACHZAEHLEN, auch hier: zurueckholen und die Pruefsumme vergleichen. Ein
|
||||||
|
# PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene
|
||||||
|
# Uebertragungen sehen genauso aus. Bei ein paar Megabyte ist das billig.
|
||||||
|
$zurueck = Join-Path $env:TEMP "pruefe-$name"
|
||||||
|
curl.exe -s -u $auth -o $zurueck $fern
|
||||||
|
$hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash
|
||||||
|
$dortHash = if (Test-Path $zurueck) { (Get-FileHash $zurueck -Algorithm SHA256).Hash } else { '' }
|
||||||
|
Remove-Item $zurueck -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($hierHash -ne $dortHash) {
|
||||||
|
Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.'
|
||||||
|
Melde $false $zahlen.media $groesse $false 'Pruefsumme der Kopie weicht ab'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)"
|
||||||
|
$inDerWolke = $true
|
||||||
|
|
||||||
|
# Auch draussen ausduennen. Sonst waechst die Nextcloud still voll, und das
|
||||||
|
# faellt erst auf, wenn sie voll ist.
|
||||||
|
$liste = (curl.exe -s -u $auth -X PROPFIND -H 'Depth: 1' "$wurzel/$($z['ordner'])") -join "`n"
|
||||||
|
$fernListe = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') |
|
||||||
|
ForEach-Object { $_.Value } | Sort-Object -Unique -Descending)
|
||||||
|
if ($fernListe.Count -gt $Behalten) {
|
||||||
|
foreach ($alt in $fernListe | Select-Object -Skip $Behalten) {
|
||||||
|
$c = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X DELETE "$wurzel/$($z['ordner'])/$alt"
|
||||||
|
if ($c -in @('204', '200')) { Gut "in der Nextcloud entfernt: $alt" }
|
||||||
|
else { Write-Host " konnte $alt nicht entfernen (HTTP $c)" -ForegroundColor Yellow }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- 5. Alte Sicherungen ausduennen (lokal) ------------------------------------
|
||||||
|
|
||||||
|
$alle = Get-ChildItem $Ziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending
|
||||||
|
if ($alle.Count -gt $Behalten) {
|
||||||
|
Schritt "Alte Sicherungen entfernen (mehr als $Behalten)"
|
||||||
|
$alle | Select-Object -Skip $Behalten | ForEach-Object {
|
||||||
|
Remove-Item $_.FullName -Force
|
||||||
|
Gut "entfernt: $($_.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Melde $true $zahlen.media $groesse $inDerWolke $null
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Gut "Fertig: $archiv"
|
||||||
|
Write-Host " Zurueckspielen: tar xzf <archiv>, dann media.db, files/ und papierkorb/" -ForegroundColor DarkGray
|
||||||
|
Write-Host " ins Volume legen," -ForegroundColor DarkGray
|
||||||
|
Write-Host " waehrend der Container steht. Siehe ROADMAP." -ForegroundColor DarkGray
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Richtet den SSH-Tunnel zum Server als HINTERGRUNDAUFGABE ein.
|
||||||
|
#
|
||||||
|
# LÄUFT AUF DEM HEIMRECHNER, nicht auf dem Server.
|
||||||
|
#
|
||||||
|
# pwsh -File tunnel-einrichten.ps1
|
||||||
|
# pwsh -File tunnel-einrichten.ps1 -Benutzer Florian -LokalerPort 13390
|
||||||
|
#
|
||||||
|
# WOZU. Seit dem 13.08.2026 ist RDP am Server gesperrt (Hintergrund:
|
||||||
|
# docs/vorfall-2026-08-12-gitea.md). Hinein geht es nur noch durch einen
|
||||||
|
# SSH-Tunnel mit Schlüssel. Von Hand hieße das: vor jeder Sitzung ein
|
||||||
|
# PowerShell-Fenster öffnen, einen Befehl tippen, das Fenster offen lassen.
|
||||||
|
#
|
||||||
|
# Das hält niemand durch, und was niemand durchhält, wird umgangen -- am Ende
|
||||||
|
# steht dann wieder ein offener Port. Deshalb wird der Tunnel hier zu etwas,
|
||||||
|
# das man nicht sieht: eine Aufgabe, die beim Anmelden startet, sich bei
|
||||||
|
# Verbindungsabbruch selbst neu aufbaut und kein Fenster zeigt.
|
||||||
|
#
|
||||||
|
# Danach verbindet man sich wie immer -- nur nicht mehr auf die Server-Adresse,
|
||||||
|
# sondern auf localhost mit dem hier eingestellten Port.
|
||||||
|
#
|
||||||
|
# WAS ES BRAUCHT: einen SSH-Schlüssel, dessen öffentlicher Teil auf dem Server
|
||||||
|
# hinterlegt ist. Fehlt einer, legt dieses Skript ihn an und zeigt, was zu
|
||||||
|
# schicken ist.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Server = '88.218.224.10',
|
||||||
|
# KEIN Standard aus $env:USERNAME. Der lokale Windows-Name und das Konto auf
|
||||||
|
# dem Server sind oft verschieden -- hier hiess der eine "az319" und der
|
||||||
|
# andere "Darkster". Das Skript lief dann sauber durch und der Tunnel kam
|
||||||
|
# trotzdem nicht, mit einer Fehlermeldung, die auf den Schluessel zeigte
|
||||||
|
# statt auf den Namen. Lieber einmal fragen als einmal in die Irre fuehren.
|
||||||
|
[string]$Benutzer,
|
||||||
|
# Der Port auf DIESEM Rechner. Nicht 3389 nehmen: Windows lauscht dort
|
||||||
|
# selbst, und dann landet die Verbindung auf dem eigenen PC statt im Tunnel
|
||||||
|
# -- mit der irreführenden Meldung "Konsolensitzung bereits aktiv".
|
||||||
|
[int]$LokalerPort = 13389,
|
||||||
|
[int]$ZielPort = 3389,
|
||||||
|
[string]$AufgabenName = 'SSH-Tunnel zum Rootserver'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not $Benutzer) {
|
||||||
|
Write-Host "Wie heisst dein Konto AUF DEM SERVER? (nicht der Name hier am PC)" -ForegroundColor Yellow
|
||||||
|
$Benutzer = (Read-Host ' Benutzername').Trim()
|
||||||
|
if (-not $Benutzer) { Write-Host ' Ohne Benutzernamen geht es nicht.' -ForegroundColor Red; exit 1 }
|
||||||
|
}
|
||||||
|
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
|
||||||
|
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
||||||
|
function Warnung($t) { Write-Host " $t" -ForegroundColor Yellow }
|
||||||
|
|
||||||
|
# -- 1. Ist überhaupt ein SSH-Client da? ---------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Voraussetzungen prüfen'
|
||||||
|
$ssh = (Get-Command ssh.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $ssh) {
|
||||||
|
Write-Host ' Kein ssh.exe gefunden. Unter Windows 10/11 nachinstallieren mit:' -ForegroundColor Red
|
||||||
|
Write-Host ' Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0' -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut "ssh.exe: $ssh"
|
||||||
|
|
||||||
|
# -- 2. Schlüssel ---------------------------------------------------------------
|
||||||
|
|
||||||
|
$schluessel = Join-Path $env:USERPROFILE '.ssh\id_ed25519'
|
||||||
|
if (-not (Test-Path $schluessel)) {
|
||||||
|
Schritt 'Es gibt noch keinen Schlüssel -- einen anlegen'
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path $schluessel) | Out-Null
|
||||||
|
# Ohne Passphrase, damit die Hintergrundaufgabe ohne Rückfrage startet. Der
|
||||||
|
# Schutz liegt dann darin, dass die Datei nur diesem Windows-Konto gehört.
|
||||||
|
# Wer eine Passphrase will, nimmt sie -- muss den Schlüssel dann aber in den
|
||||||
|
# ssh-agent legen, sonst hängt die Aufgabe an der Abfrage.
|
||||||
|
& ssh-keygen -t ed25519 -N '""' -f $schluessel -C "$Benutzer-$env:COMPUTERNAME" | Out-Null
|
||||||
|
Gut 'Schlüssel angelegt.'
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' ===> DIESE ZEILE muss auf dem Server hinterlegt werden:' -ForegroundColor Yellow
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host (" " + (Get-Content "$schluessel.pub")) -ForegroundColor White
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' Schick sie demjenigen, der den Server betreut. Erst danach' -ForegroundColor Yellow
|
||||||
|
Write-Host ' funktioniert der Tunnel -- das Skript richtet ihn trotzdem' -ForegroundColor Yellow
|
||||||
|
Write-Host ' schon ein.' -ForegroundColor Yellow
|
||||||
|
Write-Host ''
|
||||||
|
} else {
|
||||||
|
Gut "Schlüssel vorhanden: $schluessel"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- 3. Ist der lokale Port frei? ----------------------------------------------
|
||||||
|
#
|
||||||
|
# Vorher prüfen und nicht hinterher rätseln: ist er belegt, bindet ssh nicht,
|
||||||
|
# und die RDP-Verbindung landet bei dem Programm, das dort schon lauscht.
|
||||||
|
|
||||||
|
Schritt "Lokalen Port $LokalerPort prüfen"
|
||||||
|
$belegt = Get-NetTCPConnection -State Listen -LocalPort $LokalerPort -ErrorAction SilentlyContinue
|
||||||
|
if ($belegt) {
|
||||||
|
$wer = (Get-Process -Id $belegt[0].OwningProcess -ErrorAction SilentlyContinue).ProcessName
|
||||||
|
Write-Host " Port $LokalerPort ist belegt (von $wer). Nimm einen anderen:" -ForegroundColor Red
|
||||||
|
Write-Host " pwsh -File tunnel-einrichten.ps1 -LokalerPort $($LokalerPort + 1)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Gut 'frei'
|
||||||
|
|
||||||
|
# -- 4. Das Tunnelskript ablegen ------------------------------------------------
|
||||||
|
#
|
||||||
|
# Eine Schleife und kein einzelner Aufruf: bei Zwangstrennung, Standby oder
|
||||||
|
# kurzem Leitungsaussetzer endet ssh, und ohne Schleife wäre der Tunnel dann
|
||||||
|
# weg -- ohne dass es jemand merkt, bis die nächste Verbindung scheitert.
|
||||||
|
|
||||||
|
$ordner = Join-Path $env:LOCALAPPDATA 'SSH-Tunnel'
|
||||||
|
New-Item -ItemType Directory -Force -Path $ordner | Out-Null
|
||||||
|
$skript = Join-Path $ordner 'tunnel.ps1'
|
||||||
|
|
||||||
|
@"
|
||||||
|
# Erzeugt von tunnel-einrichten.ps1 -- von Hand ändern lohnt nicht,
|
||||||
|
# lieber das Einrichtungsskript neu laufen lassen.
|
||||||
|
#
|
||||||
|
# PROTOKOLL, weil es kein Fenster mehr gibt: die Aufgabe laeuft ausserhalb der
|
||||||
|
# Sitzung, also sieht niemand mehr, was ssh sagt. Ohne diese Datei waere ein
|
||||||
|
# abgelehnter Schluessel oder ein besetzter Port unsichtbar -- der Tunnel
|
||||||
|
# stuende einfach nicht, und niemand wuesste warum.
|
||||||
|
`$log = Join-Path `$PSScriptRoot 'tunnel.log'
|
||||||
|
if ((Test-Path `$log) -and (Get-Item `$log).Length -gt 1MB) { Remove-Item `$log -Force }
|
||||||
|
|
||||||
|
while (`$true) {
|
||||||
|
("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] verbinde ...") | Add-Content `$log
|
||||||
|
# -N: nur weiterleiten, keine Shell.
|
||||||
|
# ExitOnForwardFailure: lieber sofort scheitern und neu versuchen, als
|
||||||
|
# scheinbar zu laufen, während die Weiterleitung gar nicht steht.
|
||||||
|
# ServerAliveInterval: erkennt tote Leitungen, statt still einzufrieren.
|
||||||
|
& '$ssh' -N ``
|
||||||
|
-o ExitOnForwardFailure=yes ``
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 ``
|
||||||
|
-o StrictHostKeyChecking=accept-new ``
|
||||||
|
-L ${LokalerPort}:localhost:${ZielPort} ``
|
||||||
|
$Benutzer@$Server 2>&1 | Add-Content `$log
|
||||||
|
("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] beendet, neuer Versuch in 5 s") | Add-Content `$log
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
}
|
||||||
|
"@ | Set-Content -Path $skript -Encoding UTF8
|
||||||
|
|
||||||
|
Gut "Tunnelskript: $skript"
|
||||||
|
|
||||||
|
# -- 5. Als Aufgabe beim Anmelden ----------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Als Hintergrundaufgabe eintragen'
|
||||||
|
Unregister-ScheduledTask -TaskName $AufgabenName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $pwsh) { $pwsh = (Get-Command powershell.exe).Source }
|
||||||
|
|
||||||
|
# EIN VBS-STARTER ALS ZWISCHENSCHICHT, und der Umweg hat zwei Gruende.
|
||||||
|
#
|
||||||
|
# 1. "-WindowStyle Hidden" versteckt ein Konsolenprogramm nur halb. Die Aufgabe
|
||||||
|
# lief interaktiv in der Anmeldesitzung, bekam dort ein Fenster -- und wer
|
||||||
|
# es zumacht, kappt den Tunnel, ohne zu wissen was er tut.
|
||||||
|
# 2. Der naheliegende Ausweg, die Aufgabe per S4U ausserhalb der Sitzung laufen
|
||||||
|
# zu lassen, braucht erhoehte Rechte. In einem gewoehnlichen Fenster
|
||||||
|
# scheitert das Eintragen mit "Zugriff verweigert" -- und ein Werkzeug, das
|
||||||
|
# Administratorrechte verlangt, benutzt am Ende niemand.
|
||||||
|
#
|
||||||
|
# wscript.exe startet mit Fenstermodus 0, und das heisst wirklich unsichtbar.
|
||||||
|
# Es braucht keine besonderen Rechte, weil nichts Besonderes passiert.
|
||||||
|
$starter = Join-Path $ordner 'tunnel.vbs'
|
||||||
|
@"
|
||||||
|
' Erzeugt von tunnel-einrichten.ps1. Startet den Tunnel OHNE Fenster.
|
||||||
|
' Die 0 im Run-Aufruf ist der ganze Trick: Fenstermodus "unsichtbar".
|
||||||
|
Set sh = CreateObject("WScript.Shell")
|
||||||
|
sh.Run """$pwsh"" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ""$skript""", 0, False
|
||||||
|
"@ | Set-Content -Path $starter -Encoding ASCII
|
||||||
|
|
||||||
|
$aktion = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "`"$starter`""
|
||||||
|
$ausloeser = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"
|
||||||
|
# Interactive und NICHT S4U: S4U braucht Administratorrechte zum Eintragen.
|
||||||
|
# Das Fenster verhindert der VBS-Starter, nicht der Anmeldetyp.
|
||||||
|
$prinzipal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
||||||
|
$optionen = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $AufgabenName -Action $aktion -Trigger $ausloeser `
|
||||||
|
-Principal $prinzipal -Settings $optionen `
|
||||||
|
-Description "Haelt den SSH-Tunnel zu $Server offen. RDP verbindet sich danach auf localhost:$LokalerPort." | Out-Null
|
||||||
|
Gut 'eingetragen (startet künftig bei jeder Anmeldung)'
|
||||||
|
|
||||||
|
# -- 6. Jetzt starten und nachsehen ---------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Starten und nachmessen'
|
||||||
|
Start-ScheduledTask -TaskName $AufgabenName
|
||||||
|
$bis = (Get-Date).AddSeconds(25)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$steht = Get-NetTCPConnection -State Listen -LocalPort $LokalerPort -ErrorAction SilentlyContinue
|
||||||
|
} while (-not $steht -and (Get-Date) -lt $bis)
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
if ($steht) {
|
||||||
|
Gut "Der Tunnel steht: localhost:$LokalerPort -> ${Server}:$ZielPort"
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host " In der Fernwartung ab jetzt eintragen:" -ForegroundColor White
|
||||||
|
Write-Host " Hostname : localhost" -ForegroundColor White
|
||||||
|
Write-Host " Port : $LokalerPort" -ForegroundColor White
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host " Nicht mehr auf $Server verbinden -- das ist gesperrt." -ForegroundColor DarkGray
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' Es gibt jetzt KEIN Fenster mehr. Nachsehen kann man so:' -ForegroundColor DarkGray
|
||||||
|
Write-Host " Get-NetTCPConnection -LocalPort $LokalerPort -State Listen" -ForegroundColor DarkGray
|
||||||
|
Write-Host " Get-Content '$(Join-Path $ordner 'tunnel.log')' -Tail 20" -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
Warnung "Der Tunnel steht noch nicht auf Port $LokalerPort."
|
||||||
|
Write-Host ''
|
||||||
|
# NACHSEHEN STATT RATEN. Die erste Fassung behauptete hier "der Schluessel
|
||||||
|
# fehlt auf dem Server" -- und lag falsch: es war der Benutzername. Eine
|
||||||
|
# geratene Ursache schickt den Suchenden in die falsche Richtung, und das
|
||||||
|
# kostet mehr Zeit als gar kein Hinweis.
|
||||||
|
Schritt 'Nachsehen, woran es liegt'
|
||||||
|
$antwort = & $ssh -o BatchMode=yes -o ConnectTimeout=10 `
|
||||||
|
-o StrictHostKeyChecking=accept-new "$Benutzer@$Server" 'exit' 2>&1 | Out-String
|
||||||
|
|
||||||
|
if ($antwort -match 'Permission denied') {
|
||||||
|
Warnung 'Der Server nimmt den Schluessel nicht an. Zwei Ursachen sind haeufig:'
|
||||||
|
Warnung " - Der Benutzername stimmt nicht. Du hast '$Benutzer' benutzt --"
|
||||||
|
Warnung ' ist das dein Konto AUF DEM SERVER, nicht das hier am PC?'
|
||||||
|
Warnung ' - Der oeffentliche Schluessel ist dort noch nicht hinterlegt:'
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host (" " + (Get-Content "$schluessel.pub")) -ForegroundColor White
|
||||||
|
} elseif ($antwort -match 'Could not resolve|timed out|refused|unreachable') {
|
||||||
|
Warnung "Der Server ist nicht erreichbar: $($antwort.Trim())"
|
||||||
|
} elseif ($antwort.Trim()) {
|
||||||
|
Warnung "ssh sagt: $($antwort.Trim())"
|
||||||
|
} else {
|
||||||
|
Warnung 'ssh selbst kommt durch -- dann klemmt es an der Weiterleitung.'
|
||||||
|
Warnung "Steht auf dem SERVER etwas auf Port $ZielPort und laesst er"
|
||||||
|
Warnung 'Weiterleitungen zu (AllowTcpForwarding)?'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Dateien aus der Nextcloud in d4rk_media uebernehmen.
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# pwsh -File tools\uebernehmen.ps1 -Von 'CDN/brands' -Nach brands
|
||||||
|
# pwsh -File tools\uebernehmen.ps1 -Von 'CDN/items' -Nach items -Probe
|
||||||
|
#
|
||||||
|
# -Von Pfad IN der Nextcloud, relativ zum eigenen Ordner
|
||||||
|
# -Nach Zielordner im Dienst. Leer = der letzte Teil von -Von
|
||||||
|
# -Probe nur zeigen, nichts hochladen
|
||||||
|
#
|
||||||
|
# UNTERORDNER BLEIBEN ERHALTEN: aus CDN/brands/logos/x.png wird
|
||||||
|
# brands/logos/x.png. Wer das nicht will, sortiert vorher in der Nextcloud um.
|
||||||
|
#
|
||||||
|
# WARUM UEBER DAS DASHBOARD UND NICHT UEBER DIE TOKEN-API. Nextcloud-Dateien
|
||||||
|
# heissen "Brand Logo (final).PNG" -- checkPath lehnt das mit Recht ab, denn
|
||||||
|
# Klammern und Leerzeichen haben in einer oeffentlichen Adresse nichts zu
|
||||||
|
# suchen. Der Dashboard-Weg biegt den Namen bereits VORHERSAGBAR zurecht
|
||||||
|
# (brand-logo-final.png). Dieselbe Regel an einer Stelle ist besser als
|
||||||
|
# dieselbe Regel an zweien, von denen eine irgendwann abweicht.
|
||||||
|
#
|
||||||
|
# WIEDERHOLBAR: hochgeladen wird mit overwrite=false. Was schon da ist, meldet
|
||||||
|
# der Dienst je Datei als "Pfad ist belegt" -- der Lauf laesst sich also
|
||||||
|
# jederzeit abbrechen und noch einmal starten, ohne 500 Dateien erneut zu
|
||||||
|
# schieben.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Von,
|
||||||
|
[string]$Nach = '',
|
||||||
|
[switch]$Probe,
|
||||||
|
[string]$Dienst = 'https://media.d4rkst3r.de',
|
||||||
|
[string]$Container = 'd4rk-media',
|
||||||
|
[int]$ProStapel = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
|
||||||
|
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
||||||
|
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
|
||||||
|
|
||||||
|
if (-not $Nach) { $Nach = ($Von.TrimEnd('/') -split '/')[-1] }
|
||||||
|
$Nach = $Nach.Trim('/')
|
||||||
|
|
||||||
|
# -- Zugang zur Nextcloud ------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Dieselbe Quelle wie bei der Sicherung: die Werte stehen im Panel unter
|
||||||
|
# Einstellungen und werden hier direkt aus der Datenbank gelesen. So gibt es
|
||||||
|
# EINEN Ort fuer diese Zugangsdaten und nicht zwei.
|
||||||
|
|
||||||
|
$roh = docker exec $Container node -e @"
|
||||||
|
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
|
||||||
|
const hol = (k) => { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(k); return r ? r.value : ''; };
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
basis: hol('sicherung.basis'), benutzer: hol('sicherung.benutzer'), passwort: hol('sicherung.passwort'),
|
||||||
|
}));
|
||||||
|
"@
|
||||||
|
if ($LASTEXITCODE -ne 0) { Schlecht 'Der Container antwortet nicht.'; exit 1 }
|
||||||
|
$nc = $roh | ConvertFrom-Json
|
||||||
|
|
||||||
|
if (-not ($nc.basis -and $nc.benutzer -and $nc.passwort)) {
|
||||||
|
Schlecht 'Keine Nextcloud hinterlegt. Panel -> Einstellungen -> Sicherung ausfuellen.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$wurzel = "$($nc.basis.TrimEnd('/'))/remote.php/dav/files/$($nc.benutzer)"
|
||||||
|
$auth = "$($nc.benutzer):$($nc.passwort)"
|
||||||
|
|
||||||
|
# -- Was liegt dort ------------------------------------------------------------
|
||||||
|
|
||||||
|
Schritt "In der Nextcloud nachsehen: $Von"
|
||||||
|
|
||||||
|
# Depth: infinity nennt auch alles in Unterordnern. Manche Server verweigern
|
||||||
|
# das; dann bleibt nur Ebene fuer Ebene, und das sagen wir auch.
|
||||||
|
# Das -join ist NICHT Kosmetik. curl.exe gibt seine Antwort zeilenweise als
|
||||||
|
# ARRAY zurueck, und -match/-notmatch auf einem Array FILTERT, statt wahr oder
|
||||||
|
# falsch zu liefern: ein nicht-leeres Ergebnis gilt als wahr, und damit schlug
|
||||||
|
# die Pruefung unten immer an -- bei einer tadellosen Antwort mit HTTP 207.
|
||||||
|
$xml = (curl.exe -s -u $auth -X PROPFIND -H 'Depth: infinity' "$wurzel/$Von") -join "`n"
|
||||||
|
if (-not $xml -or $xml -notmatch '<[dD]:multistatus') {
|
||||||
|
Schlecht "Kein Verzeichnis gefunden. Antwort: $($xml -replace '\s+', ' ' | Select-Object -First 1)"
|
||||||
|
Schlecht 'Stimmt der Pfad? Er ist relativ zum eigenen Nextcloud-Ordner, ohne fuehrenden Schraegstrich.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Die Antwort ist XML mit Namensraumpraefix (d: oder D:). Statt einen Parser
|
||||||
|
# darauf anzusetzen, holen wir die beiden Felder, auf die es ankommt.
|
||||||
|
$eintraege = @()
|
||||||
|
foreach ($m in [regex]::Matches($xml, '(?s)<[dD]:response>(.*?)</[dD]:response>')) {
|
||||||
|
$block = $m.Groups[1].Value
|
||||||
|
if ($block -match '<[dD]:collection\s*/>') { continue } # Ordner selbst
|
||||||
|
if ($block -notmatch '<[dD]:href>(.*?)</[dD]:href>') { continue }
|
||||||
|
|
||||||
|
$href = [uri]::UnescapeDataString($Matches[1])
|
||||||
|
$groesse = if ($block -match '<[dD]:getcontentlength>(\d+)<') { [long]$Matches[1] } else { 0 }
|
||||||
|
|
||||||
|
# Der href traegt den vollen DAV-Pfad. Wir wollen den Teil UNTERHALB von
|
||||||
|
# -Von, damit die Unterordner erhalten bleiben.
|
||||||
|
$praefix = "/remote.php/dav/files/$($nc.benutzer)/$Von/"
|
||||||
|
if ($href -like "*$praefix*") {
|
||||||
|
$rel = $href.Substring($href.IndexOf($praefix) + $praefix.Length)
|
||||||
|
if ($rel) { $eintraege += [pscustomobject]@{ rel = $rel; href = $href; size = $groesse } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eintraege.Count -eq 0) { Schlecht 'Keine Dateien gefunden.'; exit 1 }
|
||||||
|
|
||||||
|
$summe = ($eintraege | Measure-Object -Property size -Sum).Sum
|
||||||
|
Gut "$($eintraege.Count) Dateien, $([math]::Round($summe/1MB,1)) MB"
|
||||||
|
|
||||||
|
# Nach Zielordner gruppieren: der Dashboard-Weg nimmt EINEN Ordner je Anfrage.
|
||||||
|
$gruppen = $eintraege | Group-Object {
|
||||||
|
$teile = $_.rel -split '/'
|
||||||
|
if ($teile.Count -gt 1) { "$Nach/" + ($teile[0..($teile.Count - 2)] -join '/') } else { $Nach }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
foreach ($g in $gruppen) {
|
||||||
|
Write-Host " $($g.Name)" -NoNewline -ForegroundColor DarkGray
|
||||||
|
Write-Host " $($g.Count) Dateien"
|
||||||
|
}
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
if ($Probe) {
|
||||||
|
Gut 'Probelauf — nichts hochgeladen. Ohne -Probe geht es wirklich los.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Anmelden am Dienst --------------------------------------------------------
|
||||||
|
|
||||||
|
$envDatei = Join-Path (Split-Path $PSScriptRoot -Parent) '.env'
|
||||||
|
if (-not (Test-Path $envDatei)) { Schlecht "Keine .env neben dem Compose gefunden."; exit 1 }
|
||||||
|
$pw = (Select-String -Path $envDatei -Pattern '^ADMIN_PASSWORD=(.+)$').Matches.Groups[1].Value
|
||||||
|
$benutzer = 'admin'
|
||||||
|
$m = Select-String -Path $envDatei -Pattern '^ADMIN_USERNAME=(.+)$'
|
||||||
|
if ($m) { $benutzer = $m.Matches.Groups[1].Value }
|
||||||
|
|
||||||
|
$keksglas = Join-Path $env:TEMP ('keks-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||||
|
$anmeldung = @{ username = $benutzer; password = $pw } | ConvertTo-Json -Compress
|
||||||
|
$code = $anmeldung | curl.exe -s -o NUL -w '%{http_code}' -c $keksglas `
|
||||||
|
-X POST "$Dienst/api/dash/auth/login" -H 'content-type: application/json' -d '@-'
|
||||||
|
if ($code -ne '200') { Schlecht "Anmeldung am Dienst fehlgeschlagen (HTTP $code)."; exit 1 }
|
||||||
|
|
||||||
|
# -- Uebertragen ---------------------------------------------------------------
|
||||||
|
|
||||||
|
$tmp = Join-Path $env:TEMP ('uebernahme-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
|
||||||
|
$hoch = 0; $schon = 0; $fehler = 0
|
||||||
|
|
||||||
|
foreach ($g in $gruppen) {
|
||||||
|
$zielordner = $g.Name
|
||||||
|
Schritt "$zielordner ($($g.Count) Dateien)"
|
||||||
|
|
||||||
|
foreach ($stapel in ($g.Group | ForEach-Object -Begin { $i = 0; $puffer = @() } -Process {
|
||||||
|
$puffer += $_
|
||||||
|
if ($puffer.Count -ge $ProStapel) { , $puffer; $puffer = @() }
|
||||||
|
} -End { if ($puffer.Count) { , $puffer } })) {
|
||||||
|
|
||||||
|
$dateien = @()
|
||||||
|
|
||||||
|
foreach ($e in $stapel) {
|
||||||
|
$name = ($e.rel -split '/')[-1]
|
||||||
|
$lokal = Join-Path $tmp $name
|
||||||
|
curl.exe -s -u $auth -o $lokal "$($nc.basis.TrimEnd('/'))$($e.href)"
|
||||||
|
if (Test-Path $lokal) { $dateien += Get-Item $lokal }
|
||||||
|
}
|
||||||
|
if (-not $dateien) { continue }
|
||||||
|
|
||||||
|
# HOCHGELADEN WIRD MIT CURL UND NICHT MIT Invoke-RestMethod -Form.
|
||||||
|
#
|
||||||
|
# Nicht aus Geschmack: .NET schreibt im Formular-Rumpf
|
||||||
|
# "Content-Disposition: form-data; name=file" OHNE Anfuehrungszeichen,
|
||||||
|
# RFC 7578 verlangt sie, und Nodes Parser besteht darauf. Der Dienst
|
||||||
|
# antwortet dann mit "Der Formular-Rumpf liess sich nicht lesen".
|
||||||
|
# Mitgeschnitten und nachgemessen -- curl setzt beides korrekt.
|
||||||
|
$argumente = @(
|
||||||
|
'-s', '-b', $keksglas,
|
||||||
|
'-X', 'POST', "$Dienst/api/dash/upload",
|
||||||
|
'-F', "folder=$zielordner",
|
||||||
|
'-F', 'overwrite=false'
|
||||||
|
)
|
||||||
|
foreach ($d in $dateien) { $argumente += @('-F', "file=@$($d.FullName)") }
|
||||||
|
|
||||||
|
# Den HTTP-Code MIT abfragen. Vorher stand hier nur ConvertFrom-Json,
|
||||||
|
# und als der Dienst wegbrach, meldete das Skript
|
||||||
|
# "Conversion from JSON failed ... Unexpected character <" -- also
|
||||||
|
# einen Parser-Fehler, obwohl die Wahrheit ein 502 vom Proxy war. Eine
|
||||||
|
# Fehlermeldung, die die Ursache verschweigt, kostet genau die Zeit,
|
||||||
|
# die sie sparen soll.
|
||||||
|
$argumente += @('-w', "`n%{http_code}")
|
||||||
|
$antwort = (& curl.exe @argumente) -join "`n"
|
||||||
|
$code = ($antwort -split "`n")[-1]
|
||||||
|
$rumpf = ($antwort -split "`n")[0..(($antwort -split "`n").Count - 2)] -join "`n"
|
||||||
|
|
||||||
|
if ($code -ne '200') {
|
||||||
|
$fehler += $dateien.Count
|
||||||
|
Schlecht "Stapel fehlgeschlagen: HTTP $code — $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))"
|
||||||
|
if ($code -eq '502') {
|
||||||
|
Schlecht 'Der Dienst antwortet nicht. Laeuft der Container? (docker ps)'
|
||||||
|
Schlecht 'Der Lauf laesst sich danach einfach wiederholen — was da ist, wird uebersprungen.'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$res = $rumpf | ConvertFrom-Json
|
||||||
|
foreach ($r in $res.ergebnisse) {
|
||||||
|
if ($r.ok) { $hoch++ }
|
||||||
|
elseif ($r.error -like '*belegt*') { $schon++ }
|
||||||
|
else { $fehler++; Schlecht "$($r.name): $($r.error)" }
|
||||||
|
}
|
||||||
|
Gut "$hoch uebernommen, $schon schon da, $fehler Fehler"
|
||||||
|
} catch {
|
||||||
|
$fehler += $dateien.Count
|
||||||
|
Schlecht "Antwort unlesbar: $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateien | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item $keksglas -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Gut "Fertig: $hoch uebernommen, $schon waren schon da, $fehler Fehler"
|
||||||
|
if ($schon -gt 0) {
|
||||||
|
Write-Host " (Ein zweiter Lauf laedt nichts doppelt — das ist Absicht.)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Wacht über die Sicherheitslage des Wirts: offene Türen und wichtige Dateien.
|
||||||
|
#
|
||||||
|
# pwsh -File tools\waechter.ps1 <- prüfen und ggf. melden
|
||||||
|
# pwsh -File tools\waechter.ps1 -Uebernehmen <- den JETZIGEN Stand als richtig eintragen
|
||||||
|
# pwsh -File tools\waechter.ps1 -Still <- prüfen, nur anzeigen, nichts melden
|
||||||
|
#
|
||||||
|
# WOZU. In der Nacht zum 13.08.2026 hat jemand über eine offene Tür in den
|
||||||
|
# Gitea geschrieben (docs/vorfall-2026-08-12-gitea.md). Aufgefallen ist es,
|
||||||
|
# weil danach ein Push nicht mehr ging -- nicht weil etwas es bemerkt hätte.
|
||||||
|
#
|
||||||
|
# DIE STATUSSEITE WAR DIE GANZE ZEIT GRÜN. Sie wäre auch grün geblieben, wenn
|
||||||
|
# der Miner gelaufen wäre. Es fehlte kein Bildschirm, auf dem man es hätte
|
||||||
|
# sehen können; es fehlte etwas, das VON SICH AUS Bescheid sagt.
|
||||||
|
#
|
||||||
|
# Zwei Prüfungen, und beide hätten den Vorfall doppelt erwischt:
|
||||||
|
#
|
||||||
|
# 1. OFFENE TÜREN ZÄHLEN. Port 3000 stand eiskalt elf Tage offen. Diese
|
||||||
|
# Prüfung hätte am ersten Tag gesagt: "da ist ein Port dazugekommen, den
|
||||||
|
# niemand eingetragen hat" -- und sie sagt es auch beim nächsten, den
|
||||||
|
# jemand beim Einrichten aus Bequemlichkeit aufmacht.
|
||||||
|
#
|
||||||
|
# 2. WICHTIGE DATEIEN NACHRECHNEN. Der Angriff war eine Datei, die sich um
|
||||||
|
# 21:13:57 änderte. Eine Prüfsumme über .gitconfig, app.ini, sshd_config
|
||||||
|
# und die Schlüsseldatei hätte das im nächsten Durchlauf gemeldet.
|
||||||
|
#
|
||||||
|
# GEMELDET WIRD NUR BEI ÄNDERUNG. Ein Wächter, der täglich meckert, wird
|
||||||
|
# stummgeschaltet -- und ist dann schlechter als keiner. Dieselbe Lehre wie bei
|
||||||
|
# den 900 Fahrzeugbildern.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Container = 'd4rk-media',
|
||||||
|
[switch]$Uebernehmen,
|
||||||
|
[switch]$Still
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
|
||||||
|
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
||||||
|
function Warn($t) { Write-Host " $t" -ForegroundColor Yellow }
|
||||||
|
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# Ports, die man auf einem Server aus dem Internet NIE erreichbar haben will --
|
||||||
|
# unabhängig davon, ob sie beim Übernehmen des Standes schon dastanden. Diese
|
||||||
|
# Liste warnt also auch dann, wenn der Zustand "schon immer so" war. Genau das
|
||||||
|
# ist der Punkt: Port 3000 war auch "schon immer so".
|
||||||
|
$HEIKEL = @{
|
||||||
|
135 = 'RPC'
|
||||||
|
139 = 'NetBIOS'
|
||||||
|
445 = 'SMB-Dateifreigabe'
|
||||||
|
1433 = 'MS SQL'
|
||||||
|
3306 = 'MySQL'
|
||||||
|
3389 = 'RDP'
|
||||||
|
5432 = 'PostgreSQL'
|
||||||
|
5985 = 'WinRM (HTTP)'
|
||||||
|
5986 = 'WinRM (HTTPS)'
|
||||||
|
6379 = 'Redis'
|
||||||
|
27017 = 'MongoDB'
|
||||||
|
47001 = 'WinRM-Listener'
|
||||||
|
# VERWALTUNGSZUGAENGE VON SPIELESERVERN. Sie sehen harmloser aus als sie
|
||||||
|
# sind, weil ein Spieleserver ja erreichbar sein SOLL -- aber über diese
|
||||||
|
# Ports verwaltet man ihn komplett, nicht spielt man auf ihm.
|
||||||
|
#
|
||||||
|
# Am 14.08.2026 gemessen: auf dieser Maschine lauschte 10011 auf allen
|
||||||
|
# Adressen (ts3server.exe aus C:\Server\TS3, läuft seit dem 09.07.2026),
|
||||||
|
# und eine handangelegte Regel "TS3_10011 (TCP)" ließ beliebige
|
||||||
|
# Gegenstellen in allen Profilen herein. Der Wächter schwieg, weil der Port
|
||||||
|
# beim Übernehmen des Standes schon dastand -- genau der Fall, für den es
|
||||||
|
# diese Liste gibt ("Port 3000 war auch schon immer so").
|
||||||
|
#
|
||||||
|
# Im Panel (d4rk_gameserver) ist derselbe Port deshalb ausdrücklich an
|
||||||
|
# 127.0.0.1 gebunden und bekommt KEINE Firewallregel.
|
||||||
|
10011 = 'TeamSpeak ServerQuery (Vollzugriff auf den Server)'
|
||||||
|
25575 = 'Minecraft RCON (Fernsteuerung der Konsole)'
|
||||||
|
28016 = 'Rust RCON (Fernsteuerung der Konsole)'
|
||||||
|
8212 = 'Palworld REST-API (Verwaltung, inkl. Kick und Bann)'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Was nachgerechnet wird. Änderungen daran sind entweder deine -- dann
|
||||||
|
# übernimmst du sie mit -Uebernehmen -- oder sie sind ein Befund.
|
||||||
|
$DATEIEN = @(
|
||||||
|
'C:\ProgramData\ssh\sshd_config'
|
||||||
|
'C:\ProgramData\ssh\administrators_authorized_keys'
|
||||||
|
'C:\docker\gitea\docker-compose.yml'
|
||||||
|
"$PSScriptRoot\..\docker-compose.yml"
|
||||||
|
)
|
||||||
|
# Dateien IN Containern, als "container:pfad".
|
||||||
|
$IM_CONTAINER = @(
|
||||||
|
'gitea:/data/gitea/conf/app.ini'
|
||||||
|
'gitea:/data/gitea/home/.gitconfig'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Stand erheben --------------------------------------------------------------
|
||||||
|
|
||||||
|
Schritt 'Offene Tueren zaehlen'
|
||||||
|
$offen = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.LocalAddress -in '0.0.0.0', '::' } |
|
||||||
|
Select-Object -ExpandProperty LocalPort -Unique | Sort-Object)
|
||||||
|
Gut "$($offen.Count) Ports lauschen auf allen Adressen"
|
||||||
|
|
||||||
|
Schritt 'Wichtige Dateien nachrechnen'
|
||||||
|
$hashes = @{}
|
||||||
|
foreach ($d in $DATEIEN) {
|
||||||
|
$p = try { (Resolve-Path $d -ErrorAction Stop).Path } catch { $null }
|
||||||
|
if ($p) { $hashes[$d] = (Get-FileHash $p -Algorithm SHA256).Hash }
|
||||||
|
else { $hashes[$d] = 'FEHLT' }
|
||||||
|
}
|
||||||
|
foreach ($eintrag in $IM_CONTAINER) {
|
||||||
|
$c, $pfad = $eintrag -split ':', 2
|
||||||
|
# sha256sum IM Container: die Datei liegt in einem Volume, das der Wirt
|
||||||
|
# nicht ohne Weiteres lesen kann -- und ein Umweg ueber docker cp wuerde
|
||||||
|
# bei jedem Lauf eine Kopie anlegen.
|
||||||
|
$h = (docker exec $c sh -c "sha256sum '$pfad' 2>/dev/null | cut -d' ' -f1") -join ''
|
||||||
|
$hashes[$eintrag] = if ($h.Trim()) { $h.Trim().ToUpper() } else { 'FEHLT' }
|
||||||
|
}
|
||||||
|
Gut "$($hashes.Count) Dateien"
|
||||||
|
|
||||||
|
# -- Mit dem hinterlegten Stand vergleichen -------------------------------------
|
||||||
|
|
||||||
|
$basisDatei = Join-Path $PSScriptRoot '..\.waechter-basis.json'
|
||||||
|
$basis = if (Test-Path $basisDatei) { Get-Content $basisDatei -Raw | ConvertFrom-Json } else { $null }
|
||||||
|
|
||||||
|
$jetzt = [ordered]@{
|
||||||
|
ports = $offen
|
||||||
|
dateien = $hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Uebernehmen -or -not $basis) {
|
||||||
|
$jetzt | ConvertTo-Json -Depth 5 | Set-Content $basisDatei -Encoding UTF8
|
||||||
|
if (-not $basis) {
|
||||||
|
Schritt 'Kein hinterlegter Stand -- den jetzigen als richtig eingetragen'
|
||||||
|
Warn 'Beim ERSTEN Lauf gibt es nichts zu vergleichen. Ab jetzt wird gemeldet,'
|
||||||
|
Warn 'was sich daran aendert. Sieh die Liste unten einmal durch.'
|
||||||
|
} else {
|
||||||
|
Gut 'Stand uebernommen.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$basis = Get-Content $basisDatei -Raw | ConvertFrom-Json
|
||||||
|
$neu = @($offen | Where-Object { $_ -notin $basis.ports })
|
||||||
|
$weg = @($basis.ports | Where-Object { $_ -notin $offen })
|
||||||
|
$geaendert = @()
|
||||||
|
foreach ($k in $hashes.Keys) {
|
||||||
|
$alt = $basis.dateien.$k
|
||||||
|
if ($alt -and $alt -ne $hashes[$k]) { $geaendert += $k }
|
||||||
|
}
|
||||||
|
$heikelOffen = @($offen | Where-Object { $HEIKEL.ContainsKey([int]$_) })
|
||||||
|
|
||||||
|
# -- Anzeigen -------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
if ($neu.Count) { Schlecht "NEU offen: $($neu -join ', ')" }
|
||||||
|
if ($weg.Count) { Gut "nicht mehr offen: $($weg -join ', ')" }
|
||||||
|
if ($geaendert.Count) { foreach ($g in $geaendert) { Schlecht "GEAENDERT: $g" } }
|
||||||
|
if (-not $neu.Count -and -not $geaendert.Count) { Gut 'Keine Aenderung gegenueber dem hinterlegten Stand.' }
|
||||||
|
|
||||||
|
if ($heikelOffen.Count) {
|
||||||
|
Write-Host ''
|
||||||
|
Warn 'Diese lauschen auf allen Adressen und gehoeren nicht ins Internet:'
|
||||||
|
foreach ($p in $heikelOffen) { Warn (" {0,6} {1}" -f $p, $HEIKEL[[int]$p]) }
|
||||||
|
Warn 'OB sie von aussen erreichbar sind, sagt nur eine Messung VON AUSSEN --'
|
||||||
|
Warn 'vom Wirt aus antwortet die eigene Adresse auch dann, wenn die Firewall sperrt.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Melden ---------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Nur bei AENDERUNG, und nur einmal je Zustand. Dafuer wandert eine Kennung des
|
||||||
|
# gemeldeten Zustands in die Einstellungen: bleibt sie gleich, bleibt es still.
|
||||||
|
|
||||||
|
if ($Still) { Write-Host ''; Gut 'Nur angezeigt (-Still), nichts gemeldet.'; exit 0 }
|
||||||
|
|
||||||
|
$befund = @()
|
||||||
|
if ($neu.Count) { $befund += "NEU offen: " + ($neu -join ', ') }
|
||||||
|
if ($geaendert.Count) { $befund += "geaendert: " + (($geaendert | ForEach-Object { Split-Path $_ -Leaf }) -join ', ') }
|
||||||
|
|
||||||
|
$kennung = if ($befund.Count) { ($befund -join ' | ') } else { '' }
|
||||||
|
|
||||||
|
function LiesEinstellung($schluessel) {
|
||||||
|
$s = @"
|
||||||
|
const db = require('better-sqlite3')('/data/media.db', { readonly: true });
|
||||||
|
const r = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel');
|
||||||
|
console.log(r ? r.value : '');
|
||||||
|
"@ -replace "`r", ''
|
||||||
|
(docker exec $Container node -e $s 2>$null) -join ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchreibEinstellung($schluessel, $wert) {
|
||||||
|
$s = @"
|
||||||
|
let roh = '';
|
||||||
|
process.stdin.on('data', (d) => (roh += d)).on('end', () => {
|
||||||
|
const db = require('better-sqlite3')('/data/media.db');
|
||||||
|
db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
|
||||||
|
.run('$schluessel', roh.trim(), Date.now());
|
||||||
|
});
|
||||||
|
"@ -replace "`r", ''
|
||||||
|
$wert | docker exec -i $Container node -e $s 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bericht fuer die Statusseite -- immer, auch wenn nichts zu melden ist.
|
||||||
|
$bericht = [ordered]@{
|
||||||
|
at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
|
||||||
|
ok = ($befund.Count -eq 0)
|
||||||
|
ports = $offen.Count
|
||||||
|
heikel = @($heikelOffen)
|
||||||
|
befund = @($befund)
|
||||||
|
}
|
||||||
|
SchreibEinstellung 'waechter.letzte' ($bericht | ConvertTo-Json -Compress)
|
||||||
|
|
||||||
|
$zuletzt = LiesEinstellung 'waechter.merker'
|
||||||
|
if ($kennung -eq $zuletzt) {
|
||||||
|
Write-Host ''
|
||||||
|
Gut 'Nichts Neues -- dieser Zustand wurde schon gemeldet.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
SchreibEinstellung 'waechter.merker' $kennung
|
||||||
|
if (-not $kennung) { Write-Host ''; Gut 'Wieder in Ordnung (Merker geleert).'; exit 0 }
|
||||||
|
|
||||||
|
$webhook = LiesEinstellung 'discord.webhook'
|
||||||
|
if (-not $webhook) { Write-Host ''; Warn 'Kein Discord-Webhook hinterlegt -- nur angezeigt.'; exit 0 }
|
||||||
|
|
||||||
|
$felder = @()
|
||||||
|
if ($neu.Count) { $felder += @{ name = 'Neu offen'; value = ($neu -join ', '); inline = $true } }
|
||||||
|
if ($geaendert.Count) { $felder += @{ name = 'Geaenderte Dateien'; value = (($geaendert | ForEach-Object { '`' + (Split-Path $_ -Leaf) + '`' }) -join "`n"); inline = $false } }
|
||||||
|
|
||||||
|
$rumpf = @{
|
||||||
|
username = 'd4rk_media'
|
||||||
|
embeds = @(@{
|
||||||
|
title = 'Die Lage am Server hat sich geaendert'
|
||||||
|
description = "Etwas ist offen oder anders, was vorher nicht so war.`n" +
|
||||||
|
"Wenn du das selbst warst: ``pwsh -File tools\waechter.ps1 -Uebernehmen``"
|
||||||
|
color = 15158332
|
||||||
|
fields = $felder
|
||||||
|
footer = @{ text = 'D4RKST3R // WAECHTER' }
|
||||||
|
timestamp = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
|
})
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
|
||||||
|
$antwort = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST `
|
||||||
|
-H 'content-type: application/json' --data-binary '@-' $webhook
|
||||||
|
Write-Host ''
|
||||||
|
if ($antwort -in '200', '204') { Gut "Gemeldet (HTTP $antwort)." } else { Schlecht "Melden fehlgeschlagen: HTTP $antwort" }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# docs/ ins Gitea-Wiki schieben.
|
||||||
|
#
|
||||||
|
# Aufruf: pwsh -File tools\wiki.ps1
|
||||||
|
#
|
||||||
|
# WARUM DIE DATEIEN IM REPO LIEGEN UND NICHT NUR IM WIKI. Ein Wiki hat keinen
|
||||||
|
# Zusammenhang mit dem Code: niemand sieht, ob die Anleitung noch zu dem passt,
|
||||||
|
# was der Dienst tut. Unter docs/ wandert sie im selben Commit mit der
|
||||||
|
# Aenderung, die sie beschreibt — und dieses Skript traegt sie danach hinaus.
|
||||||
|
#
|
||||||
|
# Die Richtung ist bewusst EINSEITIG: docs/ ist die Quelle, das Wiki die Kopie.
|
||||||
|
# Wer im Wiki tippt, verliert es beim naechsten Lauf. Das ist unschoen, aber
|
||||||
|
# ehrlicher als zwei Quellen, die auseinanderlaufen.
|
||||||
|
#
|
||||||
|
# DAS WIKI MUSS EINMAL EXISTIEREN. Gitea legt das Wiki-Repo erst mit der ersten
|
||||||
|
# Seite an; ein Klon davor antwortet mit 500. Angelegt wurde es ueber die API
|
||||||
|
# (POST /api/v1/repos/<besitzer>/<repo>/wiki/new) — danach ist es ein
|
||||||
|
# gewoehnliches Git-Repo.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Wiki = 'https://git.d4rkst3r.de/D4rkst3r/d4rk_media.wiki.git',
|
||||||
|
[string]$Quelle = 'docs'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
||||||
|
|
||||||
|
$dateien = Get-ChildItem $Quelle -Filter '*.md'
|
||||||
|
if (-not $dateien) { Write-Host " Nichts in $Quelle" -ForegroundColor Yellow; exit 0 }
|
||||||
|
|
||||||
|
$tmp = Join-Path $env:TEMP ("wiki-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||||
|
git clone --quiet $Wiki $tmp
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host ' Klon fehlgeschlagen. Gibt es das Wiki schon? (Erste Seite ueber die Oberflaeche anlegen.)' -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($d in $dateien) {
|
||||||
|
Copy-Item $d.FullName (Join-Path $tmp $d.Name) -Force
|
||||||
|
Gut $d.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $tmp
|
||||||
|
git add -A
|
||||||
|
# Nichts geaendert ist kein Fehler: dann steht das Wiki schon richtig da.
|
||||||
|
$status = git status --porcelain
|
||||||
|
if (-not $status) {
|
||||||
|
Pop-Location
|
||||||
|
Remove-Item $tmp -Recurse -Force
|
||||||
|
Gut 'Das Wiki ist bereits aktuell.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
git commit --quiet -m ("Anleitung aus docs/ — " + (Get-Date -Format 'yyyy-MM-dd HH:mm'))
|
||||||
|
git push --quiet
|
||||||
|
$ok = $LASTEXITCODE -eq 0
|
||||||
|
Pop-Location
|
||||||
|
Remove-Item $tmp -Recurse -Force
|
||||||
|
|
||||||
|
if ($ok) { Gut 'Ins Wiki geschoben.' }
|
||||||
|
else { Write-Host ' Push fehlgeschlagen.' -ForegroundColor Red; exit 1 }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user