feat: Verlauf als CSV, Meldung bei Upload mit Drossel, Deckel auf dem Papierkorb
Abschnitt 3 aus docs/ideen.md, bis auf die Sicherungsziele. VERLAUF ALS CSV. Der Export konnte nur den Bestand; wer nachrechnen wollte, was ueber einen Zeitraum passiert ist, sass vor einer Tabelle, die nach 500 Zeilen umblaettert. Der Knopf sitzt in der Reiterleiste und holt den Verlauf, DER GERADE OFFEN IST -- zwei Knoepfe nebeneinander waeren die Frage "welchen von beiden?" an einer Stelle, wo die Antwort schon auf dem Bildschirm steht. Semikolon und CRLF wie beim Bestands-Export; das war dort nachgemessen worden. Gemessen an der ausgelieferten Datei: 5371 Zeilen, kein einziges nacktes LF. Ohne Anmeldung 401 -- die Route liegt hinter dem Waechter, was nach dem Sitzungs-Fehler von vorgestern ausdruecklich nachgeprueft wurde. MELDUNG BEI UPLOAD -- die Drossel ist der eigentliche Inhalt. Jeder Upload schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht EINE Nachricht raus. Ohne das waere die Funktion ein Schaden: 900 Fahrzeugbilder ergaeben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte Meldung bekommt, sieht sie nicht mehr. Gemessen: drei Uploads mit zehn Sekunden Abstand -> eine Meldung ueber drei Dateien. Ein einzelner Upload -> eine Meldung ueber eine Datei. Nur Token-Uploads, absichtlich. Was ueber das Dashboard hereinkommt, hat gerade jemand selbst hochgeladen und bestaetigt bekommen. Zurueckholen aus dem Papierkorb meldet gar nichts: das ist kein Neues, das ist ein Wiedergefundenes. Und derselbe Fehler wie bei 'platte' lauerte schon wieder: die Liste der Anlaesse steht im Server UND in der Oberflaeche, und der Server verwirft beim Speichern alles, was in seiner Liste fehlt -- ein Kreuzchen, das sich setzen laesst und beim naechsten Laden weg ist. Diesmal beide angefasst, in beiden steht jetzt ein Verweis auf die andere. Nachgemessen: vier geschickt, vier gespeichert. DECKEL AUF DEM PAPIERKORB. 30 Tage UND 20 GB, was zuerst greift. Wer 200 GB loescht, haelt sie sonst 30 Tage doppelt und merkt es erst, wenn die Platte voll ist. Geraeumt wird das AELTESTE zuerst -- dessen Versehen waere am ehesten schon aufgefallen. Ueber PAPIERKORB_MAX_MB verstellbar, und das nicht aus Bequemlichkeit: ein Deckel von 20 GB laesst sich nicht pruefen, ohne 20 GB zu loeschen. Mit 0.0002 MB nachgemessen -- vier Eintraege zu 422 Bytes, drei entfernt, uebrig blieb der NEUESTE. Danach zurueck auf 20 GB, gemessen 21474836480. NEBENBEI, DREIMAL DIESELBE SORTE FEHLER: "0.0 MB" fuer 422 Bytes, "0 KB" fuer 106 Bytes, "0.0 MB von 0.0 MB" beim Deckel. Es gab drei Byte-Formatierer, jeder mit anderer Untergrenze. Jetzt einer in meldung.ts, von Bytes bis GB, exportiert und von dash.ts mitbenutzt. Nachgemessen ueber alle Stufen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+37
@@ -1029,6 +1029,43 @@ Gegenprüfen lässt sich beides mit dem, was schon da ist:
|
|||||||
pwsh -File tools\proxy-pruefen.ps1 -Basis https://media.d4rkst3r.de -Token d4rk_...
|
pwsh -File tools\proxy-pruefen.ps1 -Basis https://media.d4rkst3r.de -Token d4rk_...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ✅ Fertig — Verlauf als CSV, Meldung bei Upload, Deckel auf dem Papierkorb
|
||||||
|
|
||||||
|
Abschnitt 3 aus `docs/ideen.md`, der Rest der Liste bis auf die Sicherungsziele.
|
||||||
|
|
||||||
|
**Verlauf als CSV.** `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; das war dort nachgemessen worden,
|
||||||
|
hier wäre es ein zweiter Anlauf gewesen. Gemessen an der ausgelieferten Datei:
|
||||||
|
**5364 Zeilen, 5364 CRLF, kein einziges nacktes LF**, Dateiname
|
||||||
|
`d4rk_media-dateien-2026-08-12.csv`.
|
||||||
|
|
||||||
|
**Deckel auf dem Papierkorb.** 30 Tage **und** 20 GB — was zuerst greift,
|
||||||
|
greift. Beim Ausleeren fliegt erst das zu Alte, dann das **Älteste**, bis die
|
||||||
|
Grenze wieder passt; dessen Versehen wäre am ehesten schon aufgefallen. Die
|
||||||
|
Zahl steht als `maxBytes` in der Antwort von `/api/dash/papierkorb`, damit die
|
||||||
|
Oberfläche sie nicht ein zweites Mal hinschreibt — gemessen: `21474836480`.
|
||||||
|
|
||||||
|
Bei 910 GB frei ist das keine Grenze, sondern eine Zusicherung: der Papierkorb
|
||||||
|
kann nicht mehr unbemerkt zur zweiten Ablage werden.
|
||||||
|
|
||||||
|
**Meldung bei Upload — die Drossel ist der eigentliche Inhalt.** Jeder Upload
|
||||||
|
schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht *eine*
|
||||||
|
Nachricht raus, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
|
||||||
|
|
||||||
|
Ohne das wäre die Funktion ein Schaden: 900 Fahrzeugbilder ergäben 900
|
||||||
|
Nachrichten, Discord drosselt Webhooks, und wer danach eine echte Meldung
|
||||||
|
bekommt, sieht sie nicht mehr.
|
||||||
|
|
||||||
|
Und **derselbe Fehler wie bei `platte` lauerte schon wieder**: die Liste der
|
||||||
|
Anlässe steht an zwei Stellen — im Server und in der Oberfläche —, und der
|
||||||
|
Server verwirft beim Speichern alles, was in *seiner* Liste fehlt. Ein
|
||||||
|
Kreuzchen, das sich setzen lässt und beim nächsten Laden weg ist. Diesmal
|
||||||
|
beide Stellen angefasst und nachgemessen: **vier geschickt, vier
|
||||||
|
gespeichert.** In beiden steht jetzt ein Verweis auf die andere.
|
||||||
|
|
||||||
### ⬜ Als Nächstes
|
### ⬜ Als Nächstes
|
||||||
|
|
||||||
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
|
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ services:
|
|||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64}
|
||||||
SESSION_DAYS: ${SESSION_DAYS:-30}
|
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
|
# NODE_ENV=production setzt das Sitzungs-Cookie auf "Secure". Hinter
|
||||||
# NPM mit TLS ist das richtig.
|
# NPM mit TLS ist das richtig.
|
||||||
#
|
#
|
||||||
|
|||||||
+117
-3
@@ -212,12 +212,49 @@ 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
|
— danach steht nur noch sein Hash in der Datenbank, und auch der Dienst kann
|
||||||
ihn nicht mehr herausgeben.
|
ihn nicht mehr herausgeben.
|
||||||
|
|
||||||
Ein Token kann zwei Fesseln haben:
|
Ein Token kann **sechs** Fesseln haben — alle im Dashboard einstellbar, auch
|
||||||
|
nachträglich (`PATCH`, ohne dass sich der Schlüssel ändert):
|
||||||
|
|
||||||
| Fessel | Wirkung |
|
| Fessel | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` |
|
| Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` |
|
||||||
| darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 |
|
| 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".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -251,6 +288,14 @@ Nimmt die Datei in einer von **drei Rumpfformen** entgegen:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### Was schiefgehen kann
|
||||||
|
|
||||||
| Antwort | Grund |
|
| Antwort | Grund |
|
||||||
@@ -259,7 +304,7 @@ Nimmt die Datei in einer von **drei Rumpfformen** entgegen:
|
|||||||
| 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) |
|
| 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) |
|
||||||
| 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 |
|
| 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 |
|
||||||
| 409 | `X-Overwrite: false` und der Pfad ist belegt |
|
| 409 | `X-Overwrite: false` und der Pfad ist belegt |
|
||||||
| 413 | größer als `MAX_UPLOAD_MB` (Standard 64) |
|
| 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
|
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
|
Deutsch**. Ein nackter 500 ohne Text ist genau der Fehler, wegen dem dieser
|
||||||
@@ -349,14 +394,83 @@ 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
|
## Grenzen
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Größte Datei | 64 MB (`MAX_UPLOAD_MB`) |
|
| 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 |
|
| Pfadlänge | 200 Zeichen, höchstens 8 Ebenen |
|
||||||
| Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) |
|
| Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) |
|
||||||
| Sitzung | 30 Tage |
|
| 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+44
-8
@@ -98,14 +98,50 @@ einer Stunde Klickerei fünf Minuten.
|
|||||||
|
|
||||||
## 3 · Wäre schön, drängt nicht
|
## 3 · Wäre schön, drängt nicht
|
||||||
|
|
||||||
- **Verlauf als CSV** — der Export kann heute nur Medien, nicht Ereignisse.
|
### ~~Verlauf als CSV~~ — gebaut am 12.08.2026
|
||||||
- **Meldung bei Upload** — Discord-Nachricht, wenn über einen Token etwas
|
|
||||||
Neues ankommt. Nützlich für Bildschirmfotos aus dem Spiel, sinnlos für einen
|
`GET /api/dash/verlauf/export`, wahlweise `?was=verwaltung`. Der Knopf sitzt in
|
||||||
Serienlauf über 900 Fahrzeuge; bräuchte also eine Drossel.
|
der Reiterleiste und holt **den Verlauf, der gerade offen ist** — zwei Knöpfe
|
||||||
- **Papierkorb mit Größendeckel** — heute nur 30 Tage. Wer 200 GB löscht, hat
|
nebeneinander wären die Frage „welchen von beiden?" an einer Stelle, wo die
|
||||||
sie 30 Tage lang doppelt. Bei 910 GB frei ist das keine Not.
|
Antwort schon auf dem Bildschirm steht.
|
||||||
- **Mehrere Sicherungsziele** — die Archive liegen auf derselben Platte wie die
|
|
||||||
Daten, die Kopie in der Nextcloud ist die einzige Trennung.
|
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.
|
||||||
|
|
||||||
|
### Mehrere Sicherungsziele — offen
|
||||||
|
|
||||||
|
Die Archive liegen auf derselben Platte wie die Daten, die Kopie in der
|
||||||
|
Nextcloud ist die einzige Trennung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+97
-3
@@ -39,7 +39,7 @@ import { now } from './db.js'
|
|||||||
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
|
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
|
||||||
* ist. Jetzt ist es eine.
|
* ist. Jetzt ist es eine.
|
||||||
*/
|
*/
|
||||||
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte'] as const
|
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte', 'upload'] as const
|
||||||
|
|
||||||
export const MELDUNG_KEYS = {
|
export const MELDUNG_KEYS = {
|
||||||
webhook: 'discord.webhook',
|
webhook: 'discord.webhook',
|
||||||
@@ -152,8 +152,17 @@ export async function melde(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const groesse = (n: number) =>
|
// Die unterste Stufe ist neu: bisher meldete das hier nur Sicherungsarchive,
|
||||||
n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`
|
// 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.
|
/** Wacht ueber die Sicherung.
|
||||||
*
|
*
|
||||||
@@ -319,3 +328,88 @@ export async function pruefePlatte(): Promise<void> {
|
|||||||
tag: 'PLATTE',
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+117
-7
@@ -55,7 +55,7 @@ import {
|
|||||||
type Vorlage,
|
type Vorlage,
|
||||||
} from '../settings.js'
|
} from '../settings.js'
|
||||||
import { discordRoutes, redirectUri } from './discord.js'
|
import { discordRoutes, redirectUri } from './discord.js'
|
||||||
import { ANLAESSE, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
import { ANLAESSE, groesse, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
||||||
import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js'
|
import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js'
|
||||||
import { neuerSchluessel } from './freigabe.js'
|
import { neuerSchluessel } from './freigabe.js'
|
||||||
import {
|
import {
|
||||||
@@ -106,6 +106,25 @@ const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
|
|||||||
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
|
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
|
||||||
const PAPIERKORB_TAGE = 30
|
const PAPIERKORB_TAGE = 30
|
||||||
|
|
||||||
|
/** Und wieviel er hoechstens halten darf.
|
||||||
|
*
|
||||||
|
* DIE ZEIT ALLEIN REICHT NICHT. Wer 200 GB loescht, haelt sie dreissig Tage
|
||||||
|
* lang doppelt -- und merkt es erst, wenn die Platte voll ist und der
|
||||||
|
* Waechter meldet. Der Deckel raeumt dann das AELTESTE zuerst weg, denn das
|
||||||
|
* ist das, dessen Versehen am ehesten schon aufgefallen waere.
|
||||||
|
*
|
||||||
|
* 20 GB bei 910 GB frei ist heute keine Grenze, sondern eine Zusicherung: der
|
||||||
|
* Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
|
||||||
|
*
|
||||||
|
* Ueber `PAPIERKORB_MAX_MB` verstellbar -- nicht aus Bequemlichkeit, sondern
|
||||||
|
* weil sich ein Deckel von 20 GB nicht nachmessen laesst, ohne 20 GB zu
|
||||||
|
* loeschen. Wer ihn pruefen will, setzt ihn klein und sieht zu. */
|
||||||
|
const PAPIERKORB_MAX_BYTES = Math.round(
|
||||||
|
// Gerundet, weil Nachkommastellen erlaubt sind: 0.0002 MB ergaeben sonst
|
||||||
|
// einen Deckel von "209.7152 Bytes", und Bruchteile von Bytes gibt es nicht.
|
||||||
|
Number(process.env.PAPIERKORB_MAX_MB || 20 * 1024) * 1024 * 1024,
|
||||||
|
)
|
||||||
|
|
||||||
// ------------------------------------------------------------ Die Bremse
|
// ------------------------------------------------------------ Die Bremse
|
||||||
//
|
//
|
||||||
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
|
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
|
||||||
@@ -1093,6 +1112,63 @@ dashRoutes.post('/upload-url', async (c) => {
|
|||||||
*
|
*
|
||||||
* WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen
|
* WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen
|
||||||
* das Discord-Geheimnis und das Nextcloud-Passwort. */
|
* das Discord-Geheimnis und das Nextcloud-Passwort. */
|
||||||
|
/** Den Verlauf mitnehmen -- Dateien oder Verwaltung, als CSV.
|
||||||
|
*
|
||||||
|
* WOZU: der Export konnte bisher nur den BESTAND. Wer nachweisen oder
|
||||||
|
* nachrechnen will, was ueber einen Zeitraum passiert ist, sass vor einer
|
||||||
|
* Tabelle, die nach 500 Zeilen umblaettert.
|
||||||
|
*
|
||||||
|
* Dieselben zwei Eigenheiten wie beim Bestands-Export, und aus denselben
|
||||||
|
* Gruenden: SEMIKOLON, weil Excel in deutscher Einstellung sonst alles in eine
|
||||||
|
* Spalte nimmt, und CRLF, weil sonst die halbe Datei in einer Zelle steht.
|
||||||
|
* Beides ist dort nachgemessen worden; hier waere es ein zweiter Anlauf
|
||||||
|
* gewesen.
|
||||||
|
*/
|
||||||
|
dashRoutes.get('/verlauf/export', (c) => {
|
||||||
|
const welcher = c.req.query('was') === 'verwaltung' ? 'verwaltung' : 'dateien'
|
||||||
|
const stempel = new Date(now()).toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const feld = (f: unknown) => `"${String(f ?? '').replace(/"/g, '""')}"`
|
||||||
|
let kopf: string
|
||||||
|
let zeilen: string[]
|
||||||
|
|
||||||
|
if (welcher === 'verwaltung') {
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT * FROM verwaltung ORDER BY at DESC LIMIT 20000')
|
||||||
|
.all() as VerwaltungZeile[]
|
||||||
|
kopf = 'wann;wer;bereich;tat;ziel;was'
|
||||||
|
zeilen = rows.map((r) =>
|
||||||
|
[new Date(r.at).toISOString(), r.wer, r.bereich, r.tat, r.ziel, r.was]
|
||||||
|
.map(feld)
|
||||||
|
.join(';'),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT * FROM events ORDER BY at DESC LIMIT 20000')
|
||||||
|
.all() as MediaEvent[]
|
||||||
|
kopf = 'wann;art;pfad;von;groesse;wer;woher'
|
||||||
|
zeilen = rows.map((r) =>
|
||||||
|
[
|
||||||
|
new Date(r.at).toISOString(),
|
||||||
|
r.kind,
|
||||||
|
r.path,
|
||||||
|
r.von ?? '',
|
||||||
|
r.size ?? '',
|
||||||
|
r.actor,
|
||||||
|
r.actor_kind,
|
||||||
|
]
|
||||||
|
.map(feld)
|
||||||
|
.join(';'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body([kopf, ...zeilen].join('\r\n'), 200, {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="d4rk_media-${welcher}-${stempel}.csv"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
dashRoutes.get('/verwaltung', (c) => {
|
dashRoutes.get('/verwaltung', (c) => {
|
||||||
const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500)
|
const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500)
|
||||||
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
|
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
|
||||||
@@ -2305,15 +2381,45 @@ async function inPapierkorb(row: Media, wer: string): Promise<void> {
|
|||||||
* Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag
|
* Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag
|
||||||
* aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */
|
* aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */
|
||||||
export async function papierkorbAusleeren(): Promise<number> {
|
export async function papierkorbAusleeren(): Promise<number> {
|
||||||
const alt = db
|
const weg = async (e: PapierkorbEintrag) => {
|
||||||
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
|
|
||||||
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
|
|
||||||
for (const e of alt) {
|
|
||||||
await rm(papierkorbPfad(e.ablage), { force: true })
|
await rm(papierkorbPfad(e.ablage), { force: true })
|
||||||
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
|
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
|
||||||
}
|
}
|
||||||
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege endgueltig entfernt`)
|
|
||||||
return alt.length
|
// 1. Was zu ALT ist.
|
||||||
|
const alt = db
|
||||||
|
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
|
||||||
|
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
|
||||||
|
for (const e of alt) await weg(e)
|
||||||
|
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege zu alt, entfernt`)
|
||||||
|
|
||||||
|
// 2. Und was zu VIEL ist.
|
||||||
|
//
|
||||||
|
// Die Zeit allein reicht nicht: wer 200 GB loescht, haelt sie dreissig Tage
|
||||||
|
// lang doppelt. Geraeumt wird von ALTEN her -- deren Versehen waere am
|
||||||
|
// ehesten schon aufgefallen.
|
||||||
|
let zuViel = 0
|
||||||
|
const summe = () =>
|
||||||
|
(db.prepare('SELECT COALESCE(SUM(size),0) AS b FROM papierkorb').get() as { b: number }).b
|
||||||
|
|
||||||
|
if (summe() > PAPIERKORB_MAX_BYTES) {
|
||||||
|
const nachAlter = db
|
||||||
|
.prepare('SELECT * FROM papierkorb ORDER BY geloescht ASC')
|
||||||
|
.all() as PapierkorbEintrag[]
|
||||||
|
let belegt = summe()
|
||||||
|
for (const e of nachAlter) {
|
||||||
|
if (belegt <= PAPIERKORB_MAX_BYTES) break
|
||||||
|
await weg(e)
|
||||||
|
belegt -= e.size
|
||||||
|
zuViel++
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[papierkorb] Deckel erreicht: ${zuViel} der aeltesten Eintraege entfernt, ` +
|
||||||
|
`jetzt ${groesse(belegt)} von ${groesse(PAPIERKORB_MAX_BYTES)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return alt.length + zuViel
|
||||||
}
|
}
|
||||||
|
|
||||||
dashRoutes.get('/papierkorb', async (c) => {
|
dashRoutes.get('/papierkorb', async (c) => {
|
||||||
@@ -2349,6 +2455,10 @@ dashRoutes.get('/papierkorb', async (c) => {
|
|||||||
items,
|
items,
|
||||||
...summe,
|
...summe,
|
||||||
haltbarTage: PAPIERKORB_TAGE,
|
haltbarTage: PAPIERKORB_TAGE,
|
||||||
|
// Der Deckel gehoert in die Antwort, damit die Oberflaeche ihn nicht
|
||||||
|
// erraet -- eine zweite Zahl fuer dieselbe Grenze waere eine, die
|
||||||
|
// irgendwann von der ersten abweicht.
|
||||||
|
maxBytes: PAPIERKORB_MAX_BYTES,
|
||||||
verwaist: items.filter((e) => !e.dateiDa).length,
|
verwaist: items.filter((e) => !e.dateiDa).length,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
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 { meldeUpload } from '../meldung.js'
|
||||||
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
||||||
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
import { kontingentUeberschritten } from './upload.js'
|
import { kontingentUeberschritten } from './upload.js'
|
||||||
@@ -305,6 +306,7 @@ async function ablegen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name)
|
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) }
|
return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
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 { meldeUpload } from '../meldung.js'
|
||||||
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
import { db, logEvent, now, type Media, type Token } from '../db.js'
|
||||||
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
import { tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
import {
|
import {
|
||||||
@@ -321,6 +322,7 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name)
|
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),
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import { api } from '../api'
|
|||||||
import { run, useStore } from '../store'
|
import { run, useStore } from '../store'
|
||||||
import { Feld, Karte, Knopf } from './Bausteine'
|
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 = [
|
const ANLAESSE = [
|
||||||
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
|
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
|
||||||
['verwaiste', 'Verwaiste Dateien gefunden'],
|
['verwaiste', 'Verwaiste Dateien gefunden'],
|
||||||
['platte', 'Der Platz auf der Platte wird knapp'],
|
['platte', 'Der Platz auf der Platte wird knapp'],
|
||||||
|
['upload', 'Neue Dateien sind angekommen'],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Meldungen nach Discord.
|
/** Meldungen nach Discord.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Download,
|
||||||
History,
|
History,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenLine,
|
PenLine,
|
||||||
@@ -371,6 +372,10 @@ function Reiter({
|
|||||||
reiter: 'dateien' | 'verwaltung'
|
reiter: 'dateien' | 'verwaltung'
|
||||||
setReiter: (r: 'dateien' | 'verwaltung') => void
|
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) =>
|
const stil = (an: boolean) =>
|
||||||
`rounded-md px-3 py-1.5 text-sm ${
|
`rounded-md px-3 py-1.5 text-sm ${
|
||||||
an
|
an
|
||||||
@@ -378,7 +383,8 @@ function Reiter({
|
|||||||
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
|
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
|
||||||
}`
|
}`
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 self-start rounded-lg border border-rand bg-flaeche p-1">
|
<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')}>
|
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
|
||||||
Dateien
|
Dateien
|
||||||
</button>
|
</button>
|
||||||
@@ -389,5 +395,28 @@ function Reiter({
|
|||||||
Verwaltung
|
Verwaltung
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user