Compare commits
36
Commits
fce8053d09
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f064eb62c7 | ||
|
|
cedcb1ba6f | ||
|
|
dd68bc594d | ||
|
|
2e1eaf9cbf | ||
|
|
6fc157bc07 | ||
|
|
662c0ebdc1 | ||
|
|
6fab2388ca | ||
|
|
a7528d7365 | ||
|
|
e1259dd55c | ||
|
|
8586664fae | ||
|
|
16258640ad | ||
|
|
7005b2ce01 | ||
|
|
4208676aa9 | ||
|
|
0591a40d12 | ||
|
|
1d10c6bd0e | ||
|
|
94a803bf7a | ||
|
|
7d45941c03 | ||
|
|
944b2c63e2 | ||
|
|
6042f6f2f6 | ||
|
|
76176a45bc | ||
|
|
2cc02941b9 | ||
|
|
c26bcba012 | ||
|
|
89f0817dcb | ||
|
|
ef604973c8 | ||
|
|
9f476c56cf | ||
|
|
c8c4095299 | ||
|
|
5d90c0a2d2 | ||
|
|
8fdde182b7 | ||
|
|
df9d2d6ded | ||
|
|
70c963744c | ||
|
|
e9f69f7d7f | ||
|
|
511213b40d | ||
|
|
ea90f43e77 | ||
|
|
8e27c703d2 | ||
|
|
fbaf24bdbd | ||
|
|
55dcf517f2 |
No files matched your search
+26
-2
@@ -80,6 +80,20 @@ TMP_PATH=/data/tmp
|
||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||
MAX_VERSIONS_KEPT=5
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
# Standardmäßig an. Fehlen die Programme im Abbild, schaltet sich der Arbeiter
|
||||
# beim Start selbst ab, statt den Start zu verhindern.
|
||||
OCR_ENABLED=true
|
||||
# Sprachen für Tesseract. Jede zusätzliche kostet Zeit – nimm nur, was vorkommt.
|
||||
OCR_LANGUAGES=deu+eng
|
||||
# Seiten je Datei; 0 = unbegrenzt. Die Grenze ist Absicht: OCR ist das einzige
|
||||
# Stück dieser Anwendung, das ohne Zutun eines Menschen Platz und Rechenzeit
|
||||
# verbraucht. Wer regelmäßig lange Scans ablegt, setzt sie herauf.
|
||||
OCR_MAX_PAGES=50
|
||||
# Abbruch je Datei. Ein Scan, der so lange braucht, wird auch beim vierten
|
||||
# Versuch nicht fertig.
|
||||
OCR_TIMEOUT=10m
|
||||
|
||||
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
||||
@@ -103,11 +117,21 @@ OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
|
||||
# sonst verweigert der Browser den Rahmen.
|
||||
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
|
||||
# Browser → Office
|
||||
# Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
|
||||
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
|
||||
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
|
||||
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
|
||||
# ist fast immer falsch.
|
||||
OFFICE_PUBLIC_URL=
|
||||
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
|
||||
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
|
||||
# nur OnlyOffice, openssl rand -hex 32
|
||||
# Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet
|
||||
# die Zertifikatsprüfung **ab** – im Internet nichts zu suchen.
|
||||
OFFICE_INSECURE_TLS=false
|
||||
# Liest **der Office-Container**, nicht Aether: Es schützt seine eigene API.
|
||||
# Der WOPI-Weg braucht es nicht, der hat eigene Token. Bei OnlyOffice und
|
||||
# Euro-Office mit JWT_ENABLED=true gehört es gesetzt, sonst steht deren API
|
||||
# offen. Collabora kennt es nicht. openssl rand -hex 32
|
||||
OFFICE_JWT_SECRET=
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
|
||||
+4
-1
@@ -1,5 +1,5 @@
|
||||
# Secrets
|
||||
build/.env
|
||||
build/.env*
|
||||
.env
|
||||
|
||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
||||
@@ -29,3 +29,6 @@ Thumbs.db
|
||||
|
||||
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
||||
frontend/public/pdfjs/
|
||||
# Excalidraws Schriften – aus dem Paket kopiert, nicht eingecheckt (siehe
|
||||
# vite.config.ts). Fremde Binaerdateien gehoeren nicht in den Quelltext.
|
||||
frontend/public/excalidraw/
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# Bestandsaufnahme, 12.09.2026
|
||||
|
||||
> **Stand 24.09.2026.** Dieses Dokument ist die Aufnahme vom 12.09. Was seither
|
||||
> erledigt wurde, steht an Ort und Stelle durchgestrichen oder vermerkt; die
|
||||
> Kurzfassung:
|
||||
>
|
||||
> | | |
|
||||
> |---|---|
|
||||
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
|
||||
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
|
||||
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
|
||||
> | Go-Tests `rescan` | ✅ 5 Testfunktionen (26.09.) – dabei eine fehlende Notbremse gefunden |
|
||||
> | Go-Tests `audit`, `thumbnails` | **bewusst nicht** – siehe unten |
|
||||
> | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests |
|
||||
>
|
||||
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
|
||||
> sie keine Befunde abarbeitete: Rückmeldungen mit Rückgängig, Platzhalter,
|
||||
> Leerzustände, Sprache vereinheitlicht, `Intl`. Steht in
|
||||
> [TESTING.md](TESTING.md).
|
||||
|
||||
Einmal durch die ganze Cloud: Code, Sicherheit, offene Enden. Geordnet nach
|
||||
dem, was passieren kann — nicht nach Aufwand.
|
||||
|
||||
Was hier steht, ist **gemessen**, nicht geschätzt: Routen aus `main.go`
|
||||
extrahiert und gegen die Rechteprüfungen in den Handlern abgeglichen,
|
||||
`govulncheck` über den ganzen Baum, Zeilen und Testumfang je Paket gezählt.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Ein Bild-Upload kann den Prozess töten
|
||||
|
||||
**Schwere: hoch. Jeder Kontoinhaber, normale Bedienung.**
|
||||
|
||||
`cmd/aether/main.go:702` erzeugt das Vorschaubild in einer **nackten
|
||||
Goroutine**:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
…
|
||||
thumbnails.Generate(ctx, st, …) // dekodiert eine hochgeladene Datei
|
||||
}()
|
||||
```
|
||||
|
||||
Chis `Recoverer` hängt am Anfragebehandler. Eine Panik in einer *eigenen*
|
||||
Goroutine fängt er nicht — sie nimmt den **ganzen Go-Prozess** mit. Docker
|
||||
startet ihn neu (`restart: unless-stopped`), aber jeder gerade laufende Upload
|
||||
stirbt mit, und wer die Datei noch einmal hochlädt, wiederholt das Spiel.
|
||||
|
||||
Die passende Lücke war offen: **GO-2026-5061**, Panik im WebP-Dekodierer bei
|
||||
unstimmiger Alphakanal-Größe. Behoben am 12.09.2026 durch `x/image` 0.42 → 0.45
|
||||
(`ef60497`).
|
||||
|
||||
**Damit ist die Hälfte erledigt, nicht das Problem.** Die Bauweise bleibt: Der
|
||||
nächste Dekodierer-Fehler trifft genauso, und Bilddekodierer sind eine Gattung,
|
||||
in der regelmäßig etwas gefunden wird. Vier weitere Goroutinen dieser Art gibt
|
||||
es: `internal/sharing/handler.go:232` und `main.go:185` (beide nur Datenbank,
|
||||
harmlos), `internal/gc/gc.go:257`, und der OCR-Arbeiter (`go worker.Run(ctx)`),
|
||||
der Fremdprogramme aufruft und deren Ausgabe liest.
|
||||
|
||||
**✅ Erledigt am 12.09.2026.** `pkg/bg` mit zwei Funktionen:
|
||||
|
||||
* `bg.Go(name, fn)` – startet eine Goroutine, die eine Panik überlebt.
|
||||
* `bg.Run(name, fn)` – dasselbe ohne eigene Goroutine, für Schleifen.
|
||||
|
||||
Der Unterschied ist keine Feinheit: Fängt man eine Panik um den **ganzen**
|
||||
Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und von da
|
||||
an wird still keine Datei mehr erkannt. Der OCR-Arbeiter sichert deshalb jeden
|
||||
**Auftrag** einzeln ab; ein unverdauliches Dokument kostet dann genau sich
|
||||
selbst.
|
||||
|
||||
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zähler,
|
||||
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit Begründung
|
||||
im Code – die Signalbehandlung beim Herunterfahren; ein recover brächte dort
|
||||
nichts, weil `main` ohnehin wartete, und sie fasst keine fremden Daten an.
|
||||
|
||||
Eine aufgefangene Panik wird mit Stapel und Herkunftsname protokolliert. Sie
|
||||
ist ein Fehler und bleibt einer – sie reißt nur nichts mehr mit. Vier Tests in
|
||||
`pkg/bg/bg_test.go`, darunter der, auf den es ankommt: Eine Schleife läuft nach
|
||||
einer Panik im dritten Durchgang weiter.
|
||||
|
||||
> Der Unterschied ist wichtig: Die Version zu heben schloss **diese** Lücke.
|
||||
> Der `recover` schließt die **Gattung**.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Beschädigte Downloads werden erkannt und verschwiegen
|
||||
|
||||
**Schwere: mittel. Datenintegrität, still.**
|
||||
|
||||
`internal/files/handler.go:496` rechnet beim Ausliefern die Prüfsumme mit —
|
||||
genau so, wie es die Projektregel verlangt („SHA-256: beim Upload berechnen,
|
||||
beim Download verifizieren"). Nur passiert bei einer Abweichung nichts:
|
||||
|
||||
```go
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
// Log corruption; headers already sent, can't change status.
|
||||
// TODO: alert / observability hook
|
||||
_ = actual
|
||||
}
|
||||
```
|
||||
|
||||
Kein Logeintrag, kein Zähler, keine Meldung. Der Kommentar sagt, was fehlt.
|
||||
|
||||
Dass der Statuscode nicht mehr zu ändern ist, stimmt — die Kopfzeilen sind
|
||||
längst draußen. Aber **still** muss es deshalb nicht sein. Ein `log.Error()`
|
||||
mit Datei-ID, erwarteter und tatsächlicher Summe kostet nichts und ist der
|
||||
Unterschied zwischen „ich habe eine kaputte Datei" und „ich habe eine kaputte
|
||||
Datei und weiß es seit drei Monaten nicht".
|
||||
|
||||
Zweitens ließe sich die Verbindung abbrechen, statt sie sauber zu beenden: Der
|
||||
Browser zeigt dann einen abgebrochenen Download statt einer heilen Datei mit
|
||||
falschem Inhalt. Das ist die ehrlichere Ausgabe.
|
||||
|
||||
**✅ Erledigt am 12.09.2026.** Beides – und ein Drittes, das dabei herauskam.
|
||||
|
||||
Der Logeintrag nennt Datei-ID, Speicherschlüssel, erwartete und gelesene Summe.
|
||||
Dazu wird die Verbindung fallen gelassen (`panic(http.ErrAbortHandler)`, den
|
||||
chis Recoverer ausdrücklich durchreicht).
|
||||
|
||||
**Damit das überhaupt wirkt, mussten die letzten 32 KiB zurückgehalten
|
||||
werden.** Sonst wäre die Datei beim Erkennen des Fehlers längst vollständig
|
||||
beim Empfänger, und der Abbruch käme zu spät: Die Kopfzeilen sind draußen, die
|
||||
Bytes auch. Ein `tailWriter` reicht deshalb alles durch außer dem Schluss; erst
|
||||
wenn die Summe stimmt, darf der hinterher. Stimmt sie nicht, bekommt die
|
||||
Gegenstelle weniger Bytes als im `Content-Length` angekündigt – und wertet das
|
||||
als Fehler statt als Datei.
|
||||
|
||||
Geprüft in `scripts/pruefsumme-selbsttest.py` (14 Prüfungen): Der Test kippt
|
||||
ein Bit **auf der Platte**, nicht im Programm, und zwar in der Mitte der Datei,
|
||||
damit der Fehler nicht schon an der Länge auffällt. Zwei Größen – über und
|
||||
unter den 32 KiB, denn bei einer kleinen Datei wird gar nichts herausgegeben.
|
||||
Und er prüft zuerst, dass eine **heile** Datei vollständig durchkommt; ohne das
|
||||
bewiese der Rest nichts.
|
||||
|
||||
---
|
||||
|
||||
## 2b · Und der Fund dahinter: 34 Logaufrufe schrieben ins Nichts
|
||||
|
||||
**Schwere: hoch, indirekt. Ein Fehler, der andere Fehler versteckt.**
|
||||
|
||||
Der neue Logeintrag blieb beim ersten Testlauf aus, obwohl der Abbruch
|
||||
funktionierte. Ursache:
|
||||
|
||||
```go
|
||||
func Ctx(ctx context.Context) *Logger {
|
||||
if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { return l }
|
||||
else if l = DefaultContextLogger; l != nil { return l }
|
||||
return disabledLogger // ← hier landete alles
|
||||
}
|
||||
```
|
||||
|
||||
`zerolog.Ctx` liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||
steckt und kein Standard gesetzt ist. Aethers `middleware.Logger` hängte nie
|
||||
einen hinein, und `DefaultContextLogger` war nie gesetzt.
|
||||
|
||||
**Alle 34 `log.Ctx(...)`-Aufrufe im Baum schrieben damit ins Nichts.** Darunter:
|
||||
|
||||
* die Warnung, dass beim Zurückrollen einer Fassung ein Blob verwaist ist
|
||||
(`files/handler.go`, `files/replace.go`, `handler_versions.go`)
|
||||
* gescheiterte Suchen
|
||||
* Fehler der Speicherbereinigung
|
||||
* die neue Meldung über beschädigte Dateien
|
||||
|
||||
Das ist die unangenehmste Art Fehler: Der Aufruf sieht richtig aus, `go vet`
|
||||
schweigt, und auffallen kann es nur, wenn man den Fehler herbeiführt, den die
|
||||
Zeile melden soll.
|
||||
|
||||
**Behoben zweifach:** `zerolog.DefaultContextLogger` beim Start gesetzt (deckt
|
||||
auch Hintergrundarbeit mit eigenem Kontext ab), und `middleware.Logger` hängt
|
||||
zusätzlich einen Logger mit `request_id` in den Anfragekontext – damit bei zwei
|
||||
gleichzeitigen Downloads erkennbar bleibt, welche Meldung zu welchem gehört.
|
||||
Zwei Tests in `pkg/middleware/logger_test.go` halten es fest.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Vier Pakete ohne einen einzigen Go-Test
|
||||
|
||||
**Schwere: mittel. Nicht was heute kaputt ist, sondern was morgen still kaputtgeht.**
|
||||
|
||||
| Paket | Zeilen | Test (24.09.) | Aussenpruefung |
|
||||
|---|---:|---:|---|
|
||||
| `internal/tags` | 1338 | **4** ✅ | keine |
|
||||
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
|
||||
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
|
||||
| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen |
|
||||
| `internal/audit` | 180 | **0** | keine |
|
||||
| `internal/thumbnails` | 95 | **0** | keine |
|
||||
|
||||
Bei `sharing` und `rescan` deckt wenigstens ein Selbsttest die Außenseite ab.
|
||||
Bei **`documents` und `tags` gar nichts** — und `documents` ist das Stück, das
|
||||
aus `scan_0042.pdf` ein auffindbares Dokument macht. Genau der Grund, aus dem
|
||||
Aether das Rechnungsarchiv ist.
|
||||
|
||||
**Teilweise erledigt am 12.09.2026:** `internal/documents` hat jetzt acht
|
||||
Tests — gezielt auf die drei Zustände, in denen die Warteschlange
|
||||
hängenbleiben kann, ohne dass es auffällt (Details in
|
||||
[TESTING.md](TESTING.md)). Dazu `scripts/db-tests.sh`, damit sie nicht wie
|
||||
`vitest` zur Dekoration werden: Sie überspringen sich ohne `TEST_DSN` und
|
||||
liefen sonst nie.
|
||||
|
||||
Gegengeprüft an absichtlich kaputtem Code — und **der zweite Versuch blieb
|
||||
grün**: `maxAttempts` von 3 auf 9999 gesetzt, alle Tests weiter in Ordnung,
|
||||
nur die Laufzeit stieg von 0,2 auf 27 Sekunden. Die Tests schleiften über die
|
||||
Konstante und setzten die Zahl voraus, statt sie zu prüfen. Dagegen steht jetzt
|
||||
ein eigener Test.
|
||||
|
||||
**`internal/tags` ebenfalls erledigt am 12.09.2026.** Fünf Tests, alle auf
|
||||
dieselbe Frage: Bleibt ein fremdes Etikett fremd – in der Liste, beim
|
||||
Umbenennen, beim Löschen, beim Anhängen an die eigene Datei, und vor allem beim
|
||||
**Auflisten der Dateien darunter**. Der letzte Weg gibt Dateinamen, Ordner und
|
||||
Größen heraus; wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||
fremden Unterlagen.
|
||||
|
||||
Auch hier gegen kaputten Code gegengeprüft: Eigentümerprüfung aus
|
||||
`FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf"*.
|
||||
Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests rot.
|
||||
|
||||
**Ein Verdacht erwies sich dabei als falsch, und das ist der nützlichere
|
||||
Ausgang:** `List` bietet Gruppen-Etiketten an, `SetFieldsForFile` vergleicht
|
||||
gegen den Prinzipal der *Datei* – das sah nach einem Widerspruch aus, bei dem
|
||||
die Oberfläche etwas anbietet, was der Server ablehnt. Ist keiner: Die
|
||||
Oberfläche filtert bereits (`TagDialog.tsx:69`). Und die Ablehnung ist nicht
|
||||
Förmlichkeit, sondern die tragende Absicherung – `FilesWithTag` prüft, ob man
|
||||
das *Etikett* sehen darf, **nicht** die *Datei*. Das ist nur deshalb harmlos,
|
||||
weil ein Etikett stets denselben Prinzipal hat wie die Dateien darunter. Fiele
|
||||
die Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei, und jedes
|
||||
Gruppenmitglied sähe ihren Namen. Der Test hält jetzt diesen Zusammenhang fest,
|
||||
nicht bloß eine Fehlermeldung.
|
||||
|
||||
Nebenbei: `internal/tags` hat **zwei** Fehlermarken für dieselbe Aussage –
|
||||
`ErrNotFound` und `ErrCorrespondentNotFound`. Kein Fehler, aber eine
|
||||
Stolperstelle; jetzt wenigstens festgehalten.
|
||||
|
||||
**Offen bleiben** `internal/sharing`, `internal/rescan` (beide mit
|
||||
Python-Selbsttest), `internal/audit` und `internal/thumbnails`.
|
||||
|
||||
---
|
||||
|
||||
### `audit` und `thumbnails`: bewusst ohne Go-Tests (26.09.2026)
|
||||
|
||||
Nicht vergessen, sondern entschieden. Ein Fehler in `thumbnails` (95 Zeilen)
|
||||
erzeugt ein hässliches Vorschaubild; einer in `audit` (180 Zeilen) kostet einen
|
||||
Protokolleintrag. Beide schreiben nichts kaputt und löschen nichts.
|
||||
|
||||
Tests dafür wären Häkchen, keine Absicherung – und sie kosten dieselbe
|
||||
Aufmerksamkeit wie die Stellen, an denen wirklich etwas passieren kann. Wer sie
|
||||
später doch will, hat damit wenigstens die Gegenrede.
|
||||
|
||||
## 4 · Was in Ordnung ist
|
||||
|
||||
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
|
||||
gibt:
|
||||
|
||||
* **Alle 27 Admin-Routen prüfen serverseitig auf Adminrecht.** Zwei
|
||||
Ausnahmen sind Absicht und dokumentiert: `GET /admin/settings` liest jeder
|
||||
Angemeldete (Theming — und es liegen dort nur sechs Branding-Schlüssel,
|
||||
nachgesehen), `GET /ocr` ist der eigene Erkennungsstand ohne Adminrecht.
|
||||
* **Kein einziges zusammengesetztes SQL.** Alles parametrisiert.
|
||||
* **Inline-Auslieferung nur über eine Positivliste** harmloser Typen, mit
|
||||
`sandbox`, und `allow-scripts` ausschließlich für PDF.
|
||||
* **Ratenbegrenzung an jeder unangemeldeten Tür**, die eines braucht: Anmeldung
|
||||
(20/min), WebDAV, CalDAV, Freigabelinks (je 10 Fehlversuche / 15 Minuten).
|
||||
Die übrigen offenen Wege (`/health`, `/branding`, `/manifest.webmanifest`,
|
||||
`/api/v1/public/config`) geben nichts preis.
|
||||
* **Kein `dangerouslySetInnerHTML`, kein `eval`** im Frontend.
|
||||
* **Sechs Selbsttest-Suiten, 220 Prüfungen** gegen die laufende Instanz, dazu
|
||||
37 Frontend-Tests und die Go-Tests.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Kleinere offene Enden
|
||||
|
||||
* **`internal/groups` ist ein leerer Stub** (0 Zeilen). Gruppenordner liegen in
|
||||
`files/groupfolder.go`. Entweder das Verzeichnis weg oder der Code dorthin —
|
||||
ein leeres Paket im Baum ist eine Frage, die jeder einmal stellt.
|
||||
* **`favicon_storage_key`** steht als leere Zeile in den Einstellungen; die
|
||||
Funktion heißt seit dem App-Symbol anders. Schadet nichts, wird ignoriert.
|
||||
* **Die Fehlversuchssperren liegen im Arbeitsspeicher.** Ein Neustart des
|
||||
Containers hebt jede Sperre auf. Für den Hausgebrauch vertretbar; wer es
|
||||
anders will, braucht eine Tabelle.
|
||||
* ~~**Frontend ohne Komponententests.**~~ ✅ **18.09.2026:** `jsdom` ist in den
|
||||
devDependencies, die Umgebung bleibt `node` (schnell); wer ein DOM braucht,
|
||||
schreibt `// @vitest-environment jsdom` an den Anfang seiner Datei. 46 Tests,
|
||||
davon vier gegen ein DOM. Der erste davon hat sofort einen Fehler gefunden –
|
||||
in der Behebung vom Vortag (`useBackClose`).
|
||||
|
||||
**Was er nicht leistet, steht dabei:** Den ursprünglichen Fehler bildet jsdom
|
||||
nicht ab; es verwirft einen Verlaufssprung, sobald `pushState` folgt. Dafür
|
||||
bräuchte es einen echten Browser (Playwright). Nachgeprüft, nicht vermutet:
|
||||
mit der alten, kaputten Fassung blieben alle Tests grün.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge, die ich vorschlagen würde
|
||||
|
||||
1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026
|
||||
2. ~~Prüfsummen-Abweichung melden~~ ✅ 12.09.2026 – und dabei fiel auf, dass
|
||||
**alle** kontextgebundenen Logzeilen ins Nichts gingen (2b).
|
||||
3. ~~Tests für `documents`~~ ✅ und ~~`tags`~~ ✅ 12.09.2026.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Ideen
|
||||
|
||||
Was noch kommen könnte. **Nichts davon ist beschlossen** — beschlossen ist,
|
||||
was im [README](README.md) und in den Plänen steht
|
||||
([cloud-projekt-plan.md](cloud-projekt-plan.md), [kalender-plan.md](kalender-plan.md)).
|
||||
|
||||
Hier landet, was einen guten Grund hat, aber gerade keinen Anlass. Der
|
||||
Unterschied ist wichtig: Eine Funktion ohne Anlass zu bauen ist der Weg, auf
|
||||
dem eine Anwendung unübersichtlich wird.
|
||||
|
||||
---
|
||||
|
||||
## Kalender
|
||||
|
||||
### Öffentlicher Abo-Link — vertagt (12.09.2026)
|
||||
|
||||
Ein Nur-Lese-Link auf einen Kalender, den jemand **ohne Konto** abonnieren
|
||||
kann. Nicht dasselbe wie die Freigabe an ein Konto (die steht seit dem
|
||||
12.09.2026): Dort ist der Empfänger bekannt, hier ist er es nicht.
|
||||
|
||||
**Warum vertagt:** kein Anlass. Marc ist der einzige Nutzer seiner Cloud, und
|
||||
für den eigenen Gebrauch gibt es bereits zwei Wege an dieselben Termine — die
|
||||
Weboberfläche und CalDAV mit App-Passwort.
|
||||
|
||||
**Wofür es gut wäre, wenn der Anlass kommt:**
|
||||
|
||||
* Den eigenen Kalender in einer *fremden* Anwendung einblenden, die kein
|
||||
CalDAV mit Anmeldung kann, sondern nur eine `.ics`-Adresse frisst — etwa
|
||||
ein Kalender auf der Arbeit.
|
||||
* Einen Terminplan an Familie oder Verein geben, ohne für jeden ein Konto
|
||||
anzulegen.
|
||||
|
||||
**Wie es gebaut würde** (damit die Überlegung nicht verloren geht):
|
||||
|
||||
Aether hat die Freigabelinks für Dateien schon, samt Token, optionalem
|
||||
Passwort, Ablaufdatum und dem Zähler. Ein Kalender-Abo ist dieselbe Sache mit
|
||||
einer anderen Ausgabe: `GET /share/kalender/{token}.ics` liefert den Export,
|
||||
den es schon gibt (`api.go`, `Export`).
|
||||
|
||||
Drei Dinge wären dabei zu entscheiden, und sie sind der eigentliche Inhalt:
|
||||
|
||||
1. **Nur lesend, ohne Ausnahme.** Ein schreibbarer Link wäre ein Konto ohne
|
||||
Passwort.
|
||||
2. **Was steht drin?** Ein Abo zeigt Titel, Ort und Beschreibung jedes
|
||||
Termins. Wer „Arzttermin, Dr. Müller" im Kalender stehen hat und den Link
|
||||
weitergibt, gibt das mit weiter. Ein Schalter „nur Belegt/Frei" (Titel
|
||||
durch „Belegt" ersetzen) wäre deshalb kein Zierrat, sondern der Grund,
|
||||
warum man den Link überhaupt zu teilen wagt.
|
||||
3. **Ein Abo wird regelmäßig abgerufen**, nicht einmal. Ein Kalenderprogramm
|
||||
fragt alle paar Minuten nach. Der Zähler der Freigabelinks zählte dann
|
||||
Maschinen statt Menschen, und die Sperre nach zehn Fehlversuchen träfe
|
||||
einen Client, der bloß ein falsch gespeichertes Passwort wiederholt.
|
||||
Beides müsste für diesen Linktyp anders sein.
|
||||
|
||||
### Aufgabenansicht (VTODO) — vertagt (12.09.2026)
|
||||
|
||||
Aether nimmt Aufgaben bereits an, speichert sie und gleicht sie ab; die Spalte
|
||||
`component` in `cal_objects` unterscheidet sie von Terminen. Es fehlt **nur**
|
||||
die Ansicht in der Weboberfläche.
|
||||
|
||||
**Warum vertagt:** Auf Marcs Telefon gibt es nichts, was sie zeigen würde.
|
||||
|
||||
Android hat von Haus aus **keinen Speicher für Aufgaben**. Für Termine gibt es
|
||||
einen Systemanbieter — den füllt DAVx5, und Samsung Kalender liest daraus. Für
|
||||
Aufgaben gibt es das nicht. DAVx5 kann Aufgabenlisten deshalb nur abgleichen,
|
||||
wenn eine App installiert ist, die selbst den Speicher mitbringt:
|
||||
**`tasks.org`**, OpenTasks oder jtx Board.
|
||||
|
||||
**Samsungs „Erinnerungen" gehört nicht dazu.** Der Bereich „Erinnerungen" im
|
||||
Samsung Kalender ist kein Teil des Kalenders, sondern ein Fenster in die
|
||||
Samsung-Erinnerungen-App; die hängt am Samsung-Konto (bzw. an Microsoft To Do),
|
||||
nicht an CalDAV. Der Unterschied ist am Gerät ablesbar: Beim Anlegen eines
|
||||
**Termins** fragt Samsung Kalender, in welchen Kalender — dort steht Aether zur
|
||||
Auswahl. Beim Anlegen einer **Erinnerung** fragt es das nicht, weil es nur
|
||||
einen Ort gibt, an den sie gehen kann.
|
||||
|
||||
Eine Ansicht in Aether wäre heute also eine reine Weboberflächen-Aufgabenliste –
|
||||
ohne Weg, vom Telefon aus etwas hineinzuschreiben. Das ist ein eigenes
|
||||
Vorhaben, kein Nebenprodukt des Kalenders.
|
||||
|
||||
**Der Anlass wäre: `tasks.org`.** Kostenlos, quelloffen, arbeitet mit DAVx5
|
||||
zusammen und bringt den Aufgabenspeicher mit, der Android fehlt. Ist die App
|
||||
installiert und das Aether-Konto in DAVx5 für Aufgabenlisten eingeschaltet,
|
||||
landen Aufgaben vom Telefon in Aether — und dann hat die Ansicht in der
|
||||
Weboberfläche einen Anlass. Der Unterbau steht bereits: Aether meldet seinen
|
||||
Kalendern `VTODO` als unterstützt, nimmt sie an, speichert sie und gleicht sie
|
||||
über `sync-collection` ab. Zu bauen bliebe die Ansicht selbst — eine Liste mit
|
||||
Häkchen, Fälligkeit und Priorität, sinnvollerweise als vierte Ansicht neben
|
||||
Monat, Woche und Liste.
|
||||
|
||||
**Was beim Bauen zu bedenken wäre:** Eine Aufgabe hat *kein* Pflichtdatum. Alle
|
||||
drei heutigen Ansichten ordnen nach Zeit — eine Aufgabe ohne Fälligkeit hätte
|
||||
darin keinen Platz. Das ist der Grund, warum es eine eigene Ansicht braucht und
|
||||
kein Einstreuen in den Monat.
|
||||
|
||||
**Gegentest, falls die Einschätzung falsch ist:** DAVx5 öffnen, Aether-Konto
|
||||
antippen. Gibt es dort einen Reiter „Aufgabenlisten" mit Einträgen, ohne dass
|
||||
eine der genannten Apps installiert ist, stimmt das oben nicht.
|
||||
|
||||
---
|
||||
|
||||
## Eine Android-App — notiert (15.09.2026)
|
||||
|
||||
Eine einfache eigene App fürs Telefon, statt der installierten Weboberfläche.
|
||||
|
||||
**Warum das nicht offensichtlich ist:** Die Weboberfläche *ist* am Telefon
|
||||
schon installierbar, hat ein eigenes Symbol, läuft ohne Adressleiste und kennt
|
||||
die sicheren Bereiche neben Uhr und Wischbalken. Eine App, die nur dasselbe in
|
||||
einem Rahmen zeigt, wäre Arbeit ohne Gewinn.
|
||||
|
||||
**Was eine echte App kann, was die Weboberfläche nicht kann** — und nur dafür
|
||||
lohnt sie sich:
|
||||
|
||||
* **Fotos automatisch hochladen.** Der eigentliche Grund, warum Leute die
|
||||
Nextcloud-App installieren. Der Browser darf im Hintergrund nicht auf die
|
||||
Galerie, eine App schon.
|
||||
* **„Teilen mit → Aether"** aus jeder anderen App heraus. Ein Anhang aus dem
|
||||
Mailprogramm landet in einem Tippen in der Cloud.
|
||||
* **Hochladen, das weiterläuft**, wenn der Schirm aus ist. Ein Browser-Tab
|
||||
wird nach Sekunden angehalten; ein großer Upload bricht deshalb ab.
|
||||
* **Dateien im Android-Dateimanager** über einen Storage Access Provider.
|
||||
|
||||
**Was es kosten würde:** Ein zweiter Stapel (Kotlin oder Flutter), eine zweite
|
||||
Veröffentlichungskette, und jede Serveränderung ist ab dann an zwei Stellen
|
||||
nachzuziehen. Die bestehende REST-Schnittstelle plus App-Passwörter genügen
|
||||
als Unterbau, ein neues Backend wäre es nicht.
|
||||
|
||||
**Der Anlass wäre: automatischer Fotoupload.** Solange das nicht gebraucht
|
||||
wird, ist die installierte Weboberfläche der bessere Weg — sie ist da, sie ist
|
||||
gepflegt, und sie ist genau eine Codebasis.
|
||||
|
||||
---
|
||||
|
||||
## Mehrsprachigkeit — notiert (18.09.2026)
|
||||
|
||||
Die Oberfläche spricht seit dem 18.09.2026 durchgehend Englisch. Deutsch (oder
|
||||
irgendeine weitere Sprache) daneben wäre eine eigene Baustelle.
|
||||
|
||||
**Der Umfang, gemessen statt geschätzt:**
|
||||
|
||||
~986 sichtbare Zeichenketten in 55 Frontend-Dateien
|
||||
144 Fehlermeldungen im Backend
|
||||
|
||||
**Der Punkt, den man dabei vergisst: Es sind zwei Übersetzungen, nicht eine.**
|
||||
|
||||
Die 144 Meldungen des Servers landen seit dieser Woche in den Toasts – „You
|
||||
only have read access to this file" kommt aus Go, nicht aus React. Wer nur das
|
||||
Frontend übersetzt, bekommt eine deutsche Oberfläche, die bei jedem Fehler auf
|
||||
Englisch umschaltet. Das wirkt kaputter als durchgehend Englisch.
|
||||
|
||||
Der saubere Weg: Der Server schickt keinen fertigen Satz mehr, sondern einen
|
||||
Schlüssel samt Werten (`{"error":"file.read_only"}`); übersetzt wird vorne, wo
|
||||
der Katalog ohnehin liegt. Das ist eine Protokolländerung an 144 Stellen, kein
|
||||
Wörterbuch.
|
||||
|
||||
**Wie es gebaut würde:**
|
||||
|
||||
* Jede Zeichenkette wird ein Schlüssel (`t('calendar.new')`). Rund 1000
|
||||
Stellen – stumpfe Arbeit, gut mit Werkzeug zu machen.
|
||||
* `react-i18next` statt Eigenbau. Eigenbau scheitert an den **Pluralformen**:
|
||||
„1 Datei" / „5 Dateien", und andere Sprachen haben mehr als zwei Formen.
|
||||
* Die Sprache kommt aus `navigator.language`, ist im Profil überschreibbar und
|
||||
liegt **in der Datenbank** – damit sie am Telefon dieselbe ist wie am
|
||||
Rechner. Dieselbe Entscheidung wie bei den Favoriten.
|
||||
|
||||
**Warum vertagt:** Marc ist der einzige Nutzer und liest Englisch. Der Preis
|
||||
ist nicht die einmalige Umstellung, sondern dass ab dann *jeder neue Text zwei
|
||||
Änderungen kostet* – für immer. Der Anlass wäre eine zweite Person, die kein
|
||||
Englisch mag.
|
||||
|
||||
### Unabhängig davon lohnend: `Intl`
|
||||
|
||||
Aether pflegt **sechs handgeschriebene Arrays** mit Wochentagen und
|
||||
Monatsnamen (CalendarPage, DayDialog, EventDialog), und `Intl` wird im ganzen
|
||||
Projekt **nirgends** benutzt. Dabei kann der Browser das seit Jahren:
|
||||
|
||||
new Intl.DateTimeFormat(sprache, { weekday: 'short' })
|
||||
|
||||
Das ersetzt alle sechs, kostet keine Abhängigkeit und entschärft eine Falle,
|
||||
die beim Übersetzen auffiel: `DayDialog` beginnt seine Woche am **Sonntag**,
|
||||
die anderen am **Montag** – jeweils passend zu ihrer Indizierung. Heute
|
||||
korrekt, aber wer eines dieser Arrays kopiert, baut sich einen
|
||||
Off-by-one-Fehler.
|
||||
|
||||
Lohnt sich auch ohne jede Übersetzung, und wäre die Vorarbeit, falls sie
|
||||
kommt.
|
||||
@@ -507,6 +507,88 @@ hunderte pro Minute.
|
||||
|
||||
---
|
||||
|
||||
## Dateien von außen: Abgleich mit der Datenbank
|
||||
|
||||
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
|
||||
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
|
||||
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
|
||||
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
|
||||
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
|
||||
und löscht sie.
|
||||
|
||||
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
|
||||
|
||||
| Fall | Was der Abgleich tut |
|
||||
|---|---|
|
||||
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
|
||||
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
|
||||
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
|
||||
|
||||
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
|
||||
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
|
||||
Papierkorb – es ist endgültig.
|
||||
|
||||
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
|
||||
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
|
||||
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
|
||||
eigenen Datei.
|
||||
|
||||
### Für Sicherungen heißt das
|
||||
|
||||
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
|
||||
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
|
||||
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
|
||||
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
|
||||
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
|
||||
|
||||
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
|
||||
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
|
||||
auf Wunsch weg.
|
||||
|
||||
### Der große Fall: alles zurück, ohne Postgres
|
||||
|
||||
Einzelne Dateien zurückzuholen ist das eine. Der Ernstfall ist der andere:
|
||||
Der Server ist fort, aus der Sicherung kommen nur die **Dateien** zurück, die
|
||||
Datenbank ist leer.
|
||||
|
||||
Das geht – aber nur in dieser Reihenfolge, und der zweite Schritt ist der,
|
||||
den man vergisst:
|
||||
|
||||
1. **Dateien zurücklegen** nach `DATA_PATH`, mit der Struktur aus der
|
||||
Sicherung (`users/<konto>/home/…`). Aether starten.
|
||||
2. **Konten neu anlegen – mit exakt denselben Benutzernamen.** Der Ordnername
|
||||
auf der Platte *ist* die Zuordnung; ein Konto `mueller` findet nichts, was
|
||||
unter `users/Mueller/` liegt. Passwörter dürfen neu sein, Namen nicht.
|
||||
3. **Admin → Audit → Rescan storage**, erst *Check*, dann *Apply*.
|
||||
|
||||
Ohne Schritt 2 bleibt der Abgleich stehen und sagt es je Datei:
|
||||
`Konto "altnutzer" gibt es nicht`. Er rät nicht und legt nichts an – ein
|
||||
falsch zugeordnetes Dokument wäre schlimmer als ein unsichtbares.
|
||||
|
||||
**Einmal geprobt, nicht behauptet** (10.09.2026): Auf einer eigens
|
||||
aufgesetzten Instanz Dateien gesichert, Container *und* Datenbank
|
||||
weggeworfen, nur die Dateien zurückgeholt. Nach den drei Schritten standen
|
||||
Ordnerkette, Unterordner, Umlaute im Dateinamen, Größen, Prüfsummen und
|
||||
Dateitypen wieder da, und der Download lieferte den ursprünglichen Inhalt.
|
||||
`.trash/` und `.versions/` blieben zu Recht außen vor. Ein dritter Abgleich
|
||||
meldete nichts Offenes mehr.
|
||||
|
||||
**Was dabei nicht zurückkommt**, weil es nur in der Datenbank steht:
|
||||
Etiketten, Absender, Belegdatum, Freigabelinks, frühere Fassungen, der
|
||||
erkannte Text der Volltextsuche (er wird neu erzeugt), Kalender und Termine.
|
||||
Wem das wehtut, der sichert Postgres mit – die Dateien allein sind die
|
||||
Rückfallebene, nicht der volle Umfang.
|
||||
|
||||
### Aktualisieren einer laufenden Instanz
|
||||
|
||||
Neues Abbild ziehen und neu starten, mehr nicht: Die Migrationen laufen beim
|
||||
Start selbst. Geprobt am 10.09.2026 von Schema 19 auf 23 mit Daten in der
|
||||
Datenbank – Konten, Ordner, Dateien und Etikettenzuordnungen standen danach
|
||||
unverändert da, kein `dirty`-Zustand. **Ein Neuaufbau ist dafür nicht nötig
|
||||
und bringt nichts**, was das Update nicht auch bringt.
|
||||
|
||||
---
|
||||
|
||||
## Kalender (CalDAV)
|
||||
|
||||
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
|
||||
@@ -583,6 +665,13 @@ Container heraus er selbst.
|
||||
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
|
||||
OnlyOffice und Euro-Office auf 80.**
|
||||
|
||||
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
|
||||
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
|
||||
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
|
||||
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
|
||||
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
|
||||
> den Proxy die öffentliche Adresse bedienen.
|
||||
|
||||
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
||||
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
||||
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
||||
@@ -702,20 +791,31 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
|
||||
- "${APP_BIND:-0.0.0.0}:9980:80"
|
||||
```
|
||||
|
||||
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das
|
||||
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das
|
||||
Abbild.
|
||||
|
||||
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
|
||||
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
|
||||
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
|
||||
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
|
||||
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
|
||||
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
|
||||
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
|
||||
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
|
||||
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
|
||||
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
|
||||
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
|
||||
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
|
||||
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
|
||||
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
|
||||
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
|
||||
> über die Ermittlung hinaus.
|
||||
>
|
||||
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
|
||||
> nach Aktenlage hakt:
|
||||
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
|
||||
> betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
|
||||
> Collabora daneben weiterlief.
|
||||
>
|
||||
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
|
||||
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
|
||||
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
|
||||
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
|
||||
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
|
||||
> Fehler meldet sich nicht, er zeigt nur nichts.
|
||||
>
|
||||
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
|
||||
> an denen es nach Aktenlage hakt:
|
||||
>
|
||||
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
||||
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
||||
@@ -725,53 +825,14 @@ Abbild.
|
||||
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||||
> das der Ort.
|
||||
>
|
||||
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
|
||||
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
|
||||
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
|
||||
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
|
||||
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
|
||||
> gesetzt – sonst läuft die API offen.
|
||||
|
||||
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
|
||||
|
||||
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
|
||||
was aufgeht:
|
||||
|
||||
| Anbieter | am Telefon |
|
||||
|---|---|
|
||||
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
|
||||
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
|
||||
|
||||
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
|
||||
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
|
||||
eine Tastatur.
|
||||
|
||||
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
|
||||
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
|
||||
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
|
||||
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
|
||||
nicht.
|
||||
|
||||
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
|
||||
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
|
||||
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
|
||||
entscheidet der Anbieter.
|
||||
|
||||
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
|
||||
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
|
||||
LibreOffice-Layout desselben Dokuments sehen.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
|
||||
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
|
||||
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
|
||||
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
|
||||
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
|
||||
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
|
||||
|
||||
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
|
||||
Bereinigung, zusammen mit Vorschau- und Profilbildern.
|
||||
> **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
|
||||
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
|
||||
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
|
||||
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
|
||||
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
|
||||
> **Der Office-Container liest es**, und ohne steht seine API offen, wenn
|
||||
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
|
||||
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
|
||||
|
||||
### Welche Dateien, und wie gut
|
||||
|
||||
@@ -857,7 +918,7 @@ unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
|
||||
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
|
||||
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
|
||||
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
|
||||
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
|
||||
| **Euro-Office** | wie OnlyOffice, Abbild **6,99 GB** (hier nachgemessen) | derselbe Unterbau |
|
||||
|
||||
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||||
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
||||
@@ -872,7 +933,8 @@ kurzzeitig mehrere hundert Megabyte.
|
||||
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
||||
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
||||
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
||||
Arbeitsspeicher, und das Abbild wiegt rund 5 GB.
|
||||
Arbeitsspeicher, und das Abbild wiegt **6,99 GB** – nachgemessen, nicht
|
||||
geschätzt; die Ankündigung nennt weniger.
|
||||
|
||||
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
|
||||
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
||||
|
||||
+887
-2
@@ -135,6 +135,183 @@ diesem Bereich sucht:
|
||||
Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet
|
||||
Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen.
|
||||
|
||||
## Abgleich Platte ↔ Datenbank: Selbsttest per Skript
|
||||
|
||||
Der Abgleich (Admin → Audit → **Rescan storage**) übernimmt Dateien, die von
|
||||
außen dazugekommen sind – aus einer Sicherung zurückgeholt oder per SFTP
|
||||
abgelegt – und **entfernt Einträge**, deren Datei fort ist. Das zweite ist der
|
||||
Grund für diesen Test: Ein Fehler darin löscht Daten, die niemand vermisst,
|
||||
bis er sie braucht.
|
||||
|
||||
python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD]
|
||||
|
||||
Der Test schreibt selbst Dateien ins Datenverzeichnis – er muss also auf dem
|
||||
Rechner laufen, auf dem die Daten liegen (Vorgabe `~/aether-data`).
|
||||
|
||||
20 Prüfungen in fünf Gruppen: **Prüfen ändert nichts** (zweimal geprüft
|
||||
liefert dasselbe, nichts wird sichtbar), **Übernehmen** (Ordnerkette angelegt,
|
||||
Prüfsumme und Größe stimmen, Dateityp erkannt, Herunterladen liefert die
|
||||
Datei), **von außen ersetzt** (Größe und Prüfsumme werden berichtigt),
|
||||
**Datei ist fort** (verwaister Eintrag wird gefunden, bleibt beim Prüfen
|
||||
stehen, verschwindet erst beim Anwenden), **nicht anfassen** (Papierkorb und
|
||||
frühere Fassungen liegen im selben Baum und dürfen nicht übernommen werden –
|
||||
sonst tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu
|
||||
einer eigenen Datei), und **nur für Verwalter**.
|
||||
|
||||
### Warum es das überhaupt gibt
|
||||
|
||||
Der GC räumt Dateien weg, zu denen es keinen Eintrag gibt. Für Reste
|
||||
gelöschter Dateien ist das richtig – für eine per SFTP abgelegte Datei ist es
|
||||
falsch: Sie ist in Aether unsichtbar und nach 24 Stunden weg. Der Abgleich ist
|
||||
die Gegenrichtung und macht aus solchen Dateien richtige Einträge.
|
||||
|
||||
Zwei Fallen, die beim Bauen auffielen und die ein Test hier abdecken muss:
|
||||
|
||||
- **Der Dateityp muss aus Endung oder Inhalt kommen.** Beim Hochladen schickt
|
||||
der Browser ihn mit; eine per SFTP abgelegte Datei bringt niemanden mit, der
|
||||
ihn kennt. Dazu kommt: Das Alpine-Abbild hat keine `/etc/mime.types`, und
|
||||
Gos eingebaute Liste kennt nicht einmal `.txt`.
|
||||
- **Prüfsumme nicht überspringen.** Sie kostet einen vollständigen Lesevorgang
|
||||
je Datei, aber ein Eintrag ohne sie ist ein Eintrag, dessen Beschädigung
|
||||
beim Herunterladen niemand bemerkt.
|
||||
|
||||
## Abgebrochener Upload
|
||||
|
||||
Am Kreuz der Fortschrittsmeldung lässt sich ein laufender Upload fallen
|
||||
lassen. Die Zusage dahinter ist die eigentliche Frage: **Auf dem Server bleibt
|
||||
dabei nichts zurück.**
|
||||
|
||||
python3 scripts/upload-abbruch-selbsttest.py
|
||||
|
||||
Der Test kappt eine echte Verbindung mitten im Leib – auf einem rohen Socket,
|
||||
nicht über eine Bibliothek. Ein sauber gesendeter, aber zu kurzer Leib wäre
|
||||
etwas anderes: Den wiese der Server als Formatfehler ab, und über den Abbruch
|
||||
bewiese das nichts.
|
||||
|
||||
13 Prüfungen: **neue Datei** (kein Eintrag, nichts im Papierkorb),
|
||||
**Überschreiben mit Versionierung** – der gefährlichste Fall, weil der Server
|
||||
die alte Fassung dabei kurzzeitig zur Seite schiebt: Sie muss danach in Größe,
|
||||
Prüfsumme und Inhalt unversehrt sein –, **Verbrauch** und ein **Rescan**, der
|
||||
nachsieht, ob herrenlose Bytes liegengeblieben sind.
|
||||
|
||||
### Eine Falle für jeden, der die Schnittstelle skriptet
|
||||
|
||||
`GET /api/v1/auth/me` **erneuert das CSRF-Token** und gibt das neue zurück.
|
||||
Wer den Wert von der Anmeldung festhält, bekommt beim nächsten schreibenden
|
||||
Ruf ein `403` und sucht die Ursache bei den Rechten. Die Weboberfläche zieht
|
||||
den neuen Wert an allen drei Stellen nach (`hooks/useAuth.ts`); ein Skript
|
||||
muss es auch.
|
||||
|
||||
## Kalender teilen
|
||||
|
||||
python3 scripts/kalender-teilen-selbsttest.py
|
||||
|
||||
Eine Rechtefunktion ist so viel wert wie ihre engste Stelle. Aether hat **zwei**
|
||||
Zugänge zu denselben Kalendern – die Weboberfläche und CalDAV –, und beide
|
||||
prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist keines;
|
||||
deshalb geht hier jede Prüfung über beide Wege.
|
||||
|
||||
41 Prüfungen in sieben Gruppen, und die Reihenfolge ist der Punkt: **vorher
|
||||
sieht der Empfänger nichts** (ohne diesen Anfang beweist der Rest nichts – ein
|
||||
Kalender, den ohnehin jeder sieht, ist auch nach dem Freigeben sichtbar), dann
|
||||
nur lesend (sehen ja, ändern nein – und beim Eigentümer kommt nachweislich
|
||||
nichts an), schreibend, die **Grenzen** des Schreibrechts (fremden Kalender
|
||||
umbenennen, löschen oder weiterverschenken geht auch dann nicht), Freigabe über
|
||||
eine **Gruppe**, `sync-collection` auf einem fremden Kalender, und der
|
||||
Widerruf.
|
||||
|
||||
Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am Ende
|
||||
alles weg.
|
||||
|
||||
### Eine Falle, die der Test selbst hatte
|
||||
|
||||
Die Terminliste hat einen Standardzeitraum (ein Monat zurück bis ein Jahr
|
||||
voraus). Mit einem festen Testdatum lieferte sie irgendwann eine leere Liste –
|
||||
und die Prüfung „beim Eigentümer ist nichts angekommen" **bestand dann leer**.
|
||||
Eine Prüfung, die ohne Daten besteht, prüft nichts. Jetzt liegen die Testtermine
|
||||
relativ zum heutigen Tag, der Zeitraum wird ausdrücklich mitgegeben, und eine
|
||||
Zeile davor stellt sicher, dass überhaupt etwas in der Liste steht.
|
||||
|
||||
## Go-Tests mit Datenbank
|
||||
|
||||
Einige Go-Tests brauchen ein echtes Postgres – die Warteschlange der
|
||||
Texterkennung etwa lässt sich ohne nicht prüfen. Sie überspringen sich selbst,
|
||||
wenn `TEST_DSN` fehlt, damit `go test ./...` überall grün bleibt.
|
||||
|
||||
**Der Preis dieser Freundlichkeit: Sie laufen dann nie.** Und ein Test, der nie
|
||||
läuft, ist Dekoration – genau wie `vitest` es war, bevor es auffiel. Deshalb:
|
||||
|
||||
./scripts/db-tests.sh # alle
|
||||
./scripts/db-tests.sh ./internal/documents/
|
||||
|
||||
Das Skript baut eine Wegwerf-Datenbank, spielt alle Migrationen ein, lässt die
|
||||
Tests laufen und räumt sie wieder weg. Die Entwicklungsdatenbank bleibt
|
||||
unberührt.
|
||||
|
||||
### Texterkennung: die drei stillen Zustände
|
||||
|
||||
`internal/documents` ist das Paket, dessen Versagen am teuersten ist, weil es
|
||||
**still** ist: Die Volltextsuche findet dann einfach nichts, und das sieht
|
||||
genauso aus wie „steht nicht drin". Geprüft werden deshalb nicht die Abfragen,
|
||||
sondern die drei Zustände, in denen die Schlange hängenbleiben kann, ohne dass
|
||||
es auffällt:
|
||||
|
||||
1. **Ein Auftrag, der nie aufgibt.** Ein kaputtes oder passwortgeschütztes PDF
|
||||
besetzte sonst für immer den Arbeiter, und die übrigen Dateien kämen nie
|
||||
dran – während nach außen „die Erkennung läuft".
|
||||
2. **Ein Auftrag, der nach einem harten Abbruch auf `running` stehen bleibt.**
|
||||
Nähme `Claim` nur `pending`, wäre diese Datei für immer verloren, und die
|
||||
Zahl der offenen Aufträge sähe dabei gesund aus.
|
||||
3. **Ein Text nach alter Regel, der nie erneuert wird.** Auffindbar, aber im
|
||||
Betrachter nicht auf der richtigen Seite – und nichts zeigt, dass diese
|
||||
Dateien anders sind.
|
||||
|
||||
Dazu: gelöschte Dateien werden nicht mehr genommen, ein erneuter Upload gibt
|
||||
eine neue Chance, und `Complete` schreibt Text und Auftragsende zusammen.
|
||||
|
||||
### Etiketten und Absender: bleibt Fremdes fremd?
|
||||
|
||||
./scripts/db-tests.sh ./internal/tags/
|
||||
|
||||
Die Rechteprüfung steckt hier nicht in einer `if`-Zeile, sondern in
|
||||
WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
läuft, sie liefert nur zu viel.
|
||||
|
||||
Fünf Tests auf dieselbe Frage – Liste, Umbenennen, Löschen, Anhängen an die
|
||||
eigene Datei, und vor allem das **Auflisten der Dateien** unter einem Etikett.
|
||||
Der letzte Weg gibt Dateinamen, Ordner und Größen heraus.
|
||||
|
||||
Dazu die Gruppen: Ein Mitglied sieht das Gruppen-Etikett, ein Nichtmitglied
|
||||
nicht, und wer die Gruppe verlässt, verliert den Zugriff im selben Moment.
|
||||
|
||||
**Eine Absicherung, die wie eine Förmlichkeit aussieht:** Ein Gruppen-Etikett
|
||||
darf nicht an eine persönliche Datei. Der Grund steht nicht im Code, wo die
|
||||
Prüfung sitzt, sondern zwei Dateien weiter: `FilesWithTag` prüft, ob der
|
||||
Anfragende das *Etikett* sehen darf – **nicht**, ob er die *Datei* sehen darf.
|
||||
Harmlos ist das nur, weil ein Etikett stets denselben Prinzipal hat wie die
|
||||
Dateien darunter. Fiele die Prüfung weg, hinge an einem Gruppen-Etikett eine
|
||||
private Datei, und jedes Gruppenmitglied bekäme ihren Namen zu sehen.
|
||||
|
||||
### Was der Mutationsversuch gezeigt hat
|
||||
|
||||
Die Tests wurden gegen **absichtlich kaputten Code** gegengeprüft – sonst weiß
|
||||
man nicht, ob sie überhaupt etwas fangen.
|
||||
|
||||
* `'running'` aus `Claim` entfernt → zwei Tests wurden rot. Gut.
|
||||
* `maxAttempts` von 3 auf 9999 gesetzt → **alle blieben grün.** Sie schleifen
|
||||
über die Konstante, prüfen die Zahl also nicht, sondern setzen sie voraus.
|
||||
Auffällig war nur die Laufzeit: 27 statt 0,2 Sekunden.
|
||||
|
||||
Genau so verschwindet eine Entscheidung unbemerkt aus einer Anwendung. Dagegen
|
||||
steht jetzt ein Test, der die Zahl selbst festhält und sagt, wo die Begründung
|
||||
hingehört, falls sie sich ändert.
|
||||
|
||||
Bei den Etiketten dasselbe Vorgehen, mit klarerem Ausgang: Eigentümerprüfung
|
||||
aus `FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien:
|
||||
Gehaltsabrechnung.pdf"*. Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests
|
||||
rot. Beide Absicherungen sind also wirklich abgedeckt und nicht bloß
|
||||
mitgelaufen.
|
||||
|
||||
## Notfall: Migration hängt fest ("Dirty database version")
|
||||
|
||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||
@@ -1179,8 +1356,9 @@ Die Daten liegen seit dem 23.08.2026 in einem gewöhnlichen Verzeichnis
|
||||
## Office – Collabora / OnlyOffice / Euro-Office (24.08.2026)
|
||||
|
||||
Aether ist WOPI-Host; der Office-Server holt die Datei selbst ab und schreibt
|
||||
sie zurück. **Gemessen wurde mit Collabora**; OnlyOffice und Euro-Office
|
||||
sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher).
|
||||
sie zurück. Gemessen wurde mit **Collabora** und – seit dem 16.09.2026 – mit
|
||||
**Euro-Office**, beide auf dieser Anlage. OnlyOffice lief hier nur bis zur
|
||||
Ermittlung; Euro-Office ist sein Fork und spricht dieselbe Schnittstelle.
|
||||
|
||||
### Öffnen
|
||||
- [ ] Rechtsklick auf .odt/.docx/.ods → „Edit in Office…" erscheint 🔎
|
||||
@@ -1218,6 +1396,713 @@ sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher).
|
||||
|
||||
---
|
||||
|
||||
## Office am Telefon: `mobileEdit` statt PDF-Umweg (16.09.2026)
|
||||
|
||||
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, mit einem
|
||||
„Edit"-Knopf hinein. Begründet war das mit OnlyOffices Oberfläche, die auf
|
||||
400 Pixeln nichts vom Dokument übrig lässt.
|
||||
|
||||
**Die Begründung war falsch – wir hatten die falsche Oberfläche bestellt.**
|
||||
Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und für
|
||||
`.docx` stehen dort acht. Nachgesehen im laufenden Euro-Office:
|
||||
|
||||
edit …/hosting/wopi/word/edit
|
||||
mobileEdit …/hosting/wopi/word/edit?mobile=1
|
||||
|
||||
`EditURL` kannte nur `edit` und `view`. Die Handy-Fassung lag die ganze Zeit
|
||||
in der Tabelle und wurde nie abgerufen.
|
||||
|
||||
- [x] Am Telefon öffnet ein `.docx` in der Handy-Oberfläche 🔎
|
||||
- [x] Am Schreibtisch unverändert die Schreibtisch-Fassung 🔎
|
||||
- [x] Schmales Fenster am Schreibtisch → weiterhin Schreibtisch-Fassung
|
||||
(entschieden wird am Zeigegerät, nicht an der Breite)
|
||||
- [x] Collabora kennt die Aktionen nicht → fällt auf `edit` zurück
|
||||
- [ ] Nur-Lese-Freigabe am Telefon → `mobileView`
|
||||
|
||||
**Vorher geprüft, ob es überhaupt etwas ändert.** Beide Aktionen zeigen auf
|
||||
denselben Pfad; erst der Vergleich der Adressen zeigte das `?mobile=1`. Ohne
|
||||
diesen Blick wäre der Umbau eine Umbenennung gewesen.
|
||||
|
||||
### Die PDF-Ansicht ist entfallen
|
||||
|
||||
Mit dem Anlass fiel die Notlösung: `internal/office/preview.go` (229 Zeilen),
|
||||
`convert.go` (271) samt Test, `files.preview_version` (Migration 000026), die
|
||||
Sonderabfrage im GC, die Route und dreizehn Fundstellen im Frontend.
|
||||
|
||||
`convert.go` hatte **keinen anderen Aufrufer** – die gesamte Umwandlung nach
|
||||
PDF existierte allein für diese Ansicht. Wer ein PDF braucht, exportiert es
|
||||
im Editor.
|
||||
|
||||
- [x] Alle 234 Prüfungen der sieben Selbsttest-Suiten weiterhin grün
|
||||
- [ ] `previews/` ist nach einem GC-Lauf leer (24 h Schonfrist beachten)
|
||||
- [ ] Admin → abgeleitete Daten zeigt danach 0 für `previews`
|
||||
|
||||
### Der Zurück-Fehler dahinter
|
||||
|
||||
Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu
|
||||
öffnen. Ursache war kein Office, sondern der Verlauf: Beim Wechsel räumt React
|
||||
erst die alte Ansicht ab, dann baut es die neue auf. Der Aufräumer von
|
||||
`useBackClose` ruft `history.back()` – das wirkt aber **verzögert** und
|
||||
verbrauchte deshalb den Eintrag, den der Editor gerade angelegt hatte.
|
||||
|
||||
Behoben mit einem Zähler für selbst ausgelöste Schritte: So viele `popstate`
|
||||
werden verschluckt. Die Geste des Nutzers erkennt man daran, dass keiner
|
||||
offen ist.
|
||||
|
||||
- [x] Aus der Vorschau in den Editor wechseln → der Editor bleibt offen 🔎
|
||||
- [ ] Zurück-Geste im Editor → schließt den Editor, nicht die App
|
||||
- [ ] Zwei Ebenen (Vorschau unter Editor) → jede Geste schließt genau eine
|
||||
- [ ] Ungespeicherte Änderungen: „Abbrechen" lässt die Ansicht offen
|
||||
|
||||
### `.txt` ging gar nicht auf
|
||||
|
||||
`txt` stand in `officeExtensions`. Eine 12-Byte-Notiz lief damit in den
|
||||
Office-Zweig statt in den eingebauten Text-Editor – und am Telefon in die
|
||||
PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin;
|
||||
das war die Asymmetrie, über die man stolperte.
|
||||
|
||||
- [x] `.txt` antippen → eingebauter Text-Editor, Bearbeiten und Speichern 🔎
|
||||
- [x] `.md` unverändert
|
||||
- [ ] `.csv` weiterhin im Tabellenprogramm (bleibt bewusst Office)
|
||||
- [ ] „Edit in Office…" erscheint für `.txt` nicht mehr im Kontextmenü
|
||||
|
||||
---
|
||||
|
||||
## Meldungen mit Rückgängig (18.09.2026)
|
||||
|
||||
Vorher bestätigte **nichts** irgendetwas, und Fehler verschwanden still: Die
|
||||
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
|
||||
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
|
||||
klappte – nichts.
|
||||
|
||||
- [ ] Datei löschen → Meldung mit „Undo", Datei ist zurück 🔎
|
||||
- [ ] Mehrere löschen → eine Meldung, „Undo" holt alle zurück
|
||||
- [ ] Umbenennen → „Undo" stellt den alten Namen her
|
||||
- [ ] Verschieben → „Undo" bringt die Datei an den alten Ort
|
||||
- [ ] Ordner löschen → **kein** „Undo", stattdessen der Hinweis auf den
|
||||
Papierkorb (der Ordner selbst ist wirklich weg)
|
||||
- [ ] Ohne Schreibrecht löschen → rote Meldung mit dem Satz des Servers
|
||||
- [ ] Netz trennen, dann löschen → „No connection to the server."
|
||||
- [ ] Meldung verschwindet nach 4 s, mit Knopf erst nach 10
|
||||
- [ ] Fünfte Meldung verdrängt die erste (höchstens vier stehen)
|
||||
- [ ] Betriebssystem auf „Bewegung reduzieren" → keine Einblendung
|
||||
|
||||
### Wo es weiterhin still bleibt – und warum
|
||||
|
||||
Nicht jeder verschluckte Fehler ist einer. Diese bleiben absichtlich stumm,
|
||||
damit niemand sie später "nachrüstet":
|
||||
|
||||
| Stelle | Grund |
|
||||
|---|---|
|
||||
| `SearchBox` | Bricht bei jedem Tastendruck die vorige Suche ab. Eine Meldung je Anschlag wäre nur im Weg. |
|
||||
| `FileInfoDialog` (Prüfsumme kopieren) | Ohne HTTPS gibt es keine Zwischenablage – der Wert steht vollständig da und lässt sich markieren. |
|
||||
| `useAppSettings` | Erreichbarkeit des Servers ist nicht Sache des Themings; die Vorgaben bleiben stehen. |
|
||||
| `TagDialog` (Absender-Vorschläge) | Ohne Vorschläge kann man trotzdem tippen. |
|
||||
| `ProfilePage` (OCR-Stand) | Hintergrundabfrage im Sekundentakt. |
|
||||
| `useAuth` | Kein Konto = abgemeldet, das ist die Antwort und kein Fehler. |
|
||||
|
||||
Wo ein Dialog seinen Fehler **bei sich** anzeigt (ShareWithDialog, ThemingTab
|
||||
beim Hochladen, TagDialog, CalendarPage, OfficeEditor, FilePreview), bleibt es
|
||||
dabei: Dort gehört die Meldung hin, nicht an den unteren Bildschirmrand.
|
||||
|
||||
### Zwei Fehler, die schlimmer waren als Schweigen
|
||||
|
||||
**ProfilePage meldete "gespeichert", wenn das Speichern fehlgeschlagen war.**
|
||||
`updateProfile(...).catch(() => {})`, danach ungeprüft das Häkchen. Das ist die
|
||||
übelste Sorte: Sie behauptet das Gegenteil dessen, was geschehen ist.
|
||||
|
||||
**TagEditDialog riet die Ursache.** Jeder Fehler meldete "That name is already
|
||||
taken" – auch ein Netzausfall, auch ein 500er. Jetzt steht dort der Satz des
|
||||
Servers. Im TagDialog war genau das schon einmal aufgefallen und behoben
|
||||
worden; hier stand es noch.
|
||||
|
||||
- [ ] Netz trennen, Profilnamen speichern -> rote Meldung, **kein** Häkchen
|
||||
- [ ] Netz trennen, Etikett umbenennen -> "No connection to the server.",
|
||||
nicht "That name is already taken"
|
||||
- [ ] Papierkorb: Wiederherstellen und Leeren melden sich
|
||||
- [ ] Freigabe-Link kopieren ohne HTTPS -> Hinweis auf HTTPS statt gar nichts
|
||||
- [ ] Freigabe-Link löschen -> Meldung, **kein** Rückgängig (ein neuer Link
|
||||
bekäme ein neues Token; der alte bliebe tot)
|
||||
|
||||
## Der Zurück-Fehler und was der Test **nicht** kann (16.–18.09.2026)
|
||||
|
||||
Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu
|
||||
öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; deren Aufräumer rief
|
||||
`history.back()`, das verzögert eintraf und den Eintrag verbrauchte, den der
|
||||
Editor gerade angelegt hatte.
|
||||
|
||||
**Die erste Behebung war selbst fehlerhaft** und ist erst durch die neuen
|
||||
jsdom-Tests aufgefallen: Sie zählte selbst ausgelöste Sprünge mit. Das setzt
|
||||
voraus, dass jedes `back()` auch ein `popstate` nach sich zieht – und das gilt
|
||||
nicht. Folgt im selben Durchlauf ein `pushState`, verwirft der Browser den
|
||||
Sprung. Der Zähler blieb dann für immer stehen, und die **nächste** echte
|
||||
Zurück-Geste wurde verschluckt: Statt die Ansicht zu schließen, hätte man die
|
||||
Anwendung verlassen.
|
||||
|
||||
Jetzt wird erst im nächsten Durchlauf entschieden **und** geprüft, ob der
|
||||
eigene Eintrag überhaupt noch obenauf liegt. Nur dann kommt es zum Sprung – und
|
||||
dann folgt das `popstate` garantiert.
|
||||
|
||||
- [x] Vier jsdom-Tests in `useBackClose.test.tsx`, unter Mutation nachgewiesen:
|
||||
Der Schalter bleibt stehen → der Übergangstest wird rot.
|
||||
|
||||
> **Was diese Tests nicht leisten.** Den ursprünglichen Fehler bilden sie
|
||||
> **nicht** ab. jsdom verwirft einen noch nicht ausgeführten Sprung, sobald
|
||||
> `pushState` folgt – das verhängnisvolle `popstate` entsteht dort also nie.
|
||||
> Nachgeprüft, indem die alte, kaputte Fassung wieder eingesetzt wurde: **alle
|
||||
> Tests blieben grün.** Chrome auf dem Telefon verhält sich anders, und dort
|
||||
> war der Fehler zu sehen.
|
||||
>
|
||||
> Wer diesen Fall absichern will, braucht einen echten Browser (Playwright).
|
||||
> Bis dahin ist der Beleg ein Mensch mit einem Telefon.
|
||||
|
||||
Seit dem 18.09.2026 ist **jsdom** in den devDependencies. Die Vitest-Umgebung
|
||||
bleibt `node` (schnell); wer ein DOM braucht, schreibt
|
||||
`// @vitest-environment jsdom` an den Anfang seiner Datei.
|
||||
|
||||
---
|
||||
|
||||
## Zeichnen und Mindmaps (27.09.2026)
|
||||
|
||||
Auf Excalidraw (MIT). **Eine Zeichnung ist eine Datei, kein neues Ding** – das
|
||||
ist die ganze Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie
|
||||
alles, was Aether fuer Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
|
||||
Versionen, Freigabelinks, interne Freigaben, Suche. Keine neue Tabelle, kein
|
||||
zweites Rechtemodell.
|
||||
|
||||
Gespeichert wird ueber denselben Upload wie alles andere – gleicher Name,
|
||||
gleicher Ordner, wie beim Text-Editor in der Vorschau.
|
||||
|
||||
### Drei Sorgen, alle nachgemessen
|
||||
|
||||
**Das Hauptbuendel darf nicht wachsen.** Wer nie zeichnet, soll den Brocken nie
|
||||
holen. Nachgeladen wie der PDF-Betrachter:
|
||||
|
||||
| | vorher | nachher |
|
||||
|---|---:|---:|
|
||||
| Hauptbuendel `index-*.js` | 484,11 kB | **485,22 kB** |
|
||||
| Zeichenbrett (eigenes Stueck) | – | 584 kB + 142 kB CSS |
|
||||
|
||||
**Schriften.** Die Sicherheitsrichtlinie ist `default-src 'self'` ohne eigenes
|
||||
`font-src` – Excalidraw haette seine handgezeichneten Schriften von aussen
|
||||
geladen und still eine Ersatzschrift gezeigt. Sie werden jetzt beim Bauen ins
|
||||
`public/`-Verzeichnis kopiert (`vite.config.ts`) und ueber
|
||||
`window.EXCALIDRAW_ASSET_PATH` gefunden.
|
||||
|
||||
> Der Pfad wird **im Modul** gesetzt, nicht per Inline-Skript in `index.html`:
|
||||
> Das waere von derselben Richtlinie blockiert (`script-src 'self'`).
|
||||
|
||||
> **Xiaolai bleibt draussen.** Die chinesische Schrift ist 13 der 14 MB; alles
|
||||
> andere zusammen 512 KB. Wer Chinesisch in eine Zeichnung tippt, bekommt eine
|
||||
> Ersatzschrift – der Preis dafuer, das Abbild nicht um 13 MB aufzublaehen.
|
||||
|
||||
**Reihenfolge der Dateiarten.** `.excalidraw` wird **vor** `code` und `text`
|
||||
geprueft: `json` steht in `codeExt`, und stuende die Pruefung spaeter, oeffnete
|
||||
eine Zeichnung im Texteditor als Zahlenkolonne.
|
||||
|
||||
### Kein gleichzeitiges Arbeiten – und was das kostet
|
||||
|
||||
Die Komponente koennte es (`isCollaborating`), aber den Transport bringt sie
|
||||
nicht mit: Excalidraw betreibt dafuer einen eigenen WebSocket-Dienst, Nextcloud
|
||||
hat fuer seine Whiteboard-App ein eigenes Node-Backend gebaut. Das waere ein
|
||||
zweiter Container neben Aether, so wie Euro-Office einer ist – eine eigene
|
||||
Entscheidung, kein Nebenprodukt.
|
||||
|
||||
Solange es den nicht gibt, gilt: **Zwei Leute speichern nacheinander, der
|
||||
Zweite ueberschreibt den Ersten.** Zwei Dinge fangen das ab:
|
||||
|
||||
1. Vor dem Speichern wird geprueft, ob sich die Fassung geaendert hat. Wenn ja,
|
||||
wird **gefragt** statt ueberschrieben.
|
||||
2. Zeichnungen behalten ihre Fassungen **von der ersten Minute an**.
|
||||
|
||||
Punkt 2 ist erst durch den Selbsttest aufgefallen: Versionierung ist sonst
|
||||
bewusst abgeschaltet (Migration 000006), damit der Speicher nicht volllaeuft.
|
||||
Fuer Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder
|
||||
bearbeitet und sind ein paar Kilobyte gross. **Ohne sie waere die Warnung im
|
||||
Editor ein Versprechen ohne Deckung** gewesen.
|
||||
|
||||
### Selbsttest
|
||||
|
||||
`scripts/zeichnung-selbsttest.py`, 12 Pruefungen. Er prueft genau die These
|
||||
nach, auf der die Umsetzung beruht: Wenn eine Zeichnung wirklich eine Datei
|
||||
ist, muss sie koennen, was Dateien koennen.
|
||||
|
||||
- [x] Anlegen, Pruefsumme, Inhalt kommt unveraendert zurueck
|
||||
- [x] Aendern erzeugt eine Fassung in der Historie
|
||||
- [x] Jede Speicherung hebt die Fassungsnummer – darauf beruht der Abgleich
|
||||
- [x] Freigabelink darauf, oeffentlich abrufbar
|
||||
- [x] Zeichnung im Gruppenordner
|
||||
- [x] Excalifont wird vom eigenen Server ausgeliefert
|
||||
|
||||
### Vorschaubild und Politur (29.09.2026)
|
||||
|
||||
**Vorschaubilder entstehen sonst serverseitig** aus den hochgeladenen Bytes –
|
||||
bei einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer
|
||||
sie gerade bearbeitet, hat aber den Browser, der sie ohnehin zeichnet.
|
||||
|
||||
Neu: `POST /api/v1/files/{id}/thumbnail`. Nach dem Speichern schickt der Editor
|
||||
ein PNG hinterher.
|
||||
|
||||
> **Den Bytes wird nicht geglaubt.** Sie werden nicht abgelegt, sondern durch
|
||||
> denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
|
||||
> verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
|
||||
> uebliche Groesse; nichts anderes kann im `thumbs`-Bereich landen. Dazu
|
||||
> Schreibrecht auf die Datei: Wer sie nicht aendern darf, bestimmt auch nicht,
|
||||
> wie sie in der Liste aussieht.
|
||||
|
||||
Das Hochladen laeuft **nach** dem Speichern und ohne darauf zu warten. Ein
|
||||
fehlgeschlagenes Vorschaubild ist ein Schoenheitsfehler, kein Grund, das
|
||||
Speichern scheitern zu lassen – und bleibt deshalb auch stumm.
|
||||
|
||||
### Was am Zeichenbrett hueebscher wurde
|
||||
|
||||
* **Dunkles Thema.** Ein weisses Zeichenbrett in einer dunklen Oberflaeche war
|
||||
der auffaelligste Bruch. Umschalten geht weiterhin im Menue – fuer einen
|
||||
Ausdruck ist Hell die bessere Wahl.
|
||||
* **„Unsaved" als Marke** statt eines Punktes hinter dem Namen. Das Uebersehene
|
||||
war hier die ungespeicherte Arbeit.
|
||||
* **Strg+S / Cmd+S** speichert.
|
||||
* Dateisymbol und Name ohne die Endung in der Kopfzeile.
|
||||
* **„Open" und „Save to…" sind aus.** Beide gehen am Browser vorbei: „Open"
|
||||
ersetzte den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…"
|
||||
schriebe eine zweite Fassung in den Download-Ordner, die niemand
|
||||
wiederfindet. In Aether liegt die Datei da, wo sie liegt.
|
||||
|
||||
- [x] Vorschaubild setzen (204), Liste weiss davon, laesst sich abrufen
|
||||
- [x] Was kein Bild ist -> 400. Fremder Typ -> 415.
|
||||
- [ ] Zeichnung speichern -> nach kurzer Zeit zeigt die Kachel das Bild 🔎
|
||||
- [ ] Zeichnung aendern und neu speichern -> das Bild zieht nach
|
||||
- [ ] Strg+S speichert, „Unsaved" verschwindet
|
||||
|
||||
### Warum Excalidraw und nicht tldraw
|
||||
|
||||
tldraw sieht besser aus und ist angenehmer zu bedienen – aber es ist **nicht
|
||||
quelloffen**: proprietaere Lizenz, Wasserzeichen, und Produktivbetrieb nur mit
|
||||
bezahlter Lizenz. Fuer eine selbst betriebene Cloud, die laufen soll, ohne dass
|
||||
jemand jaehrlich etwas verlaengert, faellt es damit aus.
|
||||
|
||||
Excalidraw ist MIT. draw.io waere die Alternative (Apache-2.0), zielt aber auf
|
||||
Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich
|
||||
schwerere Oberflaeche mit.
|
||||
|
||||
### Feinschliff: zwei stille Wege in den Datenverlust (30.09.2026)
|
||||
|
||||
Beim Durchlesen mit fremdem Blick gefunden, beide nachgestellt.
|
||||
|
||||
**Ein Fehler beim Laden ergab ein leeres Zeichenbrett.** `fetch` wirft bei 404
|
||||
oder 403 **nicht**. Der Rumpf `{"error":"file not found"}` ist gueltiges JSON,
|
||||
`JSON.parse` gelang, `elements` war `undefined` – und die Zeichnung ging leer
|
||||
auf. Sie *sah aus*, als waere sie verloren. Wer dann speichert, macht es wahr.
|
||||
|
||||
Nachgestellt gegen die laufende Instanz:
|
||||
|
||||
GET /api/v1/files/<erfunden>/download?inline=1
|
||||
Status: 404
|
||||
Rumpf : {"error":"file not found"}
|
||||
JSON.parse gelingt -> ['error'] d.elements -> None
|
||||
|
||||
**Weiterzeichnen waehrend des Speicherns gingen verloren.** Der Upload lief mit
|
||||
dem Stand von Sekunde 0; die Fassungsnummer wurde hinterher aus
|
||||
`aktuell.current` gelesen – und das war inzwischen ein anderer Stand. Die neuen
|
||||
Striche galten als gespeichert, ohne je hochgegangen zu sein, und verschwanden
|
||||
beim Schliessen ohne Rueckfrage.
|
||||
|
||||
Jetzt wird der Stand **einmal festgehalten**: Er geht hoch, seine Nummer wird
|
||||
vermerkt, und das Vorschaubild zeigt denselben. Ist inzwischen weitergezeichnet
|
||||
worden, steht „Unsaved" sofort wieder da – richtig so.
|
||||
|
||||
Das Laden steht dafuer jetzt in `ladeZeichnung()` statt im Effekt: Der Weg hat
|
||||
drei Fallen, und zwei enden in verlorener Arbeit – das gehoert pruefbar.
|
||||
|
||||
- [x] Vier Tests fuer das Laden, unter Mutation nachgewiesen: Ohne die
|
||||
Statuspruefung werden zwei davon rot.
|
||||
|
||||
### Was bewusst so bleibt
|
||||
|
||||
**Leserecht faellt erst beim Speichern auf.** `FileItem` traegt kein
|
||||
Rechtefeld; die Oberflaeche kann vorher nicht unterscheiden. Wer eine geliehene
|
||||
Zeichnung nur lesen darf, kann darin zeichnen und erfaehrt es beim Speichern –
|
||||
mit dem Satz des Servers. Das Zeichenbrett bleibt dabei offen, die Arbeit ist
|
||||
also nicht weg. Genauso verhaelt sich der Text-Editor seit jeher; es einzeln zu
|
||||
loesen hiesse, einen Rechte-Endpunkt nur fuer diesen Fall zu bauen.
|
||||
|
||||
### Drei Nachbesserungen aus dem Testen (29.09.2026)
|
||||
|
||||
**„Close without saving?" kam, obwohl gerade gespeichert war.** Der Schalter
|
||||
„geaendert" wurde in `onChange` gesetzt – und das feuert bei jeder Kleinigkeit,
|
||||
auch bei Mauszeiger, Auswahl und Bildlauf. Sekunden nach dem Speichern stand er
|
||||
wieder auf „offen".
|
||||
|
||||
Jetzt zaehlt `getSceneVersion` den **Inhalt** statt der Bedienung. Stimmt die
|
||||
Zahl mit der zuletzt gespeicherten ueberein, gibt es nichts zu sichern.
|
||||
|
||||
**Das Vorschaubild kam nicht in der Liste an.** Der Editor laedt es *nach* dem
|
||||
Speichern hoch – die Liste kannte aber nur die Antwort des Uploads, und die
|
||||
entstand, bevor es das Bild gab. Ohne Neuladen blieb die Kachel leer. Sie wird
|
||||
jetzt ein zweites Mal benachrichtigt.
|
||||
|
||||
**Rueckfragen als eigener Dialog.** Vorher `window.confirm` – ein
|
||||
Browser-Fenster mitten in einer Oberflaeche, die sonst ihre eigene Sprache
|
||||
spricht. Jetzt derselbe `ConfirmDialog` wie ueberall.
|
||||
|
||||
> Beim Schliessen ueber die Zurueck-Geste gibt der Griff `false` zurueck,
|
||||
> solange der Dialog steht. Sonst waere der Verlaufseintrag schon verbraucht,
|
||||
> und ein „Cancel" liesse die naechste Zurueck-Geste aus der Anwendung fuehren.
|
||||
|
||||
**Kein Escape zum Schliessen** – die Beschriftung versprach es, es gab ihn nie,
|
||||
und er waere falsch: Das Zeichenbrett braucht die Taste selbst (Abwaehlen,
|
||||
Werkzeug verlassen). Wer sie abfaengt, schliesst die Zeichnung, waehrend jemand
|
||||
nur ein Rechteck abwaehlen wollte. Das Versprechen ist raus.
|
||||
|
||||
- [ ] Speichern, dann schliessen -> **keine** Rueckfrage 🔎
|
||||
- [ ] Etwas zeichnen, dann schliessen -> Rueckfrage im Aether-Dialog
|
||||
- [ ] Im Dialog „Cancel" -> Zeichenbrett bleibt, Zurueck-Geste greift weiter
|
||||
- [ ] Speichern -> Kachel zeigt das Bild **ohne** Neuladen 🔎
|
||||
- [ ] Nur etwas auswaehlen oder scrollen -> „Unsaved" erscheint **nicht**
|
||||
|
||||
### Beim zweiten Oeffnen blieb es leer (29.09.2026)
|
||||
|
||||
Eine Zeichnung liess sich anlegen und bearbeiten – **beim zweiten Oeffnen**
|
||||
blieb das Zeichenbrett leer.
|
||||
|
||||
Ursache: Die erste Fassung schrieb den `appState` roh mit `JSON.stringify`
|
||||
heraus. Darin steckt `collaborators`, und das ist eine `Map`. JSON kennt keine
|
||||
Maps; es wurde `{}` daraus. Beim Laden bekam Excalidraw ein leeres Objekt, wo
|
||||
es eine Map erwartet, und brach ab. Beim **ersten** Oeffnen fiel es nicht auf –
|
||||
da war noch nichts gespeichert.
|
||||
|
||||
Behoben mit den Werkzeugen des Pakets: `serializeAsJSON` schreibt nur, was eine
|
||||
Zeichnung ausmacht, `restore` setzt beim Laden zusammen, was fehlt. Selbst zu
|
||||
pruefen hiesse, Excalidraws Feldliste nachzubauen und bei jeder Fassung
|
||||
nachzuziehen.
|
||||
|
||||
**Eine Sache erledigt `restore` allerdings nicht** – nachgemessen, nicht
|
||||
angenommen: Ein bereits verungluecktes `collaborators: {}` reicht es
|
||||
**unveraendert durch**; die Map entsteht nur, wenn das Feld ganz fehlt. Neue
|
||||
Dateien haben es nicht mehr, aber die aus jener Stunde waeren dauerhaft kaputt
|
||||
gewesen. Deshalb wird es beim Laden zusaetzlich entfernt.
|
||||
|
||||
- [x] Fuenf Tests in `drawing.test.ts` (jsdom), darunter einer, der genau
|
||||
festhaelt, dass `restore` allein nicht reicht.
|
||||
|
||||
> **Zwei Stolpersteine beim Testen**, beide in `vite.config.ts` vermerkt:
|
||||
> Excalidraw importiert `roughjs/bin/rough` ohne Endung – Nodes Aufloesung
|
||||
> verlangt sie, der Buendler nicht. Deshalb laeuft es unter
|
||||
> `server.deps.inline`. Und es fasst beim Laden ein Canvas an, das jsdom nicht
|
||||
> hat; ein Platzhalter im Test genuegt.
|
||||
|
||||
### Von Hand zu pruefen
|
||||
|
||||
- [ ] `+ Drawing` legt eine Zeichnung an und oeffnet sie 🔎
|
||||
- [ ] Zeichnung schliessen und **erneut oeffnen** -> der Inhalt ist da 🔎
|
||||
- [ ] Zeichnen, Rechteck, Text darin, Pfeil andocken – Block verschieben, der
|
||||
Pfeil folgt (das ist der Mindmap-Fall)
|
||||
- [ ] Die Schrift sieht handgezeichnet aus, nicht nach Arial
|
||||
- [ ] Speichern -> Punkt neben dem Namen verschwindet, Meldung erscheint
|
||||
- [ ] Schliessen mit ungespeicherten Aenderungen -> Rueckfrage
|
||||
- [ ] Zurueck-Geste im Zeichenbrett -> schliesst es, nicht die App
|
||||
- [ ] Am Telefon: Geht es mit dem Finger?
|
||||
- [ ] In zwei Browsern oeffnen, in beiden etwas zeichnen, nacheinander
|
||||
speichern -> der Zweite bekommt die Rueckfrage
|
||||
|
||||
## Der Abgleich bekommt eine Notbremse (26.09.2026)
|
||||
|
||||
`internal/rescan` ist das einzige Stueck Aether, das **hart** loescht:
|
||||
`DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang ueber
|
||||
die Platte nicht gesehen wurde.
|
||||
|
||||
Der Ernstfall ist nicht ausgedacht ([localfs.go:199](../backend/internal/storage/localfs.go)):
|
||||
|
||||
if _, err := os.Stat(base); os.IsNotExist(err) {
|
||||
return nil // kein Fehler, keine Eintraege
|
||||
}
|
||||
|
||||
Existiert das Datenverzeichnis nicht, meldet `Walk` **keinen Fehler** – es
|
||||
meldet gar nichts. Genau der Fingerabdruck eines Bind-Mounts, der nach einem
|
||||
Neustart nicht hochkam, oder eines Tippfehlers in `DATA_PATH`. Dann gilt jede
|
||||
Datei als verwaist, und **ein** Aufruf mit `?anwenden=true` loescht den
|
||||
gesamten Bestand aus der Datenbank. Die Bytes laegen noch da, aber niemand
|
||||
wuesste mehr, dass es sie gibt – und der GC raeumte sie 24 Stunden spaeter ab.
|
||||
|
||||
Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank
|
||||
ab, nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall:
|
||||
Eine Seite antwortet faelschlich mit "leer". Der GC loescht dann Blobs – die
|
||||
Datenbank weiss hinterher noch, was fehlt. Hier faellt die Zeile selbst. **Die
|
||||
gefaehrlichere Seite war die ungesicherte.**
|
||||
|
||||
### Zwei Bremsen, eine davon nicht uebersteuerbar
|
||||
|
||||
| Bremse | wann | bestaetigbar |
|
||||
|---|---|---|
|
||||
| **Notbremse** | nichts auf der Platte gefunden, Datenbank kennt Dateien | **nein** |
|
||||
| **Mengenbremse** | mehr als die Haelfte wuerde fallen | ja, mit `&bestaetigt=true` |
|
||||
|
||||
Die leere Installation (nichts hier, nichts dort) ist ausgenommen – da gibt es
|
||||
nichts zu verwechseln.
|
||||
|
||||
Dazu umgebaut: erst sammeln, dann entscheiden, dann loeschen. Vorher wurde
|
||||
mitten in der Schleife geloescht; die Gesamtzahl kennt man dort erst, wenn die
|
||||
Haelfte schon weg ist, und eine Bremse kaeme zu spaet.
|
||||
|
||||
- [x] Fuenf Tests in `bremse_test.go`. Unter Mutation nachgewiesen: Wird die
|
||||
Notbremse entfernt, werden `TestNotbremseBeiLeeremSpeicher` **und** der
|
||||
Ernstfalltest rot.
|
||||
- [x] Ernstfall am echten Geraet: fuenf Zeilen in der Datenbank, ein
|
||||
Wurzelverzeichnis ohne `users/`. Nach `Apply` stehen alle fuenf noch –
|
||||
auch mit ausdruecklicher Bestaetigung.
|
||||
- [ ] Im Admin-Bereich: "Rescan storage" -> Check -> Apply bei heilem Bestand
|
||||
arbeitet wie vorher 🔎
|
||||
- [ ] Ein abgebrochener Lauf steht rot in der Karte, mit Grund, und die Karte
|
||||
bleibt im Zustand "geprueft" (der Anwenden-Knopf verschwindet nicht)
|
||||
- [ ] Ein abgebrochener Lauf steht im Audit-Log – gerade der: Er ist der
|
||||
Hinweis darauf, dass mit dem Datenordner etwas nicht stimmt
|
||||
|
||||
### Nebenbefund: der Teilen-Selbsttest leckte App-Passwoerter
|
||||
|
||||
`kalender-teilen-selbsttest.py` legte je Lauf zwei App-Passwoerter an und
|
||||
raeumte nur das des Empfaengers weg (das faellt mit dessen Konto). Das des
|
||||
Besitzers blieb liegen. Nach **achtzehn** Laeufen war
|
||||
`auth.maxAppPasswords = 20` erreicht, und caldav wie kalender-teilen
|
||||
scheiterten mit `409` – ohne dass am Code etwas kaputt gewesen waere.
|
||||
|
||||
Aufgeraeumt und behoben; zweimal hintereinander laufen lassen, danach stehen
|
||||
wieder nur die beiden echten (`CalDAV`, `DAV`).
|
||||
|
||||
## Freigabelinks: Prüfsumme, Gruppenordner, Grenzen (25.09.2026)
|
||||
|
||||
Beim Lesen von `internal/sharing` fiel eine ungleiche Zusage auf: Der
|
||||
**angemeldete** Download prüft seit dem 12.09. die SHA-256-Summe beim
|
||||
Ausliefern und lässt die Verbindung fallen, wenn sie nicht stimmt. Der
|
||||
**öffentliche Freigabelink** lief durch ein blankes `io.Copy` – ungeprüft.
|
||||
|
||||
Ausgerechnet der Weg nach draussen. Wer die Datei angemeldet holt, ist der
|
||||
Eigentümer und merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es
|
||||
nicht. In CLAUDE.md steht das unter "nicht verhandelbar": beim Upload
|
||||
berechnen, **beim Download verifizieren**. Die Regel galt an einer von zwei
|
||||
Stellen.
|
||||
|
||||
Die Prüfmechanik liegt jetzt in `files/verified.go` (`ServeVerified`) und wird
|
||||
von beiden gerufen, statt ein zweites Mal geschrieben zu werden.
|
||||
|
||||
- [x] Bit auf der Platte kippen, Datei ueber den Freigabelink holen -> kommt
|
||||
**nicht** vollstaendig an. Mit 200-KB- und 900-Byte-Datei geprueft, der
|
||||
Selbsttest macht das jetzt fuer beide Wege.
|
||||
|
||||
### Gruppenordner lassen sich verlinken
|
||||
|
||||
`Create` uebergab `user.ID` statt des aufgeloesten Prinzipals. Eine Datei in
|
||||
einem Gruppenordner liess sich damit nicht verlinken – auch nicht von einem
|
||||
Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete "file not
|
||||
found".
|
||||
|
||||
Marcs Entscheidung: Das soll gehen, der Empfaenger sieht ohnehin nur die
|
||||
Datei, nie den Ordner darum.
|
||||
|
||||
**Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst, die
|
||||
Datei oeffentlich zu machen. Wer sie nur geliehen lesen darf, soll sie nicht
|
||||
weiterreichen koennen -> 403.
|
||||
|
||||
- [x] Datei im Gruppenordner verlinken -> 201, Link funktioniert oeffentlich
|
||||
- [x] Nur-Lese-Freigabe -> eigener Freigabelink wird mit 403 abgelehnt
|
||||
|
||||
### Zwei Kleinigkeiten
|
||||
|
||||
**`expires_in_hours` hatte keine Obergrenze.** `time.Duration` rechnet in
|
||||
Nanosekunden; ab etwa 2,6 Millionen Stunden laeuft `int64` ueber, die Dauer
|
||||
wird **negativ**, und der Link ist in dem Moment abgelaufen, in dem er
|
||||
entsteht – ohne Fehlermeldung. Jetzt zehn Jahre Obergrenze mit klarer
|
||||
Ablehnung darueber.
|
||||
|
||||
**`X-Content-Type-Options: nosniff`** fehlte. `attachment` traegt die
|
||||
Hauptlast, aber der angemeldete Weg setzt beides.
|
||||
|
||||
- [x] `expires_in_hours: 9000000000` -> 400 mit Begruendung
|
||||
- [x] `expires_in_hours: 24` -> 201
|
||||
- [x] Oeffentlicher Download setzt nosniff
|
||||
|
||||
### Go-Tests fuer `internal/sharing`
|
||||
|
||||
Sechs Testfunktionen, unter Mutation nachgewiesen:
|
||||
|
||||
| Mutation | wird rot |
|
||||
|---|---|
|
||||
| Eigentuemerpruefung beim Loeschen entfernt | `TestFremderLinkLaesstSichNichtLoeschen` |
|
||||
| Ablaufpruefung entfernt | `TestAbgelaufenerLinkGiltNichtMehr` |
|
||||
| Token auf 6 Byte verkuerzt | `TestTokenIstNichtZuErraten` |
|
||||
|
||||
Dazu: Die Liste zeigt nur eigene Links, der Passwort-Hash verlaesst den
|
||||
Speicher nur beim Tokenabruf (dort wird er gebraucht), der Zaehler zaehlt.
|
||||
|
||||
> **Falle beim Erweitern des Selbsttests:** `GET /api/v1/auth/me` dreht den
|
||||
> CSRF-Token weiter. Wer ihn zwischendurch ruft und den alten weiterbenutzt,
|
||||
> bekommt 403 – genau das ist mir passiert, obwohl es seit dem 12.09. hier
|
||||
> steht.
|
||||
|
||||
## Ein Wächter für die Sprache (24.09.2026)
|
||||
|
||||
Sechs Tage nach der Übersetzung fielen beim Nachsehen **fünf Reste** auf:
|
||||
`label="Ganztägig"`, `aria-label="Kalendermenü"`, `label="Bearbeiten"` und
|
||||
zweimal `"Löschen"`. Alle in **doppelt** gequoteten JSX-Attributen – die
|
||||
Ersetzungsliste hatte nur die einfach gequoteten, und das damalige Prüfskript
|
||||
hielt Einzelwörter ohne Leerzeichen für Bezeichner.
|
||||
|
||||
Deshalb steht die Prüfung jetzt als Test (`src/sprache.test.ts`) statt als
|
||||
Skript, das man einmal laufen lässt. Sie läuft bei jedem `npm test` mit.
|
||||
|
||||
**Der erste Wächter taugte nichts, und das kam nur durch die Mutation heraus:**
|
||||
Er prüfte allein auf Umlaute und liess `"Papierkorb ist leer"` anstandslos
|
||||
durch. Wer einen Wächter baut, muss ihn einmal angreifen.
|
||||
|
||||
Jetzt: Umlaute **plus** eine Liste häufiger deutscher Wörter mit Wortgrenzen
|
||||
(sonst schlägt "ist" in "list" an). Kommentare werden vorher entfernt – und
|
||||
zwar richtig, mit einem kleinen Durchlauf statt zeilenweise: Blockkommentare
|
||||
haben Fortsetzungszeilen ohne führenden Stern, die beim ersten Versuch fünf
|
||||
Fehlalarme erzeugt haben.
|
||||
|
||||
Nachgewiesen an vier Mutationen, alle werden rot:
|
||||
|
||||
| eingeschleuster Text | erkannt an |
|
||||
|---|---|
|
||||
| `Papierkorb ist leer` | "ist", "leer" |
|
||||
| `Loeschen fehlgeschlagen` | "Loeschen", "fehlgeschlagen" |
|
||||
| `Ganztägig` | Umlaut |
|
||||
| `Bitte warten` | "bitte" |
|
||||
|
||||
> **Ein Netz, kein Beweis.** Ein deutscher Satz ohne Umlaut und ohne eines der
|
||||
> gelisteten Wörter käme durch – "Datum bereits vergeben" etwa. Der Test
|
||||
> fängt, was erfahrungsgemäß passiert: ein vergessenes Attribut beim
|
||||
> Übersetzen. Wer die Liste ergänzt, weil einmal etwas durchgerutscht ist, tut
|
||||
> genau das Richtige.
|
||||
|
||||
- [x] Gebautes Bündel enthält keine deutschen Oberflächentexte mehr –
|
||||
gegengeprüft, dass die englischen drin sind (sonst prüfte der Grep nur
|
||||
sich selbst)
|
||||
|
||||
## Wochentage und Monate aus `Intl` (20.09.2026)
|
||||
|
||||
Sechs handgeschriebene Arrays in drei Dateien, und `Intl` kam im ganzen Projekt
|
||||
nicht vor – obwohl jeder Browser es seit Jahren mitbringt.
|
||||
|
||||
Die Doppelpflege war nicht nur lästig, sie war eine Falle: `DayDialog` begann
|
||||
seine Woche am **Sonntag**, die anderen am **Montag**, jeweils passend zu ihrer
|
||||
Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays in eine
|
||||
neue Ansicht kopiert hätte, hätte sich einen Off-by-one gebaut, der nur an
|
||||
sieben Tagen im Jahr auffällt.
|
||||
|
||||
Jetzt eine Ordnung (Montag zuerst, wie `weekdayIndex`) plus eine ausdrücklich
|
||||
benannte zweite Liste `weekdayLongNamesSun` für alles, was direkt mit
|
||||
`getDay()` indiziert.
|
||||
|
||||
**Verhaltensgleich belegt:** Die erzeugten Namen stimmen zeichengenau mit den
|
||||
gelöschten Arrays überein – vor dem Austausch verglichen, nicht danach gehofft.
|
||||
|
||||
**Die Sprache ist fest auf `en` gesetzt, nicht die des Browsers.**
|
||||
`Intl.DateTimeFormat(undefined, …)` nähme die Einstellung des Rechners und
|
||||
setzte auf einem deutschen System „Montag" neben ein englisches „Repeat" –
|
||||
genau der Mischmasch, der am 18.09. ausgeräumt wurde. Kommt eine Sprachwahl,
|
||||
ist `uiLocale` in `dates.ts` die eine Stelle, an der sie andockt.
|
||||
|
||||
- [x] Fünf Tests in `dates.test.ts`, unter Mutation nachgewiesen: Nimmt man
|
||||
dem Sonntag seinen Platz vorn, wird der Test rot.
|
||||
- [ ] Monatsansicht: Kopfzeile Mon–Sun, Termine am richtigen Tag 🔎
|
||||
- [ ] Wochenansicht: Datumszeile stimmt mit dem Wochentag überein
|
||||
- [ ] Tagesdialog: Überschrift nennt den richtigen Wochentag (der Fall mit
|
||||
der Sonntags-Liste) 🔎
|
||||
- [ ] Serientermin „An diesen Tagen": Beschriftung passt zum gesetzten Tag –
|
||||
anlegen, in DAVx5 prüfen, ob derselbe Wochentag ankommt
|
||||
- [ ] Listenansicht: Datum als „September 18, 2026"
|
||||
|
||||
## Platzhalter und Leerzustände (18.09.2026)
|
||||
|
||||
Beim Laden stand ein Kreisel in der Mitte, danach klappte das Layout um. Jetzt
|
||||
steht dort ein Platzhalter **in der Form des Kommenden** – Kacheln in der
|
||||
Kachelansicht, Zeilen in der Liste –, und nichts springt mehr, wenn die Daten
|
||||
ankommen. Die Maße sind aus `FileViews.tsx` abgeschrieben, nicht geschätzt;
|
||||
weichen sie ab, springt es wieder.
|
||||
|
||||
- [ ] Ordner öffnen → Platzhalter in der Form der gewählten Ansicht 🔎
|
||||
- [ ] Beim Umschalten zwischen Kachel und Liste wechselt auch der Platzhalter
|
||||
- [ ] Datenankunft ohne Sprung: Die Kacheln stehen dort, wo die Platzhalter
|
||||
standen
|
||||
- [ ] Papierkorb, „Für mich freigegeben", Etiketten, Kalender: ebenfalls
|
||||
Platzhalter statt „Loading…"
|
||||
- [ ] Betriebssystem auf „Bewegung reduzieren" → kein Pulsieren, nur gedämpfte
|
||||
Flächen
|
||||
|
||||
Leerzustände sagen jetzt, **warum** nichts da ist, und bieten den Knopf an,
|
||||
der an dieser Stelle weiterhilft:
|
||||
|
||||
- [ ] Leerer Ordner → „This folder is empty" mit Knopf „Upload files"
|
||||
- [ ] Wurzel ohne Inhalt → „Nothing here yet"
|
||||
- [ ] Leerer Papierkorb → **kein** Knopf (ein leerer Papierkorb ist ein guter
|
||||
Zustand, kein Mangel)
|
||||
- [ ] Nichts freigegeben → „Nothing shared with you yet"
|
||||
- [ ] Etikett ohne Dateien → Hinweis auf „Organise…"
|
||||
|
||||
### Keine „Geister" beim Wechseln
|
||||
|
||||
Der Platzhalter kommt erst nach **220 ms** (`useSkeleton`). Ist die Liste
|
||||
vorher da, wird er nie sichtbar; beim Ordnerwechsel bleibt solange die
|
||||
vorherige Liste stehen, weil `load()` sie nicht löscht.
|
||||
|
||||
Die Frist gilt **auch beim ersten Laden einer Seite**. Zuerst war das anders –
|
||||
gedacht war: beim ersten Mal gibt es ja nichts, was stehenbleiben könnte. Beim
|
||||
Wechsel zwischen den Menüpunkten entsteht die Seite aber jedes Mal neu, und
|
||||
damit war *jeder* Tab-Wechsel ein „erstes Laden". Genau dort blitzte es.
|
||||
|
||||
- [ ] Schnell durch Ordner klicken -> **kein** Aufblitzen grauer Kästen 🔎
|
||||
- [ ] Zwischen Dateien, Papierkorb, Freigaben, Etiketten, Kalender wechseln ->
|
||||
ebenfalls keines 🔎
|
||||
- [ ] Langsame Verbindung (Drosselung im Browser) -> nach kurzer Leere kommt
|
||||
der Platzhalter
|
||||
- [ ] Im Wartefenster blitzt **kein Leerzustand** auf. Das ist die
|
||||
unangenehmere Sorte Geist: Der Platzhalter sieht nur nach Daten aus,
|
||||
ein Satz wie „Nothing shared with you yet" behauptet etwas.
|
||||
|
||||
### Der Kreisel stand sechsmal im Quelltext
|
||||
|
||||
`@keyframes spin` lag als Inline-`<style>` in sechs Komponenten – und zwei
|
||||
Nutzer (`AuditTab`, `SearchBox`) hatten **gar keine** eigene Kopie. Dass es
|
||||
trotzdem lief, war Zufall: `@keyframes` gilt fürs ganze Dokument, es genügte
|
||||
also, dass irgendeine der sechs gerade im Bild war. Kaputt wäre es an dem Tag
|
||||
gewesen, an dem jemand die letzte Kopie entfernt. Steht jetzt einmal in
|
||||
`index.css`.
|
||||
|
||||
- [ ] Kreisel dreht sich noch: Suchfeld tippen, Admin → Audit laden,
|
||||
Versionen-Dialog öffnen 🔎
|
||||
|
||||
### Der Sprachmix ist weg (18.09.2026)
|
||||
|
||||
Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch.
|
||||
Jetzt spricht alles Englisch – 80 Stellen in sechs Dateien. Shell, TagChip und
|
||||
UserMenu standen nur wegen ihrer **Kommentare** auf der Verdachtsliste; deren
|
||||
Oberfläche war längst englisch.
|
||||
|
||||
Kommentare bleiben deutsch. Das Ersetzen hat Kommentarzeilen deshalb
|
||||
übersprungen.
|
||||
|
||||
- [ ] Monatsansicht: Wochentage Mon–Sun, Monatsnamen englisch 🔎
|
||||
- [ ] Umschalter Month / Week / List, Knopf „Today"
|
||||
- [ ] Termin anlegen: Title, Calendar, All day, Start/End, Repeat, Color,
|
||||
Location, Description
|
||||
- [ ] Wiederholung: Does not repeat / Daily / Weekly / Monthly / Yearly,
|
||||
Ends: Never / After / On a date
|
||||
- [ ] Serientermin löschen: „Only this event" / „Entire series" / „Cancel"
|
||||
- [ ] Kalendermenü: Edit / Share… / Export (.ics) / Import (.ics) / Delete
|
||||
- [ ] Leerer Zeitraum → „No events"
|
||||
|
||||
### Der Standardkalender heißt „Personal"
|
||||
|
||||
Automatisch angelegt wurde er als „Persönlich". Migration 000027 zieht die
|
||||
bestehenden nach – **bewusst eng gefasst**: nur Zeilen mit Name *und* Farbe des
|
||||
Automaten (`#4285f4`). Wer sich selbst einen Kalender „Persönlich" angelegt
|
||||
hat, behält seinen Namen; das ist seine Beschriftung, nicht unsere.
|
||||
|
||||
- [x] Nach der Migration meldet die API `'Personal'` – geprüft, nicht nur
|
||||
„Migration gelaufen".
|
||||
|
||||
---
|
||||
|
||||
## Absender / Korrespondent (23.08.2026)
|
||||
|
||||
Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer**
|
||||
|
||||
+48
-11
@@ -32,11 +32,13 @@ import (
|
||||
"aether/internal/gc"
|
||||
"aether/internal/office"
|
||||
appoidc "aether/internal/oidc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/sharing"
|
||||
"aether/internal/storage"
|
||||
"aether/internal/tags"
|
||||
"aether/internal/thumbnails"
|
||||
appwebdav "aether/internal/webdav"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/config"
|
||||
appdb "aether/pkg/db"
|
||||
"aether/pkg/middleware"
|
||||
@@ -125,6 +127,20 @@ func main() {
|
||||
}
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
// **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.**
|
||||
//
|
||||
// zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext
|
||||
// keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher
|
||||
// Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein
|
||||
// Blob verwaist ist, und die Meldung über eine beschädigte Datei beim
|
||||
// Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos:
|
||||
// Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem
|
||||
// man den Fehler herbeiführt, den er melden soll.
|
||||
//
|
||||
// Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue
|
||||
// Logeintrag blieb aus, obwohl der Abbruch funktionierte.
|
||||
zerolog.DefaultContextLogger = &log.Logger
|
||||
|
||||
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -175,13 +191,16 @@ func main() {
|
||||
Timeout: cfg.OCRTimeout,
|
||||
TmpDir: cfg.TmpPath,
|
||||
}, log.Logger)
|
||||
go worker.Run(ctx)
|
||||
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
|
||||
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
|
||||
// Aufräumen der Zwischendateien beim Start.
|
||||
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
|
||||
|
||||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||
// Anfang der gesamte Bestand.
|
||||
go func() {
|
||||
bg.Go("Bestand nachtragen", func() {
|
||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||
@@ -204,7 +223,7 @@ func main() {
|
||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,12 +236,15 @@ func main() {
|
||||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||||
|
||||
shareStore := sharing.NewStore(pool)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
|
||||
|
||||
collector := gc.New(pool, blobStore)
|
||||
|
||||
adminStore := admin.NewStore(pool)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
|
||||
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
|
||||
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
|
||||
rescanner := rescan.New(pool, fileStore, blobStore)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
|
||||
|
||||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||
@@ -245,7 +267,7 @@ func main() {
|
||||
var officeHandler *office.Handler
|
||||
if cfg.OfficeEnabled() {
|
||||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||||
if err != nil {
|
||||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||||
@@ -276,7 +298,7 @@ func main() {
|
||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
||||
|
||||
// CalDAV
|
||||
calStore := calendar.NewStore(pool)
|
||||
calStore := calendar.NewStore(pool, aclStore)
|
||||
calHandler := calendar.NewHandler(authStore, calStore)
|
||||
calAPIHandler := calendar.NewAPIHandler(calStore)
|
||||
|
||||
@@ -482,6 +504,7 @@ func main() {
|
||||
r.Get("/search", fileHandler.Search)
|
||||
r.Get("/files/{id}/download", fileHandler.Download)
|
||||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
||||
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
|
||||
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||
@@ -489,7 +512,6 @@ func main() {
|
||||
r.Get("/office/session/{id}", officeHandler.Session)
|
||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||
// Telefon der Normalfall.
|
||||
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||
@@ -546,6 +568,10 @@ func main() {
|
||||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
||||
// Kalender liegen in derselben Rechtetabelle – deshalb dieselben
|
||||
// Endpunkte, und /acl/{id} widerruft alle drei Arten.
|
||||
r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL)
|
||||
r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL)
|
||||
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||||
|
||||
// App passwords (WebDAV/sync clients)
|
||||
@@ -585,6 +611,7 @@ func main() {
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
r.Post("/admin/rescan", adminHandler.Rescan)
|
||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||
// Platz, der in keinem Kontingent auftaucht.
|
||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||
@@ -624,6 +651,10 @@ func main() {
|
||||
done := make(chan struct{})
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
|
||||
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
|
||||
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
|
||||
// Sie berührt außerdem keine fremden Daten.
|
||||
go func() {
|
||||
<-quit
|
||||
log.Info().Msg("shutting down...")
|
||||
@@ -689,8 +720,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
|
||||
// Generate thumbnail in background – non-fatal if it fails.
|
||||
go func() {
|
||||
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||||
//
|
||||
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
|
||||
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
|
||||
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
|
||||
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
|
||||
// gewöhnlichen Upload.
|
||||
bg.Go("Vorschaubild", func() {
|
||||
ctx := context.Background()
|
||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
@@ -706,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS cal_tombstones;
|
||||
DROP INDEX IF EXISTS cal_objects_sync;
|
||||
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
|
||||
--
|
||||
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
|
||||
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
|
||||
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
|
||||
--
|
||||
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
|
||||
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
|
||||
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
|
||||
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
|
||||
-- bei dem es zuletzt angefasst wurde.
|
||||
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
|
||||
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
|
||||
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
|
||||
UPDATE cal_objects o SET sync_seq = c.ctag
|
||||
FROM cal_calendars c WHERE c.id = o.calendar_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
|
||||
|
||||
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
|
||||
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
|
||||
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
|
||||
-- Art, weil er unsichtbar ist.
|
||||
CREATE TABLE IF NOT EXISTS cal_tombstones (
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
sync_seq BIGINT NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
|
||||
|
||||
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
|
||||
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
|
||||
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_acl_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_group_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_user_calendar;
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Kalender an andere Konten freigeben.
|
||||
--
|
||||
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
|
||||
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
|
||||
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
|
||||
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
|
||||
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
|
||||
-- auf, wenn jemand sieht, was er nicht sehen soll.
|
||||
--
|
||||
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
|
||||
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
|
||||
ALTER TABLE acl_entries
|
||||
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
|
||||
|
||||
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
|
||||
|
||||
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
|
||||
-- mehrdeutig: welches Recht gilt dann?
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
|
||||
ON acl_entries (subject_user_id, resource_calendar_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
|
||||
ON acl_entries (subject_group_id, resource_calendar_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
|
||||
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
|
||||
CREATE INDEX IF NOT EXISTS idx_acl_calendar
|
||||
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Persönlich'
|
||||
WHERE name = 'Personal'
|
||||
AND color = '#4285f4';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
|
||||
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
|
||||
-- zieht die bereits angelegten nach.
|
||||
--
|
||||
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
|
||||
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
|
||||
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Personal'
|
||||
WHERE name = 'Persönlich'
|
||||
AND color = '#4285f4';
|
||||
+4
-4
@@ -15,7 +15,7 @@ require (
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
@@ -31,7 +31,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
+8
-8
@@ -111,21 +111,21 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Kalender ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Kalender liegen in derselben Rechtetabelle wie Ordner und Dateien. Nicht aus
|
||||
// Sparsamkeit, sondern weil zwei Tabellen zwei Antworten auf dieselbe Frage
|
||||
// wären – und die Lücke zwischen ihnen fällt erst auf, wenn jemand sieht, was
|
||||
// er nicht sehen soll. Die Auflösung über Gruppen ist deshalb wörtlich
|
||||
// dieselbe Bedingung wie bei Ordnern.
|
||||
|
||||
// ResolveCalendar klärt, was userID mit diesem Kalender darf.
|
||||
//
|
||||
// Der Eigentümer darf schreiben. Alle anderen nur, wenn es eine Freigabe gibt –
|
||||
// direkt oder über eine Gruppe, in der sie sind. Gibt es keine, ist die
|
||||
// Antwort ErrNoAccess, und der Aufrufer macht daraus ein 404: Ein 403 verriete,
|
||||
// dass es den Kalender gibt.
|
||||
func (s *Store) ResolveCalendar(ctx context.Context, userID, calendarID uuid.UUID) (Permission, error) {
|
||||
var ownerID uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load calendar owner: %w", err)
|
||||
}
|
||||
if ownerID == userID {
|
||||
return PermWrite, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT e.permission
|
||||
FROM acl_entries e
|
||||
WHERE e.resource_calendar_id = $1
|
||||
AND ( e.subject_user_id = $2
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2) )`,
|
||||
calendarID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve calendar permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Mehrere Wege zum selben Kalender sind möglich – persönlich *und* über
|
||||
// eine Gruppe. Dann gilt das weitergehende Recht; alles andere wäre für
|
||||
// den Betroffenen nicht nachvollziehbar.
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
if p == PermWrite {
|
||||
best = PermWrite
|
||||
} else if best == "" {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if best == "" {
|
||||
return "", ErrNoAccess
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// SharedCalendar ist ein fremder Kalender, den jemand sehen darf.
|
||||
type SharedCalendar struct {
|
||||
CalendarID uuid.UUID
|
||||
Permission Permission
|
||||
OwnerName string
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||
// man sich über einen Kalender, den einem nie jemand persönlich gab.
|
||||
ViaGroup string
|
||||
}
|
||||
|
||||
// SharedCalendars liefert alle **fremden** Kalender, die userID sehen darf.
|
||||
// Die eigenen stehen nicht darin; die kennt der Kalenderteil selbst.
|
||||
func (s *Store) SharedCalendars(ctx context.Context, userID uuid.UUID) ([]SharedCalendar, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT c.id, e.permission,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username, ''),
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN cal_calendars c ON c.id = e.resource_calendar_id
|
||||
JOIN users o ON o.id = c.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE c.owner_id <> $1
|
||||
AND ( e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1) )
|
||||
ORDER BY o.username, c.created_at`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Zwei Wege zum selben Kalender ergeben zwei Zeilen – angezeigt wird er
|
||||
// einmal, mit dem weitergehenden Recht.
|
||||
byID := map[uuid.UUID]int{}
|
||||
out := []SharedCalendar{}
|
||||
for rows.Next() {
|
||||
var sc SharedCalendar
|
||||
if err := rows.Scan(&sc.CalendarID, &sc.Permission, &sc.OwnerName, &sc.ViaGroup); err != nil {
|
||||
return nil, fmt.Errorf("scan shared calendar: %w", err)
|
||||
}
|
||||
if i, da := byID[sc.CalendarID]; da {
|
||||
if sc.Permission == PermWrite {
|
||||
out[i].Permission = PermWrite
|
||||
}
|
||||
continue
|
||||
}
|
||||
byID[sc.CalendarID] = len(out)
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -89,6 +89,63 @@ func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID,
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders.
|
||||
// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht
|
||||
// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat,
|
||||
// ist etwas anderes als es zu benutzen.
|
||||
func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid calendar id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
owner, err := h.store.CalendarOwner(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if owner != user.ID {
|
||||
// 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort
|
||||
// nicht erfahren, dass es ihn gibt.
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/acl
|
||||
func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForCalendar(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/acl
|
||||
func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "calendar", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) uuid.UUID {
|
||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||
return u.ID
|
||||
|
||||
@@ -41,17 +41,30 @@ type SharedResource struct {
|
||||
|
||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||
|
||||
// resource benennt, worum es bei einer Freigabe geht: genau eines der drei
|
||||
// Felder ist gesetzt. Als Typ statt als drei Zeigerparameter, seit der
|
||||
// Kalender dazukam – drei nebeneinanderstehende `*uuid.UUID` in einer
|
||||
// Signatur sind eine Einladung, sie zu vertauschen.
|
||||
type resource struct{ folder, file, calendar *uuid.UUID }
|
||||
|
||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||
// ein Nutzer oder eine Gruppe ist.
|
||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
return s.grant(ctx, resource{folder: &folderID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
// GrantCalendar gibt einen Kalender frei – an ein Konto oder an eine Gruppe.
|
||||
// Dass Gruppen ohne Zutun mitfunktionieren, ist der Grund, warum Kalender in
|
||||
// *dieser* Tabelle liegen und nicht in einer eigenen.
|
||||
func (s *Store) GrantCalendar(ctx context.Context, calendarID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, resource{calendar: &calendarID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, res resource, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
if !perm.valid() {
|
||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||
}
|
||||
@@ -71,10 +84,11 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO acl_entries
|
||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
(subject_user_id, subject_group_id,
|
||||
resource_folder_id, resource_file_id, resource_calendar_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, permission, created_at`,
|
||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
||||
subjUser, subjGroup, res.folder, res.file, res.calendar, perm, grantedBy,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
|
||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||
@@ -84,7 +98,7 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
// über den Fehlercode.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
||||
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant access: %w", err)
|
||||
@@ -95,16 +109,17 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
func (s *Store) updateExisting(ctx context.Context, res resource, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE acl_entries SET permission = $1
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND resource_calendar_id IS NOT DISTINCT FROM $4
|
||||
AND subject_user_id IS NOT DISTINCT FROM $5
|
||||
AND subject_group_id IS NOT DISTINCT FROM $6
|
||||
RETURNING id, permission, created_at`,
|
||||
perm, folderID, fileID, subjUser, subjGroup,
|
||||
perm, res.folder, res.file, res.calendar, subjUser, subjGroup,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -126,8 +141,9 @@ func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM acl_entries e
|
||||
WHERE e.id = $1
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM cal_calendars c WHERE c.id = e.resource_calendar_id AND c.owner_id = $2) )`,
|
||||
entryID, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke access: %w", err)
|
||||
@@ -147,6 +163,10 @@ func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, err
|
||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) ListForCalendar(ctx context.Context, calendarID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_calendar_id = $1`, calendarID)
|
||||
}
|
||||
|
||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT e.id,
|
||||
@@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string {
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
// CalendarOwner liefert, wem ein Kalender gehört – die Frage vor jeder
|
||||
// Verwaltung seiner Freigaben.
|
||||
func (s *Store) CalendarOwner(ctx context.Context, calendarID uuid.UUID) (uuid.UUID, error) {
|
||||
var owner uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&owner)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("load calendar owner: %w", err)
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/rescan"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
@@ -118,3 +119,59 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
response.JSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank
|
||||
// abgleichen.
|
||||
//
|
||||
// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag
|
||||
// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer
|
||||
// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in
|
||||
// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten.
|
||||
//
|
||||
// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas.
|
||||
// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was
|
||||
// passieren wird, kann es auch nicht verhindern.
|
||||
func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.rescan == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "rescan not available")
|
||||
return
|
||||
}
|
||||
|
||||
anwenden := r.URL.Query().Get("anwenden") == "true"
|
||||
// Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen
|
||||
// soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint
|
||||
// aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen.
|
||||
bestaetigt := r.URL.Query().Get("bestaetigt") == "true"
|
||||
var (
|
||||
bef *rescan.Report
|
||||
err error
|
||||
)
|
||||
if anwenden {
|
||||
bef, err = h.rescan.Apply(r.Context(), bestaetigt)
|
||||
} else {
|
||||
bef, err = h.rescan.Check(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "rescan failed")
|
||||
return
|
||||
}
|
||||
|
||||
if anwenden {
|
||||
// Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein
|
||||
// ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit
|
||||
// dem Datenordner etwas nicht stimmt.
|
||||
if bef.Aborted {
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
"rescan aborted, nothing removed: "+bef.AbortReason)
|
||||
} else {
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
|
||||
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
|
||||
}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, bef)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
@@ -30,10 +31,11 @@ type Handler struct {
|
||||
auditor *audit.Logger
|
||||
gc *gc.Collector
|
||||
files *files.Store
|
||||
rescan *rescan.Scanner
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner}
|
||||
}
|
||||
|
||||
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
)
|
||||
|
||||
// Wer darf was mit einem Kalender.
|
||||
//
|
||||
// **Alle Wege laufen hier zusammen.** CalDAV und die Weboberfläche sind zwei
|
||||
// völlig verschiedene Zugänge zu denselben Daten – ein Recht, das nur einer
|
||||
// von beiden prüft, ist kein Recht. Deshalb gibt es genau eine Antwort auf
|
||||
// „darf dieses Konto diesen Kalender", und beide Zugänge holen sie hier.
|
||||
|
||||
// Access ist ein Kalender samt dem, was der Anfragende damit darf.
|
||||
type Access struct {
|
||||
Calendar
|
||||
Permission acl.Permission `json:"permission"`
|
||||
// Shared: Der Kalender gehört jemand anderem.
|
||||
Shared bool `json:"shared"`
|
||||
// OwnerName steht in der Oberfläche neben dem Namen – ein fremder
|
||||
// Kalender ohne sichtbaren Absender ist ein Rätsel.
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt.
|
||||
ViaGroup string `json:"via_group,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Access) CanWrite() bool { return a.Permission == acl.PermWrite }
|
||||
|
||||
// Ref ist die Adresse in CalDAV.
|
||||
//
|
||||
// Bei einem **fremden** Kalender immer die UUID, nie das Pfadsegment: Das
|
||||
// Segment hat sich der Eigentümer ausgesucht ("…/arbeit/"), und zwei Leute
|
||||
// dürfen denselben Namen wählen. Unter dem Kalenderbestand des Empfängers
|
||||
// stünden dann zwei Kalender an derselben Adresse – und welcher gemeint ist,
|
||||
// entschiede der Zufall.
|
||||
func (a *Access) Ref() string {
|
||||
if a.Shared {
|
||||
return a.Calendar.ID.String()
|
||||
}
|
||||
return a.Calendar.Ref()
|
||||
}
|
||||
|
||||
// ListAccessible liefert alles, was userID sehen darf: die eigenen Kalender
|
||||
// und die ihm freigegebenen. Eigene zuerst – sie sind die, die man sucht.
|
||||
func (s *Store) ListAccessible(ctx context.Context, userID uuid.UUID) ([]Access, error) {
|
||||
own, err := s.ListCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Access, 0, len(own))
|
||||
for i := range own {
|
||||
out = append(out, Access{Calendar: own[i], Permission: acl.PermWrite})
|
||||
}
|
||||
|
||||
if s.rights == nil {
|
||||
return out, nil
|
||||
}
|
||||
geteilt, err := s.rights.SharedCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||
}
|
||||
for _, sc := range geteilt {
|
||||
cal, err := s.getCalendarAny(ctx, sc.CalendarID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue // zwischenzeitlich gelöscht
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, Access{
|
||||
Calendar: *cal,
|
||||
Permission: sc.Permission,
|
||||
Shared: true,
|
||||
OwnerName: sc.OwnerName,
|
||||
ViaGroup: sc.ViaGroup,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveRef findet den Kalender hinter einer CalDAV-Adresse – eigener oder
|
||||
// freigegebener.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: **erst die eigenen.** Sonst könnte ein fremder
|
||||
// Kalender, dessen UUID zufällig wie ein eigenes Pfadsegment aussieht, den
|
||||
// eigenen verdecken.
|
||||
func (s *Store) ResolveRef(ctx context.Context, userID uuid.UUID, ref string) (*Access, error) {
|
||||
cal, err := s.GetCalendarByRef(ctx, userID, ref)
|
||||
if err == nil {
|
||||
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ein freigegebener Kalender steht unter seiner UUID. Alles andere kann
|
||||
// hier nichts mehr treffen.
|
||||
id, parseErr := uuid.Parse(ref)
|
||||
if parseErr != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.RequireCalendar(ctx, userID, id, false)
|
||||
}
|
||||
|
||||
// RequireCalendar ist die Prüfung, die jeder schreibende und lesende Zugriff
|
||||
// durchläuft – aus CalDAV wie aus der Weboberfläche.
|
||||
//
|
||||
// Ohne Zugriff kommt **ErrNotFound**, nicht „verboten": Ein 403 verriete, dass
|
||||
// es den Kalender gibt. Dieselbe Regel wie überall sonst in Aether.
|
||||
func (s *Store) RequireCalendar(ctx context.Context, userID, calendarID uuid.UUID, needWrite bool) (*Access, error) {
|
||||
cal, err := s.getCalendarAny(ctx, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cal.OwnerID == userID {
|
||||
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||
}
|
||||
if s.rights == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
perm, err := s.rights.ResolveCalendar(ctx, userID, calendarID)
|
||||
if errors.Is(err, acl.ErrNoAccess) || errors.Is(err, acl.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if needWrite && perm != acl.PermWrite {
|
||||
// Hier **darf** es ein 403 sein: Dass es den Kalender gibt, weiß der
|
||||
// Anfragende bereits – er darf ihn ja lesen.
|
||||
return nil, ErrReadOnly
|
||||
}
|
||||
|
||||
a := &Access{Calendar: *cal, Permission: perm, Shared: true}
|
||||
// Name des Eigentümers nur nachschlagen, wenn er auch angezeigt wird.
|
||||
if name, err := s.ownerName(ctx, cal.OwnerID); err == nil {
|
||||
a.OwnerName = name
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ErrReadOnly: Der Kalender ist sichtbar, aber nur lesend freigegeben.
|
||||
var ErrReadOnly = errors.New("calendar: read-only share")
|
||||
|
||||
// getCalendarAny lädt einen Kalender **ohne** Rechteprüfung. Privat, und das
|
||||
// muss so bleiben: Jeder Aufruf von außen ginge an der Prüfung vorbei.
|
||||
func (s *Store) getCalendarAny(ctx context.Context, id uuid.UUID) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone,
|
||||
COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE id = $1`, id,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo,
|
||||
&c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) ownerName(ctx context.Context, ownerID uuid.UUID) (string, error) {
|
||||
var name string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = $1`,
|
||||
ownerID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
@@ -31,7 +31,19 @@ func NewAPIHandler(store *Store) *APIHandler {
|
||||
// GET /api/v1/calendars
|
||||
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
cals, err := h.store.ListCalendars(r.Context(), user.ID)
|
||||
// Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst
|
||||
// steht ein neues Konto vor einer leeren Ansicht und muss erst erraten,
|
||||
// dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich
|
||||
// vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht
|
||||
// zeigt.
|
||||
if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||
return
|
||||
}
|
||||
// Eigene **und** freigegebene. Jeder Eintrag trägt sein Recht mit, damit
|
||||
// die Oberfläche einen nur lesbaren Kalender gar nicht erst zum
|
||||
// Bearbeiten anbietet.
|
||||
cals, err := h.store.ListAccessible(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||
return
|
||||
@@ -116,7 +128,8 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
cal, err := h.store.GetCalendar(r.Context(), user.ID, id)
|
||||
// Ausgeben darf, wer lesen darf – auch bei einem freigegebenen Kalender.
|
||||
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, false)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
@@ -125,6 +138,7 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||
return
|
||||
}
|
||||
cal := &zugriff.Calendar
|
||||
objs, err := h.store.ListObjects(r.Context(), cal.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list objects failed")
|
||||
@@ -178,16 +192,22 @@ func (h *APIHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
cal, err := h.store.GetCalendar(r.Context(), user.ID, id)
|
||||
// Hineinschreiben darf nur, wer schreiben darf.
|
||||
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, true)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err == ErrReadOnly {
|
||||
response.Error(w, http.StatusForbidden, "calendar is shared read-only")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||
return
|
||||
}
|
||||
|
||||
cal := &zugriff.Calendar
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MiB – reicht für sehr große Kalender
|
||||
src, err := ical.NewDecoder(r.Body).Decode()
|
||||
if err != nil {
|
||||
|
||||
@@ -120,7 +120,10 @@ func parseCalendarPath(p string) (ref string, rest string, err error) {
|
||||
return parts[1], rest, nil
|
||||
}
|
||||
|
||||
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Calendar, error) {
|
||||
// resolveCalendar ist der **eine** Weg von einer Adresse zu einem Kalender –
|
||||
// und damit die Stelle, an der die Rechteprüfung sitzt. Jede CalDAV-Operation
|
||||
// läuft hier durch; eine zweite Auflösung daneben wäre eine Tür ohne Schloss.
|
||||
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Access, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -129,7 +132,7 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C
|
||||
if err != nil {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
cal, err := b.store.GetCalendarByRef(ctx, u.ID, ref)
|
||||
cal, err := b.store.ResolveRef(ctx, u.ID, ref)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
@@ -139,14 +142,36 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C
|
||||
return u, cal, nil
|
||||
}
|
||||
|
||||
func toCalDAVCalendar(username string, c *Calendar) caldav.Calendar {
|
||||
// resolveWritable: dasselbe, aber der Aufrufer will schreiben. Ein nur lesend
|
||||
// freigegebener Kalender darf sichtbar bleiben und trotzdem ablehnen – 403
|
||||
// verrät hier nichts, was der Anfragende nicht ohnehin sieht.
|
||||
func (b *Backend) resolveWritable(ctx context.Context, p string) (*auth.User, *Access, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !cal.CanWrite() {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusForbidden,
|
||||
errors.New("calendar is shared read-only"))
|
||||
}
|
||||
return u, cal, nil
|
||||
}
|
||||
|
||||
func toCalDAVCalendar(username string, c *Access) caldav.Calendar {
|
||||
comps := []string{"VEVENT"}
|
||||
if c.SupportsTodo {
|
||||
comps = append(comps, "VTODO")
|
||||
}
|
||||
// Ein fremder Kalender trägt den Eigentümer im Namen. Ohne das stehen in
|
||||
// der Liste des Clients zwei „Privat" nebeneinander, und welches welches
|
||||
// ist, weiß niemand.
|
||||
name := c.Name
|
||||
if c.Shared && c.OwnerName != "" {
|
||||
name = c.Name + " (" + c.OwnerName + ")"
|
||||
}
|
||||
return caldav.Calendar{
|
||||
Path: calendarPath(username, c.Ref()),
|
||||
Name: c.Name,
|
||||
Name: name,
|
||||
Description: c.Description,
|
||||
MaxResourceSize: 5 * 1024 * 1024,
|
||||
SupportedComponentSet: comps,
|
||||
@@ -194,13 +219,13 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
|
||||
return nil, fmt.Errorf("ensure default calendar: %w", err)
|
||||
}
|
||||
|
||||
all, err := b.store.ListCalendars(ctx, u.ID)
|
||||
all, err := b.store.ListAccessible(ctx, u.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list calendars: %w", err)
|
||||
}
|
||||
out := make([]caldav.Calendar, len(all))
|
||||
for i, c := range all {
|
||||
out[i] = toCalDAVCalendar(u.Username, &c)
|
||||
for i := range all {
|
||||
out[i] = toCalDAVCalendar(u.Username, &all[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -323,7 +348,7 @@ func etagFor(data string) string {
|
||||
}
|
||||
|
||||
func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||
u, calRow, err := b.resolveCalendar(ctx, p)
|
||||
u, calRow, err := b.resolveWritable(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -366,7 +391,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Cal
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error {
|
||||
_, calRow, err := b.resolveCalendar(ctx, p)
|
||||
_, calRow, err := b.resolveWritable(ctx, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -393,7 +393,8 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
|
||||
// Lesen genügt: Ein freigegebener Kalender soll sich ansehen lassen.
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, false); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
@@ -552,13 +553,17 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
||||
return opt
|
||||
}
|
||||
|
||||
// calendarOf steht vor **jedem schreibenden** Zugriff der Weboberfläche –
|
||||
// Anlegen, Ändern, Umhängen, Löschen. Deshalb verlangt es das Schreibrecht:
|
||||
// Ein nur lesend freigegebener Kalender darf sich ansehen lassen und trotzdem
|
||||
// nichts annehmen.
|
||||
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, true); err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
return calID, true
|
||||
@@ -658,10 +663,10 @@ func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||||
return
|
||||
}
|
||||
// Der Zielkalender muss demselben Konto gehören. Ohne diese Prüfung
|
||||
// In den Zielkalender muss geschrieben werden dürfen. Ohne diese Prüfung
|
||||
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
|
||||
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
|
||||
if _, err := h.store.GetCalendar(r.Context(), user.ID, toCal); err != nil {
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, toCal, true); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case "PROPPATCH":
|
||||
h.handleProppatch(w, r, user)
|
||||
return
|
||||
case "REPORT":
|
||||
// Nur sync-collection gehört uns; calendar-query und
|
||||
// calendar-multiget beantwortet die Bibliothek weiterhin selbst.
|
||||
if h.handleReport(w, r, user) {
|
||||
return
|
||||
}
|
||||
case http.MethodDelete:
|
||||
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
|
||||
// die Bibliothek weiterhin selbst.
|
||||
|
||||
@@ -27,9 +27,14 @@ type Calendar struct {
|
||||
// PathSegment ist die Adresse, unter der ein Client diesen Kalender
|
||||
// angelegt hat ("…/calendars/max/arbeit/"). Leer heißt: keine gewählte
|
||||
// Adresse, es gilt die UUID. Siehe Ref.
|
||||
PathSegment string `json:"-"`
|
||||
CTag int64 `json:"ctag"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
PathSegment string `json:"-"`
|
||||
CTag int64 `json:"ctag"`
|
||||
// SyncFloor: Bis zu diesem Stand sind Grabsteine weggeräumt. Ein Token
|
||||
// darunter lässt sich nicht mehr vollständig beantworten – der Client
|
||||
// bekommt dann eine Absage und gleicht vollständig ab. Siehe
|
||||
// Store.ChangesSince.
|
||||
SyncFloor int64 `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Ref ist das Pfadsegment, unter dem dieser Kalender in CalDAV erreichbar
|
||||
|
||||
@@ -42,9 +42,17 @@ const (
|
||||
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
|
||||
const maxPropfindBody = 1 << 20
|
||||
|
||||
type wantedExtras struct{ ctag, color bool }
|
||||
// Vier Eigenschaften, die go-webdav nicht kennt. Die ersten beiden sind
|
||||
// herstellereigen, die letzten beiden stehen im RFC – die Bibliothek liefert
|
||||
// sie trotzdem nicht.
|
||||
type wantedExtras struct{ ctag, color, syncToken, reports bool }
|
||||
|
||||
func (w wantedExtras) any() bool { return w.ctag || w.color }
|
||||
func (w wantedExtras) any() bool { return w.ctag || w.color || w.syncToken || w.reports }
|
||||
|
||||
// alleExtras: Antwort auf <allprop> und auf einen leeren Rumpf.
|
||||
func alleExtras() wantedExtras {
|
||||
return wantedExtras{ctag: true, color: true, syncToken: true, reports: true}
|
||||
}
|
||||
|
||||
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
|
||||
// und baut den Rumpf ohne sie neu auf.
|
||||
@@ -55,7 +63,7 @@ func (w wantedExtras) any() bool { return w.ctag || w.color }
|
||||
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
||||
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return wantedExtras{ctag: true, color: true}, body
|
||||
return alleExtras(), body
|
||||
}
|
||||
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
@@ -72,7 +80,7 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
|
||||
return wantedExtras{ctag: true, color: true}, body
|
||||
return alleExtras(), body
|
||||
}
|
||||
if !inProp {
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||||
@@ -89,6 +97,10 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
||||
want.ctag = true
|
||||
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
|
||||
want.color = true
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "sync-token":
|
||||
want.syncToken = true
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "supported-report-set":
|
||||
want.reports = true
|
||||
default:
|
||||
keep = append(keep, t.Name)
|
||||
}
|
||||
@@ -159,6 +171,15 @@ func appleColor(hex string) string {
|
||||
return strings.ToUpper(h) + "FF"
|
||||
}
|
||||
|
||||
// Die Berichte, die dieser Server beantwortet. Ohne diese Liste probiert ein
|
||||
// Client sync-collection gar nicht erst aus – DAVx5 sieht hier nach, bevor es
|
||||
// entscheidet, ob es die volle Terminliste holt oder nur die Änderungen.
|
||||
const supportedReports = `<supported-report-set xmlns="DAV:">` +
|
||||
`<supported-report><report><sync-collection/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-query xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-multiget xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`</supported-report-set>`
|
||||
|
||||
func extrasPropstat(cal *Calendar, want wantedExtras) string {
|
||||
var b strings.Builder
|
||||
if want.ctag {
|
||||
@@ -169,6 +190,14 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string {
|
||||
fmt.Fprintf(&b, `<calendar-color xmlns="%s">%s</calendar-color>`, nsApple, c)
|
||||
}
|
||||
}
|
||||
if want.syncToken {
|
||||
// Derselbe Zähler wie im ctag – eine Zahl, zwei Namen, weil die
|
||||
// Clients historisch nach beidem fragen.
|
||||
fmt.Fprintf(&b, `<sync-token xmlns="DAV:">%s</sync-token>`, syncToken(cal.CTag))
|
||||
}
|
||||
if want.reports {
|
||||
b.WriteString(supportedReports)
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -182,15 +211,19 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string {
|
||||
// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine
|
||||
// bleiben unangetastet.
|
||||
func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte {
|
||||
cals, err := h.store.ListCalendars(ctx, user.ID)
|
||||
// **Accessible, nicht nur eigene**: Ein freigegebener Kalender steht mit
|
||||
// in der Liste und braucht dieselben Eigenschaften – ohne ctag und
|
||||
// sync-token hielte ihn der Client für unveränderlich.
|
||||
cals, err := h.store.ListAccessible(ctx, user.ID)
|
||||
if err != nil {
|
||||
return body // im Zweifel die Antwort der Bibliothek, unverändert
|
||||
}
|
||||
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
|
||||
// erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat.
|
||||
// Bei fremden Kalendern ist es immer die UUID – siehe Access.Ref.
|
||||
byRef := make(map[string]*Calendar, len(cals))
|
||||
for i := range cals {
|
||||
byRef[cals[i].Ref()] = &cals[i]
|
||||
byRef[cals[i].Ref()] = &cals[i].Calendar
|
||||
}
|
||||
|
||||
const closeTag = "</response>"
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/acl"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("calendar: not found")
|
||||
@@ -21,15 +23,19 @@ var ErrConflict = errors.New("calendar: uid already exists in target calendar")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
// rights beantwortet „wer darf was" – dieselbe Tabelle, die auch über
|
||||
// Ordner und Dateien entscheidet. Darf nil sein: Dann gibt es schlicht
|
||||
// keine freigegebenen Kalender, und alles Übrige funktioniert weiter.
|
||||
rights *acl.Store
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
func NewStore(db *pgxpool.Pool, rights *acl.Store) *Store {
|
||||
return &Store{db: db, rights: rights}
|
||||
}
|
||||
|
||||
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||||
ownerID,
|
||||
)
|
||||
@@ -41,7 +47,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
|
||||
var out []Calendar
|
||||
for rows.Next() {
|
||||
var c Calendar
|
||||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
@@ -52,10 +58,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
|
||||
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
|
||||
ownerID, id,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -75,13 +81,13 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend
|
||||
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars
|
||||
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
||||
ORDER BY path_segment NULLS LAST
|
||||
LIMIT 1`,
|
||||
ownerID, ref,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -102,9 +108,9 @@ func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, name, color, description, segment,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
@@ -120,7 +126,7 @@ func isUniqueViolation(err error) bool {
|
||||
}
|
||||
|
||||
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
|
||||
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort
|
||||
// Bedarf "Personal" an. So findet ein frisch eingerichteter Client sofort
|
||||
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
|
||||
// CalDAV-Client kann das.
|
||||
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
|
||||
@@ -131,7 +137,7 @@ func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*
|
||||
if len(cals) > 0 {
|
||||
return &cals[0], nil
|
||||
}
|
||||
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "")
|
||||
return s.CreateCalendar(ctx, ownerID, "Personal", "#4285f4", "")
|
||||
}
|
||||
|
||||
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
|
||||
@@ -142,9 +148,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, name, color, description,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert calendar: %w", err)
|
||||
}
|
||||
@@ -164,9 +170,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name,
|
||||
description = COALESCE($5, description),
|
||||
timezone = COALESCE($6, timezone)
|
||||
WHERE owner_id = $1 AND id = $2
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, id, name, color, description, timezone,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -248,22 +254,35 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den
|
||||
// Stand tragen, auf den der Kalender damit springt.
|
||||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var o Object
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
|
||||
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now()
|
||||
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
|
||||
sync_seq = EXCLUDED.sync_seq, updated_at = now()
|
||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||||
calendarID, uid, component, data, etag, dtstart, dtend,
|
||||
calendarID, uid, component, data, etag, dtstart, dtend, seq,
|
||||
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert object: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
|
||||
return nil, fmt.Errorf("bump ctag: %w", err)
|
||||
// Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
|
||||
// Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten
|
||||
// Abgleich beides zu sehen -- die Datei und ihre Todesmeldung.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
@@ -303,11 +322,22 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
|
||||
return nil, ErrConflict
|
||||
}
|
||||
|
||||
// Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin
|
||||
// fort (Grabstein), im Ziel ist er neu.
|
||||
fromSeq, err := bumpSeq(ctx, tx, fromCal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toSeq, err := bumpSeq(ctx, tx, toCal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx,
|
||||
`UPDATE cal_objects SET calendar_id = $1, updated_at = now()
|
||||
WHERE calendar_id = $2 AND uid = $3
|
||||
`UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
|
||||
WHERE calendar_id = $3 AND uid = $4
|
||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||||
toCal, fromCal, uid,
|
||||
toCal, toSeq, fromCal, uid,
|
||||
)
|
||||
o, err := scanObject(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -317,10 +347,14 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Im Ziel darf kein alter Grabstein derselben UID stehen bleiben.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal,
|
||||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("bump ctag: %w", err)
|
||||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
@@ -343,8 +377,171 @@ func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid stri
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
|
||||
return fmt.Errorf("bump ctag: %w", err)
|
||||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pruneTombstones(ctx, tx, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ── Abgleich nur der Änderungen (RFC 6578) ───────────────────────────────────
|
||||
|
||||
// Wie lange ein Grabstein stehen bleibt.
|
||||
//
|
||||
// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann
|
||||
// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen;
|
||||
// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer.
|
||||
// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine
|
||||
// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät.
|
||||
const tombstoneTTL = 180 * 24 * time.Hour
|
||||
|
||||
type execer interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen.
|
||||
//
|
||||
// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen
|
||||
// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über
|
||||
// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander.
|
||||
func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) {
|
||||
var seq int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID,
|
||||
).Scan(&seq); err != nil {
|
||||
return 0, fmt.Errorf("bump ctag: %w", err)
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||
SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`,
|
||||
calendarID, uid, seq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tombstone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an.
|
||||
//
|
||||
// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis
|
||||
// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die
|
||||
// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort,
|
||||
// in der eine Löschung einfach fehlt, und behielte den Termin für immer.
|
||||
//
|
||||
// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen
|
||||
// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt.
|
||||
func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error {
|
||||
// Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der
|
||||
// ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die
|
||||
// erste Zeile greift, setzt den Boden auf irgendeinen der geräumten
|
||||
// Stände – und ein Client mit einem Token dazwischen bekäme dann eine
|
||||
// Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele.
|
||||
var hoechste *int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`WITH geraeumt AS (
|
||||
DELETE FROM cal_tombstones
|
||||
WHERE calendar_id = $1 AND deleted_at < now() - $2::interval
|
||||
RETURNING sync_seq
|
||||
)
|
||||
SELECT max(sync_seq) FROM geraeumt`,
|
||||
calendarID, tombstoneTTL.String(),
|
||||
).Scan(&hoechste); err != nil {
|
||||
return fmt.Errorf("prune tombstones: %w", err)
|
||||
}
|
||||
if hoechste == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`,
|
||||
calendarID, *hoechste,
|
||||
); err != nil {
|
||||
return fmt.Errorf("raise sync floor: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Changes ist die Antwort auf „was hat sich seit Stand N getan".
|
||||
type Changes struct {
|
||||
// Angelegt oder geändert – beides sieht für den Client gleich aus, er
|
||||
// holt das Objekt in beiden Fällen neu.
|
||||
Changed []Object
|
||||
// UIDs, die es nicht mehr gibt.
|
||||
Deleted []string
|
||||
// Der Stand, auf dem der Client danach steht.
|
||||
Token int64
|
||||
}
|
||||
|
||||
// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan
|
||||
// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen
|
||||
// Bestand – genau das, was ein Client beim ersten Abgleich braucht.
|
||||
//
|
||||
// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind
|
||||
// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre
|
||||
// unvollständig, ohne dass es jemandem auffiele.
|
||||
func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) {
|
||||
if since < 0 || since > cal.CTag {
|
||||
// Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von
|
||||
// einer anderen Instanz oder aus einer wiederhergestellten Sicherung.
|
||||
return nil, false, nil
|
||||
}
|
||||
if since > 0 && since < cal.SyncFloor {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}}
|
||||
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||||
FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||||
cal.ID, since)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("query changed objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
o, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out.Changed = append(out.Changed, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt:
|
||||
// Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm
|
||||
// nur Löschungen für Termine schicken, die er nie gesehen hat.
|
||||
if since == 0 {
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
trows, err := s.db.Query(ctx,
|
||||
`SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||||
cal.ID, since)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("query tombstones: %w", err)
|
||||
}
|
||||
defer trows.Close()
|
||||
for trows.Next() {
|
||||
var uid string
|
||||
if err := trows.Scan(&uid); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out.Deleted = append(out.Deleted, uid)
|
||||
}
|
||||
return out, true, trows.Err()
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Abgleich nur der Änderungen: REPORT sync-collection, RFC 6578.
|
||||
//
|
||||
// **Wozu.** Bis hierher lief jeder Durchgang so: Der Client fragt das ctag
|
||||
// ab, sieht es verändert und holt daraufhin die *volle* Terminliste. Eine
|
||||
// verschobene Besprechung kostet damit denselben Verkehr wie ein Neuanfang –
|
||||
// bei einem Jahr Termine jedes Mal. Am Telefon zahlt man das in Akku und
|
||||
// Datenvolumen, und zwar bei jedem der viertelstündlichen Durchgänge.
|
||||
//
|
||||
// Mit sync-collection sagt der Client stattdessen „ich bin auf Stand 41" und
|
||||
// bekommt zurück, was seither geschah – und ein neues Token.
|
||||
//
|
||||
// **Warum hier und nicht in der Bibliothek.** go-webdav kennt für REPORT nur
|
||||
// calendar-query und calendar-multiget; alles andere beantwortet es mit 400.
|
||||
// Der Weg geht deshalb an ihr vorbei, wie schon bei PROPFIND und MKCALENDAR.
|
||||
//
|
||||
// **Was ein Token ist.** Eine Zahl: der Stand des Kalenders, also sein ctag.
|
||||
// Kein Zeitstempel – zwei Änderungen in derselben Sekunde wären damit nicht
|
||||
// zu trennen, und eine Uhr, die zurückspringt, ließe Änderungen verschwinden.
|
||||
|
||||
// Präfix des Tokens. Ein Token ist laut RFC ein URI; der Präfix macht
|
||||
// zugleich erkennbar, dass ein Token einer *fremden* Instanz nicht passt.
|
||||
const syncTokenPrefix = "urn:aether:sync:"
|
||||
|
||||
// Ein sync-collection-Rumpf ist eine Handvoll Elemente. Alles darüber ist
|
||||
// kein Abgleich mehr.
|
||||
const maxReportBody = 1 << 20
|
||||
|
||||
type syncReport struct {
|
||||
XMLName xml.Name `xml:"DAV: sync-collection"`
|
||||
SyncToken string `xml:"DAV: sync-token"`
|
||||
SyncLevel string `xml:"DAV: sync-level"`
|
||||
Limit *struct {
|
||||
NResults int `xml:"DAV: nresults"`
|
||||
} `xml:"DAV: limit"`
|
||||
Prop struct {
|
||||
Any []struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
} `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
// wantsCalendarData: Fragt der Client die Termindaten gleich mit ab? Die
|
||||
// meisten holen erst die ETags und danach gezielt per multiget – wer aber
|
||||
// beides in einem Zug will, soll es bekommen.
|
||||
func (s *syncReport) wantsCalendarData() bool {
|
||||
for _, el := range s.Prop.Any {
|
||||
if el.XMLName.Space == nsCalDAV && el.XMLName.Local == "calendar-data" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const nsCalDAV = "urn:ietf:params:xml:ns:caldav"
|
||||
|
||||
// handleReport nimmt sync-collection an sich und lässt alles andere durch.
|
||||
// Rückgabe: true, wenn die Antwort geschrieben wurde.
|
||||
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxReportBody))
|
||||
r.Body.Close()
|
||||
// Was wir nicht selbst beantworten, muss die Bibliothek unverändert
|
||||
// vorfinden – also den gelesenen Rumpf wieder unterlegen.
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err != nil || !isSyncCollection(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
var req syncReport
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "malformed sync-collection request", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
// sync-level ist Pflicht (RFC 6578, 3.3). Bei einem Kalender gibt es nur
|
||||
// eine Ebene; "infinite" bedeutet hier dasselbe wie "1" und wird
|
||||
// angenommen, statt den Client an einer Formalie scheitern zu lassen.
|
||||
if lvl := strings.TrimSpace(req.SyncLevel); lvl != "1" && lvl != "infinite" {
|
||||
http.Error(w, "sync-level must be 1", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
// sync-collection gilt für eine Sammlung, nicht für einen Termin.
|
||||
http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
zugriff, err := h.store.ResolveRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
cal := &zugriff.Calendar
|
||||
|
||||
since, ok := parseSyncToken(req.SyncToken)
|
||||
if !ok {
|
||||
writeInvalidToken(w)
|
||||
return true
|
||||
}
|
||||
|
||||
changes, gueltig, err := h.store.ChangesSince(r.Context(), cal, since)
|
||||
if err != nil {
|
||||
http.Error(w, "sync failed", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if !gueltig {
|
||||
// Das Token ist zu alt oder stammt nicht von uns. Eine unvollständige
|
||||
// Antwort wäre hier das Schlimmste: Der Client hielte sie für den
|
||||
// ganzen Unterschied und behielte gelöschte Termine für immer.
|
||||
writeInvalidToken(w)
|
||||
return true
|
||||
}
|
||||
|
||||
writeSyncResponse(w, user, ref, changes, req.wantsCalendarData())
|
||||
return true
|
||||
}
|
||||
|
||||
// isSyncCollection sieht nach, ob das Wurzelelement sync-collection ist –
|
||||
// ohne den ganzen Rumpf zu deuten. Alles andere geht unverändert weiter an
|
||||
// go-webdav.
|
||||
func isSyncCollection(body []byte) bool {
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if start, ok := tok.(xml.StartElement); ok {
|
||||
return start.Name.Space == "DAV:" && start.Name.Local == "sync-collection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSyncToken liest den Stand aus dem Token. Ein leeres Token heißt
|
||||
// „ich fange bei null an" – der erste Abgleich eines Clients.
|
||||
func parseSyncToken(tok string) (int64, bool) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
return 0, true
|
||||
}
|
||||
if !strings.HasPrefix(tok, syncTokenPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(tok, syncTokenPrefix), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func syncToken(seq int64) string {
|
||||
return syncTokenPrefix + strconv.FormatInt(seq, 10)
|
||||
}
|
||||
|
||||
// writeInvalidToken schickt die Absage, die der RFC dafür vorsieht. Sie ist
|
||||
// kein Fehler im üblichen Sinn: Jeder Client kennt sie und macht daraufhin
|
||||
// einen Vollabgleich – genau das, was wir wollen.
|
||||
func writeInvalidToken(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, xml.Header+
|
||||
`<error xmlns="DAV:"><valid-sync-token/></error>`)
|
||||
}
|
||||
|
||||
// ref ist die Adresse, unter der **dieser** Anfragende den Kalender sieht –
|
||||
// bei einem freigegebenen also dessen UUID, nicht das Pfadsegment des
|
||||
// Eigentümers. Die Antwort muss Adressen nennen, die der Client auch abrufen
|
||||
// kann.
|
||||
func writeSyncResponse(w http.ResponseWriter, user *auth.User,
|
||||
ref string, changes *Changes, withData bool) {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString(`<multistatus xmlns="DAV:" xmlns:C="` + nsCalDAV + `">`)
|
||||
|
||||
for i := range changes.Changed {
|
||||
o := &changes.Changed[i]
|
||||
// Die Anführungszeichen stehen **außerhalb** der Maskierung: Sonst
|
||||
// macht xml.EscapeText daraus `"`. Gültiges XML – aber genug
|
||||
// CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die
|
||||
// sähen dann bei jedem Durchgang eine Änderung, wo keine ist.
|
||||
fmt.Fprintf(&b, `<response><href>%s</href><propstat><prop><getetag>%s</getetag>`,
|
||||
xmlText(objectPath(user.Username, ref, o.UID)), quoteETag(o.ETag))
|
||||
if withData {
|
||||
fmt.Fprintf(&b, `<C:calendar-data>%s</C:calendar-data>`, xmlText(o.Data))
|
||||
}
|
||||
b.WriteString(`</prop><status>HTTP/1.1 200 OK</status></propstat></response>`)
|
||||
}
|
||||
|
||||
// Gelöschtes trägt **keinen** propstat, sondern einen Status direkt an der
|
||||
// Antwort – so unterscheidet der Client „ist weg" von „gibt es, konnte
|
||||
// aber eine Eigenschaft nicht liefern".
|
||||
for _, uid := range changes.Deleted {
|
||||
fmt.Fprintf(&b, `<response><href>%s</href><status>HTTP/1.1 404 Not Found</status></response>`,
|
||||
xmlText(objectPath(user.Username, ref, uid)))
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<sync-token>%s</sync-token></multistatus>`, syncToken(changes.Token))
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, b.String())
|
||||
}
|
||||
|
||||
// quoteETag: In der Datenbank steht der nackte Wert, über die Leitung gehört
|
||||
// er in Anführungszeichen (RFC 9110). go-webdav macht das an anderer Stelle
|
||||
// selbst – hier bauen wir die Antwort von Hand, also hier auch.
|
||||
//
|
||||
// Der Inhalt wird maskiert, die Anführungszeichen nicht: Sie sind Teil der
|
||||
// Syntax, nicht des Werts.
|
||||
func quoteETag(etag string) string {
|
||||
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/`) {
|
||||
return xmlText(etag)
|
||||
}
|
||||
return `"` + xmlText(etag) + `"`
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Tests der Warteschlange für die Texterkennung.
|
||||
//
|
||||
// **Warum ausgerechnet hier.** Ein Fehler in diesem Paket ist *still*: Die
|
||||
// Volltextsuche findet dann einfach nichts – und das sieht genauso aus wie
|
||||
// „steht nicht drin". Niemand bekommt eine Fehlermeldung; man merkt es erst,
|
||||
// wenn man eine Rechnung sucht, von der man weiß, dass man sie hat.
|
||||
//
|
||||
// Geprüft werden deshalb nicht die Abfragen, sondern die drei Zustände, in
|
||||
// denen die Schlange hängenbleiben kann, ohne dass es auffällt:
|
||||
//
|
||||
// 1. Ein Auftrag, der nie aufgibt (Endlosschleife auf einer kaputten Datei).
|
||||
// 2. Ein Auftrag, der nach einem harten Abbruch für immer auf 'running'
|
||||
// steht und nie wieder angefasst wird.
|
||||
// 3. Ein Text nach alter Regel, der nie erneuert wird.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN gegen eine echte Datenbank; ohne wird übersprungen,
|
||||
// damit `go test ./...` überall grün bleibt.
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
// probeDatei legt Konto und Datei an, auf die sich ein Auftrag beziehen kann.
|
||||
// Claim verbindet mit `files`, ein Auftrag ohne Datei wäre also unsichtbar –
|
||||
// und ein Test, der das übersieht, prüft eine leere Schlange.
|
||||
func probeDatei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, marke string) uuid.UUID {
|
||||
t.Helper()
|
||||
var userID uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, 'OCR Probe', 'x', false) RETURNING id`, "ocrprobe-"+marke,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatalf("konto anlegen: %v", err)
|
||||
}
|
||||
var fileID uuid.UUID
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1234, 'deadbeef', $3) RETURNING id`,
|
||||
userID, "probe-"+marke+".pdf", "users/ocrprobe-"+marke+"/home/probe.pdf",
|
||||
).Scan(&fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("datei anlegen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
return fileID
|
||||
}
|
||||
|
||||
func status(t *testing.T, pool *pgxpool.Pool, ctx context.Context, fileID uuid.UUID) (string, int) {
|
||||
t.Helper()
|
||||
var s string
|
||||
var a int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT status, attempts FROM ocr_jobs WHERE file_id = $1`, fileID,
|
||||
).Scan(&s, &a); err != nil {
|
||||
t.Fatalf("stand lesen: %v", err)
|
||||
}
|
||||
return s, a
|
||||
}
|
||||
|
||||
// ── 1. Aufgeben ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Die Zahl selbst ist eine Entscheidung, kein Zufall – und ein Test, der nur
|
||||
// gegen die Konstante läuft, kann sie nicht schützen.
|
||||
//
|
||||
// **Das kam aus einem Mutationsversuch:** Setzt man maxAttempts auf 9999,
|
||||
// blieben alle übrigen Tests grün (sie schleifen ja über die Konstante) und
|
||||
// brauchten nur plötzlich 27 statt 0,2 Sekunden. Genau so verschwindet eine
|
||||
// Entscheidung unbemerkt aus einer Anwendung.
|
||||
//
|
||||
// Zwei Wiederholungen fangen Vorübergehendes ab – Neustart mitten im Lauf,
|
||||
// kurz fehlender Plattenplatz. Was dreimal scheitert, scheitert an der Datei.
|
||||
func TestDreiVersucheSindEineEntscheidung(t *testing.T) {
|
||||
if maxAttempts != 3 {
|
||||
t.Fatalf("maxAttempts steht auf %d. Wenn das Absicht ist, gehört die "+
|
||||
"Begründung an die Konstante und diese Zahl hierher.", maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Datei, die den Erkenner reproduzierbar scheitern lässt – ein kaputtes
|
||||
// oder passwortgeschütztes PDF –, darf die Schlange nicht dauerhaft besetzen.
|
||||
// Ohne Obergrenze liefe der Arbeiter für immer auf derselben Datei und käme an
|
||||
// die übrigen nie heran: still, denn nach außen „läuft" die Erkennung.
|
||||
func TestGibtNachDreiVersuchenAuf(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "aufgeben")
|
||||
|
||||
if err := s.Enqueue(ctx, fileID); err != nil {
|
||||
t.Fatalf("einreihen: %v", err)
|
||||
}
|
||||
|
||||
for i := 1; i <= maxAttempts; i++ {
|
||||
// Obergrenze gegen eine aus dem Ruder gelaufene Konstante: Ohne sie
|
||||
// liefe dieser Test bei maxAttempts = 9999 minutenlang, statt etwas
|
||||
// zu sagen.
|
||||
if i > 10 {
|
||||
t.Fatalf("maxAttempts steht auf %d – das ist keine Wiederholung mehr", maxAttempts)
|
||||
}
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("versuch %d: kein Auftrag mehr (%v) – zu früh aufgegeben", i, err)
|
||||
}
|
||||
if job.FileID != fileID {
|
||||
t.Fatalf("versuch %d: fremder Auftrag", i)
|
||||
}
|
||||
if job.Attempts != i {
|
||||
t.Fatalf("versuch %d: Zähler steht auf %d", i, job.Attempts)
|
||||
}
|
||||
if err := s.Fail(ctx, job, fmt.Errorf("kaputtes PDF")); err != nil {
|
||||
t.Fatalf("versuch %d: Fail: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Jetzt ist Schluss – und das muss *sichtbar* sein, nicht bloß still.
|
||||
if st, a := status(t, pool, ctx, fileID); st != "failed" || a != maxAttempts {
|
||||
t.Fatalf("nach %d Versuchen: status=%q attempts=%d, erwartet failed/%d",
|
||||
maxAttempts, st, a, maxAttempts)
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("der Auftrag wird weiter angefasst – Endlosschleife auf einer kaputten Datei")
|
||||
}
|
||||
|
||||
// Der Grund muss drinstehen. Ein 'failed' ohne Text wäre eine Sackgasse:
|
||||
// Man sieht, dass etwas nicht ging, aber nie was.
|
||||
var fehler string
|
||||
_ = pool.QueryRow(ctx, `SELECT last_error FROM ocr_jobs WHERE file_id = $1`, fileID).Scan(&fehler)
|
||||
if fehler == "" {
|
||||
t.Fatal("kein Fehlertext gespeichert")
|
||||
}
|
||||
}
|
||||
|
||||
// Wird dieselbe Datei erneut hochgeladen, verdient sie eine neue Chance –
|
||||
// vielleicht war genau der kaputte Scan das Problem.
|
||||
func TestNeuerUploadGibtEineNeueChance(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "neuechance")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
for range maxAttempts {
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
_ = s.Fail(ctx, job, fmt.Errorf("kaputt"))
|
||||
}
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "failed" {
|
||||
t.Fatalf("vorbedingung: status=%q", st)
|
||||
}
|
||||
|
||||
// Erneuter Upload.
|
||||
if err := s.Enqueue(ctx, fileID); err != nil {
|
||||
t.Fatalf("erneut einreihen: %v", err)
|
||||
}
|
||||
if st, a := status(t, pool, ctx, fileID); st != "pending" || a != 0 {
|
||||
t.Fatalf("nach erneutem Upload: status=%q attempts=%d, erwartet pending/0", st, a)
|
||||
}
|
||||
if _, err := s.Claim(ctx); err != nil {
|
||||
t.Fatalf("der neue Versuch wird nicht angefasst: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Nach einem harten Abbruch ────────────────────────────────────────────
|
||||
|
||||
// Stirbt der Prozess mitten in einem Auftrag, bleibt dessen Zeile auf
|
||||
// 'running' stehen. Würde Claim nur 'pending' nehmen, wäre diese Datei für
|
||||
// immer verloren – und niemand erführe es, denn die Zahl der offenen Aufträge
|
||||
// sähe gesund aus.
|
||||
func TestNimmtHaengengebliebeneAuftraegeWiederAuf(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "abbruch")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "running" {
|
||||
t.Fatalf("vorbedingung: status=%q", st)
|
||||
}
|
||||
|
||||
// Hier stirbt der Prozess: kein Complete, kein Fail. Die Zeile bleibt
|
||||
// stehen, wie sie ist.
|
||||
_ = job
|
||||
|
||||
wieder, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("hängengebliebener Auftrag wird nie wieder angefasst: %v", err)
|
||||
}
|
||||
if wieder.FileID != fileID {
|
||||
t.Fatal("ein anderer Auftrag kam zurück")
|
||||
}
|
||||
if wieder.Attempts != 2 {
|
||||
t.Fatalf("Zähler steht auf %d, erwartet 2 – sonst zählt der Abbruch nicht mit",
|
||||
wieder.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Wiederaufnahme darf keine Endlosschleife werden: Eine Datei, die den
|
||||
// Arbeiter reproduzierbar umbringt, wäre sonst eine Schleife aus Absturz und
|
||||
// Neustart.
|
||||
func TestWiederaufnahmeIstBegrenzt(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "begrenzt")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
for i := 1; i <= maxAttempts; i++ {
|
||||
if _, err := s.Claim(ctx); err != nil {
|
||||
t.Fatalf("versuch %d: %v", i, err)
|
||||
}
|
||||
// Jedes Mal stirbt der Prozess – der Auftrag bleibt auf 'running'.
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("unbegrenzte Wiederaufnahme: Absturz, Neustart, Absturz …")
|
||||
}
|
||||
}
|
||||
|
||||
// Eine gelöschte Datei darf nicht mehr erkannt werden – der Arbeiter käme
|
||||
// sonst an einen Blob, den es nicht mehr gibt.
|
||||
func TestGeloeschteDateienWerdenNichtGenommen(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "geloescht")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
if _, err := pool.Exec(ctx, `UPDATE files SET is_deleted = true WHERE id = $1`, fileID); err != nil {
|
||||
t.Fatalf("löschen: %v", err)
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("eine gelöschte Datei wurde zur Erkennung genommen")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Text nach alter Regel ────────────────────────────────────────────────
|
||||
|
||||
// Ändert sich die Extraktionsregel, müssen alte Einträge erneuert werden.
|
||||
// Sonst bleibt ein stiller Rest: auffindbar, aber im Betrachter nicht auf der
|
||||
// richtigen Seite – und niemand sieht, dass diese Dateien anders sind.
|
||||
func TestErneuertTextNachAltererRegel(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "veraltet")
|
||||
|
||||
// Ein Text, der nach einer älteren Regel entstanden ist.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
|
||||
VALUES ($1, 'alter text', 'ocr', 'deu', 10, 1, $2)`,
|
||||
fileID, ExtractVersion-1); err != nil {
|
||||
t.Fatalf("alten text anlegen: %v", err)
|
||||
}
|
||||
|
||||
n, err := s.EnqueueOutdated(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueOutdated: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("der veraltete Eintrag wurde nicht erneut eingeplant")
|
||||
}
|
||||
if st, a := status(t, pool, ctx, fileID); st != "pending" || a != 0 {
|
||||
t.Fatalf("status=%q attempts=%d, erwartet pending/0", st, a)
|
||||
}
|
||||
|
||||
// Und einer auf aktuellem Stand darf **nicht** erneut eingeplant werden –
|
||||
// sonst erkennt der Server bei jedem Start den ganzen Bestand neu.
|
||||
_, _ = pool.Exec(ctx, `UPDATE file_text SET extract_version = $2 WHERE file_id = $1`,
|
||||
fileID, ExtractVersion)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ocr_jobs WHERE file_id = $1`, fileID)
|
||||
n, err = s.EnqueueOutdated(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueOutdated (aktuell): %v", err)
|
||||
}
|
||||
var da bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM ocr_jobs WHERE file_id = $1)`, fileID).Scan(&da)
|
||||
if da {
|
||||
t.Fatalf("ein aktueller Eintrag wurde erneut eingeplant (%d Zeilen) – der Bestand würde bei jedem Start neu erkannt", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Abschluss ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Complete muss Text **und** Auftragsende zusammen schreiben. Ginge nur eines
|
||||
// durch, wäre die Datei entweder unauffindbar oder würde bei jedem Durchgang
|
||||
// erneut erkannt.
|
||||
func TestCompleteSchreibtTextUndSchliesstAb(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "complete")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
res := Result{Text: "Seite eins\fSeite zwei", Source: "ocr", Lang: "deu", Chars: 20, Pages: 2}
|
||||
if err := s.Complete(ctx, job, res); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "done" {
|
||||
t.Fatalf("status=%q, erwartet done", st)
|
||||
}
|
||||
seiten, quelle, err := s.TextPages(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("TextPages: %v", err)
|
||||
}
|
||||
if quelle != "ocr" {
|
||||
t.Fatalf("quelle=%q", quelle)
|
||||
}
|
||||
// Die Seitenmarke ist der Grund, aus dem es ExtractVersion gibt: Ohne sie
|
||||
// kann der Betrachter eine Fundstelle nicht auf einer Seite verorten.
|
||||
if len(seiten) != 2 {
|
||||
t.Fatalf("%d Seiten, erwartet 2 – Seitenmarke ging verloren", len(seiten))
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("ein fertiger Auftrag wird erneut genommen")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||
@@ -67,7 +68,13 @@ func (w *Worker) Run(ctx context.Context) {
|
||||
|
||||
for {
|
||||
wait := idleInterval
|
||||
if w.step(ctx) {
|
||||
// **Je Auftrag abgesichert, nicht je Arbeiter.** Fängt man eine Panik
|
||||
// um die ganze Schleife ab, überlebt zwar der Prozess – aber der
|
||||
// Arbeiter ist tot, und von da an wird still keine Datei mehr erkannt.
|
||||
// So kostet ein unverdauliches Dokument genau sich selbst.
|
||||
var etwasGetan bool
|
||||
bg.Run("Texterkennung", func() { etwasGetan = w.step(ctx) })
|
||||
if etwasGetan {
|
||||
wait = busyInterval
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -486,20 +484,25 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
|
||||
// Verify checksum while streaming.
|
||||
h256 := sha256.New()
|
||||
reader := io.TeeReader(rc, h256)
|
||||
|
||||
if _, err := io.Copy(w, reader); err != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
// Log corruption; headers already sent, can't change status.
|
||||
// TODO: alert / observability hook
|
||||
_ = actual
|
||||
}
|
||||
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
|
||||
// zurückhalten**, bis sie stimmt.
|
||||
//
|
||||
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
|
||||
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
|
||||
// dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte
|
||||
// wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv,
|
||||
// in das man einmal im Jahr sieht, die teuerste Art von Stille.
|
||||
//
|
||||
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
|
||||
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
|
||||
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
|
||||
// Verbindung noch fallen lassen, und der Browser sieht einen
|
||||
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
|
||||
ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
||||
@@ -762,7 +765,10 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
|
||||
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
||||
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
||||
var extraMIMETypes = map[string]string{
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
// Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit
|
||||
// die Oberflaeche sie vom Texteditor fernhaelt.
|
||||
".excalidraw": "application/json",
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
||||
".wav": "audio/wav", ".flac": "audio/flac",
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/thumbnails"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein
|
||||
// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und
|
||||
// halten trotzdem jeden Unfug vom Dekodierer fern.
|
||||
const maxThumbUpload = 2 << 20
|
||||
|
||||
// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen
|
||||
// kann.
|
||||
//
|
||||
// POST /api/v1/files/{id}/thumbnail
|
||||
//
|
||||
// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst
|
||||
// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort
|
||||
// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung
|
||||
// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann.
|
||||
//
|
||||
// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt,
|
||||
// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch
|
||||
// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren,
|
||||
// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
|
||||
// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen.
|
||||
func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
// Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie
|
||||
// sie in der Liste aussieht.
|
||||
owner, ok := h.resolveFile(w, r, id, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
typ := r.Header.Get("Content-Type")
|
||||
if !thumbnails.SupportedMIME(typ) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image")
|
||||
return
|
||||
}
|
||||
|
||||
// MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung
|
||||
// ab, statt erst alles zu lesen und dann abzulehnen.
|
||||
rc := http.MaxBytesReader(w, r.Body, maxThumbUpload)
|
||||
defer rc.Close()
|
||||
|
||||
if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten")
|
||||
response.Error(w, http.StatusBadRequest, "could not read that image")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -37,10 +38,12 @@ func scanFile(row interface{ Scan(...any) error }, f *File) error {
|
||||
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||
var out File
|
||||
err := scanFile(s.db.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
|
||||
versioning_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING `+fileCols,
|
||||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||||
versioniertVonAnfang(f.Name),
|
||||
), &out)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
|
||||
// behalten.
|
||||
//
|
||||
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
|
||||
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
|
||||
// unbemerkt voll.
|
||||
//
|
||||
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
|
||||
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
|
||||
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
|
||||
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
|
||||
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
|
||||
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
|
||||
// ohne Deckung.
|
||||
func versioniertVonAnfang(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
|
||||
}
|
||||
|
||||
// GetFileByID fetches a file without owner check (used by public share downloads).
|
||||
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
||||
var f File
|
||||
@@ -537,29 +558,6 @@ func isUniqueViolation(err error) bool {
|
||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||
// entstanden ist – 0 heißt: es gibt keins.
|
||||
//
|
||||
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
|
||||
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
|
||||
// Liste und jede Suche mit.
|
||||
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("preview version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
|
||||
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set preview version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Der tailWriter ist die Stelle, an der ein beschädigter Download noch
|
||||
// aufzuhalten ist. Was er falsch macht, merkt man sonst erst an einer Datei,
|
||||
// die heil aussieht und keine ist.
|
||||
|
||||
func TestTailWriterHaeltDenSchlussZurueck(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 4}
|
||||
|
||||
if _, err := tw.Write([]byte("0123456789")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := raus.String(); got != "012345" {
|
||||
t.Fatalf("durchgereicht %q, erwartet %q", got, "012345")
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := raus.String(); got != "0123456789" {
|
||||
t.Fatalf("nach Flush %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Der Fall, auf den es ankommt: Ohne Flush darf der Empfänger die Datei
|
||||
// **nicht** vollständig haben.
|
||||
func TestOhneFlushBleibtEsUnvollstaendig(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 4}
|
||||
io.Copy(tw, bytes.NewReader([]byte("0123456789")))
|
||||
if raus.Len() != 6 {
|
||||
t.Fatalf("%d Bytes draußen, erwartet 6", raus.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Datei, die kleiner ist als der zurückgehaltene Schluss, darf ohne
|
||||
// Flush gar nichts hinauslassen.
|
||||
func TestKleineDateiBleibtGanzZurueck(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 32}
|
||||
tw.Write([]byte("kurz"))
|
||||
if raus.Len() != 0 {
|
||||
t.Fatalf("%d Bytes zu früh draußen", raus.Len())
|
||||
}
|
||||
tw.Flush()
|
||||
if raus.String() != "kurz" {
|
||||
t.Fatalf("nach Flush %q", raus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Viele kleine Schreibvorgänge dürfen sich nicht anders verhalten als einer
|
||||
// großer – io.Copy zerlegt in Häppchen.
|
||||
func TestHaeppchenweiseGleichesErgebnis(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 5}
|
||||
for _, c := range []byte("abcdefghijklmnop") {
|
||||
if _, err := tw.Write([]byte{c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if raus.String() != "abcdefghijk" {
|
||||
t.Fatalf("durchgereicht %q", raus.String())
|
||||
}
|
||||
tw.Flush()
|
||||
if raus.String() != "abcdefghijklmnop" {
|
||||
t.Fatalf("nach Flush %q", raus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Der Zwischenspeicher darf nicht mitwachsen: Bei einer 5-GB-Datei läge sonst
|
||||
// alles im Arbeitsspeicher.
|
||||
func TestZwischenspeicherWaechstNicht(t *testing.T) {
|
||||
tw := &tailWriter{w: io.Discard, keep: 1024}
|
||||
block := make([]byte, 32*1024)
|
||||
for range 100 {
|
||||
tw.Write(block)
|
||||
if len(tw.buf) > len(block)+tw.keep {
|
||||
t.Fatalf("Zwischenspeicher auf %d Bytes gewachsen", len(tw.buf))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bricht die Gegenstelle weg, muss der Fehler nach oben durchkommen – sonst
|
||||
// liefe io.Copy weiter und läse die ganze Datei ins Nichts.
|
||||
type kaputterWriter struct{}
|
||||
|
||||
func (kaputterWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
|
||||
func TestSchreibfehlerKommtDurch(t *testing.T) {
|
||||
tw := &tailWriter{w: kaputterWriter{}, keep: 2}
|
||||
if _, err := tw.Write([]byte("abcdef")); err == nil {
|
||||
t.Fatal("Schreibfehler wurde verschluckt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Ausliefern mit Prüfung der SHA-256-Summe.
|
||||
//
|
||||
// Stand bis zum 25.09.2026 nur im angemeldeten Download. Der **öffentliche**
|
||||
// Freigabelink lieferte ungeprüft aus – ausgerechnet der Weg, auf dem eine
|
||||
// Datei das Haus verlässt. Wer sie angemeldet holt, ist der Eigentümer und
|
||||
// merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht.
|
||||
//
|
||||
// In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen,
|
||||
// **beim Download verifizieren**. Die Regel galt an einer von zwei Stellen.
|
||||
// Deshalb liegt sie jetzt hier und wird von beiden gerufen.
|
||||
|
||||
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
|
||||
//
|
||||
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
|
||||
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
|
||||
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
|
||||
const verifyTail = 32 * 1024
|
||||
|
||||
// VerifyInfo ist nur für den Logeintrag – wer hinterher sucht, braucht die
|
||||
// Datei und ihren Platz auf der Platte, nicht bloß „stimmt nicht".
|
||||
type VerifyInfo struct {
|
||||
FileID string
|
||||
StorageKey string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ServeVerified kopiert `src` nach `w` und prüft dabei die Summe.
|
||||
//
|
||||
// Stimmt sie nicht, wird der letzte Rest **nicht** herausgegeben und die
|
||||
// Verbindung fallen gelassen. Die Kopfzeilen sind da längst draußen, der
|
||||
// Statuscode also nicht mehr zu ändern – aber die Gegenstelle bekommt weniger
|
||||
// Bytes als im Content-Length angekündigt und wertet das als Fehler. Besser
|
||||
// ein abgebrochener Download als eine heile Datei mit falschem Inhalt.
|
||||
func ServeVerified(ctx context.Context, w io.Writer, src io.Reader, erwartet string, info VerifyInfo) {
|
||||
h256 := sha256.New()
|
||||
zurueck := &tailWriter{w: w, keep: verifyTail}
|
||||
|
||||
if _, err := io.Copy(zurueck, io.TeeReader(src, h256)); err != nil {
|
||||
return // Gegenstelle ist weg
|
||||
}
|
||||
|
||||
gelesen := hex.EncodeToString(h256.Sum(nil))
|
||||
if gelesen != erwartet {
|
||||
log.Ctx(ctx).Error().
|
||||
Str("file_id", info.FileID).
|
||||
Str("storage_key", info.StorageKey).
|
||||
Str("erwartet", erwartet).
|
||||
Str("gelesen", gelesen).
|
||||
Int64("size", info.Size).
|
||||
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
|
||||
|
||||
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
|
||||
// fallen zu lassen: net/http schließt die Verbindung und schreibt
|
||||
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
|
||||
// Panik ausdrücklich durch).
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// Stimmt sie, darf der Rest hinterher.
|
||||
_ = zurueck.Flush()
|
||||
}
|
||||
|
||||
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
|
||||
type tailWriter struct {
|
||||
w io.Writer
|
||||
keep int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (t *tailWriter) Write(p []byte) (int, error) {
|
||||
t.buf = append(t.buf, p...)
|
||||
if ueber := len(t.buf) - t.keep; ueber > 0 {
|
||||
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
t.buf = append(t.buf[:0], t.buf[ueber:]...)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
|
||||
// bleibt die Antwort unvollständig – genau das ist der Zweck.
|
||||
func (t *tailWriter) Flush() error {
|
||||
if len(t.buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := t.w.Write(t.buf)
|
||||
t.buf = t.buf[:0]
|
||||
return err
|
||||
}
|
||||
+10
-23
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,6 +73,11 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||
// "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die
|
||||
// als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und
|
||||
// die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so
|
||||
// sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste,
|
||||
// bleiben sie für immer liegen.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
res.Scanned++
|
||||
@@ -153,27 +159,6 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
return nil, fmt.Errorf("iterate file keys: %w", err)
|
||||
}
|
||||
|
||||
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
|
||||
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
|
||||
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
|
||||
prevRows, err := c.db.Query(ctx,
|
||||
`SELECT id::text FROM files WHERE preview_version > 0`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query preview keys: %w", err)
|
||||
}
|
||||
for prevRows.Next() {
|
||||
var id string
|
||||
if err := prevRows.Scan(&id); err != nil {
|
||||
prevRows.Close()
|
||||
return nil, fmt.Errorf("scan preview key: %w", err)
|
||||
}
|
||||
keys["previews/"+id+".pdf"] = true
|
||||
}
|
||||
prevRows.Close()
|
||||
if err := prevRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate preview keys: %w", err)
|
||||
}
|
||||
|
||||
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
||||
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
||||
@@ -254,7 +239,9 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||
// Hochfahren konkurriert.
|
||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
go func() {
|
||||
// Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein
|
||||
// Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll.
|
||||
bg.Go("Speicherbereinigung", func() {
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -282,7 +269,7 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
}
|
||||
timer.Reset(Interval)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ein Dokument nach PDF wandeln – dafür, es *anzusehen*, statt es zu
|
||||
// bearbeiten.
|
||||
//
|
||||
// **Warum überhaupt:** Am Telefon ist der Office-Editor unbrauchbar. Er ist
|
||||
// eine vollständige Textverarbeitung; Menüband, Formatleisten und
|
||||
// Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt. Auf 400 Pixeln
|
||||
// bleibt für das Dokument übrig, was die Leisten nicht brauchen. Das gilt für
|
||||
// Collabora wie für OnlyOffice – Marc hat beide ausprobiert, mit demselben
|
||||
// Ergebnis. Ein PDF im eigenen Betrachter ist für „kurz nachsehen" das
|
||||
// bessere Werkzeug, und für „kurz nachsehen" ist man unterwegs da.
|
||||
//
|
||||
// **Warum derselbe Server, der auch bearbeitet:** Wer OnlyOffice wegen der
|
||||
// Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe,
|
||||
// sähe unterwegs ein LibreOffice-Layout desselben Dokuments. Zwei Geräte,
|
||||
// zwei Umbrüche – ausgerechnet bei Unterlagen, bei denen es auf die Seite
|
||||
// ankommt. Die Umwandlung gehört deshalb zu dem Anbieter, der auch bearbeitet.
|
||||
//
|
||||
// Die beiden Anbieter gehen dabei verschiedene Wege, und der Unterschied ist
|
||||
// grundsätzlich:
|
||||
//
|
||||
// - **Collabora** nimmt die Datei entgegen und antwortet mit dem PDF. Ein
|
||||
// Aufruf, keine Anmeldung.
|
||||
// - **OnlyOffice** nimmt eine *Adresse* entgegen, holt die Datei selbst ab
|
||||
// und antwortet mit einer Adresse, unter der das Ergebnis liegt. Also
|
||||
// zwei Aufrufe, eine Signatur, und ein zweiter Ort, dem man nicht trauen
|
||||
// darf (siehe unten).
|
||||
|
||||
// convertTimeout: So lange darf eine Umwandlung dauern.
|
||||
//
|
||||
// Gemessen an dieser Anlage braucht ein gewöhnliches Textdokument etwa eine
|
||||
// Sekunde. Eine Minute ist keine Erwartung, sondern eine Notbremse: Hängt der
|
||||
// Office-Server, soll die Anfrage enden und nicht der Server mit ihr.
|
||||
const convertTimeout = 60 * time.Second
|
||||
|
||||
// jwtLifetime: Wie lange die Signatur an OnlyOffice gilt. Sie wird für genau
|
||||
// einen Aufruf ausgestellt.
|
||||
const jwtLifetime = 2 * time.Minute
|
||||
|
||||
// ConvertToPDF wandelt ein Dokument um.
|
||||
//
|
||||
// `src` ist die Adresse, unter der der Office-Server die Datei abholen kann –
|
||||
// nur OnlyOffice braucht sie. `body` sind die Bytes selbst – nur Collabora
|
||||
// braucht die. Der Aufrufer stellt beides bereit; welcher Weg genommen wird,
|
||||
// entscheidet der Anbieter, nicht der Aufrufer.
|
||||
func (c *Client) ConvertToPDF(ctx context.Context, ext, key, name, src string, body io.Reader) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch c.provider {
|
||||
case "collabora":
|
||||
return c.convertCollabora(ctx, name, body)
|
||||
case "onlyoffice":
|
||||
return c.convertOnlyOffice(ctx, ext, key, name, src)
|
||||
}
|
||||
return nil, fmt.Errorf("office: provider %q cannot convert", c.provider)
|
||||
}
|
||||
|
||||
// CanConvert sagt, ob dieser Anbieter das Wandeln überhaupt anbietet. Für die
|
||||
// Oberfläche: Ohne das gäbe es einen Knopf, der nur Fehler erzeugt.
|
||||
func (c *Client) CanConvert() bool {
|
||||
return c != nil && (c.provider == "collabora" || c.provider == "onlyoffice")
|
||||
}
|
||||
|
||||
// ── Collabora ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *Client) convertCollabora(ctx context.Context, name string, body io.Reader) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
// Der Feldname ist vorgeschrieben („data"), der Dateiname bestimmt, wie
|
||||
// Collabora den Typ erkennt – ohne Endung rät es und liegt daneben.
|
||||
part, err := mw.CreateFormFile("data", name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, body); err != nil {
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/cool/convert-to/pdf", &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("convert: office server answered %d", resp.StatusCode)
|
||||
}
|
||||
// Collabora antwortet mit dem PDF selbst, nicht mit einer Auskunft.
|
||||
return readPDF(resp.Body)
|
||||
}
|
||||
|
||||
// ── OnlyOffice / Euro-Office ────────────────────────────────────────────────
|
||||
|
||||
type convertResponse struct {
|
||||
FileURL string `json:"fileUrl"`
|
||||
FileType string `json:"fileType"`
|
||||
Percent int `json:"percent"`
|
||||
EndConvert bool `json:"endConvert"`
|
||||
// Error ist eine Zahl, keine Meldung: -8 heißt „Signatur fehlt oder ist
|
||||
// falsch", -4 „Quelle nicht erreichbar". Die Zahl steht deshalb im
|
||||
// Fehlertext, sonst sucht man im Log des anderen Servers.
|
||||
Error int `json:"error"`
|
||||
}
|
||||
|
||||
func (c *Client) convertOnlyOffice(ctx context.Context, ext, key, name, src string) ([]byte, error) {
|
||||
if src == "" {
|
||||
return nil, fmt.Errorf("convert: no source address for the document server")
|
||||
}
|
||||
payload := map[string]any{
|
||||
// Synchron: Die Umwandlung dauert Sekunden, und ein zweiter Aufruf zum
|
||||
// Nachfragen wäre ein Zustand mehr, den jemand verwalten muss.
|
||||
"async": false,
|
||||
"filetype": strings.TrimPrefix(ext, "."),
|
||||
"key": key,
|
||||
"outputtype": "pdf",
|
||||
"title": name,
|
||||
"url": src,
|
||||
}
|
||||
if c.jwtSecret != "" {
|
||||
// Die Signatur trägt genau die Nutzlast, die auch gesendet wird –
|
||||
// gemessen: ohne sie antwortet der Server `{"error":-8}`.
|
||||
payload["token"] = signJWT(c.jwtSecret, payload)
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/ConvertService.ashx", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Ohne diesen Kopf antwortet der Server in XML.
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("convert: document server answered %d", resp.StatusCode)
|
||||
}
|
||||
var out convertResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("convert: unreadable answer: %w", err)
|
||||
}
|
||||
if out.Error != 0 {
|
||||
return nil, fmt.Errorf("convert: document server error %d", out.Error)
|
||||
}
|
||||
if !out.EndConvert || out.FileURL == "" {
|
||||
return nil, fmt.Errorf("convert: document server did not finish (%d%%)", out.Percent)
|
||||
}
|
||||
|
||||
// **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** Sie kommt aus
|
||||
// der Antwort eines anderen Servers, und wir würden sie gleich abrufen –
|
||||
// das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf
|
||||
// 169.254.169.254 würde. Der Wähler des Transports lässt ohnehin nur
|
||||
// Adressen aus OFFICE_ALLOWED_HOSTS zu; hier steht die Prüfung trotzdem
|
||||
// noch einmal ausdrücklich, weil eine klare Meldung mehr wert ist als ein
|
||||
// abgewiesener Verbindungsversuch tief im Netzstapel.
|
||||
if err := c.sameHostAsServer(out.FileURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
get, err := http.NewRequestWithContext(ctx, http.MethodGet, out.FileURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res2, err := c.http.Do(get)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch result: %w", err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch result: answered %d", res2.StatusCode)
|
||||
}
|
||||
return readPDF(res2.Body)
|
||||
}
|
||||
|
||||
// sameHostAsServer prüft, dass eine Adresse auf denselben Rechner zeigt wie
|
||||
// der Office-Server selbst.
|
||||
func (c *Client) sameHostAsServer(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: unreadable result address")
|
||||
}
|
||||
base, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: OFFICE_URL is not a valid address")
|
||||
}
|
||||
if !strings.EqualFold(u.Hostname(), base.Hostname()) {
|
||||
return fmt.Errorf("convert: result address points elsewhere (%q), refused", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── gemeinsam ────────────────────────────────────────────────────────────────
|
||||
|
||||
// maxPDFBytes begrenzt, was als Ergebnis angenommen wird. Ohne Grenze
|
||||
// bestimmte der andere Server, wie viel Speicher wir belegen.
|
||||
const maxPDFBytes = 256 << 20
|
||||
|
||||
func readPDF(r io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxPDFBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read result: %w", err)
|
||||
}
|
||||
if len(data) > maxPDFBytes {
|
||||
return nil, fmt.Errorf("read result: converted file is too large")
|
||||
}
|
||||
// Kein Vertrauen auf den Content-Type: Collabora liefert
|
||||
// `application/octet-stream`. Die ersten Bytes sind die Auskunft, die
|
||||
// zählt – und sie verhindert, dass eine Fehlerseite als PDF abgelegt wird.
|
||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
return nil, fmt.Errorf("read result: answer is not a PDF")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// signJWT baut ein HS256-Token über die Nutzlast.
|
||||
//
|
||||
// Von Hand statt mit einer Bibliothek: Es sind zwölf Zeilen, und die einzige
|
||||
// Alternative wäre eine weitere Abhängigkeit für einen einzigen Aufruf.
|
||||
func signJWT(secret string, payload map[string]any) string {
|
||||
claims := make(map[string]any, len(payload)+1)
|
||||
for k, v := range payload {
|
||||
claims[k] = v
|
||||
}
|
||||
claims["exp"] = time.Now().Add(jwtLifetime).Unix()
|
||||
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
head := enc(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||
body := enc(claims)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(head + "." + body))
|
||||
return head + "." + body + "." +
|
||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
|
||||
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
|
||||
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
|
||||
|
||||
func TestSignJWTIsVerifiable(t *testing.T) {
|
||||
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
|
||||
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(parts[0] + "." + parts[1]))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if parts[2] != want {
|
||||
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
|
||||
}
|
||||
|
||||
head, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Kopf nicht dekodierbar: %v", err)
|
||||
}
|
||||
var h map[string]string
|
||||
if err := json.Unmarshal(head, &h); err != nil {
|
||||
t.Fatalf("Kopf ist kein JSON: %v", err)
|
||||
}
|
||||
if h["alg"] != "HS256" {
|
||||
t.Errorf("alg = %q, erwartet HS256", h["alg"])
|
||||
}
|
||||
|
||||
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(body, &claims); err != nil {
|
||||
t.Fatalf("Nutzlast ist kein JSON: %v", err)
|
||||
}
|
||||
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
|
||||
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
|
||||
}
|
||||
// Ohne Ablauf gälte ein abgefangenes Token für immer.
|
||||
if _, ok := claims["exp"]; !ok {
|
||||
t.Error("exp fehlt – das Token liefe nie ab")
|
||||
}
|
||||
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
|
||||
if _, ok := claims["token"]; ok {
|
||||
t.Error("die Signatur signiert sich selbst mit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPDFRejectsNonPDF(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
|
||||
"leer": "",
|
||||
"JSON-Fehler": `{"error":-4}`,
|
||||
"fast ein PDF": "%PD",
|
||||
}
|
||||
for name, body := range cases {
|
||||
if _, err := readPDF(strings.NewReader(body)); err == nil {
|
||||
t.Errorf("%s wurde als PDF angenommen", name)
|
||||
}
|
||||
}
|
||||
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
|
||||
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
|
||||
t.Errorf("echtes PDF abgelehnt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
|
||||
c := &Client{baseURL: "http://office:80"}
|
||||
|
||||
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
|
||||
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
|
||||
foreign := []string{
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost:8080/api/v1/admin/users",
|
||||
"http://office.evil.example.com/x.pdf",
|
||||
"file:///etc/passwd",
|
||||
"://kaputt",
|
||||
}
|
||||
for _, raw := range foreign {
|
||||
if err := c.sameHostAsServer(raw); err == nil {
|
||||
t.Errorf("fremde Adresse angenommen: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ok := range []string{
|
||||
"http://office/cache/files/x.pdf",
|
||||
"http://office:80/cache/files/x.pdf",
|
||||
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
|
||||
} {
|
||||
if err := c.sameHostAsServer(ok); err != nil {
|
||||
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`Rechnung "2026".docx`: "Rechnung 2026",
|
||||
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
|
||||
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
|
||||
".odt": ".odt",
|
||||
"": "document",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFilename(in); got != want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,7 @@ type Client struct {
|
||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||
// keinem Browser.
|
||||
publicBase string
|
||||
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
|
||||
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
|
||||
jwtSecret string
|
||||
http *http.Client
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
actions map[string]string // ".docx|edit" -> urlsrc
|
||||
@@ -65,7 +62,7 @@ type Client struct {
|
||||
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
||||
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
||||
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
||||
@@ -99,7 +96,6 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
|
||||
provider: provider,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
publicBase: strings.TrimRight(publicURL, "/"),
|
||||
jwtSecret: jwtSecret,
|
||||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||
}, nil
|
||||
}
|
||||
@@ -128,15 +124,33 @@ func (c *Client) Provider() string { return c.provider }
|
||||
//
|
||||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Am Finger zuerst die Handy-Fassung.
|
||||
//
|
||||
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
|
||||
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
|
||||
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
|
||||
// Schirme gebaute Oberfläche. Aether hat bisher immer die
|
||||
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
|
||||
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
|
||||
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
|
||||
// wir selbst falsch bestellt hatten.
|
||||
//
|
||||
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
|
||||
// Eintrag, und es bleibt beim bisherigen Verhalten.
|
||||
order := []string{"edit", "view"}
|
||||
if !write {
|
||||
switch {
|
||||
case mobile && write:
|
||||
order = []string{"mobileEdit", "edit", "mobileView", "view"}
|
||||
case mobile:
|
||||
order = []string{"mobileView", "view", "mobileEdit", "edit"}
|
||||
case !write:
|
||||
order = []string{"view", "edit"}
|
||||
}
|
||||
for _, action := range order {
|
||||
|
||||
@@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
write := access.Permission == acl.PermWrite
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
||||
// Finger oder Maus? Das entscheidet die Oberfläche, nicht der Server:
|
||||
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus.
|
||||
// Deshalb fragen wir nicht die Kennung des Browsers, sondern lassen die
|
||||
// Anwendung sagen, was sie über ihr Zeigegerät weiß.
|
||||
mobile := r.URL.Query().Get("mobile") == "1"
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write, mobile)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
|
||||
//
|
||||
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
|
||||
// bleibt einen Knopfdruck entfernt.
|
||||
//
|
||||
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
|
||||
//
|
||||
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
|
||||
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
|
||||
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
|
||||
// Frage des Aufräumens, sondern des Schlüssels.
|
||||
//
|
||||
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
|
||||
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
|
||||
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
|
||||
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
|
||||
//
|
||||
// ── Was das für die Platte heißt ────────────────────────────────────────────
|
||||
//
|
||||
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
|
||||
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
|
||||
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
|
||||
// den Ordner – beides ist nötig, und beides einzeln geprüft.
|
||||
|
||||
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
|
||||
// ebenfalls kennen müssen.
|
||||
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
|
||||
|
||||
// PreviewPrefix ist der Ordner, den der GC durchgeht.
|
||||
const PreviewPrefix = "previews"
|
||||
|
||||
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
|
||||
//
|
||||
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
|
||||
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
|
||||
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
|
||||
const maxPreviewSource = 100 << 20
|
||||
|
||||
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
|
||||
//
|
||||
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
|
||||
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
|
||||
// Umwandlung, nicht die Richtigkeit.
|
||||
var previewLocks sync.Map // fileID -> *sync.Mutex
|
||||
|
||||
func lockPreview(id string) func() {
|
||||
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := m.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// GET /api/v1/files/{id}/preview.pdf
|
||||
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.Enabled() || !h.client.CanConvert() {
|
||||
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
|
||||
return
|
||||
}
|
||||
f, userID, ok := h.resolveSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !Editable(f.Name) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
|
||||
return
|
||||
}
|
||||
if f.Size > maxPreviewSource {
|
||||
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
|
||||
// Editor anbieten soll oder einen Fehler zeigt.
|
||||
response.Error(w, http.StatusRequestEntityTooLarge,
|
||||
"this document is too large to be shown as a PDF")
|
||||
return
|
||||
}
|
||||
|
||||
unlock := lockPreview(f.ID.String())
|
||||
defer unlock()
|
||||
|
||||
key := PreviewKey(f.ID.String())
|
||||
have, err := h.files.PreviewVersion(r.Context(), f.ID)
|
||||
if err != nil && !errors.Is(err, files.ErrNotFound) {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
if have == f.Version {
|
||||
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
|
||||
defer rc.Close()
|
||||
servePDF(w, f.Name, f.Version)
|
||||
_, _ = io.Copy(w, rc)
|
||||
return
|
||||
}
|
||||
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
|
||||
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
|
||||
// sie neu zu erzeugen.
|
||||
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
|
||||
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
|
||||
}
|
||||
|
||||
pdf, err := h.convert(r.Context(), f, userID)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("office preview: Umwandlung fehlgeschlagen")
|
||||
response.Error(w, http.StatusBadGateway, "the document could not be converted")
|
||||
return
|
||||
}
|
||||
|
||||
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
|
||||
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
|
||||
// ohne es zu merken.
|
||||
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
|
||||
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
|
||||
}
|
||||
|
||||
servePDF(w, f.Name, f.Version)
|
||||
_, _ = w.Write(pdf)
|
||||
}
|
||||
|
||||
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
|
||||
// versteht.
|
||||
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
|
||||
ext := Extension(f.Name)
|
||||
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
|
||||
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
|
||||
// alte Dokument aus.
|
||||
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
|
||||
|
||||
switch h.client.Provider() {
|
||||
case "collabora":
|
||||
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
|
||||
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
|
||||
// Erreichbarkeit – und ein Token mehr in der Welt.
|
||||
rc, err := h.blobs.Get(ctx, f.StorageKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
|
||||
|
||||
default:
|
||||
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
|
||||
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
|
||||
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
|
||||
token, _ := h.signer.Issue(f.ID, userID, false)
|
||||
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
|
||||
h.wopiURL, f.ID, token)
|
||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func servePDF(w http.ResponseWriter, name string, version int) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
|
||||
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
|
||||
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
|
||||
// der Browser holt es nach einer Änderung also von selbst neu.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
|
||||
}
|
||||
|
||||
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
|
||||
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
|
||||
if i := strings.LastIndexByte(name, '.'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
|
||||
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
|
||||
//
|
||||
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
|
||||
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
|
||||
// Token für genau eine Datei.
|
||||
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
|
||||
// Datei gibt.
|
||||
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
return f, user.ID, true
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// checksumOf liest die Datei einmal ganz und liefert Prüfsum und Größe.
|
||||
//
|
||||
// Ohne sie ginge es schneller – aber eine Datei ohne Prüfsum in der
|
||||
// Datenbank ist eine Datei, deren Beschädigung beim Herunterladen niemand
|
||||
// bemerkt. Das ist der Punkt, an dem Aether sonst gerade nicht spart.
|
||||
// Der Anfang der Datei genügt zur Typerkennung – http.DetectContentType
|
||||
// sieht sich ohnehin nur die ersten 512 Bytes an.
|
||||
const sniffLen = 512
|
||||
|
||||
func (s *Scanner) checksumOf(ctx context.Context, key string) (sum string, size int64, head []byte, err error) {
|
||||
rc, err := s.storage.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
h := sha256.New()
|
||||
// Die Datei wird für die Prüfsumme ohnehin einmal ganz gelesen; den
|
||||
// Anfang dabei mitzunehmen kostet nichts und erspart einen zweiten Weg
|
||||
// über die Platte.
|
||||
kopf := &bytes.Buffer{}
|
||||
n, err := io.Copy(h, io.TeeReader(rc, &begrenzterSchreiber{buf: kopf, rest: sniffLen}))
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, kopf.Bytes(), nil
|
||||
}
|
||||
|
||||
// begrenzterSchreiber nimmt nur die ersten rest Bytes an und wirft den Rest
|
||||
// weg – ohne das läge bei einer 5-GB-Datei alles im Arbeitsspeicher.
|
||||
type begrenzterSchreiber struct {
|
||||
buf *bytes.Buffer
|
||||
rest int
|
||||
}
|
||||
|
||||
func (w *begrenzterSchreiber) Write(p []byte) (int, error) {
|
||||
if w.rest > 0 {
|
||||
n := min(w.rest, len(p))
|
||||
w.buf.Write(p[:n])
|
||||
w.rest -= n
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// mimeOf bestimmt den Dateityp – erst über die Endung, sonst über den
|
||||
// Inhalt.
|
||||
//
|
||||
// Der Umweg über den Inhalt ist nötig, nicht schmückend: Das Alpine-Abbild
|
||||
// bringt keine /etc/mime.types mit, und Gos eingebaute Liste kennt gerade
|
||||
// einmal ein Dutzend Endungen – .txt gehört nicht dazu. Ohne den zweiten
|
||||
// Anlauf bekäme jede Textdatei "application/octet-stream" und würde in der
|
||||
// Vorschau nicht angezeigt.
|
||||
//
|
||||
// Beim Hochladen fällt das nie auf, weil der Browser den Typ mitschickt.
|
||||
// Eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt.
|
||||
func mimeOf(name string, head []byte) string {
|
||||
if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); t != "" {
|
||||
return ohneParameter(t)
|
||||
}
|
||||
if len(head) > 0 {
|
||||
return ohneParameter(http.DetectContentType(head))
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// "text/plain; charset=utf-8" → "text/plain", so wie es der Upload-Weg auch
|
||||
// speichert.
|
||||
func ohneParameter(t string) string {
|
||||
if i := strings.Index(t, ";"); i > 0 {
|
||||
return strings.TrimSpace(t[:i])
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resolveOwner findet heraus, wem eine gefundene Datei gehört: das Konto
|
||||
// mit diesem Namen, oder die Gruppe mit dieser Kennung.
|
||||
func (s *Scanner) resolveOwner(ctx context.Context, root, owner string) (ownerID, groupID *uuid.UUID, err error) {
|
||||
if root == "groups" {
|
||||
id, err := uuid.Parse(owner)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("keine Gruppenkennung: %q", owner)
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM groups WHERE id = $1)`, id).Scan(&exists); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("Gruppe %s gibt es nicht", owner)
|
||||
}
|
||||
return nil, &id, nil
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE username = $1`, owner).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, fmt.Errorf("Konto %q gibt es nicht", owner)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &id, nil, nil
|
||||
}
|
||||
|
||||
// ensureFolders legt die Ordnerkette an, in der die Datei liegt, und liefert
|
||||
// den innersten Ordner. Ein Ordner, den es schon gibt, wird benutzt.
|
||||
func (s *Scanner) ensureFolders(ctx context.Context, ownerID, groupID *uuid.UUID, folderPath string) (*uuid.UUID, error) {
|
||||
if folderPath == "" {
|
||||
return nil, nil // liegt direkt im Wurzelverzeichnis
|
||||
}
|
||||
var parent *uuid.UUID
|
||||
for _, name := range strings.Split(folderPath, "/") {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
// COALESCE statt zweier Abfragen: Ein Ordner hängt entweder an einem
|
||||
// Konto oder an einer Gruppe, nie an beidem.
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id FROM folders
|
||||
WHERE COALESCE(owner_id, group_id) = COALESCE($1::uuid, $2::uuid)
|
||||
AND name = $3
|
||||
AND parent_id IS NOT DISTINCT FROM $4`,
|
||||
ownerID, groupID, name, parent,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO folders (owner_id, group_id, parent_id, name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
ownerID, groupID, parent, name,
|
||||
).Scan(&id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ordner %q: %w", name, err)
|
||||
}
|
||||
copy := id
|
||||
parent = ©
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// adopt macht aus einer Datei auf der Platte einen Entry in Aether.
|
||||
func (s *Scanner) adopt(ctx context.Context, root, owner, folder, name, key string) error {
|
||||
ownerID, groupID, err := s.resolveOwner(ctx, root, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := s.ensureFolders(ctx, ownerID, groupID, folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, size, head, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ownerID, groupID, folderID, name, mimeOf(name, head), size, sum, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Entry anlegen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// correct übernimmt Größe und Prüfsum einer von außen ersetzten Datei.
|
||||
func (s *Scanner) correct(ctx context.Context, id uuid.UUID, key string) error {
|
||||
sum, size, _, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(ctx,
|
||||
`UPDATE files SET size = $2, checksum = $3, updated_at = now() WHERE id = $1`,
|
||||
id, size, sum)
|
||||
return err
|
||||
}
|
||||
|
||||
// remove räumt einen Entry weg, dessen Datei nicht mehr exists ist – und mit
|
||||
// ihm alles, was nur wegen dieser Datei existierte: frühere Fassungen,
|
||||
// Vorschaubild, umgewandelte Ansicht.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: erst die Schlüssel der früheren Fassungen
|
||||
// sample, dann die Zeile löschen (die Kaskade nimmt die Versionszeilen mit),
|
||||
// dann die Bytes. Andersherum bliebe bei einem Abbruch eine Zeile stehen, die
|
||||
// auf nichts mehr zeigt – also genau das, was hier aufgeräumt werden soll.
|
||||
func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error {
|
||||
rows, err := s.db.Query(ctx, `SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("frühere Fassungen lesen: %w", err)
|
||||
}
|
||||
var versionKeys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
versionKeys = append(versionKeys, k)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("Entry remove: %w", err)
|
||||
}
|
||||
|
||||
// Ab hier ist der Entry weg; was jetzt noch scheitert, ist ein Rest auf
|
||||
// der Platte, den der GC beim nächsten Lauf ohnehin einsammelt.
|
||||
for _, k := range versionKeys {
|
||||
_ = s.storage.Delete(ctx, k)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Der Abgleich ist das einzige Stück Aether, das **hart** löscht:
|
||||
// `DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang über
|
||||
// die Platte nicht gesehen wurde.
|
||||
//
|
||||
// Der Ernstfall ist nicht ausgedacht: `LocalFS.Walk` gibt für ein nicht
|
||||
// vorhandenes Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam.
|
||||
// Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit
|
||||
// `?anwenden=true` löschte den ganzen Bestand aus der Datenbank.
|
||||
//
|
||||
// Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der
|
||||
// gefährlicheren von beiden Stellen.
|
||||
|
||||
func TestNotbremseBeiLeeremSpeicher(t *testing.T) {
|
||||
// Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar.
|
||||
grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false)
|
||||
if grund == "" {
|
||||
t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen")
|
||||
}
|
||||
if uebersteuerbar {
|
||||
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||
}
|
||||
// Auch mit ausdruecklicher Bestaetigung nicht.
|
||||
if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" {
|
||||
t.Fatal("auch mit Bestaetigung muss die Notbremse halten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeereInstallationDarfDurch(t *testing.T) {
|
||||
// Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu
|
||||
// verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie
|
||||
// abgleichen.
|
||||
if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" {
|
||||
t.Fatalf("leere Installation darf nicht bremsen: %s", grund)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMengenbremseLaesstSichBestaetigen(t *testing.T) {
|
||||
// Mehr als die Hälfte auf einmal: erst nachfragen.
|
||||
grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false)
|
||||
if grund == "" {
|
||||
t.Fatal("mehr als die Haelfte muss nachfragen")
|
||||
}
|
||||
if !uebersteuerbar {
|
||||
t.Fatal("die Mengenbremse muss sich bestaetigen lassen")
|
||||
}
|
||||
if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" {
|
||||
t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) {
|
||||
// Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die
|
||||
// Funktion unbrauchbar – und jemand baute die Bremse wieder aus.
|
||||
for _, f := range []struct {
|
||||
gescannt, lebende, fallen int
|
||||
}{
|
||||
{1000, 1000, 1},
|
||||
{1000, 1000, 17},
|
||||
{1000, 1000, 500}, // genau die Hälfte: noch erlaubt
|
||||
{1, 1, 0},
|
||||
} {
|
||||
if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" {
|
||||
t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein
|
||||
// Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch
|
||||
// da sein.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/
|
||||
func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var nutzer uuid.UUID
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8],
|
||||
).Scan(&nutzer); err != nil {
|
||||
t.Fatalf("konto: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`,
|
||||
nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(),
|
||||
); err != nil {
|
||||
t.Fatalf("datei: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das
|
||||
// sieht der Server, wenn der Bind-Mount nicht hochkam.
|
||||
wurzel := t.TempDir()
|
||||
st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp"))
|
||||
if err != nil {
|
||||
t.Fatalf("speicher: %v", err)
|
||||
}
|
||||
|
||||
scanner := New(pool, files.NewStore(pool), st)
|
||||
bericht, err := scanner.Apply(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("anwenden: %v", err)
|
||||
}
|
||||
|
||||
if !bericht.Aborted {
|
||||
t.Fatal("der Lauf haette abbrechen muessen")
|
||||
}
|
||||
if bericht.Removed != 0 {
|
||||
t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed)
|
||||
}
|
||||
if bericht.Overridable {
|
||||
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||
}
|
||||
|
||||
var uebrig int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||
t.Fatalf("zaehlen: %v", err)
|
||||
}
|
||||
if uebrig != 5 {
|
||||
t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig)
|
||||
}
|
||||
|
||||
// Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen.
|
||||
if _, err := scanner.Apply(ctx, true); err != nil {
|
||||
t.Fatalf("anwenden mit Bestaetigung: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||
t.Fatalf("zaehlen: %v", err)
|
||||
}
|
||||
if uebrig != 5 {
|
||||
t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung,
|
||||
// die der GC nicht abdeckt.
|
||||
//
|
||||
// Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr
|
||||
// gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für
|
||||
// Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer
|
||||
// Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar
|
||||
// – und der GC hält sie nach 24 Stunden für Reste.
|
||||
//
|
||||
// Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf,
|
||||
// der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen.
|
||||
// Hier geht es um Dateien.
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die
|
||||
// Antwort nicht ins Uferlose wächst.
|
||||
const (
|
||||
maxSamples = 200
|
||||
maxDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank.
|
||||
// Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen
|
||||
// Verzeichnis sie liegt.
|
||||
KindNew Kind = "new"
|
||||
// KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt
|
||||
// Vorschaubild und früheren Fassungen.
|
||||
KindOrphaned Kind = "orphaned"
|
||||
// KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei
|
||||
// von außen ersetzt. Apply übernimmt Größe und Prüfsumme.
|
||||
KindChanged Kind = "changed"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Kind Kind `json:"kind"`
|
||||
StorageKey string `json:"storage_key"`
|
||||
// Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte.
|
||||
Path string `json:"path"`
|
||||
Owner string `json:"owner"`
|
||||
Size int64 `json:"size"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
New int `json:"new"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
Changed int `json:"changed"`
|
||||
Scanned int `json:"scanned"`
|
||||
Samples []Entry `json:"samples"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
// AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true"
|
||||
// ohne Grund lässt genau die Frage offen, die man dann hat.
|
||||
AbortReason string `json:"abort_reason,omitempty"`
|
||||
// Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt
|
||||
// sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen
|
||||
// Knopf anbieten, der nichts bewirkt.
|
||||
Overridable bool `json:"overridable,omitempty"`
|
||||
|
||||
// Nur bei Apply gefüllt.
|
||||
Adopted int `json:"adopted,omitempty"`
|
||||
Removed int `json:"removed,omitempty"`
|
||||
Corrected int `json:"corrected,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
db *pgxpool.Pool
|
||||
store *files.Store
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner {
|
||||
return &Scanner{db: db, store: store, storage: st}
|
||||
}
|
||||
|
||||
// known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über
|
||||
// den Ablagepfad auffindbar.
|
||||
type known struct {
|
||||
id uuid.UUID
|
||||
size int64
|
||||
deleted bool
|
||||
seen bool
|
||||
}
|
||||
|
||||
func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read known files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]*known, 1024)
|
||||
for rows.Next() {
|
||||
var k known
|
||||
var key string
|
||||
if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil {
|
||||
return nil, fmt.Errorf("scan file row: %w", err)
|
||||
}
|
||||
row := k
|
||||
out[key] = &row
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// splitKey liest aus einem Ablagepfad, wohin die Datei gehört:
|
||||
//
|
||||
// users/{name}/home/{ordner...}/{datei}
|
||||
// groups/{id}/home/{ordner...}/{datei}
|
||||
//
|
||||
// Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp)
|
||||
// ist kein Nutzerinhalt und wird nicht angefasst.
|
||||
func splitKey(key string) (root, owner, folderPath, name string, ok bool) {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) < 4 {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[0] != "users" && parts[0] != "groups" {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[2] != "home" {
|
||||
return "", "", "", "", false // .trash und .versions gehören dem System
|
||||
}
|
||||
name = parts[len(parts)-1]
|
||||
folderPath = strings.Join(parts[3:len(parts)-1], "/")
|
||||
return parts[0], parts[1], folderPath, name, name != ""
|
||||
}
|
||||
|
||||
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
||||
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) }
|
||||
|
||||
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
||||
// Einträge.
|
||||
// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse.
|
||||
func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) {
|
||||
return s.run(ctx, true, trotzdem)
|
||||
}
|
||||
|
||||
func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
||||
defer cancel()
|
||||
|
||||
known, err := s.knownFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null,
|
||||
// und kein Aufrufer braucht dafür einen Sonderfall.
|
||||
rep := &Report{Samples: []Entry{}}
|
||||
sample := func(e Entry) {
|
||||
if len(rep.Samples) < maxSamples {
|
||||
rep.Samples = append(rep.Samples, e)
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range []string{"users/", "groups/"} {
|
||||
err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
if ctx.Err() != nil {
|
||||
rep.Aborted = true
|
||||
return ctx.Err()
|
||||
}
|
||||
root, owner, folder, name, ok := splitKey(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rep.Scanned++
|
||||
|
||||
if row, exists := known[key]; exists {
|
||||
row.seen = true
|
||||
if row.deleted {
|
||||
return nil // liegt im Papierkorb – kein Widerspruch
|
||||
}
|
||||
if row.size != info.Size {
|
||||
rep.Changed++
|
||||
e := Entry{Kind: KindChanged, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.correct(ctx, row.id, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Corrected++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rep.New++
|
||||
e := Entry{Kind: KindNew, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.adopt(ctx, root, owner, folder, name, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Adopted++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
||||
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
||||
// pauschal für verwaist zu erklären.
|
||||
// **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in
|
||||
// der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die
|
||||
// Hälfte schon weg ist, und eine Bremse käme zu spät.
|
||||
type waise struct {
|
||||
key string
|
||||
id uuid.UUID
|
||||
}
|
||||
var waisen []waise
|
||||
var lebende int
|
||||
for key, row := range known {
|
||||
if !row.deleted {
|
||||
lebende++
|
||||
}
|
||||
if row.seen || row.deleted {
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, ok := splitKey(key); !ok {
|
||||
if _, err := s.storage.Stat(ctx, key); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
waisen = append(waisen, waise{key: key, id: row.id})
|
||||
}
|
||||
rep.Orphaned = len(waisen)
|
||||
|
||||
if apply {
|
||||
if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" {
|
||||
rep.Aborted = true
|
||||
rep.AbortReason = grund
|
||||
rep.Overridable = uebersteuerbar
|
||||
log.Ctx(ctx).Error().
|
||||
Int("scanned", rep.Scanned).
|
||||
Int("lebende_eintraege", lebende).
|
||||
Int("wuerden_fallen", len(waisen)).
|
||||
Str("grund", grund).
|
||||
Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus")
|
||||
// Die Waisen trotzdem in die Stichprobe, damit man im Bericht
|
||||
// sieht, worum es gegangen wäre.
|
||||
for _, o := range waisen {
|
||||
sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key})
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range waisen {
|
||||
e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}
|
||||
if apply {
|
||||
if err := s.remove(ctx, o.id); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Removed++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── Die Bremsen ──────────────────────────────────────────────────────────────
|
||||
|
||||
// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr
|
||||
// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist
|
||||
// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner
|
||||
// entfernt hat, bestätigt einmal und kommt durch.
|
||||
const mengenAnteil = 2
|
||||
|
||||
// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe-
|
||||
// wert heißt: ja.
|
||||
//
|
||||
// **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen
|
||||
// Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben
|
||||
// denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC
|
||||
// löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt
|
||||
// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die
|
||||
// Datei gab. Die gefährlichere Seite war die ungesicherte.
|
||||
//
|
||||
// Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht
|
||||
// vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam,
|
||||
// oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede
|
||||
// Datei als verwaist.
|
||||
func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) {
|
||||
// **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen,
|
||||
// obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der
|
||||
// verschwunden ist, das ist ein Speicher, der nicht da war.
|
||||
if gescannt == 0 && lebende > 0 {
|
||||
return "auf der Platte wurde keine einzige Datei gefunden, " +
|
||||
"obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " +
|
||||
"nicht eingehaengt. Es wurde nichts geloescht.", false
|
||||
}
|
||||
|
||||
// **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die
|
||||
// Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor.
|
||||
if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende {
|
||||
return fmt.Sprintf(
|
||||
"%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+
|
||||
"Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.",
|
||||
wuerdenFallen, lebende), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -13,10 +12,12 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
@@ -29,6 +30,10 @@ const (
|
||||
shareMaxFailures = 10
|
||||
shareWindow = 15 * time.Minute
|
||||
shareLockout = 15 * time.Minute
|
||||
|
||||
// Zehn Jahre. Grosszügig gemeint – es geht nicht darum, kurze Fristen zu
|
||||
// erzwingen, sondern den Überlauf abzufangen.
|
||||
maxExpiryHours = 10 * 365 * 24
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -36,12 +41,16 @@ type Handler struct {
|
||||
fileStore *files.Store
|
||||
storage storage.Storage
|
||||
auditor *audit.Logger
|
||||
limiter *middleware.FailureLimiter
|
||||
// acl löst auf, in wessen Namen die Datei liegt. Ohne ihn ging nur, was
|
||||
// dem Anfragenden persönlich gehört – siehe Create.
|
||||
acl *acl.Store
|
||||
limiter *middleware.FailureLimiter
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
|
||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler {
|
||||
return &Handler{
|
||||
store: store, fileStore: fileStore, storage: st, auditor: auditor,
|
||||
acl: aclStore,
|
||||
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
|
||||
}
|
||||
}
|
||||
@@ -54,7 +63,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); err != nil {
|
||||
// **Prinzipal statt Konto.** Vorher stand hier `user.ID` – damit liess sich
|
||||
// eine Datei in einem Gruppenordner nicht per Link teilen, auch nicht von
|
||||
// einem Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete
|
||||
// „file not found", was zwar sicher, aber schlicht falsch war: Der
|
||||
// Empfänger eines Links sieht ohnehin nur die Datei, nie den Ordner
|
||||
// darum.
|
||||
//
|
||||
// **Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst,
|
||||
// die Datei öffentlich zu machen. Wer sie nur geliehen lesen darf, soll
|
||||
// sie nicht weiterreichen können.
|
||||
zugriff, ok := h.resolve(w, r, fileID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, zugriff.OwnerID); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
@@ -76,6 +99,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var expiresAt *time.Time
|
||||
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
||||
// Obergrenze, weil `time.Duration` in Nanosekunden rechnet: Ab etwa
|
||||
// 2,6 Millionen Stunden läuft int64 über, die Dauer wird **negativ**,
|
||||
// und der Link ist in dem Moment abgelaufen, in dem er entsteht – ohne
|
||||
// Fehlermeldung. Wer eine unsinnige Zahl schickt, soll das erfahren,
|
||||
// statt einen toten Link zu bekommen.
|
||||
if *body.ExpiresIn > maxExpiryHours {
|
||||
response.Error(w, http.StatusBadRequest,
|
||||
"expires_in_hours is too large (at most 10 years)")
|
||||
return
|
||||
}
|
||||
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
@@ -229,13 +262,49 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
// abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das
|
||||
// Rennen fast immer und der Zähler blieb auf 0 stehen.
|
||||
zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||
go func() {
|
||||
bg.Go("Freigabe-Zaehler", func() {
|
||||
defer abbrechen()
|
||||
h.store.IncrementDownloads(zaehlCtx, token)
|
||||
}()
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", f.MimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
_, _ = io.Copy(w, rc)
|
||||
// Kein Typ-Raten. `attachment` trägt die Hauptlast, aber der angemeldete
|
||||
// Weg setzt beides – und dies ist der Weg nach draussen.
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// **Dieselbe Prüfung wie beim angemeldeten Download.** Bis zum 25.09.2026
|
||||
// lief hier ein blankes io.Copy: Wer die Datei über einen Link bekam,
|
||||
// bekam sie ungeprüft. Ausgerechnet auf dem Weg, auf dem sie das Haus
|
||||
// verlässt – und wo der Empfänger es am wenigsten merken kann.
|
||||
files.ServeVerified(r.Context(), w, rc, f.Checksum, files.VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// resolve klärt, in wessen Namen die Datei liegt, und verlangt Schreibrecht.
|
||||
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, fileID uuid.UUID) (acl.Access, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if h.acl == nil {
|
||||
return acl.Access{OwnerID: user.ID, Permission: acl.PermWrite}, true
|
||||
}
|
||||
a, err := h.acl.ResolveFile(r.Context(), user.ID, fileID)
|
||||
switch {
|
||||
case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess):
|
||||
// 404 statt 403, wie überall: Ein „verboten" verriete, dass es die ID
|
||||
// gibt.
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return acl.Access{}, false
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return acl.Access{}, false
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
response.Error(w, http.StatusForbidden, "you only have read access to this file")
|
||||
return acl.Access{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Ein Freigabelink ist der einzige Weg, auf dem eine Datei dieses Haus ohne
|
||||
// Anmeldung verlässt. Wer das Token hat, ist drin – also entscheidet sich hier
|
||||
// alles an zwei Fragen: Gilt der Link noch, und gehört er dem, der ihn
|
||||
// anfasst?
|
||||
//
|
||||
// Beides steckt nicht in einer if-Zeile, sondern in WHERE-Klauseln und einem
|
||||
// Zeitvergleich. Ein Fehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
// läuft, sie liefert nur zu viel.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/sharing/
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("konto %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||
owner, name, "k/"+uuid.NewString(),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("datei %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestAbgelaufenerLinkGiltNichtMehr(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
gestern := time.Now().Add(-1 * time.Hour)
|
||||
abgelaufen, err := s.Create(ctx, f, anna, "", &gestern)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, abgelaufen.Token); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("abgelaufener Link muss ErrExpired liefern, kam: %v", err)
|
||||
}
|
||||
|
||||
// Gegenprobe – sonst prüfte der Test nur, dass irgendetwas fehlschlägt.
|
||||
morgen := time.Now().Add(1 * time.Hour)
|
||||
gueltig, err := s.Create(ctx, f, anna, "", &morgen)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, gueltig.Token); err != nil {
|
||||
t.Fatalf("gültiger Link muss durchgehen, kam: %v", err)
|
||||
}
|
||||
|
||||
// Ohne Ablauf gilt er unbegrenzt.
|
||||
ewig, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, ewig.Token); err != nil {
|
||||
t.Fatalf("Link ohne Ablauf muss gelten, kam: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFremderLinkLaesstSichNichtLoeschen(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
bert := konto(t, pool, ctx, "bert")
|
||||
f := datei(t, pool, ctx, anna, "Gehaltsabrechnung.pdf")
|
||||
|
||||
link, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
// Bert kennt die ID – etwa, weil sie einmal irgendwo auftauchte.
|
||||
if err := s.Delete(ctx, link.ID, bert); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Bert darf Annas Link nicht löschen, kam: %v", err)
|
||||
}
|
||||
// Und er ist wirklich noch da.
|
||||
if _, _, err := s.GetByToken(ctx, link.Token); err != nil {
|
||||
t.Fatalf("Link muss nach dem Fehlversuch noch gelten, kam: %v", err)
|
||||
}
|
||||
// Anna darf.
|
||||
if err := s.Delete(ctx, link.ID, anna); err != nil {
|
||||
t.Fatalf("Anna muss ihren Link löschen dürfen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, link.Token); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("gelöschter Link muss ErrNotFound liefern, kam: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeZeigtNurEigeneLinks(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
bert := konto(t, pool, ctx, "bert")
|
||||
fa := datei(t, pool, ctx, anna, "Annas.pdf")
|
||||
fb := datei(t, pool, ctx, bert, "Berts.pdf")
|
||||
|
||||
if _, err := s.Create(ctx, fa, anna, "", nil); err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, err := s.Create(ctx, fb, bert, "", nil); err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
annas, err := s.ListByOwner(ctx, anna)
|
||||
if err != nil {
|
||||
t.Fatalf("liste: %v", err)
|
||||
}
|
||||
if len(annas) != 1 {
|
||||
t.Fatalf("Anna soll genau einen Link sehen, sah %d", len(annas))
|
||||
}
|
||||
if annas[0].FileID != fa {
|
||||
t.Fatalf("Anna sieht einen fremden Link: %v", annas[0].FileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVerlaesstDenSpeicherNichtVersehentlich(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Vertrag.pdf")
|
||||
|
||||
mitPw, err := s.Create(ctx, f, anna, "argon2-hash-platzhalter", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
// Das Anlegen gibt den Hash **nicht** zurück, nur die Tatsache.
|
||||
if !mitPw.HasPassword {
|
||||
t.Fatal("HasPassword muss true sein")
|
||||
}
|
||||
|
||||
// Beim Abruf über das Token kommt er heraus – dort wird er gebraucht,
|
||||
// um das eingegebene Passwort zu prüfen. Aber nur dort.
|
||||
link, hash, err := s.GetByToken(ctx, mitPw.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("abrufen: %v", err)
|
||||
}
|
||||
if hash != "argon2-hash-platzhalter" {
|
||||
t.Fatalf("Hash muss beim Tokenabruf vorliegen, kam %q", hash)
|
||||
}
|
||||
if !link.HasPassword {
|
||||
t.Fatal("HasPassword muss auch beim Abruf true sein")
|
||||
}
|
||||
|
||||
// Die Liste für die Oberfläche kennt ihn nicht.
|
||||
liste, err := s.ListByOwner(ctx, anna)
|
||||
if err != nil {
|
||||
t.Fatalf("liste: %v", err)
|
||||
}
|
||||
if len(liste) != 1 || !liste[0].HasPassword {
|
||||
t.Fatalf("Liste soll den Link mit HasPassword zeigen, kam %+v", liste)
|
||||
}
|
||||
|
||||
// Ohne Passwort bleibt es false – sonst prüfte das oben nichts.
|
||||
ohnePw, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if ohnePw.HasPassword {
|
||||
t.Fatal("HasPassword muss ohne Passwort false sein")
|
||||
}
|
||||
if _, hash, _ := s.GetByToken(ctx, ohnePw.Token); hash != "" {
|
||||
t.Fatalf("ohne Passwort darf kein Hash kommen, kam %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIstNichtZuErraten(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
gesehen := map[string]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
l, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
// 24 Zufallsbytes, base64url ohne Auffüllen = 32 Zeichen. Wird das
|
||||
// jemals kürzer, ist das Token ratbar – und der Link ist das einzige
|
||||
// Geheimnis, das ihn schützt.
|
||||
if len(l.Token) != 32 {
|
||||
t.Fatalf("Token soll 32 Zeichen haben, hat %d: %q", len(l.Token), l.Token)
|
||||
}
|
||||
if gesehen[l.Token] {
|
||||
t.Fatalf("Token doppelt vergeben: %q", l.Token)
|
||||
}
|
||||
gesehen[l.Token] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestZaehlerZaehlt(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
link, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if link.DownloadCount != 0 {
|
||||
t.Fatalf("frischer Link soll bei 0 stehen, stand bei %d", link.DownloadCount)
|
||||
}
|
||||
s.IncrementDownloads(ctx, link.Token)
|
||||
s.IncrementDownloads(ctx, link.Token)
|
||||
|
||||
nach, _, err := s.GetByToken(ctx, link.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("abrufen: %v", err)
|
||||
}
|
||||
if nach.DownloadCount != 2 {
|
||||
t.Fatalf("Zähler soll 2 sein, ist %d", nach.DownloadCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package tags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Etiketten und Absender gehören einem Konto oder einer Gruppe. Dass ein
|
||||
// fremdes Etikett fremd **bleibt**, prüfte bis zum 12.09.2026 nichts nach –
|
||||
// und die Prüfung steckt hier nicht in einer if-Zeile, sondern in
|
||||
// WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
// läuft, sie liefert nur zu viel.
|
||||
//
|
||||
// Der teuerste Fall ist FilesWithTag: Es gibt **Dateinamen und Ordner**
|
||||
// heraus. Wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||
// fremden Unterlagen.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/tags/
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("konto %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||
owner, name, "k/"+uuid.NewString(),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("datei: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Ein fremdes Etikett bleibt fremd ────────────────────────────────────────
|
||||
|
||||
func TestFremdesEtikettBleibtFremd(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
a := konto(t, pool, ctx, "anna")
|
||||
b := konto(t, pool, ctx, "bert")
|
||||
|
||||
annas, err := s.Create(ctx, a, nil, "Steuer 2026", "#ff0000", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
t.Run("taucht nicht in Berts Liste auf", func(t *testing.T) {
|
||||
liste, err := s.List(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, tg := range liste {
|
||||
if tg.ID == annas.ID {
|
||||
t.Fatal("Bert sieht Annas Etikett")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht umbenennen", func(t *testing.T) {
|
||||
if _, err := s.Update(ctx, b, annas.ID, "Uebernommen", "#00ff00", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Update lieferte %v, erwartet ErrNotFound", err)
|
||||
}
|
||||
// Und der Name steht wirklich noch da – der Fehlercode allein sagt
|
||||
// nichts darüber, ob nebenbei geschrieben wurde.
|
||||
var name string
|
||||
_ = pool.QueryRow(ctx, `SELECT name FROM tags WHERE id = $1`, annas.ID).Scan(&name)
|
||||
if name != "Steuer 2026" {
|
||||
t.Fatalf("Name ist jetzt %q", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht löschen", func(t *testing.T) {
|
||||
if err := s.Delete(ctx, b, annas.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Delete lieferte %v, erwartet ErrNotFound", err)
|
||||
}
|
||||
var da bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1)`, annas.ID).Scan(&da)
|
||||
if !da {
|
||||
t.Fatal("Annas Etikett ist weg")
|
||||
}
|
||||
})
|
||||
|
||||
// Der teuerste Fall: Hier kämen Dateinamen und Ordner heraus.
|
||||
t.Run("Bert sieht die Dateien darunter nicht", func(t *testing.T) {
|
||||
f := datei(t, pool, ctx, a, "Gehaltsabrechnung.pdf")
|
||||
if err := s.SetForFile(ctx, f, a, a, []uuid.UUID{annas.ID}, nil); err != nil {
|
||||
t.Fatalf("etikettieren: %v", err)
|
||||
}
|
||||
// Anna sieht sie – sonst prüfte der nächste Teil eine leere Liste.
|
||||
meine, err := s.FilesWithTag(ctx, a, annas.ID)
|
||||
if err != nil || len(meine) != 1 {
|
||||
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d Treffer", err, len(meine))
|
||||
}
|
||||
fremde, err := s.FilesWithTag(ctx, b, annas.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesWithTag: %v", err)
|
||||
}
|
||||
if len(fremde) != 0 {
|
||||
t.Fatalf("Bert sieht %d fremde Dateien: %v", len(fremde), fremde[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht an seine eigene Datei hängen", func(t *testing.T) {
|
||||
f := datei(t, pool, ctx, b, "Berts Datei.pdf")
|
||||
err := s.SetForFile(ctx, f, b, b, []uuid.UUID{annas.ID}, nil)
|
||||
if !errors.Is(err, ErrWrongOwner) {
|
||||
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner", err)
|
||||
}
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Gruppen ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func gruppeMit(t *testing.T, pool *pgxpool.Pool, ctx context.Context, mitglieder ...uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
var g uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO groups (name) VALUES ($1) RETURNING id`, "probe-"+uuid.NewString()[:8],
|
||||
).Scan(&g)
|
||||
if err != nil {
|
||||
t.Fatalf("gruppe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, g) })
|
||||
for _, m := range mitglieder {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)`, g, m); err != nil {
|
||||
t.Fatalf("mitglied: %v", err)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestGruppenEtikettSiehtNurDieGruppe(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
drin := konto(t, pool, ctx, "drin")
|
||||
draussen := konto(t, pool, ctx, "draussen")
|
||||
g := gruppeMit(t, pool, ctx, drin)
|
||||
|
||||
tag, err := s.Create(ctx, drin, &g, "Buchhaltung", "#0000ff", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
sieht := func(user uuid.UUID) bool {
|
||||
liste, err := s.List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, x := range liste {
|
||||
if x.ID == tag.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !sieht(drin) {
|
||||
t.Fatal("das Mitglied sieht das Gruppen-Etikett nicht")
|
||||
}
|
||||
if sieht(draussen) {
|
||||
t.Fatal("ein Nichtmitglied sieht das Gruppen-Etikett")
|
||||
}
|
||||
|
||||
// Mitgliedschaft ersetzt die Freigabe: Wer die Gruppe verlässt, verliert
|
||||
// den Zugriff im selben Moment.
|
||||
if _, err := pool.Exec(ctx,
|
||||
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, g, drin); err != nil {
|
||||
t.Fatalf("austragen: %v", err)
|
||||
}
|
||||
if sieht(drin) {
|
||||
t.Fatal("nach dem Austritt weiterhin sichtbar")
|
||||
}
|
||||
if _, err := s.Update(ctx, drin, tag.ID, "Umbenannt", "#111111", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("nach dem Austritt noch änderbar: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Gruppen-Etikett gehört **nicht** an eine persönliche Datei – und das ist
|
||||
// keine Förmlichkeit, sondern die Stelle, an der die Rechte hängen.
|
||||
//
|
||||
// Der Anlass war ein Verdacht, der sich als falsch erwies: `List` bietet dem
|
||||
// Nutzer Gruppen-Etiketten mit an, `SetFieldsForFile` vergleicht aber gegen
|
||||
// den *Prinzipal der Datei*. Sieht nach Widerspruch aus. Ist keiner – die
|
||||
// Oberfläche filtert bereits (`TagDialog.tsx`: bei einer persönlichen Datei
|
||||
// nur Etiketten ohne group_id).
|
||||
//
|
||||
// **Warum die Ablehnung bleiben muss:** `FilesWithTag` prüft, ob der
|
||||
// Anfragende das *Etikett* sehen darf – aber **nicht**, ob er die *Datei*
|
||||
// sehen darf. Das ist nur deshalb harmlos, weil ein Etikett stets denselben
|
||||
// Prinzipal hat wie die Dateien darunter: ein persönliches nur an eigenen
|
||||
// Dateien, ein Gruppen-Etikett nur an Gruppendateien, die alle Mitglieder
|
||||
// ohnehin sehen.
|
||||
//
|
||||
// Fiele diese Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei –
|
||||
// und jedes Gruppenmitglied bekäme über die Etikettenansicht ihren Namen,
|
||||
// Ordner und ihre Größe zu sehen. Deshalb steht dieser Test hier: Er hält
|
||||
// nicht eine Fehlermeldung fest, sondern eine Absicherung.
|
||||
func TestGruppenEtikettGehoertNichtAnEinePersoenlicheDatei(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
u := konto(t, pool, ctx, "mitglied")
|
||||
g := gruppeMit(t, pool, ctx, u)
|
||||
|
||||
tag, err := s.Create(ctx, u, &g, "Gemeinsam", "#00ffff", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
f := datei(t, pool, ctx, u, "Privat.pdf")
|
||||
|
||||
// Prinzipal der Datei ist der Nutzer, nicht die Gruppe.
|
||||
if err := s.SetForFile(ctx, f, u, u, []uuid.UUID{tag.ID}, nil); !errors.Is(err, ErrWrongOwner) {
|
||||
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner – ohne diese "+
|
||||
"Prüfung sähe jedes Gruppenmitglied diese private Datei", err)
|
||||
}
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||
}
|
||||
|
||||
// Die Gegenprobe: An einer **Gruppendatei** geht dasselbe Etikett.
|
||||
var gf uuid.UUID
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES (NULL, $1, NULL, 'Gemeinsam.pdf', 'application/pdf', 1, 'x', $2) RETURNING id`,
|
||||
g, "k/"+uuid.NewString()).Scan(&gf)
|
||||
if err != nil {
|
||||
t.Fatalf("gruppendatei: %v", err)
|
||||
}
|
||||
if err := s.SetForFile(ctx, gf, g, u, []uuid.UUID{tag.ID}, nil); err != nil {
|
||||
t.Fatalf("an einer Gruppendatei muss es gehen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Absender ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFremderAbsenderBleibtFremd(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
a := konto(t, pool, ctx, "anna")
|
||||
b := konto(t, pool, ctx, "bert")
|
||||
|
||||
k, err := s.CreateCorrespondent(ctx, a, nil, "Finanzamt", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
liste, err := s.ListCorrespondents(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, x := range liste {
|
||||
if x.ID == k.ID {
|
||||
t.Fatal("Bert sieht Annas Absender")
|
||||
}
|
||||
}
|
||||
// Absender haben ihre eigene Fehlermarke – ErrCorrespondentNotFound, nicht
|
||||
// ErrNotFound. Zwei Marken für dieselbe Aussage in einem Paket sind eine
|
||||
// Stolperstelle; hier festgehalten, damit sie wenigstens nicht unbemerkt
|
||||
// die Seite wechselt.
|
||||
if _, err := s.UpdateCorrespondent(ctx, b, k.ID, "Uebernommen", ""); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||
t.Fatalf("Update lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||
}
|
||||
if err := s.DeleteCorrespondent(ctx, b, k.ID); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||
t.Fatalf("Delete lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||
}
|
||||
|
||||
// Auch hier: Der Weg, der Dateinamen herausgibt.
|
||||
f := datei(t, pool, ctx, a, "Bescheid.pdf")
|
||||
if err := s.SetFieldsForFile(ctx, f, a, a, FileFields{CorrespondentID: &k.ID}); err != nil {
|
||||
t.Fatalf("zuordnen: %v", err)
|
||||
}
|
||||
meine, err := s.FilesWithCorrespondent(ctx, a, k.ID)
|
||||
if err != nil || len(meine) != 1 {
|
||||
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d", err, len(meine))
|
||||
}
|
||||
fremde, err := s.FilesWithCorrespondent(ctx, b, k.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesWithCorrespondent: %v", err)
|
||||
}
|
||||
if len(fremde) != 0 {
|
||||
t.Fatalf("Bert sieht %d fremde Dateien", len(fremde))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package bg startet Hintergrundarbeit, die den Prozess nicht mitreißen kann.
|
||||
//
|
||||
// **Der Anlass ist gemessen, nicht theoretisch.** Aether erzeugt das
|
||||
// Vorschaubild einer hochgeladenen Datei in einer eigenen Goroutine. Chis
|
||||
// `Recoverer` hängt am Anfragebehandler und sieht davon nichts – eine Panik
|
||||
// dort nimmt den **ganzen Go-Prozess** mit. Am 12.09.2026 stand dafür sogar
|
||||
// eine passende Lücke offen (GO-2026-5061, Panik im WebP-Dekodierer): Ein
|
||||
// gewöhnlicher Bild-Upload hätte den Server umgelegt.
|
||||
//
|
||||
// Die Bibliothek zu aktualisieren schließt **diese** Lücke. Diese Datei
|
||||
// schließt die **Gattung**: Bilddekodierer, Fremdformate und externe Programme
|
||||
// sind Felder, in denen regelmäßig etwas gefunden wird, und keines davon darf
|
||||
// über einen Weg erreichbar sein, an dessen Ende der Prozess stirbt.
|
||||
//
|
||||
// Was hier *nicht* passiert: Fehler verschlucken. Eine aufgefangene Panik wird
|
||||
// mit Stapel protokolliert. Sie ist ein Fehler und bleibt einer – sie reißt
|
||||
// nur nichts mehr mit.
|
||||
package bg
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Go startet fn in einer Goroutine, die eine Panik überlebt.
|
||||
//
|
||||
// name steht im Logeintrag und sollte sagen, *was* da lief ("Vorschaubild",
|
||||
// "Texterkennung") – eine Zeile „panic recovered" ohne Herkunft hilft um drei
|
||||
// Uhr nachts niemandem.
|
||||
func Go(name string, fn func()) {
|
||||
go Run(name, fn)
|
||||
}
|
||||
|
||||
// Run ist dasselbe ohne eigene Goroutine: für Schleifen, in denen **ein**
|
||||
// Durchgang scheitern darf, ohne dass die Schleife endet.
|
||||
//
|
||||
// Der Unterschied ist keine Feinheit. Fängt man eine Panik um den ganzen
|
||||
// Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und
|
||||
// von da an wird still keine Datei mehr erkannt. Fängt man sie um den
|
||||
// einzelnen Auftrag ab, verliert man den einen Auftrag und macht weiter.
|
||||
func Run(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().
|
||||
Str("wo", name).
|
||||
Interface("panik", r).
|
||||
Str("stapel", string(debug.Stack())).
|
||||
Msg("Hintergrundarbeit abgestürzt – aufgefangen")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunFaengtPanikAuf(t *testing.T) {
|
||||
// Ohne das recover in Run stürbe hier der Testprozess – das ist zugleich
|
||||
// die Aussage des Tests.
|
||||
Run("test", func() { panic("absichtlich") })
|
||||
}
|
||||
|
||||
func TestRunLaesstErgebnisDurch(t *testing.T) {
|
||||
gelaufen := false
|
||||
Run("test", func() { gelaufen = true })
|
||||
if !gelaufen {
|
||||
t.Fatal("fn wurde nicht ausgeführt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoLaeuftUndUeberlebt(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
Go("heil", func() { defer wg.Done() })
|
||||
Go("panisch", func() { defer wg.Done(); panic("absichtlich") })
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Eine Schleife, in der ein Durchgang scheitert, muss weiterlaufen – das ist
|
||||
// der Grund, warum es Run neben Go gibt.
|
||||
func TestSchleifeLaeuftNachPanikWeiter(t *testing.T) {
|
||||
durchgaenge := 0
|
||||
for i := range 5 {
|
||||
Run("schritt", func() {
|
||||
durchgaenge++
|
||||
if i == 2 {
|
||||
panic("mittendrin")
|
||||
}
|
||||
})
|
||||
}
|
||||
if durchgaenge != 5 {
|
||||
t.Fatalf("nur %d von 5 Durchgängen", durchgaenge)
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,6 @@ type Config struct {
|
||||
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
|
||||
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
||||
OfficeInsecureTLS bool
|
||||
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
|
||||
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
|
||||
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
|
||||
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
|
||||
OfficeJWTSecret string
|
||||
}
|
||||
|
||||
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
|
||||
@@ -150,7 +145,6 @@ func Load() (*Config, error) {
|
||||
|
||||
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
||||
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
||||
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
|
||||
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
||||
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
||||
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS cal_tombstones;
|
||||
DROP INDEX IF EXISTS cal_objects_sync;
|
||||
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
|
||||
--
|
||||
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
|
||||
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
|
||||
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
|
||||
--
|
||||
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
|
||||
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
|
||||
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
|
||||
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
|
||||
-- bei dem es zuletzt angefasst wurde.
|
||||
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
|
||||
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
|
||||
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
|
||||
UPDATE cal_objects o SET sync_seq = c.ctag
|
||||
FROM cal_calendars c WHERE c.id = o.calendar_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
|
||||
|
||||
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
|
||||
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
|
||||
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
|
||||
-- Art, weil er unsichtbar ist.
|
||||
CREATE TABLE IF NOT EXISTS cal_tombstones (
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
sync_seq BIGINT NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
|
||||
|
||||
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
|
||||
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
|
||||
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_acl_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_group_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_user_calendar;
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Kalender an andere Konten freigeben.
|
||||
--
|
||||
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
|
||||
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
|
||||
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
|
||||
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
|
||||
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
|
||||
-- auf, wenn jemand sieht, was er nicht sehen soll.
|
||||
--
|
||||
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
|
||||
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
|
||||
ALTER TABLE acl_entries
|
||||
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
|
||||
|
||||
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
|
||||
|
||||
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
|
||||
-- mehrdeutig: welches Recht gilt dann?
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
|
||||
ON acl_entries (subject_user_id, resource_calendar_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
|
||||
ON acl_entries (subject_group_id, resource_calendar_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
|
||||
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
|
||||
CREATE INDEX IF NOT EXISTS idx_acl_calendar
|
||||
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Persönlich'
|
||||
WHERE name = 'Personal'
|
||||
AND color = '#4285f4';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
|
||||
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
|
||||
-- zieht die bereits angelegten nach.
|
||||
--
|
||||
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
|
||||
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
|
||||
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Personal'
|
||||
WHERE name = 'Persönlich'
|
||||
AND color = '#4285f4';
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -13,6 +15,17 @@ func Logger(next http.Handler) http.Handler {
|
||||
start := time.Now()
|
||||
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
// Den Logger in den Kontext hängen, damit log.Ctx(r.Context()) in den
|
||||
// Handlern etwas findet – und damit jede Zeile aus einer Anfrage die
|
||||
// Kennung dieser Anfrage trägt. Ohne das schreibt log.Ctx gar nicht
|
||||
// (zerolog gibt dann einen abgeschalteten Logger zurück), und mit
|
||||
// einem globalen Standard allein ließe sich nicht mehr zuordnen,
|
||||
// welche Meldung zu welchem Aufruf gehört.
|
||||
l := log.Logger.With().
|
||||
Str("request_id", chimiddleware.GetReqID(r.Context())).
|
||||
Logger()
|
||||
r = r.WithContext(l.WithContext(r.Context()))
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := zerolog.InfoLevel
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Der Test, der gefehlt hat.
|
||||
//
|
||||
// zerolog.Ctx liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||
// steckt. 34 Aufrufe von log.Ctx(...) im Baum schrieben deshalb ins Nichts –
|
||||
// darunter Warnungen über verwaiste Blobs und beschädigte Dateien. Der Aufruf
|
||||
// sieht dabei völlig richtig aus; auffallen kann es nur, wenn man den Fehler
|
||||
// herbeiführt, den er melden soll.
|
||||
func TestLoggerHaengtLoggerInDenKontext(t *testing.T) {
|
||||
var aus bytes.Buffer
|
||||
alt := log.Logger
|
||||
log.Logger = zerolog.New(&aus)
|
||||
defer func() { log.Logger = alt }()
|
||||
|
||||
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Genau der Aufruf, der überall im Baum steht.
|
||||
log.Ctx(r.Context()).Error().Msg("kaputte-datei")
|
||||
}))
|
||||
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||
|
||||
if !strings.Contains(aus.String(), "kaputte-datei") {
|
||||
t.Fatalf("log.Ctx im Handler schrieb nichts. Ausgabe: %q", aus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Jede Zeile aus einer Anfrage soll deren Kennung tragen – sonst weiß man bei
|
||||
// zwei gleichzeitigen Downloads nicht, welche Meldung zu welchem gehört.
|
||||
func TestZeilenTragenDieAnfrageKennung(t *testing.T) {
|
||||
var aus bytes.Buffer
|
||||
alt := log.Logger
|
||||
log.Logger = zerolog.New(&aus)
|
||||
defer func() { log.Logger = alt }()
|
||||
|
||||
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Ctx(r.Context()).Error().Msg("x")
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||
|
||||
if !strings.Contains(aus.String(), "request_id") {
|
||||
t.Fatalf("keine request_id in der Zeile: %q", aus.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package response
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
@@ -13,7 +14,31 @@ type envelope struct {
|
||||
func JSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(envelope{Data: data})
|
||||
json.NewEncoder(w).Encode(envelope{Data: leereListeStattNull(data)})
|
||||
}
|
||||
|
||||
// leereListeStattNull macht aus einer nicht belegten Liste eine leere.
|
||||
//
|
||||
// In Go ist eine Liste ohne Einträge oft nil, und `encoding/json` schreibt
|
||||
// dafür `null` statt `[]`. Jede Store-Funktion, die ihre Liste mit
|
||||
// `var out []T` beginnt, liefert das bei leerem Ergebnis – und jede
|
||||
// Oberfläche, die darüber läuft, bricht dann ab.
|
||||
//
|
||||
// Genau so ist die Kalenderansicht bei einem Konto ohne Kalender
|
||||
// weiß geblieben: `for (const cal of calendars)` über `null`.
|
||||
//
|
||||
// Der Ort dafür ist hier und nicht in den Stores: Es gibt Dutzende
|
||||
// Listenendpunkte, und der nächste wird wieder mit `var out []T` anfangen.
|
||||
// Eine Regel, an die man sich erinnern muss, ist keine.
|
||||
func leereListeStattNull(data any) any {
|
||||
if data == nil {
|
||||
return data
|
||||
}
|
||||
v := reflect.ValueOf(data)
|
||||
if v.Kind() == reflect.Slice && v.IsNil() {
|
||||
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Eine leere Liste muss als [] herauskommen, nicht als null. Jede
|
||||
// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die
|
||||
// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben.
|
||||
func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) {
|
||||
type eintrag struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var leer []eintrag
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, leer)
|
||||
|
||||
if got := w.Body.String(); !contains(got, `"data":[]`) {
|
||||
t.Fatalf("erwartet \"data\":[], bekam %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, []string{"a", "b"})
|
||||
|
||||
var out struct {
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Data) != 2 || out.Data[0] != "a" {
|
||||
t.Fatalf("Inhalt verändert: %v", out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst
|
||||
// verwandelte die Regel jede fehlende Antwort in eine leere Liste.
|
||||
func TestJSONObjekteBleibenObjekte(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, map[string]int{"n": 1})
|
||||
if got := w.Body.String(); !contains(got, `"n":1`) {
|
||||
t.Fatalf("Objekt verändert: %s", got)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
JSON(w, 200, nil)
|
||||
if got := w.Body.String(); contains(got, "[]") {
|
||||
t.Fatalf("nil sollte keine Liste werden: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,10 @@ WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Erst prüfen, dann bauen: Die Tests decken die Stellen ab, die sich ohne
|
||||
# Browser prüfen lassen – Reihenfolgen, Zoomstufen, Suche im Text. Sie laufen
|
||||
# in einer halben Sekunde; ein Abbild mit einer kaputten Sortierung kostet mehr.
|
||||
RUN npm test && npm run build
|
||||
|
||||
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
|
||||
@@ -102,6 +102,37 @@ services:
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
|
||||
|
||||
# Euro-Office – OnlyOffice-Fork (Nextcloud/IONOS, seit 09.06.2026). Für
|
||||
# Aether ist es derselbe Anbieter wie OnlyOffice (`OFFICE_PROVIDER=onlyoffice`),
|
||||
# nur ein anderes Abbild: Die WOPI-Schnittstelle und die Variablen heißen
|
||||
# gleich.
|
||||
#
|
||||
# Eigenes Profil, damit es neben Collabora stehen kann, ohne sich den Port
|
||||
# zu teilen: ... --profile euro up -d
|
||||
#
|
||||
# Rechne mit rund 5 GB fürs Abbild und 400 MB im Leerlauf.
|
||||
euro:
|
||||
container_name: aether-euro
|
||||
image: ghcr.io/euro-office/documentserver:latest
|
||||
profiles: ["euro"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WOPI_ENABLED: "true"
|
||||
# Ohne Signatur antwortet der Server mit {"error":-8} – und zwar erst
|
||||
# im Editor, nicht schon bei der Ermittlung. Der Rahmen bleibt dann leer.
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||
# Die Beispiel-App gehört nicht ins Netz.
|
||||
EXAMPLE_ENABLED: "false"
|
||||
# Es muss app:8080 erreichen – eine private Adresse, die es sonst
|
||||
# von sich aus ablehnt.
|
||||
ALLOW_PRIVATE_IP_ADDRESS: "true"
|
||||
networks: [internal]
|
||||
ports:
|
||||
# 9981, damit Collabora auf 9980 bleiben kann. Anders als dort ist der
|
||||
# Port hier **80** im Container, nicht 9980.
|
||||
- "${APP_BIND:-127.0.0.1}:${EURO_PORT:-9981}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
||||
|
||||
Generated
+3598
-9
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^19.2.7",
|
||||
@@ -22,8 +23,10 @@
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
|
||||
@@ -53,7 +53,6 @@ function AuthedApp() {
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.7s linear infinite',
|
||||
}} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+114
-12
@@ -103,6 +103,36 @@ export interface PublicShareInfo {
|
||||
mime_type: string
|
||||
}
|
||||
|
||||
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
|
||||
export interface RescanEntry {
|
||||
kind: 'new' | 'orphaned' | 'changed'
|
||||
storage_key: string
|
||||
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
|
||||
path: string
|
||||
owner: string
|
||||
size: number
|
||||
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RescanReport {
|
||||
new: number
|
||||
orphaned: number
|
||||
changed: number
|
||||
scanned: number
|
||||
samples: RescanEntry[]
|
||||
aborted?: boolean
|
||||
/** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */
|
||||
abort_reason?: string
|
||||
/** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */
|
||||
overridable?: boolean
|
||||
/** Nur nach dem Anwenden gefüllt. */
|
||||
adopted?: number
|
||||
removed?: number
|
||||
corrected?: number
|
||||
errors?: number
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string
|
||||
file_id: string
|
||||
@@ -159,12 +189,25 @@ async function request<T>(
|
||||
}
|
||||
|
||||
// XHR-based upload with real progress reporting.
|
||||
/**
|
||||
* Ein vom Nutzer abgebrochener Upload – kein Fehler, sondern eine Absicht.
|
||||
*
|
||||
* Eigene Klasse, damit die Warteschlange die beiden unterscheiden kann: Ein
|
||||
* Fehler bleibt als Meldung stehen, weil er eine Auskunft trägt. „Ich wollte
|
||||
* das nicht" trägt keine und verschwindet von selbst.
|
||||
*/
|
||||
export class UploadAborted extends Error {
|
||||
constructor() { super('Upload aborted') }
|
||||
}
|
||||
|
||||
export function uploadWithProgress(
|
||||
file: File,
|
||||
folderId: string | undefined,
|
||||
onProgress: (p: UploadProgress) => void
|
||||
onProgress: (p: UploadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FileItem> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new UploadAborted()); return }
|
||||
const form = new FormData()
|
||||
// folder_id MUST come before the file so the server can stream the file
|
||||
// straight to storage without buffering the whole body first.
|
||||
@@ -203,7 +246,17 @@ export function uploadWithProgress(
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
||||
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
|
||||
xhr.addEventListener('abort', () => reject(new UploadAborted()))
|
||||
|
||||
/**
|
||||
* Abbrechen heißt hier: die Verbindung fallen lassen.
|
||||
*
|
||||
* Der Server merkt das mitten im Lesen. Er schreibt in eine Temp-Datei und
|
||||
* benennt sie erst am Ende um – ein abgebrochener Upload hinterlässt
|
||||
* deshalb weder Bytes noch eine Zeile in der Datenbank. Es braucht also
|
||||
* keinen zweiten Ruf zum Aufräumen; das Aufräumen ist die Bauweise.
|
||||
*/
|
||||
signal?.addEventListener('abort', () => xhr.abort(), { once: true })
|
||||
|
||||
xhr.send(form)
|
||||
})
|
||||
@@ -306,12 +359,15 @@ export const aclApi = {
|
||||
|
||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
||||
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
|
||||
|
||||
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
|
||||
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
|
||||
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
||||
}
|
||||
|
||||
@@ -539,8 +595,21 @@ export interface OfficeSession {
|
||||
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
||||
* entschieden wird serverseitig.
|
||||
*/
|
||||
/**
|
||||
* Endungen, die im Office-Server aufgehen sollen.
|
||||
*
|
||||
* **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es
|
||||
* einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur
|
||||
* hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server
|
||||
* braucht und direkt speichert. Mit `txt` in dieser Liste lief eine
|
||||
* 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht
|
||||
* auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie.
|
||||
*
|
||||
* `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in
|
||||
* einem Textfeld.
|
||||
*/
|
||||
const officeExtensions = new Set([
|
||||
'odt', 'docx', 'doc', 'rtf', 'txt',
|
||||
'odt', 'docx', 'doc', 'rtf',
|
||||
'ods', 'xlsx', 'xls', 'csv',
|
||||
'odp', 'pptx', 'ppt',
|
||||
'odg',
|
||||
@@ -552,8 +621,11 @@ export function isOfficeFile(name: string): boolean {
|
||||
}
|
||||
|
||||
export const officeApi = {
|
||||
session: (fileId: string) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}`),
|
||||
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
|
||||
* anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie
|
||||
* nicht und bleibt unverändert. */
|
||||
session: (fileId: string, mobile = false) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`),
|
||||
}
|
||||
|
||||
export const ocrApi = {
|
||||
@@ -600,15 +672,25 @@ export const filesApi = {
|
||||
* mit den Fassungen 1, 2, 3.
|
||||
*/
|
||||
/**
|
||||
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
|
||||
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
|
||||
*
|
||||
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
|
||||
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
|
||||
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
|
||||
* Vorschaubild.
|
||||
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
|
||||
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
|
||||
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
|
||||
*
|
||||
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
|
||||
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
|
||||
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
|
||||
*/
|
||||
officePreview: (id: string, version?: number) =>
|
||||
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
|
||||
setThumbnail: async (id: string, bild: Blob) => {
|
||||
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
|
||||
body: bild,
|
||||
})
|
||||
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
|
||||
},
|
||||
|
||||
thumbnail: (id: string, version?: number | string) =>
|
||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||
@@ -851,6 +933,12 @@ export const adminApi = {
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||
/** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde
|
||||
* fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */
|
||||
rescan: (apply = false, bestaetigt = false) =>
|
||||
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
@@ -906,6 +994,20 @@ export interface CalendarItem {
|
||||
timezone: string
|
||||
ctag: number
|
||||
created_at: string
|
||||
/** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */
|
||||
permission: 'read' | 'write'
|
||||
/** Der Kalender gehört jemand anderem. */
|
||||
shared: boolean
|
||||
/** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne
|
||||
* sichtbaren Absender ist ein Rätsel. */
|
||||
owner_name?: string
|
||||
/** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */
|
||||
via_group?: string
|
||||
}
|
||||
|
||||
/** Kurz: Darf ich diesen Kalender ändern? */
|
||||
export function canEditCalendar(c: CalendarItem): boolean {
|
||||
return c.permission !== 'read'
|
||||
}
|
||||
|
||||
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Die eine Breite, ab der die Bedienung „breit" ist.
|
||||
*
|
||||
* Sie entscheidet **zwei** Dinge, und beide müssen zusammen umschalten:
|
||||
* die Seitenleiste steht fest im Fluss statt über dem Inhalt zu liegen, und
|
||||
* die Kopfleiste kommt in eine Zeile – der Knopf zum Aufschieben verschwindet
|
||||
* dabei, weil es nichts mehr aufzuschieben gibt.
|
||||
*
|
||||
* Vorher standen dafür zwei Zahlen an zwei Stellen: 720px in der Kopfleiste,
|
||||
* 767px in der Seitenleiste. Dazwischen lag ein Band von 48 Pixeln, in dem der
|
||||
* Knopf schon weg war, die Leiste aber noch nach links aus dem Bild geschoben –
|
||||
* kein Menü, kein Weg dorthin. Ein aufgeklapptes Fold landet mit rund 757 px
|
||||
* genau darin.
|
||||
*
|
||||
* Also: eine Zahl, ein Ort. Wer sie ändert, ändert beides zugleich.
|
||||
*/
|
||||
export const wideEnough = '(min-width: 720px)'
|
||||
|
||||
/** Dieselbe Grenze zur Laufzeit – für Fälle, die kein CSS lösen kann. */
|
||||
export function isWide(): boolean {
|
||||
return typeof matchMedia === 'function' && matchMedia(wideEnough).matches
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Excalidraw, exportToBlob, getSceneVersion, restore, serializeAsJSON } from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import { APIError, filesApi, type FileItem } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { FileTypeIcon } from './icons'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { toast, fehlertext } from './Toasts'
|
||||
|
||||
/**
|
||||
* Zeichnen und Mindmaps – auf Excalidraw.
|
||||
*
|
||||
* **Eine Zeichnung ist eine Datei, kein neues Ding.** Das ist die ganze
|
||||
* Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie alles, was
|
||||
* Aether für Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
|
||||
* Versionen, Freigabelinks, interne Freigaben, Suche nach Namen und Etikett.
|
||||
* Keine neue Tabelle, kein zweites Rechtemodell.
|
||||
*
|
||||
* Gespeichert wird über denselben Weg wie ein Upload – gleicher Name,
|
||||
* gleicher Ordner. Genau wie der Text-Editor in der Vorschau. Ein eigener
|
||||
* Schreib-Endpunkt bräuchte dieselben Prüfungen noch einmal.
|
||||
*
|
||||
* **Kein gleichzeitiges Arbeiten.** Die Komponente könnte es (siehe
|
||||
* `isCollaborating`), aber der Transport gehört nicht dazu – dafür bräuchte
|
||||
* es einen zweiten Dienst neben Aether, so wie Euro-Office einer ist. Der
|
||||
* Preis dafür steht unten bei `pruefeFremdeAenderung`: Ohne Abgleich würden
|
||||
* zwei Leute sich lautlos gegenseitig die Arbeit löschen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wo die Schriften liegen.
|
||||
*
|
||||
* Ohne das laedt Excalidraw sie von einem fremden Ursprung – und die
|
||||
* Sicherheitsrichtlinie (`default-src 'self'`, kein eigenes `font-src`)
|
||||
* verbietet genau das. Die Zeichnung erschiene in einer Ersatzschrift, und man
|
||||
* suchte den Grund im Zeichenbrett statt in der Richtlinie.
|
||||
*
|
||||
* **Hier und nicht in index.html:** Ein Inline-Skript dort waere von derselben
|
||||
* Richtlinie blockiert (`script-src 'self'`, kein `unsafe-inline`). Kopiert
|
||||
* werden die Dateien beim Bauen, siehe vite.config.ts.
|
||||
*/
|
||||
declare global {
|
||||
interface Window { EXCALIDRAW_ASSET_PATH?: string }
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.EXCALIDRAW_ASSET_PATH = '/excalidraw/'
|
||||
}
|
||||
|
||||
/** Was in einer leeren Zeichnung steht. */
|
||||
export function leereZeichnung(): string {
|
||||
return serializeAsJSON([], {}, {}, 'local')
|
||||
}
|
||||
|
||||
export const drawingExtension = '.excalidraw'
|
||||
|
||||
/**
|
||||
* Eine Zeichnung holen und in einen Zustand verwandeln, den das Zeichenbrett
|
||||
* annimmt.
|
||||
*
|
||||
* Steht hier und nicht im Effekt, damit es sich prüfen lässt – der Weg hat
|
||||
* drei Fallen, und zwei davon enden in verlorener Arbeit.
|
||||
*/
|
||||
export async function ladeZeichnung(url: string) {
|
||||
const r = await fetch(url, { credentials: 'include' })
|
||||
// **`fetch` wirft bei 404 oder 403 nicht.** Ohne diese Zeile käme der
|
||||
// Fehlerrumpf `{"error":"file not found"}` weiter, `JSON.parse` gelänge –
|
||||
// es ist gültiges JSON – und `elements` wäre `undefined`. Das Zeichenbrett
|
||||
// ginge **leer** auf; es sähe aus, als sei die Zeichnung verloren. Wer dann
|
||||
// speichert, macht es wahr.
|
||||
if (!r.ok) throw new APIError(r.status, 'could not load the drawing')
|
||||
|
||||
const text = await r.text()
|
||||
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
|
||||
const d = JSON.parse(text.trim() || leereZeichnung())
|
||||
|
||||
// `restore` säubert und ergänzt Fehlendes. Es ist der Weg, den Excalidraw
|
||||
// dafür vorsieht – selbst zu prüfen hiesse, seine Feldliste nachzubauen und
|
||||
// bei jeder Fassung nachzuziehen.
|
||||
//
|
||||
// **Eine Sache erledigt es allerdings nicht** (nachgemessen): Ein bereits
|
||||
// verunglücktes `collaborators: {}` reicht es unverändert durch – die Map
|
||||
// entsteht nur, wenn das Feld ganz fehlt. Neue Dateien haben es nicht mehr,
|
||||
// weil `serializeAsJSON` es gar nicht erst schreibt; die paar aus der ersten
|
||||
// Stunde wären sonst dauerhaft kaputt.
|
||||
if (d?.appState) delete d.appState.collaborators
|
||||
return restore(d, null, null)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste auffrischen, damit Größe und Fassung stimmen. */
|
||||
onSaved?: (aktualisiert: FileItem) => void
|
||||
}
|
||||
|
||||
export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
const [szene, setSzene] = useState<ReturnType<typeof restore> | null>(null)
|
||||
const [fehler, setFehler] = useState('')
|
||||
const [speichert, setSpeichert] = useState(false)
|
||||
const [geaendert, setGeaendert] = useState(false)
|
||||
/** Rückfragen als eigener Dialog statt window.confirm – siehe unten. */
|
||||
const [frage, setFrage] = useState<null | {
|
||||
titel: string; text: string; label: string; danger?: boolean; tun: () => void
|
||||
}>(null)
|
||||
|
||||
/**
|
||||
* Die Fassung der Zeichnung, wie sie zuletzt gespeichert wurde.
|
||||
*
|
||||
* **Nicht einfach ein Schalter „geändert".** `onChange` feuert bei jeder
|
||||
* Kleinigkeit – Mauszeiger, Auswahl, Bildlauf –, nicht nur wenn sich etwas
|
||||
* am Inhalt tut. Ein Schalter stand deshalb schon Sekunden nach dem
|
||||
* Speichern wieder auf „geändert", und beim Schließen kam die Rückfrage,
|
||||
* obwohl nichts offen war.
|
||||
*
|
||||
* `getSceneVersion` zählt den Inhalt, nicht die Bedienung. Stimmt die Zahl
|
||||
* mit der gespeicherten überein, gibt es nichts zu sichern.
|
||||
*/
|
||||
const gespeicherteFassung = useRef<number | null>(null)
|
||||
|
||||
/** Die Fassung, auf der wir arbeiten. Grundlage für den Abgleich unten. */
|
||||
const basis = useRef(file.version)
|
||||
/** Der jeweils letzte Stand aus dem Zeichenbrett, ohne bei jedem Strich zu rendern. */
|
||||
const aktuell = useRef<{ elements: readonly unknown[]; appState: Record<string, unknown>; files: unknown } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let lebt = true
|
||||
ladeZeichnung(filesApi.inline(file.id))
|
||||
.then(szene => { if (lebt) setSzene(szene) })
|
||||
.catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) })
|
||||
return () => { lebt = false }
|
||||
}, [file.id])
|
||||
|
||||
/**
|
||||
* Hat jemand anders die Datei inzwischen geändert?
|
||||
*
|
||||
* Ohne diese Frage überschreibt der Zweite den Ersten – **lautlos**, und das
|
||||
* ist die unangenehmste Sorte Datenverlust: Niemand merkt sie, bis jemand
|
||||
* seine Arbeit sucht. Es gibt keinen Endpunkt für eine einzelne Datei,
|
||||
* deshalb über die Ordnerliste; das ist eine Anfrage und keine neue
|
||||
* Schnittstelle.
|
||||
*/
|
||||
const pruefeFremdeAenderung = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const d = await filesApi.list(file.folder_id ?? undefined)
|
||||
const jetzt = (d.files ?? []).find(f => f.id === file.id)
|
||||
return !!jetzt && jetzt.version !== basis.current
|
||||
} catch {
|
||||
// Lässt sich die Frage nicht beantworten, wird nicht geraten: Wir
|
||||
// speichern und verlassen uns auf die Versionshistorie.
|
||||
return false
|
||||
}
|
||||
}, [file.id, file.folder_id])
|
||||
|
||||
async function speichern(trotzFremderAenderung = false) {
|
||||
if (!aktuell.current) return
|
||||
setSpeichert(true)
|
||||
try {
|
||||
if (!trotzFremderAenderung && await pruefeFremdeAenderung()) {
|
||||
setSpeichert(false)
|
||||
setFrage({
|
||||
titel: 'Someone else saved this drawing',
|
||||
text: 'Saving now replaces their version. The previous one stays in '
|
||||
+ 'the version history either way.',
|
||||
label: 'Save anyway',
|
||||
danger: true,
|
||||
tun: () => { setFrage(null); void speichern(true) },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// **Einmal festhalten, was hochgeht.** Während der Upload läuft, kann
|
||||
// weitergezeichnet werden – `aktuell.current` ist dann ein anderer
|
||||
// Stand. Läse man die Fassungsnummer hinterher von dort, gälten die
|
||||
// neuen Striche als gespeichert, obwohl sie nie hochgingen. Beim
|
||||
// Schliessen wären sie lautlos weg.
|
||||
const stand = aktuell.current
|
||||
|
||||
// Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder,
|
||||
// die eine Zeichnung ausmachen – nicht den Laufzeitzustand.
|
||||
const inhalt = serializeAsJSON(
|
||||
stand.elements as never, stand.appState as never, stand.files as never, 'local')
|
||||
const blob = new File([inhalt], file.name, { type: 'application/json' })
|
||||
const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
|
||||
basis.current = neu.version
|
||||
gespeicherteFassung.current = getSceneVersion(stand.elements as never)
|
||||
// Ist inzwischen weitergezeichnet worden, steht „Unsaved" sofort wieder
|
||||
// da – richtig so, es ist ja etwas offen.
|
||||
const jetzt = aktuell.current ? getSceneVersion(aktuell.current.elements as never) : null
|
||||
setGeaendert(jetzt !== null && jetzt !== gespeicherteFassung.current)
|
||||
onSaved?.(neu)
|
||||
toast.info('Drawing saved')
|
||||
void vorschaubild(neu, stand)
|
||||
} catch (err) {
|
||||
toast.error(fehlertext(err, 'Could not save the drawing'))
|
||||
} finally {
|
||||
setSpeichert(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Vorschaubild erzeugen und hochladen.
|
||||
*
|
||||
* Der Server kann das nicht: In der Datei steht JSON, und daraus wird ohne
|
||||
* Browser kein Bild. Hier ist der Browser, der die Zeichnung ohnehin gerade
|
||||
* gezeichnet hat.
|
||||
*
|
||||
* **Nach** dem Speichern und ohne darauf zu warten: Ein fehlgeschlagenes
|
||||
* Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern
|
||||
* scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst
|
||||
* ist heil, und eine rote Meldung dafür wäre verwirrend.
|
||||
*/
|
||||
async function vorschaubild(neu: FileItem, stand: NonNullable<typeof aktuell.current>) {
|
||||
try {
|
||||
const bild = await exportToBlob({
|
||||
// Derselbe Stand, der hochgegangen ist – nicht der, der gerade auf
|
||||
// dem Zeichenbrett liegt. Sonst zeigte die Kachel etwas, das so in
|
||||
// keiner Fassung steht.
|
||||
elements: stand.elements as never,
|
||||
appState: { ...(stand.appState as object), exportBackground: true } as never,
|
||||
files: stand.files as never,
|
||||
mimeType: 'image/png',
|
||||
exportPadding: 16,
|
||||
// Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen
|
||||
// kostet nur Bandbreite.
|
||||
getDimensions: (w: number, h: number) => {
|
||||
const faktor = Math.min(1, 512 / Math.max(w, h))
|
||||
return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor }
|
||||
},
|
||||
})
|
||||
await filesApi.setThumbnail(neu.id, bild)
|
||||
// **Der Liste Bescheid geben.** Sie kennt nur die Antwort des Uploads,
|
||||
// und die entstand, bevor es das Bild gab – ohne diese Zeile zeigte die
|
||||
// Kachel bis zum nächsten Neuladen nichts.
|
||||
onSaved?.({ ...neu, has_thumbnail: true })
|
||||
} catch {
|
||||
/* Schönheitsfehler – die Zeichnung ist gespeichert. */
|
||||
}
|
||||
}
|
||||
|
||||
// Strg+S / Cmd+S, wie überall sonst auch.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
if (geaendert && !speichert) void speichern()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
// Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen
|
||||
// erst fragen – `false` hält die Ansicht offen (siehe useBackClose).
|
||||
useBackClose({
|
||||
active: true,
|
||||
onClose: () => {
|
||||
if (!geaendert) { onClose(); return }
|
||||
// `false` hält die Ansicht offen, solange der Dialog steht – sonst wäre
|
||||
// der Verlaufseintrag schon verbraucht, und ein „Cancel" liesse die
|
||||
// nächste Zurück-Geste aus der Anwendung führen.
|
||||
frageSchliessen()
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
/** Die eine Rückfrage vor dem Verwerfen – an drei Stellen dieselbe. */
|
||||
function frageSchliessen() {
|
||||
setFrage({
|
||||
titel: 'Close without saving?',
|
||||
text: 'Your changes to this drawing will be lost.',
|
||||
label: 'Discard changes',
|
||||
danger: true,
|
||||
tun: () => { setFrage(null); onClose() },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-fullscreen="" style={{
|
||||
position: 'fixed', inset: 'var(--honeybar-h) 0 0 0', zIndex: 120,
|
||||
background: 'var(--color-background)', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', flexShrink: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{ display: 'flex', flexShrink: 0 }}>
|
||||
<FileTypeIcon mime={file.mime_type} name={file.name} size={18} />
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
minWidth: 0, fontSize: 14, fontWeight: 500, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name.replace(/\.excalidraw$/i, '')}
|
||||
</span>
|
||||
|
||||
{/* Ein Punkt hinter dem Namen ist leicht zu übersehen – und das
|
||||
Übersehene ist hier die ungespeicherte Arbeit. */}
|
||||
{geaendert && (
|
||||
<span style={{
|
||||
flexShrink: 0, padding: '2px 8px', borderRadius: 999,
|
||||
background: 'rgba(var(--color-primary-rgb), 0.18)',
|
||||
color: 'var(--color-primary)', fontSize: 11, fontWeight: 600,
|
||||
}}>
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<button
|
||||
onClick={() => void speichern()}
|
||||
disabled={speichert || !geaendert}
|
||||
title={geaendert ? 'Save (Ctrl+S)' : 'Nothing to save'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||||
background: geaendert ? 'var(--color-primary)' : 'var(--color-surface-2)',
|
||||
color: geaendert ? 'var(--color-primary-fg)' : 'var(--color-text-muted)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
cursor: speichert || !geaendert ? 'default' : 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{speichert ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { if (geaendert) frageSchliessen(); else onClose() }}
|
||||
// **Kein Escape.** Das Zeichenbrett braucht die Taste selbst – zum
|
||||
// Abwählen und zum Verlassen eines Werkzeugs. Wer sie hier abfängt,
|
||||
// schliesst die Zeichnung, während jemand nur ein Rechteck abwählen
|
||||
// wollte. Hinaus geht es über diesen Knopf oder die Zurück-Geste.
|
||||
title="Close"
|
||||
style={{
|
||||
padding: 6, borderRadius: 8, border: 'none', background: 'none',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{frage && (
|
||||
<ConfirmDialog
|
||||
title={frage.titel}
|
||||
message={frage.text}
|
||||
confirmLabel={frage.label}
|
||||
danger={frage.danger}
|
||||
onConfirm={frage.tun}
|
||||
onCancel={() => setFrage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{fehler && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
color: 'var(--color-danger)', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
{fehler}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fehler && !szene && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-text-muted)', fontSize: 13,
|
||||
}}>
|
||||
Opening…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{szene && (
|
||||
<Excalidraw
|
||||
initialData={szene}
|
||||
// Die Oberfläche ist dunkel; ein weisses Zeichenbrett darin war
|
||||
// der auffälligste Bruch. Umschalten kann man es im Menü – für
|
||||
// einen Ausdruck ist Hell die bessere Wahl.
|
||||
theme="dark"
|
||||
name={file.name.replace(/\.excalidraw$/i, '')}
|
||||
autoFocus
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
// **Aus.** Beide laden oder speichern eine Datei am Browser
|
||||
// vorbei: „Open" ersetzte den Inhalt dieser Zeichnung durch
|
||||
// eine fremde, „Save to…" schriebe eine zweite Fassung in den
|
||||
// Download-Ordner, die niemand mehr wiederfindet. In Aether
|
||||
// gehört die Datei dorthin, wo sie liegt.
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
},
|
||||
}}
|
||||
onChange={(elements, appState, files) => {
|
||||
// **Nicht bei jedem Aufruf in den Zustand schreiben.** onChange
|
||||
// feuert bei jeder Mausbewegung; ein setState hier würde das
|
||||
// Zeichenbrett bei jedem Strich neu rendern.
|
||||
aktuell.current = { elements, appState: appState as unknown as Record<string, unknown>, files }
|
||||
const fassung = getSceneVersion(elements)
|
||||
if (gespeicherteFassung.current === null) gespeicherteFassung.current = fassung
|
||||
const offen = fassung !== gespeicherteFassung.current
|
||||
if (offen !== geaendert) setGeaendert(offen)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Was dasteht, wenn nichts dasteht.
|
||||
*
|
||||
* Bisher war das je nach Seite ein grauer Halbsatz („Trash is empty") oder gar
|
||||
* nichts. Beides lässt offen, ob die Liste leer ist oder das Laden
|
||||
* fehlgeschlagen – und beides verschenkt die Stelle, an der jemand am
|
||||
* ehesten Hilfe gebrauchen kann: Er steht vor etwas Leerem und weiß nicht,
|
||||
* wie es sich füllt.
|
||||
*
|
||||
* Deshalb drei Teile, und der dritte ist der wichtige:
|
||||
*
|
||||
* 1. ein Zeichen, damit die Fläche nicht kaputt aussieht
|
||||
* 2. ein Satz, der sagt, **warum** hier nichts ist
|
||||
* 3. der Knopf, der genau hier weiterhilft – wo es einen gibt
|
||||
*
|
||||
* Wo es keinen sinnvollen Knopf gibt (ein leerer Papierkorb ist ein guter
|
||||
* Zustand, kein Mangel), bleibt es bei zweien. Ein Knopf, der nur da ist,
|
||||
* damit einer da ist, führt nirgendwohin.
|
||||
*/
|
||||
export default function EmptyState({ icon, title, hint, action }: {
|
||||
/** Ein Emoji oder ein SVG. Gedämpft – es soll die Fläche tragen, nicht sie beherrschen. */
|
||||
icon: ReactNode
|
||||
title: string
|
||||
hint?: ReactNode
|
||||
action?: { label: string; onClick: () => void }
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', textAlign: 'center',
|
||||
padding: '72px 24px', gap: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 48, opacity: 0.28, lineHeight: 1 }}>{icon}</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{hint && (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 380 }}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: '8px 16px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: 'var(--color-primary)', color: 'var(--color-primary-fg)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { formatBytes } from '../format'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
import TagChip from './TagChip'
|
||||
|
||||
|
||||
@@ -19,17 +19,6 @@ interface Props {
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* Ein Office-Dokument als PDF zeigen statt im Editor.
|
||||
*
|
||||
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
|
||||
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
|
||||
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
|
||||
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
|
||||
*/
|
||||
asOfficePdf?: boolean
|
||||
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
|
||||
onEdit?: () => void
|
||||
/**
|
||||
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
||||
@@ -101,12 +90,10 @@ export function canPreview(file: FileItem): boolean {
|
||||
return kindOf(file.mime_type, file.name) !== 'none'
|
||||
}
|
||||
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
|
||||
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
|
||||
// gewandeltes PDF ist es eins.
|
||||
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||
const kind = kindOf(file.mime_type, file.name)
|
||||
const narrow = useNarrow()
|
||||
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
|
||||
const src = filesApi.inline(file.id)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [tooBig, setTooBig] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
|
||||
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
|
||||
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
|
||||
die Ausnahme. */}
|
||||
{asOfficePdf && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{kind === 'text' && !tooBig && text !== null && (
|
||||
editing ? (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TagRow } from './TagDialog'
|
||||
import TagChip from './TagChip'
|
||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||
import FileCover, { FolderCover } from './FileCover'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { formatBytes } from '../format'
|
||||
|
||||
/**
|
||||
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { useCoarsePointer } from './FileViews'
|
||||
|
||||
/**
|
||||
* Ein Dokument im Office-Server bearbeiten.
|
||||
@@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: {
|
||||
const [session, setSession] = useState<OfficeSession | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
// Am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem
|
||||
// Schreibtisch ist immer noch eine Maus und eine Tastatur.
|
||||
const coarse = useCoarsePointer()
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
officeApi.session(file.id)
|
||||
officeApi.session(file.id, coarse)
|
||||
.then(s => {
|
||||
if (!alive) return
|
||||
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
|
||||
@@ -35,7 +39,7 @@ export default function OfficeEditor({ file, onClose }: {
|
||||
setError(err instanceof Error ? err.message : 'Could not open the editor')
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [file.id])
|
||||
}, [file.id, coarse])
|
||||
|
||||
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
||||
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import UserMenu, { useSidebarToggle } from './UserMenu'
|
||||
import { wideEnough } from '../breakpoints'
|
||||
|
||||
/**
|
||||
* Die Kopfleiste jeder Ansicht.
|
||||
@@ -23,8 +24,12 @@ export const headerHeight = 57
|
||||
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
||||
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
||||
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
||||
*
|
||||
* Dieselbe Grenze schaltet die Seitenleiste um; sie steht deshalb in
|
||||
* `breakpoints.ts` und nicht mehr hier. Re-exportiert, weil sie hier gesucht
|
||||
* wird.
|
||||
*/
|
||||
export const wideEnough = '(min-width: 720px)'
|
||||
export { wideEnough }
|
||||
|
||||
interface Props {
|
||||
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
||||
|
||||
@@ -3,6 +3,10 @@ import { filesApi } from '../api/client'
|
||||
import * as pdfjs from 'pdfjs-dist'
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
import PdfToolbar from './pdf/PdfToolbar'
|
||||
import { commonWidth, padOf, pageGap, type PageSize } from './pdf/geometry'
|
||||
import { countIn, wordAround, type Hit } from './pdf/text'
|
||||
import { nextFine, nextStep } from './pdf/zoom'
|
||||
import './pdf.css'
|
||||
|
||||
/**
|
||||
@@ -23,6 +27,10 @@ import './pdf.css'
|
||||
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
||||
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
||||
* schreiben kann, wäre ein Widerspruch dazu.
|
||||
*
|
||||
* Hier steht, was am DOM hängt: laden, zeichnen, rollen, zoomen, färben. Was
|
||||
* sich ohne Browser ausrechnen lässt, liegt daneben in `pdf/` – Zoomstufen,
|
||||
* Seitenmaße, Suche im Text – und wird dort auch geprüft.
|
||||
*/
|
||||
|
||||
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
|
||||
@@ -39,63 +47,45 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
|
||||
*/
|
||||
const standardFontDataUrl = '/pdfjs/standard_fonts/'
|
||||
|
||||
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
|
||||
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
|
||||
/**
|
||||
* Wie viele Seitenmaße gleichzeitig geholt werden.
|
||||
*
|
||||
* Vorher lief die Schleife streng nacheinander: Bei einem 200-seitigen Scan
|
||||
* waren das 200 Runden, bevor überhaupt etwas zu sehen war. Alle auf einmal
|
||||
* wäre das andere Extrem – PDF.js hält dann 200 Seitenobjekte zugleich.
|
||||
*/
|
||||
const sizeBatch = 24
|
||||
|
||||
/**
|
||||
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
|
||||
* Zahlen gerastet – 65, 70, 75, 80 …
|
||||
* Wartezeit, bevor eine Eingabe im Suchfeld das ganze Dokument durchsucht.
|
||||
*
|
||||
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
|
||||
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
|
||||
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
|
||||
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
|
||||
* Raster, und das hier ist eines.
|
||||
*
|
||||
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
|
||||
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
|
||||
* Ohne sie las jeder einzelne Tastendruck den Text **aller** Seiten neu aus.
|
||||
* Wer „Rechnung" tippt, löste damit acht vollständige Durchläufe aus, von
|
||||
* denen sieben schon veraltet waren, bevor sie fertig wurden. Ein Sechstel
|
||||
* einer Sekunde bemerkt beim Tippen niemand.
|
||||
*/
|
||||
const zoomStepPercent = 5
|
||||
const minScale = 0.25
|
||||
const maxScale = 4
|
||||
const searchDelay = 160
|
||||
|
||||
/**
|
||||
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
|
||||
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
|
||||
* als ein Zehntel der Darstellungsgröße.
|
||||
*/
|
||||
function padOf(width: number): number {
|
||||
return width < 560 ? 4 : 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
|
||||
* Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet
|
||||
* wird.
|
||||
*
|
||||
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
|
||||
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
|
||||
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
|
||||
* nicht mittig. Macht 2 × 275 + 492.
|
||||
* **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die
|
||||
* Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht
|
||||
* zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter
|
||||
* Pixeldichte sind zwanzig Bitmaps zu je rund acht Megabyte – der Abbruch der
|
||||
* vorigen half nur gegen die Rechenzeit, nicht gegen das Belegen und Freigeben.
|
||||
*
|
||||
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
|
||||
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
|
||||
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
|
||||
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
|
||||
* der Steuerung nichts mehr zu sehen.
|
||||
* Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und
|
||||
* der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also
|
||||
* von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen
|
||||
* weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen.
|
||||
*
|
||||
* Die Textebene wandert korrekt mit: PDF.js setzt ihre Stücke in **Prozent**
|
||||
* des Kastens, und die Schriftgröße hängt an `--total-scale-factor`. Beides
|
||||
* folgt dem Layout, nicht der Leinwand.
|
||||
*/
|
||||
const minGridWidth = 2 * 275 + 492
|
||||
|
||||
/**
|
||||
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
|
||||
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
|
||||
*/
|
||||
const counterWidth = 54
|
||||
const hitButtonsWidth = 60
|
||||
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
|
||||
const hitsWidth = counterWidth + hitButtonsWidth + 6
|
||||
|
||||
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
|
||||
* wird, wenn die sichtbare Seite bestimmt wird. */
|
||||
const pageGap = 12
|
||||
const zoomSettle = 120
|
||||
|
||||
interface Props {
|
||||
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
||||
@@ -109,23 +99,22 @@ interface Props {
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
|
||||
*
|
||||
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
|
||||
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
|
||||
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
|
||||
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
|
||||
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
|
||||
*/
|
||||
interface Hit { page: number; nth: number; exact: boolean }
|
||||
|
||||
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
|
||||
const [sizes, setSizes] = useState<PageSize[]>([])
|
||||
const [scale, setScale] = useState<number | 'fit'>('fit')
|
||||
const [fitScale, setFitScale] = useState(1)
|
||||
/**
|
||||
* Ist die Einpassung schon gemessen?
|
||||
*
|
||||
* Ohne diese Wache zeichnete jedes Öffnen **einmal umsonst**: Die Maße
|
||||
* kommen an, der Zeichner läuft los – und erst danach steht fest, dass das
|
||||
* Blatt nicht auf 100 %, sondern auf 65 % gehört. Die halbfertige Leinwand
|
||||
* wurde weggeworfen. Bei einem A4-Blatt in doppelter Pixeldichte sind das
|
||||
* acht Megabyte, die niemand je zu sehen bekam.
|
||||
*/
|
||||
const [fitReady, setFitReady] = useState(false)
|
||||
const [current, setCurrent] = useState(1)
|
||||
|
||||
const [query, setQuery] = useState(searchTerm ?? '')
|
||||
@@ -141,16 +130,33 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
/** Der Maßstab, in dem das Blatt **liegt** – Kastengröße, Textebene, Anzeige. */
|
||||
const effScale = scale === 'fit' ? fitScale : scale
|
||||
/**
|
||||
* Der Maßstab, in dem zuletzt **gemalt** wurde. Er hinkt während einer Geste
|
||||
* hinterher; solange streckt der Browser das vorhandene Bild. Siehe
|
||||
* `zoomSettle`.
|
||||
*/
|
||||
const [renderScale, setRenderScale] = useState(effScale)
|
||||
useEffect(() => {
|
||||
if (renderScale === effScale) return
|
||||
const t = setTimeout(() => setRenderScale(effScale), zoomSettle)
|
||||
return () => clearTimeout(t)
|
||||
}, [effScale, renderScale])
|
||||
/** Läuft gerade eine Geste – ist das Blatt also nur gestreckt? */
|
||||
const stretched = renderScale !== effScale
|
||||
|
||||
// ---------------------------------------------------------------- Laden
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let task: ReturnType<typeof pdfjs.getDocument> | null = null
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1)
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
|
||||
// Die Verweise auf die Seitenkästen gehören zum alten Dokument. Ohne das
|
||||
// blieben bei einem kürzeren nächsten Dokument Einträge stehen, die auf
|
||||
// längst entfernte Elemente zeigen.
|
||||
pageRefs.current = []
|
||||
|
||||
task = pdfjs.getDocument({
|
||||
const task = pdfjs.getDocument({
|
||||
url,
|
||||
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
||||
withCredentials: true,
|
||||
@@ -165,10 +171,15 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
|
||||
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
||||
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
||||
const s: { w: number; h: number }[] = []
|
||||
for (let i = 1; i <= d.numPages; i++) {
|
||||
const v = (await d.getPage(i)).getViewport({ scale: 1 })
|
||||
s.push({ w: v.width, h: v.height })
|
||||
const s: PageSize[] = []
|
||||
for (let from = 1; from <= d.numPages; from += sizeBatch) {
|
||||
const to = Math.min(from + sizeBatch - 1, d.numPages)
|
||||
const batch = await Promise.all(
|
||||
Array.from({ length: to - from + 1 }, (_, i) =>
|
||||
d.getPage(from + i).then(p => p.getViewport({ scale: 1 }))),
|
||||
)
|
||||
if (!alive) return
|
||||
for (const v of batch) s.push({ w: v.width, h: v.height })
|
||||
}
|
||||
if (!alive) return
|
||||
setSizes(s)
|
||||
@@ -185,7 +196,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
)
|
||||
return () => {
|
||||
alive = false
|
||||
task?.destroy()
|
||||
task.destroy()
|
||||
}
|
||||
}, [url])
|
||||
|
||||
@@ -210,9 +221,6 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
*
|
||||
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
|
||||
* schmaler als das Blatt, die Breite also unter 100 %.
|
||||
*
|
||||
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
|
||||
* breiteste – warum, steht bei `commonWidth`.
|
||||
*/
|
||||
const recomputeFit = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -227,6 +235,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
||||
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
||||
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
||||
setFitReady(true)
|
||||
}, [sizes])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -248,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
||||
* ihre Leinwand frei.
|
||||
*/
|
||||
/**
|
||||
* Steht der Maßstab fest? Bewusst als **ein Wahrheitswert** in der
|
||||
* Abhängigkeitsliste und nicht als `scale`: Der Zeichner darf sich nicht bei
|
||||
* jedem Zoomschritt neu aufbauen, sonst wäre die Geste wieder so teuer wie
|
||||
* vorher. Dieser Wert kippt einmal von falsch auf wahr.
|
||||
*/
|
||||
const scaleKnown = scale !== 'fit' || fitReady
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || sizes.length === 0) return
|
||||
if (!doc || sizes.length === 0 || !scaleKnown) return
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
|
||||
@@ -263,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!doc || !host || drawn.has(n)) return
|
||||
drawn.add(n)
|
||||
const page = await doc.getPage(n)
|
||||
const viewport = page.getViewport({ scale: effScale })
|
||||
const viewport = page.getViewport({ scale: renderScale })
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -330,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
io.disconnect()
|
||||
for (const t of running.values()) t.cancel()
|
||||
}
|
||||
}, [doc, sizes, effScale])
|
||||
}, [doc, sizes, renderScale, scaleKnown])
|
||||
|
||||
// --------------------------------------------------- Sichtbare Seitenzahl
|
||||
|
||||
@@ -403,10 +420,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
*/
|
||||
useEffect(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
|
||||
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); setSearching(false); return }
|
||||
let alive = true
|
||||
setSearching(true)
|
||||
;(async () => {
|
||||
|
||||
// Erst abwarten, ob noch ein Zeichen kommt – siehe `searchDelay`.
|
||||
const timer = setTimeout(() => void (async () => {
|
||||
const found: Hit[] = []
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (!alive) return
|
||||
@@ -446,9 +465,9 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!alive) return
|
||||
setHits(found)
|
||||
setHitIndex(0)
|
||||
setSearching(false)
|
||||
})()
|
||||
return () => { alive = false; setSearching(false) }
|
||||
})().finally(() => { if (alive) setSearching(false) }), searchDelay)
|
||||
|
||||
return () => { alive = false; clearTimeout(timer) }
|
||||
}, [doc, query, fileId])
|
||||
|
||||
/**
|
||||
@@ -466,12 +485,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
||||
*
|
||||
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
|
||||
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
|
||||
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
|
||||
* Textebene.
|
||||
* Kästen, die der Browser auch beim Markieren mit der Maus malt.
|
||||
*
|
||||
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
|
||||
* warum, steht bei `wordAround` unten.
|
||||
* warum, steht bei `wordAround` in pdf/text.ts.
|
||||
*
|
||||
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
|
||||
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
|
||||
@@ -555,6 +572,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
}
|
||||
}, [query, hits])
|
||||
|
||||
/**
|
||||
* Während einer Zoomgeste die Färbung wegnehmen.
|
||||
*
|
||||
* Die Kästen sind das einzige im Seitenkasten, das in **Pixeln** sitzt –
|
||||
* Leinwand und Textebene rechnen in Prozent und wandern von selbst mit. Ein
|
||||
* stehengebliebener Kasten läge nach zwei Stufen sichtbar neben dem Wort.
|
||||
* Nach dem Zeichnen kommt er von selbst zurück (`pdf-text-ready`).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!stretched) return
|
||||
for (const host of pageRefs.current) {
|
||||
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
|
||||
}
|
||||
}, [stretched])
|
||||
|
||||
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
||||
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
||||
const target = hits[hitIndex]
|
||||
@@ -604,6 +636,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
|
||||
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
|
||||
|
||||
const stepHit = useCallback((delta: number) => {
|
||||
// Ohne die Wache teilte der Rest durch null und der Zeiger stünde auf
|
||||
// NaN. Die Leiste ruft das zwar nur mit Treffern, aber ein Zähler, der
|
||||
// sich nicht mehr erholt, ist ein teurer Preis für eine Annahme.
|
||||
if (hits.length === 0) return
|
||||
setHitIndex(i => (i + delta + hits.length) % hits.length)
|
||||
}, [hits.length])
|
||||
|
||||
// ------------------------------------------------------------------ Zoom
|
||||
|
||||
/**
|
||||
@@ -625,6 +665,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
||||
const scaleRef = useRef(effScale)
|
||||
scaleRef.current = effScale
|
||||
|
||||
useEffect(() => {
|
||||
const anchor = keepAnchor.current
|
||||
if (anchor) {
|
||||
@@ -643,41 +684,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
goToPage(n)
|
||||
}, [effScale, goToPage])
|
||||
|
||||
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
|
||||
function nextStep(now: number, dir: 1 | -1): number | undefined {
|
||||
return dir > 0
|
||||
? zoomSteps.find(s => s > now + 0.001)
|
||||
: [...zoomSteps].reverse().find(s => s < now - 0.001)
|
||||
}
|
||||
/** Merkt den Punkt unter Zeiger oder Fingermitte, damit er dort bleibt. */
|
||||
const anchorAt = useCallback((cx: number, cy: number, from: number) => {
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
const r = root.getBoundingClientRect()
|
||||
keepAnchor.current = {
|
||||
px: root.scrollLeft + (cx - r.left), py: root.scrollTop + (cy - r.top),
|
||||
cx: cx - r.left, cy: cy - r.top, from,
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
|
||||
* Anschlag.
|
||||
*
|
||||
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
|
||||
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
|
||||
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
|
||||
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
|
||||
* Fünfern weiter.
|
||||
*/
|
||||
function nextFine(now: number, dir: 1 | -1): number | undefined {
|
||||
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
|
||||
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
|
||||
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
|
||||
// genau eine Stufe, danach nichts mehr.
|
||||
const p = Math.round(now * 100 * 1e6) / 1e6
|
||||
const step = zoomStepPercent
|
||||
const raw = dir > 0
|
||||
? Math.floor(p / step + 1) * step
|
||||
: Math.ceil(p / step - 1) * step
|
||||
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
|
||||
return Math.abs(next - now) < 0.001 ? undefined : next
|
||||
}
|
||||
|
||||
function zoom(dir: 1 | -1) {
|
||||
const next = nextStep(effScale, dir)
|
||||
const zoom = useCallback((dir: 1 | -1) => {
|
||||
const next = nextStep(scaleRef.current, dir)
|
||||
if (next) { keepPage.current = current; setScale(next) }
|
||||
}
|
||||
}, [current])
|
||||
|
||||
/**
|
||||
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
|
||||
@@ -694,31 +715,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
const root2 = scrollRef.current
|
||||
if (!root2) return
|
||||
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
|
||||
const next = nextFine(effScale, dir)
|
||||
const from = scaleRef.current
|
||||
const next = nextFine(from, e.deltaY < 0 ? 1 : -1)
|
||||
if (!next) return
|
||||
const r = root2.getBoundingClientRect()
|
||||
const cx = e.clientX - r.left
|
||||
const cy = e.clientY - r.top
|
||||
keepAnchor.current = {
|
||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
||||
cx, cy, from: effScale,
|
||||
}
|
||||
anchorAt(e.clientX, e.clientY, from)
|
||||
setScale(next)
|
||||
}
|
||||
root.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => root.removeEventListener('wheel', onWheel)
|
||||
}, [effScale])
|
||||
}, [anchorAt])
|
||||
|
||||
/**
|
||||
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
||||
*
|
||||
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
||||
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
|
||||
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
|
||||
* bisher nur die Knöpfe blieben.
|
||||
* hinterher stand alles verschoben.
|
||||
*
|
||||
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
|
||||
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
|
||||
@@ -744,9 +756,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!root) return
|
||||
|
||||
function distance(t: TouchList) {
|
||||
const dx = t[0].clientX - t[1].clientX
|
||||
const dy = t[0].clientY - t[1].clientY
|
||||
return Math.hypot(dx, dy)
|
||||
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
|
||||
}
|
||||
|
||||
function onStart(e: TouchEvent) {
|
||||
@@ -783,17 +793,13 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (dir < 0 && ratio > Math.min(0.98, needed)) return
|
||||
|
||||
pinchBase.current = now
|
||||
const root2 = scrollRef.current
|
||||
if (!root2) return
|
||||
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
||||
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
||||
const r = root2.getBoundingClientRect()
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
|
||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
|
||||
keepAnchor.current = {
|
||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
||||
cx, cy, from,
|
||||
}
|
||||
anchorAt(
|
||||
(e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
(e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
from,
|
||||
)
|
||||
setScale(next)
|
||||
}
|
||||
|
||||
@@ -812,60 +818,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
root.removeEventListener('touchend', onEnd)
|
||||
root.removeEventListener('touchcancel', onEnd)
|
||||
}
|
||||
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
|
||||
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
|
||||
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
|
||||
// dann über genau eine Stufe nicht hinaus.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
// `anchorAt` ist über useCallback stabil, der Zuhörer meldet sich also
|
||||
// **einmal** an. Hinge er am Maßstab, meldete er sich nach jeder Stufe neu
|
||||
// an – mitten in der Geste, und der gemerkte Fingerabstand wäre weg.
|
||||
// Gemessen: Eine lange Geste kam dann über genau eine Stufe nicht hinaus.
|
||||
}, [anchorAt])
|
||||
|
||||
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
||||
|
||||
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
|
||||
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
|
||||
* ersten Zeile. */
|
||||
const zoomGroup = (
|
||||
<>
|
||||
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
|
||||
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
|
||||
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
|
||||
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
|
||||
tut, ist schlimmer als keiner. */}
|
||||
<button
|
||||
onClick={() => { keepPage.current = current; setScale('fit') }}
|
||||
disabled={scale === 'fit'}
|
||||
title="Fit width"
|
||||
style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
|
||||
border: 'none', background: 'none',
|
||||
cursor: scale === 'fit' ? 'default' : 'pointer',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{Math.round(effScale * 100)}%
|
||||
</button>
|
||||
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
|
||||
</>
|
||||
)
|
||||
/**
|
||||
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
|
||||
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
|
||||
*/
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const [barNarrow, setBarNarrow] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = barRef.current
|
||||
if (!el) return
|
||||
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
|
||||
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
|
||||
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
|
||||
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
|
||||
set()
|
||||
const ro = new ResizeObserver(set)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const [pad, setPad] = useState(16)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -892,137 +852,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
||||
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
||||
}}>
|
||||
{/*
|
||||
Werkzeugleiste.
|
||||
|
||||
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
|
||||
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
|
||||
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
|
||||
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
|
||||
landete gemessen 79 bzw. 142 Pixel daneben.
|
||||
|
||||
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
|
||||
beim Umbruch, und die Suche steht in der zweiten Zeile.
|
||||
*/}
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
display: barNarrow ? 'flex' : 'grid',
|
||||
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
|
||||
alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||||
padding: '6px 10px', flexShrink: 0,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<ToolButton label="Previous page" disabled={current <= 1}
|
||||
onClick={() => goToPage(current - 1)} glyph="up" />
|
||||
<span style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)',
|
||||
minWidth: 62, flexShrink: 0,
|
||||
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{doc ? `${current} / ${doc.numPages}` : '…'}
|
||||
</span>
|
||||
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
|
||||
onClick={() => goToPage(current + 1)} glyph="down" />
|
||||
{barNarrow && (
|
||||
<>
|
||||
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
|
||||
{zoomGroup}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
|
||||
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
|
||||
wie ein Zusatz, der nicht recht dazugehört. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
|
||||
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
|
||||
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
|
||||
ansieht, nicht der Kasten drumherum.
|
||||
|
||||
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
|
||||
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
|
||||
rechts der zweite Sprungknopf aus dem Bild fiel. */}
|
||||
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
|
||||
}
|
||||
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
|
||||
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
|
||||
}}
|
||||
placeholder="Find in document"
|
||||
style={{
|
||||
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
|
||||
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
|
||||
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
|
||||
// jetzt auch hinein, ohne zu wandern.
|
||||
width: 240, minWidth: 0,
|
||||
padding: '5px 8px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-background)', color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 11, color: 'var(--color-text-muted)',
|
||||
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
|
||||
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
|
||||
width: counterWidth, flexShrink: 0,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{searching ? 'Searching…'
|
||||
: query.trim().length < 2 ? ''
|
||||
: hits.length === 0 ? 'No hits'
|
||||
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
|
||||
</span>
|
||||
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
|
||||
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
|
||||
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
|
||||
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
|
||||
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
|
||||
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
|
||||
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
|
||||
schon. */}
|
||||
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
|
||||
{hits.length > 0 && (
|
||||
<>
|
||||
<ToolButton label="Previous hit" glyph="hitUp"
|
||||
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
|
||||
<ToolButton label="Next hit" glyph="hitDown"
|
||||
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Die dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
|
||||
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
|
||||
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
|
||||
auch etwas.
|
||||
|
||||
Schmal steht der Zoom **nicht** hier, sondern oben neben der
|
||||
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
|
||||
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
|
||||
Dokument ab. */}
|
||||
{!barNarrow && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
justifyContent: 'flex-end', minWidth: 0,
|
||||
}}>
|
||||
{zoomGroup}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PdfToolbar
|
||||
page={current}
|
||||
pageCount={doc?.numPages ?? null}
|
||||
onGoPage={goToPage}
|
||||
percent={Math.round(effScale * 100)}
|
||||
isFit={scale === 'fit'}
|
||||
onZoom={zoom}
|
||||
onFit={() => { keepPage.current = current; setScale('fit') }}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
searching={searching}
|
||||
hitCount={hits.length}
|
||||
hitIndex={hitIndex}
|
||||
pageOnlyHits={!!hits[0] && !hits[0].exact}
|
||||
onStepHit={stepHit}
|
||||
/>
|
||||
|
||||
{/* Seiten */}
|
||||
<div
|
||||
@@ -1070,124 +915,3 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die häufigste Seitenbreite eines Dokuments.
|
||||
*
|
||||
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
|
||||
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
|
||||
* stimmt – nur ist der Preis höher als der Gewinn.
|
||||
*
|
||||
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
|
||||
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
|
||||
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
|
||||
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
|
||||
* so die Darstellung des ganzen Dokuments.
|
||||
*
|
||||
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
|
||||
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
|
||||
* ohnehin tun will.
|
||||
*
|
||||
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
|
||||
*/
|
||||
function commonWidth(sizes: { w: number; h: number }[]): number {
|
||||
const count = new Map<number, number>()
|
||||
for (const s of sizes) {
|
||||
const w = Math.round(s.w)
|
||||
count.set(w, (count.get(w) ?? 0) + 1)
|
||||
}
|
||||
let best = 0, bestN = 0
|
||||
for (const [w, n] of count) {
|
||||
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
|
||||
}
|
||||
return best || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Weitet eine Fundstelle auf das **ganze Wort** aus.
|
||||
*
|
||||
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
|
||||
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
|
||||
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
|
||||
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
|
||||
* um ein bis drei Pixel anders als die gemalten.
|
||||
*
|
||||
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
|
||||
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
|
||||
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
|
||||
*
|
||||
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
|
||||
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
|
||||
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
|
||||
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
|
||||
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
|
||||
*
|
||||
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
|
||||
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
|
||||
*/
|
||||
const maxWordSnap = 40
|
||||
|
||||
function wordAround(text: string, at: number, to: number): [number, number] {
|
||||
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
|
||||
let s = at, e = to
|
||||
while (s > 0 && isWord(text[s - 1])) s--
|
||||
while (e < text.length && isWord(text[e])) e++
|
||||
return e - s > maxWordSnap ? [at, to] : [s, e]
|
||||
}
|
||||
|
||||
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
|
||||
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
|
||||
const out: Hit[] = []
|
||||
let from = 0, nth = 0
|
||||
for (;;) {
|
||||
const at = haystack.indexOf(needle, from)
|
||||
if (at < 0) break
|
||||
out.push({ page, nth: nth++, exact })
|
||||
from = at + needle.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
|
||||
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
|
||||
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
|
||||
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
|
||||
*
|
||||
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
|
||||
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
|
||||
* wenn es Fundstellen gibt.
|
||||
*/
|
||||
const glyphs: Record<string, string> = {
|
||||
up: '18 15 12 9 6 15',
|
||||
down: '6 9 12 15 18 9',
|
||||
}
|
||||
|
||||
function ToolButton({ label, onClick, disabled, glyph }: {
|
||||
label: string; onClick: () => void; disabled?: boolean
|
||||
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{
|
||||
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
|
||||
background: 'none', color: 'var(--color-text-muted)',
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
|
||||
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
|
||||
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
|
||||
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
|
||||
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
||||
resource: { type: 'folder' | 'file'; id: string; name: string }
|
||||
resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [list, people] = await Promise.all([
|
||||
resource.type === 'folder'
|
||||
? aclApi.listForFolder(resource.id)
|
||||
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
|
||||
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
|
||||
: aclApi.listForFile(resource.id),
|
||||
aclApi.recipients(),
|
||||
])
|
||||
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (resource.type === 'folder') {
|
||||
if (resource.type === 'calendar') {
|
||||
await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission)
|
||||
} else if (resource.type === 'folder') {
|
||||
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
||||
} else {
|
||||
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
||||
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resource.type === 'calendar' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
The calendar shows up in their app too — web and CalDAV alike.
|
||||
With “can edit” they may add and change events; renaming or
|
||||
deleting the calendar stays with you.
|
||||
</p>
|
||||
)}
|
||||
{resource.type === 'folder' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Everything inside this folder is shared too, including anything added later.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { filesApi } from '../api/client'
|
||||
import type { FolderItem, User } from '../api/client'
|
||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { Toaster } from './Toasts'
|
||||
import Sidebar from './Sidebar'
|
||||
import FileBrowser from '../pages/FileBrowser'
|
||||
import TrashPage from '../pages/TrashPage'
|
||||
@@ -14,6 +15,7 @@ import ProfilePage from '../pages/ProfilePage'
|
||||
import TagsPage from '../pages/TagsPage'
|
||||
import CalendarPage from '../pages/CalendarPage'
|
||||
import { UserMenuProvider } from './UserMenu'
|
||||
import { wideEnough } from '../breakpoints'
|
||||
|
||||
export type { View }
|
||||
|
||||
@@ -44,6 +46,19 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
||||
})
|
||||
|
||||
// Wird das Gerät aufgeklappt, während die Leiste aufgeschoben war, steht sie
|
||||
// ab da ohnehin fest im Bild. Bliebe „aufgeschoben" dabei stehen, schluckte
|
||||
// die Zurück-Geste einmal ins Leere – sie zielte auf etwas, das nichts mehr
|
||||
// verdeckt.
|
||||
useEffect(() => {
|
||||
if (typeof matchMedia !== 'function') return
|
||||
const mq = matchMedia(wideEnough)
|
||||
const auf = () => { if (mq.matches) setSidebarOpen(false) }
|
||||
auf()
|
||||
mq.addEventListener('change', auf)
|
||||
return () => mq.removeEventListener('change', auf)
|
||||
}, [])
|
||||
|
||||
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||||
// hängt daran und holt sich daraufhin neu.
|
||||
const [trashBump, setTrashBump] = useState(0)
|
||||
@@ -81,9 +96,18 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
onOpenSidebar: () => setSidebarOpen(true),
|
||||
}}>
|
||||
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
|
||||
{/* Mobile overlay */}
|
||||
{/* Die Meldungen liegen über allem und sind an genau einer Stelle
|
||||
eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur
|
||||
diese eine Anzeige. */}
|
||||
<Toaster />
|
||||
|
||||
{/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste.
|
||||
Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht:
|
||||
Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber
|
||||
machte sie unanklickbar – sichtbar, aber taub. */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="aether-scrim"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
@@ -106,7 +130,6 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
{view === 'files' && (
|
||||
<FileBrowser
|
||||
user={user}
|
||||
folderPath={folderPath}
|
||||
onNavigate={nav.setFolderPath}
|
||||
onUserChanged={onUserChanged}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RenameIcon, TrashIcon } from './icons'
|
||||
import type { User } from '../api/client'
|
||||
import type { View } from './Shell'
|
||||
import { useAppSettings } from '../hooks/useAppSettings'
|
||||
import { wideEnough } from '../breakpoints'
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
@@ -775,7 +776,11 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag, onTrashDro
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Schmal: Die Leiste liegt über dem Inhalt statt neben ihm.
|
||||
Die Grenze ist dieselbe, die in der Kopfleiste den Knopf zum
|
||||
Aufschieben zeigt – hätte sie eine eigene, gäbe es dazwischen einen
|
||||
Streifen ohne Menü und ohne Knopf dorthin. */
|
||||
@media not all and ${wideEnough} {
|
||||
.aether-sidebar {
|
||||
position: fixed;
|
||||
top: var(--honeybar-h); left: 0;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Platzhalter, solange geladen wird.
|
||||
*
|
||||
* Vorher stand dort ein Kreisel in der Mitte. Der sagt „warte", aber er sagt
|
||||
* nicht, **worauf** – und wenn die Daten da sind, klappt das Layout um: Der
|
||||
* Kreisel verschwindet, Kacheln erscheinen, die Seite springt. Ein Platzhalter
|
||||
* in der Form des Kommenden tut beides besser. Er zeigt, was gleich da ist,
|
||||
* und er hält den Platz, sodass nichts springt.
|
||||
*
|
||||
* **Die Maße sind abgeschrieben, nicht geschätzt** – aus FileViews.tsx: Kachel
|
||||
* mit 12er Radius und 100 Pixel Deckfläche, Zeile mit 8/12er Innenabstand und
|
||||
* 8er Radius. Weichen sie ab, springt es beim Wechsel wieder, und der ganze
|
||||
* Zweck wäre dahin.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Zeigen wir den Platzhalter **wirklich** – oder ist die Liste schneller da?
|
||||
*
|
||||
* Ohne Frist blitzt bei jedem Ordnerwechsel und bei jedem Tab-Wechsel kurz ein
|
||||
* Feld grauer Kästen auf. Sie sehen aus wie Dateien, die sofort wieder
|
||||
* verschwinden; Marc nannte sie „Geister", und das trifft es: Der Platzhalter
|
||||
* soll Warten überbrücken, nicht Warten erfinden.
|
||||
*
|
||||
* **Die Frist gilt auch beim allerersten Laden.** Das war zuerst anders – ich
|
||||
* hatte gedacht, beim ersten Mal gebe es ja nichts, was stehenbleiben könnte,
|
||||
* also könne er sofort kommen. Beim Wechsel zwischen den Menüpunkten entsteht
|
||||
* die Seite aber jedes Mal neu, und damit war *jeder* Tab-Wechsel ein „erstes
|
||||
* Laden". Genau dort blitzte es dann. 220 ms Leere ist das ruhigere Bild: Man
|
||||
* kommt ohnehin von einer anderen Seite, der Wechsel liest sich als Wechsel.
|
||||
*
|
||||
* 220 ms ist die Grenze, ab der ein Wechsel als „hakt" empfunden wird –
|
||||
* darunter merkt niemand, dass überhaupt geladen wurde.
|
||||
*/
|
||||
export function useSkeleton(loading: boolean, frist = 220): boolean {
|
||||
const [zeigen, setZeigen] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!loading) { setZeigen(false); return }
|
||||
const t = setTimeout(() => setZeigen(true), frist)
|
||||
return () => clearTimeout(t)
|
||||
}, [loading, frist])
|
||||
return zeigen
|
||||
}
|
||||
|
||||
/** Ein graues Feld, das leicht pulsiert. */
|
||||
function Feld({ w, h, r = 6, mb }: { w: number | string; h: number; r?: number; mb?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="aether-skeleton"
|
||||
style={{ width: w, height: h, borderRadius: r, marginBottom: mb }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Platzhalter.
|
||||
*
|
||||
* Genug, um die sichtbare Fläche zu füllen, und nicht mehr: Ein Schirm voll
|
||||
* grauer Kästen, der sich noch scrollen lässt, verspricht mehr Inhalt als da
|
||||
* ist – und enttäuscht zweimal, wenn der Ordner am Ende drei Dateien hat.
|
||||
*/
|
||||
const kacheln = 8
|
||||
const zeilen = 7
|
||||
|
||||
export function SkeletonGrid() {
|
||||
return (
|
||||
<div className="aether-grid" aria-hidden="true">
|
||||
{Array.from({ length: kacheln }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 100 }}>
|
||||
<Feld w="100%" h={100} r={0} />
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px 10px' }}>
|
||||
{/* Unterschiedlich lang, sonst sieht es aus wie ein Formular und
|
||||
nicht wie eine Liste von Namen. */}
|
||||
<Feld w={`${55 + ((i * 17) % 35)}%`} h={9} mb={6} />
|
||||
<Feld w="38%" h={8} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonRows() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }} aria-hidden="true">
|
||||
{Array.from({ length: zeilen }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Feld w={28} h={28} r={6} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Feld w={`${35 + ((i * 23) % 40)}%`} h={10} />
|
||||
</div>
|
||||
<Feld w={64} h={8} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client'
|
||||
import { tagPalette, tagColor } from './TagChip'
|
||||
import { fehlertext } from './Toasts'
|
||||
|
||||
/**
|
||||
* Ein Etikett umbenennen, einfärben oder löschen.
|
||||
@@ -28,8 +29,12 @@ export default function TagEditDialog({ tag, onClose, onChanged }: {
|
||||
notifyTagsChanged()
|
||||
onChanged(updated)
|
||||
onClose()
|
||||
} catch {
|
||||
setError('That name is already taken')
|
||||
} catch (err) {
|
||||
// **Nicht mehr raten.** Die feste Meldung „That name is already taken"
|
||||
// behauptete eine Ursache und lag bei jedem anderen Fehler daneben –
|
||||
// Netz weg, Server kaputt, Recht fehlt. Der Server schreibt seine
|
||||
// Meldungen selbst; dieselbe Entscheidung wie im TagDialog.
|
||||
setError(fehlertext(err, 'Could not save the change'))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -242,8 +247,12 @@ export function CorrespondentRenameDialog({ correspondent, onClose, onChanged }:
|
||||
notifyTagsChanged()
|
||||
onChanged(updated)
|
||||
onClose()
|
||||
} catch {
|
||||
setError('That name is already taken')
|
||||
} catch (err) {
|
||||
// **Nicht mehr raten.** Die feste Meldung „That name is already taken"
|
||||
// behauptete eine Ursache und lag bei jedem anderen Fehler daneben –
|
||||
// Netz weg, Server kaputt, Recht fehlt. Der Server schreibt seine
|
||||
// Meldungen selbst; dieselbe Entscheidung wie im TagDialog.
|
||||
setError(fehlertext(err, 'Could not save the change'))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { APIError } from '../api/client'
|
||||
|
||||
/**
|
||||
* Kurze Rückmeldungen am unteren Rand – und, wo es geht, ein „Rückgängig".
|
||||
*
|
||||
* Vorher bestätigte **nichts** irgendetwas: Verschieben, Umbenennen, Teilen
|
||||
* geschah, und die Oberfläche schwieg. Schlimmer noch das Scheitern – die
|
||||
* Handler fingen ihre Fehler mit `catch { /* bleibt stehen *\/ }` ab. Wer eine
|
||||
* Datei löschen wollte und keine Berechtigung hatte, sah exakt dasselbe wie
|
||||
* jemand, bei dem es geklappt hat: nichts.
|
||||
*
|
||||
* **Kein Context, sondern Modulzustand.** Eine Meldung soll von überall
|
||||
* abgesetzt werden können – auch aus `api/client.ts`, das keine Komponente
|
||||
* ist und in keinem Anbieter steckt. Derselbe Weg wie bei `useBackClose`:
|
||||
* ein Zustand im Modul, und die Anzeige hört zu.
|
||||
*/
|
||||
|
||||
export type ToastTone = 'info' | 'error'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
/** Läuft genau einmal; danach schließt die Meldung. */
|
||||
run: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
text: string
|
||||
tone: ToastTone
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
let toasts: Toast[] = []
|
||||
let seq = 0
|
||||
const hoerer = new Set<(t: Toast[]) => void>()
|
||||
|
||||
/** Höchstens so viele gleichzeitig. Darüber stapelt sich Lärm, kein Inhalt. */
|
||||
const maxSichtbar = 4
|
||||
|
||||
/**
|
||||
* Wie lange eine Meldung steht.
|
||||
*
|
||||
* Mit Knopf deutlich länger: Man muss sie lesen, verstehen, *entscheiden* und
|
||||
* dann treffen. Vier Sekunden reichen zum Lesen, nicht zum Entscheiden – ein
|
||||
* „Rückgängig", das weg ist, bevor die Hand da war, ist schlimmer als keines,
|
||||
* weil es die Erwartung geweckt hat.
|
||||
*/
|
||||
const dauerSchlicht = 4000
|
||||
const dauerMitKnopf = 10000
|
||||
|
||||
function melden() {
|
||||
for (const h of hoerer) h(toasts)
|
||||
}
|
||||
|
||||
function schliessen(id: number) {
|
||||
toasts = toasts.filter(t => t.id !== id)
|
||||
melden()
|
||||
}
|
||||
|
||||
function zeigen(text: string, tone: ToastTone, action?: ToastAction): number {
|
||||
const id = ++seq
|
||||
toasts = [...toasts, { id, text, tone, action }].slice(-maxSichtbar)
|
||||
melden()
|
||||
// Bewusst `setTimeout` statt `window.setTimeout`: Dieselbe Funktion, aber
|
||||
// ohne `window` läuft der Zustandsteil dieser Datei auch im Test, der ohne
|
||||
// DOM startet.
|
||||
setTimeout(() => schliessen(id), action ? dauerMitKnopf : dauerSchlicht)
|
||||
return id
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
/** Es hat geklappt. */
|
||||
info: (text: string) => zeigen(text, 'info'),
|
||||
/** Es hat nicht geklappt – bleibt genauso lange stehen, das ist Absicht:
|
||||
* Eine Fehlermeldung, die man festhalten will, steht in der Konsole. */
|
||||
error: (text: string) => zeigen(text, 'error'),
|
||||
/**
|
||||
* Es hat geklappt, und man kann es zurücknehmen.
|
||||
*
|
||||
* Der eigentliche Grund, warum es diese Datei gibt. Löschen ist bei uns ein
|
||||
* Papierkorb, Wiederherstellen ein einziger Aufruf – das Rückgängig kostet
|
||||
* also fast nichts und nimmt dem Löschen den Schrecken.
|
||||
*/
|
||||
undo: (text: string, run: ToastAction['run'], label = 'Undo') =>
|
||||
zeigen(text, 'info', { label, run }),
|
||||
schliessen,
|
||||
}
|
||||
|
||||
/** Einmal im Gerüst einhängen. */
|
||||
export function Toaster() {
|
||||
const [liste, setListe] = useState<Toast[]>(toasts)
|
||||
const [laufend, setLaufend] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
hoerer.add(setListe)
|
||||
return () => { hoerer.delete(setListe) }
|
||||
}, [])
|
||||
|
||||
if (liste.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
// `polite`, nicht `assertive`: Eine Bestätigung darf warten, bis der
|
||||
// Vorleser den laufenden Satz beendet hat.
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed', zIndex: 300,
|
||||
left: 'calc(12px + var(--safe-left))', right: 12,
|
||||
bottom: 'calc(12px + var(--safe-bottom))',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
alignItems: 'center',
|
||||
// Der Bereich selbst fängt keine Klicks – darunter liegt die
|
||||
// Dateiliste, und die soll bedienbar bleiben. Nur die Meldungen
|
||||
// selbst nehmen wieder an.
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{liste.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="aether-toast"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
maxWidth: 480, width: 'fit-content',
|
||||
padding: '11px 14px',
|
||||
borderRadius: 10,
|
||||
background: 'var(--color-surface)',
|
||||
border: `1px solid ${t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-border)'}`,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.35)',
|
||||
fontSize: 13,
|
||||
color: t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{t.text}</span>
|
||||
|
||||
{t.action && (
|
||||
<button
|
||||
disabled={laufend === t.id}
|
||||
onClick={async () => {
|
||||
setLaufend(t.id)
|
||||
try {
|
||||
await t.action!.run()
|
||||
} finally {
|
||||
// Auch nach einem Fehler schließen: Der Aufrufer meldet ihn
|
||||
// selbst, und eine stehengebliebene Meldung mit totem Knopf
|
||||
// wäre die schlechtere Antwort.
|
||||
setLaufend(null)
|
||||
schliessen(t.id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: 'none', background: 'none',
|
||||
color: 'var(--color-primary)',
|
||||
font: 'inherit', fontWeight: 600,
|
||||
cursor: laufend === t.id ? 'default' : 'pointer',
|
||||
padding: '2px 4px',
|
||||
opacity: laufend === t.id ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => schliessen(t.id)}
|
||||
aria-label="Dismiss"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: 'none', background: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)', display: 'flex', padding: 2,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus einem Fehler einen Satz machen, den man lesen kann.
|
||||
*
|
||||
* Der Server schreibt seine Meldungen selbst und in ganzen Sätzen („You only
|
||||
* have read access to this file") – die ist besser als alles, was wir hier
|
||||
* erfinden könnten, und wird deshalb bevorzugt. Der Rückfalltext greift nur,
|
||||
* wenn gar nichts da ist.
|
||||
*
|
||||
* Zwei Fälle bekommen einen eigenen Satz, weil die Rohfassung nichts sagt:
|
||||
* `403` ohne Text, und der Abbruch ohne Antwort (Status 0) – „Unknown error"
|
||||
* hilft niemandem, „keine Verbindung" schon.
|
||||
*/
|
||||
export function fehlertext(err: unknown, rückfall: string): string {
|
||||
if (err instanceof APIError) {
|
||||
if (err.status === 0) return 'No connection to the server.'
|
||||
if (err.message && err.message !== 'Unknown error') return err.message
|
||||
if (err.status === 403) return 'You do not have permission for that.'
|
||||
return rückfall
|
||||
}
|
||||
return err instanceof Error && err.message ? err.message : rückfall
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { formatBytes, formatDuration } from '../format'
|
||||
|
||||
interface UploadItem {
|
||||
id: string
|
||||
name: string
|
||||
progress: number // 0-100
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
status: 'uploading' | 'done' | 'error' | 'canceled'
|
||||
error?: string
|
||||
/** Schon draußen, in Bytes. */
|
||||
loaded?: number
|
||||
/** Größe der Datei, in Bytes. */
|
||||
total?: number
|
||||
/** Bytes je Sekunde, geglättet. 0 heißt „noch nicht messbar". */
|
||||
speed?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: UploadItem[]
|
||||
onDismiss: (id: string) => void
|
||||
/** Bricht einen laufenden oder noch wartenden Upload ab. */
|
||||
onCancel: (id: string) => void
|
||||
}
|
||||
|
||||
export type { UploadItem }
|
||||
@@ -31,7 +40,7 @@ const dismissAfter = 4000
|
||||
*/
|
||||
const maxVisible = 4
|
||||
|
||||
export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
|
||||
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
|
||||
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
|
||||
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
|
||||
@@ -45,7 +54,9 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
|
||||
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
|
||||
// von selbst.
|
||||
if (item.status !== 'done') continue
|
||||
// Abgebrochenes verschwindet wie Fertiges: Die Entscheidung ist
|
||||
// getroffen, die Zeile hat nichts mehr zu sagen.
|
||||
if (item.status !== 'done' && item.status !== 'canceled') continue
|
||||
if (timers.current.has(item.id)) continue
|
||||
timers.current.set(item.id, setTimeout(() => {
|
||||
timers.current.delete(item.id)
|
||||
@@ -85,29 +96,52 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.status !== 'uploading' && (
|
||||
<button onClick={() => onDismiss(item.id)}
|
||||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
||||
)}
|
||||
{/* Dasselbe Kreuz, zwei Bedeutungen – und beide sind dieselbe
|
||||
Geste: „weg damit". Läuft der Upload noch, lässt es die
|
||||
Verbindung fallen; ist er vorbei, räumt es nur die Meldung ab.
|
||||
Auf dem Server bleibt in keinem Fall etwas liegen. */}
|
||||
<button
|
||||
onClick={() => item.status === 'uploading' ? onCancel(item.id) : onDismiss(item.id)}
|
||||
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||
aria-label={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
// Beim Darüberfahren rot – **nur solange der Upload läuft**.
|
||||
// Dasselbe Zeichen tut hier zwei verschiedene Dinge: eine
|
||||
// Meldung wegräumen ist folgenlos, einen laufenden Vorgang
|
||||
// fallen zu lassen nicht. Die Farbe ist der einzige Hinweis
|
||||
// darauf, bevor man klickt.
|
||||
onMouseEnter={e => {
|
||||
if (item.status === 'uploading') e.currentTarget.style.color = 'var(--color-danger)'
|
||||
}}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${item.progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||
}} />
|
||||
</div>
|
||||
<>
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${item.progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||
}} />
|
||||
</div>
|
||||
<Details item={item} />
|
||||
</>
|
||||
)}
|
||||
{item.status === 'done' && (
|
||||
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
|
||||
<p style={{ color: 'var(--color-success)' }} className="text-xs">
|
||||
✓ Uploaded{item.total ? ` · ${formatBytes(item.total)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
||||
)}
|
||||
{item.status === 'canceled' && (
|
||||
<p style={{ color: 'var(--color-text-muted)' }} className="text-xs">Cancelled — nothing was saved</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -124,3 +158,40 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeile unter dem Balken: wie viel, wie schnell, wie lange noch.
|
||||
*
|
||||
* Ein Prozentwert allein sagt nichts über das Warten. Bei 40 % weiß man nicht,
|
||||
* ob noch zehn Sekunden oder zehn Minuten kommen – und genau das ist die
|
||||
* Frage, wegen der man auf den Balken sieht.
|
||||
*
|
||||
* Jeder Teil erscheint erst, wenn er stimmt: Die Größe kennt der Browser
|
||||
* sofort, das Tempo erst nach dem zweiten Messpunkt, die Restzeit erst mit
|
||||
* beidem. Eine Schätzung aus dem Nichts wäre schlimmer als keine – man richtet
|
||||
* sich danach.
|
||||
*/
|
||||
function Details({ item }: { item: UploadItem }) {
|
||||
const teile: string[] = []
|
||||
|
||||
if (item.total) {
|
||||
teile.push(`${formatBytes(item.loaded ?? 0)} / ${formatBytes(item.total)}`)
|
||||
}
|
||||
if (item.speed) {
|
||||
teile.push(`${formatBytes(Math.round(item.speed))}/s`)
|
||||
if (item.total) {
|
||||
const rest = formatDuration((item.total - (item.loaded ?? 0)) / item.speed)
|
||||
if (rest) teile.push(`${rest} left`)
|
||||
}
|
||||
}
|
||||
if (teile.length === 0) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
className="text-xs mt-1 truncate"
|
||||
style={{ color: 'var(--color-text-muted)', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{teile.join(' · ')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { formatBytes } from '../format'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
import { useAppSettings } from '../hooks/useAppSettings'
|
||||
|
||||
@@ -134,7 +134,6 @@ export default function VersionsDialog({ file, onClose, onRestored }: Props) {
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
|
||||
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
|
||||
borderRadius: 14, padding: 24, width: 340, maxWidth: '100%',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{calendar ? 'Kalender bearbeiten' : 'Neuer Kalender'}
|
||||
{calendar ? 'Edit calendar' : 'New calendar'}
|
||||
</h2>
|
||||
|
||||
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label>
|
||||
@@ -48,7 +48,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label>
|
||||
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Color</label>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<ColorSwatches value={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
import type { CalendarEvent } from '../../api/client'
|
||||
import { weekdayLongNamesSun, monthLongNames } from './dates'
|
||||
|
||||
const weekdayLong = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
|
||||
const monthLabels = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
export interface DayEvent extends CalendarEvent {
|
||||
calendarId: string
|
||||
@@ -42,7 +38,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
|
||||
}, [onClose])
|
||||
|
||||
const timeLabel = (ev: DayEvent) => {
|
||||
if (ev.all_day) return 'ganztägig'
|
||||
if (ev.all_day) return 'all day'
|
||||
const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
if (!ev.endsAt) return from
|
||||
const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -60,9 +56,9 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ padding: '18px 22px 12px', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLong[day.getDay()]}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLongNamesSun[day.getDay()]}</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
|
||||
{day.getDate()}. {monthLongNames[day.getMonth()]} {day.getFullYear()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +82,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
|
||||
<span style={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ev.summary || '(ohne Titel)'}
|
||||
{ev.summary || '(untitled)'}
|
||||
{ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}>
|
||||
|
||||
@@ -42,17 +42,17 @@ export default function DeleteEventDialog({ summary, recurring, onSingle, onSeri
|
||||
borderRadius: 14, padding: 24, width: 380, maxWidth: '100%',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{recurring ? 'Serientermin löschen' : 'Termin löschen'}
|
||||
{recurring ? 'Delete recurring event' : 'Delete event'}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
{recurring
|
||||
? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
|
||||
: <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?</>}
|
||||
? <>„{summary || 'Untitled'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
|
||||
: <>„{summary || 'Untitled'}" unwiderruflich löschen?</>}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={onSingle} style={danger}>
|
||||
{recurring ? 'Nur diesen Termin' : 'Löschen'}
|
||||
{recurring ? 'Only this event' : 'Delete'}
|
||||
</button>
|
||||
{recurring && (
|
||||
<button onClick={onSeries} style={danger}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode }
|
||||
import {
|
||||
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
|
||||
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
|
||||
weekdayShortNames, weekdayLongNames, monthLongNames,
|
||||
} from './dates'
|
||||
import { CalendarSelect, ColorSwatches, Toggle } from './controls'
|
||||
|
||||
@@ -23,27 +24,21 @@ type FreqChoice = 'NONE' | Recurrence['freq']
|
||||
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
|
||||
|
||||
const freqLabels: Record<FreqChoice, string> = {
|
||||
NONE: 'Einmalig',
|
||||
DAILY: 'Täglich',
|
||||
WEEKLY: 'Wöchentlich',
|
||||
MONTHLY: 'Monatlich',
|
||||
YEARLY: 'Jährlich',
|
||||
NONE: 'Does not repeat',
|
||||
DAILY: 'Daily',
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
YEARLY: 'Yearly',
|
||||
}
|
||||
|
||||
const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
const monthNames = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
/** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie
|
||||
* man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */
|
||||
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
|
||||
const n = nthOfMonth(start)
|
||||
const nth = n === -1 ? 'letzten' : `${n}.`
|
||||
const wd = weekdayLong[(start.getDay() + 6) % 7]
|
||||
const month = monthNames[start.getMonth()]
|
||||
const wd = weekdayLongNames[(start.getDay() + 6) % 7]
|
||||
const month = monthLongNames[start.getMonth()]
|
||||
return yearly
|
||||
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
|
||||
: { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` }
|
||||
@@ -185,7 +180,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}
|
||||
{event ? (isSeries ? 'Edit series' : 'Edit event') : 'New event'}
|
||||
</h2>
|
||||
|
||||
{readOnly && (
|
||||
@@ -197,20 +192,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
)}
|
||||
{isSeries && !readOnly && (
|
||||
<div style={noteStyle}>
|
||||
Terminreihe: Änderungen gelten für <strong>alle</strong> Termine der
|
||||
Terminreihe: Änderungen gelten für <strong>every</strong> Termine der
|
||||
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Titel</label>
|
||||
<label style={labelStyle}>Title</label>
|
||||
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
|
||||
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
|
||||
{calendars.length > 1 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Kalender</label>
|
||||
<label style={labelStyle}>Calendar</label>
|
||||
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
|
||||
Speichern die Sammlung, behält aber seine Kennung – für
|
||||
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
|
||||
@@ -219,14 +214,14 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
{!!event && calId !== calendarId && (
|
||||
<p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben
|
||||
{isSeries ? ' – mit der ganzen Reihe.' : '.'}
|
||||
{isSeries ? ' – together with the whole series.' : '.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
|
||||
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="All day" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
@@ -237,7 +232,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div style={{ width: 116 }}>
|
||||
<label style={labelStyle}>Uhrzeit</label>
|
||||
<label style={labelStyle}>Time</label>
|
||||
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
@@ -245,13 +240,13 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Ende</label>
|
||||
<label style={labelStyle}>End</label>
|
||||
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
||||
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div style={{ width: 116 }}>
|
||||
<label style={labelStyle}>Uhrzeit</label>
|
||||
<label style={labelStyle}>Time</label>
|
||||
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
@@ -259,7 +254,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
{/* ── Wiederholung ───────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Wiederholung</label>
|
||||
<label style={labelStyle}>Repeat</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={freq} disabled={readOnly}
|
||||
onChange={e => setFreq(e.target.value as FreqChoice)}
|
||||
@@ -270,7 +265,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
</select>
|
||||
{freq !== 'NONE' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>alle</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>every</span>
|
||||
<input type="number" min={1} max={99} value={interval} disabled={readOnly}
|
||||
onChange={e => setInterval(e.target.value)}
|
||||
style={{ ...inputStyle, width: 60 }} />
|
||||
@@ -283,7 +278,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
|
||||
{freq === 'WEEKLY' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>An diesen Tagen</label>
|
||||
<label style={labelStyle}>On these days</label>
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
{weekdayCodes.map((code, i) => {
|
||||
const on = activeDays.includes(code)
|
||||
@@ -292,7 +287,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
<button
|
||||
key={code} type="button" disabled={readOnly || fixed}
|
||||
onClick={() => toggleDay(code)}
|
||||
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
|
||||
title={fixed ? `${weekdayLongNames[i]} – the day the event starts` : weekdayLongNames[i]}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
@@ -303,7 +298,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
{weekdayShort[i]}
|
||||
{weekdayShortNames[i]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -314,7 +309,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
|
||||
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Wiederholt sich</label>
|
||||
<label style={labelStyle}>Repeats</label>
|
||||
<select value={monthMode} disabled={readOnly}
|
||||
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
|
||||
style={inputStyle}>
|
||||
@@ -326,20 +321,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
{freq !== 'NONE' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Endet</label>
|
||||
<label style={labelStyle}>Ends</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={endChoice} disabled={readOnly}
|
||||
onChange={e => setEndChoice(e.target.value as EndChoice)}
|
||||
style={{ ...inputStyle, flex: 1 }}>
|
||||
<option value="NEVER">Nie</option>
|
||||
<option value="COUNT">Nach Anzahl</option>
|
||||
<option value="UNTIL">An einem Datum</option>
|
||||
<option value="NEVER">Never</option>
|
||||
<option value="COUNT">After</option>
|
||||
<option value="UNTIL">On a date</option>
|
||||
</select>
|
||||
{endChoice === 'COUNT' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<input type="number" min={1} max={999} value={count} disabled={readOnly}
|
||||
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Mal</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>times</span>
|
||||
</div>
|
||||
)}
|
||||
{endChoice === 'UNTIL' && (
|
||||
@@ -352,17 +347,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
{/* ── Farbe ──────────────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Farbe</label>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Ort</label>
|
||||
<label style={labelStyle}>Location</label>
|
||||
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={labelStyle}>Beschreibung</label>
|
||||
<label style={labelStyle}>Description</label>
|
||||
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
|
||||
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
</div>
|
||||
@@ -384,7 +379,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
{readOnly ? 'Schließen' : 'Abbrechen'}
|
||||
{readOnly ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button type="submit" style={{
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ColorSwatches({ value, onChange, inherit, disabled }: {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Farbe des Kalenders')}
|
||||
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Calendar color')}
|
||||
{inherit !== undefined && (
|
||||
<span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} />
|
||||
)}
|
||||
@@ -151,7 +151,7 @@ export function CalendarSelect({ calendars, value, onChange, disabled }: {
|
||||
aria-haspopup="listbox" aria-expanded={open} style={field}>
|
||||
{current && dot(current.color, true)}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{current?.name ?? 'Kalender wählen'}
|
||||
{current?.name ?? 'Choose calendar'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
weekdayIndex, weekdayCodes,
|
||||
weekdayShortNames, weekdayLongNames, weekdayLongNamesSun, monthLongNames,
|
||||
} from './dates'
|
||||
|
||||
/**
|
||||
* Die Namen kommen seit dem 20.09.2026 aus `Intl` statt aus sechs
|
||||
* handgeschriebenen Arrays. Geprüft wird hier nicht, ob der Browser Englisch
|
||||
* kann – sondern die **Reihenfolge**, denn daran hing die eigentliche Falle:
|
||||
* Eine Liste begann am Montag, eine am Sonntag, jede passend zu ihrer
|
||||
* Indizierung. Wer die falsche erwischt, verschiebt jeden Wochentag um eins.
|
||||
*/
|
||||
describe('Namen von Wochentagen und Monaten', () => {
|
||||
it('beginnt die Woche am Montag – wie weekdayIndex', () => {
|
||||
expect(weekdayLongNames[0]).toBe('Monday')
|
||||
expect(weekdayLongNames[6]).toBe('Sunday')
|
||||
expect(weekdayShortNames[0]).toBe('Mon')
|
||||
expect(weekdayShortNames).toHaveLength(7)
|
||||
|
||||
// Der Beweis, dass Namen und Index zusammenpassen: Für jeden Tag einer
|
||||
// echten Woche muss der Name am errechneten Index stehen.
|
||||
const montag = new Date(2024, 0, 1)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const tag = new Date(2024, 0, 1 + i)
|
||||
expect(weekdayIndex(tag)).toBe(i)
|
||||
expect(weekdayLongNames[weekdayIndex(tag)]).toBe(
|
||||
new Intl.DateTimeFormat('en', { weekday: 'long' }).format(tag))
|
||||
}
|
||||
expect(weekdayIndex(montag)).toBe(0)
|
||||
})
|
||||
|
||||
it('hat eine zweite Liste, die am Sonntag beginnt – für getDay()', () => {
|
||||
expect(weekdayLongNamesSun[0]).toBe('Sunday')
|
||||
expect(weekdayLongNamesSun[1]).toBe('Monday')
|
||||
|
||||
// Genau dafür ist sie da: direkt mit getDay() indizierbar.
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const tag = new Date(2024, 0, 1 + i)
|
||||
expect(weekdayLongNamesSun[tag.getDay()]).toBe(
|
||||
new Intl.DateTimeFormat('en', { weekday: 'long' }).format(tag))
|
||||
}
|
||||
})
|
||||
|
||||
it('passt zu den Wiederholungs-Kürzeln der Terminreihe', () => {
|
||||
// weekdayCodes ist MO..SU. Stünden die Namen anders herum, bekäme die
|
||||
// Auswahl „An diesen Tagen" die falsche Beschriftung – und ein
|
||||
// Serientermin landete auf dem falschen Wochentag.
|
||||
expect(weekdayCodes).toHaveLength(weekdayLongNames.length)
|
||||
expect(weekdayCodes[0]).toBe('MO')
|
||||
expect(weekdayLongNames[0].startsWith('Mo')).toBe(true)
|
||||
expect(weekdayCodes[6]).toBe('SU')
|
||||
expect(weekdayLongNames[6].startsWith('Su')).toBe(true)
|
||||
})
|
||||
|
||||
it('liefert zwölf Monate, Januar zuerst', () => {
|
||||
expect(monthLongNames).toHaveLength(12)
|
||||
expect(monthLongNames[0]).toBe('January')
|
||||
expect(monthLongNames[11]).toBe('December')
|
||||
// Mitte des Monats als Bezugspunkt: Am Monatsersten könnte eine Zeitzone
|
||||
// westlich von UTC in den Vormonat kippen.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
expect(monthLongNames[new Date(2024, i, 15).getMonth()]).toBe(monthLongNames[i])
|
||||
}
|
||||
})
|
||||
|
||||
it('spricht Englisch, unabhängig von der Sprache des Browsers', () => {
|
||||
// Die Oberfläche ist englisch; die Daten müssen es auch sein. Sonst stünde
|
||||
// auf einem deutschen Rechner „Montag" neben „Repeat".
|
||||
expect(monthLongNames[2]).toBe('March')
|
||||
expect(weekdayLongNames[2]).toBe('Wednesday')
|
||||
})
|
||||
})
|
||||
@@ -132,3 +132,57 @@ export function spannedDays(start: Date, end: Date | null, allDay: boolean): Dat
|
||||
for (let d = first, i = 0; +d <= +last && i < 400; d = addDays(d, 1), i++) out.push(d)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Namen von Wochentagen und Monaten ────────────────────────────────────────
|
||||
//
|
||||
// Bis zum 20.09.2026 standen die als **sechs** handgeschriebene Arrays in drei
|
||||
// Dateien – und `Intl` kam im ganzen Projekt nicht vor, obwohl jeder Browser
|
||||
// es seit Jahren mitbringt.
|
||||
//
|
||||
// Die Doppelpflege war nicht nur lästig, sie war eine Falle: `DayDialog`
|
||||
// begann seine Woche am Sonntag, die anderen am Montag, jeweils passend zu
|
||||
// ihrer Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays
|
||||
// in eine neue Ansicht kopiert hätte, hätte sich mit hoher Wahrscheinlichkeit
|
||||
// einen Off-by-one gebaut, der nur an sieben Tagen im Jahr auffällt.
|
||||
//
|
||||
// Hier gibt es deshalb nur noch **eine** Ordnung: Montag zuerst, wie
|
||||
// `weekdayIndex` oben. Wer den Sonntag vorn braucht, nimmt `weekdayNamesSun`.
|
||||
|
||||
/**
|
||||
* **Feste Sprache, nicht die des Browsers.**
|
||||
*
|
||||
* `Intl.DateTimeFormat(undefined, …)` nähme die Spracheinstellung des
|
||||
* Browsers – und läge auf einem deutschen Rechner „Montag" neben ein
|
||||
* englisches „Repeat". Genau diesen Mischmasch haben wir am 18.09.2026
|
||||
* ausgeräumt; ihn über die Hintertür wieder hereinzulassen wäre absurd.
|
||||
*
|
||||
* Die Oberfläche spricht Englisch, also sprechen die Daten Englisch. Kommt
|
||||
* eines Tages eine Sprachwahl (siehe IDEAS.md), ist **diese Zeile** die
|
||||
* Stelle, an der sie andockt – und dann stimmt es überall auf einmal.
|
||||
*/
|
||||
const uiLocale = 'en'
|
||||
|
||||
/** Einmal rechnen, nicht bei jedem Rendern. */
|
||||
function bauen(anzahl: number, tag: (i: number) => Date, opts: Intl.DateTimeFormatOptions) {
|
||||
const fmt = new Intl.DateTimeFormat(uiLocale, opts)
|
||||
return Array.from({ length: anzahl }, (_, i) => fmt.format(tag(i)))
|
||||
}
|
||||
|
||||
// Der 1. Januar 2024 war ein Montag – der Bezugspunkt für die Wochentage.
|
||||
const ersterMontag = (i: number) => new Date(2024, 0, 1 + i)
|
||||
// Mitte des Monats, damit keine Zeitzone den Monat kippen kann.
|
||||
const monatsMitte = (i: number) => new Date(2024, i, 15)
|
||||
|
||||
/** Mo–So, kurz („Mon"). */
|
||||
export const weekdayShortNames = bauen(7, ersterMontag, { weekday: 'short' })
|
||||
/** Montag–Sonntag, lang. */
|
||||
export const weekdayLongNames = bauen(7, ersterMontag, { weekday: 'long' })
|
||||
/** Januar–Dezember, lang. */
|
||||
export const monthLongNames = bauen(12, monatsMitte, { month: 'long' })
|
||||
|
||||
/**
|
||||
* Dieselben Namen, aber **Sonntag zuerst** – für alles, was direkt mit
|
||||
* `Date.getDay()` indiziert. Bewusst eine eigene Liste statt eines Umrechnens
|
||||
* am Aufrufort: Genau dort entsteht sonst der Off-by-one.
|
||||
*/
|
||||
export const weekdayLongNamesSun = [weekdayLongNames[6], ...weekdayLongNames.slice(0, 6)]
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// jsdom hat kein echtes Canvas. Excalidraw fasst es beim Laden an, obwohl die
|
||||
// beiden geprueften Funktionen es nicht brauchen – ein Platzhalter genuegt.
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
measureText: () => ({ width: 0 }), fillText: () => {}, clearRect: () => {},
|
||||
save: () => {}, restore: () => {}, translate: () => {}, scale: () => {},
|
||||
beginPath: () => {}, closePath: () => {}, fill: () => {}, stroke: () => {},
|
||||
setTransform: () => {}, drawImage: () => {}, getImageData: () => ({ data: [] }),
|
||||
})) as never
|
||||
|
||||
const { restore, serializeAsJSON } = await import('@excalidraw/excalidraw')
|
||||
const { ladeZeichnung } = await import('./DrawingEditor')
|
||||
|
||||
/**
|
||||
* Der Fehler, den dieser Test festhält, ist echt passiert (29.09.2026):
|
||||
* Eine Zeichnung liess sich anlegen und bearbeiten – aber **beim zweiten
|
||||
* Öffnen** blieb das Zeichenbrett leer.
|
||||
*
|
||||
* Ursache: Die erste Fassung schrieb den `appState` roh mit `JSON.stringify`
|
||||
* heraus. Darin steckt `collaborators`, und das ist eine `Map`. JSON kennt
|
||||
* keine Maps; es wurde `{}` daraus. Beim Laden bekam Excalidraw ein leeres
|
||||
* Objekt, wo es eine Map erwartet, rief eine Map-Methode darauf und brach ab.
|
||||
*
|
||||
* Beim **ersten** Öffnen fiel das nicht auf, weil da noch nichts gespeichert
|
||||
* war – genau die Sorte Fehler, die man nur findet, wenn man zweimal hinsieht.
|
||||
*
|
||||
* Behoben mit den Werkzeugen, die das Paket dafür mitbringt:
|
||||
* `serializeAsJSON` schreibt nur, was eine Zeichnung ausmacht, und `restore`
|
||||
* setzt beim Laden wieder zusammen, was fehlt. Selbst zu prüfen hiesse, die
|
||||
* Feldliste von Excalidraw nachzubauen und bei jeder Fassung nachzuziehen.
|
||||
*/
|
||||
describe('Zeichnungen: Speichern und wieder Öffnen', () => {
|
||||
const beispiel = [{
|
||||
id: 'a1', type: 'rectangle', x: 10, y: 20, width: 100, height: 50,
|
||||
angle: 0, strokeColor: '#000', backgroundColor: 'transparent',
|
||||
fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 1,
|
||||
opacity: 100, seed: 1, version: 1, versionNonce: 1, isDeleted: false,
|
||||
groupIds: [], frameId: null, roundness: null, boundElements: null,
|
||||
updatedAt: 1, link: null, locked: false, index: 'a0',
|
||||
}]
|
||||
|
||||
it('schreibt den Laufzeitzustand gar nicht erst mit', () => {
|
||||
// `collaborators` ist eine Map und hat in einer Datei nichts zu suchen.
|
||||
const json = serializeAsJSON(
|
||||
beispiel as never,
|
||||
{ collaborators: new Map(), viewBackgroundColor: '#ffffff' } as never,
|
||||
{}, 'local')
|
||||
const d = JSON.parse(json)
|
||||
|
||||
expect(d.type).toBe('excalidraw')
|
||||
expect(d.elements).toHaveLength(1)
|
||||
expect('collaborators' in d.appState).toBe(false)
|
||||
// Was zur Zeichnung gehört, bleibt erhalten.
|
||||
expect(d.appState.viewBackgroundColor).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('überlebt den Weg durch die Datei und zurück', () => {
|
||||
const json = serializeAsJSON(beispiel as never, { viewBackgroundColor: '#fafafa' } as never, {}, 'local')
|
||||
const zurueck = restore(JSON.parse(json), null, null)
|
||||
|
||||
expect(zurueck.elements).toHaveLength(1)
|
||||
expect(zurueck.elements[0].id).toBe('a1')
|
||||
expect(zurueck.elements[0].width).toBe(100)
|
||||
expect(zurueck.appState.viewBackgroundColor).toBe('#fafafa')
|
||||
})
|
||||
|
||||
it('restore allein repariert ein verunglücktes collaborators NICHT', () => {
|
||||
// Nachgemessen, nicht angenommen: `restore` erzeugt die Map nur, wenn das
|
||||
// Feld **fehlt**. Ein bereits vorhandenes `{}` reicht es durch.
|
||||
const kaputt = {
|
||||
type: 'excalidraw', version: 2, source: 'aether',
|
||||
elements: beispiel, appState: { collaborators: {} }, files: {},
|
||||
}
|
||||
const zurueck = restore(kaputt as never, null, null)
|
||||
expect(zurueck.appState.collaborators instanceof Map).toBe(false)
|
||||
})
|
||||
|
||||
it('deshalb wird das Feld vorher entfernt – so wie im Editor', () => {
|
||||
// Genau das, was die alte Fassung in die Datei geschrieben hat. Die paar
|
||||
// Dateien aus jener Stunde sollen sich wieder öffnen lassen.
|
||||
const kaputt: { appState: Record<string, unknown>; elements: unknown[]; files: unknown } = {
|
||||
elements: beispiel, appState: { collaborators: {} }, files: {},
|
||||
}
|
||||
delete kaputt.appState.collaborators
|
||||
const zurueck = restore(kaputt as never, null, null)
|
||||
|
||||
expect(zurueck.appState.collaborators instanceof Map).toBe(true)
|
||||
expect(zurueck.elements).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('kommt mit einer leeren Datei zurecht', () => {
|
||||
// Eine frisch angelegte Zeichnung hat noch keinen Inhalt.
|
||||
const leer = restore(JSON.parse(serializeAsJSON([], {}, {}, 'local')), null, null)
|
||||
expect(leer.elements).toHaveLength(0)
|
||||
expect(leer.appState.collaborators instanceof Map).toBe(true)
|
||||
})
|
||||
|
||||
describe('Laden', () => {
|
||||
function antwort(status: number, rumpf: string) {
|
||||
vi.stubGlobal('fetch', async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => rumpf,
|
||||
}))
|
||||
}
|
||||
|
||||
it('macht aus einem Fehlerrumpf KEINE leere Zeichnung', async () => {
|
||||
// Das war der Fehler: `fetch` wirft bei 404 nicht, der Rumpf
|
||||
// `{"error":"file not found"}` ist gueltiges JSON, `elements` waere
|
||||
// undefined – und das Zeichenbrett ginge leer auf. Wer dann speichert,
|
||||
// ueberschreibt die echte Zeichnung mit nichts.
|
||||
antwort(404, '{"error":"file not found"}')
|
||||
await expect(ladeZeichnung('/egal')).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('auch 403 fuehrt nicht zu einem leeren Brett', async () => {
|
||||
antwort(403, '{"error":"you only have read access to this file"}')
|
||||
await expect(ladeZeichnung('/egal')).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('eine leere Datei ist dagegen in Ordnung', async () => {
|
||||
// Eine frisch angelegte Zeichnung hat noch keinen Inhalt.
|
||||
antwort(200, '')
|
||||
const szene = await ladeZeichnung('/egal')
|
||||
expect(szene.elements).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('und eine echte Zeichnung kommt vollstaendig an', async () => {
|
||||
antwort(200, serializeAsJSON(beispiel as never, { viewBackgroundColor: '#123456' } as never, {}, 'local'))
|
||||
const szene = await ladeZeichnung('/egal')
|
||||
expect(szene.elements).toHaveLength(1)
|
||||
expect(szene.appState.viewBackgroundColor).toBe('#123456')
|
||||
expect(szene.appState.collaborators instanceof Map).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Correspondent, FileItem, FolderItem, Tag } from '../../api/client'
|
||||
import InputDialog from '../Dialog'
|
||||
import ConfirmDialog from '../ConfirmDialog'
|
||||
import ShareDialog from '../ShareDialog'
|
||||
import ShareWithDialog from '../ShareWithDialog'
|
||||
import VersionsDialog from '../VersionsDialog'
|
||||
import FileInfoDialog from '../FileInfoDialog'
|
||||
import TagDialog from '../TagDialog'
|
||||
import MoveTo from '../MoveTo'
|
||||
|
||||
/**
|
||||
* Alle Dialoge des Dateibrowsers an einer Stelle.
|
||||
*
|
||||
* Es ist **immer höchstens einer** offen – das ist der Grund, warum sie einen
|
||||
* gemeinsamen Zustand haben und hier eine gemeinsame Verzweigung. Am Ende der
|
||||
* Seite standen dafür 150 Zeilen aus dreizehn fast gleich aussehenden Blöcken,
|
||||
* hinter denen die eigentliche Anzeige verschwand.
|
||||
*
|
||||
* Die Verzweigung selbst bleibt, wie sie war: eine ausgezeichnete Vereinigung
|
||||
* (`type`), keine dreizehn Wahrheitswerte. Ein Zustand, in dem zwei Dialoge
|
||||
* zugleich „offen" sind, lässt sich so gar nicht erst hinschreiben.
|
||||
*/
|
||||
export type Dialog =
|
||||
| { type: 'rename'; file: FileItem }
|
||||
| { type: 'rename-folder'; folder: FolderItem }
|
||||
| { type: 'confirm-delete-folder'; folder: FolderItem }
|
||||
| { type: 'new-folder' }
|
||||
| { type: 'share'; file: FileItem }
|
||||
| { type: 'share-with'; resource: { type: 'folder' | 'file'; id: string; name: string } }
|
||||
| { type: 'versions'; file: FileItem }
|
||||
| { type: 'confirm-delete'; file: FileItem }
|
||||
| { type: 'confirm-delete-many'; ids: string[] }
|
||||
| { type: 'move-file'; file: FileItem }
|
||||
| { type: 'move-folder'; folder: FolderItem }
|
||||
| { type: 'tags'; file: FileItem }
|
||||
| { type: 'info'; file: FileItem }
|
||||
|
||||
interface Props {
|
||||
dialog: Dialog | null
|
||||
/** Abbrechen – bei jedem Dialog dieselbe Geste. */
|
||||
onClose: () => void
|
||||
fileTags: Record<string, Tag[]>
|
||||
fileCorrespondents: Record<string, Correspondent>
|
||||
renameFile(file: FileItem, name: string): void
|
||||
renameFolder(folder: FolderItem, name: string): void
|
||||
createFolder(name: string): void
|
||||
deleteFile(file: FileItem): void
|
||||
deleteFolder(folder: FolderItem): void
|
||||
deleteMany(ids: string[]): void
|
||||
moveFile(file: FileItem, targetId: string | null): void
|
||||
moveFolder(folder: FolderItem, targetId: string | null): void
|
||||
versionRestored(file: FileItem): void
|
||||
tagsSaved(fileId: string, tags: Tag[]): void
|
||||
}
|
||||
|
||||
export default function FileDialogs({
|
||||
dialog, onClose, fileTags, fileCorrespondents,
|
||||
renameFile, renameFolder, createFolder,
|
||||
deleteFile, deleteFolder, deleteMany,
|
||||
moveFile, moveFolder, versionRestored, tagsSaved,
|
||||
}: Props) {
|
||||
if (!dialog) return null
|
||||
|
||||
switch (dialog.type) {
|
||||
case 'rename':
|
||||
return (
|
||||
<InputDialog
|
||||
title="Rename"
|
||||
defaultValue={dialog.file.name}
|
||||
confirmLabel="Rename"
|
||||
onConfirm={name => renameFile(dialog.file, name)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'rename-folder':
|
||||
return (
|
||||
<InputDialog
|
||||
title="Rename folder"
|
||||
defaultValue={dialog.folder.name}
|
||||
confirmLabel="Rename"
|
||||
onConfirm={name => renameFolder(dialog.folder, name)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'new-folder':
|
||||
return (
|
||||
<InputDialog
|
||||
title="New folder"
|
||||
placeholder="Folder name"
|
||||
confirmLabel="Create"
|
||||
onConfirm={createFolder}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'confirm-delete-folder':
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Delete folder"
|
||||
message={`Move "${dialog.folder.name}" and all its contents to the trash?`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => deleteFolder(dialog.folder)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'confirm-delete':
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Move to trash"
|
||||
message={`Move "${dialog.file.name}" to the trash?`}
|
||||
confirmLabel="Move to trash"
|
||||
danger
|
||||
onConfirm={() => deleteFile(dialog.file)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'confirm-delete-many':
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Move to trash"
|
||||
message={`Move ${dialog.ids.length} ${dialog.ids.length === 1 ? 'file' : 'files'} to the trash? You can restore them from there.`}
|
||||
confirmLabel={`Move ${dialog.ids.length} to trash`}
|
||||
danger
|
||||
onConfirm={() => deleteMany(dialog.ids)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'share':
|
||||
return <ShareDialog file={dialog.file} onClose={onClose} />
|
||||
|
||||
case 'share-with':
|
||||
return <ShareWithDialog resource={dialog.resource} onClose={onClose} />
|
||||
|
||||
case 'versions':
|
||||
return (
|
||||
<VersionsDialog
|
||||
file={dialog.file}
|
||||
onClose={onClose}
|
||||
onRestored={versionRestored}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'info':
|
||||
return (
|
||||
<FileInfoDialog
|
||||
file={dialog.file}
|
||||
tags={fileTags[dialog.file.id] ?? []}
|
||||
correspondent={fileCorrespondents[dialog.file.id]}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'tags':
|
||||
return (
|
||||
<TagDialog
|
||||
file={dialog.file}
|
||||
current={fileTags[dialog.file.id] ?? []}
|
||||
currentCorrespondent={fileCorrespondents[dialog.file.id]}
|
||||
onClose={onClose}
|
||||
onSaved={tags => tagsSaved(dialog.file.id, tags)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'move-file':
|
||||
return (
|
||||
<MoveTo
|
||||
title={`Move "${dialog.file.name}"`}
|
||||
onConfirm={targetId => moveFile(dialog.file, targetId)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'move-folder':
|
||||
return (
|
||||
<MoveTo
|
||||
title={`Move "${dialog.folder.name}"`}
|
||||
excludeId={dialog.folder.id}
|
||||
onConfirm={targetId => moveFolder(dialog.folder, targetId)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import SearchBox from '../SearchBox'
|
||||
import { DownloadIcon, TrashIcon } from '../icons'
|
||||
import type { SearchHit } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Die Bedienelemente rechts in der Kopfleiste des Dateibrowsers.
|
||||
*
|
||||
* Zwei Zustände, nicht zwei Leisten: Solange etwas ausgewählt ist, **ersetzt**
|
||||
* die Auswahlleiste Suche und Sortierung. Sie danebenzustellen wäre auf einem
|
||||
* schmalen Schirm eine dritte Zeile – und solange man auswählt, will man
|
||||
* ohnehin nichts anderes.
|
||||
*/
|
||||
|
||||
export type ViewMode = 'grid' | 'list'
|
||||
export type Sort = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc'
|
||||
|
||||
export const sortLabels: Record<Sort, string> = {
|
||||
'name-asc': 'Name A–Z',
|
||||
'name-desc': 'Name Z–A',
|
||||
'size-asc': 'Smallest first',
|
||||
'size-desc': 'Largest first',
|
||||
'date-asc': 'Oldest first',
|
||||
'date-desc': 'Newest first',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectedCount: number
|
||||
onDownloadSelection: () => void
|
||||
onTrashSelection: () => void
|
||||
onClearSelection: () => void
|
||||
onOpenFolder: (folderId: string | null, folderName: string) => void
|
||||
onOpenFile: (hit: SearchHit, term: string) => void
|
||||
sort: Sort
|
||||
onSort: (s: Sort) => void
|
||||
viewMode: ViewMode
|
||||
onViewMode: (v: ViewMode) => void
|
||||
onNewFolder: () => void
|
||||
onNewDrawing: () => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
export default function FileToolbar({
|
||||
selectedCount, onDownloadSelection, onTrashSelection, onClearSelection,
|
||||
onOpenFolder, onOpenFile, sort, onSort, viewMode, onViewMode,
|
||||
onNewFolder, onNewDrawing, onUpload,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{selectedCount > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<button onClick={onDownloadSelection} style={primaryButton}>
|
||||
<DownloadIcon /> Download as ZIP
|
||||
</button>
|
||||
<button onClick={onTrashSelection} style={dangerButton}>
|
||||
<TrashIcon /> Move to trash
|
||||
</button>
|
||||
<button onClick={onClearSelection} style={quietButton}>Clear</button>
|
||||
</div>
|
||||
) : (
|
||||
<SearchBox onOpenFolder={onOpenFolder} onOpenFile={onOpenFile} />
|
||||
)}
|
||||
|
||||
<select
|
||||
value={sort}
|
||||
onChange={e => onSort(e.target.value as Sort)}
|
||||
title="Sort by"
|
||||
style={{
|
||||
padding: '6px 8px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{(Object.keys(sortLabels) as Sort[]).map(key => (
|
||||
<option key={key} value={key}>{sortLabels[key]}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--color-border)' }}>
|
||||
<button onClick={() => onViewMode('grid')} title="Grid view" style={toggleStyle(viewMode === 'grid')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onViewMode('list')} title="List view" style={toggleStyle(viewMode === 'list')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1" fill="currentColor" /><circle cx="3" cy="12" r="1" fill="currentColor" /><circle cx="3" cy="18" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onNewFolder}
|
||||
title="New folder"
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={onNewDrawing}
|
||||
title="New drawing or mind map"
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Drawing
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
title="Upload files (drag & drop for folders)"
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const primaryButton: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, color: '#fff', background: 'var(--color-primary)',
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
const dangerButton: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, flexShrink: 0,
|
||||
border: '1px solid var(--color-danger)', background: 'transparent',
|
||||
color: 'var(--color-danger)',
|
||||
}
|
||||
|
||||
const quietButton: React.CSSProperties = {
|
||||
padding: '6px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 12,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', flexShrink: 0,
|
||||
}
|
||||
|
||||
function toggleStyle(active: boolean): React.CSSProperties {
|
||||
return {
|
||||
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--color-surface-2)' : 'transparent',
|
||||
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { MenuItem } from '../ContextMenu'
|
||||
import type { FileItem, FolderItem } from '../../api/client'
|
||||
import { isOfficeFile } from '../../api/client'
|
||||
import {
|
||||
DownloadIcon, ShareIcon, HistoryIcon, RenameIcon, MoveIcon, TrashIcon,
|
||||
FolderOpenIcon, StarIcon, CheckIcon, PeopleIcon, TagIcon, OfficeIcon, InfoIcon,
|
||||
} from '../icons'
|
||||
|
||||
/**
|
||||
* Die beiden Kontextmenüs des Dateibrowsers.
|
||||
*
|
||||
* Ausgelagert, weil sie **Beschreibung** sind und keine Ablaufsteuerung: eine
|
||||
* Liste aus Beschriftung, Symbol und einem Ruf nach außen. Zwischen dem
|
||||
* Hochladen und den Dialogen der Seite standen 120 Zeilen davon; hier lassen
|
||||
* sie sich am Stück lesen – und die Reihenfolge der Einträge ist genau das,
|
||||
* worüber man beim Lesen nachdenkt.
|
||||
*
|
||||
* Die Gruppierung ist Absicht und bleibt, wie sie war: erst, was man am
|
||||
* häufigsten will, dann Teilen, dann Verwalten, ganz unten abgesetzt das
|
||||
* Löschen.
|
||||
*/
|
||||
|
||||
export interface FileActions {
|
||||
/** Steht ein Office-Server bereit? Ohne ihn fehlt „Edit in Office". */
|
||||
officeEnabled: boolean
|
||||
download(file: FileItem): void
|
||||
properties(file: FileItem): void
|
||||
shareWithPeople(file: FileItem): void
|
||||
shareLink(file: FileItem): void
|
||||
organise(file: FileItem): void
|
||||
editInOffice(file: FileItem): void
|
||||
versionHistory(file: FileItem): void
|
||||
setVersioning(file: FileItem, enabled: boolean): void
|
||||
rename(file: FileItem): void
|
||||
move(file: FileItem): void
|
||||
remove(file: FileItem): void
|
||||
}
|
||||
|
||||
export function fileMenuItems(file: FileItem, a: FileActions): MenuItem[] {
|
||||
return [
|
||||
{ label: 'Download', icon: <DownloadIcon />, onClick: () => a.download(file) },
|
||||
// Nachschlagen statt anfassen – deshalb direkt beim Herunterladen und
|
||||
// nicht unten bei Umbenennen/Verschieben. Beantwortet Fragen, die die
|
||||
// Kachel nicht beantwortet: Bildmaße, genaue Bytezahl, Prüfsumme.
|
||||
{ label: 'Properties…', icon: <InfoIcon />, onClick: () => a.properties(file) },
|
||||
{ separator: true },
|
||||
{ label: 'Share with people…', icon: <PeopleIcon />, onClick: () => a.shareWithPeople(file) },
|
||||
{ label: 'Share link…', icon: <ShareIcon />, onClick: () => a.shareLink(file) },
|
||||
{ separator: true },
|
||||
// Zwischen Teilen und Verwalten: Etikettieren ist beides nicht ganz –
|
||||
// es ordnet ein, ohne die Datei anzufassen.
|
||||
// Hiess bis zum 23.08.2026 „Tags…". Der Dialog setzt inzwischen drei
|
||||
// Dinge – Etiketten, Absender und Belegdatum –, und ein Menuepunkt,
|
||||
// der nur eines davon nennt, laesst die anderen beiden unauffindbar.
|
||||
{ label: 'Organise…', icon: <TagIcon />, onClick: () => a.organise(file) },
|
||||
// Nur bei passenden Dateien und nur, wenn es einen Office-Server gibt.
|
||||
// Ein Punkt, der immer scheitert, ist schlimmer als keiner.
|
||||
...(a.officeEnabled && isOfficeFile(file.name)
|
||||
? [{ label: 'Edit in Office…', icon: <OfficeIcon />, onClick: () => a.editInOffice(file) }]
|
||||
: []),
|
||||
{ separator: true },
|
||||
{ label: 'Version history…', icon: <HistoryIcon />, onClick: () => a.versionHistory(file) },
|
||||
{
|
||||
label: 'Keep versions',
|
||||
icon: <CheckIcon />,
|
||||
checked: file.versioning_enabled,
|
||||
onClick: () => a.setVersioning(file, !file.versioning_enabled),
|
||||
},
|
||||
{ separator: true },
|
||||
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => a.rename(file) },
|
||||
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => a.move(file) },
|
||||
{ separator: true },
|
||||
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => a.remove(file) },
|
||||
]
|
||||
}
|
||||
|
||||
export interface FolderActions {
|
||||
isFavourite(folderId: string): boolean
|
||||
open(folder: FolderItem): void
|
||||
toggleFavourite(folder: FolderItem, isFav: boolean): void
|
||||
shareWithPeople(folder: FolderItem): void
|
||||
downloadZip(folder: FolderItem): void
|
||||
rename(folder: FolderItem): void
|
||||
move(folder: FolderItem): void
|
||||
remove(folder: FolderItem): void
|
||||
}
|
||||
|
||||
export function folderMenuItems(folder: FolderItem, a: FolderActions): MenuItem[] {
|
||||
const isFav = a.isFavourite(folder.id)
|
||||
|
||||
// Ein Gruppenordner gehört der Gruppe, nicht dem, der ihn gerade offen hat.
|
||||
// Umbenennen, Verschieben und Löschen fehlen deshalb ganz statt als
|
||||
// Einträge, die nur eine Fehlermeldung liefern – der Server lehnt sie
|
||||
// ohnehin ab. Alles *innerhalb* des Ordners bleibt normal bedienbar.
|
||||
const isGroupFolder = folder.group_id !== null
|
||||
|
||||
return [
|
||||
{ label: 'Open', icon: <FolderOpenIcon />, onClick: () => a.open(folder) },
|
||||
{
|
||||
label: 'Favourite',
|
||||
icon: <StarIcon filled={isFav} />,
|
||||
checked: isFav,
|
||||
onClick: () => a.toggleFavourite(folder, isFav),
|
||||
},
|
||||
...(isGroupFolder ? [] : [
|
||||
{ separator: true as const },
|
||||
{ label: 'Share with people…', icon: <PeopleIcon />, onClick: () => a.shareWithPeople(folder) },
|
||||
{ separator: true as const },
|
||||
// Ganzer Ordnerinhalt als Archiv – mit Unterordnern, ihre Struktur
|
||||
// bleibt im Archiv erhalten.
|
||||
{ label: 'Download as ZIP', icon: <DownloadIcon />, onClick: () => a.downloadZip(folder) },
|
||||
{ separator: true as const },
|
||||
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => a.rename(folder) },
|
||||
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => a.move(folder) },
|
||||
{ separator: true as const },
|
||||
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => a.remove(folder) },
|
||||
]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortFiles, sortFolders } from './sorting'
|
||||
import type { FileItem, FolderItem } from '../../api/client'
|
||||
|
||||
function file(name: string, size: number, updated: string): FileItem {
|
||||
return { id: name, name, size, updated_at: updated } as FileItem
|
||||
}
|
||||
function folder(name: string): FolderItem {
|
||||
return { id: name, name } as FolderItem
|
||||
}
|
||||
|
||||
const files = [
|
||||
file('beta.txt', 300, '2026-01-03T00:00:00Z'),
|
||||
file('alpha.txt', 100, '2026-01-02T00:00:00Z'),
|
||||
file('gamma.txt', 200, '2026-01-01T00:00:00Z'),
|
||||
]
|
||||
|
||||
const names = (list: { name: string }[]) => list.map(x => x.name)
|
||||
|
||||
describe('sortFiles', () => {
|
||||
it('sortiert nach Namen in beide Richtungen', () => {
|
||||
expect(names(sortFiles(files, 'name-asc'))).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt'])
|
||||
expect(names(sortFiles(files, 'name-desc'))).toEqual(['gamma.txt', 'beta.txt', 'alpha.txt'])
|
||||
})
|
||||
|
||||
it('sortiert nach Größe', () => {
|
||||
expect(names(sortFiles(files, 'size-asc'))).toEqual(['alpha.txt', 'gamma.txt', 'beta.txt'])
|
||||
expect(names(sortFiles(files, 'size-desc'))).toEqual(['beta.txt', 'gamma.txt', 'alpha.txt'])
|
||||
})
|
||||
|
||||
it('sortiert nach Datum', () => {
|
||||
expect(names(sortFiles(files, 'date-asc'))).toEqual(['gamma.txt', 'alpha.txt', 'beta.txt'])
|
||||
expect(names(sortFiles(files, 'date-desc'))).toEqual(['beta.txt', 'alpha.txt', 'gamma.txt'])
|
||||
})
|
||||
|
||||
it('lässt die übergebene Liste unangetastet', () => {
|
||||
const before = names(files)
|
||||
sortFiles(files, 'size-desc')
|
||||
expect(names(files)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortFolders', () => {
|
||||
const folders = [folder('Zeta'), folder('Alpha'), folder('Mitte')]
|
||||
|
||||
it('sortiert nach Namen', () => {
|
||||
expect(names(sortFolders(folders, 'name-asc'))).toEqual(['Alpha', 'Mitte', 'Zeta'])
|
||||
expect(names(sortFolders(folders, 'name-desc'))).toEqual(['Zeta', 'Mitte', 'Alpha'])
|
||||
})
|
||||
|
||||
it('bleibt bei Größe und Datum aufsteigend beim Namen', () => {
|
||||
// Ordner haben keine Größe und kein aussagekräftiges Datum – nach ihnen
|
||||
// zu sortieren ergäbe eine zufällige Reihe, die nach Fehler aussieht.
|
||||
for (const sort of ['size-asc', 'size-desc', 'date-asc', 'date-desc'] as const) {
|
||||
expect(names(sortFolders(folders, sort))).toEqual(['Alpha', 'Mitte', 'Zeta'])
|
||||
}
|
||||
})
|
||||
})
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user