Compare commits
38
Commits
1e1f35955d
...
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 | ||
|
|
fce8053d09 | ||
|
|
ec8f162fd5 |
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".
|
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||||
MAX_VERSIONS_KEPT=5
|
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) ════════════════════════
|
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
||||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
||||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
# 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.
|
# sonst verweigert der Browser den Rahmen.
|
||||||
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
|
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
|
||||||
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
|
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_PUBLIC_URL=
|
||||||
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
|
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
|
||||||
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
|
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=
|
OFFICE_JWT_SECRET=
|
||||||
|
|
||||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
# Secrets
|
# Secrets
|
||||||
build/.env
|
build/.env*
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
# 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)
|
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
||||||
frontend/public/pdfjs/
|
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)
|
## Kalender (CalDAV)
|
||||||
|
|
||||||
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
|
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,
|
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
|
||||||
OnlyOffice und Euro-Office auf 80.**
|
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
|
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
||||||
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
||||||
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
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"
|
- "${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.
|
Abbild.
|
||||||
|
|
||||||
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
|
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
|
||||||
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
|
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
|
||||||
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
|
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
|
||||||
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
|
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
|
||||||
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
|
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
|
||||||
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
|
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
|
||||||
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
|
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
|
||||||
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
|
> über die Ermittlung hinaus.
|
||||||
>
|
>
|
||||||
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
|
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
|
||||||
> nach Aktenlage hakt:
|
> 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
|
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
||||||
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
> „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
|
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||||||
> das der Ort.
|
> das der Ort.
|
||||||
>
|
>
|
||||||
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
|
> **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
|
||||||
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
|
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
|
||||||
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
|
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
|
||||||
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
|
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
|
||||||
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
|
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
|
||||||
> gesetzt – sonst läuft die API offen.
|
> **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
|
||||||
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
|
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Welche Dateien, und wie gut
|
### 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,
|
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
|
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
|
||||||
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
|
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
|
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||||||
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
**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.
|
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
||||||
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
||||||
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
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
|
**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
|
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
||||||
|
|||||||
+927
-2
@@ -95,6 +95,223 @@ Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin
|
|||||||
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
|
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
|
||||||
aus go-webdav; Clients prüfen auf 2xx.
|
aus go-webdav; Clients prüfen auf 2xx.
|
||||||
|
|
||||||
|
## Freigabelinks: Selbsttest per Skript
|
||||||
|
|
||||||
|
Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung
|
||||||
|
erreicht. Ein Fehler darin ist kein Anzeigefehler, sondern eine offene Tür.
|
||||||
|
|
||||||
|
python3 scripts/sharing-selbsttest.py
|
||||||
|
|
||||||
|
24 Prüfungen: anlegen nur angemeldet und nur für **eigene** Dateien (auch der
|
||||||
|
Admin kommt nicht an fremde), Tokenlänge, öffentlicher Abruf ohne Anmeldung,
|
||||||
|
Herunterladen byte-genau, Passwortschutz (ohne, falsch, richtig),
|
||||||
|
Download-Zähler, Widerrufen (danach ist der Link tot), ein Link auf eine
|
||||||
|
gelöschte Datei liefert nichts, und die Sperre nach wiederholten
|
||||||
|
Fehlversuchen.
|
||||||
|
|
||||||
|
Der Test legt einen Zweitnutzer an – ohne ihn lässt sich „fremde Dateien
|
||||||
|
bleiben fremd" gar nicht prüfen – und räumt ihn samt Dateien wieder weg.
|
||||||
|
|
||||||
|
**Nach einem Lauf ist die eigene IP 15 Minuten gesperrt**, für
|
||||||
|
passwortgeschützte Freigabelinks (nicht fürs Anmelden). Das ist der Preis der
|
||||||
|
letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb
|
||||||
|
der Frist bricht früh ab und sagt es. Beim Entwickeln hilft ein
|
||||||
|
`docker restart aether-app` – die Sperre liegt im Arbeitsspeicher.
|
||||||
|
|
||||||
|
### Was dieser Test beim ersten Lauf gefunden hat
|
||||||
|
|
||||||
|
Drei Dinge, alle behoben – hier notiert, weil sie zeigen, wonach man in
|
||||||
|
diesem Bereich sucht:
|
||||||
|
|
||||||
|
- **Der Download-Zähler blieb auf 0.** Die Zählung lief in einer Goroutine
|
||||||
|
mit dem Kontext der Anfrage – der wird abgebrochen, sobald die Antwort
|
||||||
|
draußen ist, und die Zählung verlor das Rennen fast immer.
|
||||||
|
- **`owner_id` stand in der öffentlichen Antwort.** Das ganze
|
||||||
|
Datenbankobjekt durchzureichen war bequem, gab aber die Kennung des
|
||||||
|
Besitzers an jeden heraus, der den Link hat: Zwei Links desselben
|
||||||
|
Besitzers ließen sich einander zuordnen, ohne dass irgendwer angemeldet
|
||||||
|
ist.
|
||||||
|
- **Passwörter ließen sich unbegrenzt durchprobieren.** Der öffentliche
|
||||||
|
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")
|
## Notfall: Migration hängt fest ("Dirty database version")
|
||||||
|
|
||||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||||
@@ -1139,8 +1356,9 @@ Die Daten liegen seit dem 23.08.2026 in einem gewöhnlichen Verzeichnis
|
|||||||
## Office – Collabora / OnlyOffice / Euro-Office (24.08.2026)
|
## Office – Collabora / OnlyOffice / Euro-Office (24.08.2026)
|
||||||
|
|
||||||
Aether ist WOPI-Host; der Office-Server holt die Datei selbst ab und schreibt
|
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
|
sie zurück. Gemessen wurde mit **Collabora** und – seit dem 16.09.2026 – mit
|
||||||
sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher).
|
**Euro-Office**, beide auf dieser Anlage. OnlyOffice lief hier nur bis zur
|
||||||
|
Ermittlung; Euro-Office ist sein Fork und spricht dieselbe Schnittstelle.
|
||||||
|
|
||||||
### Öffnen
|
### Öffnen
|
||||||
- [ ] Rechtsklick auf .odt/.docx/.ods → „Edit in Office…" erscheint 🔎
|
- [ ] Rechtsklick auf .odt/.docx/.ods → „Edit in Office…" erscheint 🔎
|
||||||
@@ -1178,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)
|
## Absender / Korrespondent (23.08.2026)
|
||||||
|
|
||||||
Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer**
|
Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer**
|
||||||
|
|||||||
+76
-11
@@ -32,11 +32,13 @@ import (
|
|||||||
"aether/internal/gc"
|
"aether/internal/gc"
|
||||||
"aether/internal/office"
|
"aether/internal/office"
|
||||||
appoidc "aether/internal/oidc"
|
appoidc "aether/internal/oidc"
|
||||||
|
"aether/internal/rescan"
|
||||||
"aether/internal/sharing"
|
"aether/internal/sharing"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
"aether/internal/tags"
|
"aether/internal/tags"
|
||||||
"aether/internal/thumbnails"
|
"aether/internal/thumbnails"
|
||||||
appwebdav "aether/internal/webdav"
|
appwebdav "aether/internal/webdav"
|
||||||
|
"aether/pkg/bg"
|
||||||
"aether/pkg/config"
|
"aether/pkg/config"
|
||||||
appdb "aether/pkg/db"
|
appdb "aether/pkg/db"
|
||||||
"aether/pkg/middleware"
|
"aether/pkg/middleware"
|
||||||
@@ -69,6 +71,7 @@ func spaHandler(root string) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||||||
|
setStaticCaching(w, upath)
|
||||||
fs.ServeHTTP(w, r)
|
fs.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,10 +84,37 @@ func spaHandler(root string) http.Handler {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setStaticCaching(w, "/index.html")
|
||||||
http.ServeFile(w, r, index)
|
http.ServeFile(w, r, index)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setStaticCaching entscheidet, was der Browser behalten darf.
|
||||||
|
//
|
||||||
|
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
|
||||||
|
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
|
||||||
|
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
|
||||||
|
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
|
||||||
|
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
|
||||||
|
// als rätselhafter Fehler mitten in der Anwendung.
|
||||||
|
//
|
||||||
|
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
|
||||||
|
//
|
||||||
|
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
|
||||||
|
// ändert sich der Name. Solche Dateien darf der Browser für immer
|
||||||
|
// behalten – "immutable" spart ihm sogar die Rückfrage.
|
||||||
|
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
|
||||||
|
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
|
||||||
|
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
|
||||||
|
// der Server dann meist nur mit 304 und ein paar Byte.
|
||||||
|
func setStaticCaching(w http.ResponseWriter, upath string) {
|
||||||
|
if strings.HasPrefix(upath, "/assets/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,6 +127,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
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")
|
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -147,13 +191,16 @@ func main() {
|
|||||||
Timeout: cfg.OCRTimeout,
|
Timeout: cfg.OCRTimeout,
|
||||||
TmpDir: cfg.TmpPath,
|
TmpDir: cfg.TmpPath,
|
||||||
}, log.Logger)
|
}, 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
|
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||||
// Anfang der gesamte Bestand.
|
// Anfang der gesamte Bestand.
|
||||||
go func() {
|
bg.Go("Bestand nachtragen", func() {
|
||||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||||
@@ -176,7 +223,7 @@ func main() {
|
|||||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,12 +236,15 @@ func main() {
|
|||||||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||||||
|
|
||||||
shareStore := sharing.NewStore(pool)
|
shareStore := sharing.NewStore(pool)
|
||||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
|
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
|
||||||
|
|
||||||
collector := gc.New(pool, blobStore)
|
collector := gc.New(pool, blobStore)
|
||||||
|
|
||||||
adminStore := admin.NewStore(pool)
|
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
|
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||||
@@ -217,7 +267,7 @@ func main() {
|
|||||||
var officeHandler *office.Handler
|
var officeHandler *office.Handler
|
||||||
if cfg.OfficeEnabled() {
|
if cfg.OfficeEnabled() {
|
||||||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
|
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||||||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||||||
@@ -248,7 +298,7 @@ func main() {
|
|||||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
||||||
|
|
||||||
// CalDAV
|
// CalDAV
|
||||||
calStore := calendar.NewStore(pool)
|
calStore := calendar.NewStore(pool, aclStore)
|
||||||
calHandler := calendar.NewHandler(authStore, calStore)
|
calHandler := calendar.NewHandler(authStore, calStore)
|
||||||
calAPIHandler := calendar.NewAPIHandler(calStore)
|
calAPIHandler := calendar.NewAPIHandler(calStore)
|
||||||
|
|
||||||
@@ -454,6 +504,7 @@ func main() {
|
|||||||
r.Get("/search", fileHandler.Search)
|
r.Get("/search", fileHandler.Search)
|
||||||
r.Get("/files/{id}/download", fileHandler.Download)
|
r.Get("/files/{id}/download", fileHandler.Download)
|
||||||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
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.
|
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||||||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||||
@@ -461,7 +512,6 @@ func main() {
|
|||||||
r.Get("/office/session/{id}", officeHandler.Session)
|
r.Get("/office/session/{id}", officeHandler.Session)
|
||||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||||
// Telefon der Normalfall.
|
// Telefon der Normalfall.
|
||||||
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
|
||||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||||
@@ -518,6 +568,10 @@ func main() {
|
|||||||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||||||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||||||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
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)
|
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||||||
|
|
||||||
// App passwords (WebDAV/sync clients)
|
// App passwords (WebDAV/sync clients)
|
||||||
@@ -557,6 +611,7 @@ func main() {
|
|||||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||||
r.Post("/admin/gc", adminHandler.RunGC)
|
r.Post("/admin/gc", adminHandler.RunGC)
|
||||||
|
r.Post("/admin/rescan", adminHandler.Rescan)
|
||||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||||
// Platz, der in keinem Kontingent auftaucht.
|
// Platz, der in keinem Kontingent auftaucht.
|
||||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||||
@@ -596,6 +651,10 @@ func main() {
|
|||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
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() {
|
go func() {
|
||||||
<-quit
|
<-quit
|
||||||
log.Info().Msg("shutting down...")
|
log.Info().Msg("shutting down...")
|
||||||
@@ -661,8 +720,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate thumbnail in background – non-fatal if it fails.
|
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||||||
go func() {
|
//
|
||||||
|
// **Ü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()
|
ctx := context.Background()
|
||||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -678,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
_ = 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/rs/zerolog v1.33.0
|
||||||
github.com/teambition/rrule-go v1.8.2
|
github.com/teambition/rrule-go v1.8.2
|
||||||
golang.org/x/crypto v0.53.0
|
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/net v0.56.0
|
||||||
golang.org/x/oauth2 v0.36.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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.38.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=
|
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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
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.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
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 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission {
|
|||||||
}
|
}
|
||||||
return ""
|
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
|
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 {
|
func userFrom(r *http.Request) uuid.UUID {
|
||||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||||
return u.ID
|
return u.ID
|
||||||
|
|||||||
@@ -41,17 +41,30 @@ type SharedResource struct {
|
|||||||
|
|
||||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
// ── 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
|
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||||
// ein Nutzer oder eine Gruppe ist.
|
// 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) {
|
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) {
|
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() {
|
if !perm.valid() {
|
||||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
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
|
var e Entry
|
||||||
err := s.db.QueryRow(ctx,
|
err := s.db.QueryRow(ctx,
|
||||||
`INSERT INTO acl_entries
|
`INSERT INTO acl_entries
|
||||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
(subject_user_id, subject_group_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
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`,
|
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)
|
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||||
|
|
||||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
// 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.
|
// über den Fehlercode.
|
||||||
var pgErr *pgconn.PgError
|
var pgErr *pgconn.PgError
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("grant access: %w", err)
|
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
|
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
|
var e Entry
|
||||||
err := s.db.QueryRow(ctx,
|
err := s.db.QueryRow(ctx,
|
||||||
`UPDATE acl_entries SET permission = $1
|
`UPDATE acl_entries SET permission = $1
|
||||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
AND resource_calendar_id IS NOT DISTINCT FROM $4
|
||||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
AND subject_user_id IS NOT DISTINCT FROM $5
|
||||||
|
AND subject_group_id IS NOT DISTINCT FROM $6
|
||||||
RETURNING id, permission, created_at`,
|
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)
|
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -127,7 +142,8 @@ func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
|||||||
`DELETE FROM acl_entries e
|
`DELETE FROM acl_entries e
|
||||||
WHERE e.id = $1
|
WHERE e.id = $1
|
||||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.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 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)
|
entryID, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("revoke access: %w", err)
|
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)
|
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) {
|
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT e.id,
|
SELECT e.id,
|
||||||
@@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string {
|
|||||||
}
|
}
|
||||||
return "user"
|
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"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"aether/internal/audit"
|
"aether/internal/audit"
|
||||||
|
"aether/internal/rescan"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,3 +119,59 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
response.JSON(w, http.StatusOK, usage)
|
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/auth"
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/gc"
|
"aether/internal/gc"
|
||||||
|
"aether/internal/rescan"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
"aether/pkg/middleware"
|
"aether/pkg/middleware"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
@@ -30,10 +31,11 @@ type Handler struct {
|
|||||||
auditor *audit.Logger
|
auditor *audit.Logger
|
||||||
gc *gc.Collector
|
gc *gc.Collector
|
||||||
files *files.Store
|
files *files.Store
|
||||||
|
rescan *rescan.Scanner
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
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}
|
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
|
// 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
|
// GET /api/v1/calendars
|
||||||
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
user := auth.UserFromContext(r.Context())
|
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 {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||||
return
|
return
|
||||||
@@ -116,7 +128,8 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
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 {
|
if err == ErrNotFound {
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
@@ -125,6 +138,7 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cal := &zugriff.Calendar
|
||||||
objs, err := h.store.ListObjects(r.Context(), cal.ID)
|
objs, err := h.store.ListObjects(r.Context(), cal.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "list objects failed")
|
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")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
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 {
|
if err == ErrNotFound {
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err == ErrReadOnly {
|
||||||
|
response.Error(w, http.StatusForbidden, "calendar is shared read-only")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cal := &zugriff.Calendar
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MiB – reicht für sehr große Kalender
|
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()
|
src, err := ical.NewDecoder(r.Body).Decode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -120,7 +120,10 @@ func parseCalendarPath(p string) (ref string, rest string, err error) {
|
|||||||
return parts[1], rest, nil
|
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)
|
u, err := userFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -129,7 +132,7 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
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) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
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
|
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"}
|
comps := []string{"VEVENT"}
|
||||||
if c.SupportsTodo {
|
if c.SupportsTodo {
|
||||||
comps = append(comps, "VTODO")
|
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{
|
return caldav.Calendar{
|
||||||
Path: calendarPath(username, c.Ref()),
|
Path: calendarPath(username, c.Ref()),
|
||||||
Name: c.Name,
|
Name: name,
|
||||||
Description: c.Description,
|
Description: c.Description,
|
||||||
MaxResourceSize: 5 * 1024 * 1024,
|
MaxResourceSize: 5 * 1024 * 1024,
|
||||||
SupportedComponentSet: comps,
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list calendars: %w", err)
|
return nil, fmt.Errorf("list calendars: %w", err)
|
||||||
}
|
}
|
||||||
out := make([]caldav.Calendar, len(all))
|
out := make([]caldav.Calendar, len(all))
|
||||||
for i, c := range all {
|
for i := range all {
|
||||||
out[i] = toCalDAVCalendar(u.Username, &c)
|
out[i] = toCalDAVCalendar(u.Username, &all[i])
|
||||||
}
|
}
|
||||||
return out, nil
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
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")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -552,13 +553,17 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
|||||||
return opt
|
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) {
|
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
|
||||||
user := auth.UserFromContext(r.Context())
|
user := auth.UserFromContext(r.Context())
|
||||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, false
|
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 uuid.UUID{}, false
|
||||||
}
|
}
|
||||||
return calID, true
|
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")
|
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||||||
return
|
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
|
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
|
||||||
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
|
// 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")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "PROPPATCH":
|
case "PROPPATCH":
|
||||||
h.handleProppatch(w, r, user)
|
h.handleProppatch(w, r, user)
|
||||||
return
|
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:
|
case http.MethodDelete:
|
||||||
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
|
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
|
||||||
// die Bibliothek weiterhin selbst.
|
// die Bibliothek weiterhin selbst.
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ type Calendar struct {
|
|||||||
// Adresse, es gilt die UUID. Siehe Ref.
|
// Adresse, es gilt die UUID. Siehe Ref.
|
||||||
PathSegment string `json:"-"`
|
PathSegment string `json:"-"`
|
||||||
CTag int64 `json:"ctag"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,17 @@ const (
|
|||||||
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
|
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
|
||||||
const maxPropfindBody = 1 << 20
|
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,
|
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
|
||||||
// und baut den Rumpf ohne sie neu auf.
|
// 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) {
|
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
||||||
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
|
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
return wantedExtras{ctag: true, color: true}, body
|
return alleExtras(), body
|
||||||
}
|
}
|
||||||
|
|
||||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||||
@@ -72,7 +80,7 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
|||||||
switch t := tok.(type) {
|
switch t := tok.(type) {
|
||||||
case xml.StartElement:
|
case xml.StartElement:
|
||||||
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
|
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
|
||||||
return wantedExtras{ctag: true, color: true}, body
|
return alleExtras(), body
|
||||||
}
|
}
|
||||||
if !inProp {
|
if !inProp {
|
||||||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||||||
@@ -89,6 +97,10 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
|||||||
want.ctag = true
|
want.ctag = true
|
||||||
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
|
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
|
||||||
want.color = true
|
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:
|
default:
|
||||||
keep = append(keep, t.Name)
|
keep = append(keep, t.Name)
|
||||||
}
|
}
|
||||||
@@ -159,6 +171,15 @@ func appleColor(hex string) string {
|
|||||||
return strings.ToUpper(h) + "FF"
|
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 {
|
func extrasPropstat(cal *Calendar, want wantedExtras) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if want.ctag {
|
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)
|
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 {
|
if b.Len() == 0 {
|
||||||
return ""
|
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
|
// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine
|
||||||
// bleiben unangetastet.
|
// bleiben unangetastet.
|
||||||
func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte {
|
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 {
|
if err != nil {
|
||||||
return body // im Zweifel die Antwort der Bibliothek, unverändert
|
return body // im Zweifel die Antwort der Bibliothek, unverändert
|
||||||
}
|
}
|
||||||
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
|
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
|
||||||
// erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat.
|
// 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))
|
byRef := make(map[string]*Calendar, len(cals))
|
||||||
for i := range cals {
|
for i := range cals {
|
||||||
byRef[cals[i].Ref()] = &cals[i]
|
byRef[cals[i].Ref()] = &cals[i].Calendar
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeTag = "</response>"
|
const closeTag = "</response>"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"aether/internal/acl"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("calendar: not found")
|
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 {
|
type Store struct {
|
||||||
db *pgxpool.Pool
|
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 {
|
func NewStore(db *pgxpool.Pool, rights *acl.Store) *Store {
|
||||||
return &Store{db: db}
|
return &Store{db: db, rights: rights}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
||||||
rows, err := s.db.Query(ctx,
|
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`,
|
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||||||
ownerID,
|
ownerID,
|
||||||
)
|
)
|
||||||
@@ -41,7 +47,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
|
|||||||
var out []Calendar
|
var out []Calendar
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Calendar
|
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)
|
return nil, fmt.Errorf("scan calendar: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, c)
|
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) {
|
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
|
||||||
var c Calendar
|
var c Calendar
|
||||||
err := s.db.QueryRow(ctx,
|
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`,
|
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
|
||||||
ownerID, id,
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
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) {
|
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
|
||||||
var c Calendar
|
var c Calendar
|
||||||
err := s.db.QueryRow(ctx,
|
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
|
FROM cal_calendars
|
||||||
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
||||||
ORDER BY path_segment NULLS LAST
|
ORDER BY path_segment NULLS LAST
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
ownerID, ref,
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -102,9 +108,9 @@ func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment
|
|||||||
err := s.db.QueryRow(ctx,
|
err := s.db.QueryRow(ctx,
|
||||||
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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,
|
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) {
|
if isUniqueViolation(err) {
|
||||||
return nil, ErrConflict
|
return nil, ErrConflict
|
||||||
}
|
}
|
||||||
@@ -120,7 +126,7 @@ func isUniqueViolation(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
|
// 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
|
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
|
||||||
// CalDAV-Client kann das.
|
// CalDAV-Client kann das.
|
||||||
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
|
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 {
|
if len(cals) > 0 {
|
||||||
return &cals[0], nil
|
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) {
|
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,
|
err := s.db.QueryRow(ctx,
|
||||||
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
||||||
VALUES ($1, $2, $3, $4)
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("insert calendar: %w", err)
|
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),
|
description = COALESCE($5, description),
|
||||||
timezone = COALESCE($6, timezone)
|
timezone = COALESCE($6, timezone)
|
||||||
WHERE owner_id = $1 AND id = $2
|
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,
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -248,22 +254,35 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
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
|
var o Object
|
||||||
err = tx.QueryRow(ctx,
|
err = tx.QueryRow(ctx,
|
||||||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
|
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||||
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
|
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`,
|
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)
|
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("upsert object: %w", err)
|
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 {
|
// Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
|
||||||
return nil, fmt.Errorf("bump ctag: %w", err)
|
// 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 {
|
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
|
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,
|
row := tx.QueryRow(ctx,
|
||||||
`UPDATE cal_objects SET calendar_id = $1, updated_at = now()
|
`UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
|
||||||
WHERE calendar_id = $2 AND uid = $3
|
WHERE calendar_id = $3 AND uid = $4
|
||||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
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)
|
o, err := scanObject(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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
|
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,
|
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 {
|
); 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 {
|
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 {
|
if tag.RowsAffected() == 0 {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
|
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||||
return fmt.Errorf("bump ctag: %w", err)
|
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)
|
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"
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
|
"aether/pkg/bg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||||
@@ -67,7 +68,13 @@ func (w *Worker) Run(ctx context.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
wait := idleInterval
|
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
|
wait = busyInterval
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||||
|
|
||||||
// Verify checksum while streaming.
|
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
|
||||||
h256 := sha256.New()
|
// zurückhalten**, bis sie stimmt.
|
||||||
reader := io.TeeReader(rc, h256)
|
//
|
||||||
|
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
|
||||||
if _, err := io.Copy(w, reader); err != nil {
|
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
|
||||||
return // client disconnected
|
// 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.
|
||||||
actual := hex.EncodeToString(h256.Sum(nil))
|
//
|
||||||
if actual != f.Checksum {
|
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
|
||||||
// Log corruption; headers already sent, can't change status.
|
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
|
||||||
// TODO: alert / observability hook
|
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
|
||||||
_ = actual
|
// 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.
|
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
||||||
@@ -762,6 +765,9 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
|
|||||||
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
||||||
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
||||||
var extraMIMETypes = map[string]string{
|
var extraMIMETypes = map[string]string{
|
||||||
|
// 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",
|
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||||
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
||||||
".wav": "audio/wav", ".flac": "audio/flac",
|
".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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"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) {
|
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||||
var out File
|
var out File
|
||||||
err := scanFile(s.db.QueryRow(ctx,
|
err := scanFile(s.db.QueryRow(ctx,
|
||||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
`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)
|
versioning_enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING `+fileCols,
|
RETURNING `+fileCols,
|
||||||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||||||
|
versioniertVonAnfang(f.Name),
|
||||||
), &out)
|
), &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
|||||||
return &out, nil
|
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).
|
// GetFileByID fetches a file without owner check (used by public share downloads).
|
||||||
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
||||||
var f File
|
var f File
|
||||||
@@ -537,29 +558,6 @@ func isUniqueViolation(err error) bool {
|
|||||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||||
// entstanden ist – 0 heißt: es gibt keins.
|
// 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
|
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
// 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"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
|
"aether/pkg/bg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
// 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"} {
|
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||||
res.Scanned++
|
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)
|
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
|
// 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
|
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
// "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
|
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||||
// Hochfahren konkurriert.
|
// Hochfahren konkurriert.
|
||||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
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)
|
timer := time.NewTimer(5 * time.Minute)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
@@ -282,7 +269,7 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
|||||||
}
|
}
|
||||||
timer.Reset(Interval)
|
timer.Reset(Interval)
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
// 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,9 +48,6 @@ type Client struct {
|
|||||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||||
// keinem Browser.
|
// keinem Browser.
|
||||||
publicBase string
|
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
|
mu sync.Mutex
|
||||||
@@ -65,7 +62,7 @@ type Client struct {
|
|||||||
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
||||||
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
||||||
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
// 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)
|
u, err := url.Parse(baseURL)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
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,
|
provider: provider,
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
publicBase: strings.TrimRight(publicURL, "/"),
|
publicBase: strings.TrimRight(publicURL, "/"),
|
||||||
jwtSecret: jwtSecret,
|
|
||||||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||||
}, nil
|
}, 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
|
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
// 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 {
|
if err := c.refresh(ctx); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
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"}
|
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"}
|
order = []string{"view", "edit"}
|
||||||
}
|
}
|
||||||
for _, action := range order {
|
for _, action := range order {
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write := access.Permission == acl.PermWrite
|
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 {
|
if err != nil {
|
||||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
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
|
||||||
|
}
|
||||||
@@ -1,33 +1,58 @@
|
|||||||
package sharing
|
package sharing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"aether/internal/acl"
|
||||||
"aether/internal/audit"
|
"aether/internal/audit"
|
||||||
"aether/internal/auth"
|
"aether/internal/auth"
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
|
"aether/pkg/bg"
|
||||||
"aether/pkg/middleware"
|
"aether/pkg/middleware"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Ein öffentlicher Endpunkt mit Passwort braucht dieselbe Bremse wie /dav
|
||||||
|
// und /caldav: Ohne sie kann man Passwörter unbegrenzt durchprobieren – und
|
||||||
|
// weil jeder Versuch Argon2 rechnet, ist das zugleich ein billiger Weg, den
|
||||||
|
// Server zu beschäftigen.
|
||||||
|
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 {
|
type Handler struct {
|
||||||
store *Store
|
store *Store
|
||||||
fileStore *files.Store
|
fileStore *files.Store
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
auditor *audit.Logger
|
auditor *audit.Logger
|
||||||
|
// 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}
|
return &Handler{
|
||||||
|
store: store, fileStore: fileStore, storage: st, auditor: auditor,
|
||||||
|
acl: aclStore,
|
||||||
|
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/files/{id}/share
|
// POST /api/v1/files/{id}/share
|
||||||
@@ -38,7 +63,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||||
return
|
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")
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,6 +99,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var expiresAt *time.Time
|
var expiresAt *time.Time
|
||||||
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
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)
|
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
||||||
expiresAt = &t
|
expiresAt = &t
|
||||||
}
|
}
|
||||||
@@ -147,8 +196,16 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusNotFound, "file not found")
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Nur, was die öffentliche Seite wirklich braucht. Das ganze
|
||||||
|
// Datenbankobjekt durchzureichen war bequem, gab aber owner_id und die
|
||||||
|
// internen Kennungen an jeden heraus, der den Link hat: Zwei Links
|
||||||
|
// desselben Besitzers ließen sich so einander zuordnen, ohne dass
|
||||||
|
// irgendwer angemeldet ist.
|
||||||
response.JSON(w, http.StatusOK, map[string]any{
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
"link": link,
|
"has_password": link.HasPassword,
|
||||||
|
"expires_at": link.ExpiresAt,
|
||||||
|
"download_count": link.DownloadCount,
|
||||||
|
"created_at": link.CreatedAt,
|
||||||
"file_name": f.Name,
|
"file_name": f.Name,
|
||||||
"file_size": f.Size,
|
"file_size": f.Size,
|
||||||
"mime_type": f.MimeType,
|
"mime_type": f.MimeType,
|
||||||
@@ -169,15 +226,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if link.HasPassword {
|
if link.HasPassword {
|
||||||
|
clientIP := middleware.RealIP(r)
|
||||||
|
if erlaubt, retryAfter := h.limiter.Allowed(clientIP); !erlaubt {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
|
||||||
|
response.Error(w, http.StatusTooManyRequests, "too many failed attempts")
|
||||||
|
return
|
||||||
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
h.limiter.Fail(clientIP)
|
||||||
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.limiter.Reset(clientIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
||||||
@@ -193,10 +258,53 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer rc.Close()
|
defer rc.Close()
|
||||||
|
|
||||||
go h.store.IncrementDownloads(r.Context(), token)
|
// context.WithoutCancel, nicht r.Context(): Der Request-Kontext wird
|
||||||
|
// 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)
|
||||||
|
bg.Go("Freigabe-Zaehler", func() {
|
||||||
|
defer abbrechen()
|
||||||
|
h.store.IncrementDownloads(zaehlCtx, token)
|
||||||
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", f.MimeType)
|
w.Header().Set("Content-Type", f.MimeType)
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
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
|
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
|
||||||
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
||||||
OfficeInsecureTLS bool
|
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.
|
// 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", ""))),
|
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
||||||
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
||||||
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
|
|
||||||
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
||||||
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
||||||
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
|
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"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -13,6 +15,17 @@ func Logger(next http.Handler) http.Handler {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
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)
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
level := zerolog.InfoLevel
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -13,7 +14,31 @@ type envelope struct {
|
|||||||
func JSON(w http.ResponseWriter, status int, data any) {
|
func JSON(w http.ResponseWriter, status int, data any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
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 ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
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 ────────────────────────────────────────────────────
|
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
||||||
FROM golang:1.25-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
|||||||
@@ -102,6 +102,37 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
|
- "${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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
# 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"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@excalidraw/excalidraw": "^0.18.1",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -22,8 +23,10 @@
|
|||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ function AuthedApp() {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
animation: 'spin 0.7s linear infinite',
|
animation: 'spin 0.7s linear infinite',
|
||||||
}} />
|
}} />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-13
@@ -90,13 +90,49 @@ export interface CreateShareRequest {
|
|||||||
expires_in_hours?: number
|
expires_in_hours?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst
|
||||||
|
* nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden
|
||||||
|
* etwas an, der bloß den Link hat. */
|
||||||
export interface PublicShareInfo {
|
export interface PublicShareInfo {
|
||||||
link: ShareLink
|
has_password: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
download_count: number
|
||||||
|
created_at: string
|
||||||
file_name: string
|
file_name: string
|
||||||
file_size: number
|
file_size: number
|
||||||
mime_type: string
|
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 {
|
export interface FileVersion {
|
||||||
id: string
|
id: string
|
||||||
file_id: string
|
file_id: string
|
||||||
@@ -153,12 +189,25 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// XHR-based upload with real progress reporting.
|
// 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(
|
export function uploadWithProgress(
|
||||||
file: File,
|
file: File,
|
||||||
folderId: string | undefined,
|
folderId: string | undefined,
|
||||||
onProgress: (p: UploadProgress) => void
|
onProgress: (p: UploadProgress) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<FileItem> {
|
): Promise<FileItem> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new UploadAborted()); return }
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
// folder_id MUST come before the file so the server can stream the file
|
// folder_id MUST come before the file so the server can stream the file
|
||||||
// straight to storage without buffering the whole body first.
|
// straight to storage without buffering the whole body first.
|
||||||
@@ -197,7 +246,17 @@ export function uploadWithProgress(
|
|||||||
})
|
})
|
||||||
|
|
||||||
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
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)
|
xhr.send(form)
|
||||||
})
|
})
|
||||||
@@ -300,12 +359,15 @@ export const aclApi = {
|
|||||||
|
|
||||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${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) =>
|
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
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}`),
|
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,8 +595,21 @@ export interface OfficeSession {
|
|||||||
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
||||||
* entschieden wird serverseitig.
|
* 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([
|
const officeExtensions = new Set([
|
||||||
'odt', 'docx', 'doc', 'rtf', 'txt',
|
'odt', 'docx', 'doc', 'rtf',
|
||||||
'ods', 'xlsx', 'xls', 'csv',
|
'ods', 'xlsx', 'xls', 'csv',
|
||||||
'odp', 'pptx', 'ppt',
|
'odp', 'pptx', 'ppt',
|
||||||
'odg',
|
'odg',
|
||||||
@@ -546,8 +621,11 @@ export function isOfficeFile(name: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const officeApi = {
|
export const officeApi = {
|
||||||
session: (fileId: string) =>
|
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
|
||||||
request<OfficeSession>('GET', `/office/session/${fileId}`),
|
* 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 = {
|
export const ocrApi = {
|
||||||
@@ -594,15 +672,25 @@ export const filesApi = {
|
|||||||
* mit den Fassungen 1, 2, 3.
|
* 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,
|
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
|
||||||
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
|
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
|
||||||
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
|
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
|
||||||
* Vorschaubild.
|
*
|
||||||
|
* 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) =>
|
setThumbnail: async (id: string, bild: Blob) => {
|
||||||
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
|
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) =>
|
thumbnail: (id: string, version?: number | string) =>
|
||||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||||
@@ -845,6 +933,12 @@ export const adminApi = {
|
|||||||
derivedStorage: () =>
|
derivedStorage: () =>
|
||||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
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<{
|
runCleanup: () => request<{
|
||||||
scanned: number; orphans: number; deleted: number
|
scanned: number; orphans: number; deleted: number
|
||||||
freed_bytes: number; skipped_young: number
|
freed_bytes: number; skipped_young: number
|
||||||
@@ -900,6 +994,20 @@ export interface CalendarItem {
|
|||||||
timezone: string
|
timezone: string
|
||||||
ctag: number
|
ctag: number
|
||||||
created_at: string
|
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/
|
/** 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 { useEffect, useState } from 'react'
|
||||||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
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 { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||||
import TagChip from './TagChip'
|
import TagChip from './TagChip'
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,6 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||||
onSaved?: () => void
|
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
|
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
* 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'
|
return kindOf(file.mime_type, file.name) !== 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
|
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||||
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
|
const kind = kindOf(file.mime_type, file.name)
|
||||||
// gewandeltes PDF ist es eins.
|
|
||||||
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
|
|
||||||
const narrow = useNarrow()
|
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 [text, setText] = useState<string | null>(null)
|
||||||
const [tooBig, setTooBig] = useState(false)
|
const [tooBig, setTooBig] = useState(false)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
|||||||
{file.name}
|
{file.name}
|
||||||
</span>
|
</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 && (
|
{kind === 'text' && !tooBig && text !== null && (
|
||||||
editing ? (
|
editing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TagRow } from './TagDialog'
|
|||||||
import TagChip from './TagChip'
|
import TagChip from './TagChip'
|
||||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||||
import FileCover, { FolderCover } from './FileCover'
|
import FileCover, { FolderCover } from './FileCover'
|
||||||
import { formatBytes } from '../pages/FileBrowser'
|
import { formatBytes } from '../format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||||
import { useBackClose } from '../hooks/useBackClose'
|
import { useBackClose } from '../hooks/useBackClose'
|
||||||
|
import { useCoarsePointer } from './FileViews'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Dokument im Office-Server bearbeiten.
|
* Ein Dokument im Office-Server bearbeiten.
|
||||||
@@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: {
|
|||||||
const [session, setSession] = useState<OfficeSession | null>(null)
|
const [session, setSession] = useState<OfficeSession | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
officeApi.session(file.id)
|
officeApi.session(file.id, coarse)
|
||||||
.then(s => {
|
.then(s => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
if (!s.enabled) { setError('Office editing is switched off on this server.'); 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')
|
setError(err instanceof Error ? err.message : 'Could not open the editor')
|
||||||
})
|
})
|
||||||
return () => { alive = false }
|
return () => { alive = false }
|
||||||
}, [file.id])
|
}, [file.id, coarse])
|
||||||
|
|
||||||
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
||||||
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import UserMenu, { useSidebarToggle } from './UserMenu'
|
import UserMenu, { useSidebarToggle } from './UserMenu'
|
||||||
|
import { wideEnough } from '../breakpoints'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Kopfleiste jeder Ansicht.
|
* Die Kopfleiste jeder Ansicht.
|
||||||
@@ -23,8 +24,12 @@ export const headerHeight = 57
|
|||||||
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
||||||
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
||||||
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
* 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 {
|
interface Props {
|
||||||
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { filesApi } from '../api/client'
|
|||||||
import * as pdfjs from 'pdfjs-dist'
|
import * as pdfjs from 'pdfjs-dist'
|
||||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
||||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
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'
|
import './pdf.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +27,10 @@ import './pdf.css'
|
|||||||
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
||||||
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
||||||
* schreiben kann, wäre ein Widerspruch dazu.
|
* 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
|
// 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/'
|
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
|
* Wartezeit, bevor eine Eingabe im Suchfeld das ganze Dokument durchsucht.
|
||||||
* Zahlen gerastet – 65, 70, 75, 80 …
|
|
||||||
*
|
*
|
||||||
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
|
* Ohne sie las jeder einzelne Tastendruck den Text **aller** Seiten neu aus.
|
||||||
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
|
* Wer „Rechnung" tippt, löste damit acht vollständige Durchläufe aus, von
|
||||||
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
|
* denen sieben schon veraltet waren, bevor sie fertig wurden. Ein Sechstel
|
||||||
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
|
* einer Sekunde bemerkt beim Tippen niemand.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const zoomStepPercent = 5
|
const searchDelay = 160
|
||||||
const minScale = 0.25
|
|
||||||
const maxScale = 4
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
|
* Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet
|
||||||
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
|
* wird.
|
||||||
* als ein Zehntel der Darstellungsgröße.
|
|
||||||
*/
|
|
||||||
function padOf(width: number): number {
|
|
||||||
return width < 560 ? 4 : 16
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
|
|
||||||
*
|
*
|
||||||
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
|
* **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die
|
||||||
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
|
* Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht
|
||||||
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
|
* zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter
|
||||||
* nicht mittig. Macht 2 × 275 + 492.
|
* 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
|
* Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und
|
||||||
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
|
* der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also
|
||||||
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
|
* von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen
|
||||||
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
|
* weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen.
|
||||||
* der Steuerung nichts mehr zu sehen.
|
*
|
||||||
|
* 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
|
const zoomSettle = 120
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
||||||
@@ -109,23 +99,22 @@ interface Props {
|
|||||||
searchTerm?: string
|
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) {
|
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
||||||
const [error, setError] = useState('')
|
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 [scale, setScale] = useState<number | 'fit'>('fit')
|
||||||
const [fitScale, setFitScale] = useState(1)
|
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 [current, setCurrent] = useState(1)
|
||||||
|
|
||||||
const [query, setQuery] = useState(searchTerm ?? '')
|
const [query, setQuery] = useState(searchTerm ?? '')
|
||||||
@@ -141,16 +130,33 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const pageRefs = 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
|
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
|
// ---------------------------------------------------------------- Laden
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
let task: ReturnType<typeof pdfjs.getDocument> | null = null
|
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
|
||||||
setDoc(null); setSizes([]); setError(''); setCurrent(1)
|
// 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,
|
url,
|
||||||
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
||||||
withCredentials: true,
|
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
|
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
|
||||||
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
||||||
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
||||||
const s: { w: number; h: number }[] = []
|
const s: PageSize[] = []
|
||||||
for (let i = 1; i <= d.numPages; i++) {
|
for (let from = 1; from <= d.numPages; from += sizeBatch) {
|
||||||
const v = (await d.getPage(i)).getViewport({ scale: 1 })
|
const to = Math.min(from + sizeBatch - 1, d.numPages)
|
||||||
s.push({ w: v.width, h: v.height })
|
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
|
if (!alive) return
|
||||||
setSizes(s)
|
setSizes(s)
|
||||||
@@ -185,7 +196,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
)
|
)
|
||||||
return () => {
|
return () => {
|
||||||
alive = false
|
alive = false
|
||||||
task?.destroy()
|
task.destroy()
|
||||||
}
|
}
|
||||||
}, [url])
|
}, [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
|
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
|
||||||
* schmaler als das Blatt, die Breite also unter 100 %.
|
* 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 recomputeFit = useCallback(() => {
|
||||||
const el = scrollRef.current
|
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.
|
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
||||||
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
||||||
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
||||||
|
setFitReady(true)
|
||||||
}, [sizes])
|
}, [sizes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -248,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
||||||
* ihre Leinwand frei.
|
* 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(() => {
|
useEffect(() => {
|
||||||
if (!doc || sizes.length === 0) return
|
if (!doc || sizes.length === 0 || !scaleKnown) return
|
||||||
const root = scrollRef.current
|
const root = scrollRef.current
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
@@ -263,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
if (!doc || !host || drawn.has(n)) return
|
if (!doc || !host || drawn.has(n)) return
|
||||||
drawn.add(n)
|
drawn.add(n)
|
||||||
const page = await doc.getPage(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 dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -330,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
io.disconnect()
|
io.disconnect()
|
||||||
for (const t of running.values()) t.cancel()
|
for (const t of running.values()) t.cancel()
|
||||||
}
|
}
|
||||||
}, [doc, sizes, effScale])
|
}, [doc, sizes, renderScale, scaleKnown])
|
||||||
|
|
||||||
// --------------------------------------------------- Sichtbare Seitenzahl
|
// --------------------------------------------------- Sichtbare Seitenzahl
|
||||||
|
|
||||||
@@ -403,10 +420,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim().toLowerCase()
|
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
|
let alive = true
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
;(async () => {
|
|
||||||
|
// Erst abwarten, ob noch ein Zeichen kommt – siehe `searchDelay`.
|
||||||
|
const timer = setTimeout(() => void (async () => {
|
||||||
const found: Hit[] = []
|
const found: Hit[] = []
|
||||||
for (let n = 1; n <= doc.numPages; n++) {
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
@@ -446,9 +465,9 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
if (!alive) return
|
if (!alive) return
|
||||||
setHits(found)
|
setHits(found)
|
||||||
setHitIndex(0)
|
setHitIndex(0)
|
||||||
setSearching(false)
|
})().finally(() => { if (alive) setSearching(false) }), searchDelay)
|
||||||
})()
|
|
||||||
return () => { alive = false; setSearching(false) }
|
return () => { alive = false; clearTimeout(timer) }
|
||||||
}, [doc, query, fileId])
|
}, [doc, query, fileId])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -466,12 +485,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
||||||
*
|
*
|
||||||
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
|
* 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
|
* Kästen, die der Browser auch beim Markieren mit der Maus malt.
|
||||||
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
|
|
||||||
* Textebene.
|
|
||||||
*
|
*
|
||||||
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
|
* 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
|
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
|
||||||
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
|
* 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])
|
}, [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
|
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
||||||
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
||||||
const target = hits[hitIndex]
|
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.
|
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
|
||||||
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
|
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
|
// ------------------------------------------------------------------ Zoom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -625,6 +665,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
||||||
const scaleRef = useRef(effScale)
|
const scaleRef = useRef(effScale)
|
||||||
scaleRef.current = effScale
|
scaleRef.current = effScale
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchor = keepAnchor.current
|
const anchor = keepAnchor.current
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
@@ -643,41 +684,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
goToPage(n)
|
goToPage(n)
|
||||||
}, [effScale, goToPage])
|
}, [effScale, goToPage])
|
||||||
|
|
||||||
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
|
/** Merkt den Punkt unter Zeiger oder Fingermitte, damit er dort bleibt. */
|
||||||
function nextStep(now: number, dir: 1 | -1): number | undefined {
|
const anchorAt = useCallback((cx: number, cy: number, from: number) => {
|
||||||
return dir > 0
|
const root = scrollRef.current
|
||||||
? zoomSteps.find(s => s > now + 0.001)
|
if (!root) return
|
||||||
: [...zoomSteps].reverse().find(s => s < now - 0.001)
|
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,
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
const zoom = useCallback((dir: 1 | -1) => {
|
||||||
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
|
const next = nextStep(scaleRef.current, dir)
|
||||||
* 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)
|
|
||||||
if (next) { keepPage.current = current; setScale(next) }
|
if (next) { keepPage.current = current; setScale(next) }
|
||||||
}
|
}, [current])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
|
* 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.
|
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
|
||||||
if (!e.ctrlKey && !e.metaKey) return
|
if (!e.ctrlKey && !e.metaKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const root2 = scrollRef.current
|
const from = scaleRef.current
|
||||||
if (!root2) return
|
const next = nextFine(from, e.deltaY < 0 ? 1 : -1)
|
||||||
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
|
|
||||||
const next = nextFine(effScale, dir)
|
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const r = root2.getBoundingClientRect()
|
anchorAt(e.clientX, e.clientY, from)
|
||||||
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,
|
|
||||||
}
|
|
||||||
setScale(next)
|
setScale(next)
|
||||||
}
|
}
|
||||||
root.addEventListener('wheel', onWheel, { passive: false })
|
root.addEventListener('wheel', onWheel, { passive: false })
|
||||||
return () => root.removeEventListener('wheel', onWheel)
|
return () => root.removeEventListener('wheel', onWheel)
|
||||||
}, [effScale])
|
}, [anchorAt])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
||||||
*
|
*
|
||||||
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
||||||
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
|
* 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
|
* hinterher stand alles verschoben.
|
||||||
* bisher nur die Knöpfe blieben.
|
|
||||||
*
|
*
|
||||||
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
|
* 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
|
* 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
|
if (!root) return
|
||||||
|
|
||||||
function distance(t: TouchList) {
|
function distance(t: TouchList) {
|
||||||
const dx = t[0].clientX - t[1].clientX
|
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
|
||||||
const dy = t[0].clientY - t[1].clientY
|
|
||||||
return Math.hypot(dx, dy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStart(e: TouchEvent) {
|
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
|
if (dir < 0 && ratio > Math.min(0.98, needed)) return
|
||||||
|
|
||||||
pinchBase.current = now
|
pinchBase.current = now
|
||||||
const root2 = scrollRef.current
|
|
||||||
if (!root2) return
|
|
||||||
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
||||||
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
||||||
const r = root2.getBoundingClientRect()
|
anchorAt(
|
||||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
|
(e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
|
(e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||||
keepAnchor.current = {
|
from,
|
||||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
)
|
||||||
cx, cy, from,
|
|
||||||
}
|
|
||||||
setScale(next)
|
setScale(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,60 +818,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
root.removeEventListener('touchend', onEnd)
|
root.removeEventListener('touchend', onEnd)
|
||||||
root.removeEventListener('touchcancel', onEnd)
|
root.removeEventListener('touchcancel', onEnd)
|
||||||
}
|
}
|
||||||
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
|
// `anchorAt` ist über useCallback stabil, der Zuhörer meldet sich also
|
||||||
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
|
// **einmal** an. Hinge er am Maßstab, meldete er sich nach jeder Stufe neu
|
||||||
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
|
// an – mitten in der Geste, und der gemerkte Fingerabstand wäre weg.
|
||||||
// dann über genau eine Stufe nicht hinaus.
|
// Gemessen: Eine lange Geste kam dann über genau eine Stufe nicht hinaus.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [anchorAt])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
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)
|
const [pad, setPad] = useState(16)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -892,137 +852,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
||||||
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
||||||
}}>
|
}}>
|
||||||
{/*
|
<PdfToolbar
|
||||||
Werkzeugleiste.
|
page={current}
|
||||||
|
pageCount={doc?.numPages ?? null}
|
||||||
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
|
onGoPage={goToPage}
|
||||||
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
|
percent={Math.round(effScale * 100)}
|
||||||
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
|
isFit={scale === 'fit'}
|
||||||
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
|
onZoom={zoom}
|
||||||
landete gemessen 79 bzw. 142 Pixel daneben.
|
onFit={() => { keepPage.current = current; setScale('fit') }}
|
||||||
|
query={query}
|
||||||
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
|
onQuery={setQuery}
|
||||||
beim Umbruch, und die Suche steht in der zweiten Zeile.
|
searching={searching}
|
||||||
*/}
|
hitCount={hits.length}
|
||||||
<div
|
hitIndex={hitIndex}
|
||||||
ref={barRef}
|
pageOnlyHits={!!hits[0] && !hits[0].exact}
|
||||||
style={{
|
onStepHit={stepHit}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Seiten */}
|
{/* Seiten */}
|
||||||
<div
|
<div
|
||||||
@@ -1070,124 +915,3 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
</div>
|
</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 {
|
interface Props {
|
||||||
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
/** 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
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [list, people] = await Promise.all([
|
const [list, people] = await Promise.all([
|
||||||
resource.type === 'folder'
|
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
|
||||||
? aclApi.listForFolder(resource.id)
|
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
|
||||||
: aclApi.listForFile(resource.id),
|
: aclApi.listForFile(resource.id),
|
||||||
aclApi.recipients(),
|
aclApi.recipients(),
|
||||||
])
|
])
|
||||||
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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)
|
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
||||||
} else {
|
} else {
|
||||||
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
||||||
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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' && (
|
{resource.type === 'folder' && (
|
||||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
<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.
|
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 { filesApi } from '../api/client'
|
||||||
import type { FolderItem, User } from '../api/client'
|
import type { FolderItem, User } from '../api/client'
|
||||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||||
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
||||||
import { useBackClose } from '../hooks/useBackClose'
|
import { useBackClose } from '../hooks/useBackClose'
|
||||||
|
import { Toaster } from './Toasts'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import FileBrowser from '../pages/FileBrowser'
|
import FileBrowser from '../pages/FileBrowser'
|
||||||
import TrashPage from '../pages/TrashPage'
|
import TrashPage from '../pages/TrashPage'
|
||||||
@@ -14,6 +15,7 @@ import ProfilePage from '../pages/ProfilePage'
|
|||||||
import TagsPage from '../pages/TagsPage'
|
import TagsPage from '../pages/TagsPage'
|
||||||
import CalendarPage from '../pages/CalendarPage'
|
import CalendarPage from '../pages/CalendarPage'
|
||||||
import { UserMenuProvider } from './UserMenu'
|
import { UserMenuProvider } from './UserMenu'
|
||||||
|
import { wideEnough } from '../breakpoints'
|
||||||
|
|
||||||
export type { View }
|
export type { View }
|
||||||
|
|
||||||
@@ -44,6 +46,19 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
|||||||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
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
|
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||||||
// hängt daran und holt sich daraufhin neu.
|
// hängt daran und holt sich daraufhin neu.
|
||||||
const [trashBump, setTrashBump] = useState(0)
|
const [trashBump, setTrashBump] = useState(0)
|
||||||
@@ -81,9 +96,18 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
|||||||
onOpenSidebar: () => setSidebarOpen(true),
|
onOpenSidebar: () => setSidebarOpen(true),
|
||||||
}}>
|
}}>
|
||||||
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
|
<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 && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
|
className="aether-scrim"
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
||||||
onClick={() => setSidebarOpen(false)}
|
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' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||||
{view === 'files' && (
|
{view === 'files' && (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
user={user}
|
|
||||||
folderPath={folderPath}
|
folderPath={folderPath}
|
||||||
onNavigate={nav.setFolderPath}
|
onNavigate={nav.setFolderPath}
|
||||||
onUserChanged={onUserChanged}
|
onUserChanged={onUserChanged}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { RenameIcon, TrashIcon } from './icons'
|
|||||||
import type { User } from '../api/client'
|
import type { User } from '../api/client'
|
||||||
import type { View } from './Shell'
|
import type { View } from './Shell'
|
||||||
import { useAppSettings } from '../hooks/useAppSettings'
|
import { useAppSettings } from '../hooks/useAppSettings'
|
||||||
|
import { wideEnough } from '../breakpoints'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
@@ -775,7 +776,11 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag, onTrashDro
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
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 {
|
.aether-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--honeybar-h); left: 0;
|
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 { useState } from 'react'
|
||||||
import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client'
|
import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client'
|
||||||
import { tagPalette, tagColor } from './TagChip'
|
import { tagPalette, tagColor } from './TagChip'
|
||||||
|
import { fehlertext } from './Toasts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Etikett umbenennen, einfärben oder löschen.
|
* Ein Etikett umbenennen, einfärben oder löschen.
|
||||||
@@ -28,8 +29,12 @@ export default function TagEditDialog({ tag, onClose, onChanged }: {
|
|||||||
notifyTagsChanged()
|
notifyTagsChanged()
|
||||||
onChanged(updated)
|
onChanged(updated)
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError('That name is already taken')
|
// **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)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,8 +247,12 @@ export function CorrespondentRenameDialog({ correspondent, onClose, onChanged }:
|
|||||||
notifyTagsChanged()
|
notifyTagsChanged()
|
||||||
onChanged(updated)
|
onChanged(updated)
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError('That name is already taken')
|
// **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)
|
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 { useEffect, useRef } from 'react'
|
||||||
|
import { formatBytes, formatDuration } from '../format'
|
||||||
|
|
||||||
interface UploadItem {
|
interface UploadItem {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
progress: number // 0-100
|
progress: number // 0-100
|
||||||
status: 'uploading' | 'done' | 'error'
|
status: 'uploading' | 'done' | 'error' | 'canceled'
|
||||||
error?: string
|
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 {
|
interface Props {
|
||||||
items: UploadItem[]
|
items: UploadItem[]
|
||||||
onDismiss: (id: string) => void
|
onDismiss: (id: string) => void
|
||||||
|
/** Bricht einen laufenden oder noch wartenden Upload ab. */
|
||||||
|
onCancel: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { UploadItem }
|
export type { UploadItem }
|
||||||
@@ -31,7 +40,7 @@ const dismissAfter = 4000
|
|||||||
*/
|
*/
|
||||||
const maxVisible = 4
|
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
|
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
|
||||||
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
|
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
|
||||||
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
|
// 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
|
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
|
||||||
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
|
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
|
||||||
// von selbst.
|
// 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
|
if (timers.current.has(item.id)) continue
|
||||||
timers.current.set(item.id, setTimeout(() => {
|
timers.current.set(item.id, setTimeout(() => {
|
||||||
timers.current.delete(item.id)
|
timers.current.delete(item.id)
|
||||||
@@ -85,14 +96,30 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
|||||||
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
|
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
{item.status !== 'uploading' && (
|
{/* Dasselbe Kreuz, zwei Bedeutungen – und beide sind dieselbe
|
||||||
<button onClick={() => onDismiss(item.id)}
|
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"
|
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{item.status === 'uploading' && (
|
{item.status === 'uploading' && (
|
||||||
|
<>
|
||||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all"
|
className="h-full rounded-full transition-all"
|
||||||
@@ -101,13 +128,20 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
|||||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
<Details item={item} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{item.status === 'done' && (
|
{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' && (
|
{item.status === 'error' && (
|
||||||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -124,3 +158,40 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
|||||||
</div>
|
</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 { useState, useEffect } from 'react'
|
||||||
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
|
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
|
||||||
import { formatBytes } from '../pages/FileBrowser'
|
import { formatBytes } from '../format'
|
||||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||||
import { useAppSettings } from '../hooks/useAppSettings'
|
import { useAppSettings } from '../hooks/useAppSettings'
|
||||||
|
|
||||||
@@ -134,7 +134,6 @@ export default function VersionsDialog({ file, onClose, onRestored }: Props) {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
|
<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' }} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
|
|||||||
borderRadius: 14, padding: 24, width: 340, maxWidth: '100%',
|
borderRadius: 14, padding: 24, width: 340, maxWidth: '100%',
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
<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>
|
</h2>
|
||||||
|
|
||||||
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label>
|
<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 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<ColorSwatches value={color} onChange={setColor} />
|
<ColorSwatches value={color} onChange={setColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||||
import type { CalendarEvent } from '../../api/client'
|
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 {
|
export interface DayEvent extends CalendarEvent {
|
||||||
calendarId: string
|
calendarId: string
|
||||||
@@ -42,7 +38,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
|
|||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
const timeLabel = (ev: DayEvent) => {
|
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' })
|
const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
if (!ev.endsAt) return from
|
if (!ev.endsAt) return from
|
||||||
const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
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',
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '18px 22px 12px', borderBottom: '1px solid var(--color-border)' }}>
|
<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)' }}>
|
<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>
|
||||||
</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={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, minWidth: 0 }}>
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
<span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<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>}
|
{ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}>
|
<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%',
|
borderRadius: 14, padding: 24, width: 380, maxWidth: '100%',
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
<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>
|
</h2>
|
||||||
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||||
{recurring
|
{recurring
|
||||||
? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
|
? <>„{summary || 'Untitled'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
|
||||||
: <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?</>}
|
: <>„{summary || 'Untitled'}" unwiderruflich löschen?</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<button onClick={onSingle} style={danger}>
|
<button onClick={onSingle} style={danger}>
|
||||||
{recurring ? 'Nur diesen Termin' : 'Löschen'}
|
{recurring ? 'Only this event' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
{recurring && (
|
{recurring && (
|
||||||
<button onClick={onSeries} style={danger}>
|
<button onClick={onSeries} style={danger}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode }
|
|||||||
import {
|
import {
|
||||||
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
|
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
|
||||||
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
|
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
|
||||||
|
weekdayShortNames, weekdayLongNames, monthLongNames,
|
||||||
} from './dates'
|
} from './dates'
|
||||||
import { CalendarSelect, ColorSwatches, Toggle } from './controls'
|
import { CalendarSelect, ColorSwatches, Toggle } from './controls'
|
||||||
|
|
||||||
@@ -23,27 +24,21 @@ type FreqChoice = 'NONE' | Recurrence['freq']
|
|||||||
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
|
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
|
||||||
|
|
||||||
const freqLabels: Record<FreqChoice, string> = {
|
const freqLabels: Record<FreqChoice, string> = {
|
||||||
NONE: 'Einmalig',
|
NONE: 'Does not repeat',
|
||||||
DAILY: 'Täglich',
|
DAILY: 'Daily',
|
||||||
WEEKLY: 'Wöchentlich',
|
WEEKLY: 'Weekly',
|
||||||
MONTHLY: 'Monatlich',
|
MONTHLY: 'Monthly',
|
||||||
YEARLY: 'Jährlich',
|
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
|
/** „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. */
|
* man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */
|
||||||
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
|
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
|
||||||
const n = nthOfMonth(start)
|
const n = nthOfMonth(start)
|
||||||
const nth = n === -1 ? 'letzten' : `${n}.`
|
const nth = n === -1 ? 'letzten' : `${n}.`
|
||||||
const wd = weekdayLong[(start.getDay() + 6) % 7]
|
const wd = weekdayLongNames[(start.getDay() + 6) % 7]
|
||||||
const month = monthNames[start.getMonth()]
|
const month = monthLongNames[start.getMonth()]
|
||||||
return yearly
|
return yearly
|
||||||
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
|
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
|
||||||
: { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` }
|
: { 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',
|
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)' }}>
|
<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>
|
</h2>
|
||||||
|
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
@@ -197,20 +192,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
)}
|
)}
|
||||||
{isSeries && !readOnly && (
|
{isSeries && !readOnly && (
|
||||||
<div style={noteStyle}>
|
<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.
|
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<label style={labelStyle}>Titel</label>
|
<label style={labelStyle}>Title</label>
|
||||||
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
|
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
|
||||||
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
|
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{calendars.length > 1 && (
|
{calendars.length > 1 && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<label style={labelStyle}>Kalender</label>
|
<label style={labelStyle}>Calendar</label>
|
||||||
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
|
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
|
||||||
Speichern die Sammlung, behält aber seine Kennung – für
|
Speichern die Sammlung, behält aber seine Kennung – für
|
||||||
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
|
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
|
||||||
@@ -219,14 +214,14 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
{!!event && calId !== calendarId && (
|
{!!event && calId !== calendarId && (
|
||||||
<p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
<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
|
Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben
|
||||||
{isSeries ? ' – mit der ganzen Reihe.' : '.'}
|
{isSeries ? ' – together with the whole series.' : '.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
<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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
@@ -237,7 +232,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
</div>
|
</div>
|
||||||
{!allDay && (
|
{!allDay && (
|
||||||
<div style={{ width: 116 }}>
|
<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} />
|
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -245,13 +240,13 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={labelStyle}>Ende</label>
|
<label style={labelStyle}>End</label>
|
||||||
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
||||||
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
{!allDay && (
|
{!allDay && (
|
||||||
<div style={{ width: 116 }}>
|
<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} />
|
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -259,7 +254,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
|
|
||||||
{/* ── Wiederholung ───────────────────────────────────────────── */}
|
{/* ── Wiederholung ───────────────────────────────────────────── */}
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<label style={labelStyle}>Wiederholung</label>
|
<label style={labelStyle}>Repeat</label>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<select value={freq} disabled={readOnly}
|
<select value={freq} disabled={readOnly}
|
||||||
onChange={e => setFreq(e.target.value as FreqChoice)}
|
onChange={e => setFreq(e.target.value as FreqChoice)}
|
||||||
@@ -270,7 +265,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
</select>
|
</select>
|
||||||
{freq !== 'NONE' && (
|
{freq !== 'NONE' && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
<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}
|
<input type="number" min={1} max={99} value={interval} disabled={readOnly}
|
||||||
onChange={e => setInterval(e.target.value)}
|
onChange={e => setInterval(e.target.value)}
|
||||||
style={{ ...inputStyle, width: 60 }} />
|
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. */}
|
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
|
||||||
{freq === 'WEEKLY' && (
|
{freq === 'WEEKLY' && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<label style={labelStyle}>An diesen Tagen</label>
|
<label style={labelStyle}>On these days</label>
|
||||||
<div style={{ display: 'flex', gap: 5 }}>
|
<div style={{ display: 'flex', gap: 5 }}>
|
||||||
{weekdayCodes.map((code, i) => {
|
{weekdayCodes.map((code, i) => {
|
||||||
const on = activeDays.includes(code)
|
const on = activeDays.includes(code)
|
||||||
@@ -292,7 +287,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
<button
|
<button
|
||||||
key={code} type="button" disabled={readOnly || fixed}
|
key={code} type="button" disabled={readOnly || fixed}
|
||||||
onClick={() => toggleDay(code)}
|
onClick={() => toggleDay(code)}
|
||||||
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
|
title={fixed ? `${weekdayLongNames[i]} – the day the event starts` : weekdayLongNames[i]}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
|
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
|
||||||
border: '1px solid var(--color-border)',
|
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',
|
transition: 'background 0.12s, color 0.12s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{weekdayShort[i]}
|
{weekdayShortNames[i]}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -314,7 +309,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
|
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
|
||||||
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
|
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<label style={labelStyle}>Wiederholt sich</label>
|
<label style={labelStyle}>Repeats</label>
|
||||||
<select value={monthMode} disabled={readOnly}
|
<select value={monthMode} disabled={readOnly}
|
||||||
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
|
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
|
||||||
style={inputStyle}>
|
style={inputStyle}>
|
||||||
@@ -326,20 +321,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
|
|
||||||
{freq !== 'NONE' && (
|
{freq !== 'NONE' && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<label style={labelStyle}>Endet</label>
|
<label style={labelStyle}>Ends</label>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<select value={endChoice} disabled={readOnly}
|
<select value={endChoice} disabled={readOnly}
|
||||||
onChange={e => setEndChoice(e.target.value as EndChoice)}
|
onChange={e => setEndChoice(e.target.value as EndChoice)}
|
||||||
style={{ ...inputStyle, flex: 1 }}>
|
style={{ ...inputStyle, flex: 1 }}>
|
||||||
<option value="NEVER">Nie</option>
|
<option value="NEVER">Never</option>
|
||||||
<option value="COUNT">Nach Anzahl</option>
|
<option value="COUNT">After</option>
|
||||||
<option value="UNTIL">An einem Datum</option>
|
<option value="UNTIL">On a date</option>
|
||||||
</select>
|
</select>
|
||||||
{endChoice === 'COUNT' && (
|
{endChoice === 'COUNT' && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
<input type="number" min={1} max={999} value={count} disabled={readOnly}
|
<input type="number" min={1} max={999} value={count} disabled={readOnly}
|
||||||
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{endChoice === 'UNTIL' && (
|
{endChoice === 'UNTIL' && (
|
||||||
@@ -352,17 +347,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
|
|
||||||
{/* ── Farbe ──────────────────────────────────────────────────── */}
|
{/* ── Farbe ──────────────────────────────────────────────────── */}
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<label style={labelStyle}>Farbe</label>
|
<label style={labelStyle}>Color</label>
|
||||||
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
|
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
<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} />
|
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
<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)}
|
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
|
||||||
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -384,7 +379,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
{readOnly ? 'Schließen' : 'Abbrechen'}
|
{readOnly ? 'Close' : 'Cancel'}
|
||||||
</button>
|
</button>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button type="submit" style={{
|
<button type="submit" style={{
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function ColorSwatches({ value, onChange, inherit, disabled }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}>
|
<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 && (
|
{inherit !== undefined && (
|
||||||
<span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} />
|
<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}>
|
aria-haspopup="listbox" aria-expanded={open} style={field}>
|
||||||
{current && dot(current.color, true)}
|
{current && dot(current.color, true)}
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{current?.name ?? 'Kalender wählen'}
|
{current?.name ?? 'Choose calendar'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
|
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
|
||||||
</button>
|
</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)
|
for (let d = first, i = 0; +d <= +last && i < 400; d = addDays(d, 1), i++) out.push(d)
|
||||||
return out
|
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 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user