Compare commits
67
Commits
471679ff64
..
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 | ||
|
|
1e1f35955d | ||
|
|
ac61df9796 | ||
|
|
f71ab710e1 | ||
|
|
820ffdde6a | ||
|
|
128d54035a | ||
|
|
d7b5c1cd5f | ||
|
|
cc35161e74 | ||
|
|
7a1f0837f5 | ||
|
|
77bf16b27f | ||
|
|
688bb13bde | ||
|
|
0c9cc69493 | ||
|
|
79a7cc6a3d | ||
|
|
105ce8de4a | ||
|
|
ba33706d2e | ||
|
|
27a026421f | ||
|
|
4d635fff20 | ||
|
|
3f1b2b8a14 | ||
|
|
469274171c | ||
|
|
d6e1854f1b | ||
|
|
87788817f8 | ||
|
|
e44bed0a34 | ||
|
|
0cea81978f | ||
|
|
5808ff5ef8 | ||
|
|
6f0efc8e7b | ||
|
|
8f90a7fbd6 | ||
|
|
9d152e3677 | ||
|
|
31d51f9bc3 | ||
|
|
86da975424 | ||
|
|
69912081dd |
No files matched your search
+56
-57
@@ -1,9 +1,13 @@
|
||||
# Aether – alle Einstellungen. cp .env.example .env
|
||||
# Aether – alle Einstellungen des Betriebs. cp .env.example .env
|
||||
#
|
||||
# Diese Datei gilt für beides: den Betrieb mit dem fertigen Abbild
|
||||
# (docker-compose.example.yml hier im Wurzelverzeichnis) und die Entwicklung
|
||||
# aus dem Quelltext (build/docker-compose.yml, liest build/.env).
|
||||
# Für die Entwicklung: cp .env.example build/.env
|
||||
# Hier steht, was die ANWENDUNG liest. Nichts davon hängt daran, ob du das
|
||||
# fertige Abbild ziehst (docker-compose.example.yml) oder selbst baust
|
||||
# (build/docker-compose.yml) – zum Entwickeln: cp .env.example build/.env
|
||||
#
|
||||
# Was NICHT hier steht: die Schalter des mitgelieferten Collabora-Containers
|
||||
# (aliasgroup1, ssl, Port). Die gehören zum Compose, das ihn startet, und
|
||||
# stehen kommentiert in build/docker-compose.yml. Wer Office selbst betreibt,
|
||||
# richtet sie in dessen eigener Compose-Datei ein.
|
||||
#
|
||||
# Zwei Fallen beim Bearbeiten:
|
||||
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
||||
@@ -48,9 +52,17 @@ ADMIN_PASSWORD=
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe, davor
|
||||
# gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
||||
APP_BIND=127.0.0.1
|
||||
# Wer den Port erreichen darf.
|
||||
# 0.0.0.0 alles, was den Server im Netz erreicht (Vorgabe)
|
||||
# 127.0.0.1 nur dieser Rechner – richtig, wenn der Reverse Proxy hier läuft
|
||||
# 10.0.0.5 eine bestimmte Adresse, etwa das Ende eines WireGuard-Tunnels
|
||||
#
|
||||
# Die Vorgabe ist 0.0.0.0, weil der Proxy typischerweise woanders steht und
|
||||
# 127.0.0.1 ihn gar nicht erst herankommen ließe. Der Preis: Der Port ist auch
|
||||
# aus dem eigenen Netz offen, und TLS endet erst am Proxy – bis dahin liegt die
|
||||
# Anmeldung unverschlüsselt im Netz. Wer den Proxy auf demselben Rechner hat,
|
||||
# setzt hier 127.0.0.1 und ist besser dran.
|
||||
APP_BIND=0.0.0.0
|
||||
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
|
||||
|
||||
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
|
||||
@@ -68,6 +80,20 @@ TMP_PATH=/data/tmp
|
||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||
MAX_VERSIONS_KEPT=5
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
# Standardmäßig an. Fehlen die Programme im Abbild, schaltet sich der Arbeiter
|
||||
# beim Start selbst ab, statt den Start zu verhindern.
|
||||
OCR_ENABLED=true
|
||||
# Sprachen für Tesseract. Jede zusätzliche kostet Zeit – nimm nur, was vorkommt.
|
||||
OCR_LANGUAGES=deu+eng
|
||||
# Seiten je Datei; 0 = unbegrenzt. Die Grenze ist Absicht: OCR ist das einzige
|
||||
# Stück dieser Anwendung, das ohne Zutun eines Menschen Platz und Rechenzeit
|
||||
# verbraucht. Wer regelmäßig lange Scans ablegt, setzt sie herauf.
|
||||
OCR_MAX_PAGES=50
|
||||
# Abbruch je Datei. Ein Scan, der so lange braucht, wird auch beim vierten
|
||||
# Versuch nicht fertig.
|
||||
OCR_TIMEOUT=10m
|
||||
|
||||
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
||||
@@ -86,35 +112,27 @@ OIDC_LINK_BY_USERNAME=false
|
||||
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
|
||||
|
||||
# ══ Office (optional) ═════════════════════════════════════════════════════════
|
||||
# Leer = aus, auch wenn ein Office-Container läuft.
|
||||
# Compose-Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
|
||||
# collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_PROVIDER=
|
||||
|
||||
# Drei Adressen für drei Richtungen – die häufigste Fehlerquelle.
|
||||
#
|
||||
# Backend → Office. Port je nach Anbieter:
|
||||
# Collabora http://office:9980
|
||||
# OnlyOffice/Euro-Office http://office
|
||||
OFFICE_URL=http://office:9980
|
||||
# Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name.
|
||||
OFFICE_PUBLIC_URL=https://office.example.com
|
||||
# Office → Aether.
|
||||
#
|
||||
# **Bei Collabora die öffentliche Adresse der Cloud**, nicht app:8080: Aus
|
||||
# diesem Wert baut Collabora sein frame-ancestors, und der Browser darf den
|
||||
# Rahmen sonst nicht zeigen ("Framing … violates 'frame-ancestors …'").
|
||||
# Collabora holt die Datei dann über deinen Reverse Proxy.
|
||||
OFFICE_WOPI_URL=https://cloud.example.com
|
||||
# Gegen SSRF: was das Backend anfragen darf. Der Host aus OFFICE_URL zählt
|
||||
# immer dazu.
|
||||
OFFICE_ALLOWED_HOSTS=office
|
||||
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen: openssl rand -hex 32
|
||||
# Muss mit JWT_SECRET des Office-Containers übereinstimmen. Ohne ihn schlägt
|
||||
# das Wandeln nach PDF fehl (der Server antwortet `{"error":-8}`). Für den
|
||||
# WOPI-Weg wird er nicht gebraucht, der hat eigene Token.
|
||||
# Leer = aus. Compose-Schnipsel, Adressen und Fallstricke: README.md.
|
||||
# Bei Collabora muss OFFICE_WOPI_URL die öffentliche Adresse der Cloud sein,
|
||||
# sonst verweigert der Browser den Rahmen.
|
||||
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
|
||||
# Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
|
||||
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
|
||||
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
|
||||
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
|
||||
# ist fast immer falsch.
|
||||
OFFICE_PUBLIC_URL=
|
||||
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
|
||||
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
|
||||
# Nur 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_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
|
||||
@@ -123,10 +141,6 @@ OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
||||
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
|
||||
# OCR_TIMEOUT=10m # Notbremse je Datei
|
||||
|
||||
# ══ App-Umschalter (optional) ═════════════════════════════════════════════════
|
||||
# Andere selbst gehostete Apps im Menü der Seitenleiste.
|
||||
# EXTERNAL_APPS=InkNote|https://notes.example.com|📝
|
||||
|
||||
# ══ HoneyOS-Leiste (optional) ═════════════════════════════════════════════════
|
||||
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
||||
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
|
||||
@@ -134,25 +148,10 @@ OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
||||
# Wirkt beim **Neustart** – kein Neubau nötig.
|
||||
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
||||
HONEYBAR_ORIGIN=
|
||||
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
|
||||
# drüben passen. Nur nötig, wenn HoneyOS auf einer anderen Site liegt als
|
||||
# Aether – dann schickt der Browser die HoneyOS-Sitzung nicht mit.
|
||||
HONEYBAR_KEY=
|
||||
|
||||
# ══ Nur für build/docker-compose.yml ══════════════════════════════════════════
|
||||
# Diese vier steuern den Office-Container aus dem Entwicklungs-Compose. Beim
|
||||
# Betrieb mit eigenem Office-Container stehen sie in dessen Compose-Datei.
|
||||
#
|
||||
# Wer seine Dateien in Collabora öffnen darf – derselbe Host wie in
|
||||
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
|
||||
# anhängen. Fehlt er, lehnt Collabora ab:
|
||||
# "No authorized hosts found matching the target host […] in config"
|
||||
OFFICE_ALIASGROUP=https://cloud\.example\.com:443
|
||||
OFFICE_SSL=false
|
||||
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
|
||||
OFFICE_PORT=9980
|
||||
|
||||
# ══ Später ════════════════════════════════════════════════════════════════════
|
||||
# NOCH NICHT GEBAUT – Notiz, kein Schalter. Gesucht wird derzeit mit der
|
||||
# Volltextsuche von Postgres; diese beiden Werte liest niemand.
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_KEY=
|
||||
|
||||
|
||||
+4
-1
@@ -1,5 +1,5 @@
|
||||
# Secrets
|
||||
build/.env
|
||||
build/.env*
|
||||
.env
|
||||
|
||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
||||
@@ -29,3 +29,6 @@ Thumbs.db
|
||||
|
||||
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
||||
frontend/public/pdfjs/
|
||||
# Excalidraws Schriften – aus dem Paket kopiert, nicht eingecheckt (siehe
|
||||
# vite.config.ts). Fremde Binaerdateien gehoeren nicht in den Quelltext.
|
||||
frontend/public/excalidraw/
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# Bestandsaufnahme, 12.09.2026
|
||||
|
||||
> **Stand 24.09.2026.** Dieses Dokument ist die Aufnahme vom 12.09. Was seither
|
||||
> erledigt wurde, steht an Ort und Stelle durchgestrichen oder vermerkt; die
|
||||
> Kurzfassung:
|
||||
>
|
||||
> | | |
|
||||
> |---|---|
|
||||
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
|
||||
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
|
||||
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
|
||||
> | Go-Tests `rescan` | ✅ 5 Testfunktionen (26.09.) – dabei eine fehlende Notbremse gefunden |
|
||||
> | Go-Tests `audit`, `thumbnails` | **bewusst nicht** – siehe unten |
|
||||
> | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests |
|
||||
>
|
||||
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
|
||||
> sie keine Befunde abarbeitete: Rückmeldungen mit Rückgängig, Platzhalter,
|
||||
> Leerzustände, Sprache vereinheitlicht, `Intl`. Steht in
|
||||
> [TESTING.md](TESTING.md).
|
||||
|
||||
Einmal durch die ganze Cloud: Code, Sicherheit, offene Enden. Geordnet nach
|
||||
dem, was passieren kann — nicht nach Aufwand.
|
||||
|
||||
Was hier steht, ist **gemessen**, nicht geschätzt: Routen aus `main.go`
|
||||
extrahiert und gegen die Rechteprüfungen in den Handlern abgeglichen,
|
||||
`govulncheck` über den ganzen Baum, Zeilen und Testumfang je Paket gezählt.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Ein Bild-Upload kann den Prozess töten
|
||||
|
||||
**Schwere: hoch. Jeder Kontoinhaber, normale Bedienung.**
|
||||
|
||||
`cmd/aether/main.go:702` erzeugt das Vorschaubild in einer **nackten
|
||||
Goroutine**:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
…
|
||||
thumbnails.Generate(ctx, st, …) // dekodiert eine hochgeladene Datei
|
||||
}()
|
||||
```
|
||||
|
||||
Chis `Recoverer` hängt am Anfragebehandler. Eine Panik in einer *eigenen*
|
||||
Goroutine fängt er nicht — sie nimmt den **ganzen Go-Prozess** mit. Docker
|
||||
startet ihn neu (`restart: unless-stopped`), aber jeder gerade laufende Upload
|
||||
stirbt mit, und wer die Datei noch einmal hochlädt, wiederholt das Spiel.
|
||||
|
||||
Die passende Lücke war offen: **GO-2026-5061**, Panik im WebP-Dekodierer bei
|
||||
unstimmiger Alphakanal-Größe. Behoben am 12.09.2026 durch `x/image` 0.42 → 0.45
|
||||
(`ef60497`).
|
||||
|
||||
**Damit ist die Hälfte erledigt, nicht das Problem.** Die Bauweise bleibt: Der
|
||||
nächste Dekodierer-Fehler trifft genauso, und Bilddekodierer sind eine Gattung,
|
||||
in der regelmäßig etwas gefunden wird. Vier weitere Goroutinen dieser Art gibt
|
||||
es: `internal/sharing/handler.go:232` und `main.go:185` (beide nur Datenbank,
|
||||
harmlos), `internal/gc/gc.go:257`, und der OCR-Arbeiter (`go worker.Run(ctx)`),
|
||||
der Fremdprogramme aufruft und deren Ausgabe liest.
|
||||
|
||||
**✅ Erledigt am 12.09.2026.** `pkg/bg` mit zwei Funktionen:
|
||||
|
||||
* `bg.Go(name, fn)` – startet eine Goroutine, die eine Panik überlebt.
|
||||
* `bg.Run(name, fn)` – dasselbe ohne eigene Goroutine, für Schleifen.
|
||||
|
||||
Der Unterschied ist keine Feinheit: Fängt man eine Panik um den **ganzen**
|
||||
Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und von da
|
||||
an wird still keine Datei mehr erkannt. Der OCR-Arbeiter sichert deshalb jeden
|
||||
**Auftrag** einzeln ab; ein unverdauliches Dokument kostet dann genau sich
|
||||
selbst.
|
||||
|
||||
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zähler,
|
||||
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit Begründung
|
||||
im Code – die Signalbehandlung beim Herunterfahren; ein recover brächte dort
|
||||
nichts, weil `main` ohnehin wartete, und sie fasst keine fremden Daten an.
|
||||
|
||||
Eine aufgefangene Panik wird mit Stapel und Herkunftsname protokolliert. Sie
|
||||
ist ein Fehler und bleibt einer – sie reißt nur nichts mehr mit. Vier Tests in
|
||||
`pkg/bg/bg_test.go`, darunter der, auf den es ankommt: Eine Schleife läuft nach
|
||||
einer Panik im dritten Durchgang weiter.
|
||||
|
||||
> Der Unterschied ist wichtig: Die Version zu heben schloss **diese** Lücke.
|
||||
> Der `recover` schließt die **Gattung**.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Beschädigte Downloads werden erkannt und verschwiegen
|
||||
|
||||
**Schwere: mittel. Datenintegrität, still.**
|
||||
|
||||
`internal/files/handler.go:496` rechnet beim Ausliefern die Prüfsumme mit —
|
||||
genau so, wie es die Projektregel verlangt („SHA-256: beim Upload berechnen,
|
||||
beim Download verifizieren"). Nur passiert bei einer Abweichung nichts:
|
||||
|
||||
```go
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
// Log corruption; headers already sent, can't change status.
|
||||
// TODO: alert / observability hook
|
||||
_ = actual
|
||||
}
|
||||
```
|
||||
|
||||
Kein Logeintrag, kein Zähler, keine Meldung. Der Kommentar sagt, was fehlt.
|
||||
|
||||
Dass der Statuscode nicht mehr zu ändern ist, stimmt — die Kopfzeilen sind
|
||||
längst draußen. Aber **still** muss es deshalb nicht sein. Ein `log.Error()`
|
||||
mit Datei-ID, erwarteter und tatsächlicher Summe kostet nichts und ist der
|
||||
Unterschied zwischen „ich habe eine kaputte Datei" und „ich habe eine kaputte
|
||||
Datei und weiß es seit drei Monaten nicht".
|
||||
|
||||
Zweitens ließe sich die Verbindung abbrechen, statt sie sauber zu beenden: Der
|
||||
Browser zeigt dann einen abgebrochenen Download statt einer heilen Datei mit
|
||||
falschem Inhalt. Das ist die ehrlichere Ausgabe.
|
||||
|
||||
**✅ Erledigt am 12.09.2026.** Beides – und ein Drittes, das dabei herauskam.
|
||||
|
||||
Der Logeintrag nennt Datei-ID, Speicherschlüssel, erwartete und gelesene Summe.
|
||||
Dazu wird die Verbindung fallen gelassen (`panic(http.ErrAbortHandler)`, den
|
||||
chis Recoverer ausdrücklich durchreicht).
|
||||
|
||||
**Damit das überhaupt wirkt, mussten die letzten 32 KiB zurückgehalten
|
||||
werden.** Sonst wäre die Datei beim Erkennen des Fehlers längst vollständig
|
||||
beim Empfänger, und der Abbruch käme zu spät: Die Kopfzeilen sind draußen, die
|
||||
Bytes auch. Ein `tailWriter` reicht deshalb alles durch außer dem Schluss; erst
|
||||
wenn die Summe stimmt, darf der hinterher. Stimmt sie nicht, bekommt die
|
||||
Gegenstelle weniger Bytes als im `Content-Length` angekündigt – und wertet das
|
||||
als Fehler statt als Datei.
|
||||
|
||||
Geprüft in `scripts/pruefsumme-selbsttest.py` (14 Prüfungen): Der Test kippt
|
||||
ein Bit **auf der Platte**, nicht im Programm, und zwar in der Mitte der Datei,
|
||||
damit der Fehler nicht schon an der Länge auffällt. Zwei Größen – über und
|
||||
unter den 32 KiB, denn bei einer kleinen Datei wird gar nichts herausgegeben.
|
||||
Und er prüft zuerst, dass eine **heile** Datei vollständig durchkommt; ohne das
|
||||
bewiese der Rest nichts.
|
||||
|
||||
---
|
||||
|
||||
## 2b · Und der Fund dahinter: 34 Logaufrufe schrieben ins Nichts
|
||||
|
||||
**Schwere: hoch, indirekt. Ein Fehler, der andere Fehler versteckt.**
|
||||
|
||||
Der neue Logeintrag blieb beim ersten Testlauf aus, obwohl der Abbruch
|
||||
funktionierte. Ursache:
|
||||
|
||||
```go
|
||||
func Ctx(ctx context.Context) *Logger {
|
||||
if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { return l }
|
||||
else if l = DefaultContextLogger; l != nil { return l }
|
||||
return disabledLogger // ← hier landete alles
|
||||
}
|
||||
```
|
||||
|
||||
`zerolog.Ctx` liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||
steckt und kein Standard gesetzt ist. Aethers `middleware.Logger` hängte nie
|
||||
einen hinein, und `DefaultContextLogger` war nie gesetzt.
|
||||
|
||||
**Alle 34 `log.Ctx(...)`-Aufrufe im Baum schrieben damit ins Nichts.** Darunter:
|
||||
|
||||
* die Warnung, dass beim Zurückrollen einer Fassung ein Blob verwaist ist
|
||||
(`files/handler.go`, `files/replace.go`, `handler_versions.go`)
|
||||
* gescheiterte Suchen
|
||||
* Fehler der Speicherbereinigung
|
||||
* die neue Meldung über beschädigte Dateien
|
||||
|
||||
Das ist die unangenehmste Art Fehler: Der Aufruf sieht richtig aus, `go vet`
|
||||
schweigt, und auffallen kann es nur, wenn man den Fehler herbeiführt, den die
|
||||
Zeile melden soll.
|
||||
|
||||
**Behoben zweifach:** `zerolog.DefaultContextLogger` beim Start gesetzt (deckt
|
||||
auch Hintergrundarbeit mit eigenem Kontext ab), und `middleware.Logger` hängt
|
||||
zusätzlich einen Logger mit `request_id` in den Anfragekontext – damit bei zwei
|
||||
gleichzeitigen Downloads erkennbar bleibt, welche Meldung zu welchem gehört.
|
||||
Zwei Tests in `pkg/middleware/logger_test.go` halten es fest.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Vier Pakete ohne einen einzigen Go-Test
|
||||
|
||||
**Schwere: mittel. Nicht was heute kaputt ist, sondern was morgen still kaputtgeht.**
|
||||
|
||||
| Paket | Zeilen | Test (24.09.) | Aussenpruefung |
|
||||
|---|---:|---:|---|
|
||||
| `internal/tags` | 1338 | **4** ✅ | keine |
|
||||
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
|
||||
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
|
||||
| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen |
|
||||
| `internal/audit` | 180 | **0** | keine |
|
||||
| `internal/thumbnails` | 95 | **0** | keine |
|
||||
|
||||
Bei `sharing` und `rescan` deckt wenigstens ein Selbsttest die Außenseite ab.
|
||||
Bei **`documents` und `tags` gar nichts** — und `documents` ist das Stück, das
|
||||
aus `scan_0042.pdf` ein auffindbares Dokument macht. Genau der Grund, aus dem
|
||||
Aether das Rechnungsarchiv ist.
|
||||
|
||||
**Teilweise erledigt am 12.09.2026:** `internal/documents` hat jetzt acht
|
||||
Tests — gezielt auf die drei Zustände, in denen die Warteschlange
|
||||
hängenbleiben kann, ohne dass es auffällt (Details in
|
||||
[TESTING.md](TESTING.md)). Dazu `scripts/db-tests.sh`, damit sie nicht wie
|
||||
`vitest` zur Dekoration werden: Sie überspringen sich ohne `TEST_DSN` und
|
||||
liefen sonst nie.
|
||||
|
||||
Gegengeprüft an absichtlich kaputtem Code — und **der zweite Versuch blieb
|
||||
grün**: `maxAttempts` von 3 auf 9999 gesetzt, alle Tests weiter in Ordnung,
|
||||
nur die Laufzeit stieg von 0,2 auf 27 Sekunden. Die Tests schleiften über die
|
||||
Konstante und setzten die Zahl voraus, statt sie zu prüfen. Dagegen steht jetzt
|
||||
ein eigener Test.
|
||||
|
||||
**`internal/tags` ebenfalls erledigt am 12.09.2026.** Fünf Tests, alle auf
|
||||
dieselbe Frage: Bleibt ein fremdes Etikett fremd – in der Liste, beim
|
||||
Umbenennen, beim Löschen, beim Anhängen an die eigene Datei, und vor allem beim
|
||||
**Auflisten der Dateien darunter**. Der letzte Weg gibt Dateinamen, Ordner und
|
||||
Größen heraus; wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||
fremden Unterlagen.
|
||||
|
||||
Auch hier gegen kaputten Code gegengeprüft: Eigentümerprüfung aus
|
||||
`FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf"*.
|
||||
Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests rot.
|
||||
|
||||
**Ein Verdacht erwies sich dabei als falsch, und das ist der nützlichere
|
||||
Ausgang:** `List` bietet Gruppen-Etiketten an, `SetFieldsForFile` vergleicht
|
||||
gegen den Prinzipal der *Datei* – das sah nach einem Widerspruch aus, bei dem
|
||||
die Oberfläche etwas anbietet, was der Server ablehnt. Ist keiner: Die
|
||||
Oberfläche filtert bereits (`TagDialog.tsx:69`). Und die Ablehnung ist nicht
|
||||
Förmlichkeit, sondern die tragende Absicherung – `FilesWithTag` prüft, ob man
|
||||
das *Etikett* sehen darf, **nicht** die *Datei*. Das ist nur deshalb harmlos,
|
||||
weil ein Etikett stets denselben Prinzipal hat wie die Dateien darunter. Fiele
|
||||
die Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei, und jedes
|
||||
Gruppenmitglied sähe ihren Namen. Der Test hält jetzt diesen Zusammenhang fest,
|
||||
nicht bloß eine Fehlermeldung.
|
||||
|
||||
Nebenbei: `internal/tags` hat **zwei** Fehlermarken für dieselbe Aussage –
|
||||
`ErrNotFound` und `ErrCorrespondentNotFound`. Kein Fehler, aber eine
|
||||
Stolperstelle; jetzt wenigstens festgehalten.
|
||||
|
||||
**Offen bleiben** `internal/sharing`, `internal/rescan` (beide mit
|
||||
Python-Selbsttest), `internal/audit` und `internal/thumbnails`.
|
||||
|
||||
---
|
||||
|
||||
### `audit` und `thumbnails`: bewusst ohne Go-Tests (26.09.2026)
|
||||
|
||||
Nicht vergessen, sondern entschieden. Ein Fehler in `thumbnails` (95 Zeilen)
|
||||
erzeugt ein hässliches Vorschaubild; einer in `audit` (180 Zeilen) kostet einen
|
||||
Protokolleintrag. Beide schreiben nichts kaputt und löschen nichts.
|
||||
|
||||
Tests dafür wären Häkchen, keine Absicherung – und sie kosten dieselbe
|
||||
Aufmerksamkeit wie die Stellen, an denen wirklich etwas passieren kann. Wer sie
|
||||
später doch will, hat damit wenigstens die Gegenrede.
|
||||
|
||||
## 4 · Was in Ordnung ist
|
||||
|
||||
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
|
||||
gibt:
|
||||
|
||||
* **Alle 27 Admin-Routen prüfen serverseitig auf Adminrecht.** Zwei
|
||||
Ausnahmen sind Absicht und dokumentiert: `GET /admin/settings` liest jeder
|
||||
Angemeldete (Theming — und es liegen dort nur sechs Branding-Schlüssel,
|
||||
nachgesehen), `GET /ocr` ist der eigene Erkennungsstand ohne Adminrecht.
|
||||
* **Kein einziges zusammengesetztes SQL.** Alles parametrisiert.
|
||||
* **Inline-Auslieferung nur über eine Positivliste** harmloser Typen, mit
|
||||
`sandbox`, und `allow-scripts` ausschließlich für PDF.
|
||||
* **Ratenbegrenzung an jeder unangemeldeten Tür**, die eines braucht: Anmeldung
|
||||
(20/min), WebDAV, CalDAV, Freigabelinks (je 10 Fehlversuche / 15 Minuten).
|
||||
Die übrigen offenen Wege (`/health`, `/branding`, `/manifest.webmanifest`,
|
||||
`/api/v1/public/config`) geben nichts preis.
|
||||
* **Kein `dangerouslySetInnerHTML`, kein `eval`** im Frontend.
|
||||
* **Sechs Selbsttest-Suiten, 220 Prüfungen** gegen die laufende Instanz, dazu
|
||||
37 Frontend-Tests und die Go-Tests.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Kleinere offene Enden
|
||||
|
||||
* **`internal/groups` ist ein leerer Stub** (0 Zeilen). Gruppenordner liegen in
|
||||
`files/groupfolder.go`. Entweder das Verzeichnis weg oder der Code dorthin —
|
||||
ein leeres Paket im Baum ist eine Frage, die jeder einmal stellt.
|
||||
* **`favicon_storage_key`** steht als leere Zeile in den Einstellungen; die
|
||||
Funktion heißt seit dem App-Symbol anders. Schadet nichts, wird ignoriert.
|
||||
* **Die Fehlversuchssperren liegen im Arbeitsspeicher.** Ein Neustart des
|
||||
Containers hebt jede Sperre auf. Für den Hausgebrauch vertretbar; wer es
|
||||
anders will, braucht eine Tabelle.
|
||||
* ~~**Frontend ohne Komponententests.**~~ ✅ **18.09.2026:** `jsdom` ist in den
|
||||
devDependencies, die Umgebung bleibt `node` (schnell); wer ein DOM braucht,
|
||||
schreibt `// @vitest-environment jsdom` an den Anfang seiner Datei. 46 Tests,
|
||||
davon vier gegen ein DOM. Der erste davon hat sofort einen Fehler gefunden –
|
||||
in der Behebung vom Vortag (`useBackClose`).
|
||||
|
||||
**Was er nicht leistet, steht dabei:** Den ursprünglichen Fehler bildet jsdom
|
||||
nicht ab; es verwirft einen Verlaufssprung, sobald `pushState` folgt. Dafür
|
||||
bräuchte es einen echten Browser (Playwright). Nachgeprüft, nicht vermutet:
|
||||
mit der alten, kaputten Fassung blieben alle Tests grün.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge, die ich vorschlagen würde
|
||||
|
||||
1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026
|
||||
2. ~~Prüfsummen-Abweichung melden~~ ✅ 12.09.2026 – und dabei fiel auf, dass
|
||||
**alle** kontextgebundenen Logzeilen ins Nichts gingen (2b).
|
||||
3. ~~Tests für `documents`~~ ✅ und ~~`tags`~~ ✅ 12.09.2026.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Ideen
|
||||
|
||||
Was noch kommen könnte. **Nichts davon ist beschlossen** — beschlossen ist,
|
||||
was im [README](README.md) und in den Plänen steht
|
||||
([cloud-projekt-plan.md](cloud-projekt-plan.md), [kalender-plan.md](kalender-plan.md)).
|
||||
|
||||
Hier landet, was einen guten Grund hat, aber gerade keinen Anlass. Der
|
||||
Unterschied ist wichtig: Eine Funktion ohne Anlass zu bauen ist der Weg, auf
|
||||
dem eine Anwendung unübersichtlich wird.
|
||||
|
||||
---
|
||||
|
||||
## Kalender
|
||||
|
||||
### Öffentlicher Abo-Link — vertagt (12.09.2026)
|
||||
|
||||
Ein Nur-Lese-Link auf einen Kalender, den jemand **ohne Konto** abonnieren
|
||||
kann. Nicht dasselbe wie die Freigabe an ein Konto (die steht seit dem
|
||||
12.09.2026): Dort ist der Empfänger bekannt, hier ist er es nicht.
|
||||
|
||||
**Warum vertagt:** kein Anlass. Marc ist der einzige Nutzer seiner Cloud, und
|
||||
für den eigenen Gebrauch gibt es bereits zwei Wege an dieselben Termine — die
|
||||
Weboberfläche und CalDAV mit App-Passwort.
|
||||
|
||||
**Wofür es gut wäre, wenn der Anlass kommt:**
|
||||
|
||||
* Den eigenen Kalender in einer *fremden* Anwendung einblenden, die kein
|
||||
CalDAV mit Anmeldung kann, sondern nur eine `.ics`-Adresse frisst — etwa
|
||||
ein Kalender auf der Arbeit.
|
||||
* Einen Terminplan an Familie oder Verein geben, ohne für jeden ein Konto
|
||||
anzulegen.
|
||||
|
||||
**Wie es gebaut würde** (damit die Überlegung nicht verloren geht):
|
||||
|
||||
Aether hat die Freigabelinks für Dateien schon, samt Token, optionalem
|
||||
Passwort, Ablaufdatum und dem Zähler. Ein Kalender-Abo ist dieselbe Sache mit
|
||||
einer anderen Ausgabe: `GET /share/kalender/{token}.ics` liefert den Export,
|
||||
den es schon gibt (`api.go`, `Export`).
|
||||
|
||||
Drei Dinge wären dabei zu entscheiden, und sie sind der eigentliche Inhalt:
|
||||
|
||||
1. **Nur lesend, ohne Ausnahme.** Ein schreibbarer Link wäre ein Konto ohne
|
||||
Passwort.
|
||||
2. **Was steht drin?** Ein Abo zeigt Titel, Ort und Beschreibung jedes
|
||||
Termins. Wer „Arzttermin, Dr. Müller" im Kalender stehen hat und den Link
|
||||
weitergibt, gibt das mit weiter. Ein Schalter „nur Belegt/Frei" (Titel
|
||||
durch „Belegt" ersetzen) wäre deshalb kein Zierrat, sondern der Grund,
|
||||
warum man den Link überhaupt zu teilen wagt.
|
||||
3. **Ein Abo wird regelmäßig abgerufen**, nicht einmal. Ein Kalenderprogramm
|
||||
fragt alle paar Minuten nach. Der Zähler der Freigabelinks zählte dann
|
||||
Maschinen statt Menschen, und die Sperre nach zehn Fehlversuchen träfe
|
||||
einen Client, der bloß ein falsch gespeichertes Passwort wiederholt.
|
||||
Beides müsste für diesen Linktyp anders sein.
|
||||
|
||||
### Aufgabenansicht (VTODO) — vertagt (12.09.2026)
|
||||
|
||||
Aether nimmt Aufgaben bereits an, speichert sie und gleicht sie ab; die Spalte
|
||||
`component` in `cal_objects` unterscheidet sie von Terminen. Es fehlt **nur**
|
||||
die Ansicht in der Weboberfläche.
|
||||
|
||||
**Warum vertagt:** Auf Marcs Telefon gibt es nichts, was sie zeigen würde.
|
||||
|
||||
Android hat von Haus aus **keinen Speicher für Aufgaben**. Für Termine gibt es
|
||||
einen Systemanbieter — den füllt DAVx5, und Samsung Kalender liest daraus. Für
|
||||
Aufgaben gibt es das nicht. DAVx5 kann Aufgabenlisten deshalb nur abgleichen,
|
||||
wenn eine App installiert ist, die selbst den Speicher mitbringt:
|
||||
**`tasks.org`**, OpenTasks oder jtx Board.
|
||||
|
||||
**Samsungs „Erinnerungen" gehört nicht dazu.** Der Bereich „Erinnerungen" im
|
||||
Samsung Kalender ist kein Teil des Kalenders, sondern ein Fenster in die
|
||||
Samsung-Erinnerungen-App; die hängt am Samsung-Konto (bzw. an Microsoft To Do),
|
||||
nicht an CalDAV. Der Unterschied ist am Gerät ablesbar: Beim Anlegen eines
|
||||
**Termins** fragt Samsung Kalender, in welchen Kalender — dort steht Aether zur
|
||||
Auswahl. Beim Anlegen einer **Erinnerung** fragt es das nicht, weil es nur
|
||||
einen Ort gibt, an den sie gehen kann.
|
||||
|
||||
Eine Ansicht in Aether wäre heute also eine reine Weboberflächen-Aufgabenliste –
|
||||
ohne Weg, vom Telefon aus etwas hineinzuschreiben. Das ist ein eigenes
|
||||
Vorhaben, kein Nebenprodukt des Kalenders.
|
||||
|
||||
**Der Anlass wäre: `tasks.org`.** Kostenlos, quelloffen, arbeitet mit DAVx5
|
||||
zusammen und bringt den Aufgabenspeicher mit, der Android fehlt. Ist die App
|
||||
installiert und das Aether-Konto in DAVx5 für Aufgabenlisten eingeschaltet,
|
||||
landen Aufgaben vom Telefon in Aether — und dann hat die Ansicht in der
|
||||
Weboberfläche einen Anlass. Der Unterbau steht bereits: Aether meldet seinen
|
||||
Kalendern `VTODO` als unterstützt, nimmt sie an, speichert sie und gleicht sie
|
||||
über `sync-collection` ab. Zu bauen bliebe die Ansicht selbst — eine Liste mit
|
||||
Häkchen, Fälligkeit und Priorität, sinnvollerweise als vierte Ansicht neben
|
||||
Monat, Woche und Liste.
|
||||
|
||||
**Was beim Bauen zu bedenken wäre:** Eine Aufgabe hat *kein* Pflichtdatum. Alle
|
||||
drei heutigen Ansichten ordnen nach Zeit — eine Aufgabe ohne Fälligkeit hätte
|
||||
darin keinen Platz. Das ist der Grund, warum es eine eigene Ansicht braucht und
|
||||
kein Einstreuen in den Monat.
|
||||
|
||||
**Gegentest, falls die Einschätzung falsch ist:** DAVx5 öffnen, Aether-Konto
|
||||
antippen. Gibt es dort einen Reiter „Aufgabenlisten" mit Einträgen, ohne dass
|
||||
eine der genannten Apps installiert ist, stimmt das oben nicht.
|
||||
|
||||
---
|
||||
|
||||
## Eine Android-App — notiert (15.09.2026)
|
||||
|
||||
Eine einfache eigene App fürs Telefon, statt der installierten Weboberfläche.
|
||||
|
||||
**Warum das nicht offensichtlich ist:** Die Weboberfläche *ist* am Telefon
|
||||
schon installierbar, hat ein eigenes Symbol, läuft ohne Adressleiste und kennt
|
||||
die sicheren Bereiche neben Uhr und Wischbalken. Eine App, die nur dasselbe in
|
||||
einem Rahmen zeigt, wäre Arbeit ohne Gewinn.
|
||||
|
||||
**Was eine echte App kann, was die Weboberfläche nicht kann** — und nur dafür
|
||||
lohnt sie sich:
|
||||
|
||||
* **Fotos automatisch hochladen.** Der eigentliche Grund, warum Leute die
|
||||
Nextcloud-App installieren. Der Browser darf im Hintergrund nicht auf die
|
||||
Galerie, eine App schon.
|
||||
* **„Teilen mit → Aether"** aus jeder anderen App heraus. Ein Anhang aus dem
|
||||
Mailprogramm landet in einem Tippen in der Cloud.
|
||||
* **Hochladen, das weiterläuft**, wenn der Schirm aus ist. Ein Browser-Tab
|
||||
wird nach Sekunden angehalten; ein großer Upload bricht deshalb ab.
|
||||
* **Dateien im Android-Dateimanager** über einen Storage Access Provider.
|
||||
|
||||
**Was es kosten würde:** Ein zweiter Stapel (Kotlin oder Flutter), eine zweite
|
||||
Veröffentlichungskette, und jede Serveränderung ist ab dann an zwei Stellen
|
||||
nachzuziehen. Die bestehende REST-Schnittstelle plus App-Passwörter genügen
|
||||
als Unterbau, ein neues Backend wäre es nicht.
|
||||
|
||||
**Der Anlass wäre: automatischer Fotoupload.** Solange das nicht gebraucht
|
||||
wird, ist die installierte Weboberfläche der bessere Weg — sie ist da, sie ist
|
||||
gepflegt, und sie ist genau eine Codebasis.
|
||||
|
||||
---
|
||||
|
||||
## Mehrsprachigkeit — notiert (18.09.2026)
|
||||
|
||||
Die Oberfläche spricht seit dem 18.09.2026 durchgehend Englisch. Deutsch (oder
|
||||
irgendeine weitere Sprache) daneben wäre eine eigene Baustelle.
|
||||
|
||||
**Der Umfang, gemessen statt geschätzt:**
|
||||
|
||||
~986 sichtbare Zeichenketten in 55 Frontend-Dateien
|
||||
144 Fehlermeldungen im Backend
|
||||
|
||||
**Der Punkt, den man dabei vergisst: Es sind zwei Übersetzungen, nicht eine.**
|
||||
|
||||
Die 144 Meldungen des Servers landen seit dieser Woche in den Toasts – „You
|
||||
only have read access to this file" kommt aus Go, nicht aus React. Wer nur das
|
||||
Frontend übersetzt, bekommt eine deutsche Oberfläche, die bei jedem Fehler auf
|
||||
Englisch umschaltet. Das wirkt kaputter als durchgehend Englisch.
|
||||
|
||||
Der saubere Weg: Der Server schickt keinen fertigen Satz mehr, sondern einen
|
||||
Schlüssel samt Werten (`{"error":"file.read_only"}`); übersetzt wird vorne, wo
|
||||
der Katalog ohnehin liegt. Das ist eine Protokolländerung an 144 Stellen, kein
|
||||
Wörterbuch.
|
||||
|
||||
**Wie es gebaut würde:**
|
||||
|
||||
* Jede Zeichenkette wird ein Schlüssel (`t('calendar.new')`). Rund 1000
|
||||
Stellen – stumpfe Arbeit, gut mit Werkzeug zu machen.
|
||||
* `react-i18next` statt Eigenbau. Eigenbau scheitert an den **Pluralformen**:
|
||||
„1 Datei" / „5 Dateien", und andere Sprachen haben mehr als zwei Formen.
|
||||
* Die Sprache kommt aus `navigator.language`, ist im Profil überschreibbar und
|
||||
liegt **in der Datenbank** – damit sie am Telefon dieselbe ist wie am
|
||||
Rechner. Dieselbe Entscheidung wie bei den Favoriten.
|
||||
|
||||
**Warum vertagt:** Marc ist der einzige Nutzer und liest Englisch. Der Preis
|
||||
ist nicht die einmalige Umstellung, sondern dass ab dann *jeder neue Text zwei
|
||||
Änderungen kostet* – für immer. Der Anlass wäre eine zweite Person, die kein
|
||||
Englisch mag.
|
||||
|
||||
### Unabhängig davon lohnend: `Intl`
|
||||
|
||||
Aether pflegt **sechs handgeschriebene Arrays** mit Wochentagen und
|
||||
Monatsnamen (CalendarPage, DayDialog, EventDialog), und `Intl` wird im ganzen
|
||||
Projekt **nirgends** benutzt. Dabei kann der Browser das seit Jahren:
|
||||
|
||||
new Intl.DateTimeFormat(sprache, { weekday: 'short' })
|
||||
|
||||
Das ersetzt alle sechs, kostet keine Abhängigkeit und entschärft eine Falle,
|
||||
die beim Übersetzen auffiel: `DayDialog` beginnt seine Woche am **Sonntag**,
|
||||
die anderen am **Montag** – jeweils passend zu ihrer Indizierung. Heute
|
||||
korrekt, aber wer eines dieser Arrays kopiert, baut sich einen
|
||||
Off-by-one-Fehler.
|
||||
|
||||
Lohnt sich auch ohne jede Übersetzung, und wäre die Vorarbeit, falls sie
|
||||
kommt.
|
||||
@@ -44,12 +44,16 @@ Zwei Wege, je nachdem was du vorhast:
|
||||
| **Betreiben** | `docker-compose.example.yml` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||||
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
|
||||
|
||||
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – eine Datei, alle
|
||||
Einstellungen. Sie lag bis zum 02.09.2026 doppelt vor (einmal schlank hier,
|
||||
einmal vollständig im Bau-Ordner), und die beiden liefen auseinander: Der
|
||||
produktiven fehlten am Ende zwölf Schlüssel, darunter `SESSION_MAX_AGE`, und
|
||||
sie empfahl für `OFFICE_WOPI_URL` einen Wert, der mit Collabora nicht
|
||||
funktioniert.
|
||||
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – sie beschreibt,
|
||||
was die Anwendung liest, und das ist in beiden Fällen dasselbe. Sie lag bis zum
|
||||
02.09.2026 doppelt vor (einmal schlank hier, einmal vollständig im Bau-Ordner),
|
||||
und die beiden liefen auseinander: Der produktiven fehlten am Ende zwölf
|
||||
Schlüssel, darunter `SESSION_MAX_AGE`.
|
||||
|
||||
Die Schalter des **mitgelieferten Collabora-Containers** (`aliasgroup1`, SSL,
|
||||
Port) stehen nicht dort, sondern kommentiert in `build/docker-compose.yml` –
|
||||
sie konfigurieren einen fremden Container, nicht Aether. Wer Office selbst
|
||||
betreibt, richtet sie in dessen eigener Compose-Datei ein.
|
||||
|
||||
### 1. Dateien holen
|
||||
|
||||
@@ -90,10 +94,9 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
|
||||
```
|
||||
|
||||
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, App-Umschalter – steht mit
|
||||
Erklärung in derselben Datei, nach Abschnitten geordnet. Der letzte Abschnitt
|
||||
gilt nur für den Entwicklungs-Compose; beim Betrieb mit eigenem
|
||||
Office-Container stehen diese Werte in dessen Compose-Datei.
|
||||
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, HoneyOS-Leiste – steht mit
|
||||
Erklärung in derselben Datei, nach Abschnitten geordnet. Absichtlich
|
||||
ausführlich: Sie ist auch die Übersicht darüber, was überhaupt geht.
|
||||
|
||||
### 3. Starten
|
||||
|
||||
@@ -232,12 +235,27 @@ selbst anlegst (PocketID, Authentik); bei freier Registrierung wäre es die
|
||||
Übernahme fremder Konten. Verbunden wird ohnehin nur, was noch keine Bindung
|
||||
hat.
|
||||
|
||||
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Die Vorgabe
|
||||
`127.0.0.1` heißt: **nur dieser Rechner.** Von einem anderen Gerät kommt dann
|
||||
kein Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt
|
||||
ab, bevor irgendetwas die Anwendung erreicht. Das ist Absicht: Davor gehört
|
||||
dein Reverse Proxy mit TLS. Zum Ausprobieren im eigenen Netz `APP_BIND=0.0.0.0`
|
||||
setzen – dann geht die Anmeldung allerdings unverschlüsselt über das Netz.
|
||||
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Der richtige Wert
|
||||
hängt davon ab, **wo dein Reverse Proxy steht:**
|
||||
|
||||
| Proxy läuft … | Wert |
|
||||
|---|---|
|
||||
| auf einem anderen Rechner (VPS, Router) | `0.0.0.0` – die Vorgabe |
|
||||
| hinter einem Tunnel (WireGuard, Tailscale) | die Tunnel-Adresse, z. B. `10.0.0.5` |
|
||||
| auf demselben Rechner | `127.0.0.1` |
|
||||
| als Container im selben Docker-Netz | dann braucht es `ports:` gar nicht – der Proxy erreicht `app:8080` intern |
|
||||
|
||||
`127.0.0.1` heißt **nur dieser Rechner**: Von einem anderen Gerät kommt kein
|
||||
Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt ab,
|
||||
bevor irgendetwas die Anwendung erreicht. Wer den Proxy daneben stehen hat,
|
||||
sollte genau das nehmen.
|
||||
|
||||
Die Vorgabe ist trotzdem `0.0.0.0`, weil der häufigere Fall ein Proxy auf einem
|
||||
anderen Rechner ist, den loopback gar nicht erst herankommen ließe. Was das
|
||||
kostet, damit es niemanden überrascht: Der Port ist dann auch aus dem eigenen
|
||||
Netz offen, und **TLS endet erst am Proxy** – auf dem Weg dorthin liegt die
|
||||
Anmeldung unverschlüsselt im Netz. Wer das nicht will, engt den Wert ein oder
|
||||
lässt nur den Proxy durch die Firewall.
|
||||
|
||||
**`DATA_PATH`** ist ein gewöhnliches Verzeichnis auf dem Wirt, kein
|
||||
Docker-Volume – deshalb kommst du per SFTP, rsync oder im Dateimanager daran.
|
||||
@@ -489,6 +507,134 @@ hunderte pro Minute.
|
||||
|
||||
---
|
||||
|
||||
## Dateien von außen: Abgleich mit der Datenbank
|
||||
|
||||
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
|
||||
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
|
||||
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
|
||||
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
|
||||
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
|
||||
und löscht sie.
|
||||
|
||||
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
|
||||
|
||||
| Fall | Was der Abgleich tut |
|
||||
|---|---|
|
||||
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
|
||||
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
|
||||
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
|
||||
|
||||
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
|
||||
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
|
||||
Papierkorb – es ist endgültig.
|
||||
|
||||
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
|
||||
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
|
||||
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
|
||||
eigenen Datei.
|
||||
|
||||
### Für Sicherungen heißt das
|
||||
|
||||
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
|
||||
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
|
||||
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
|
||||
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
|
||||
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
|
||||
|
||||
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
|
||||
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
|
||||
auf Wunsch weg.
|
||||
|
||||
### Der große Fall: alles zurück, ohne Postgres
|
||||
|
||||
Einzelne Dateien zurückzuholen ist das eine. Der Ernstfall ist der andere:
|
||||
Der Server ist fort, aus der Sicherung kommen nur die **Dateien** zurück, die
|
||||
Datenbank ist leer.
|
||||
|
||||
Das geht – aber nur in dieser Reihenfolge, und der zweite Schritt ist der,
|
||||
den man vergisst:
|
||||
|
||||
1. **Dateien zurücklegen** nach `DATA_PATH`, mit der Struktur aus der
|
||||
Sicherung (`users/<konto>/home/…`). Aether starten.
|
||||
2. **Konten neu anlegen – mit exakt denselben Benutzernamen.** Der Ordnername
|
||||
auf der Platte *ist* die Zuordnung; ein Konto `mueller` findet nichts, was
|
||||
unter `users/Mueller/` liegt. Passwörter dürfen neu sein, Namen nicht.
|
||||
3. **Admin → Audit → Rescan storage**, erst *Check*, dann *Apply*.
|
||||
|
||||
Ohne Schritt 2 bleibt der Abgleich stehen und sagt es je Datei:
|
||||
`Konto "altnutzer" gibt es nicht`. Er rät nicht und legt nichts an – ein
|
||||
falsch zugeordnetes Dokument wäre schlimmer als ein unsichtbares.
|
||||
|
||||
**Einmal geprobt, nicht behauptet** (10.09.2026): Auf einer eigens
|
||||
aufgesetzten Instanz Dateien gesichert, Container *und* Datenbank
|
||||
weggeworfen, nur die Dateien zurückgeholt. Nach den drei Schritten standen
|
||||
Ordnerkette, Unterordner, Umlaute im Dateinamen, Größen, Prüfsummen und
|
||||
Dateitypen wieder da, und der Download lieferte den ursprünglichen Inhalt.
|
||||
`.trash/` und `.versions/` blieben zu Recht außen vor. Ein dritter Abgleich
|
||||
meldete nichts Offenes mehr.
|
||||
|
||||
**Was dabei nicht zurückkommt**, weil es nur in der Datenbank steht:
|
||||
Etiketten, Absender, Belegdatum, Freigabelinks, frühere Fassungen, der
|
||||
erkannte Text der Volltextsuche (er wird neu erzeugt), Kalender und Termine.
|
||||
Wem das wehtut, der sichert Postgres mit – die Dateien allein sind die
|
||||
Rückfallebene, nicht der volle Umfang.
|
||||
|
||||
### Aktualisieren einer laufenden Instanz
|
||||
|
||||
Neues Abbild ziehen und neu starten, mehr nicht: Die Migrationen laufen beim
|
||||
Start selbst. Geprobt am 10.09.2026 von Schema 19 auf 23 mit Daten in der
|
||||
Datenbank – Konten, Ordner, Dateien und Etikettenzuordnungen standen danach
|
||||
unverändert da, kein `dirty`-Zustand. **Ein Neuaufbau ist dafür nicht nötig
|
||||
und bringt nichts**, was das Update nicht auch bringt.
|
||||
|
||||
---
|
||||
|
||||
## Kalender (CalDAV)
|
||||
|
||||
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
|
||||
automatische Einrichtung über `/.well-known/caldav` laufen lassen, die jeder
|
||||
verbreitete Client von selbst versucht.
|
||||
|
||||
Angemeldet wird sich wie bei WebDAV mit **Benutzername und App-Passwort**; das
|
||||
Kontopasswort wird auch hier abgewiesen. Dieselbe Sperre nach zehn
|
||||
Fehlanmeldungen gilt ebenfalls.
|
||||
|
||||
Was geht:
|
||||
|
||||
- **Termine** lesen und schreiben, mit Wiederholungen, Ausnahmen einzelner
|
||||
Termine (`EXDATE`), Ganztagsterminen und Zeitzonen.
|
||||
- **Mehrere Kalender**, jeder mit Name und Farbe. Anlegen, umbenennen,
|
||||
umfärben und löschen geht in der Weboberfläche **und** vom Client aus
|
||||
(`MKCALENDAR`, `MKCOL`, `PROPPATCH`, `DELETE`).
|
||||
- **Termin in einen anderen Kalender umhängen**, ohne dass er dabei seine
|
||||
Kennung verliert – für Clients derselbe Termin an neuer Adresse, kein neuer.
|
||||
- **Farben** in beide Richtungen: die des Kalenders (`calendar-color`) und die
|
||||
einzelner Termine (`COLOR` nach RFC 7986, als CSS3-Name, weil DAVx5 sie so
|
||||
liest).
|
||||
- **Im- und Export** ganzer Kalender als `.ics`.
|
||||
|
||||
Zwei Dinge, die man beim Einrichten wissen sollte:
|
||||
|
||||
**Die Kalenderfarbe holt DAVx5 nur beim Erfassen der Sammlungen**, nicht bei
|
||||
jedem Abgleich. Ändert sich die Farbe hier, bleibt sie im Telefon so lange
|
||||
alt, bis man dort einmal *Kalenderliste aktualisieren* (⟳) antippt. Das ist
|
||||
kein Fehler, sondern DAVx5' Sparsamkeit – kostet aber eine halbe Stunde
|
||||
Suche, wenn man es nicht weiß.
|
||||
|
||||
**Kalender verwalten kann die Kalender-App des Telefons nicht.** Android
|
||||
überlässt das der Sync-App: Anlegen und Löschen geschieht in DAVx5 selbst,
|
||||
nicht in der Samsung- oder Google-Kalender-App.
|
||||
|
||||
Noch nicht da: `sync-collection` (RFC 6578). Jeder Abgleich holt deshalb die
|
||||
volle Terminliste statt nur der Änderungen – richtig, aber sparsamer ginge es.
|
||||
Ebenso offen: Aufgaben (`VTODO`) in der Weboberfläche und das Teilen von
|
||||
Kalendern mit anderen Nutzern.
|
||||
|
||||
Zwei Selbsttests decken den Bereich ab, einer je Zugangsweg – siehe
|
||||
[`TESTING.md`](TESTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Office (Collabora / OnlyOffice / Euro-Office)
|
||||
|
||||
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
|
||||
@@ -519,6 +665,13 @@ Container heraus er selbst.
|
||||
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
|
||||
OnlyOffice und Euro-Office auf 80.**
|
||||
|
||||
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
|
||||
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
|
||||
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
|
||||
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
|
||||
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
|
||||
> den Proxy die öffentliche Adresse bedienen.
|
||||
|
||||
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
||||
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
||||
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
||||
@@ -555,7 +708,7 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
|
||||
cap_add: [MKNOD]
|
||||
networks: [internal]
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:9980:9980"
|
||||
- "${APP_BIND:-0.0.0.0}:9980:9980"
|
||||
```
|
||||
|
||||
```env
|
||||
@@ -603,7 +756,7 @@ OFFICE_ALLOWED_HOSTS=office
|
||||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||
networks: [internal]
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
|
||||
- "${APP_BIND:-0.0.0.0}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
|
||||
```
|
||||
|
||||
```env
|
||||
@@ -635,23 +788,34 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
|
||||
ALLOW_PRIVATE_IP_ADDRESS: "true" # es muss app:8080 erreichen
|
||||
networks: [internal]
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}: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.
|
||||
|
||||
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
|
||||
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
|
||||
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
|
||||
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
|
||||
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
|
||||
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
|
||||
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
|
||||
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
|
||||
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
|
||||
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
|
||||
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
|
||||
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
|
||||
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
|
||||
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
|
||||
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
|
||||
> über die Ermittlung hinaus.
|
||||
>
|
||||
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
|
||||
> nach Aktenlage hakt:
|
||||
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
|
||||
> betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
|
||||
> Collabora daneben weiterlief.
|
||||
>
|
||||
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
|
||||
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
|
||||
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
|
||||
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
|
||||
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
|
||||
> Fehler meldet sich nicht, er zeigt nur nichts.
|
||||
>
|
||||
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
|
||||
> an denen es nach Aktenlage hakt:
|
||||
>
|
||||
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
||||
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
||||
@@ -661,53 +825,14 @@ Abbild.
|
||||
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||||
> das der Ort.
|
||||
>
|
||||
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
|
||||
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
|
||||
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
|
||||
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
|
||||
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
|
||||
> gesetzt – sonst läuft die API offen.
|
||||
|
||||
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
|
||||
|
||||
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
|
||||
was aufgeht:
|
||||
|
||||
| Anbieter | am Telefon |
|
||||
|---|---|
|
||||
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
|
||||
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
|
||||
|
||||
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
|
||||
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
|
||||
eine Tastatur.
|
||||
|
||||
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
|
||||
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
|
||||
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
|
||||
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
|
||||
nicht.
|
||||
|
||||
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
|
||||
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
|
||||
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
|
||||
entscheidet der Anbieter.
|
||||
|
||||
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
|
||||
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
|
||||
LibreOffice-Layout desselben Dokuments sehen.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
|
||||
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
|
||||
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
|
||||
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
|
||||
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
|
||||
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
|
||||
|
||||
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
|
||||
Bereinigung, zusammen mit Vorschau- und Profilbildern.
|
||||
> **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
|
||||
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
|
||||
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
|
||||
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
|
||||
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
|
||||
> **Der Office-Container liest es**, und ohne steht seine API offen, wenn
|
||||
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
|
||||
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
|
||||
|
||||
### Welche Dateien, und wie gut
|
||||
|
||||
@@ -793,7 +918,7 @@ unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
|
||||
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
|
||||
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
|
||||
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
|
||||
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
|
||||
| **Euro-Office** | wie OnlyOffice, Abbild **6,99 GB** (hier nachgemessen) | derselbe Unterbau |
|
||||
|
||||
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||||
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
||||
@@ -808,7 +933,8 @@ kurzzeitig mehrere hundert Megabyte.
|
||||
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
||||
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
||||
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
||||
Arbeitsspeicher, und das Abbild wiegt rund 5 GB.
|
||||
Arbeitsspeicher, und das Abbild wiegt **6,99 GB** – nachgemessen, nicht
|
||||
geschätzt; die Ankündigung nennt weniger.
|
||||
|
||||
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
|
||||
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
||||
@@ -832,5 +958,6 @@ kostet einen Container und eine Zeile `.env`, nicht mehr.
|
||||
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
|
||||
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
|
||||
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
|
||||
| — | [Kalender](#kalender-caldav): CalDAV, mehrere Kalender, Weboberfläche (Monat/Woche/Liste) – `sync-collection`, Aufgaben und Teilen offen | ✅ (im Test) |
|
||||
|
||||
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).
|
||||
+1036
-2
File diff suppressed because it is too large.
Load diff
+103
-23
@@ -26,16 +26,19 @@ import (
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/avatar"
|
||||
"aether/internal/calendar"
|
||||
"aether/internal/documents"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/office"
|
||||
appoidc "aether/internal/oidc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/sharing"
|
||||
"aether/internal/storage"
|
||||
"aether/internal/tags"
|
||||
"aether/internal/thumbnails"
|
||||
appwebdav "aether/internal/webdav"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/config"
|
||||
appdb "aether/pkg/db"
|
||||
"aether/pkg/middleware"
|
||||
@@ -68,6 +71,7 @@ func spaHandler(root string) http.Handler {
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||||
setStaticCaching(w, upath)
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -80,10 +84,37 @@ func spaHandler(root string) http.Handler {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
setStaticCaching(w, "/index.html")
|
||||
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() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -96,6 +127,20 @@ func main() {
|
||||
}
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
// **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.**
|
||||
//
|
||||
// zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext
|
||||
// keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher
|
||||
// Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein
|
||||
// Blob verwaist ist, und die Meldung über eine beschädigte Datei beim
|
||||
// Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos:
|
||||
// Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem
|
||||
// man den Fehler herbeiführt, den er melden soll.
|
||||
//
|
||||
// Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue
|
||||
// Logeintrag blieb aus, obwohl der Abbruch funktionierte.
|
||||
zerolog.DefaultContextLogger = &log.Logger
|
||||
|
||||
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -146,13 +191,16 @@ func main() {
|
||||
Timeout: cfg.OCRTimeout,
|
||||
TmpDir: cfg.TmpPath,
|
||||
}, log.Logger)
|
||||
go worker.Run(ctx)
|
||||
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
|
||||
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
|
||||
// Aufräumen der Zwischendateien beim Start.
|
||||
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
|
||||
|
||||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||
// Anfang der gesamte Bestand.
|
||||
go func() {
|
||||
bg.Go("Bestand nachtragen", func() {
|
||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||
@@ -175,7 +223,7 @@ func main() {
|
||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +236,15 @@ func main() {
|
||||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||||
|
||||
shareStore := sharing.NewStore(pool)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
|
||||
|
||||
collector := gc.New(pool, blobStore)
|
||||
|
||||
adminStore := admin.NewStore(pool)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
|
||||
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
|
||||
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
|
||||
rescanner := rescan.New(pool, fileStore, blobStore)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
|
||||
|
||||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||
@@ -216,7 +267,7 @@ func main() {
|
||||
var officeHandler *office.Handler
|
||||
if cfg.OfficeEnabled() {
|
||||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||||
if err != nil {
|
||||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||||
@@ -246,6 +297,11 @@ func main() {
|
||||
// WebDAV
|
||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
||||
|
||||
// CalDAV
|
||||
calStore := calendar.NewStore(pool, aclStore)
|
||||
calHandler := calendar.NewHandler(authStore, calStore)
|
||||
calAPIHandler := calendar.NewAPIHandler(calStore)
|
||||
|
||||
// OIDC (optional – disabled if OIDC_ISSUER not set)
|
||||
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
||||
Issuer: cfg.OIDCIssuer,
|
||||
@@ -306,7 +362,7 @@ func main() {
|
||||
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
||||
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
||||
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
|
||||
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"} {
|
||||
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT", "MKCALENDAR"} {
|
||||
chi.RegisterMethod(m)
|
||||
}
|
||||
|
||||
@@ -352,6 +408,12 @@ func main() {
|
||||
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
||||
r.Mount("/dav", davHandler)
|
||||
|
||||
// CalDAV endpoint – eigene Basic Auth (App-Passwörter), wie /dav.
|
||||
// .well-known/caldav liegt bewusst außerhalb von /caldav: RFC 6764 sieht
|
||||
// diesen Pfad an der Wurzel vor, Clients wie DAVx5 fragen dort zuerst an.
|
||||
r.Mount("/caldav", calHandler)
|
||||
r.Get("/.well-known/caldav", calHandler.ServeWellKnown)
|
||||
|
||||
// Public config (no auth) – used by login page to discover OIDC availability
|
||||
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cloudName := "Aether"
|
||||
@@ -374,16 +436,6 @@ func main() {
|
||||
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
|
||||
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
|
||||
}
|
||||
// Ohne Ursprung gibt es keine Leiste — dann hat auch der Schluessel
|
||||
// nichts auf der Seite zu suchen.
|
||||
honeybarKey := ""
|
||||
if cfg.HoneybarOrigin != "" {
|
||||
honeybarKey = cfg.HoneybarKey
|
||||
}
|
||||
apps := cfg.ExternalApps
|
||||
if apps == nil {
|
||||
apps = []config.ExternalApp{} // nie null – das Frontend erwartet eine Liste
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"oidc_enabled": oidcHandler.IsEnabled(),
|
||||
"cloud_name": cloudName,
|
||||
@@ -392,7 +444,6 @@ func main() {
|
||||
"logo_url": logoURL,
|
||||
"favicon_url": faviconURL,
|
||||
"apple_touch_icon_url": appleTouchURL,
|
||||
"external_apps": apps,
|
||||
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
||||
// erst anbietet, wenn es keinen Office-Server gibt.
|
||||
"office_enabled": officeHandler.Enabled(),
|
||||
@@ -419,7 +470,6 @@ func main() {
|
||||
// .env umgezogen werden kann — und damit derselbe Ursprung
|
||||
// gilt, den die CSP erlaubt.
|
||||
"honeybar_origin": cfg.HoneybarOrigin,
|
||||
"honeybar_key": honeybarKey,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -454,6 +504,7 @@ func main() {
|
||||
r.Get("/search", fileHandler.Search)
|
||||
r.Get("/files/{id}/download", fileHandler.Download)
|
||||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
||||
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
|
||||
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||
@@ -461,7 +512,6 @@ func main() {
|
||||
r.Get("/office/session/{id}", officeHandler.Session)
|
||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||
// Telefon der Normalfall.
|
||||
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||
@@ -518,6 +568,10 @@ func main() {
|
||||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
||||
// Kalender liegen in derselben Rechtetabelle – deshalb dieselben
|
||||
// Endpunkte, und /acl/{id} widerruft alle drei Arten.
|
||||
r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL)
|
||||
r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL)
|
||||
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||||
|
||||
// App passwords (WebDAV/sync clients)
|
||||
@@ -525,6 +579,21 @@ func main() {
|
||||
// es steht ohnehin an Freigaben und in Nutzerlisten.
|
||||
r.Get("/users/{id}/avatar", avatarHandler.Serve)
|
||||
|
||||
// Kalender – die Termine selbst laufen über CalDAV (/caldav/), das
|
||||
// hier ist nur Verwaltung: mehrere Kalender, umbenennen, Farbe,
|
||||
// löschen, sowie Export/Import als .ics.
|
||||
r.Get("/calendars", calAPIHandler.List)
|
||||
r.Post("/calendars", calAPIHandler.Create)
|
||||
r.Patch("/calendars/{id}", calAPIHandler.Update)
|
||||
r.Delete("/calendars/{id}", calAPIHandler.Delete)
|
||||
r.Get("/calendars/{id}/export", calAPIHandler.Export)
|
||||
r.Post("/calendars/{id}/import", calAPIHandler.Import)
|
||||
r.Get("/calendars/{id}/events", calAPIHandler.ListEvents)
|
||||
r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent)
|
||||
r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent)
|
||||
r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent)
|
||||
r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent)
|
||||
|
||||
r.Get("/app-passwords", authHandler.ListAppPasswords)
|
||||
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
||||
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
|
||||
@@ -542,6 +611,7 @@ func main() {
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
r.Post("/admin/rescan", adminHandler.Rescan)
|
||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||
// Platz, der in keinem Kontingent auftaucht.
|
||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||
@@ -581,6 +651,10 @@ func main() {
|
||||
done := make(chan struct{})
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
|
||||
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
|
||||
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
|
||||
// Sie berührt außerdem keine fremden Daten.
|
||||
go func() {
|
||||
<-quit
|
||||
log.Info().Msg("shutting down...")
|
||||
@@ -646,8 +720,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
|
||||
// Generate thumbnail in background – non-fatal if it fails.
|
||||
go func() {
|
||||
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||||
//
|
||||
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
|
||||
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
|
||||
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
|
||||
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
|
||||
// gewöhnlichen Upload.
|
||||
bg.Go("Vorschaubild", func() {
|
||||
ctx := context.Background()
|
||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
@@ -663,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS cal_objects;
|
||||
DROP TABLE IF EXISTS cal_calendars;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
|
||||
--
|
||||
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
|
||||
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
|
||||
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
|
||||
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_calendars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
|
||||
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
|
||||
color TEXT NOT NULL DEFAULT '#4285f4',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
|
||||
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
|
||||
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
|
||||
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
|
||||
-- einzelne Objekt abzufragen.
|
||||
ctag BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
|
||||
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
|
||||
uid TEXT NOT NULL,
|
||||
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
|
||||
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
|
||||
component TEXT NOT NULL,
|
||||
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
|
||||
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
|
||||
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
|
||||
data TEXT NOT NULL,
|
||||
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
|
||||
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
|
||||
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
|
||||
etag TEXT NOT NULL,
|
||||
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
|
||||
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
|
||||
dtstart TIMESTAMPTZ,
|
||||
dtend TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (calendar_id, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS timezone;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Standard-Zeitzone pro Kalender. Wird von CalDAV selbst nicht gebraucht
|
||||
-- (jeder Termin trägt seine Zeitzone im .ics), sondern erst von der
|
||||
-- künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3, siehe
|
||||
-- kalender-plan.md) – ein neuer Termin braucht eine Vorgabe, wenn der
|
||||
-- Nutzer keine eigene wählt. Jetzt schon angelegt, damit später keine
|
||||
-- weitere Migration für ein einzelnes Feld nötig ist.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus
|
||||
-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer
|
||||
-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen
|
||||
-- und der Client bekäme auf seine eigene Adresse ein 404.
|
||||
--
|
||||
-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle
|
||||
-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar,
|
||||
-- unter der die eingerichteten Geräte sie schon kennen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT;
|
||||
|
||||
-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe
|
||||
-- Adresse haben, aber beliebig viele dürfen gar keine haben.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment
|
||||
ON cal_calendars (owner_id, path_segment)
|
||||
WHERE path_segment IS NOT NULL;
|
||||
@@ -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';
|
||||
+7
-4
@@ -4,6 +4,8 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
@@ -11,8 +13,9 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
@@ -28,7 +31,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
+16
-8
@@ -22,6 +22,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
@@ -87,6 +93,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
@@ -103,21 +111,21 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Kalender ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Kalender liegen in derselben Rechtetabelle wie Ordner und Dateien. Nicht aus
|
||||
// Sparsamkeit, sondern weil zwei Tabellen zwei Antworten auf dieselbe Frage
|
||||
// wären – und die Lücke zwischen ihnen fällt erst auf, wenn jemand sieht, was
|
||||
// er nicht sehen soll. Die Auflösung über Gruppen ist deshalb wörtlich
|
||||
// dieselbe Bedingung wie bei Ordnern.
|
||||
|
||||
// ResolveCalendar klärt, was userID mit diesem Kalender darf.
|
||||
//
|
||||
// Der Eigentümer darf schreiben. Alle anderen nur, wenn es eine Freigabe gibt –
|
||||
// direkt oder über eine Gruppe, in der sie sind. Gibt es keine, ist die
|
||||
// Antwort ErrNoAccess, und der Aufrufer macht daraus ein 404: Ein 403 verriete,
|
||||
// dass es den Kalender gibt.
|
||||
func (s *Store) ResolveCalendar(ctx context.Context, userID, calendarID uuid.UUID) (Permission, error) {
|
||||
var ownerID uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load calendar owner: %w", err)
|
||||
}
|
||||
if ownerID == userID {
|
||||
return PermWrite, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT e.permission
|
||||
FROM acl_entries e
|
||||
WHERE e.resource_calendar_id = $1
|
||||
AND ( e.subject_user_id = $2
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2) )`,
|
||||
calendarID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve calendar permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Mehrere Wege zum selben Kalender sind möglich – persönlich *und* über
|
||||
// eine Gruppe. Dann gilt das weitergehende Recht; alles andere wäre für
|
||||
// den Betroffenen nicht nachvollziehbar.
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
if p == PermWrite {
|
||||
best = PermWrite
|
||||
} else if best == "" {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if best == "" {
|
||||
return "", ErrNoAccess
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// SharedCalendar ist ein fremder Kalender, den jemand sehen darf.
|
||||
type SharedCalendar struct {
|
||||
CalendarID uuid.UUID
|
||||
Permission Permission
|
||||
OwnerName string
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||
// man sich über einen Kalender, den einem nie jemand persönlich gab.
|
||||
ViaGroup string
|
||||
}
|
||||
|
||||
// SharedCalendars liefert alle **fremden** Kalender, die userID sehen darf.
|
||||
// Die eigenen stehen nicht darin; die kennt der Kalenderteil selbst.
|
||||
func (s *Store) SharedCalendars(ctx context.Context, userID uuid.UUID) ([]SharedCalendar, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT c.id, e.permission,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username, ''),
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN cal_calendars c ON c.id = e.resource_calendar_id
|
||||
JOIN users o ON o.id = c.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE c.owner_id <> $1
|
||||
AND ( e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1) )
|
||||
ORDER BY o.username, c.created_at`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Zwei Wege zum selben Kalender ergeben zwei Zeilen – angezeigt wird er
|
||||
// einmal, mit dem weitergehenden Recht.
|
||||
byID := map[uuid.UUID]int{}
|
||||
out := []SharedCalendar{}
|
||||
for rows.Next() {
|
||||
var sc SharedCalendar
|
||||
if err := rows.Scan(&sc.CalendarID, &sc.Permission, &sc.OwnerName, &sc.ViaGroup); err != nil {
|
||||
return nil, fmt.Errorf("scan shared calendar: %w", err)
|
||||
}
|
||||
if i, da := byID[sc.CalendarID]; da {
|
||||
if sc.Permission == PermWrite {
|
||||
out[i].Permission = PermWrite
|
||||
}
|
||||
continue
|
||||
}
|
||||
byID[sc.CalendarID] = len(out)
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -89,6 +89,63 @@ func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID,
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders.
|
||||
// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht
|
||||
// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat,
|
||||
// ist etwas anderes als es zu benutzen.
|
||||
func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid calendar id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
owner, err := h.store.CalendarOwner(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if owner != user.ID {
|
||||
// 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort
|
||||
// nicht erfahren, dass es ihn gibt.
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/acl
|
||||
func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForCalendar(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/acl
|
||||
func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "calendar", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) uuid.UUID {
|
||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||
return u.ID
|
||||
|
||||
@@ -41,17 +41,30 @@ type SharedResource struct {
|
||||
|
||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||
|
||||
// resource benennt, worum es bei einer Freigabe geht: genau eines der drei
|
||||
// Felder ist gesetzt. Als Typ statt als drei Zeigerparameter, seit der
|
||||
// Kalender dazukam – drei nebeneinanderstehende `*uuid.UUID` in einer
|
||||
// Signatur sind eine Einladung, sie zu vertauschen.
|
||||
type resource struct{ folder, file, calendar *uuid.UUID }
|
||||
|
||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||
// ein Nutzer oder eine Gruppe ist.
|
||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
return s.grant(ctx, resource{folder: &folderID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
// GrantCalendar gibt einen Kalender frei – an ein Konto oder an eine Gruppe.
|
||||
// Dass Gruppen ohne Zutun mitfunktionieren, ist der Grund, warum Kalender in
|
||||
// *dieser* Tabelle liegen und nicht in einer eigenen.
|
||||
func (s *Store) GrantCalendar(ctx context.Context, calendarID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, resource{calendar: &calendarID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, res resource, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
if !perm.valid() {
|
||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||
}
|
||||
@@ -71,10 +84,11 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO acl_entries
|
||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
(subject_user_id, subject_group_id,
|
||||
resource_folder_id, resource_file_id, resource_calendar_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, permission, created_at`,
|
||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
||||
subjUser, subjGroup, res.folder, res.file, res.calendar, perm, grantedBy,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
|
||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||
@@ -84,7 +98,7 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
// über den Fehlercode.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
||||
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant access: %w", err)
|
||||
@@ -95,16 +109,17 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
func (s *Store) updateExisting(ctx context.Context, res resource, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE acl_entries SET permission = $1
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND resource_calendar_id IS NOT DISTINCT FROM $4
|
||||
AND subject_user_id IS NOT DISTINCT FROM $5
|
||||
AND subject_group_id IS NOT DISTINCT FROM $6
|
||||
RETURNING id, permission, created_at`,
|
||||
perm, folderID, fileID, subjUser, subjGroup,
|
||||
perm, res.folder, res.file, res.calendar, subjUser, subjGroup,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -126,8 +141,9 @@ func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM acl_entries e
|
||||
WHERE e.id = $1
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM cal_calendars c WHERE c.id = e.resource_calendar_id AND c.owner_id = $2) )`,
|
||||
entryID, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke access: %w", err)
|
||||
@@ -147,6 +163,10 @@ func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, err
|
||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) ListForCalendar(ctx context.Context, calendarID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_calendar_id = $1`, calendarID)
|
||||
}
|
||||
|
||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT e.id,
|
||||
@@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string {
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
// CalendarOwner liefert, wem ein Kalender gehört – die Frage vor jeder
|
||||
// Verwaltung seiner Freigaben.
|
||||
func (s *Store) CalendarOwner(ctx context.Context, calendarID uuid.UUID) (uuid.UUID, error) {
|
||||
var owner uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&owner)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("load calendar owner: %w", err)
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/rescan"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
@@ -118,3 +119,59 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
response.JSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank
|
||||
// abgleichen.
|
||||
//
|
||||
// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag
|
||||
// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer
|
||||
// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in
|
||||
// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten.
|
||||
//
|
||||
// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas.
|
||||
// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was
|
||||
// passieren wird, kann es auch nicht verhindern.
|
||||
func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.rescan == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "rescan not available")
|
||||
return
|
||||
}
|
||||
|
||||
anwenden := r.URL.Query().Get("anwenden") == "true"
|
||||
// Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen
|
||||
// soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint
|
||||
// aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen.
|
||||
bestaetigt := r.URL.Query().Get("bestaetigt") == "true"
|
||||
var (
|
||||
bef *rescan.Report
|
||||
err error
|
||||
)
|
||||
if anwenden {
|
||||
bef, err = h.rescan.Apply(r.Context(), bestaetigt)
|
||||
} else {
|
||||
bef, err = h.rescan.Check(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "rescan failed")
|
||||
return
|
||||
}
|
||||
|
||||
if anwenden {
|
||||
// Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein
|
||||
// ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit
|
||||
// dem Datenordner etwas nicht stimmt.
|
||||
if bef.Aborted {
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
"rescan aborted, nothing removed: "+bef.AbortReason)
|
||||
} else {
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
|
||||
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
|
||||
}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, bef)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
@@ -30,10 +31,11 @@ type Handler struct {
|
||||
auditor *audit.Logger
|
||||
gc *gc.Collector
|
||||
files *files.Store
|
||||
rescan *rescan.Scanner
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner}
|
||||
}
|
||||
|
||||
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
)
|
||||
|
||||
// Wer darf was mit einem Kalender.
|
||||
//
|
||||
// **Alle Wege laufen hier zusammen.** CalDAV und die Weboberfläche sind zwei
|
||||
// völlig verschiedene Zugänge zu denselben Daten – ein Recht, das nur einer
|
||||
// von beiden prüft, ist kein Recht. Deshalb gibt es genau eine Antwort auf
|
||||
// „darf dieses Konto diesen Kalender", und beide Zugänge holen sie hier.
|
||||
|
||||
// Access ist ein Kalender samt dem, was der Anfragende damit darf.
|
||||
type Access struct {
|
||||
Calendar
|
||||
Permission acl.Permission `json:"permission"`
|
||||
// Shared: Der Kalender gehört jemand anderem.
|
||||
Shared bool `json:"shared"`
|
||||
// OwnerName steht in der Oberfläche neben dem Namen – ein fremder
|
||||
// Kalender ohne sichtbaren Absender ist ein Rätsel.
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt.
|
||||
ViaGroup string `json:"via_group,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Access) CanWrite() bool { return a.Permission == acl.PermWrite }
|
||||
|
||||
// Ref ist die Adresse in CalDAV.
|
||||
//
|
||||
// Bei einem **fremden** Kalender immer die UUID, nie das Pfadsegment: Das
|
||||
// Segment hat sich der Eigentümer ausgesucht ("…/arbeit/"), und zwei Leute
|
||||
// dürfen denselben Namen wählen. Unter dem Kalenderbestand des Empfängers
|
||||
// stünden dann zwei Kalender an derselben Adresse – und welcher gemeint ist,
|
||||
// entschiede der Zufall.
|
||||
func (a *Access) Ref() string {
|
||||
if a.Shared {
|
||||
return a.Calendar.ID.String()
|
||||
}
|
||||
return a.Calendar.Ref()
|
||||
}
|
||||
|
||||
// ListAccessible liefert alles, was userID sehen darf: die eigenen Kalender
|
||||
// und die ihm freigegebenen. Eigene zuerst – sie sind die, die man sucht.
|
||||
func (s *Store) ListAccessible(ctx context.Context, userID uuid.UUID) ([]Access, error) {
|
||||
own, err := s.ListCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Access, 0, len(own))
|
||||
for i := range own {
|
||||
out = append(out, Access{Calendar: own[i], Permission: acl.PermWrite})
|
||||
}
|
||||
|
||||
if s.rights == nil {
|
||||
return out, nil
|
||||
}
|
||||
geteilt, err := s.rights.SharedCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||
}
|
||||
for _, sc := range geteilt {
|
||||
cal, err := s.getCalendarAny(ctx, sc.CalendarID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue // zwischenzeitlich gelöscht
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, Access{
|
||||
Calendar: *cal,
|
||||
Permission: sc.Permission,
|
||||
Shared: true,
|
||||
OwnerName: sc.OwnerName,
|
||||
ViaGroup: sc.ViaGroup,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveRef findet den Kalender hinter einer CalDAV-Adresse – eigener oder
|
||||
// freigegebener.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: **erst die eigenen.** Sonst könnte ein fremder
|
||||
// Kalender, dessen UUID zufällig wie ein eigenes Pfadsegment aussieht, den
|
||||
// eigenen verdecken.
|
||||
func (s *Store) ResolveRef(ctx context.Context, userID uuid.UUID, ref string) (*Access, error) {
|
||||
cal, err := s.GetCalendarByRef(ctx, userID, ref)
|
||||
if err == nil {
|
||||
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ein freigegebener Kalender steht unter seiner UUID. Alles andere kann
|
||||
// hier nichts mehr treffen.
|
||||
id, parseErr := uuid.Parse(ref)
|
||||
if parseErr != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.RequireCalendar(ctx, userID, id, false)
|
||||
}
|
||||
|
||||
// RequireCalendar ist die Prüfung, die jeder schreibende und lesende Zugriff
|
||||
// durchläuft – aus CalDAV wie aus der Weboberfläche.
|
||||
//
|
||||
// Ohne Zugriff kommt **ErrNotFound**, nicht „verboten": Ein 403 verriete, dass
|
||||
// es den Kalender gibt. Dieselbe Regel wie überall sonst in Aether.
|
||||
func (s *Store) RequireCalendar(ctx context.Context, userID, calendarID uuid.UUID, needWrite bool) (*Access, error) {
|
||||
cal, err := s.getCalendarAny(ctx, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cal.OwnerID == userID {
|
||||
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||
}
|
||||
if s.rights == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
perm, err := s.rights.ResolveCalendar(ctx, userID, calendarID)
|
||||
if errors.Is(err, acl.ErrNoAccess) || errors.Is(err, acl.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if needWrite && perm != acl.PermWrite {
|
||||
// Hier **darf** es ein 403 sein: Dass es den Kalender gibt, weiß der
|
||||
// Anfragende bereits – er darf ihn ja lesen.
|
||||
return nil, ErrReadOnly
|
||||
}
|
||||
|
||||
a := &Access{Calendar: *cal, Permission: perm, Shared: true}
|
||||
// Name des Eigentümers nur nachschlagen, wenn er auch angezeigt wird.
|
||||
if name, err := s.ownerName(ctx, cal.OwnerID); err == nil {
|
||||
a.OwnerName = name
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ErrReadOnly: Der Kalender ist sichtbar, aber nur lesend freigegeben.
|
||||
var ErrReadOnly = errors.New("calendar: read-only share")
|
||||
|
||||
// getCalendarAny lädt einen Kalender **ohne** Rechteprüfung. Privat, und das
|
||||
// muss so bleiben: Jeder Aufruf von außen ginge an der Prüfung vorbei.
|
||||
func (s *Store) getCalendarAny(ctx context.Context, id uuid.UUID) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone,
|
||||
COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE id = $1`, id,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo,
|
||||
&c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) ownerName(ctx context.Context, ownerID uuid.UUID) (string, error) {
|
||||
var name string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = $1`,
|
||||
ownerID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// APIHandler bedient die angemeldete Web-Oberfläche unter /api/v1/calendars
|
||||
// (Session-Auth, wie jeder andere Bereich). Getrennt vom CalDAV-Handler
|
||||
// (Handler in handler.go), der eigene Basic-Auth mit App-Passwörtern nutzt –
|
||||
// unterschiedliche Auth-Modelle, unterschiedliche Handler, wie /dav vs. der
|
||||
// Rest der API.
|
||||
type APIHandler struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewAPIHandler(store *Store) *APIHandler {
|
||||
return &APIHandler{store: store}
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars
|
||||
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
// Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst
|
||||
// steht ein neues Konto vor einer leeren Ansicht und muss erst erraten,
|
||||
// dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich
|
||||
// vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht
|
||||
// zeigt.
|
||||
if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||
return
|
||||
}
|
||||
// Eigene **und** freigegebene. Jeder Eintrag trägt sein Recht mit, damit
|
||||
// die Oberfläche einen nur lesbaren Kalender gar nicht erst zum
|
||||
// Bearbeiten anbietet.
|
||||
cals, err := h.store.ListAccessible(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, cals)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars
|
||||
func (h *APIHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
cal, err := h.store.CreateCalendar(r.Context(), user.ID, body.Name, body.Color, body.Description)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "create calendar failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, cal)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/calendars/{id}
|
||||
func (h *APIHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Description *string `json:"description"`
|
||||
TimeZone *string `json:"timezone"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
cal, err := h.store.UpdateCalendar(r.Context(), user.ID, id, body.Name, body.Color, body.Description, body.TimeZone)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update calendar failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, cal)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendars/{id}
|
||||
func (h *APIHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteCalendar(r.Context(), user.ID, id); err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "delete calendar failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/export – ein .ics mit allen Terminen/Aufgaben
|
||||
// des Kalenders zusammen, zum Sichern oder Umziehen in einen anderen Dienst.
|
||||
func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
// Ausgeben darf, wer lesen darf – auch bei einem freigegebenen Kalender.
|
||||
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, false)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||
return
|
||||
}
|
||||
cal := &zugriff.Calendar
|
||||
objs, err := h.store.ListObjects(r.Context(), cal.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list objects failed")
|
||||
return
|
||||
}
|
||||
|
||||
out := ical.NewCalendar()
|
||||
out.Props.SetText(ical.PropVersion, "2.0")
|
||||
out.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
|
||||
// VTIMEZONE-Blöcke nur einmal je TZID mitnehmen – jedes einzeln
|
||||
// gespeicherte Objekt trägt seine eigenen mit, sonst stünde dieselbe
|
||||
// Definition mehrfach in der Ausgabedatei.
|
||||
seenTZ := map[string]bool{}
|
||||
for _, o := range objs {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
c, err := dec.Decode()
|
||||
if err != nil {
|
||||
continue // eine kaputte Einzeldatei darf den Export nicht abbrechen
|
||||
}
|
||||
for _, child := range c.Children {
|
||||
if child.Name == ical.CompTimezone {
|
||||
tzid, _ := child.Props.Text(ical.PropTimezoneID)
|
||||
if seenTZ[tzid] {
|
||||
continue
|
||||
}
|
||||
seenTZ[tzid] = true
|
||||
}
|
||||
out.Children = append(out.Children, child)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(out); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "encode calendar failed")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.ics"`, sanitizeFilename(cal.Name)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/import – eine hochgeladene .ics-Datei
|
||||
// einlesen. Jede darin enthaltene VEVENT/VTODO-Komponente wird ein eigenes
|
||||
// Objekt, wie beim einzelnen PUT über CalDAV – nur eben viele auf einmal.
|
||||
func (h *APIHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
// Hineinschreiben darf nur, wer schreiben darf.
|
||||
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, true)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err == ErrReadOnly {
|
||||
response.Error(w, http.StatusForbidden, "calendar is shared read-only")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||
return
|
||||
}
|
||||
|
||||
cal := &zugriff.Calendar
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MiB – reicht für sehr große Kalender
|
||||
src, err := ical.NewDecoder(r.Body).Decode()
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid .ics file")
|
||||
return
|
||||
}
|
||||
|
||||
var tzBlocks []*ical.Component
|
||||
for _, child := range src.Children {
|
||||
if child.Name == ical.CompTimezone {
|
||||
tzBlocks = append(tzBlocks, child)
|
||||
}
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, child := range src.Children {
|
||||
if child.Name != "VEVENT" && child.Name != "VTODO" {
|
||||
continue
|
||||
}
|
||||
uid, err := child.Props.Text("UID")
|
||||
if err != nil || uid == "" {
|
||||
continue // ohne UID kein gültiges CalDAV-Objekt – überspringen statt den ganzen Import abzubrechen
|
||||
}
|
||||
|
||||
single := ical.NewCalendar()
|
||||
single.Props.SetText(ical.PropVersion, "2.0")
|
||||
single.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
single.Children = append(single.Children, tzBlocks...)
|
||||
single.Children = append(single.Children, child)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(single); err != nil {
|
||||
continue
|
||||
}
|
||||
data := buf.String()
|
||||
start, end := timeRange(single)
|
||||
if _, err := h.store.PutObject(r.Context(), cal.ID, uid, child.Name, data, etagFor(data), start, end); err != nil {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]int{"imported": imported})
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '"':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
if name == "" {
|
||||
return "kalender"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Pfadschema. Zwei Eigenheiten der Bibliothek bestimmen es:
|
||||
//
|
||||
// 1. Zurückgegebene Pfade (CurrentUserPrincipal, CalendarHomeSetPath,
|
||||
// Calendar.Path, ...) vergleicht sie direkt mit r.URL.Path – also
|
||||
// inklusive Präfix. Deshalb tragen alle hier gebauten Pfade davPrefix
|
||||
// von Anfang an mit.
|
||||
//
|
||||
// 2. Welche Art Ressource ein Pfad ist, entscheidet sie allein an der
|
||||
// Anzahl der Pfadsegmente hinter dem Präfix (resourceTypeAtPath in
|
||||
// server.go) – nicht am Namen. Ein Segment = Principal, zwei = Calendar-
|
||||
// Home-Set, drei = Kalender, vier = Objekt. Deshalb steckt der
|
||||
// Nutzername beim Principal *in* dem einen Segment ("principals-max"),
|
||||
// nicht als eigenes Unterverzeichnis – sonst läse die Bibliothek ihn als
|
||||
// Calendar-Home-Set und die PROPFIND-Antwort bliebe leer.
|
||||
//
|
||||
// /caldav/principals-{username}/ – 1 Segment
|
||||
// /caldav/calendars/{username}/ – 2 Segmente, Calendar-Home-Set
|
||||
// /caldav/calendars/{username}/{calendar-id}/ – 3 Segmente, ein Kalender
|
||||
// /caldav/calendars/{username}/{calendar-id}/{uid}.ics – 4 Segmente, ein Objekt
|
||||
const davPrefix = "/caldav"
|
||||
|
||||
type contextKey int
|
||||
|
||||
const keyUser contextKey = iota
|
||||
|
||||
func contextWithUser(ctx context.Context, u *auth.User) context.Context {
|
||||
return context.WithValue(ctx, keyUser, u)
|
||||
}
|
||||
|
||||
func userFromContext(ctx context.Context) (*auth.User, error) {
|
||||
u, _ := ctx.Value(keyUser).(*auth.User)
|
||||
if u == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusUnauthorized, errors.New("no authenticated user"))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Backend implementiert caldav.Backend. Phase 1: jeder Nutzer sieht nur die
|
||||
// eigenen Kalender – Freigaben (cal_shares) kommen erst in einer späteren
|
||||
// Phase, siehe kalender-plan.md.
|
||||
type Backend struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewBackend(store *Store) *Backend {
|
||||
return &Backend{store: store}
|
||||
}
|
||||
|
||||
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return davPrefix + "/principals-" + u.Username + "/", nil
|
||||
}
|
||||
|
||||
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return davPrefix + "/calendars/" + u.Username + "/", nil
|
||||
}
|
||||
|
||||
// calendarPath baut die Adresse eines Kalenders. ref ist sein Pfadsegment –
|
||||
// die vom Client gewählte Adresse, sonst die UUID (siehe Calendar.Ref).
|
||||
func calendarPath(username, ref string) string {
|
||||
return davPrefix + "/calendars/" + username + "/" + ref + "/"
|
||||
}
|
||||
|
||||
func objectPath(username, ref, uid string) string {
|
||||
return calendarPath(username, ref) + uid + ".ics"
|
||||
}
|
||||
|
||||
// parseCalendarPath erwartet "/caldav/calendars/{username}/{segment}(/...)"
|
||||
// und liefert das Pfadsegment des Kalenders – nicht dessen UUID: Legt ein
|
||||
// Client per MKCALENDAR einen Kalender an, sucht **er** die Adresse aus
|
||||
// ("…/calendars/max/arbeit/"). Aufgelöst wird sie in resolveCalendar.
|
||||
//
|
||||
// Der Benutzername im Pfad wird hier nicht geprüft; das tut der Handler,
|
||||
// bevor die Anfrage überhaupt hier ankommt (pathBelongsTo).
|
||||
func parseCalendarPath(p string) (ref string, rest string, err error) {
|
||||
// Kommt vor: ein Browser macht GET auf eine Sammlung (Principal,
|
||||
// Home-Set) statt PROPFIND – go-webdav reicht das trotzdem unverändert
|
||||
// an GetCalendarObject durch, siehe backend.go-Kommentar zum Pfadschema.
|
||||
// Ohne diese Prüfung würde z. B. "/caldav/principals-max/" das Wort
|
||||
// "caldav" für eine Kalender-ID halten und einen kryptischen
|
||||
// UUID-Parsing-Fehler nach außen geben.
|
||||
const calPrefix = davPrefix + "/calendars/"
|
||||
if !strings.HasPrefix(p, calPrefix) {
|
||||
return "", "", fmt.Errorf("calendar: not a calendar path: %q", p)
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(p, calPrefix), "/", 3)
|
||||
if len(parts) < 2 || parts[1] == "" {
|
||||
return "", "", fmt.Errorf("calendar: malformed path %q", p)
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
rest = parts[2]
|
||||
}
|
||||
return parts[1], rest, nil
|
||||
}
|
||||
|
||||
// resolveCalendar ist der **eine** Weg von einer Adresse zu einem Kalender –
|
||||
// und damit die Stelle, an der die Rechteprüfung sitzt. Jede CalDAV-Operation
|
||||
// läuft hier durch; eine zweite Auflösung daneben wäre eine Tür ohne Schloss.
|
||||
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Access, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ref, _, err := parseCalendarPath(p)
|
||||
if err != nil {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
cal, err := b.store.ResolveRef(ctx, u.ID, ref)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, cal, nil
|
||||
}
|
||||
|
||||
// resolveWritable: dasselbe, aber der Aufrufer will schreiben. Ein nur lesend
|
||||
// freigegebener Kalender darf sichtbar bleiben und trotzdem ablehnen – 403
|
||||
// verrät hier nichts, was der Anfragende nicht ohnehin sieht.
|
||||
func (b *Backend) resolveWritable(ctx context.Context, p string) (*auth.User, *Access, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !cal.CanWrite() {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusForbidden,
|
||||
errors.New("calendar is shared read-only"))
|
||||
}
|
||||
return u, cal, nil
|
||||
}
|
||||
|
||||
func toCalDAVCalendar(username string, c *Access) caldav.Calendar {
|
||||
comps := []string{"VEVENT"}
|
||||
if c.SupportsTodo {
|
||||
comps = append(comps, "VTODO")
|
||||
}
|
||||
// Ein fremder Kalender trägt den Eigentümer im Namen. Ohne das stehen in
|
||||
// der Liste des Clients zwei „Privat" nebeneinander, und welches welches
|
||||
// ist, weiß niemand.
|
||||
name := c.Name
|
||||
if c.Shared && c.OwnerName != "" {
|
||||
name = c.Name + " (" + c.OwnerName + ")"
|
||||
}
|
||||
return caldav.Calendar{
|
||||
Path: calendarPath(username, c.Ref()),
|
||||
Name: name,
|
||||
Description: c.Description,
|
||||
MaxResourceSize: 5 * 1024 * 1024,
|
||||
SupportedComponentSet: comps,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) error {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := cal.Name
|
||||
if name == "" {
|
||||
name = "Kalender"
|
||||
}
|
||||
// Die Adresse sucht der Client aus (MKCOL/MKCALENDAR). Sie zu ersetzen
|
||||
// wäre der eine Fehler, den man hier machen kann: Der Client legt an
|
||||
// unter "…/arbeit/", fragt gleich darauf dieselbe Adresse ab – und bekäme
|
||||
// ein 404, weil der Kalender in Wahrheit unter seiner UUID läge.
|
||||
ref, rest, err := parseCalendarPath(cal.Path)
|
||||
if err != nil || rest != "" {
|
||||
return webdav.NewHTTPError(http.StatusForbidden,
|
||||
fmt.Errorf("calendar: cannot create a calendar at %q", cal.Path))
|
||||
}
|
||||
created, err := b.store.CreateCalendarAt(ctx, u.ID, ref, name, "", cal.Description)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
// RFC 4918: Anlegen, wo schon etwas liegt, ist 405 – nicht stilles
|
||||
// Überschreiben.
|
||||
return webdav.NewHTTPError(http.StatusMethodNotAllowed,
|
||||
fmt.Errorf("calendar: %q already exists", ref))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("create calendar: %w", err)
|
||||
}
|
||||
cal.Path = calendarPath(u.Username, created.Ref())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := b.store.EnsureDefaultCalendar(ctx, u.ID); err != nil {
|
||||
return nil, fmt.Errorf("ensure default calendar: %w", err)
|
||||
}
|
||||
|
||||
all, err := b.store.ListAccessible(ctx, u.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list calendars: %w", err)
|
||||
}
|
||||
out := make([]caldav.Calendar, len(all))
|
||||
for i := range all {
|
||||
out[i] = toCalDAVCalendar(u.Username, &all[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *Backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := toCalDAVCalendar(u.Username, cal)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func toCalendarObject(username, calRef string, o *Object) (*caldav.CalendarObject, error) {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
data, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode object %s: %w", o.UID, err)
|
||||
}
|
||||
return &caldav.CalendarObject{
|
||||
Path: objectPath(username, calRef, o.UID),
|
||||
ModTime: o.UpdatedAt,
|
||||
ContentLength: int64(len(o.Data)),
|
||||
ETag: o.ETag,
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) GetCalendarObject(ctx context.Context, p string, _ *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, rest, err := parseCalendarPath(p)
|
||||
if err != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
uid := strings.TrimSuffix(rest, ".ics")
|
||||
obj, err := b.store.GetObjectByUID(ctx, cal.ID, uid)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toCalendarObject(u.Username, cal.Ref(), obj)
|
||||
}
|
||||
|
||||
func (b *Backend) ListCalendarObjects(ctx context.Context, p string, _ *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objs, err := b.store.ListObjects(ctx, cal.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list objects: %w", err)
|
||||
}
|
||||
out := make([]caldav.CalendarObject, 0, len(objs))
|
||||
for i := range objs {
|
||||
co, err := toCalendarObject(u.Username, cal.Ref(), &objs[i])
|
||||
if err != nil {
|
||||
// Eine kaputte Einzeldatei darf nicht die gesamte Synchronisation
|
||||
// blockieren; der Client bekommt die übrigen Termine trotzdem.
|
||||
continue
|
||||
}
|
||||
out = append(out, *co)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *Backend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||
all, err := b.ListCalendarObjects(ctx, p, &query.CompRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return caldav.Filter(query, all)
|
||||
}
|
||||
|
||||
// firstUID/firstComponent lesen UID und Komponententyp (VEVENT/VTODO) aus
|
||||
// dem ersten Termin-/Aufgaben-Kind der .ics-Datei. VTIMEZONE-Kinder werden
|
||||
// übersprungen, die tragen keine UID.
|
||||
func firstUIDAndComponent(cal *ical.Calendar) (uid, component string, err error) {
|
||||
for _, child := range cal.Children {
|
||||
if child.Name != "VEVENT" && child.Name != "VTODO" {
|
||||
continue
|
||||
}
|
||||
v, err := child.Props.Text("UID")
|
||||
if err != nil || v == "" {
|
||||
return "", "", fmt.Errorf("calendar object missing UID")
|
||||
}
|
||||
return v, child.Name, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("calendar object has no VEVENT/VTODO component")
|
||||
}
|
||||
|
||||
func timeRange(cal *ical.Calendar) (start, end *time.Time) {
|
||||
for _, ev := range cal.Events() {
|
||||
if t, err := ev.DateTimeStart(time.Local); err == nil {
|
||||
start = &t
|
||||
}
|
||||
if t, err := ev.DateTimeEnd(time.Local); err == nil {
|
||||
end = &t
|
||||
}
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func encodeICal(cal *ical.Calendar) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||||
return "", fmt.Errorf("encode object: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func etagFor(data string) string {
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||
u, calRow, err := b.resolveWritable(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, component, err := firstUIDAndComponent(cal)
|
||||
if err != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
existing, err := b.store.GetObjectByUID(ctx, calRow.ID, uid)
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if opts != nil {
|
||||
if opts.IfNoneMatch.IsSet() && opts.IfNoneMatch.IsWildcard() && existing != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object already exists"))
|
||||
}
|
||||
if opts.IfMatch.IsSet() {
|
||||
if existing == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object not found"))
|
||||
}
|
||||
ok, err := opts.IfMatch.MatchETag(existing.ETag)
|
||||
if err != nil || !ok {
|
||||
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("etag mismatch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag := etagFor(data)
|
||||
start, end := timeRange(cal)
|
||||
|
||||
obj, err := b.store.PutObject(ctx, calRow.ID, uid, component, data, etag, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("put object: %w", err)
|
||||
}
|
||||
return toCalendarObject(u.Username, calRow.Ref(), obj)
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error {
|
||||
_, calRow, err := b.resolveWritable(ctx, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, rest, err := parseCalendarPath(p)
|
||||
if err != nil {
|
||||
return webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
uid := strings.TrimSuffix(rest, ".ics")
|
||||
if err := b.store.DeleteObject(ctx, calRow.ID, uid); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
return fmt.Errorf("delete object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Kalender verwalten – anlegen, umbenennen, umfärben, löschen –, und zwar
|
||||
// vom Client aus.
|
||||
//
|
||||
// go-webdav deckt davon nur MKCOL ab. MKCALENDAR (RFC 4791, das Verb, das
|
||||
// Apple-Clients benutzen) kennt es gar nicht, PROPPATCH beantwortet es mit
|
||||
// 501, und ein DELETE auf einen Kalender reicht es an das Löschen eines
|
||||
// *Termins* weiter – das findet dort natürlich nichts und endet im 404.
|
||||
//
|
||||
// Alle drei landen deshalb hier, bevor die Bibliothek sie zu sehen bekommt.
|
||||
|
||||
// Eigenschaften, die ein Client an einem Kalender ändern darf. Alles andere
|
||||
// wird höflich mit 403 beantwortet, statt es stillschweigend zu schlucken:
|
||||
// Ein Client, der glaubt, seine Änderung sei angekommen, ist schlimmer dran
|
||||
// als einer, der eine klare Absage bekommt.
|
||||
var (
|
||||
propDisplayName = xml.Name{Space: "DAV:", Local: "displayname"}
|
||||
propCalColor = xml.Name{Space: nsApple, Local: "calendar-color"}
|
||||
propCalDesc = xml.Name{Space: "urn:ietf:params:xml:ns:caldav", Local: "calendar-description"}
|
||||
)
|
||||
|
||||
// mkcalendarReq deckt MKCALENDAR und den erweiterten MKCOL ab: Beide tragen
|
||||
// die Anfangswerte in <set><prop>.
|
||||
type mkcalendarReq struct {
|
||||
Sets []struct {
|
||||
Prop struct {
|
||||
DisplayName string `xml:"DAV: displayname"`
|
||||
Color string `xml:"http://apple.com/ns/ical/ calendar-color"`
|
||||
Description string `xml:"urn:ietf:params:xml:ns:caldav calendar-description"`
|
||||
} `xml:"prop"`
|
||||
} `xml:"set"`
|
||||
}
|
||||
|
||||
// handleMkcalendar legt einen Kalender unter der Adresse an, die der Client
|
||||
// gewählt hat.
|
||||
func (h *Handler) handleMkcalendar(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
http.Error(w, "calendars can only be created directly in the calendar home set",
|
||||
http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req mkcalendarReq
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "malformed MKCALENDAR body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
var name, color, desc string
|
||||
for _, set := range req.Sets {
|
||||
if set.Prop.DisplayName != "" {
|
||||
name = set.Prop.DisplayName
|
||||
}
|
||||
if set.Prop.Color != "" {
|
||||
color = set.Prop.Color
|
||||
}
|
||||
if set.Prop.Description != "" {
|
||||
desc = set.Prop.Description
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
// Ohne Namen wäre der Kalender in der Oberfläche eine leere Zeile.
|
||||
// Die gewählte Adresse ist der beste Anhaltspunkt, den es gibt.
|
||||
name = ref
|
||||
}
|
||||
|
||||
created, err := h.store.CreateCalendarAt(r.Context(), user.ID, ref, name, fromAppleColor(color), desc)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
http.Error(w, "a calendar already exists at this address", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not create calendar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Location", calendarPath(user.Username, created.Ref()))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// handleProppatch ändert Name, Farbe und Beschreibung eines Kalenders.
|
||||
//
|
||||
// Die Antwort ist immer ein 207 mit einem Status **je Eigenschaft** – auch
|
||||
// wenn alles geklappt hat. Ein nacktes 200 wäre hier falsch: PROPPATCH kann
|
||||
// teilweise gelingen, und der Client will wissen, welcher Teil.
|
||||
func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||||
gesetzt, abgelehnt, err := parseProppatch(body)
|
||||
if err != nil {
|
||||
http.Error(w, "malformed PROPPATCH body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var name, color, desc *string
|
||||
for n, v := range gesetzt {
|
||||
switch n {
|
||||
case propDisplayName:
|
||||
s := v
|
||||
name = &s
|
||||
case propCalColor:
|
||||
s := fromAppleColor(v)
|
||||
if s == "" {
|
||||
// Unlesbare Farbe: nicht raten, sondern absagen.
|
||||
abgelehnt = append(abgelehnt, n)
|
||||
continue
|
||||
}
|
||||
color = &s
|
||||
case propCalDesc:
|
||||
s := v
|
||||
desc = &s
|
||||
default:
|
||||
abgelehnt = append(abgelehnt, n)
|
||||
}
|
||||
}
|
||||
|
||||
if name != nil || color != nil || desc != nil {
|
||||
if _, err := h.store.UpdateCalendar(r.Context(), user.ID, cal.ID, name, color, desc, nil); err != nil {
|
||||
http.Error(w, "could not update calendar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<multistatus xmlns="DAV:"><response><href>` + xmlText(r.URL.Path) + `</href>`)
|
||||
writePropstat := func(namen []xml.Name, status string) {
|
||||
if len(namen) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("<propstat><prop>")
|
||||
for _, n := range namen {
|
||||
if n.Space == "DAV:" {
|
||||
fmt.Fprintf(&b, "<%s/>", xmlName(n.Local))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space))
|
||||
}
|
||||
}
|
||||
b.WriteString("</prop><status>" + status + "</status></propstat>")
|
||||
}
|
||||
var ok []xml.Name
|
||||
for n := range gesetzt {
|
||||
if !enthalten(abgelehnt, n) {
|
||||
ok = append(ok, n)
|
||||
}
|
||||
}
|
||||
writePropstat(ok, "HTTP/1.1 200 OK")
|
||||
writePropstat(abgelehnt, "HTTP/1.1 403 Forbidden")
|
||||
b.WriteString("</response></multistatus>")
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, b.String())
|
||||
}
|
||||
|
||||
func enthalten(list []xml.Name, n xml.Name) bool {
|
||||
for _, x := range list {
|
||||
if x == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseProppatch liest <set> und <remove> eines propertyupdate.
|
||||
//
|
||||
// Entfernen wird nicht unterstützt: Ein Kalender ohne Namen oder ohne Farbe
|
||||
// hat keine sinnvolle Entsprechung – die Felder haben immer einen Wert.
|
||||
// Solche Eigenschaften landen unter „abgelehnt".
|
||||
func parseProppatch(body []byte) (gesetzt map[xml.Name]string, abgelehnt []xml.Name, err error) {
|
||||
gesetzt = map[xml.Name]string{}
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
|
||||
var modus string // "set" | "remove" | ""
|
||||
inProp := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch {
|
||||
case t.Name.Space == "DAV:" && (t.Name.Local == "set" || t.Name.Local == "remove"):
|
||||
modus = t.Name.Local
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "prop":
|
||||
inProp = true
|
||||
case inProp && modus == "set":
|
||||
var wert string
|
||||
if err := dec.DecodeElement(&wert, &t); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
gesetzt[t.Name] = strings.TrimSpace(wert)
|
||||
case inProp && modus == "remove":
|
||||
abgelehnt = append(abgelehnt, t.Name)
|
||||
if err := dec.Skip(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||||
inProp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return gesetzt, abgelehnt, nil
|
||||
}
|
||||
|
||||
// handleDeleteCalendar löscht einen Kalender **mitsamt allen Terminen** –
|
||||
// das ist in WebDAV die Bedeutung eines DELETE auf eine Sammlung.
|
||||
func (h *Handler) handleDeleteCalendar(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
return false // kein Kalender, sondern ein Termin: weiter zur Bibliothek
|
||||
}
|
||||
cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if err := h.store.DeleteCalendar(r.Context(), user.ID, cal.ID); err != nil {
|
||||
http.Error(w, "could not delete calendar", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return true
|
||||
}
|
||||
|
||||
// fromAppleColor nimmt "#RRGGBB" oder "#RRGGBBAA" entgegen und gibt die
|
||||
// Schreibweise zurück, die intern gilt. Der Alpha-Anteil fällt weg: Ein
|
||||
// halbdurchsichtiger Kalender ist in der Oberfläche nicht darstellbar, und
|
||||
// ihn zu behalten hieße, ihn irgendwo später doch zu verlieren.
|
||||
func fromAppleColor(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 9 && strings.HasPrefix(s, "#") {
|
||||
s = s[:7]
|
||||
}
|
||||
return normalizeHex(s)
|
||||
}
|
||||
|
||||
func xmlText(s string) string {
|
||||
var b bytes.Buffer
|
||||
xml.EscapeText(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Terminfarben und die Synchronisierung.
|
||||
//
|
||||
// RFC 7986 schreibt für COLOR einen **CSS3-Farbnamen** vor ("tomato"), keinen
|
||||
// Hex-Wert. Genau daran hängt, ob die Farbe eines Termins auf dem Telefon
|
||||
// ankommt: DAVx5 liest die Eigenschaft über eine Namenstabelle und wirft
|
||||
// alles weg, was es dort nicht findet – ein "#ea4335" landet also nirgends,
|
||||
// und im Samsung-Kalender bliebe der Termin farblos.
|
||||
//
|
||||
// Deshalb zwei Eigenschaften nebeneinander:
|
||||
//
|
||||
// COLOR:tomato – der Standard, damit fremde Clients es verstehen
|
||||
// X-AETHER-COLOR:#ea4335 – der genaue Wert, damit *hier* die Palette stimmt
|
||||
//
|
||||
// Beim Lesen gewinnt X-AETHER-COLOR, wenn vorhanden; sonst wird der Name in
|
||||
// Hex zurückübersetzt. So geht in beide Richtungen nichts verloren: Ein
|
||||
// Termin, den das Telefon einfärbt, kommt hier mit seiner Farbe an, und
|
||||
// unsere Palette bleibt auf dem Telefon erkennbar – wenn auch auf den
|
||||
// nächstgelegenen benannten Ton gerundet.
|
||||
const (
|
||||
propColor = "COLOR"
|
||||
propColorX = "X-AETHER-COLOR"
|
||||
)
|
||||
|
||||
// css3Colors ist eine Auswahl aus der CSS3-Namensliste – über den Farbkreis
|
||||
// verteilt, dazu die Töne, die Kalender-Clients üblicherweise selbst
|
||||
// vergeben. Nicht die vollständige Liste (147 Namen): Für "nächstgelegener
|
||||
// Ton" bringt jeder weitere Zwischenwert kaum etwas, und die Liste will
|
||||
// gepflegt werden.
|
||||
var css3Colors = map[string]string{
|
||||
"black": "#000000", "dimgray": "#696969", "gray": "#808080",
|
||||
"darkgray": "#a9a9a9", "silver": "#c0c0c0", "white": "#ffffff",
|
||||
"maroon": "#800000", "brown": "#a52a2a", "firebrick": "#b22222",
|
||||
"crimson": "#dc143c", "red": "#ff0000", "tomato": "#ff6347",
|
||||
"coral": "#ff7f50", "orangered": "#ff4500", "darkorange": "#ff8c00",
|
||||
"orange": "#ffa500", "goldenrod": "#daa520", "gold": "#ffd700",
|
||||
"yellow": "#ffff00", "olive": "#808000", "yellowgreen": "#9acd32",
|
||||
"darkolivegreen": "#556b2f", "lawngreen": "#7cfc00", "limegreen": "#32cd32",
|
||||
"green": "#008000", "darkgreen": "#006400", "seagreen": "#2e8b57",
|
||||
"mediumseagreen": "#3cb371", "teal": "#008080", "darkcyan": "#008b8b",
|
||||
"lightseagreen": "#20b2aa", "turquoise": "#40e0d0", "cyan": "#00ffff",
|
||||
"deepskyblue": "#00bfff", "steelblue": "#4682b4", "cornflowerblue": "#6495ed",
|
||||
"royalblue": "#4169e1", "dodgerblue": "#1e90ff", "blue": "#0000ff",
|
||||
"mediumblue": "#0000cd", "navy": "#000080", "slateblue": "#6a5acd",
|
||||
"mediumpurple": "#9370db", "blueviolet": "#8a2be2", "darkviolet": "#9400d3",
|
||||
"purple": "#800080", "orchid": "#da70d6", "magenta": "#ff00ff",
|
||||
"mediumvioletred": "#c71585", "deeppink": "#ff1493", "hotpink": "#ff69b4",
|
||||
"pink": "#ffc0cb", "slategray": "#708090", "lightslategray": "#778899",
|
||||
"tan": "#d2b48c", "peru": "#cd853f", "sienna": "#a0522d",
|
||||
}
|
||||
|
||||
func parseHex(s string) (r, g, b int, ok bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(s), "#"))
|
||||
if len(s) == 3 { // Kurzform #abc
|
||||
s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]})
|
||||
}
|
||||
if len(s) == 8 { // mit Alpha – der Kanal interessiert hier nicht
|
||||
s = s[:6]
|
||||
}
|
||||
if len(s) != 6 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return int(v>>16) & 0xff, int(v>>8) & 0xff, int(v) & 0xff, true
|
||||
}
|
||||
|
||||
// nearestCSS3 findet den nächstgelegenen benannten Ton. Der Abstand wird im
|
||||
// RGB-Würfel gemessen – grob, aber für "welcher Name kommt dem am nächsten"
|
||||
// vollkommen ausreichend, und ohne Farbmetrik-Bibliothek im Gepäck.
|
||||
func nearestCSS3(hex string) string {
|
||||
r, g, b, ok := parseHex(hex)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
best, bestDist := "", 1<<30
|
||||
for name, ref := range css3Colors {
|
||||
rr, rg, rb, _ := parseHex(ref)
|
||||
d := (r-rr)*(r-rr) + (g-rg)*(g-rg) + (b-rb)*(b-rb)
|
||||
// Bei gleichem Abstand der alphabetisch erste Name – sonst wechselt
|
||||
// die Ausgabe zwischen zwei Läufen, weil map-Reihenfolge zufällig ist.
|
||||
if d < bestDist || (d == bestDist && name < best) {
|
||||
best, bestDist = name, d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// css3ToHex übersetzt zurück. Unbekannter Name: leer – der Termin erbt dann
|
||||
// die Kalenderfarbe, was allemal besser ist als eine geratene.
|
||||
func css3ToHex(name string) string {
|
||||
return css3Colors[strings.ToLower(strings.TrimSpace(name))]
|
||||
}
|
||||
|
||||
// normalizeHex bringt eine Farbe auf die Schreibweise "#rrggbb".
|
||||
func normalizeHex(s string) string {
|
||||
r, g, b, ok := parseHex(s)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/teambition/rrule-go"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
|
||||
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
||||
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
||||
const maxOccurrences = 750
|
||||
|
||||
// RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche
|
||||
// anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer
|
||||
// Anzahl oder an einem Datum.
|
||||
//
|
||||
// Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im
|
||||
// August"). Solche Regeln werden gelesen und unverändert behalten, aber
|
||||
// nicht in dieses Formular gezwängt – siehe recurrenceFromRule.
|
||||
type RecurrenceDTO struct {
|
||||
Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY
|
||||
Interval int `json:"interval"` // >= 1
|
||||
Count int `json:"count,omitempty"`
|
||||
Until *time.Time `json:"until,omitempty"`
|
||||
// WEEKLY: an welchen Wochentagen (MO…SU). Leer = Wochentag des Starts.
|
||||
Byday []string `json:"byday,omitempty"`
|
||||
// MONTHLY/YEARLY: "DAY" wiederholt am festen Kalendertag (am 4.),
|
||||
// "WEEKDAY" am n-ten Wochentag (am 1. Freitag). Leer zählt als DAY.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Nur bei Mode=WEEKDAY und nur zum **Anzeigen**: der wievielte (−1 =
|
||||
// letzter) und welcher Wochentag. Beim Schreiben rechnet der Server
|
||||
// beides aus dem Startdatum aus – zwei Quellen für dieselbe Angabe wären
|
||||
// eine zu viel, und sie liefen irgendwann auseinander.
|
||||
Nth int `json:"nth,omitempty"`
|
||||
Weekday string `json:"weekday,omitempty"`
|
||||
}
|
||||
|
||||
var weekdayCodes = []string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
|
||||
|
||||
// weekdayIndex: 0 = Montag. Gos time.Weekday fängt beim Sonntag an, rrule-go
|
||||
// beim Montag – diese eine Zeile ist der Übersetzer dazwischen.
|
||||
func weekdayIndex(t time.Time) int { return (int(t.Weekday()) + 6) % 7 }
|
||||
|
||||
func weekdayCode(t time.Time) string { return weekdayCodes[weekdayIndex(t)] }
|
||||
|
||||
func rruleWeekday(code string) (rrule.Weekday, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||
case "MO":
|
||||
return rrule.MO, true
|
||||
case "TU":
|
||||
return rrule.TU, true
|
||||
case "WE":
|
||||
return rrule.WE, true
|
||||
case "TH":
|
||||
return rrule.TH, true
|
||||
case "FR":
|
||||
return rrule.FR, true
|
||||
case "SA":
|
||||
return rrule.SA, true
|
||||
case "SU":
|
||||
return rrule.SU, true
|
||||
}
|
||||
return rrule.MO, false
|
||||
}
|
||||
|
||||
// nthOfMonth: der wievielte Wochentag seiner Art der Tag im Monat ist.
|
||||
//
|
||||
// Der fünfte wird zu −1 ("letzter"), und das ist kein Schönheitsfehler: Einen
|
||||
// fünften Freitag hat nicht jeder Monat. FREQ=MONTHLY;BYDAY=5FR ließe die
|
||||
// Reihe in etwa der Hälfte aller Monate stillschweigend ausfallen – ein
|
||||
// Fehler, den man erst Wochen später bemerkt.
|
||||
func nthOfMonth(t time.Time) int {
|
||||
if n := (t.Day()-1)/7 + 1; n < 5 {
|
||||
return n
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
|
||||
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
|
||||
// ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt
|
||||
// im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur,
|
||||
// was das Formular auch wirklich kennt.
|
||||
type EventDTO struct {
|
||||
UID string `json:"uid"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
// Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten
|
||||
// belegten** Tages – in beide Richtungen gleich. Die exklusive
|
||||
// DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig
|
||||
// hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag.
|
||||
Start time.Time `json:"start"`
|
||||
End *time.Time `json:"end,omitempty"`
|
||||
AllDay bool `json:"all_day"`
|
||||
// Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur
|
||||
// für diesen einen Termin.
|
||||
Color string `json:"color,omitempty"`
|
||||
// Recurring: es hängt überhaupt eine Wiederholung dran.
|
||||
// Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt.
|
||||
// Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum
|
||||
// Bearbeiten" – dann ist Recurring true und Recurrence nil.
|
||||
Recurring bool `json:"recurring"`
|
||||
Recurrence *RecurrenceDTO `json:"recurrence,omitempty"`
|
||||
// Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen*
|
||||
// Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular
|
||||
// bearbeitet immer die Reihe und braucht daher deren Anfang.
|
||||
SeriesStart *time.Time `json:"series_start,omitempty"`
|
||||
SeriesEnd *time.Time `json:"series_end,omitempty"`
|
||||
ETag string `json:"etag"`
|
||||
}
|
||||
|
||||
func firstEventComponent(cal *ical.Calendar) *ical.Component {
|
||||
for _, child := range cal.Children {
|
||||
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
|
||||
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
|
||||
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
|
||||
func isDateOnly(prop *ical.Prop) bool {
|
||||
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
|
||||
}
|
||||
|
||||
var freqToString = map[rrule.Frequency]string{
|
||||
rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY",
|
||||
rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY",
|
||||
}
|
||||
|
||||
var stringToFreq = map[string]rrule.Frequency{
|
||||
"DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY,
|
||||
"MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY,
|
||||
}
|
||||
|
||||
// recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder
|
||||
// gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig
|
||||
// ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb
|
||||
// verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das
|
||||
// Speichern die verlorenen Teile still weg.
|
||||
func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
|
||||
if opt == nil {
|
||||
return nil
|
||||
}
|
||||
freq, ok := freqToString[opt.Freq]
|
||||
if !ok {
|
||||
return nil // stündlich und feiner bietet die Oberfläche nicht an
|
||||
}
|
||||
// Diese Bestandteile kennt das Formular in keiner Betriebsart.
|
||||
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
||||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
|
||||
len(opt.Byeaster) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||||
|
||||
switch freq {
|
||||
case "DAILY":
|
||||
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
case "WEEKLY":
|
||||
if len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||||
return nil
|
||||
}
|
||||
for _, wd := range opt.Byweekday {
|
||||
if wd.N() != 0 {
|
||||
return nil // "jeder 2. Dienstag" ist keine Wochenregel
|
||||
}
|
||||
dto.Byday = append(dto.Byday, weekdayCodes[wd.Day()])
|
||||
}
|
||||
if len(dto.Byday) == 0 {
|
||||
dto.Byday = []string{weekdayCode(start)}
|
||||
}
|
||||
|
||||
case "MONTHLY", "YEARLY":
|
||||
// Bei jährlich darf der Monat genannt sein – aber nur der des Starts.
|
||||
if freq == "YEARLY" {
|
||||
if len(opt.Bymonth) > 1 || (len(opt.Bymonth) == 1 && opt.Bymonth[0] != int(start.Month())) {
|
||||
return nil
|
||||
}
|
||||
} else if len(opt.Bymonth) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(opt.Byweekday) == 1 && opt.Byweekday[0].N() != 0:
|
||||
// „am n-ten Wochentag". Nur bearbeitbar, wenn die Regel auch
|
||||
// wirklich auf den Starttag zeigt – sonst zeigte das Formular
|
||||
// etwas anderes an, als gespeichert ist.
|
||||
wd := opt.Byweekday[0]
|
||||
if len(opt.Bymonthday) > 0 || wd.Day() != weekdayIndex(start) || wd.N() != nthOfMonth(start) {
|
||||
return nil
|
||||
}
|
||||
dto.Mode, dto.Nth, dto.Weekday = "WEEKDAY", wd.N(), weekdayCode(start)
|
||||
case len(opt.Byweekday) == 0:
|
||||
// „am festen Kalendertag" – ausgeschrieben oder implizit.
|
||||
if len(opt.Bymonthday) > 1 || (len(opt.Bymonthday) == 1 && opt.Bymonthday[0] != start.Day()) {
|
||||
return nil
|
||||
}
|
||||
dto.Mode = "DAY"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if dto.Interval < 1 {
|
||||
dto.Interval = 1
|
||||
}
|
||||
if !opt.Until.IsZero() {
|
||||
u := opt.Until.UTC()
|
||||
dto.Until = &u
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return nil, errNoEventComponent
|
||||
}
|
||||
|
||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||
description, _ := ev.Props.Text(ical.PropDescription)
|
||||
location, _ := ev.Props.Text(ical.PropLocation)
|
||||
|
||||
// Farbe: unser genauer Wert schlägt den benannten Ton, siehe color.go.
|
||||
// Ein Termin, den ein anderer Client eingefärbt hat, bringt nur COLOR
|
||||
// mit – der wird dann übersetzt.
|
||||
color, _ := ev.Props.Text(propColorX)
|
||||
if color == "" {
|
||||
if name, _ := ev.Props.Text(propColor); name != "" {
|
||||
if strings.HasPrefix(strings.TrimSpace(name), "#") {
|
||||
color = normalizeHex(name)
|
||||
} else {
|
||||
color = css3ToHex(name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
color = normalizeHex(color)
|
||||
}
|
||||
|
||||
dto := &EventDTO{
|
||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||
Color: color, ETag: o.ETag,
|
||||
}
|
||||
|
||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||
if startProp != nil {
|
||||
dto.AllDay = isDateOnly(startProp)
|
||||
if t, err := startProp.DateTime(time.UTC); err == nil {
|
||||
dto.Start = t
|
||||
}
|
||||
}
|
||||
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
|
||||
if t, err := endProp.DateTime(time.UTC); err == nil {
|
||||
// Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein
|
||||
// eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt
|
||||
// diese Schnittstelle den letzten *belegten* Tag zurück – sonst
|
||||
// müsste jede Oberfläche dieselbe Rechnung noch einmal machen
|
||||
// und eine davon würde sie vergessen.
|
||||
if dto.AllDay {
|
||||
t = t.AddDate(0, 0, -1)
|
||||
if t.Before(dto.Start) {
|
||||
t = dto.Start
|
||||
}
|
||||
}
|
||||
dto.End = &t
|
||||
}
|
||||
}
|
||||
if dto.AllDay && dto.End == nil {
|
||||
// Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag.
|
||||
end := dto.Start
|
||||
dto.End = &end
|
||||
}
|
||||
|
||||
if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil {
|
||||
dto.Recurring = true
|
||||
dto.Recurrence = recurrenceFromRule(opt, dto.Start)
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
// expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne
|
||||
// Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau
|
||||
// **einen** Eintrag – nämlich den ersten.
|
||||
func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO {
|
||||
if !base.Recurring {
|
||||
// Einzeltermin: nur, wenn er das Fenster überhaupt berührt.
|
||||
end := base.Start
|
||||
if base.End != nil {
|
||||
end = *base.End
|
||||
}
|
||||
if end.Before(from) || base.Start.After(to) {
|
||||
return nil
|
||||
}
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
set, err := ev.RecurrenceSet(time.UTC)
|
||||
if err != nil || set == nil {
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
|
||||
// Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert.
|
||||
var dur time.Duration
|
||||
if base.End != nil {
|
||||
dur = base.End.Sub(base.Start)
|
||||
}
|
||||
seriesStart, seriesEnd := base.Start, base.End
|
||||
|
||||
out := make([]EventDTO, 0, 16)
|
||||
for i, occ := range set.Between(from, to, true) {
|
||||
if i >= maxOccurrences {
|
||||
break
|
||||
}
|
||||
item := *base
|
||||
item.Start = occ.UTC()
|
||||
if seriesEnd != nil {
|
||||
end := item.Start.Add(dur)
|
||||
item.End = &end
|
||||
}
|
||||
item.SeriesStart = &seriesStart
|
||||
item.SeriesEnd = seriesEnd
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
||||
|
||||
type icalError struct{ msg string }
|
||||
|
||||
func (e *icalError) Error() string { return e.msg }
|
||||
|
||||
// parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges
|
||||
// Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem
|
||||
// etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird.
|
||||
func parseWindow(r *http.Request) (from, to time.Time) {
|
||||
now := time.Now().UTC()
|
||||
from = now.AddDate(0, -1, 0)
|
||||
to = now.AddDate(1, 0, 0)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t.UTC()
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t.UTC()
|
||||
}
|
||||
}
|
||||
if to.Before(from) {
|
||||
to = from
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/events?from=…&to=…
|
||||
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
// Lesen genügt: Ein freigegebener Kalender soll sich ansehen lassen.
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, false); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
objs, err := h.store.ListObjects(r.Context(), calID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list events failed")
|
||||
return
|
||||
}
|
||||
from, to := parseWindow(r)
|
||||
|
||||
out := make([]EventDTO, 0, len(objs))
|
||||
for i := range objs {
|
||||
base, err := eventFromObject(&objs[i])
|
||||
if err != nil {
|
||||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||||
}
|
||||
out = append(out, expand(&objs[i], base, from, to)...)
|
||||
}
|
||||
response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type eventBody struct {
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
Start time.Time `json:"start"`
|
||||
End *time.Time `json:"end"`
|
||||
AllDay bool `json:"all_day"`
|
||||
Color string `json:"color"`
|
||||
Recurrence *RecurrenceDTO `json:"recurrence"`
|
||||
}
|
||||
|
||||
func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||
root := ical.NewCalendar()
|
||||
root.Props.SetText(ical.PropVersion, "2.0")
|
||||
root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
|
||||
ev := ical.NewEvent()
|
||||
ev.Props.SetText(ical.PropUID, uid)
|
||||
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||||
if body.Summary != "" {
|
||||
ev.Props.SetText(ical.PropSummary, body.Summary)
|
||||
}
|
||||
if body.Description != "" {
|
||||
ev.Props.SetText(ical.PropDescription, body.Description)
|
||||
}
|
||||
if body.Location != "" {
|
||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||
}
|
||||
if hex := normalizeHex(body.Color); hex != "" {
|
||||
// Beides schreiben: den benannten Ton für fremde Clients (DAVx5
|
||||
// überträgt daraus die Terminfarbe ins Telefon), den genauen Wert
|
||||
// für die eigene Palette. Siehe color.go.
|
||||
if name := nearestCSS3(hex); name != "" {
|
||||
ev.Props.SetText(propColor, name)
|
||||
}
|
||||
// Ohne SetText geschrieben: Das hängte an die unbekannte
|
||||
// X-Eigenschaft ein überflüssiges VALUE=TEXT an.
|
||||
p := ical.NewProp(propColorX)
|
||||
p.Value = hex
|
||||
ev.Props.Set(p)
|
||||
}
|
||||
|
||||
if body.AllDay {
|
||||
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
|
||||
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
|
||||
// Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine
|
||||
// Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der
|
||||
// 3. wurde zum 2.).
|
||||
start := body.Start.UTC()
|
||||
ev.Props.SetDate(ical.PropDateTimeStart, start)
|
||||
// DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten
|
||||
// belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen
|
||||
// (siehe eventFromObject), also hier +1. Ohne das verletzt ein
|
||||
// eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und
|
||||
// Clients zeigen ihn unterschiedlich an.
|
||||
last := start
|
||||
if body.End != nil {
|
||||
last = body.End.UTC()
|
||||
if last.Before(start) {
|
||||
last = start
|
||||
}
|
||||
}
|
||||
ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1))
|
||||
} else {
|
||||
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
|
||||
if body.End != nil {
|
||||
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
if rule := ruleFromRecurrence(body); rule != nil {
|
||||
ev.Props.SetRecurrenceRule(rule)
|
||||
}
|
||||
|
||||
root.Children = append(root.Children, ev.Component)
|
||||
return root
|
||||
}
|
||||
|
||||
func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
||||
rec := body.Recurrence
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
freq, ok := stringToFreq[strings.ToUpper(rec.Freq)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
interval := rec.Interval
|
||||
if interval < 1 {
|
||||
interval = 1
|
||||
}
|
||||
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
|
||||
|
||||
// Der Start ist die Bezugsgröße für alle BY-Angaben: „am 4." und „am 1.
|
||||
// Freitag" leiten sich beide aus ihm ab, statt getrennt mitgeschickt zu
|
||||
// werden. So kann die Regel gar nicht erst auf einen anderen Tag zeigen
|
||||
// als der Termin selbst.
|
||||
start := body.Start.UTC()
|
||||
switch strings.ToUpper(rec.Freq) {
|
||||
case "WEEKLY":
|
||||
codes := rec.Byday
|
||||
if len(codes) == 0 {
|
||||
codes = []string{weekdayCode(start)}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range codes {
|
||||
wd, ok := rruleWeekday(c)
|
||||
if !ok || seen[strings.ToUpper(c)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToUpper(c)] = true
|
||||
opt.Byweekday = append(opt.Byweekday, wd)
|
||||
}
|
||||
|
||||
case "MONTHLY", "YEARLY":
|
||||
if strings.EqualFold(rec.Mode, "WEEKDAY") {
|
||||
wd, _ := rruleWeekday(weekdayCode(start))
|
||||
opt.Byweekday = []rrule.Weekday{wd.Nth(nthOfMonth(start))}
|
||||
} else {
|
||||
opt.Bymonthday = []int{start.Day()}
|
||||
}
|
||||
if strings.EqualFold(rec.Freq, "YEARLY") {
|
||||
opt.Bymonth = []int{int(start.Month())}
|
||||
}
|
||||
}
|
||||
|
||||
if rec.Until != nil {
|
||||
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
||||
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
||||
// Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen
|
||||
// später bemerkt.
|
||||
u := rec.Until.UTC()
|
||||
opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC)
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
// calendarOf steht vor **jedem schreibenden** Zugriff der Weboberfläche –
|
||||
// Anlegen, Ändern, Umhängen, Löschen. Deshalb verlangt es das Schreibrecht:
|
||||
// Ein nur lesend freigegebener Kalender darf sich ansehen lassen und trotzdem
|
||||
// nichts annehmen.
|
||||
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, true); err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
return calID, true
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/events
|
||||
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
|
||||
uid := uuid.New().String()
|
||||
dto, err := h.writeEvent(r, calID, uid, body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "create event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, dto)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/calendars/{id}/events/{uid}
|
||||
//
|
||||
// Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne
|
||||
// Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine
|
||||
// eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne
|
||||
// die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen.
|
||||
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
uid := chi.URLParam(r, "uid")
|
||||
existing, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load event failed")
|
||||
return
|
||||
}
|
||||
if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil {
|
||||
// Wiederholung, die das Formular nicht vollständig abbildet (etwa
|
||||
// „jeden zweiten Dienstag"). Speichern würde die Regel auf das
|
||||
// reduzieren, was das Formular kennt – also lieber ablehnen.
|
||||
response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client")
|
||||
return
|
||||
}
|
||||
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
dto, err := h.writeEvent(r, calID, uid, body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/events/{uid}/move {"calendar_id": "…"}
|
||||
//
|
||||
// Umhängen in einen anderen Kalender. Bewusst ein eigener Endpunkt und nicht
|
||||
// ein weiteres Feld im PATCH: Ein Umzug lässt sich auch **ohne** jede
|
||||
// Änderung am Termin auslösen (Ziehen mit der Maus, langes Drücken am Handy),
|
||||
// und dafür den ganzen Termin mitschicken zu müssen wäre eine Einladung,
|
||||
// nebenbei etwas zu überschreiben.
|
||||
//
|
||||
// Bei einer Reihe zieht die ganze Reihe um – sie ist eine einzige Datei, kein
|
||||
// Bündel von Einzelterminen.
|
||||
func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
fromCal, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CalendarID string `json:"calendar_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||||
return
|
||||
}
|
||||
toCal, err := uuid.Parse(strings.TrimSpace(body.CalendarID))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||||
return
|
||||
}
|
||||
// In den Zielkalender muss geschrieben werden dürfen. Ohne diese Prüfung
|
||||
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
|
||||
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, toCal, true); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := h.store.MoveObject(r.Context(), fromCal, toCal, chi.URLParam(r, "uid"))
|
||||
switch {
|
||||
case err == ErrNotFound:
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
case err == ErrConflict:
|
||||
response.Error(w, http.StatusConflict, "an event with this uid already exists in the target calendar")
|
||||
return
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "move event failed")
|
||||
return
|
||||
}
|
||||
|
||||
dto, err := eventFromObject(obj)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "move event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
var errNotRecurring = &icalError{"calendar: event has no recurrence"}
|
||||
|
||||
// excludeOccurrence streicht einen einzelnen Termin aus einer Reihe, indem
|
||||
// er als EXDATE vermerkt wird – die Art, wie das iCalendar-Format eine
|
||||
// Ausnahme ausdrückt. Die Reihe selbst bleibt mit ihrer Regel bestehen, und
|
||||
// jeder CalDAV-Client versteht das ohne Zutun.
|
||||
//
|
||||
// Bewusst **nicht** so gelöst, dass die Reihe in zwei Reihen zerlegt wird:
|
||||
// Das änderte die UID, und alle Geräte hielten den Rest für einen neuen
|
||||
// Termin.
|
||||
func (h *APIHandler) excludeOccurrence(r *http.Request, calID uuid.UUID, uid string, at time.Time) error {
|
||||
obj, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cal, err := ical.NewDecoder(strings.NewReader(obj.Data)).Decode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return errNoEventComponent
|
||||
}
|
||||
if rule, err := ev.Props.RecurrenceRule(); err != nil || rule == nil {
|
||||
return errNotRecurring
|
||||
}
|
||||
|
||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||
allDay := startProp != nil && isDateOnly(startProp)
|
||||
|
||||
// Schon ausgetragen? Dann nichts tun – ein zweites EXDATE auf denselben
|
||||
// Zeitpunkt wäre harmlos, aber die Datei liefe mit der Zeit voll.
|
||||
for _, ex := range ev.Props[ical.PropExceptionDates] {
|
||||
if t, err := ex.DateTime(time.UTC); err == nil && t.Equal(at) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ex := ical.NewProp(ical.PropExceptionDates)
|
||||
if allDay {
|
||||
ex.SetDate(at)
|
||||
} else {
|
||||
ex.SetDateTime(at)
|
||||
}
|
||||
ev.Props.Add(ex)
|
||||
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start, end := timeRange(cal)
|
||||
_, err = h.store.PutObject(r.Context(), calID, uid, obj.Component, data, etagFor(data), start, end)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) {
|
||||
cal := buildEventICal(uid, body)
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end := timeRange(cal)
|
||||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return eventFromObject(obj)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendars/{id}/events/{uid}[?occurrence=<RFC3339>]
|
||||
//
|
||||
// Ohne occurrence verschwindet der Termin ganz – bei einer Reihe also die
|
||||
// **ganze** Reihe. Mit occurrence fällt nur dieser eine Termin aus; die Reihe
|
||||
// bleibt bestehen und behält ihre Regel.
|
||||
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
uid := chi.URLParam(r, "uid")
|
||||
|
||||
if occ := r.URL.Query().Get("occurrence"); occ != "" {
|
||||
at, err := time.Parse(time.RFC3339, occ)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid occurrence")
|
||||
return
|
||||
}
|
||||
switch err := h.excludeOccurrence(r, calID, uid, at.UTC()); err {
|
||||
case nil:
|
||||
response.NoContent(w)
|
||||
case ErrNotFound:
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
case errNotRecurring:
|
||||
// Kein Serientermin: Dann ist „nur dieser" dasselbe wie „ganz
|
||||
// weg" – also einfach löschen, statt den Aufrufer mit einer
|
||||
// Unterscheidung zu behelligen, die es hier nicht gibt.
|
||||
if err := h.store.DeleteObject(r.Context(), calID, uid); err != nil && err != ErrNotFound {
|
||||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "delete occurrence failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/middleware"
|
||||
)
|
||||
|
||||
// Brute-force-Schutz für /caldav/, aus denselben Gründen wie beim
|
||||
// WebDAV-Endpunkt: Basic Auth läuft am normalen Login-Rate-Limit vorbei.
|
||||
const (
|
||||
davMaxFailures = 10
|
||||
davWindow = 15 * time.Minute
|
||||
davLockout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// Handler serves CalDAV at /caldav/ with HTTP Basic Auth (App-Passwörter).
|
||||
type Handler struct {
|
||||
authStore *auth.Store
|
||||
store *Store
|
||||
dav *caldav.Handler
|
||||
limiter *middleware.FailureLimiter
|
||||
}
|
||||
|
||||
func NewHandler(authStore *auth.Store, store *Store) *Handler {
|
||||
return &Handler{
|
||||
authStore: authStore,
|
||||
store: store,
|
||||
dav: &caldav.Handler{Backend: NewBackend(store), Prefix: "/caldav"},
|
||||
limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := middleware.RealIP(r)
|
||||
if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
|
||||
http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username == "" || password == "" {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Nur App-Passwörter, aus demselben Grund wie bei /dav/: das
|
||||
// Kontopasswort öffnet auch den Admin-Bereich.
|
||||
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
|
||||
if err != nil {
|
||||
h.limiter.Fail(clientIP)
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(clientIP)
|
||||
|
||||
// Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit
|
||||
// GET statt PROPFIND, oder ein Mensch öffnet die Adresse zum Testen im
|
||||
// Browser. Ohne diese Abkürzung würde das an GetCalendarObject
|
||||
// durchgereicht, das für Sammlungen (Principal, Home-Set, Kalender) mit
|
||||
// 404 antwortet – ein Client, der so den Dienst prüft, bräche die
|
||||
// Einrichtung ab, bevor er je ein PROPFIND schickt.
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !strings.HasSuffix(r.URL.Path, ".ics") {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodGet {
|
||||
io.WriteString(w, "Dies ist eine CalDAV-Sammlung. Mit einem CalDAV-Client verbinden (PROPFIND), nicht mit dem Browser.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ein Pfad, der auf einen *anderen* Benutzer zeigt, ist ein Fehler und
|
||||
// muss auch so aussehen. Ohne diese Prüfung antwortet go-webdav mit
|
||||
// einem leeren 207: Der Client sieht dann einen Kalenderbestand ohne
|
||||
// Kalender und meldet „nichts gefunden", statt „falsche Adresse" – die
|
||||
// stille Variante desselben Fehlers, die bei der Einrichtung Stunden
|
||||
// kostet.
|
||||
if !pathBelongsTo(r.URL.Path, user.Username) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
r = r.WithContext(contextWithUser(r.Context(), user))
|
||||
|
||||
// Drei Wege gehen an der Bibliothek vorbei, weil sie sie nicht oder
|
||||
// falsch bedient – siehe propfind.go und collections.go.
|
||||
switch r.Method {
|
||||
case "PROPFIND":
|
||||
h.servePropfind(w, r, user)
|
||||
return
|
||||
case "MKCALENDAR":
|
||||
h.handleMkcalendar(w, r, user)
|
||||
return
|
||||
case "PROPPATCH":
|
||||
h.handleProppatch(w, r, user)
|
||||
return
|
||||
case "REPORT":
|
||||
// Nur sync-collection gehört uns; calendar-query und
|
||||
// calendar-multiget beantwortet die Bibliothek weiterhin selbst.
|
||||
if h.handleReport(w, r, user) {
|
||||
return
|
||||
}
|
||||
case http.MethodDelete:
|
||||
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
|
||||
// die Bibliothek weiterhin selbst.
|
||||
if h.handleDeleteCalendar(w, r, user) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.dav.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem
|
||||
// Clients wie DAVx5 die automatische Einrichtung starten (RFC 6764). Er
|
||||
// liegt außerhalb von /caldav/, deshalb braucht es diese eigene Route statt
|
||||
// caldav.Handler's eingebauter Behandlung, die nur den exakten Pfad
|
||||
// "/.well-known/caldav" innerhalb des eigenen Präfix kennt.
|
||||
func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username == "" || password == "" {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
|
||||
if err != nil {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
// pathBelongsTo erkennt Pfade, die auf einen anderen Benutzer zeigen.
|
||||
// Bewusst nur ablehnen, was sicher fremd ist: Alles Unbekannte geht weiter
|
||||
// an go-webdav, damit diese Prüfung keine Pfade abschneidet, die sie gar
|
||||
// nicht kennt.
|
||||
func pathBelongsTo(p, username string) bool {
|
||||
rest := strings.Trim(strings.TrimPrefix(p, davPrefix), "/")
|
||||
if rest == "" {
|
||||
return true
|
||||
}
|
||||
seg := strings.Split(rest, "/")
|
||||
switch {
|
||||
case strings.HasPrefix(seg[0], "principals-"):
|
||||
return strings.EqualFold(strings.TrimPrefix(seg[0], "principals-"), username)
|
||||
case seg[0] == "calendars" && len(seg) >= 2:
|
||||
return strings.EqualFold(seg[1], username)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func challenge(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`)
|
||||
http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package calendar ist ein eigenständiges Modul, kein Kernbestandteil:
|
||||
// CalDAV für Termine und Aufgaben. Es importiert nichts aus files, sharing
|
||||
// oder admin – nur auth und acl, wie jeder andere Bereich auch. Ist es
|
||||
// abgeschaltet, gibt es keine Route und keinen Menüpunkt; die Tabellen
|
||||
// bleiben dann einfach ungenutzt liegen. Siehe kalender-plan.md.
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Calendar ist eine Sammlung von Terminen/Aufgaben – das, was ein Client als
|
||||
// einen Kalender in seiner Liste zeigt.
|
||||
type Calendar struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
SupportsTodo bool `json:"supports_todo"`
|
||||
// TimeZone ist die Vorgabe für neue Termine, die die Web-Oberfläche
|
||||
// künftig hier anlegt (Phase 3). CalDAV selbst braucht sie nicht –
|
||||
// jeder Termin trägt seine Zeitzone bereits im .ics.
|
||||
TimeZone string `json:"timezone"`
|
||||
// PathSegment ist die Adresse, unter der ein Client diesen Kalender
|
||||
// angelegt hat ("…/calendars/max/arbeit/"). Leer heißt: keine gewählte
|
||||
// Adresse, es gilt die UUID. Siehe Ref.
|
||||
PathSegment string `json:"-"`
|
||||
CTag int64 `json:"ctag"`
|
||||
// SyncFloor: Bis zu diesem Stand sind Grabsteine weggeräumt. Ein Token
|
||||
// darunter lässt sich nicht mehr vollständig beantworten – der Client
|
||||
// bekommt dann eine Absage und gleicht vollständig ab. Siehe
|
||||
// Store.ChangesSince.
|
||||
SyncFloor int64 `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Ref ist das Pfadsegment, unter dem dieser Kalender in CalDAV erreichbar
|
||||
// ist: die vom Client gewählte Adresse, sonst die UUID.
|
||||
func (c *Calendar) Ref() string {
|
||||
if c.PathSegment != "" {
|
||||
return c.PathSegment
|
||||
}
|
||||
return c.ID.String()
|
||||
}
|
||||
|
||||
// Object ist ein einzelner Termin oder eine Aufgabe. Data ist die
|
||||
// vollständige .ics-Datei, unverändert wie vom Client geschickt – siehe
|
||||
// kalender-plan.md, Abschnitt "Felder".
|
||||
type Object struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CalendarID uuid.UUID `json:"calendar_id"`
|
||||
UID string `json:"uid"`
|
||||
Component string `json:"component"` // "VEVENT" | "VTODO"
|
||||
Data string `json:"-"`
|
||||
ETag string `json:"etag"`
|
||||
DTStart *time.Time `json:"dtstart,omitempty"`
|
||||
DTEnd *time.Time `json:"dtend,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Zwei Eigenschaften, die jeder verbreitete CalDAV-Client abfragt und die
|
||||
// go-webdav v0.7.0 nicht kennt:
|
||||
//
|
||||
// - getctag (Calendarserver): die billige Frage „hat sich in diesem
|
||||
// Kalender etwas geändert?". Ohne sie lädt ein Client bei jedem Abgleich
|
||||
// die Liste aller Termine herunter, um sie zu vergleichen.
|
||||
// - calendar-color (Apple): die Farbe des Kalenders. Ohne sie zeigt der
|
||||
// Client ihn in seiner eigenen Vorgabefarbe, und die Farbgebung im
|
||||
// Telefon hat mit der in der Cloud nichts mehr zu tun.
|
||||
//
|
||||
// Die Bibliothek beantwortet in PROPFIND einen festen Satz Eigenschaften und
|
||||
// bietet keinen Haken, eigene dazuzulegen (server.go, propFindCalendar).
|
||||
// Deshalb dieser Umweg: Wir nehmen die beiden aus der Anfrage heraus, bevor
|
||||
// go-webdav sie zu sehen bekommt, und hängen sie der Antwort wieder an.
|
||||
//
|
||||
// Warum herausnehmen und nicht einfach anhängen? Weil go-webdav sie sonst
|
||||
// als „nicht gefunden" meldet und dieselbe Eigenschaft in derselben Antwort
|
||||
// zweimal stünde – einmal mit 404, einmal mit 200. Was ein Client daraus
|
||||
// macht, ist Glückssache.
|
||||
const (
|
||||
nsCalendarServer = "http://calendarserver.org/ns/"
|
||||
nsApple = "http://apple.com/ns/ical/"
|
||||
)
|
||||
|
||||
// Ein PROPFIND-Rumpf ist eine Liste leerer Elemente. Alles darüber ist kein
|
||||
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
|
||||
const maxPropfindBody = 1 << 20
|
||||
|
||||
// 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 || w.syncToken || w.reports }
|
||||
|
||||
// alleExtras: Antwort auf <allprop> und auf einen leeren Rumpf.
|
||||
func alleExtras() wantedExtras {
|
||||
return wantedExtras{ctag: true, color: true, syncToken: true, reports: true}
|
||||
}
|
||||
|
||||
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
|
||||
// und baut den Rumpf ohne sie neu auf.
|
||||
//
|
||||
// Der Neuaufbau ist verlustfrei, weil in einem PROPFIND ausschließlich leere
|
||||
// Elemente stehen: Name und Namensraum sind alles, was eine Eigenschaft dort
|
||||
// ausmacht.
|
||||
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
||||
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return alleExtras(), body
|
||||
}
|
||||
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
var (
|
||||
inProp bool
|
||||
depth int
|
||||
keep []xml.Name
|
||||
)
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
|
||||
return alleExtras(), body
|
||||
}
|
||||
if !inProp {
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||||
inProp, depth = true, 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
depth++
|
||||
if depth > 1 {
|
||||
continue // verschachtelte Inhalte gibt es in PROPFIND nicht
|
||||
}
|
||||
switch {
|
||||
case t.Name.Space == nsCalendarServer && t.Name.Local == "getctag":
|
||||
want.ctag = true
|
||||
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
|
||||
want.color = true
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "sync-token":
|
||||
want.syncToken = true
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "supported-report-set":
|
||||
want.reports = true
|
||||
default:
|
||||
keep = append(keep, t.Name)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if inProp {
|
||||
if depth == 0 {
|
||||
inProp = false
|
||||
} else {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !want.any() {
|
||||
return want, body // nichts zu tun – Rumpf unverändert weiterreichen
|
||||
}
|
||||
return want, buildPropfind(keep)
|
||||
}
|
||||
|
||||
func buildPropfind(props []xml.Name) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop>`)
|
||||
for _, n := range props {
|
||||
if n.Space == "DAV:" {
|
||||
fmt.Fprintf(&b, "<%s/>", xmlName(n.Local))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space))
|
||||
}
|
||||
}
|
||||
// Fragt ein Client *nur* nach unseren beiden Eigenschaften, bliebe hier
|
||||
// ein leeres <prop> stehen – damit weist go-webdav die Anfrage ab.
|
||||
// resourcetype kennt jede Ressource und stört keinen Client.
|
||||
if len(props) == 0 {
|
||||
b.WriteString("<resourcetype/>")
|
||||
}
|
||||
b.WriteString("</prop></propfind>")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// Elementnamen und Namensräume stammen aus fremdem XML und wandern hier
|
||||
// zurück in XML. Alles, was dort etwas anderes bedeuten könnte, fliegt raus –
|
||||
// gültige Namen und URIs enthalten davon ohnehin nichts.
|
||||
func xmlName(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.':
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, s)
|
||||
}
|
||||
|
||||
func xmlAttr(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "\"", """, "<", "<", ">", ">")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// appleColor bringt eine Farbe in die Schreibweise, die Apple-Clients und
|
||||
// DAVx5 erwarten: #RRGGBBAA. Ohne den Alpha-Anteil zeigen manche Clients
|
||||
// die Farbe gar nicht an.
|
||||
func appleColor(hex string) string {
|
||||
h := normalizeHex(hex)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(h) + "FF"
|
||||
}
|
||||
|
||||
// Die Berichte, die dieser Server beantwortet. Ohne diese Liste probiert ein
|
||||
// Client sync-collection gar nicht erst aus – DAVx5 sieht hier nach, bevor es
|
||||
// entscheidet, ob es die volle Terminliste holt oder nur die Änderungen.
|
||||
const supportedReports = `<supported-report-set xmlns="DAV:">` +
|
||||
`<supported-report><report><sync-collection/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-query xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-multiget xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`</supported-report-set>`
|
||||
|
||||
func extrasPropstat(cal *Calendar, want wantedExtras) string {
|
||||
var b strings.Builder
|
||||
if want.ctag {
|
||||
fmt.Fprintf(&b, `<getctag xmlns="%s">%d</getctag>`, nsCalendarServer, cal.CTag)
|
||||
}
|
||||
if want.color {
|
||||
if c := appleColor(cal.Color); 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 {
|
||||
return ""
|
||||
}
|
||||
// Mehrere propstat je Antwort sind der Normalfall in WebDAV – so trennt
|
||||
// go-webdav ohnehin schon gefundene von nicht gefundenen Eigenschaften.
|
||||
return `<propstat xmlns="DAV:"><prop>` + b.String() +
|
||||
`</prop><status>HTTP/1.1 200 OK</status></propstat>`
|
||||
}
|
||||
|
||||
// injectExtras hängt jeder Antwort, die einen Kalender beschreibt, unsere
|
||||
// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine
|
||||
// bleiben unangetastet.
|
||||
func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte {
|
||||
// **Accessible, nicht nur eigene**: Ein freigegebener Kalender steht mit
|
||||
// in der Liste und braucht dieselben Eigenschaften – ohne ctag und
|
||||
// sync-token hielte ihn der Client für unveränderlich.
|
||||
cals, err := h.store.ListAccessible(ctx, user.ID)
|
||||
if err != nil {
|
||||
return body // im Zweifel die Antwort der Bibliothek, unverändert
|
||||
}
|
||||
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
|
||||
// erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat.
|
||||
// Bei fremden Kalendern ist es immer die UUID – siehe Access.Ref.
|
||||
byRef := make(map[string]*Calendar, len(cals))
|
||||
for i := range cals {
|
||||
byRef[cals[i].Ref()] = &cals[i].Calendar
|
||||
}
|
||||
|
||||
const closeTag = "</response>"
|
||||
parts := strings.Split(string(body), closeTag)
|
||||
var out strings.Builder
|
||||
for i, part := range parts {
|
||||
out.WriteString(part)
|
||||
if i == len(parts)-1 {
|
||||
break // hinter dem letzten </response> steht nur noch </multistatus>
|
||||
}
|
||||
if cal := calendarOfResponse(part, byRef); cal != nil {
|
||||
out.WriteString(extrasPropstat(cal, want))
|
||||
}
|
||||
out.WriteString(closeTag)
|
||||
}
|
||||
return []byte(out.String())
|
||||
}
|
||||
|
||||
// calendarOfResponse findet den Kalender, den ein <response>-Abschnitt
|
||||
// beschreibt – oder nil, wenn er etwas anderes beschreibt.
|
||||
func calendarOfResponse(part string, byRef map[string]*Calendar) *Calendar {
|
||||
start := strings.Index(part, "<href")
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
open := strings.Index(part[start:], ">")
|
||||
if open < 0 {
|
||||
return nil
|
||||
}
|
||||
rest := part[start+open+1:]
|
||||
end := strings.Index(rest, "</href>")
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
href := rest[:end]
|
||||
if p, err := url.PathUnescape(href); err == nil {
|
||||
href = p
|
||||
}
|
||||
ref, objekt, err := parseCalendarPath(href)
|
||||
if err != nil || objekt != "" {
|
||||
return nil // Home-Set, Principal oder ein einzelner Termin
|
||||
}
|
||||
return byRef[ref]
|
||||
}
|
||||
|
||||
// recorder hält die Antwort der Bibliothek fest, bis feststeht, ob noch
|
||||
// etwas hineingehört. Nötig, weil die Länge sich ändert und Content-Length
|
||||
// erst danach stimmt.
|
||||
type recorder struct {
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
}
|
||||
|
||||
func (rec *recorder) Header() http.Header { return rec.header }
|
||||
|
||||
func (rec *recorder) WriteHeader(code int) {
|
||||
if rec.status == 0 {
|
||||
rec.status = code
|
||||
}
|
||||
}
|
||||
|
||||
func (rec *recorder) Write(p []byte) (int, error) {
|
||||
if rec.status == 0 {
|
||||
rec.status = http.StatusOK
|
||||
}
|
||||
return rec.body.Write(p)
|
||||
}
|
||||
|
||||
// servePropfind reicht die Anfrage an go-webdav weiter und ergänzt die
|
||||
// Antwort um die Eigenschaften, die die Bibliothek nicht kennt.
|
||||
func (h *Handler) servePropfind(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||||
if err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
want, rewritten := parsePropfind(body)
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewReader(rewritten))
|
||||
r.ContentLength = int64(len(rewritten))
|
||||
if !want.any() {
|
||||
h.dav.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rec := &recorder{header: http.Header{}}
|
||||
h.dav.ServeHTTP(rec, r)
|
||||
|
||||
out := rec.body.Bytes()
|
||||
if rec.status == http.StatusMultiStatus {
|
||||
out = h.injectExtras(r.Context(), user, out, want)
|
||||
}
|
||||
|
||||
for k, v := range rec.header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||
w.WriteHeader(rec.status)
|
||||
if _, err := w.Write(out); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const propfindHead = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:i="http://apple.com/ns/ical/">`
|
||||
|
||||
func TestParsePropfindTrenntUnsereEigenschaftenAb(t *testing.T) {
|
||||
body := []byte(propfindHead + `<d:prop><d:displayname/><cs:getctag/><i:calendar-color/></d:prop></d:propfind>`)
|
||||
|
||||
want, rewritten := parsePropfind(body)
|
||||
if !want.ctag || !want.color {
|
||||
t.Fatalf("beide Eigenschaften sollten erkannt werden, bekam %+v", want)
|
||||
}
|
||||
out := string(rewritten)
|
||||
if !strings.Contains(out, "<displayname/>") {
|
||||
t.Errorf("die übrigen Eigenschaften müssen erhalten bleiben: %s", out)
|
||||
}
|
||||
// Der Kern: go-webdav darf sie nicht zu sehen bekommen, sonst meldet es
|
||||
// sie als 404 und dieselbe Eigenschaft stünde zweimal in der Antwort.
|
||||
if strings.Contains(out, "getctag") || strings.Contains(out, "calendar-color") {
|
||||
t.Errorf("unsere Eigenschaften müssen entfernt sein: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePropfindLaesstFremdeAnfragenInRuhe(t *testing.T) {
|
||||
body := []byte(propfindHead + `<d:prop><d:resourcetype/><d:getetag/></d:prop></d:propfind>`)
|
||||
|
||||
want, rewritten := parsePropfind(body)
|
||||
if want.any() {
|
||||
t.Errorf("nichts von uns gefragt, trotzdem %+v", want)
|
||||
}
|
||||
if string(rewritten) != string(body) {
|
||||
t.Errorf("Rumpf muss unverändert durchgereicht werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePropfindNurUnsereEigenschaftHinterlaesstKeinLeeresProp(t *testing.T) {
|
||||
// Ein leeres <prop> weist go-webdav ab – dann käme auf eine gültige
|
||||
// Anfrage ein Fehler statt einer Antwort.
|
||||
body := []byte(propfindHead + `<d:prop><cs:getctag/></d:prop></d:propfind>`)
|
||||
|
||||
_, rewritten := parsePropfind(body)
|
||||
if !strings.Contains(string(rewritten), "<resourcetype/>") {
|
||||
t.Errorf("Platzhalter fehlt: %s", rewritten)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePropfindAllpropUndLeererRumpf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{"allprop", []byte(propfindHead + `<d:allprop/></d:propfind>`)},
|
||||
{"leerer Rumpf", nil}, // RFC 4918, 9.1: leer bedeutet allprop
|
||||
} {
|
||||
want, rewritten := parsePropfind(tc.body)
|
||||
if !want.ctag || !want.color {
|
||||
t.Errorf("%s: sollte alles liefern, bekam %+v", tc.name, want)
|
||||
}
|
||||
if string(rewritten) != string(tc.body) {
|
||||
t.Errorf("%s: Rumpf darf nicht umgebaut werden", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarOfResponseTrifftNurKalender(t *testing.T) {
|
||||
id := uuid.New()
|
||||
cal := &Calendar{ID: id, Color: "#a142f4", CTag: 7}
|
||||
// Ein Kalender unter seiner UUID, einer unter einer vom Client
|
||||
// gewählten Adresse – beide müssen gefunden werden.
|
||||
eigen := &Calendar{ID: uuid.New(), PathSegment: "arbeit", Color: "#34a853"}
|
||||
byRef := map[string]*Calendar{id.String(): cal, "arbeit": eigen}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
will *Calendar
|
||||
}{
|
||||
{"Kalender", "/caldav/calendars/max/" + id.String() + "/", cal},
|
||||
{"Home-Set", "/caldav/calendars/max/", nil},
|
||||
{"Principal", "/caldav/principals-max/", nil},
|
||||
{"einzelner Termin", "/caldav/calendars/max/" + id.String() + "/abc.ics", nil},
|
||||
{"fremder Kalender", "/caldav/calendars/max/" + uuid.New().String() + "/", nil},
|
||||
{"selbst gewählte Adresse", "/caldav/calendars/max/arbeit/", eigen},
|
||||
{"Termin darin", "/caldav/calendars/max/arbeit/abc.ics", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
part := `<response xmlns="DAV:"><href>` + tc.href + `</href><propstat/>`
|
||||
if got := calendarOfResponse(part, byRef); got != tc.will {
|
||||
t.Errorf("%s: bekam %v, erwartet %v", tc.name, got, tc.will)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtrasPropstat(t *testing.T) {
|
||||
cal := &Calendar{Color: "#a142f4", CTag: 12}
|
||||
|
||||
got := extrasPropstat(cal, wantedExtras{ctag: true, color: true})
|
||||
if !strings.Contains(got, ">12</getctag>") {
|
||||
t.Errorf("ctag fehlt: %s", got)
|
||||
}
|
||||
// Apple-Clients und DAVx5 erwarten den Alpha-Anteil.
|
||||
if !strings.Contains(got, ">#A142F4FF</calendar-color>") {
|
||||
t.Errorf("Farbe nicht in Apple-Schreibweise: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "HTTP/1.1 200 OK") {
|
||||
t.Errorf("Status fehlt: %s", got)
|
||||
}
|
||||
|
||||
// Ohne Farbe bleibt die Eigenschaft weg – lieber gar nicht melden als
|
||||
// leer, sonst färbt der Client den Kalender schwarz.
|
||||
if got := extrasPropstat(&Calendar{CTag: 1}, wantedExtras{color: true}); got != "" {
|
||||
t.Errorf("ohne Farbe sollte nichts entstehen, bekam %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/acl"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("calendar: not found")
|
||||
|
||||
// ErrConflict: im Zielkalender liegt bereits ein Termin mit dieser UID. Zwei
|
||||
// Objekte gleicher UID in einer Sammlung wären für CalDAV-Clients nicht
|
||||
// unterscheidbar – deshalb bleibt der Termin dann lieber, wo er ist.
|
||||
var ErrConflict = errors.New("calendar: uid already exists in target calendar")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
// rights beantwortet „wer darf was" – dieselbe Tabelle, die auch über
|
||||
// Ordner und Dateien entscheidet. Darf nil sein: Dann gibt es schlicht
|
||||
// keine freigegebenen Kalender, und alles Übrige funktioniert weiter.
|
||||
rights *acl.Store
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool, rights *acl.Store) *Store {
|
||||
return &Store{db: db, rights: rights}
|
||||
}
|
||||
|
||||
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||||
ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendars: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Calendar
|
||||
for rows.Next() {
|
||||
var c Calendar
|
||||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
|
||||
ownerID, id,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetCalendarByRef findet einen Kalender über sein Pfadsegment: entweder die
|
||||
// vom Client gewählte Adresse oder – wenn keine gewählt wurde – die UUID.
|
||||
//
|
||||
// Beides in einer Abfrage, damit der Aufruf nicht raten muss, welche Art
|
||||
// Adresse er gerade in der Hand hält. Die gewählte Adresse hat Vorrang: Nur
|
||||
// so bleibt eine Adresse eindeutig, selbst wenn sie zufällig aussieht wie
|
||||
// die UUID eines anderen Kalenders.
|
||||
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars
|
||||
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
||||
ORDER BY path_segment NULLS LAST
|
||||
LIMIT 1`,
|
||||
ownerID, ref,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar by ref: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateCalendarAt legt einen Kalender unter einer **vom Client gewählten**
|
||||
// Adresse an – der Fall MKCALENDAR/MKCOL. Ist die Adresse schon vergeben,
|
||||
// kommt ErrConflict: In WebDAV ist das ein 405, kein stilles Überschreiben.
|
||||
func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment, name, color, description string) (*Calendar, error) {
|
||||
if color == "" {
|
||||
color = "#4285f4"
|
||||
}
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, name, color, description, segment,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert calendar at %q: %w", segment, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
|
||||
// Bedarf "Personal" an. So findet ein frisch eingerichteter Client sofort
|
||||
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
|
||||
// CalDAV-Client kann das.
|
||||
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
|
||||
cals, err := s.ListCalendars(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cals) > 0 {
|
||||
return &cals[0], nil
|
||||
}
|
||||
return s.CreateCalendar(ctx, ownerID, "Personal", "#4285f4", "")
|
||||
}
|
||||
|
||||
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
|
||||
if color == "" {
|
||||
color = "#4285f4"
|
||||
}
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, name, color, description,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert calendar: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpdateCalendar ändert Name/Farbe/Beschreibung/Zeitzone. Ein nil-Feld lässt
|
||||
// die bestehende Spalte unangetastet – so kann der Aufrufer nur das Feld
|
||||
// schicken, das sich wirklich ändert, ohne vorher den ganzen Datensatz zu
|
||||
// laden.
|
||||
func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, color, description, timezone *string) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE cal_calendars SET
|
||||
name = COALESCE($3, name),
|
||||
color = COALESCE($4, color),
|
||||
description = COALESCE($5, description),
|
||||
timezone = COALESCE($6, timezone)
|
||||
WHERE owner_id = $1 AND id = $2
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, id, name, color, description, timezone,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update calendar: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete calendar: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) {
|
||||
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 ORDER BY uid`,
|
||||
calendarID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Object
|
||||
for rows.Next() {
|
||||
o, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) {
|
||||
row := s.db.QueryRow(ctx,
|
||||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||||
FROM cal_objects WHERE calendar_id = $1 AND uid = $2`,
|
||||
calendarID, uid,
|
||||
)
|
||||
o, err := scanObject(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanObject(row rowScanner) (Object, error) {
|
||||
var o Object
|
||||
err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||||
if err != nil {
|
||||
return Object{}, fmt.Errorf("scan object: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben
|
||||
// Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der
|
||||
// Aufrufer vorher – hier zählt nur noch das Schreiben selbst.
|
||||
func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den
|
||||
// Stand tragen, auf den der Kalender damit springt.
|
||||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var o Object
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
|
||||
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
|
||||
sync_seq = EXCLUDED.sync_seq, updated_at = now()
|
||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||||
calendarID, uid, component, data, etag, dtstart, dtend, seq,
|
||||
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert object: %w", err)
|
||||
}
|
||||
|
||||
// Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
|
||||
// Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten
|
||||
// Abgleich beides zu sehen -- die Datei und ihre Todesmeldung.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// MoveObject hängt einen Termin an einen anderen Kalender um. Die UID und der
|
||||
// gesamte .ics-Inhalt bleiben dabei unangetastet – es wechselt nur die
|
||||
// Sammlung, so wie ein MOVE in WebDAV.
|
||||
//
|
||||
// Für CalDAV-Clients ändert sich damit die Adresse des Termins: Sie sehen ihn
|
||||
// in der Quelle verschwinden und im Ziel auftauchen. Deshalb bekommen
|
||||
// **beide** Kalender ein neues ctag – mit nur einem holte der Client die
|
||||
// andere Seite erst beim nächsten Vollabgleich, und der Termin stünde
|
||||
// zwischenzeitlich doppelt oder gar nicht im Telefon.
|
||||
func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid string) (*Object, error) {
|
||||
if fromCal == toCal {
|
||||
return s.GetObjectByUID(ctx, fromCal, uid)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var taken bool
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM cal_objects WHERE calendar_id = $1 AND uid = $2)`,
|
||||
toCal, uid,
|
||||
).Scan(&taken); err != nil {
|
||||
return nil, fmt.Errorf("check target: %w", err)
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
|
||||
// Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin
|
||||
// fort (Grabstein), im Ziel ist er neu.
|
||||
fromSeq, err := bumpSeq(ctx, tx, fromCal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toSeq, err := bumpSeq(ctx, tx, toCal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx,
|
||||
`UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
|
||||
WHERE calendar_id = $3 AND uid = $4
|
||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||||
toCal, toSeq, fromCal, uid,
|
||||
)
|
||||
o, err := scanObject(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
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,
|
||||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete object: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pruneTombstones(ctx, tx, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ── Abgleich nur der Änderungen (RFC 6578) ───────────────────────────────────
|
||||
|
||||
// Wie lange ein Grabstein stehen bleibt.
|
||||
//
|
||||
// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann
|
||||
// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen;
|
||||
// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer.
|
||||
// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine
|
||||
// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät.
|
||||
const tombstoneTTL = 180 * 24 * time.Hour
|
||||
|
||||
type execer interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen.
|
||||
//
|
||||
// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen
|
||||
// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über
|
||||
// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander.
|
||||
func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) {
|
||||
var seq int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID,
|
||||
).Scan(&seq); err != nil {
|
||||
return 0, fmt.Errorf("bump ctag: %w", err)
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||
SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`,
|
||||
calendarID, uid, seq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tombstone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an.
|
||||
//
|
||||
// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis
|
||||
// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die
|
||||
// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort,
|
||||
// in der eine Löschung einfach fehlt, und behielte den Termin für immer.
|
||||
//
|
||||
// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen
|
||||
// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt.
|
||||
func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error {
|
||||
// Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der
|
||||
// ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die
|
||||
// erste Zeile greift, setzt den Boden auf irgendeinen der geräumten
|
||||
// Stände – und ein Client mit einem Token dazwischen bekäme dann eine
|
||||
// Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele.
|
||||
var hoechste *int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`WITH geraeumt AS (
|
||||
DELETE FROM cal_tombstones
|
||||
WHERE calendar_id = $1 AND deleted_at < now() - $2::interval
|
||||
RETURNING sync_seq
|
||||
)
|
||||
SELECT max(sync_seq) FROM geraeumt`,
|
||||
calendarID, tombstoneTTL.String(),
|
||||
).Scan(&hoechste); err != nil {
|
||||
return fmt.Errorf("prune tombstones: %w", err)
|
||||
}
|
||||
if hoechste == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`,
|
||||
calendarID, *hoechste,
|
||||
); err != nil {
|
||||
return fmt.Errorf("raise sync floor: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Changes ist die Antwort auf „was hat sich seit Stand N getan".
|
||||
type Changes struct {
|
||||
// Angelegt oder geändert – beides sieht für den Client gleich aus, er
|
||||
// holt das Objekt in beiden Fällen neu.
|
||||
Changed []Object
|
||||
// UIDs, die es nicht mehr gibt.
|
||||
Deleted []string
|
||||
// Der Stand, auf dem der Client danach steht.
|
||||
Token int64
|
||||
}
|
||||
|
||||
// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan
|
||||
// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen
|
||||
// Bestand – genau das, was ein Client beim ersten Abgleich braucht.
|
||||
//
|
||||
// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind
|
||||
// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre
|
||||
// unvollständig, ohne dass es jemandem auffiele.
|
||||
func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) {
|
||||
if since < 0 || since > cal.CTag {
|
||||
// Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von
|
||||
// einer anderen Instanz oder aus einer wiederhergestellten Sicherung.
|
||||
return nil, false, nil
|
||||
}
|
||||
if since > 0 && since < cal.SyncFloor {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}}
|
||||
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||||
FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||||
cal.ID, since)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("query changed objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
o, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out.Changed = append(out.Changed, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt:
|
||||
// Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm
|
||||
// nur Löschungen für Termine schicken, die er nie gesehen hat.
|
||||
if since == 0 {
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
trows, err := s.db.Query(ctx,
|
||||
`SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||||
cal.ID, since)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("query tombstones: %w", err)
|
||||
}
|
||||
defer trows.Close()
|
||||
for trows.Next() {
|
||||
var uid string
|
||||
if err := trows.Scan(&uid); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out.Deleted = append(out.Deleted, uid)
|
||||
}
|
||||
return out, true, trows.Err()
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Abgleich nur der Änderungen: REPORT sync-collection, RFC 6578.
|
||||
//
|
||||
// **Wozu.** Bis hierher lief jeder Durchgang so: Der Client fragt das ctag
|
||||
// ab, sieht es verändert und holt daraufhin die *volle* Terminliste. Eine
|
||||
// verschobene Besprechung kostet damit denselben Verkehr wie ein Neuanfang –
|
||||
// bei einem Jahr Termine jedes Mal. Am Telefon zahlt man das in Akku und
|
||||
// Datenvolumen, und zwar bei jedem der viertelstündlichen Durchgänge.
|
||||
//
|
||||
// Mit sync-collection sagt der Client stattdessen „ich bin auf Stand 41" und
|
||||
// bekommt zurück, was seither geschah – und ein neues Token.
|
||||
//
|
||||
// **Warum hier und nicht in der Bibliothek.** go-webdav kennt für REPORT nur
|
||||
// calendar-query und calendar-multiget; alles andere beantwortet es mit 400.
|
||||
// Der Weg geht deshalb an ihr vorbei, wie schon bei PROPFIND und MKCALENDAR.
|
||||
//
|
||||
// **Was ein Token ist.** Eine Zahl: der Stand des Kalenders, also sein ctag.
|
||||
// Kein Zeitstempel – zwei Änderungen in derselben Sekunde wären damit nicht
|
||||
// zu trennen, und eine Uhr, die zurückspringt, ließe Änderungen verschwinden.
|
||||
|
||||
// Präfix des Tokens. Ein Token ist laut RFC ein URI; der Präfix macht
|
||||
// zugleich erkennbar, dass ein Token einer *fremden* Instanz nicht passt.
|
||||
const syncTokenPrefix = "urn:aether:sync:"
|
||||
|
||||
// Ein sync-collection-Rumpf ist eine Handvoll Elemente. Alles darüber ist
|
||||
// kein Abgleich mehr.
|
||||
const maxReportBody = 1 << 20
|
||||
|
||||
type syncReport struct {
|
||||
XMLName xml.Name `xml:"DAV: sync-collection"`
|
||||
SyncToken string `xml:"DAV: sync-token"`
|
||||
SyncLevel string `xml:"DAV: sync-level"`
|
||||
Limit *struct {
|
||||
NResults int `xml:"DAV: nresults"`
|
||||
} `xml:"DAV: limit"`
|
||||
Prop struct {
|
||||
Any []struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
} `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
// wantsCalendarData: Fragt der Client die Termindaten gleich mit ab? Die
|
||||
// meisten holen erst die ETags und danach gezielt per multiget – wer aber
|
||||
// beides in einem Zug will, soll es bekommen.
|
||||
func (s *syncReport) wantsCalendarData() bool {
|
||||
for _, el := range s.Prop.Any {
|
||||
if el.XMLName.Space == nsCalDAV && el.XMLName.Local == "calendar-data" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const nsCalDAV = "urn:ietf:params:xml:ns:caldav"
|
||||
|
||||
// handleReport nimmt sync-collection an sich und lässt alles andere durch.
|
||||
// Rückgabe: true, wenn die Antwort geschrieben wurde.
|
||||
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxReportBody))
|
||||
r.Body.Close()
|
||||
// Was wir nicht selbst beantworten, muss die Bibliothek unverändert
|
||||
// vorfinden – also den gelesenen Rumpf wieder unterlegen.
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err != nil || !isSyncCollection(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
var req syncReport
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "malformed sync-collection request", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
// sync-level ist Pflicht (RFC 6578, 3.3). Bei einem Kalender gibt es nur
|
||||
// eine Ebene; "infinite" bedeutet hier dasselbe wie "1" und wird
|
||||
// angenommen, statt den Client an einer Formalie scheitern zu lassen.
|
||||
if lvl := strings.TrimSpace(req.SyncLevel); lvl != "1" && lvl != "infinite" {
|
||||
http.Error(w, "sync-level must be 1", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
// sync-collection gilt für eine Sammlung, nicht für einen Termin.
|
||||
http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
zugriff, err := h.store.ResolveRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
cal := &zugriff.Calendar
|
||||
|
||||
since, ok := parseSyncToken(req.SyncToken)
|
||||
if !ok {
|
||||
writeInvalidToken(w)
|
||||
return true
|
||||
}
|
||||
|
||||
changes, gueltig, err := h.store.ChangesSince(r.Context(), cal, since)
|
||||
if err != nil {
|
||||
http.Error(w, "sync failed", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if !gueltig {
|
||||
// Das Token ist zu alt oder stammt nicht von uns. Eine unvollständige
|
||||
// Antwort wäre hier das Schlimmste: Der Client hielte sie für den
|
||||
// ganzen Unterschied und behielte gelöschte Termine für immer.
|
||||
writeInvalidToken(w)
|
||||
return true
|
||||
}
|
||||
|
||||
writeSyncResponse(w, user, ref, changes, req.wantsCalendarData())
|
||||
return true
|
||||
}
|
||||
|
||||
// isSyncCollection sieht nach, ob das Wurzelelement sync-collection ist –
|
||||
// ohne den ganzen Rumpf zu deuten. Alles andere geht unverändert weiter an
|
||||
// go-webdav.
|
||||
func isSyncCollection(body []byte) bool {
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if start, ok := tok.(xml.StartElement); ok {
|
||||
return start.Name.Space == "DAV:" && start.Name.Local == "sync-collection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSyncToken liest den Stand aus dem Token. Ein leeres Token heißt
|
||||
// „ich fange bei null an" – der erste Abgleich eines Clients.
|
||||
func parseSyncToken(tok string) (int64, bool) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
return 0, true
|
||||
}
|
||||
if !strings.HasPrefix(tok, syncTokenPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(tok, syncTokenPrefix), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func syncToken(seq int64) string {
|
||||
return syncTokenPrefix + strconv.FormatInt(seq, 10)
|
||||
}
|
||||
|
||||
// writeInvalidToken schickt die Absage, die der RFC dafür vorsieht. Sie ist
|
||||
// kein Fehler im üblichen Sinn: Jeder Client kennt sie und macht daraufhin
|
||||
// einen Vollabgleich – genau das, was wir wollen.
|
||||
func writeInvalidToken(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, xml.Header+
|
||||
`<error xmlns="DAV:"><valid-sync-token/></error>`)
|
||||
}
|
||||
|
||||
// ref ist die Adresse, unter der **dieser** Anfragende den Kalender sieht –
|
||||
// bei einem freigegebenen also dessen UUID, nicht das Pfadsegment des
|
||||
// Eigentümers. Die Antwort muss Adressen nennen, die der Client auch abrufen
|
||||
// kann.
|
||||
func writeSyncResponse(w http.ResponseWriter, user *auth.User,
|
||||
ref string, changes *Changes, withData bool) {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString(`<multistatus xmlns="DAV:" xmlns:C="` + nsCalDAV + `">`)
|
||||
|
||||
for i := range changes.Changed {
|
||||
o := &changes.Changed[i]
|
||||
// Die Anführungszeichen stehen **außerhalb** der Maskierung: Sonst
|
||||
// macht xml.EscapeText daraus `"`. Gültiges XML – aber genug
|
||||
// CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die
|
||||
// sähen dann bei jedem Durchgang eine Änderung, wo keine ist.
|
||||
fmt.Fprintf(&b, `<response><href>%s</href><propstat><prop><getetag>%s</getetag>`,
|
||||
xmlText(objectPath(user.Username, ref, o.UID)), quoteETag(o.ETag))
|
||||
if withData {
|
||||
fmt.Fprintf(&b, `<C:calendar-data>%s</C:calendar-data>`, xmlText(o.Data))
|
||||
}
|
||||
b.WriteString(`</prop><status>HTTP/1.1 200 OK</status></propstat></response>`)
|
||||
}
|
||||
|
||||
// Gelöschtes trägt **keinen** propstat, sondern einen Status direkt an der
|
||||
// Antwort – so unterscheidet der Client „ist weg" von „gibt es, konnte
|
||||
// aber eine Eigenschaft nicht liefern".
|
||||
for _, uid := range changes.Deleted {
|
||||
fmt.Fprintf(&b, `<response><href>%s</href><status>HTTP/1.1 404 Not Found</status></response>`,
|
||||
xmlText(objectPath(user.Username, ref, uid)))
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<sync-token>%s</sync-token></multistatus>`, syncToken(changes.Token))
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, b.String())
|
||||
}
|
||||
|
||||
// quoteETag: In der Datenbank steht der nackte Wert, über die Leitung gehört
|
||||
// er in Anführungszeichen (RFC 9110). go-webdav macht das an anderer Stelle
|
||||
// selbst – hier bauen wir die Antwort von Hand, also hier auch.
|
||||
//
|
||||
// Der Inhalt wird maskiert, die Anführungszeichen nicht: Sie sind Teil der
|
||||
// Syntax, nicht des Werts.
|
||||
func quoteETag(etag string) string {
|
||||
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/`) {
|
||||
return xmlText(etag)
|
||||
}
|
||||
return `"` + xmlText(etag) + `"`
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Tests der Warteschlange für die Texterkennung.
|
||||
//
|
||||
// **Warum ausgerechnet hier.** Ein Fehler in diesem Paket ist *still*: Die
|
||||
// Volltextsuche findet dann einfach nichts – und das sieht genauso aus wie
|
||||
// „steht nicht drin". Niemand bekommt eine Fehlermeldung; man merkt es erst,
|
||||
// wenn man eine Rechnung sucht, von der man weiß, dass man sie hat.
|
||||
//
|
||||
// Geprüft werden deshalb nicht die Abfragen, sondern die drei Zustände, in
|
||||
// denen die Schlange hängenbleiben kann, ohne dass es auffällt:
|
||||
//
|
||||
// 1. Ein Auftrag, der nie aufgibt (Endlosschleife auf einer kaputten Datei).
|
||||
// 2. Ein Auftrag, der nach einem harten Abbruch für immer auf 'running'
|
||||
// steht und nie wieder angefasst wird.
|
||||
// 3. Ein Text nach alter Regel, der nie erneuert wird.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN gegen eine echte Datenbank; ohne wird übersprungen,
|
||||
// damit `go test ./...` überall grün bleibt.
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
// probeDatei legt Konto und Datei an, auf die sich ein Auftrag beziehen kann.
|
||||
// Claim verbindet mit `files`, ein Auftrag ohne Datei wäre also unsichtbar –
|
||||
// und ein Test, der das übersieht, prüft eine leere Schlange.
|
||||
func probeDatei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, marke string) uuid.UUID {
|
||||
t.Helper()
|
||||
var userID uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, 'OCR Probe', 'x', false) RETURNING id`, "ocrprobe-"+marke,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatalf("konto anlegen: %v", err)
|
||||
}
|
||||
var fileID uuid.UUID
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1234, 'deadbeef', $3) RETURNING id`,
|
||||
userID, "probe-"+marke+".pdf", "users/ocrprobe-"+marke+"/home/probe.pdf",
|
||||
).Scan(&fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("datei anlegen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
return fileID
|
||||
}
|
||||
|
||||
func status(t *testing.T, pool *pgxpool.Pool, ctx context.Context, fileID uuid.UUID) (string, int) {
|
||||
t.Helper()
|
||||
var s string
|
||||
var a int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT status, attempts FROM ocr_jobs WHERE file_id = $1`, fileID,
|
||||
).Scan(&s, &a); err != nil {
|
||||
t.Fatalf("stand lesen: %v", err)
|
||||
}
|
||||
return s, a
|
||||
}
|
||||
|
||||
// ── 1. Aufgeben ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Die Zahl selbst ist eine Entscheidung, kein Zufall – und ein Test, der nur
|
||||
// gegen die Konstante läuft, kann sie nicht schützen.
|
||||
//
|
||||
// **Das kam aus einem Mutationsversuch:** Setzt man maxAttempts auf 9999,
|
||||
// blieben alle übrigen Tests grün (sie schleifen ja über die Konstante) und
|
||||
// brauchten nur plötzlich 27 statt 0,2 Sekunden. Genau so verschwindet eine
|
||||
// Entscheidung unbemerkt aus einer Anwendung.
|
||||
//
|
||||
// Zwei Wiederholungen fangen Vorübergehendes ab – Neustart mitten im Lauf,
|
||||
// kurz fehlender Plattenplatz. Was dreimal scheitert, scheitert an der Datei.
|
||||
func TestDreiVersucheSindEineEntscheidung(t *testing.T) {
|
||||
if maxAttempts != 3 {
|
||||
t.Fatalf("maxAttempts steht auf %d. Wenn das Absicht ist, gehört die "+
|
||||
"Begründung an die Konstante und diese Zahl hierher.", maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Datei, die den Erkenner reproduzierbar scheitern lässt – ein kaputtes
|
||||
// oder passwortgeschütztes PDF –, darf die Schlange nicht dauerhaft besetzen.
|
||||
// Ohne Obergrenze liefe der Arbeiter für immer auf derselben Datei und käme an
|
||||
// die übrigen nie heran: still, denn nach außen „läuft" die Erkennung.
|
||||
func TestGibtNachDreiVersuchenAuf(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "aufgeben")
|
||||
|
||||
if err := s.Enqueue(ctx, fileID); err != nil {
|
||||
t.Fatalf("einreihen: %v", err)
|
||||
}
|
||||
|
||||
for i := 1; i <= maxAttempts; i++ {
|
||||
// Obergrenze gegen eine aus dem Ruder gelaufene Konstante: Ohne sie
|
||||
// liefe dieser Test bei maxAttempts = 9999 minutenlang, statt etwas
|
||||
// zu sagen.
|
||||
if i > 10 {
|
||||
t.Fatalf("maxAttempts steht auf %d – das ist keine Wiederholung mehr", maxAttempts)
|
||||
}
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("versuch %d: kein Auftrag mehr (%v) – zu früh aufgegeben", i, err)
|
||||
}
|
||||
if job.FileID != fileID {
|
||||
t.Fatalf("versuch %d: fremder Auftrag", i)
|
||||
}
|
||||
if job.Attempts != i {
|
||||
t.Fatalf("versuch %d: Zähler steht auf %d", i, job.Attempts)
|
||||
}
|
||||
if err := s.Fail(ctx, job, fmt.Errorf("kaputtes PDF")); err != nil {
|
||||
t.Fatalf("versuch %d: Fail: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Jetzt ist Schluss – und das muss *sichtbar* sein, nicht bloß still.
|
||||
if st, a := status(t, pool, ctx, fileID); st != "failed" || a != maxAttempts {
|
||||
t.Fatalf("nach %d Versuchen: status=%q attempts=%d, erwartet failed/%d",
|
||||
maxAttempts, st, a, maxAttempts)
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("der Auftrag wird weiter angefasst – Endlosschleife auf einer kaputten Datei")
|
||||
}
|
||||
|
||||
// Der Grund muss drinstehen. Ein 'failed' ohne Text wäre eine Sackgasse:
|
||||
// Man sieht, dass etwas nicht ging, aber nie was.
|
||||
var fehler string
|
||||
_ = pool.QueryRow(ctx, `SELECT last_error FROM ocr_jobs WHERE file_id = $1`, fileID).Scan(&fehler)
|
||||
if fehler == "" {
|
||||
t.Fatal("kein Fehlertext gespeichert")
|
||||
}
|
||||
}
|
||||
|
||||
// Wird dieselbe Datei erneut hochgeladen, verdient sie eine neue Chance –
|
||||
// vielleicht war genau der kaputte Scan das Problem.
|
||||
func TestNeuerUploadGibtEineNeueChance(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "neuechance")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
for range maxAttempts {
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
_ = s.Fail(ctx, job, fmt.Errorf("kaputt"))
|
||||
}
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "failed" {
|
||||
t.Fatalf("vorbedingung: status=%q", st)
|
||||
}
|
||||
|
||||
// Erneuter Upload.
|
||||
if err := s.Enqueue(ctx, fileID); err != nil {
|
||||
t.Fatalf("erneut einreihen: %v", err)
|
||||
}
|
||||
if st, a := status(t, pool, ctx, fileID); st != "pending" || a != 0 {
|
||||
t.Fatalf("nach erneutem Upload: status=%q attempts=%d, erwartet pending/0", st, a)
|
||||
}
|
||||
if _, err := s.Claim(ctx); err != nil {
|
||||
t.Fatalf("der neue Versuch wird nicht angefasst: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Nach einem harten Abbruch ────────────────────────────────────────────
|
||||
|
||||
// Stirbt der Prozess mitten in einem Auftrag, bleibt dessen Zeile auf
|
||||
// 'running' stehen. Würde Claim nur 'pending' nehmen, wäre diese Datei für
|
||||
// immer verloren – und niemand erführe es, denn die Zahl der offenen Aufträge
|
||||
// sähe gesund aus.
|
||||
func TestNimmtHaengengebliebeneAuftraegeWiederAuf(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "abbruch")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "running" {
|
||||
t.Fatalf("vorbedingung: status=%q", st)
|
||||
}
|
||||
|
||||
// Hier stirbt der Prozess: kein Complete, kein Fail. Die Zeile bleibt
|
||||
// stehen, wie sie ist.
|
||||
_ = job
|
||||
|
||||
wieder, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("hängengebliebener Auftrag wird nie wieder angefasst: %v", err)
|
||||
}
|
||||
if wieder.FileID != fileID {
|
||||
t.Fatal("ein anderer Auftrag kam zurück")
|
||||
}
|
||||
if wieder.Attempts != 2 {
|
||||
t.Fatalf("Zähler steht auf %d, erwartet 2 – sonst zählt der Abbruch nicht mit",
|
||||
wieder.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Wiederaufnahme darf keine Endlosschleife werden: Eine Datei, die den
|
||||
// Arbeiter reproduzierbar umbringt, wäre sonst eine Schleife aus Absturz und
|
||||
// Neustart.
|
||||
func TestWiederaufnahmeIstBegrenzt(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "begrenzt")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
for i := 1; i <= maxAttempts; i++ {
|
||||
if _, err := s.Claim(ctx); err != nil {
|
||||
t.Fatalf("versuch %d: %v", i, err)
|
||||
}
|
||||
// Jedes Mal stirbt der Prozess – der Auftrag bleibt auf 'running'.
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("unbegrenzte Wiederaufnahme: Absturz, Neustart, Absturz …")
|
||||
}
|
||||
}
|
||||
|
||||
// Eine gelöschte Datei darf nicht mehr erkannt werden – der Arbeiter käme
|
||||
// sonst an einen Blob, den es nicht mehr gibt.
|
||||
func TestGeloeschteDateienWerdenNichtGenommen(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "geloescht")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
if _, err := pool.Exec(ctx, `UPDATE files SET is_deleted = true WHERE id = $1`, fileID); err != nil {
|
||||
t.Fatalf("löschen: %v", err)
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("eine gelöschte Datei wurde zur Erkennung genommen")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Text nach alter Regel ────────────────────────────────────────────────
|
||||
|
||||
// Ändert sich die Extraktionsregel, müssen alte Einträge erneuert werden.
|
||||
// Sonst bleibt ein stiller Rest: auffindbar, aber im Betrachter nicht auf der
|
||||
// richtigen Seite – und niemand sieht, dass diese Dateien anders sind.
|
||||
func TestErneuertTextNachAltererRegel(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "veraltet")
|
||||
|
||||
// Ein Text, der nach einer älteren Regel entstanden ist.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
|
||||
VALUES ($1, 'alter text', 'ocr', 'deu', 10, 1, $2)`,
|
||||
fileID, ExtractVersion-1); err != nil {
|
||||
t.Fatalf("alten text anlegen: %v", err)
|
||||
}
|
||||
|
||||
n, err := s.EnqueueOutdated(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueOutdated: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("der veraltete Eintrag wurde nicht erneut eingeplant")
|
||||
}
|
||||
if st, a := status(t, pool, ctx, fileID); st != "pending" || a != 0 {
|
||||
t.Fatalf("status=%q attempts=%d, erwartet pending/0", st, a)
|
||||
}
|
||||
|
||||
// Und einer auf aktuellem Stand darf **nicht** erneut eingeplant werden –
|
||||
// sonst erkennt der Server bei jedem Start den ganzen Bestand neu.
|
||||
_, _ = pool.Exec(ctx, `UPDATE file_text SET extract_version = $2 WHERE file_id = $1`,
|
||||
fileID, ExtractVersion)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ocr_jobs WHERE file_id = $1`, fileID)
|
||||
n, err = s.EnqueueOutdated(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueOutdated (aktuell): %v", err)
|
||||
}
|
||||
var da bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM ocr_jobs WHERE file_id = $1)`, fileID).Scan(&da)
|
||||
if da {
|
||||
t.Fatalf("ein aktueller Eintrag wurde erneut eingeplant (%d Zeilen) – der Bestand würde bei jedem Start neu erkannt", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Abschluss ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Complete muss Text **und** Auftragsende zusammen schreiben. Ginge nur eines
|
||||
// durch, wäre die Datei entweder unauffindbar oder würde bei jedem Durchgang
|
||||
// erneut erkannt.
|
||||
func TestCompleteSchreibtTextUndSchliesstAb(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "complete")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
res := Result{Text: "Seite eins\fSeite zwei", Source: "ocr", Lang: "deu", Chars: 20, Pages: 2}
|
||||
if err := s.Complete(ctx, job, res); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "done" {
|
||||
t.Fatalf("status=%q, erwartet done", st)
|
||||
}
|
||||
seiten, quelle, err := s.TextPages(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("TextPages: %v", err)
|
||||
}
|
||||
if quelle != "ocr" {
|
||||
t.Fatalf("quelle=%q", quelle)
|
||||
}
|
||||
// Die Seitenmarke ist der Grund, aus dem es ExtractVersion gibt: Ohne sie
|
||||
// kann der Betrachter eine Fundstelle nicht auf einer Seite verorten.
|
||||
if len(seiten) != 2 {
|
||||
t.Fatalf("%d Seiten, erwartet 2 – Seitenmarke ging verloren", len(seiten))
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("ein fertiger Auftrag wird erneut genommen")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||
@@ -67,7 +68,13 @@ func (w *Worker) Run(ctx context.Context) {
|
||||
|
||||
for {
|
||||
wait := idleInterval
|
||||
if w.step(ctx) {
|
||||
// **Je Auftrag abgesichert, nicht je Arbeiter.** Fängt man eine Panik
|
||||
// um die ganze Schleife ab, überlebt zwar der Prozess – aber der
|
||||
// Arbeiter ist tot, und von da an wird still keine Datei mehr erkannt.
|
||||
// So kostet ein unverdauliches Dokument genau sich selbst.
|
||||
var etwasGetan bool
|
||||
bg.Run("Texterkennung", func() { etwasGetan = w.step(ctx) })
|
||||
if etwasGetan {
|
||||
wait = busyInterval
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -486,20 +484,25 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
|
||||
// Verify checksum while streaming.
|
||||
h256 := sha256.New()
|
||||
reader := io.TeeReader(rc, h256)
|
||||
|
||||
if _, err := io.Copy(w, reader); err != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
// Log corruption; headers already sent, can't change status.
|
||||
// TODO: alert / observability hook
|
||||
_ = actual
|
||||
}
|
||||
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
|
||||
// zurückhalten**, bis sie stimmt.
|
||||
//
|
||||
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
|
||||
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
|
||||
// dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte
|
||||
// wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv,
|
||||
// in das man einmal im Jahr sieht, die teuerste Art von Stille.
|
||||
//
|
||||
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
|
||||
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
|
||||
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
|
||||
// Verbindung noch fallen lassen, und der Browser sieht einen
|
||||
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
|
||||
ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
||||
@@ -762,7 +765,10 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
|
||||
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
||||
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
||||
var extraMIMETypes = map[string]string{
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
// Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit
|
||||
// die Oberflaeche sie vom Texteditor fernhaelt.
|
||||
".excalidraw": "application/json",
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
||||
".wav": "audio/wav", ".flac": "audio/flac",
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/thumbnails"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein
|
||||
// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und
|
||||
// halten trotzdem jeden Unfug vom Dekodierer fern.
|
||||
const maxThumbUpload = 2 << 20
|
||||
|
||||
// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen
|
||||
// kann.
|
||||
//
|
||||
// POST /api/v1/files/{id}/thumbnail
|
||||
//
|
||||
// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst
|
||||
// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort
|
||||
// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung
|
||||
// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann.
|
||||
//
|
||||
// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt,
|
||||
// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch
|
||||
// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren,
|
||||
// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
|
||||
// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen.
|
||||
func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
// Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie
|
||||
// sie in der Liste aussieht.
|
||||
owner, ok := h.resolveFile(w, r, id, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
typ := r.Header.Get("Content-Type")
|
||||
if !thumbnails.SupportedMIME(typ) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image")
|
||||
return
|
||||
}
|
||||
|
||||
// MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung
|
||||
// ab, statt erst alles zu lesen und dann abzulehnen.
|
||||
rc := http.MaxBytesReader(w, r.Body, maxThumbUpload)
|
||||
defer rc.Close()
|
||||
|
||||
if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten")
|
||||
response.Error(w, http.StatusBadRequest, "could not read that image")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -37,10 +38,12 @@ func scanFile(row interface{ Scan(...any) error }, f *File) error {
|
||||
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||
var out File
|
||||
err := scanFile(s.db.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
|
||||
versioning_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING `+fileCols,
|
||||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||||
versioniertVonAnfang(f.Name),
|
||||
), &out)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
|
||||
// behalten.
|
||||
//
|
||||
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
|
||||
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
|
||||
// unbemerkt voll.
|
||||
//
|
||||
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
|
||||
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
|
||||
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
|
||||
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
|
||||
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
|
||||
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
|
||||
// ohne Deckung.
|
||||
func versioniertVonAnfang(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
|
||||
}
|
||||
|
||||
// GetFileByID fetches a file without owner check (used by public share downloads).
|
||||
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
||||
var f File
|
||||
@@ -537,29 +558,6 @@ func isUniqueViolation(err error) bool {
|
||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||
// entstanden ist – 0 heißt: es gibt keins.
|
||||
//
|
||||
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
|
||||
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
|
||||
// Liste und jede Suche mit.
|
||||
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("preview version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
|
||||
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set preview version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Der tailWriter ist die Stelle, an der ein beschädigter Download noch
|
||||
// aufzuhalten ist. Was er falsch macht, merkt man sonst erst an einer Datei,
|
||||
// die heil aussieht und keine ist.
|
||||
|
||||
func TestTailWriterHaeltDenSchlussZurueck(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 4}
|
||||
|
||||
if _, err := tw.Write([]byte("0123456789")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := raus.String(); got != "012345" {
|
||||
t.Fatalf("durchgereicht %q, erwartet %q", got, "012345")
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := raus.String(); got != "0123456789" {
|
||||
t.Fatalf("nach Flush %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Der Fall, auf den es ankommt: Ohne Flush darf der Empfänger die Datei
|
||||
// **nicht** vollständig haben.
|
||||
func TestOhneFlushBleibtEsUnvollstaendig(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 4}
|
||||
io.Copy(tw, bytes.NewReader([]byte("0123456789")))
|
||||
if raus.Len() != 6 {
|
||||
t.Fatalf("%d Bytes draußen, erwartet 6", raus.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Datei, die kleiner ist als der zurückgehaltene Schluss, darf ohne
|
||||
// Flush gar nichts hinauslassen.
|
||||
func TestKleineDateiBleibtGanzZurueck(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 32}
|
||||
tw.Write([]byte("kurz"))
|
||||
if raus.Len() != 0 {
|
||||
t.Fatalf("%d Bytes zu früh draußen", raus.Len())
|
||||
}
|
||||
tw.Flush()
|
||||
if raus.String() != "kurz" {
|
||||
t.Fatalf("nach Flush %q", raus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Viele kleine Schreibvorgänge dürfen sich nicht anders verhalten als einer
|
||||
// großer – io.Copy zerlegt in Häppchen.
|
||||
func TestHaeppchenweiseGleichesErgebnis(t *testing.T) {
|
||||
var raus bytes.Buffer
|
||||
tw := &tailWriter{w: &raus, keep: 5}
|
||||
for _, c := range []byte("abcdefghijklmnop") {
|
||||
if _, err := tw.Write([]byte{c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if raus.String() != "abcdefghijk" {
|
||||
t.Fatalf("durchgereicht %q", raus.String())
|
||||
}
|
||||
tw.Flush()
|
||||
if raus.String() != "abcdefghijklmnop" {
|
||||
t.Fatalf("nach Flush %q", raus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Der Zwischenspeicher darf nicht mitwachsen: Bei einer 5-GB-Datei läge sonst
|
||||
// alles im Arbeitsspeicher.
|
||||
func TestZwischenspeicherWaechstNicht(t *testing.T) {
|
||||
tw := &tailWriter{w: io.Discard, keep: 1024}
|
||||
block := make([]byte, 32*1024)
|
||||
for range 100 {
|
||||
tw.Write(block)
|
||||
if len(tw.buf) > len(block)+tw.keep {
|
||||
t.Fatalf("Zwischenspeicher auf %d Bytes gewachsen", len(tw.buf))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bricht die Gegenstelle weg, muss der Fehler nach oben durchkommen – sonst
|
||||
// liefe io.Copy weiter und läse die ganze Datei ins Nichts.
|
||||
type kaputterWriter struct{}
|
||||
|
||||
func (kaputterWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
|
||||
func TestSchreibfehlerKommtDurch(t *testing.T) {
|
||||
tw := &tailWriter{w: kaputterWriter{}, keep: 2}
|
||||
if _, err := tw.Write([]byte("abcdef")); err == nil {
|
||||
t.Fatal("Schreibfehler wurde verschluckt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Ausliefern mit Prüfung der SHA-256-Summe.
|
||||
//
|
||||
// Stand bis zum 25.09.2026 nur im angemeldeten Download. Der **öffentliche**
|
||||
// Freigabelink lieferte ungeprüft aus – ausgerechnet der Weg, auf dem eine
|
||||
// Datei das Haus verlässt. Wer sie angemeldet holt, ist der Eigentümer und
|
||||
// merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht.
|
||||
//
|
||||
// In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen,
|
||||
// **beim Download verifizieren**. Die Regel galt an einer von zwei Stellen.
|
||||
// Deshalb liegt sie jetzt hier und wird von beiden gerufen.
|
||||
|
||||
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
|
||||
//
|
||||
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
|
||||
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
|
||||
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
|
||||
const verifyTail = 32 * 1024
|
||||
|
||||
// VerifyInfo ist nur für den Logeintrag – wer hinterher sucht, braucht die
|
||||
// Datei und ihren Platz auf der Platte, nicht bloß „stimmt nicht".
|
||||
type VerifyInfo struct {
|
||||
FileID string
|
||||
StorageKey string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ServeVerified kopiert `src` nach `w` und prüft dabei die Summe.
|
||||
//
|
||||
// Stimmt sie nicht, wird der letzte Rest **nicht** herausgegeben und die
|
||||
// Verbindung fallen gelassen. Die Kopfzeilen sind da längst draußen, der
|
||||
// Statuscode also nicht mehr zu ändern – aber die Gegenstelle bekommt weniger
|
||||
// Bytes als im Content-Length angekündigt und wertet das als Fehler. Besser
|
||||
// ein abgebrochener Download als eine heile Datei mit falschem Inhalt.
|
||||
func ServeVerified(ctx context.Context, w io.Writer, src io.Reader, erwartet string, info VerifyInfo) {
|
||||
h256 := sha256.New()
|
||||
zurueck := &tailWriter{w: w, keep: verifyTail}
|
||||
|
||||
if _, err := io.Copy(zurueck, io.TeeReader(src, h256)); err != nil {
|
||||
return // Gegenstelle ist weg
|
||||
}
|
||||
|
||||
gelesen := hex.EncodeToString(h256.Sum(nil))
|
||||
if gelesen != erwartet {
|
||||
log.Ctx(ctx).Error().
|
||||
Str("file_id", info.FileID).
|
||||
Str("storage_key", info.StorageKey).
|
||||
Str("erwartet", erwartet).
|
||||
Str("gelesen", gelesen).
|
||||
Int64("size", info.Size).
|
||||
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
|
||||
|
||||
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
|
||||
// fallen zu lassen: net/http schließt die Verbindung und schreibt
|
||||
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
|
||||
// Panik ausdrücklich durch).
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// Stimmt sie, darf der Rest hinterher.
|
||||
_ = zurueck.Flush()
|
||||
}
|
||||
|
||||
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
|
||||
type tailWriter struct {
|
||||
w io.Writer
|
||||
keep int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (t *tailWriter) Write(p []byte) (int, error) {
|
||||
t.buf = append(t.buf, p...)
|
||||
if ueber := len(t.buf) - t.keep; ueber > 0 {
|
||||
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
t.buf = append(t.buf[:0], t.buf[ueber:]...)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
|
||||
// bleibt die Antwort unvollständig – genau das ist der Zweck.
|
||||
func (t *tailWriter) Flush() error {
|
||||
if len(t.buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := t.w.Write(t.buf)
|
||||
t.buf = t.buf[:0]
|
||||
return err
|
||||
}
|
||||
+10
-23
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,6 +73,11 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||
// "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die
|
||||
// als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und
|
||||
// die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so
|
||||
// sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste,
|
||||
// bleiben sie für immer liegen.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
res.Scanned++
|
||||
@@ -153,27 +159,6 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
return nil, fmt.Errorf("iterate file keys: %w", err)
|
||||
}
|
||||
|
||||
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
|
||||
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
|
||||
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
|
||||
prevRows, err := c.db.Query(ctx,
|
||||
`SELECT id::text FROM files WHERE preview_version > 0`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query preview keys: %w", err)
|
||||
}
|
||||
for prevRows.Next() {
|
||||
var id string
|
||||
if err := prevRows.Scan(&id); err != nil {
|
||||
prevRows.Close()
|
||||
return nil, fmt.Errorf("scan preview key: %w", err)
|
||||
}
|
||||
keys["previews/"+id+".pdf"] = true
|
||||
}
|
||||
prevRows.Close()
|
||||
if err := prevRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate preview keys: %w", err)
|
||||
}
|
||||
|
||||
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
||||
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
||||
@@ -254,7 +239,9 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||
// Hochfahren konkurriert.
|
||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
go func() {
|
||||
// Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein
|
||||
// Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll.
|
||||
bg.Go("Speicherbereinigung", func() {
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -282,7 +269,7 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
}
|
||||
timer.Reset(Interval)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ein Dokument nach PDF wandeln – dafür, es *anzusehen*, statt es zu
|
||||
// bearbeiten.
|
||||
//
|
||||
// **Warum überhaupt:** Am Telefon ist der Office-Editor unbrauchbar. Er ist
|
||||
// eine vollständige Textverarbeitung; Menüband, Formatleisten und
|
||||
// Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt. Auf 400 Pixeln
|
||||
// bleibt für das Dokument übrig, was die Leisten nicht brauchen. Das gilt für
|
||||
// Collabora wie für OnlyOffice – Marc hat beide ausprobiert, mit demselben
|
||||
// Ergebnis. Ein PDF im eigenen Betrachter ist für „kurz nachsehen" das
|
||||
// bessere Werkzeug, und für „kurz nachsehen" ist man unterwegs da.
|
||||
//
|
||||
// **Warum derselbe Server, der auch bearbeitet:** Wer OnlyOffice wegen der
|
||||
// Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe,
|
||||
// sähe unterwegs ein LibreOffice-Layout desselben Dokuments. Zwei Geräte,
|
||||
// zwei Umbrüche – ausgerechnet bei Unterlagen, bei denen es auf die Seite
|
||||
// ankommt. Die Umwandlung gehört deshalb zu dem Anbieter, der auch bearbeitet.
|
||||
//
|
||||
// Die beiden Anbieter gehen dabei verschiedene Wege, und der Unterschied ist
|
||||
// grundsätzlich:
|
||||
//
|
||||
// - **Collabora** nimmt die Datei entgegen und antwortet mit dem PDF. Ein
|
||||
// Aufruf, keine Anmeldung.
|
||||
// - **OnlyOffice** nimmt eine *Adresse* entgegen, holt die Datei selbst ab
|
||||
// und antwortet mit einer Adresse, unter der das Ergebnis liegt. Also
|
||||
// zwei Aufrufe, eine Signatur, und ein zweiter Ort, dem man nicht trauen
|
||||
// darf (siehe unten).
|
||||
|
||||
// convertTimeout: So lange darf eine Umwandlung dauern.
|
||||
//
|
||||
// Gemessen an dieser Anlage braucht ein gewöhnliches Textdokument etwa eine
|
||||
// Sekunde. Eine Minute ist keine Erwartung, sondern eine Notbremse: Hängt der
|
||||
// Office-Server, soll die Anfrage enden und nicht der Server mit ihr.
|
||||
const convertTimeout = 60 * time.Second
|
||||
|
||||
// jwtLifetime: Wie lange die Signatur an OnlyOffice gilt. Sie wird für genau
|
||||
// einen Aufruf ausgestellt.
|
||||
const jwtLifetime = 2 * time.Minute
|
||||
|
||||
// ConvertToPDF wandelt ein Dokument um.
|
||||
//
|
||||
// `src` ist die Adresse, unter der der Office-Server die Datei abholen kann –
|
||||
// nur OnlyOffice braucht sie. `body` sind die Bytes selbst – nur Collabora
|
||||
// braucht die. Der Aufrufer stellt beides bereit; welcher Weg genommen wird,
|
||||
// entscheidet der Anbieter, nicht der Aufrufer.
|
||||
func (c *Client) ConvertToPDF(ctx context.Context, ext, key, name, src string, body io.Reader) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch c.provider {
|
||||
case "collabora":
|
||||
return c.convertCollabora(ctx, name, body)
|
||||
case "onlyoffice":
|
||||
return c.convertOnlyOffice(ctx, ext, key, name, src)
|
||||
}
|
||||
return nil, fmt.Errorf("office: provider %q cannot convert", c.provider)
|
||||
}
|
||||
|
||||
// CanConvert sagt, ob dieser Anbieter das Wandeln überhaupt anbietet. Für die
|
||||
// Oberfläche: Ohne das gäbe es einen Knopf, der nur Fehler erzeugt.
|
||||
func (c *Client) CanConvert() bool {
|
||||
return c != nil && (c.provider == "collabora" || c.provider == "onlyoffice")
|
||||
}
|
||||
|
||||
// ── Collabora ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *Client) convertCollabora(ctx context.Context, name string, body io.Reader) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
// Der Feldname ist vorgeschrieben („data"), der Dateiname bestimmt, wie
|
||||
// Collabora den Typ erkennt – ohne Endung rät es und liegt daneben.
|
||||
part, err := mw.CreateFormFile("data", name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, body); err != nil {
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/cool/convert-to/pdf", &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("convert: office server answered %d", resp.StatusCode)
|
||||
}
|
||||
// Collabora antwortet mit dem PDF selbst, nicht mit einer Auskunft.
|
||||
return readPDF(resp.Body)
|
||||
}
|
||||
|
||||
// ── OnlyOffice / Euro-Office ────────────────────────────────────────────────
|
||||
|
||||
type convertResponse struct {
|
||||
FileURL string `json:"fileUrl"`
|
||||
FileType string `json:"fileType"`
|
||||
Percent int `json:"percent"`
|
||||
EndConvert bool `json:"endConvert"`
|
||||
// Error ist eine Zahl, keine Meldung: -8 heißt „Signatur fehlt oder ist
|
||||
// falsch", -4 „Quelle nicht erreichbar". Die Zahl steht deshalb im
|
||||
// Fehlertext, sonst sucht man im Log des anderen Servers.
|
||||
Error int `json:"error"`
|
||||
}
|
||||
|
||||
func (c *Client) convertOnlyOffice(ctx context.Context, ext, key, name, src string) ([]byte, error) {
|
||||
if src == "" {
|
||||
return nil, fmt.Errorf("convert: no source address for the document server")
|
||||
}
|
||||
payload := map[string]any{
|
||||
// Synchron: Die Umwandlung dauert Sekunden, und ein zweiter Aufruf zum
|
||||
// Nachfragen wäre ein Zustand mehr, den jemand verwalten muss.
|
||||
"async": false,
|
||||
"filetype": strings.TrimPrefix(ext, "."),
|
||||
"key": key,
|
||||
"outputtype": "pdf",
|
||||
"title": name,
|
||||
"url": src,
|
||||
}
|
||||
if c.jwtSecret != "" {
|
||||
// Die Signatur trägt genau die Nutzlast, die auch gesendet wird –
|
||||
// gemessen: ohne sie antwortet der Server `{"error":-8}`.
|
||||
payload["token"] = signJWT(c.jwtSecret, payload)
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/ConvertService.ashx", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Ohne diesen Kopf antwortet der Server in XML.
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("convert: document server answered %d", resp.StatusCode)
|
||||
}
|
||||
var out convertResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("convert: unreadable answer: %w", err)
|
||||
}
|
||||
if out.Error != 0 {
|
||||
return nil, fmt.Errorf("convert: document server error %d", out.Error)
|
||||
}
|
||||
if !out.EndConvert || out.FileURL == "" {
|
||||
return nil, fmt.Errorf("convert: document server did not finish (%d%%)", out.Percent)
|
||||
}
|
||||
|
||||
// **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** Sie kommt aus
|
||||
// der Antwort eines anderen Servers, und wir würden sie gleich abrufen –
|
||||
// das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf
|
||||
// 169.254.169.254 würde. Der Wähler des Transports lässt ohnehin nur
|
||||
// Adressen aus OFFICE_ALLOWED_HOSTS zu; hier steht die Prüfung trotzdem
|
||||
// noch einmal ausdrücklich, weil eine klare Meldung mehr wert ist als ein
|
||||
// abgewiesener Verbindungsversuch tief im Netzstapel.
|
||||
if err := c.sameHostAsServer(out.FileURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
get, err := http.NewRequestWithContext(ctx, http.MethodGet, out.FileURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res2, err := c.http.Do(get)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch result: %w", err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch result: answered %d", res2.StatusCode)
|
||||
}
|
||||
return readPDF(res2.Body)
|
||||
}
|
||||
|
||||
// sameHostAsServer prüft, dass eine Adresse auf denselben Rechner zeigt wie
|
||||
// der Office-Server selbst.
|
||||
func (c *Client) sameHostAsServer(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: unreadable result address")
|
||||
}
|
||||
base, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: OFFICE_URL is not a valid address")
|
||||
}
|
||||
if !strings.EqualFold(u.Hostname(), base.Hostname()) {
|
||||
return fmt.Errorf("convert: result address points elsewhere (%q), refused", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── gemeinsam ────────────────────────────────────────────────────────────────
|
||||
|
||||
// maxPDFBytes begrenzt, was als Ergebnis angenommen wird. Ohne Grenze
|
||||
// bestimmte der andere Server, wie viel Speicher wir belegen.
|
||||
const maxPDFBytes = 256 << 20
|
||||
|
||||
func readPDF(r io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxPDFBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read result: %w", err)
|
||||
}
|
||||
if len(data) > maxPDFBytes {
|
||||
return nil, fmt.Errorf("read result: converted file is too large")
|
||||
}
|
||||
// Kein Vertrauen auf den Content-Type: Collabora liefert
|
||||
// `application/octet-stream`. Die ersten Bytes sind die Auskunft, die
|
||||
// zählt – und sie verhindert, dass eine Fehlerseite als PDF abgelegt wird.
|
||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
return nil, fmt.Errorf("read result: answer is not a PDF")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// signJWT baut ein HS256-Token über die Nutzlast.
|
||||
//
|
||||
// Von Hand statt mit einer Bibliothek: Es sind zwölf Zeilen, und die einzige
|
||||
// Alternative wäre eine weitere Abhängigkeit für einen einzigen Aufruf.
|
||||
func signJWT(secret string, payload map[string]any) string {
|
||||
claims := make(map[string]any, len(payload)+1)
|
||||
for k, v := range payload {
|
||||
claims[k] = v
|
||||
}
|
||||
claims["exp"] = time.Now().Add(jwtLifetime).Unix()
|
||||
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
head := enc(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||
body := enc(claims)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(head + "." + body))
|
||||
return head + "." + body + "." +
|
||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
|
||||
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
|
||||
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
|
||||
|
||||
func TestSignJWTIsVerifiable(t *testing.T) {
|
||||
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
|
||||
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(parts[0] + "." + parts[1]))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if parts[2] != want {
|
||||
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
|
||||
}
|
||||
|
||||
head, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Kopf nicht dekodierbar: %v", err)
|
||||
}
|
||||
var h map[string]string
|
||||
if err := json.Unmarshal(head, &h); err != nil {
|
||||
t.Fatalf("Kopf ist kein JSON: %v", err)
|
||||
}
|
||||
if h["alg"] != "HS256" {
|
||||
t.Errorf("alg = %q, erwartet HS256", h["alg"])
|
||||
}
|
||||
|
||||
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(body, &claims); err != nil {
|
||||
t.Fatalf("Nutzlast ist kein JSON: %v", err)
|
||||
}
|
||||
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
|
||||
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
|
||||
}
|
||||
// Ohne Ablauf gälte ein abgefangenes Token für immer.
|
||||
if _, ok := claims["exp"]; !ok {
|
||||
t.Error("exp fehlt – das Token liefe nie ab")
|
||||
}
|
||||
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
|
||||
if _, ok := claims["token"]; ok {
|
||||
t.Error("die Signatur signiert sich selbst mit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPDFRejectsNonPDF(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
|
||||
"leer": "",
|
||||
"JSON-Fehler": `{"error":-4}`,
|
||||
"fast ein PDF": "%PD",
|
||||
}
|
||||
for name, body := range cases {
|
||||
if _, err := readPDF(strings.NewReader(body)); err == nil {
|
||||
t.Errorf("%s wurde als PDF angenommen", name)
|
||||
}
|
||||
}
|
||||
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
|
||||
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
|
||||
t.Errorf("echtes PDF abgelehnt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
|
||||
c := &Client{baseURL: "http://office:80"}
|
||||
|
||||
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
|
||||
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
|
||||
foreign := []string{
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost:8080/api/v1/admin/users",
|
||||
"http://office.evil.example.com/x.pdf",
|
||||
"file:///etc/passwd",
|
||||
"://kaputt",
|
||||
}
|
||||
for _, raw := range foreign {
|
||||
if err := c.sameHostAsServer(raw); err == nil {
|
||||
t.Errorf("fremde Adresse angenommen: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ok := range []string{
|
||||
"http://office/cache/files/x.pdf",
|
||||
"http://office:80/cache/files/x.pdf",
|
||||
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
|
||||
} {
|
||||
if err := c.sameHostAsServer(ok); err != nil {
|
||||
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`Rechnung "2026".docx`: "Rechnung 2026",
|
||||
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
|
||||
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
|
||||
".odt": ".odt",
|
||||
"": "document",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFilename(in); got != want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,7 @@ type Client struct {
|
||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||
// keinem Browser.
|
||||
publicBase string
|
||||
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
|
||||
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
|
||||
jwtSecret string
|
||||
http *http.Client
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
actions map[string]string // ".docx|edit" -> urlsrc
|
||||
@@ -65,7 +62,7 @@ type Client struct {
|
||||
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
||||
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
||||
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
||||
@@ -99,7 +96,6 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
|
||||
provider: provider,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
publicBase: strings.TrimRight(publicURL, "/"),
|
||||
jwtSecret: jwtSecret,
|
||||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||
}, nil
|
||||
}
|
||||
@@ -128,15 +124,33 @@ func (c *Client) Provider() string { return c.provider }
|
||||
//
|
||||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Am Finger zuerst die Handy-Fassung.
|
||||
//
|
||||
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
|
||||
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
|
||||
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
|
||||
// Schirme gebaute Oberfläche. Aether hat bisher immer die
|
||||
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
|
||||
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
|
||||
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
|
||||
// wir selbst falsch bestellt hatten.
|
||||
//
|
||||
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
|
||||
// Eintrag, und es bleibt beim bisherigen Verhalten.
|
||||
order := []string{"edit", "view"}
|
||||
if !write {
|
||||
switch {
|
||||
case mobile && write:
|
||||
order = []string{"mobileEdit", "edit", "mobileView", "view"}
|
||||
case mobile:
|
||||
order = []string{"mobileView", "view", "mobileEdit", "edit"}
|
||||
case !write:
|
||||
order = []string{"view", "edit"}
|
||||
}
|
||||
for _, action := range order {
|
||||
|
||||
@@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
write := access.Permission == acl.PermWrite
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
||||
// Finger oder Maus? Das entscheidet die Oberfläche, nicht der Server:
|
||||
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus.
|
||||
// Deshalb fragen wir nicht die Kennung des Browsers, sondern lassen die
|
||||
// Anwendung sagen, was sie über ihr Zeigegerät weiß.
|
||||
mobile := r.URL.Query().Get("mobile") == "1"
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write, mobile)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
|
||||
//
|
||||
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
|
||||
// bleibt einen Knopfdruck entfernt.
|
||||
//
|
||||
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
|
||||
//
|
||||
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
|
||||
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
|
||||
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
|
||||
// Frage des Aufräumens, sondern des Schlüssels.
|
||||
//
|
||||
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
|
||||
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
|
||||
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
|
||||
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
|
||||
//
|
||||
// ── Was das für die Platte heißt ────────────────────────────────────────────
|
||||
//
|
||||
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
|
||||
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
|
||||
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
|
||||
// den Ordner – beides ist nötig, und beides einzeln geprüft.
|
||||
|
||||
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
|
||||
// ebenfalls kennen müssen.
|
||||
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
|
||||
|
||||
// PreviewPrefix ist der Ordner, den der GC durchgeht.
|
||||
const PreviewPrefix = "previews"
|
||||
|
||||
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
|
||||
//
|
||||
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
|
||||
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
|
||||
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
|
||||
const maxPreviewSource = 100 << 20
|
||||
|
||||
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
|
||||
//
|
||||
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
|
||||
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
|
||||
// Umwandlung, nicht die Richtigkeit.
|
||||
var previewLocks sync.Map // fileID -> *sync.Mutex
|
||||
|
||||
func lockPreview(id string) func() {
|
||||
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := m.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// GET /api/v1/files/{id}/preview.pdf
|
||||
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.Enabled() || !h.client.CanConvert() {
|
||||
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
|
||||
return
|
||||
}
|
||||
f, userID, ok := h.resolveSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !Editable(f.Name) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
|
||||
return
|
||||
}
|
||||
if f.Size > maxPreviewSource {
|
||||
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
|
||||
// Editor anbieten soll oder einen Fehler zeigt.
|
||||
response.Error(w, http.StatusRequestEntityTooLarge,
|
||||
"this document is too large to be shown as a PDF")
|
||||
return
|
||||
}
|
||||
|
||||
unlock := lockPreview(f.ID.String())
|
||||
defer unlock()
|
||||
|
||||
key := PreviewKey(f.ID.String())
|
||||
have, err := h.files.PreviewVersion(r.Context(), f.ID)
|
||||
if err != nil && !errors.Is(err, files.ErrNotFound) {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
if have == f.Version {
|
||||
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
|
||||
defer rc.Close()
|
||||
servePDF(w, f.Name, f.Version)
|
||||
_, _ = io.Copy(w, rc)
|
||||
return
|
||||
}
|
||||
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
|
||||
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
|
||||
// sie neu zu erzeugen.
|
||||
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
|
||||
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
|
||||
}
|
||||
|
||||
pdf, err := h.convert(r.Context(), f, userID)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("office preview: Umwandlung fehlgeschlagen")
|
||||
response.Error(w, http.StatusBadGateway, "the document could not be converted")
|
||||
return
|
||||
}
|
||||
|
||||
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
|
||||
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
|
||||
// ohne es zu merken.
|
||||
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
|
||||
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
|
||||
}
|
||||
|
||||
servePDF(w, f.Name, f.Version)
|
||||
_, _ = w.Write(pdf)
|
||||
}
|
||||
|
||||
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
|
||||
// versteht.
|
||||
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
|
||||
ext := Extension(f.Name)
|
||||
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
|
||||
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
|
||||
// alte Dokument aus.
|
||||
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
|
||||
|
||||
switch h.client.Provider() {
|
||||
case "collabora":
|
||||
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
|
||||
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
|
||||
// Erreichbarkeit – und ein Token mehr in der Welt.
|
||||
rc, err := h.blobs.Get(ctx, f.StorageKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
|
||||
|
||||
default:
|
||||
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
|
||||
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
|
||||
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
|
||||
token, _ := h.signer.Issue(f.ID, userID, false)
|
||||
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
|
||||
h.wopiURL, f.ID, token)
|
||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func servePDF(w http.ResponseWriter, name string, version int) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
|
||||
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
|
||||
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
|
||||
// der Browser holt es nach einer Änderung also von selbst neu.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
|
||||
}
|
||||
|
||||
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
|
||||
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
|
||||
if i := strings.LastIndexByte(name, '.'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
|
||||
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
|
||||
//
|
||||
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
|
||||
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
|
||||
// Token für genau eine Datei.
|
||||
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
|
||||
// Datei gibt.
|
||||
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
return f, user.ID, true
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// checksumOf liest die Datei einmal ganz und liefert Prüfsum und Größe.
|
||||
//
|
||||
// Ohne sie ginge es schneller – aber eine Datei ohne Prüfsum in der
|
||||
// Datenbank ist eine Datei, deren Beschädigung beim Herunterladen niemand
|
||||
// bemerkt. Das ist der Punkt, an dem Aether sonst gerade nicht spart.
|
||||
// Der Anfang der Datei genügt zur Typerkennung – http.DetectContentType
|
||||
// sieht sich ohnehin nur die ersten 512 Bytes an.
|
||||
const sniffLen = 512
|
||||
|
||||
func (s *Scanner) checksumOf(ctx context.Context, key string) (sum string, size int64, head []byte, err error) {
|
||||
rc, err := s.storage.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
h := sha256.New()
|
||||
// Die Datei wird für die Prüfsumme ohnehin einmal ganz gelesen; den
|
||||
// Anfang dabei mitzunehmen kostet nichts und erspart einen zweiten Weg
|
||||
// über die Platte.
|
||||
kopf := &bytes.Buffer{}
|
||||
n, err := io.Copy(h, io.TeeReader(rc, &begrenzterSchreiber{buf: kopf, rest: sniffLen}))
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, kopf.Bytes(), nil
|
||||
}
|
||||
|
||||
// begrenzterSchreiber nimmt nur die ersten rest Bytes an und wirft den Rest
|
||||
// weg – ohne das läge bei einer 5-GB-Datei alles im Arbeitsspeicher.
|
||||
type begrenzterSchreiber struct {
|
||||
buf *bytes.Buffer
|
||||
rest int
|
||||
}
|
||||
|
||||
func (w *begrenzterSchreiber) Write(p []byte) (int, error) {
|
||||
if w.rest > 0 {
|
||||
n := min(w.rest, len(p))
|
||||
w.buf.Write(p[:n])
|
||||
w.rest -= n
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// mimeOf bestimmt den Dateityp – erst über die Endung, sonst über den
|
||||
// Inhalt.
|
||||
//
|
||||
// Der Umweg über den Inhalt ist nötig, nicht schmückend: Das Alpine-Abbild
|
||||
// bringt keine /etc/mime.types mit, und Gos eingebaute Liste kennt gerade
|
||||
// einmal ein Dutzend Endungen – .txt gehört nicht dazu. Ohne den zweiten
|
||||
// Anlauf bekäme jede Textdatei "application/octet-stream" und würde in der
|
||||
// Vorschau nicht angezeigt.
|
||||
//
|
||||
// Beim Hochladen fällt das nie auf, weil der Browser den Typ mitschickt.
|
||||
// Eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt.
|
||||
func mimeOf(name string, head []byte) string {
|
||||
if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); t != "" {
|
||||
return ohneParameter(t)
|
||||
}
|
||||
if len(head) > 0 {
|
||||
return ohneParameter(http.DetectContentType(head))
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// "text/plain; charset=utf-8" → "text/plain", so wie es der Upload-Weg auch
|
||||
// speichert.
|
||||
func ohneParameter(t string) string {
|
||||
if i := strings.Index(t, ";"); i > 0 {
|
||||
return strings.TrimSpace(t[:i])
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resolveOwner findet heraus, wem eine gefundene Datei gehört: das Konto
|
||||
// mit diesem Namen, oder die Gruppe mit dieser Kennung.
|
||||
func (s *Scanner) resolveOwner(ctx context.Context, root, owner string) (ownerID, groupID *uuid.UUID, err error) {
|
||||
if root == "groups" {
|
||||
id, err := uuid.Parse(owner)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("keine Gruppenkennung: %q", owner)
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM groups WHERE id = $1)`, id).Scan(&exists); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("Gruppe %s gibt es nicht", owner)
|
||||
}
|
||||
return nil, &id, nil
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE username = $1`, owner).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, fmt.Errorf("Konto %q gibt es nicht", owner)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &id, nil, nil
|
||||
}
|
||||
|
||||
// ensureFolders legt die Ordnerkette an, in der die Datei liegt, und liefert
|
||||
// den innersten Ordner. Ein Ordner, den es schon gibt, wird benutzt.
|
||||
func (s *Scanner) ensureFolders(ctx context.Context, ownerID, groupID *uuid.UUID, folderPath string) (*uuid.UUID, error) {
|
||||
if folderPath == "" {
|
||||
return nil, nil // liegt direkt im Wurzelverzeichnis
|
||||
}
|
||||
var parent *uuid.UUID
|
||||
for _, name := range strings.Split(folderPath, "/") {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
// COALESCE statt zweier Abfragen: Ein Ordner hängt entweder an einem
|
||||
// Konto oder an einer Gruppe, nie an beidem.
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id FROM folders
|
||||
WHERE COALESCE(owner_id, group_id) = COALESCE($1::uuid, $2::uuid)
|
||||
AND name = $3
|
||||
AND parent_id IS NOT DISTINCT FROM $4`,
|
||||
ownerID, groupID, name, parent,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO folders (owner_id, group_id, parent_id, name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
ownerID, groupID, parent, name,
|
||||
).Scan(&id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ordner %q: %w", name, err)
|
||||
}
|
||||
copy := id
|
||||
parent = ©
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// adopt macht aus einer Datei auf der Platte einen Entry in Aether.
|
||||
func (s *Scanner) adopt(ctx context.Context, root, owner, folder, name, key string) error {
|
||||
ownerID, groupID, err := s.resolveOwner(ctx, root, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := s.ensureFolders(ctx, ownerID, groupID, folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, size, head, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ownerID, groupID, folderID, name, mimeOf(name, head), size, sum, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Entry anlegen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// correct übernimmt Größe und Prüfsum einer von außen ersetzten Datei.
|
||||
func (s *Scanner) correct(ctx context.Context, id uuid.UUID, key string) error {
|
||||
sum, size, _, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(ctx,
|
||||
`UPDATE files SET size = $2, checksum = $3, updated_at = now() WHERE id = $1`,
|
||||
id, size, sum)
|
||||
return err
|
||||
}
|
||||
|
||||
// remove räumt einen Entry weg, dessen Datei nicht mehr exists ist – und mit
|
||||
// ihm alles, was nur wegen dieser Datei existierte: frühere Fassungen,
|
||||
// Vorschaubild, umgewandelte Ansicht.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: erst die Schlüssel der früheren Fassungen
|
||||
// sample, dann die Zeile löschen (die Kaskade nimmt die Versionszeilen mit),
|
||||
// dann die Bytes. Andersherum bliebe bei einem Abbruch eine Zeile stehen, die
|
||||
// auf nichts mehr zeigt – also genau das, was hier aufgeräumt werden soll.
|
||||
func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error {
|
||||
rows, err := s.db.Query(ctx, `SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("frühere Fassungen lesen: %w", err)
|
||||
}
|
||||
var versionKeys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
versionKeys = append(versionKeys, k)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("Entry remove: %w", err)
|
||||
}
|
||||
|
||||
// Ab hier ist der Entry weg; was jetzt noch scheitert, ist ein Rest auf
|
||||
// der Platte, den der GC beim nächsten Lauf ohnehin einsammelt.
|
||||
for _, k := range versionKeys {
|
||||
_ = s.storage.Delete(ctx, k)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Der Abgleich ist das einzige Stück Aether, das **hart** löscht:
|
||||
// `DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang über
|
||||
// die Platte nicht gesehen wurde.
|
||||
//
|
||||
// Der Ernstfall ist nicht ausgedacht: `LocalFS.Walk` gibt für ein nicht
|
||||
// vorhandenes Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam.
|
||||
// Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit
|
||||
// `?anwenden=true` löschte den ganzen Bestand aus der Datenbank.
|
||||
//
|
||||
// Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der
|
||||
// gefährlicheren von beiden Stellen.
|
||||
|
||||
func TestNotbremseBeiLeeremSpeicher(t *testing.T) {
|
||||
// Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar.
|
||||
grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false)
|
||||
if grund == "" {
|
||||
t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen")
|
||||
}
|
||||
if uebersteuerbar {
|
||||
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||
}
|
||||
// Auch mit ausdruecklicher Bestaetigung nicht.
|
||||
if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" {
|
||||
t.Fatal("auch mit Bestaetigung muss die Notbremse halten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeereInstallationDarfDurch(t *testing.T) {
|
||||
// Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu
|
||||
// verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie
|
||||
// abgleichen.
|
||||
if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" {
|
||||
t.Fatalf("leere Installation darf nicht bremsen: %s", grund)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMengenbremseLaesstSichBestaetigen(t *testing.T) {
|
||||
// Mehr als die Hälfte auf einmal: erst nachfragen.
|
||||
grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false)
|
||||
if grund == "" {
|
||||
t.Fatal("mehr als die Haelfte muss nachfragen")
|
||||
}
|
||||
if !uebersteuerbar {
|
||||
t.Fatal("die Mengenbremse muss sich bestaetigen lassen")
|
||||
}
|
||||
if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" {
|
||||
t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) {
|
||||
// Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die
|
||||
// Funktion unbrauchbar – und jemand baute die Bremse wieder aus.
|
||||
for _, f := range []struct {
|
||||
gescannt, lebende, fallen int
|
||||
}{
|
||||
{1000, 1000, 1},
|
||||
{1000, 1000, 17},
|
||||
{1000, 1000, 500}, // genau die Hälfte: noch erlaubt
|
||||
{1, 1, 0},
|
||||
} {
|
||||
if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" {
|
||||
t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein
|
||||
// Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch
|
||||
// da sein.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/
|
||||
func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var nutzer uuid.UUID
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8],
|
||||
).Scan(&nutzer); err != nil {
|
||||
t.Fatalf("konto: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`,
|
||||
nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(),
|
||||
); err != nil {
|
||||
t.Fatalf("datei: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das
|
||||
// sieht der Server, wenn der Bind-Mount nicht hochkam.
|
||||
wurzel := t.TempDir()
|
||||
st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp"))
|
||||
if err != nil {
|
||||
t.Fatalf("speicher: %v", err)
|
||||
}
|
||||
|
||||
scanner := New(pool, files.NewStore(pool), st)
|
||||
bericht, err := scanner.Apply(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("anwenden: %v", err)
|
||||
}
|
||||
|
||||
if !bericht.Aborted {
|
||||
t.Fatal("der Lauf haette abbrechen muessen")
|
||||
}
|
||||
if bericht.Removed != 0 {
|
||||
t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed)
|
||||
}
|
||||
if bericht.Overridable {
|
||||
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||
}
|
||||
|
||||
var uebrig int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||
t.Fatalf("zaehlen: %v", err)
|
||||
}
|
||||
if uebrig != 5 {
|
||||
t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig)
|
||||
}
|
||||
|
||||
// Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen.
|
||||
if _, err := scanner.Apply(ctx, true); err != nil {
|
||||
t.Fatalf("anwenden mit Bestaetigung: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||
t.Fatalf("zaehlen: %v", err)
|
||||
}
|
||||
if uebrig != 5 {
|
||||
t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung,
|
||||
// die der GC nicht abdeckt.
|
||||
//
|
||||
// Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr
|
||||
// gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für
|
||||
// Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer
|
||||
// Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar
|
||||
// – und der GC hält sie nach 24 Stunden für Reste.
|
||||
//
|
||||
// Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf,
|
||||
// der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen.
|
||||
// Hier geht es um Dateien.
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die
|
||||
// Antwort nicht ins Uferlose wächst.
|
||||
const (
|
||||
maxSamples = 200
|
||||
maxDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank.
|
||||
// Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen
|
||||
// Verzeichnis sie liegt.
|
||||
KindNew Kind = "new"
|
||||
// KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt
|
||||
// Vorschaubild und früheren Fassungen.
|
||||
KindOrphaned Kind = "orphaned"
|
||||
// KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei
|
||||
// von außen ersetzt. Apply übernimmt Größe und Prüfsumme.
|
||||
KindChanged Kind = "changed"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Kind Kind `json:"kind"`
|
||||
StorageKey string `json:"storage_key"`
|
||||
// Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte.
|
||||
Path string `json:"path"`
|
||||
Owner string `json:"owner"`
|
||||
Size int64 `json:"size"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
New int `json:"new"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
Changed int `json:"changed"`
|
||||
Scanned int `json:"scanned"`
|
||||
Samples []Entry `json:"samples"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
// AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true"
|
||||
// ohne Grund lässt genau die Frage offen, die man dann hat.
|
||||
AbortReason string `json:"abort_reason,omitempty"`
|
||||
// Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt
|
||||
// sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen
|
||||
// Knopf anbieten, der nichts bewirkt.
|
||||
Overridable bool `json:"overridable,omitempty"`
|
||||
|
||||
// Nur bei Apply gefüllt.
|
||||
Adopted int `json:"adopted,omitempty"`
|
||||
Removed int `json:"removed,omitempty"`
|
||||
Corrected int `json:"corrected,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
db *pgxpool.Pool
|
||||
store *files.Store
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner {
|
||||
return &Scanner{db: db, store: store, storage: st}
|
||||
}
|
||||
|
||||
// known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über
|
||||
// den Ablagepfad auffindbar.
|
||||
type known struct {
|
||||
id uuid.UUID
|
||||
size int64
|
||||
deleted bool
|
||||
seen bool
|
||||
}
|
||||
|
||||
func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read known files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]*known, 1024)
|
||||
for rows.Next() {
|
||||
var k known
|
||||
var key string
|
||||
if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil {
|
||||
return nil, fmt.Errorf("scan file row: %w", err)
|
||||
}
|
||||
row := k
|
||||
out[key] = &row
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// splitKey liest aus einem Ablagepfad, wohin die Datei gehört:
|
||||
//
|
||||
// users/{name}/home/{ordner...}/{datei}
|
||||
// groups/{id}/home/{ordner...}/{datei}
|
||||
//
|
||||
// Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp)
|
||||
// ist kein Nutzerinhalt und wird nicht angefasst.
|
||||
func splitKey(key string) (root, owner, folderPath, name string, ok bool) {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) < 4 {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[0] != "users" && parts[0] != "groups" {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[2] != "home" {
|
||||
return "", "", "", "", false // .trash und .versions gehören dem System
|
||||
}
|
||||
name = parts[len(parts)-1]
|
||||
folderPath = strings.Join(parts[3:len(parts)-1], "/")
|
||||
return parts[0], parts[1], folderPath, name, name != ""
|
||||
}
|
||||
|
||||
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
||||
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) }
|
||||
|
||||
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
||||
// Einträge.
|
||||
// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse.
|
||||
func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) {
|
||||
return s.run(ctx, true, trotzdem)
|
||||
}
|
||||
|
||||
func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
||||
defer cancel()
|
||||
|
||||
known, err := s.knownFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null,
|
||||
// und kein Aufrufer braucht dafür einen Sonderfall.
|
||||
rep := &Report{Samples: []Entry{}}
|
||||
sample := func(e Entry) {
|
||||
if len(rep.Samples) < maxSamples {
|
||||
rep.Samples = append(rep.Samples, e)
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range []string{"users/", "groups/"} {
|
||||
err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
if ctx.Err() != nil {
|
||||
rep.Aborted = true
|
||||
return ctx.Err()
|
||||
}
|
||||
root, owner, folder, name, ok := splitKey(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rep.Scanned++
|
||||
|
||||
if row, exists := known[key]; exists {
|
||||
row.seen = true
|
||||
if row.deleted {
|
||||
return nil // liegt im Papierkorb – kein Widerspruch
|
||||
}
|
||||
if row.size != info.Size {
|
||||
rep.Changed++
|
||||
e := Entry{Kind: KindChanged, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.correct(ctx, row.id, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Corrected++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rep.New++
|
||||
e := Entry{Kind: KindNew, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.adopt(ctx, root, owner, folder, name, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Adopted++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
||||
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
||||
// pauschal für verwaist zu erklären.
|
||||
// **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in
|
||||
// der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die
|
||||
// Hälfte schon weg ist, und eine Bremse käme zu spät.
|
||||
type waise struct {
|
||||
key string
|
||||
id uuid.UUID
|
||||
}
|
||||
var waisen []waise
|
||||
var lebende int
|
||||
for key, row := range known {
|
||||
if !row.deleted {
|
||||
lebende++
|
||||
}
|
||||
if row.seen || row.deleted {
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, ok := splitKey(key); !ok {
|
||||
if _, err := s.storage.Stat(ctx, key); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
waisen = append(waisen, waise{key: key, id: row.id})
|
||||
}
|
||||
rep.Orphaned = len(waisen)
|
||||
|
||||
if apply {
|
||||
if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" {
|
||||
rep.Aborted = true
|
||||
rep.AbortReason = grund
|
||||
rep.Overridable = uebersteuerbar
|
||||
log.Ctx(ctx).Error().
|
||||
Int("scanned", rep.Scanned).
|
||||
Int("lebende_eintraege", lebende).
|
||||
Int("wuerden_fallen", len(waisen)).
|
||||
Str("grund", grund).
|
||||
Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus")
|
||||
// Die Waisen trotzdem in die Stichprobe, damit man im Bericht
|
||||
// sieht, worum es gegangen wäre.
|
||||
for _, o := range waisen {
|
||||
sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key})
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range waisen {
|
||||
e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}
|
||||
if apply {
|
||||
if err := s.remove(ctx, o.id); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Removed++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── Die Bremsen ──────────────────────────────────────────────────────────────
|
||||
|
||||
// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr
|
||||
// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist
|
||||
// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner
|
||||
// entfernt hat, bestätigt einmal und kommt durch.
|
||||
const mengenAnteil = 2
|
||||
|
||||
// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe-
|
||||
// wert heißt: ja.
|
||||
//
|
||||
// **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen
|
||||
// Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben
|
||||
// denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC
|
||||
// löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt
|
||||
// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die
|
||||
// Datei gab. Die gefährlichere Seite war die ungesicherte.
|
||||
//
|
||||
// Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht
|
||||
// vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam,
|
||||
// oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede
|
||||
// Datei als verwaist.
|
||||
func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) {
|
||||
// **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen,
|
||||
// obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der
|
||||
// verschwunden ist, das ist ein Speicher, der nicht da war.
|
||||
if gescannt == 0 && lebende > 0 {
|
||||
return "auf der Platte wurde keine einzige Datei gefunden, " +
|
||||
"obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " +
|
||||
"nicht eingehaengt. Es wurde nichts geloescht.", false
|
||||
}
|
||||
|
||||
// **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die
|
||||
// Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor.
|
||||
if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende {
|
||||
return fmt.Sprintf(
|
||||
"%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+
|
||||
"Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.",
|
||||
wuerdenFallen, lebende), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,33 +1,58 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
store *Store
|
||||
fileStore *files.Store
|
||||
storage storage.Storage
|
||||
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 {
|
||||
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor}
|
||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler {
|
||||
return &Handler{
|
||||
store: store, fileStore: fileStore, storage: st, auditor: auditor,
|
||||
acl: aclStore,
|
||||
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); err != nil {
|
||||
// **Prinzipal statt Konto.** Vorher stand hier `user.ID` – damit liess sich
|
||||
// eine Datei in einem Gruppenordner nicht per Link teilen, auch nicht von
|
||||
// einem Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete
|
||||
// „file not found", was zwar sicher, aber schlicht falsch war: Der
|
||||
// Empfänger eines Links sieht ohnehin nur die Datei, nie den Ordner
|
||||
// darum.
|
||||
//
|
||||
// **Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst,
|
||||
// die Datei öffentlich zu machen. Wer sie nur geliehen lesen darf, soll
|
||||
// sie nicht weiterreichen können.
|
||||
zugriff, ok := h.resolve(w, r, fileID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, zugriff.OwnerID); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
@@ -60,6 +99,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var expiresAt *time.Time
|
||||
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
||||
// Obergrenze, weil `time.Duration` in Nanosekunden rechnet: Ab etwa
|
||||
// 2,6 Millionen Stunden läuft int64 über, die Dauer wird **negativ**,
|
||||
// und der Link ist in dem Moment abgelaufen, in dem er entsteht – ohne
|
||||
// Fehlermeldung. Wer eine unsinnige Zahl schickt, soll das erfahren,
|
||||
// statt einen toten Link zu bekommen.
|
||||
if *body.ExpiresIn > maxExpiryHours {
|
||||
response.Error(w, http.StatusBadRequest,
|
||||
"expires_in_hours is too large (at most 10 years)")
|
||||
return
|
||||
}
|
||||
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
@@ -147,11 +196,19 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
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{
|
||||
"link": link,
|
||||
"file_name": f.Name,
|
||||
"file_size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
"has_password": link.HasPassword,
|
||||
"expires_at": link.ExpiresAt,
|
||||
"download_count": link.DownloadCount,
|
||||
"created_at": link.CreatedAt,
|
||||
"file_name": f.Name,
|
||||
"file_size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,15 +226,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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 {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
||||
if !ok {
|
||||
h.limiter.Fail(clientIP)
|
||||
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(clientIP)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
_, _ = io.Copy(w, rc)
|
||||
// Kein Typ-Raten. `attachment` trägt die Hauptlast, aber der angemeldete
|
||||
// Weg setzt beides – und dies ist der Weg nach draussen.
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// **Dieselbe Prüfung wie beim angemeldeten Download.** Bis zum 25.09.2026
|
||||
// lief hier ein blankes io.Copy: Wer die Datei über einen Link bekam,
|
||||
// bekam sie ungeprüft. Ausgerechnet auf dem Weg, auf dem sie das Haus
|
||||
// verlässt – und wo der Empfänger es am wenigsten merken kann.
|
||||
files.ServeVerified(r.Context(), w, rc, f.Checksum, files.VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// resolve klärt, in wessen Namen die Datei liegt, und verlangt Schreibrecht.
|
||||
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, fileID uuid.UUID) (acl.Access, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if h.acl == nil {
|
||||
return acl.Access{OwnerID: user.ID, Permission: acl.PermWrite}, true
|
||||
}
|
||||
a, err := h.acl.ResolveFile(r.Context(), user.ID, fileID)
|
||||
switch {
|
||||
case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess):
|
||||
// 404 statt 403, wie überall: Ein „verboten" verriete, dass es die ID
|
||||
// gibt.
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return acl.Access{}, false
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return acl.Access{}, false
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
response.Error(w, http.StatusForbidden, "you only have read access to this file")
|
||||
return acl.Access{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Ein Freigabelink ist der einzige Weg, auf dem eine Datei dieses Haus ohne
|
||||
// Anmeldung verlässt. Wer das Token hat, ist drin – also entscheidet sich hier
|
||||
// alles an zwei Fragen: Gilt der Link noch, und gehört er dem, der ihn
|
||||
// anfasst?
|
||||
//
|
||||
// Beides steckt nicht in einer if-Zeile, sondern in WHERE-Klauseln und einem
|
||||
// Zeitvergleich. Ein Fehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
// läuft, sie liefert nur zu viel.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/sharing/
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("konto %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||
owner, name, "k/"+uuid.NewString(),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("datei %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestAbgelaufenerLinkGiltNichtMehr(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
gestern := time.Now().Add(-1 * time.Hour)
|
||||
abgelaufen, err := s.Create(ctx, f, anna, "", &gestern)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, abgelaufen.Token); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("abgelaufener Link muss ErrExpired liefern, kam: %v", err)
|
||||
}
|
||||
|
||||
// Gegenprobe – sonst prüfte der Test nur, dass irgendetwas fehlschlägt.
|
||||
morgen := time.Now().Add(1 * time.Hour)
|
||||
gueltig, err := s.Create(ctx, f, anna, "", &morgen)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, gueltig.Token); err != nil {
|
||||
t.Fatalf("gültiger Link muss durchgehen, kam: %v", err)
|
||||
}
|
||||
|
||||
// Ohne Ablauf gilt er unbegrenzt.
|
||||
ewig, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, ewig.Token); err != nil {
|
||||
t.Fatalf("Link ohne Ablauf muss gelten, kam: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFremderLinkLaesstSichNichtLoeschen(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
bert := konto(t, pool, ctx, "bert")
|
||||
f := datei(t, pool, ctx, anna, "Gehaltsabrechnung.pdf")
|
||||
|
||||
link, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
// Bert kennt die ID – etwa, weil sie einmal irgendwo auftauchte.
|
||||
if err := s.Delete(ctx, link.ID, bert); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Bert darf Annas Link nicht löschen, kam: %v", err)
|
||||
}
|
||||
// Und er ist wirklich noch da.
|
||||
if _, _, err := s.GetByToken(ctx, link.Token); err != nil {
|
||||
t.Fatalf("Link muss nach dem Fehlversuch noch gelten, kam: %v", err)
|
||||
}
|
||||
// Anna darf.
|
||||
if err := s.Delete(ctx, link.ID, anna); err != nil {
|
||||
t.Fatalf("Anna muss ihren Link löschen dürfen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, link.Token); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("gelöschter Link muss ErrNotFound liefern, kam: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeZeigtNurEigeneLinks(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
bert := konto(t, pool, ctx, "bert")
|
||||
fa := datei(t, pool, ctx, anna, "Annas.pdf")
|
||||
fb := datei(t, pool, ctx, bert, "Berts.pdf")
|
||||
|
||||
if _, err := s.Create(ctx, fa, anna, "", nil); err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, err := s.Create(ctx, fb, bert, "", nil); err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
annas, err := s.ListByOwner(ctx, anna)
|
||||
if err != nil {
|
||||
t.Fatalf("liste: %v", err)
|
||||
}
|
||||
if len(annas) != 1 {
|
||||
t.Fatalf("Anna soll genau einen Link sehen, sah %d", len(annas))
|
||||
}
|
||||
if annas[0].FileID != fa {
|
||||
t.Fatalf("Anna sieht einen fremden Link: %v", annas[0].FileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVerlaesstDenSpeicherNichtVersehentlich(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Vertrag.pdf")
|
||||
|
||||
mitPw, err := s.Create(ctx, f, anna, "argon2-hash-platzhalter", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
// Das Anlegen gibt den Hash **nicht** zurück, nur die Tatsache.
|
||||
if !mitPw.HasPassword {
|
||||
t.Fatal("HasPassword muss true sein")
|
||||
}
|
||||
|
||||
// Beim Abruf über das Token kommt er heraus – dort wird er gebraucht,
|
||||
// um das eingegebene Passwort zu prüfen. Aber nur dort.
|
||||
link, hash, err := s.GetByToken(ctx, mitPw.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("abrufen: %v", err)
|
||||
}
|
||||
if hash != "argon2-hash-platzhalter" {
|
||||
t.Fatalf("Hash muss beim Tokenabruf vorliegen, kam %q", hash)
|
||||
}
|
||||
if !link.HasPassword {
|
||||
t.Fatal("HasPassword muss auch beim Abruf true sein")
|
||||
}
|
||||
|
||||
// Die Liste für die Oberfläche kennt ihn nicht.
|
||||
liste, err := s.ListByOwner(ctx, anna)
|
||||
if err != nil {
|
||||
t.Fatalf("liste: %v", err)
|
||||
}
|
||||
if len(liste) != 1 || !liste[0].HasPassword {
|
||||
t.Fatalf("Liste soll den Link mit HasPassword zeigen, kam %+v", liste)
|
||||
}
|
||||
|
||||
// Ohne Passwort bleibt es false – sonst prüfte das oben nichts.
|
||||
ohnePw, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if ohnePw.HasPassword {
|
||||
t.Fatal("HasPassword muss ohne Passwort false sein")
|
||||
}
|
||||
if _, hash, _ := s.GetByToken(ctx, ohnePw.Token); hash != "" {
|
||||
t.Fatalf("ohne Passwort darf kein Hash kommen, kam %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIstNichtZuErraten(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
gesehen := map[string]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
l, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
// 24 Zufallsbytes, base64url ohne Auffüllen = 32 Zeichen. Wird das
|
||||
// jemals kürzer, ist das Token ratbar – und der Link ist das einzige
|
||||
// Geheimnis, das ihn schützt.
|
||||
if len(l.Token) != 32 {
|
||||
t.Fatalf("Token soll 32 Zeichen haben, hat %d: %q", len(l.Token), l.Token)
|
||||
}
|
||||
if gesehen[l.Token] {
|
||||
t.Fatalf("Token doppelt vergeben: %q", l.Token)
|
||||
}
|
||||
gesehen[l.Token] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestZaehlerZaehlt(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
link, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if link.DownloadCount != 0 {
|
||||
t.Fatalf("frischer Link soll bei 0 stehen, stand bei %d", link.DownloadCount)
|
||||
}
|
||||
s.IncrementDownloads(ctx, link.Token)
|
||||
s.IncrementDownloads(ctx, link.Token)
|
||||
|
||||
nach, _, err := s.GetByToken(ctx, link.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("abrufen: %v", err)
|
||||
}
|
||||
if nach.DownloadCount != 2 {
|
||||
t.Fatalf("Zähler soll 2 sein, ist %d", nach.DownloadCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package tags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Etiketten und Absender gehören einem Konto oder einer Gruppe. Dass ein
|
||||
// fremdes Etikett fremd **bleibt**, prüfte bis zum 12.09.2026 nichts nach –
|
||||
// und die Prüfung steckt hier nicht in einer if-Zeile, sondern in
|
||||
// WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
// läuft, sie liefert nur zu viel.
|
||||
//
|
||||
// Der teuerste Fall ist FilesWithTag: Es gibt **Dateinamen und Ordner**
|
||||
// heraus. Wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||
// fremden Unterlagen.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/tags/
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("konto %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||
owner, name, "k/"+uuid.NewString(),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("datei: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Ein fremdes Etikett bleibt fremd ────────────────────────────────────────
|
||||
|
||||
func TestFremdesEtikettBleibtFremd(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
a := konto(t, pool, ctx, "anna")
|
||||
b := konto(t, pool, ctx, "bert")
|
||||
|
||||
annas, err := s.Create(ctx, a, nil, "Steuer 2026", "#ff0000", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
t.Run("taucht nicht in Berts Liste auf", func(t *testing.T) {
|
||||
liste, err := s.List(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, tg := range liste {
|
||||
if tg.ID == annas.ID {
|
||||
t.Fatal("Bert sieht Annas Etikett")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht umbenennen", func(t *testing.T) {
|
||||
if _, err := s.Update(ctx, b, annas.ID, "Uebernommen", "#00ff00", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Update lieferte %v, erwartet ErrNotFound", err)
|
||||
}
|
||||
// Und der Name steht wirklich noch da – der Fehlercode allein sagt
|
||||
// nichts darüber, ob nebenbei geschrieben wurde.
|
||||
var name string
|
||||
_ = pool.QueryRow(ctx, `SELECT name FROM tags WHERE id = $1`, annas.ID).Scan(&name)
|
||||
if name != "Steuer 2026" {
|
||||
t.Fatalf("Name ist jetzt %q", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht löschen", func(t *testing.T) {
|
||||
if err := s.Delete(ctx, b, annas.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Delete lieferte %v, erwartet ErrNotFound", err)
|
||||
}
|
||||
var da bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1)`, annas.ID).Scan(&da)
|
||||
if !da {
|
||||
t.Fatal("Annas Etikett ist weg")
|
||||
}
|
||||
})
|
||||
|
||||
// Der teuerste Fall: Hier kämen Dateinamen und Ordner heraus.
|
||||
t.Run("Bert sieht die Dateien darunter nicht", func(t *testing.T) {
|
||||
f := datei(t, pool, ctx, a, "Gehaltsabrechnung.pdf")
|
||||
if err := s.SetForFile(ctx, f, a, a, []uuid.UUID{annas.ID}, nil); err != nil {
|
||||
t.Fatalf("etikettieren: %v", err)
|
||||
}
|
||||
// Anna sieht sie – sonst prüfte der nächste Teil eine leere Liste.
|
||||
meine, err := s.FilesWithTag(ctx, a, annas.ID)
|
||||
if err != nil || len(meine) != 1 {
|
||||
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d Treffer", err, len(meine))
|
||||
}
|
||||
fremde, err := s.FilesWithTag(ctx, b, annas.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesWithTag: %v", err)
|
||||
}
|
||||
if len(fremde) != 0 {
|
||||
t.Fatalf("Bert sieht %d fremde Dateien: %v", len(fremde), fremde[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht an seine eigene Datei hängen", func(t *testing.T) {
|
||||
f := datei(t, pool, ctx, b, "Berts Datei.pdf")
|
||||
err := s.SetForFile(ctx, f, b, b, []uuid.UUID{annas.ID}, nil)
|
||||
if !errors.Is(err, ErrWrongOwner) {
|
||||
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner", err)
|
||||
}
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Gruppen ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func gruppeMit(t *testing.T, pool *pgxpool.Pool, ctx context.Context, mitglieder ...uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
var g uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO groups (name) VALUES ($1) RETURNING id`, "probe-"+uuid.NewString()[:8],
|
||||
).Scan(&g)
|
||||
if err != nil {
|
||||
t.Fatalf("gruppe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, g) })
|
||||
for _, m := range mitglieder {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)`, g, m); err != nil {
|
||||
t.Fatalf("mitglied: %v", err)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestGruppenEtikettSiehtNurDieGruppe(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
drin := konto(t, pool, ctx, "drin")
|
||||
draussen := konto(t, pool, ctx, "draussen")
|
||||
g := gruppeMit(t, pool, ctx, drin)
|
||||
|
||||
tag, err := s.Create(ctx, drin, &g, "Buchhaltung", "#0000ff", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
sieht := func(user uuid.UUID) bool {
|
||||
liste, err := s.List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, x := range liste {
|
||||
if x.ID == tag.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !sieht(drin) {
|
||||
t.Fatal("das Mitglied sieht das Gruppen-Etikett nicht")
|
||||
}
|
||||
if sieht(draussen) {
|
||||
t.Fatal("ein Nichtmitglied sieht das Gruppen-Etikett")
|
||||
}
|
||||
|
||||
// Mitgliedschaft ersetzt die Freigabe: Wer die Gruppe verlässt, verliert
|
||||
// den Zugriff im selben Moment.
|
||||
if _, err := pool.Exec(ctx,
|
||||
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, g, drin); err != nil {
|
||||
t.Fatalf("austragen: %v", err)
|
||||
}
|
||||
if sieht(drin) {
|
||||
t.Fatal("nach dem Austritt weiterhin sichtbar")
|
||||
}
|
||||
if _, err := s.Update(ctx, drin, tag.ID, "Umbenannt", "#111111", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("nach dem Austritt noch änderbar: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Gruppen-Etikett gehört **nicht** an eine persönliche Datei – und das ist
|
||||
// keine Förmlichkeit, sondern die Stelle, an der die Rechte hängen.
|
||||
//
|
||||
// Der Anlass war ein Verdacht, der sich als falsch erwies: `List` bietet dem
|
||||
// Nutzer Gruppen-Etiketten mit an, `SetFieldsForFile` vergleicht aber gegen
|
||||
// den *Prinzipal der Datei*. Sieht nach Widerspruch aus. Ist keiner – die
|
||||
// Oberfläche filtert bereits (`TagDialog.tsx`: bei einer persönlichen Datei
|
||||
// nur Etiketten ohne group_id).
|
||||
//
|
||||
// **Warum die Ablehnung bleiben muss:** `FilesWithTag` prüft, ob der
|
||||
// Anfragende das *Etikett* sehen darf – aber **nicht**, ob er die *Datei*
|
||||
// sehen darf. Das ist nur deshalb harmlos, weil ein Etikett stets denselben
|
||||
// Prinzipal hat wie die Dateien darunter: ein persönliches nur an eigenen
|
||||
// Dateien, ein Gruppen-Etikett nur an Gruppendateien, die alle Mitglieder
|
||||
// ohnehin sehen.
|
||||
//
|
||||
// Fiele diese Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei –
|
||||
// und jedes Gruppenmitglied bekäme über die Etikettenansicht ihren Namen,
|
||||
// Ordner und ihre Größe zu sehen. Deshalb steht dieser Test hier: Er hält
|
||||
// nicht eine Fehlermeldung fest, sondern eine Absicherung.
|
||||
func TestGruppenEtikettGehoertNichtAnEinePersoenlicheDatei(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
u := konto(t, pool, ctx, "mitglied")
|
||||
g := gruppeMit(t, pool, ctx, u)
|
||||
|
||||
tag, err := s.Create(ctx, u, &g, "Gemeinsam", "#00ffff", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
f := datei(t, pool, ctx, u, "Privat.pdf")
|
||||
|
||||
// Prinzipal der Datei ist der Nutzer, nicht die Gruppe.
|
||||
if err := s.SetForFile(ctx, f, u, u, []uuid.UUID{tag.ID}, nil); !errors.Is(err, ErrWrongOwner) {
|
||||
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner – ohne diese "+
|
||||
"Prüfung sähe jedes Gruppenmitglied diese private Datei", err)
|
||||
}
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||
}
|
||||
|
||||
// Die Gegenprobe: An einer **Gruppendatei** geht dasselbe Etikett.
|
||||
var gf uuid.UUID
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES (NULL, $1, NULL, 'Gemeinsam.pdf', 'application/pdf', 1, 'x', $2) RETURNING id`,
|
||||
g, "k/"+uuid.NewString()).Scan(&gf)
|
||||
if err != nil {
|
||||
t.Fatalf("gruppendatei: %v", err)
|
||||
}
|
||||
if err := s.SetForFile(ctx, gf, g, u, []uuid.UUID{tag.ID}, nil); err != nil {
|
||||
t.Fatalf("an einer Gruppendatei muss es gehen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Absender ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFremderAbsenderBleibtFremd(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
a := konto(t, pool, ctx, "anna")
|
||||
b := konto(t, pool, ctx, "bert")
|
||||
|
||||
k, err := s.CreateCorrespondent(ctx, a, nil, "Finanzamt", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
liste, err := s.ListCorrespondents(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, x := range liste {
|
||||
if x.ID == k.ID {
|
||||
t.Fatal("Bert sieht Annas Absender")
|
||||
}
|
||||
}
|
||||
// Absender haben ihre eigene Fehlermarke – ErrCorrespondentNotFound, nicht
|
||||
// ErrNotFound. Zwei Marken für dieselbe Aussage in einem Paket sind eine
|
||||
// Stolperstelle; hier festgehalten, damit sie wenigstens nicht unbemerkt
|
||||
// die Seite wechselt.
|
||||
if _, err := s.UpdateCorrespondent(ctx, b, k.ID, "Uebernommen", ""); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||
t.Fatalf("Update lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||
}
|
||||
if err := s.DeleteCorrespondent(ctx, b, k.ID); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||
t.Fatalf("Delete lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||
}
|
||||
|
||||
// Auch hier: Der Weg, der Dateinamen herausgibt.
|
||||
f := datei(t, pool, ctx, a, "Bescheid.pdf")
|
||||
if err := s.SetFieldsForFile(ctx, f, a, a, FileFields{CorrespondentID: &k.ID}); err != nil {
|
||||
t.Fatalf("zuordnen: %v", err)
|
||||
}
|
||||
meine, err := s.FilesWithCorrespondent(ctx, a, k.ID)
|
||||
if err != nil || len(meine) != 1 {
|
||||
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d", err, len(meine))
|
||||
}
|
||||
fremde, err := s.FilesWithCorrespondent(ctx, b, k.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesWithCorrespondent: %v", err)
|
||||
}
|
||||
if len(fremde) != 0 {
|
||||
t.Fatalf("Bert sieht %d fremde Dateien", len(fremde))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package bg startet Hintergrundarbeit, die den Prozess nicht mitreißen kann.
|
||||
//
|
||||
// **Der Anlass ist gemessen, nicht theoretisch.** Aether erzeugt das
|
||||
// Vorschaubild einer hochgeladenen Datei in einer eigenen Goroutine. Chis
|
||||
// `Recoverer` hängt am Anfragebehandler und sieht davon nichts – eine Panik
|
||||
// dort nimmt den **ganzen Go-Prozess** mit. Am 12.09.2026 stand dafür sogar
|
||||
// eine passende Lücke offen (GO-2026-5061, Panik im WebP-Dekodierer): Ein
|
||||
// gewöhnlicher Bild-Upload hätte den Server umgelegt.
|
||||
//
|
||||
// Die Bibliothek zu aktualisieren schließt **diese** Lücke. Diese Datei
|
||||
// schließt die **Gattung**: Bilddekodierer, Fremdformate und externe Programme
|
||||
// sind Felder, in denen regelmäßig etwas gefunden wird, und keines davon darf
|
||||
// über einen Weg erreichbar sein, an dessen Ende der Prozess stirbt.
|
||||
//
|
||||
// Was hier *nicht* passiert: Fehler verschlucken. Eine aufgefangene Panik wird
|
||||
// mit Stapel protokolliert. Sie ist ein Fehler und bleibt einer – sie reißt
|
||||
// nur nichts mehr mit.
|
||||
package bg
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Go startet fn in einer Goroutine, die eine Panik überlebt.
|
||||
//
|
||||
// name steht im Logeintrag und sollte sagen, *was* da lief ("Vorschaubild",
|
||||
// "Texterkennung") – eine Zeile „panic recovered" ohne Herkunft hilft um drei
|
||||
// Uhr nachts niemandem.
|
||||
func Go(name string, fn func()) {
|
||||
go Run(name, fn)
|
||||
}
|
||||
|
||||
// Run ist dasselbe ohne eigene Goroutine: für Schleifen, in denen **ein**
|
||||
// Durchgang scheitern darf, ohne dass die Schleife endet.
|
||||
//
|
||||
// Der Unterschied ist keine Feinheit. Fängt man eine Panik um den ganzen
|
||||
// Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und
|
||||
// von da an wird still keine Datei mehr erkannt. Fängt man sie um den
|
||||
// einzelnen Auftrag ab, verliert man den einen Auftrag und macht weiter.
|
||||
func Run(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().
|
||||
Str("wo", name).
|
||||
Interface("panik", r).
|
||||
Str("stapel", string(debug.Stack())).
|
||||
Msg("Hintergrundarbeit abgestürzt – aufgefangen")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunFaengtPanikAuf(t *testing.T) {
|
||||
// Ohne das recover in Run stürbe hier der Testprozess – das ist zugleich
|
||||
// die Aussage des Tests.
|
||||
Run("test", func() { panic("absichtlich") })
|
||||
}
|
||||
|
||||
func TestRunLaesstErgebnisDurch(t *testing.T) {
|
||||
gelaufen := false
|
||||
Run("test", func() { gelaufen = true })
|
||||
if !gelaufen {
|
||||
t.Fatal("fn wurde nicht ausgeführt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoLaeuftUndUeberlebt(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
Go("heil", func() { defer wg.Done() })
|
||||
Go("panisch", func() { defer wg.Done(); panic("absichtlich") })
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Eine Schleife, in der ein Durchgang scheitert, muss weiterlaufen – das ist
|
||||
// der Grund, warum es Run neben Go gibt.
|
||||
func TestSchleifeLaeuftNachPanikWeiter(t *testing.T) {
|
||||
durchgaenge := 0
|
||||
for i := range 5 {
|
||||
Run("schritt", func() {
|
||||
durchgaenge++
|
||||
if i == 2 {
|
||||
panic("mittendrin")
|
||||
}
|
||||
})
|
||||
}
|
||||
if durchgaenge != 5 {
|
||||
t.Fatalf("nur %d von 5 Durchgängen", durchgaenge)
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,6 @@ type Config struct {
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
|
||||
// ExternalApps erscheinen im App-Umschalter der Oberfläche. Kommt aus der
|
||||
// Umgebung, damit neue Apps ohne Neubau des Frontends dazukommen können.
|
||||
ExternalApps []ExternalApp
|
||||
|
||||
// OCR – Volltextsuche über den Inhalt von PDFs und Bildern.
|
||||
// Standardmäßig an; fehlen die Programme im Image, schaltet sich der
|
||||
// Arbeiter beim Start selbst ab, statt den Start zu verhindern.
|
||||
@@ -89,15 +85,6 @@ type Config struct {
|
||||
//
|
||||
// Leer heißt: keine Leiste, und die CSP bleibt so eng wie bisher.
|
||||
HoneybarOrigin string
|
||||
// HoneybarKey ist der Ausweis, mit dem sich die Leiste bei HoneyOS die
|
||||
// App-Liste holt (dort HONEYOS_APPS_KEY). Nötig, wenn HoneyOS auf einer
|
||||
// anderen Site liegt als Aether: dann schickt der Browser die
|
||||
// HoneyOS-Sitzung nicht mit.
|
||||
//
|
||||
// Er steht anschließend im Quelltext der Seite — das ist unvermeidlich,
|
||||
// die Leiste läuft im Browser. Er gibt nur die App-Liste her, nichts
|
||||
// Schreibendes.
|
||||
HoneybarKey string
|
||||
// OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether
|
||||
// erreicht.
|
||||
//
|
||||
@@ -113,11 +100,6 @@ type Config struct {
|
||||
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
|
||||
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
||||
OfficeInsecureTLS bool
|
||||
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
|
||||
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
|
||||
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
|
||||
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
|
||||
OfficeJWTSecret string
|
||||
}
|
||||
|
||||
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
|
||||
@@ -138,45 +120,6 @@ func (c *Config) OfficeEnabled() bool {
|
||||
return (c.OfficeProvider == "collabora" || c.OfficeProvider == "onlyoffice") && c.OfficeURL != ""
|
||||
}
|
||||
|
||||
// ExternalApp ist ein Eintrag im App-Umschalter.
|
||||
type ExternalApp struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"` // ein Emoji, optional
|
||||
}
|
||||
|
||||
// parseExternalApps liest EXTERNAL_APPS im Format
|
||||
//
|
||||
// Name|https://url|Emoji , Name|https://url|Emoji
|
||||
//
|
||||
// Einträge ohne Namen oder URL werden übersprungen, damit ein Tippfehler in der
|
||||
// .env nicht den Start verhindert.
|
||||
func parseExternalApps(raw string) []ExternalApp {
|
||||
var apps []ExternalApp
|
||||
for _, entry := range strings.Split(raw, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(entry, "|")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
app := ExternalApp{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
URL: strings.TrimSpace(parts[1]),
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
app.Icon = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if app.Name == "" || app.URL == "" {
|
||||
continue
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
@@ -198,16 +141,12 @@ func Load() (*Config, error) {
|
||||
AdminUsername: getEnv("ADMIN_USERNAME", ""),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
|
||||
ExternalApps: parseExternalApps(getEnv("EXTERNAL_APPS", "")),
|
||||
|
||||
OCREnabled: getEnv("OCR_ENABLED", "true") != "false",
|
||||
|
||||
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
||||
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
||||
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
|
||||
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
||||
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
||||
HoneybarKey: getEnv("HONEYBAR_KEY", ""),
|
||||
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
|
||||
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
|
||||
OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS cal_objects;
|
||||
DROP TABLE IF EXISTS cal_calendars;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
|
||||
--
|
||||
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
|
||||
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
|
||||
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
|
||||
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_calendars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
|
||||
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
|
||||
color TEXT NOT NULL DEFAULT '#4285f4',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
|
||||
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
|
||||
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
|
||||
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
|
||||
-- einzelne Objekt abzufragen.
|
||||
ctag BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
|
||||
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
|
||||
uid TEXT NOT NULL,
|
||||
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
|
||||
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
|
||||
component TEXT NOT NULL,
|
||||
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
|
||||
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
|
||||
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
|
||||
data TEXT NOT NULL,
|
||||
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
|
||||
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
|
||||
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
|
||||
etag TEXT NOT NULL,
|
||||
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
|
||||
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
|
||||
dtstart TIMESTAMPTZ,
|
||||
dtend TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (calendar_id, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS timezone;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Standard-Zeitzone pro Kalender. Wird von CalDAV selbst nicht gebraucht
|
||||
-- (jeder Termin trägt seine Zeitzone im .ics), sondern erst von der
|
||||
-- künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3, siehe
|
||||
-- kalender-plan.md) – ein neuer Termin braucht eine Vorgabe, wenn der
|
||||
-- Nutzer keine eigene wählt. Jetzt schon angelegt, damit später keine
|
||||
-- weitere Migration für ein einzelnes Feld nötig ist.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus
|
||||
-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer
|
||||
-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen
|
||||
-- und der Client bekäme auf seine eigene Adresse ein 404.
|
||||
--
|
||||
-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle
|
||||
-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar,
|
||||
-- unter der die eingerichteten Geräte sie schon kennen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT;
|
||||
|
||||
-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe
|
||||
-- Adresse haben, aber beliebig viele dürfen gar keine haben.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment
|
||||
ON cal_calendars (owner_id, path_segment)
|
||||
WHERE path_segment IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS cal_tombstones;
|
||||
DROP INDEX IF EXISTS cal_objects_sync;
|
||||
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
|
||||
--
|
||||
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
|
||||
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
|
||||
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
|
||||
--
|
||||
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
|
||||
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
|
||||
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
|
||||
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
|
||||
-- bei dem es zuletzt angefasst wurde.
|
||||
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
|
||||
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
|
||||
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
|
||||
UPDATE cal_objects o SET sync_seq = c.ctag
|
||||
FROM cal_calendars c WHERE c.id = o.calendar_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
|
||||
|
||||
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
|
||||
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
|
||||
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
|
||||
-- Art, weil er unsichtbar ist.
|
||||
CREATE TABLE IF NOT EXISTS cal_tombstones (
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
sync_seq BIGINT NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
|
||||
|
||||
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
|
||||
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
|
||||
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_acl_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_group_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_user_calendar;
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Kalender an andere Konten freigeben.
|
||||
--
|
||||
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
|
||||
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
|
||||
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
|
||||
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
|
||||
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
|
||||
-- auf, wenn jemand sieht, was er nicht sehen soll.
|
||||
--
|
||||
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
|
||||
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
|
||||
ALTER TABLE acl_entries
|
||||
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
|
||||
|
||||
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
|
||||
|
||||
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
|
||||
-- mehrdeutig: welches Recht gilt dann?
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
|
||||
ON acl_entries (subject_user_id, resource_calendar_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
|
||||
ON acl_entries (subject_group_id, resource_calendar_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
|
||||
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
|
||||
CREATE INDEX IF NOT EXISTS idx_acl_calendar
|
||||
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Persönlich'
|
||||
WHERE name = 'Personal'
|
||||
AND color = '#4285f4';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
|
||||
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
|
||||
-- zieht die bereits angelegten nach.
|
||||
--
|
||||
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
|
||||
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
|
||||
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Personal'
|
||||
WHERE name = 'Persönlich'
|
||||
AND color = '#4285f4';
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -13,6 +15,17 @@ func Logger(next http.Handler) http.Handler {
|
||||
start := time.Now()
|
||||
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
// Den Logger in den Kontext hängen, damit log.Ctx(r.Context()) in den
|
||||
// Handlern etwas findet – und damit jede Zeile aus einer Anfrage die
|
||||
// Kennung dieser Anfrage trägt. Ohne das schreibt log.Ctx gar nicht
|
||||
// (zerolog gibt dann einen abgeschalteten Logger zurück), und mit
|
||||
// einem globalen Standard allein ließe sich nicht mehr zuordnen,
|
||||
// welche Meldung zu welchem Aufruf gehört.
|
||||
l := log.Logger.With().
|
||||
Str("request_id", chimiddleware.GetReqID(r.Context())).
|
||||
Logger()
|
||||
r = r.WithContext(l.WithContext(r.Context()))
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := zerolog.InfoLevel
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Der Test, der gefehlt hat.
|
||||
//
|
||||
// zerolog.Ctx liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||
// steckt. 34 Aufrufe von log.Ctx(...) im Baum schrieben deshalb ins Nichts –
|
||||
// darunter Warnungen über verwaiste Blobs und beschädigte Dateien. Der Aufruf
|
||||
// sieht dabei völlig richtig aus; auffallen kann es nur, wenn man den Fehler
|
||||
// herbeiführt, den er melden soll.
|
||||
func TestLoggerHaengtLoggerInDenKontext(t *testing.T) {
|
||||
var aus bytes.Buffer
|
||||
alt := log.Logger
|
||||
log.Logger = zerolog.New(&aus)
|
||||
defer func() { log.Logger = alt }()
|
||||
|
||||
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Genau der Aufruf, der überall im Baum steht.
|
||||
log.Ctx(r.Context()).Error().Msg("kaputte-datei")
|
||||
}))
|
||||
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||
|
||||
if !strings.Contains(aus.String(), "kaputte-datei") {
|
||||
t.Fatalf("log.Ctx im Handler schrieb nichts. Ausgabe: %q", aus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Jede Zeile aus einer Anfrage soll deren Kennung tragen – sonst weiß man bei
|
||||
// zwei gleichzeitigen Downloads nicht, welche Meldung zu welchem gehört.
|
||||
func TestZeilenTragenDieAnfrageKennung(t *testing.T) {
|
||||
var aus bytes.Buffer
|
||||
alt := log.Logger
|
||||
log.Logger = zerolog.New(&aus)
|
||||
defer func() { log.Logger = alt }()
|
||||
|
||||
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Ctx(r.Context()).Error().Msg("x")
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||
|
||||
if !strings.Contains(aus.String(), "request_id") {
|
||||
t.Fatalf("keine request_id in der Zeile: %q", aus.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package response
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
@@ -13,7 +14,31 @@ type envelope struct {
|
||||
func JSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(envelope{Data: data})
|
||||
json.NewEncoder(w).Encode(envelope{Data: leereListeStattNull(data)})
|
||||
}
|
||||
|
||||
// leereListeStattNull macht aus einer nicht belegten Liste eine leere.
|
||||
//
|
||||
// In Go ist eine Liste ohne Einträge oft nil, und `encoding/json` schreibt
|
||||
// dafür `null` statt `[]`. Jede Store-Funktion, die ihre Liste mit
|
||||
// `var out []T` beginnt, liefert das bei leerem Ergebnis – und jede
|
||||
// Oberfläche, die darüber läuft, bricht dann ab.
|
||||
//
|
||||
// Genau so ist die Kalenderansicht bei einem Konto ohne Kalender
|
||||
// weiß geblieben: `for (const cal of calendars)` über `null`.
|
||||
//
|
||||
// Der Ort dafür ist hier und nicht in den Stores: Es gibt Dutzende
|
||||
// Listenendpunkte, und der nächste wird wieder mit `var out []T` anfangen.
|
||||
// Eine Regel, an die man sich erinnern muss, ist keine.
|
||||
func leereListeStattNull(data any) any {
|
||||
if data == nil {
|
||||
return data
|
||||
}
|
||||
v := reflect.ValueOf(data)
|
||||
if v.Kind() == reflect.Slice && v.IsNil() {
|
||||
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Eine leere Liste muss als [] herauskommen, nicht als null. Jede
|
||||
// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die
|
||||
// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben.
|
||||
func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) {
|
||||
type eintrag struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var leer []eintrag
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, leer)
|
||||
|
||||
if got := w.Body.String(); !contains(got, `"data":[]`) {
|
||||
t.Fatalf("erwartet \"data\":[], bekam %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, []string{"a", "b"})
|
||||
|
||||
var out struct {
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Data) != 2 || out.Data[0] != "a" {
|
||||
t.Fatalf("Inhalt verändert: %v", out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst
|
||||
// verwandelte die Regel jede fehlende Antwort in eine leere Liste.
|
||||
func TestJSONObjekteBleibenObjekte(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, map[string]int{"n": 1})
|
||||
if got := w.Body.String(); !contains(got, `"n":1`) {
|
||||
t.Fatalf("Objekt verändert: %s", got)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
JSON(w, 200, nil)
|
||||
if got := w.Body.String(); contains(got, "[]") {
|
||||
t.Fatalf("nil sollte keine Liste werden: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,10 @@ WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Erst prüfen, dann bauen: Die Tests decken die Stellen ab, die sich ohne
|
||||
# Browser prüfen lassen – Reihenfolgen, Zoomstufen, Suche im Text. Sie laufen
|
||||
# in einer halben Sekunde; ein Abbild mit einer kaputten Sortierung kostet mehr.
|
||||
RUN npm test && npm run build
|
||||
|
||||
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
|
||||
@@ -76,6 +76,16 @@ services:
|
||||
profiles: ["office"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Wer seine Dateien in Collabora öffnen darf. Derselbe Host wie in
|
||||
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
|
||||
# anhängen – etwa OFFICE_ALIASGROUP=https://cloud\.example\.com:443.
|
||||
# Fehlt er, lehnt Collabora ab: "No authorized hosts found matching the
|
||||
# target host […] in config".
|
||||
#
|
||||
# Diese vier Werte (aliasgroup, ssl, ssl.termination, Port) stehen
|
||||
# bewusst NICHT in .env.example: Die beschreibt, was die Anwendung liest.
|
||||
# Hier konfigurierst du einen fremden Container – wer Office selbst
|
||||
# betreibt, tut das in dessen eigener Compose-Datei.
|
||||
aliasgroup1: ${OFFICE_ALIASGROUP:-http://app:8080}
|
||||
# ssl.termination=true nur, wenn davor wirklich HTTPS endet – sonst
|
||||
# verlangt Collabora eine wss://-Verbindung, die es nicht gibt, und der
|
||||
@@ -92,6 +102,37 @@ services:
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
|
||||
|
||||
# Euro-Office – OnlyOffice-Fork (Nextcloud/IONOS, seit 09.06.2026). Für
|
||||
# Aether ist es derselbe Anbieter wie OnlyOffice (`OFFICE_PROVIDER=onlyoffice`),
|
||||
# nur ein anderes Abbild: Die WOPI-Schnittstelle und die Variablen heißen
|
||||
# gleich.
|
||||
#
|
||||
# Eigenes Profil, damit es neben Collabora stehen kann, ohne sich den Port
|
||||
# zu teilen: ... --profile euro up -d
|
||||
#
|
||||
# Rechne mit rund 5 GB fürs Abbild und 400 MB im Leerlauf.
|
||||
euro:
|
||||
container_name: aether-euro
|
||||
image: ghcr.io/euro-office/documentserver:latest
|
||||
profiles: ["euro"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WOPI_ENABLED: "true"
|
||||
# Ohne Signatur antwortet der Server mit {"error":-8} – und zwar erst
|
||||
# im Editor, nicht schon bei der Ermittlung. Der Rahmen bleibt dann leer.
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||
# Die Beispiel-App gehört nicht ins Netz.
|
||||
EXAMPLE_ENABLED: "false"
|
||||
# Es muss app:8080 erreichen – eine private Adresse, die es sonst
|
||||
# von sich aus ablehnt.
|
||||
ALLOW_PRIVATE_IP_ADDRESS: "true"
|
||||
networks: [internal]
|
||||
ports:
|
||||
# 9981, damit Collabora auf 9980 bleiben kann. Anders als dort ist der
|
||||
# Port hier **80** im Container, nicht 9980.
|
||||
- "${APP_BIND:-127.0.0.1}:${EURO_PORT:-9981}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
||||
|
||||
@@ -41,10 +41,12 @@ services:
|
||||
- ${DATA_PATH:-./data}:/data
|
||||
networks: [internal]
|
||||
ports:
|
||||
# Standardmäßig nur loopback – davor gehört dein Reverse Proxy mit TLS.
|
||||
# Zum Ausprobieren im LAN: APP_BIND=0.0.0.0 in der .env. Dann liegt die
|
||||
# Anmeldung allerdings unverschlüsselt im Netz.
|
||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-8080}:8080"
|
||||
# Vorgabe 0.0.0.0: erreichbar für alles, was den Server im Netz sieht –
|
||||
# denn der Reverse Proxy steht meist auf einem anderen Rechner, und
|
||||
# loopback ließe ihn nicht heran. Davor gehört er trotzdem, mit TLS.
|
||||
# Läuft er auf DIESEM Rechner: APP_BIND=127.0.0.1 in der .env, dann ist
|
||||
# der Port von außen dicht.
|
||||
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-8080}:8080"
|
||||
|
||||
postgres:
|
||||
container_name: aether-db
|
||||
|
||||
Generated
+3598
-9
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^19.2.7",
|
||||
@@ -22,8 +23,10 @@
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
|
||||
@@ -53,7 +53,6 @@ function AuthedApp() {
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.7s linear infinite',
|
||||
}} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+244
-23
@@ -90,13 +90,49 @@ export interface CreateShareRequest {
|
||||
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 {
|
||||
link: ShareLink
|
||||
has_password: boolean
|
||||
expires_at?: string | null
|
||||
download_count: number
|
||||
created_at: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
}
|
||||
|
||||
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
|
||||
export interface RescanEntry {
|
||||
kind: 'new' | 'orphaned' | 'changed'
|
||||
storage_key: string
|
||||
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
|
||||
path: string
|
||||
owner: string
|
||||
size: number
|
||||
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RescanReport {
|
||||
new: number
|
||||
orphaned: number
|
||||
changed: number
|
||||
scanned: number
|
||||
samples: RescanEntry[]
|
||||
aborted?: boolean
|
||||
/** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */
|
||||
abort_reason?: string
|
||||
/** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */
|
||||
overridable?: boolean
|
||||
/** Nur nach dem Anwenden gefüllt. */
|
||||
adopted?: number
|
||||
removed?: number
|
||||
corrected?: number
|
||||
errors?: number
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string
|
||||
file_id: string
|
||||
@@ -153,12 +189,25 @@ async function request<T>(
|
||||
}
|
||||
|
||||
// XHR-based upload with real progress reporting.
|
||||
/**
|
||||
* Ein vom Nutzer abgebrochener Upload – kein Fehler, sondern eine Absicht.
|
||||
*
|
||||
* Eigene Klasse, damit die Warteschlange die beiden unterscheiden kann: Ein
|
||||
* Fehler bleibt als Meldung stehen, weil er eine Auskunft trägt. „Ich wollte
|
||||
* das nicht" trägt keine und verschwindet von selbst.
|
||||
*/
|
||||
export class UploadAborted extends Error {
|
||||
constructor() { super('Upload aborted') }
|
||||
}
|
||||
|
||||
export function uploadWithProgress(
|
||||
file: File,
|
||||
folderId: string | undefined,
|
||||
onProgress: (p: UploadProgress) => void
|
||||
onProgress: (p: UploadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FileItem> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new UploadAborted()); return }
|
||||
const form = new FormData()
|
||||
// folder_id MUST come before the file so the server can stream the file
|
||||
// straight to storage without buffering the whole body first.
|
||||
@@ -197,7 +246,17 @@ export function uploadWithProgress(
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
||||
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
|
||||
xhr.addEventListener('abort', () => reject(new UploadAborted()))
|
||||
|
||||
/**
|
||||
* Abbrechen heißt hier: die Verbindung fallen lassen.
|
||||
*
|
||||
* Der Server merkt das mitten im Lesen. Er schreibt in eine Temp-Datei und
|
||||
* benennt sie erst am Ende um – ein abgebrochener Upload hinterlässt
|
||||
* deshalb weder Bytes noch eine Zeile in der Datenbank. Es braucht also
|
||||
* keinen zweiten Ruf zum Aufräumen; das Aufräumen ist die Bauweise.
|
||||
*/
|
||||
signal?.addEventListener('abort', () => xhr.abort(), { once: true })
|
||||
|
||||
xhr.send(form)
|
||||
})
|
||||
@@ -205,12 +264,6 @@ export function uploadWithProgress(
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExternalApp {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
oidc_enabled: boolean
|
||||
cloud_name: string
|
||||
@@ -235,10 +288,6 @@ export interface PublicConfig {
|
||||
accent_color?: string
|
||||
/** Ursprung der HoneyOS-Leiste (HONEYBAR_ORIGIN). Leer = keine Leiste. */
|
||||
honeybar_origin?: string
|
||||
/** Ausweis fuer deren App-Liste (HONEYBAR_KEY). Nur gesetzt, wenn es einen
|
||||
* Ursprung gibt. */
|
||||
honeybar_key?: string
|
||||
external_apps: ExternalApp[]
|
||||
}
|
||||
|
||||
export const publicApi = {
|
||||
@@ -310,12 +359,15 @@ export const aclApi = {
|
||||
|
||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
||||
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
|
||||
|
||||
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
|
||||
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
|
||||
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
||||
}
|
||||
|
||||
@@ -543,8 +595,21 @@ export interface OfficeSession {
|
||||
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
||||
* entschieden wird serverseitig.
|
||||
*/
|
||||
/**
|
||||
* Endungen, die im Office-Server aufgehen sollen.
|
||||
*
|
||||
* **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es
|
||||
* einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur
|
||||
* hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server
|
||||
* braucht und direkt speichert. Mit `txt` in dieser Liste lief eine
|
||||
* 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht
|
||||
* auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie.
|
||||
*
|
||||
* `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in
|
||||
* einem Textfeld.
|
||||
*/
|
||||
const officeExtensions = new Set([
|
||||
'odt', 'docx', 'doc', 'rtf', 'txt',
|
||||
'odt', 'docx', 'doc', 'rtf',
|
||||
'ods', 'xlsx', 'xls', 'csv',
|
||||
'odp', 'pptx', 'ppt',
|
||||
'odg',
|
||||
@@ -556,8 +621,11 @@ export function isOfficeFile(name: string): boolean {
|
||||
}
|
||||
|
||||
export const officeApi = {
|
||||
session: (fileId: string) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}`),
|
||||
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
|
||||
* anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie
|
||||
* nicht und bleibt unverändert. */
|
||||
session: (fileId: string, mobile = false) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`),
|
||||
}
|
||||
|
||||
export const ocrApi = {
|
||||
@@ -604,15 +672,25 @@ export const filesApi = {
|
||||
* mit den Fassungen 1, 2, 3.
|
||||
*/
|
||||
/**
|
||||
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
|
||||
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
|
||||
*
|
||||
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
|
||||
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
|
||||
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
|
||||
* Vorschaubild.
|
||||
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
|
||||
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
|
||||
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
|
||||
*
|
||||
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
|
||||
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
|
||||
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
|
||||
*/
|
||||
officePreview: (id: string, version?: number) =>
|
||||
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
|
||||
setThumbnail: async (id: string, bild: Blob) => {
|
||||
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
|
||||
body: bild,
|
||||
})
|
||||
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
|
||||
},
|
||||
|
||||
thumbnail: (id: string, version?: number | string) =>
|
||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||
@@ -855,6 +933,12 @@ export const adminApi = {
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||
/** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde
|
||||
* fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */
|
||||
rescan: (apply = false, bestaetigt = false) =>
|
||||
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
@@ -894,4 +978,141 @@ export const adminApi = {
|
||||
request<AppSettings>('PATCH', '/admin/settings', data),
|
||||
}
|
||||
|
||||
// ── Calendar ─────────────────────────────────────────────────────────────
|
||||
// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit
|
||||
// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese
|
||||
// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen
|
||||
// Session, wie jeder andere Bereich.
|
||||
|
||||
export interface CalendarItem {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
supports_todo: boolean
|
||||
timezone: string
|
||||
ctag: number
|
||||
created_at: string
|
||||
/** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */
|
||||
permission: 'read' | 'write'
|
||||
/** Der Kalender gehört jemand anderem. */
|
||||
shared: boolean
|
||||
/** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne
|
||||
* sichtbaren Absender ist ein Rätsel. */
|
||||
owner_name?: string
|
||||
/** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */
|
||||
via_group?: string
|
||||
}
|
||||
|
||||
/** Kurz: Darf ich diesen Kalender ändern? */
|
||||
export function canEditCalendar(c: CalendarItem): boolean {
|
||||
return c.permission !== 'read'
|
||||
}
|
||||
|
||||
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
|
||||
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
|
||||
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
|
||||
* sind dann nur lesbar (siehe events_api.go). */
|
||||
export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
|
||||
|
||||
export interface Recurrence {
|
||||
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
||||
interval: number
|
||||
count?: number
|
||||
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
|
||||
until?: string
|
||||
/** Nur WEEKLY: an welchen Wochentagen. Leer = Wochentag des Starts. */
|
||||
byday?: WeekdayCode[]
|
||||
/** Nur MONTHLY/YEARLY: am festen Kalendertag (am 4.) oder am n-ten
|
||||
* Wochentag (am 1. Freitag). */
|
||||
mode?: 'DAY' | 'WEEKDAY'
|
||||
/** Nur zum Anzeigen – der Server rechnet beides aus dem Startdatum aus. */
|
||||
nth?: number
|
||||
weekday?: WeekdayCode
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
uid: string
|
||||
summary: string
|
||||
description: string
|
||||
location: string
|
||||
/** ISO 8601, immer UTC ("...Z"). Bei ganztägigen Terminen Mitternacht UTC
|
||||
* als reine Datumsangabe – nie über new Date(...) in Ortszeit umrechnen,
|
||||
* sondern über eventDay() aus components/calendar/dates.ts. */
|
||||
start: string
|
||||
end?: string
|
||||
all_day: boolean
|
||||
/** Leer = Farbe des Kalenders. */
|
||||
color?: string
|
||||
/** Es hängt überhaupt eine Wiederholung dran. */
|
||||
recurring: boolean
|
||||
/** Die Regel – nur gesetzt, wenn sie sich im Formular abbilden lässt. */
|
||||
recurrence?: Recurrence
|
||||
/** Bei aufgeklappten Wiederholungen: Anfang/Ende der **Reihe**, während
|
||||
* start/end den einzelnen Termin tragen. Das Formular bearbeitet immer
|
||||
* die Reihe. */
|
||||
series_start?: string
|
||||
series_end?: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface EventInput {
|
||||
summary: string
|
||||
description?: string
|
||||
location?: string
|
||||
start: string
|
||||
end?: string
|
||||
all_day: boolean
|
||||
color?: string
|
||||
recurrence?: Recurrence | null
|
||||
}
|
||||
|
||||
export const calendarApi = {
|
||||
list: () => request<CalendarItem[]>('GET', '/calendars'),
|
||||
create: (name: string, color: string, description = '') =>
|
||||
request<CalendarItem>('POST', '/calendars', { name, color, description }),
|
||||
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
|
||||
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
|
||||
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
|
||||
|
||||
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
|
||||
|
||||
import: async (id: string, file: File): Promise<{ imported: number }> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/calendar' }
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
|
||||
const res = await fetch(`/api/v1/calendars/${id}/import`, {
|
||||
method: 'POST', headers, credentials: 'include', body: file,
|
||||
})
|
||||
const json = await res.json().catch(() => ({ error: res.statusText }))
|
||||
if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed')
|
||||
return json.data
|
||||
},
|
||||
|
||||
/** Termine im Zeitfenster. Wiederholungen kommen vom Server bereits in
|
||||
* einzelne Termine aufgeklappt zurück – deshalb braucht es die Grenzen:
|
||||
* eine tägliche Reihe ohne Ende hat sonst kein natürliches Ende. */
|
||||
listEvents: (id: string, from?: Date, to?: Date) => {
|
||||
const qs = from && to ? `?from=${from.toISOString()}&to=${to.toISOString()}` : ''
|
||||
return request<CalendarEvent[]>('GET', `/calendars/${id}/events${qs}`)
|
||||
},
|
||||
createEvent: (id: string, input: EventInput) =>
|
||||
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
|
||||
updateEvent: (id: string, uid: string, input: EventInput) =>
|
||||
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
|
||||
/** Hängt einen Termin in einen anderen Kalender um – UID und Inhalt bleiben,
|
||||
* nur die Sammlung wechselt. Bei einer Reihe zieht die ganze Reihe mit:
|
||||
* Sie ist eine einzige Datei, kein Bündel von Einzelterminen. */
|
||||
moveEvent: (id: string, uid: string, targetId: string) =>
|
||||
request<CalendarEvent>('POST', `/calendars/${id}/events/${encodeURIComponent(uid)}/move`,
|
||||
{ calendar_id: targetId }),
|
||||
/** Ohne `occurrence` verschwindet der Termin ganz – bei einer Reihe also
|
||||
* die ganze Reihe. Mit `occurrence` (ISO-Anfang des einzelnen Termins)
|
||||
* fällt nur dieser eine aus, die Reihe bleibt bestehen. */
|
||||
deleteEvent: (id: string, uid: string, occurrence?: string) => {
|
||||
const qs = occurrence ? `?occurrence=${encodeURIComponent(occurrence)}` : ''
|
||||
return request<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`)
|
||||
},
|
||||
}
|
||||
|
||||
export { APIError }
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { publicApi, type ExternalApp } from '../api/client'
|
||||
|
||||
/**
|
||||
* DERZEIT NICHT EINGEHÄNGT (Stand 2026-07-30).
|
||||
*
|
||||
* Auf Wunsch wieder aus der Seitenleiste entfernt – erst soll Aether für sich
|
||||
* fertig werden. Die Komponente bleibt liegen, weil eine gemeinsame Leiste für
|
||||
* alle Apps noch überlegt wird. Wird sie nirgends importiert, landet sie auch
|
||||
* nicht im Bundle. Zum Reaktivieren: in `Sidebar.tsx` neben dem Markenschriftzug
|
||||
* einsetzen und EXTERNAL_APPS in `deploy/.env` füllen.
|
||||
*
|
||||
* Umschalter zu den anderen selbst gehosteten Apps.
|
||||
*
|
||||
* Die Einträge kommen aus EXTERNAL_APPS in der .env des Servers – so lassen
|
||||
* sich Apps ergänzen, ohne das Frontend neu zu bauen. Ist nichts gesetzt,
|
||||
* rendert die Komponente nichts.
|
||||
*
|
||||
* Die Ziele liegen auf anderen Ursprüngen (eigene Subdomain je App). Deshalb
|
||||
* `target="_blank"`: Ist die andere App als PWA installiert, übernimmt das
|
||||
* Betriebssystem sie als eigene App; sonst öffnet sie in einem neuen Tab.
|
||||
* Eine Navigation im selben Fenster würde die installierte Aether-App
|
||||
* verlassen und in einer Browserleiste landen.
|
||||
*/
|
||||
export default function AppSwitcher() {
|
||||
const [apps, setApps] = useState<ExternalApp[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
publicApi.config()
|
||||
.then(c => setApps(c.external_apps ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDown(e: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onEsc)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (apps.length === 0) return null
|
||||
|
||||
return (
|
||||
<div ref={boxRef} style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-label="Other apps"
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 8, cursor: 'pointer',
|
||||
background: open ? 'var(--color-surface-2)' : 'transparent',
|
||||
border: 'none', color: 'var(--color-text-muted)', padding: 0,
|
||||
}}
|
||||
>
|
||||
{/* Raster aus neun Punkten – das übliche Zeichen für einen App-Wechsel */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
{[5, 12, 19].map(y => [5, 12, 19].map(x => (
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" />
|
||||
)))}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 34, right: 0, zIndex: 60, minWidth: 190,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
|
||||
padding: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '6px 10px 4px', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--color-text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
My apps
|
||||
</div>
|
||||
{apps.map(app => (
|
||||
<a
|
||||
key={app.url}
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--color-surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span style={{ fontSize: 16, width: 20, textAlign: 'center' }}>{app.icon || '🔗'}</span>
|
||||
{app.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Excalidraw, exportToBlob, getSceneVersion, restore, serializeAsJSON } from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import { APIError, filesApi, type FileItem } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { FileTypeIcon } from './icons'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { toast, fehlertext } from './Toasts'
|
||||
|
||||
/**
|
||||
* Zeichnen und Mindmaps – auf Excalidraw.
|
||||
*
|
||||
* **Eine Zeichnung ist eine Datei, kein neues Ding.** Das ist die ganze
|
||||
* Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie alles, was
|
||||
* Aether für Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
|
||||
* Versionen, Freigabelinks, interne Freigaben, Suche nach Namen und Etikett.
|
||||
* Keine neue Tabelle, kein zweites Rechtemodell.
|
||||
*
|
||||
* Gespeichert wird über denselben Weg wie ein Upload – gleicher Name,
|
||||
* gleicher Ordner. Genau wie der Text-Editor in der Vorschau. Ein eigener
|
||||
* Schreib-Endpunkt bräuchte dieselben Prüfungen noch einmal.
|
||||
*
|
||||
* **Kein gleichzeitiges Arbeiten.** Die Komponente könnte es (siehe
|
||||
* `isCollaborating`), aber der Transport gehört nicht dazu – dafür bräuchte
|
||||
* es einen zweiten Dienst neben Aether, so wie Euro-Office einer ist. Der
|
||||
* Preis dafür steht unten bei `pruefeFremdeAenderung`: Ohne Abgleich würden
|
||||
* zwei Leute sich lautlos gegenseitig die Arbeit löschen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wo die Schriften liegen.
|
||||
*
|
||||
* Ohne das laedt Excalidraw sie von einem fremden Ursprung – und die
|
||||
* Sicherheitsrichtlinie (`default-src 'self'`, kein eigenes `font-src`)
|
||||
* verbietet genau das. Die Zeichnung erschiene in einer Ersatzschrift, und man
|
||||
* suchte den Grund im Zeichenbrett statt in der Richtlinie.
|
||||
*
|
||||
* **Hier und nicht in index.html:** Ein Inline-Skript dort waere von derselben
|
||||
* Richtlinie blockiert (`script-src 'self'`, kein `unsafe-inline`). Kopiert
|
||||
* werden die Dateien beim Bauen, siehe vite.config.ts.
|
||||
*/
|
||||
declare global {
|
||||
interface Window { EXCALIDRAW_ASSET_PATH?: string }
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.EXCALIDRAW_ASSET_PATH = '/excalidraw/'
|
||||
}
|
||||
|
||||
/** Was in einer leeren Zeichnung steht. */
|
||||
export function leereZeichnung(): string {
|
||||
return serializeAsJSON([], {}, {}, 'local')
|
||||
}
|
||||
|
||||
export const drawingExtension = '.excalidraw'
|
||||
|
||||
/**
|
||||
* Eine Zeichnung holen und in einen Zustand verwandeln, den das Zeichenbrett
|
||||
* annimmt.
|
||||
*
|
||||
* Steht hier und nicht im Effekt, damit es sich prüfen lässt – der Weg hat
|
||||
* drei Fallen, und zwei davon enden in verlorener Arbeit.
|
||||
*/
|
||||
export async function ladeZeichnung(url: string) {
|
||||
const r = await fetch(url, { credentials: 'include' })
|
||||
// **`fetch` wirft bei 404 oder 403 nicht.** Ohne diese Zeile käme der
|
||||
// Fehlerrumpf `{"error":"file not found"}` weiter, `JSON.parse` gelänge –
|
||||
// es ist gültiges JSON – und `elements` wäre `undefined`. Das Zeichenbrett
|
||||
// ginge **leer** auf; es sähe aus, als sei die Zeichnung verloren. Wer dann
|
||||
// speichert, macht es wahr.
|
||||
if (!r.ok) throw new APIError(r.status, 'could not load the drawing')
|
||||
|
||||
const text = await r.text()
|
||||
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
|
||||
const d = JSON.parse(text.trim() || leereZeichnung())
|
||||
|
||||
// `restore` säubert und ergänzt Fehlendes. Es ist der Weg, den Excalidraw
|
||||
// dafür vorsieht – selbst zu prüfen hiesse, seine Feldliste nachzubauen und
|
||||
// bei jeder Fassung nachzuziehen.
|
||||
//
|
||||
// **Eine Sache erledigt es allerdings nicht** (nachgemessen): Ein bereits
|
||||
// verunglücktes `collaborators: {}` reicht es unverändert durch – die Map
|
||||
// entsteht nur, wenn das Feld ganz fehlt. Neue Dateien haben es nicht mehr,
|
||||
// weil `serializeAsJSON` es gar nicht erst schreibt; die paar aus der ersten
|
||||
// Stunde wären sonst dauerhaft kaputt.
|
||||
if (d?.appState) delete d.appState.collaborators
|
||||
return restore(d, null, null)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste auffrischen, damit Größe und Fassung stimmen. */
|
||||
onSaved?: (aktualisiert: FileItem) => void
|
||||
}
|
||||
|
||||
export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
const [szene, setSzene] = useState<ReturnType<typeof restore> | null>(null)
|
||||
const [fehler, setFehler] = useState('')
|
||||
const [speichert, setSpeichert] = useState(false)
|
||||
const [geaendert, setGeaendert] = useState(false)
|
||||
/** Rückfragen als eigener Dialog statt window.confirm – siehe unten. */
|
||||
const [frage, setFrage] = useState<null | {
|
||||
titel: string; text: string; label: string; danger?: boolean; tun: () => void
|
||||
}>(null)
|
||||
|
||||
/**
|
||||
* Die Fassung der Zeichnung, wie sie zuletzt gespeichert wurde.
|
||||
*
|
||||
* **Nicht einfach ein Schalter „geändert".** `onChange` feuert bei jeder
|
||||
* Kleinigkeit – Mauszeiger, Auswahl, Bildlauf –, nicht nur wenn sich etwas
|
||||
* am Inhalt tut. Ein Schalter stand deshalb schon Sekunden nach dem
|
||||
* Speichern wieder auf „geändert", und beim Schließen kam die Rückfrage,
|
||||
* obwohl nichts offen war.
|
||||
*
|
||||
* `getSceneVersion` zählt den Inhalt, nicht die Bedienung. Stimmt die Zahl
|
||||
* mit der gespeicherten überein, gibt es nichts zu sichern.
|
||||
*/
|
||||
const gespeicherteFassung = useRef<number | null>(null)
|
||||
|
||||
/** Die Fassung, auf der wir arbeiten. Grundlage für den Abgleich unten. */
|
||||
const basis = useRef(file.version)
|
||||
/** Der jeweils letzte Stand aus dem Zeichenbrett, ohne bei jedem Strich zu rendern. */
|
||||
const aktuell = useRef<{ elements: readonly unknown[]; appState: Record<string, unknown>; files: unknown } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let lebt = true
|
||||
ladeZeichnung(filesApi.inline(file.id))
|
||||
.then(szene => { if (lebt) setSzene(szene) })
|
||||
.catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) })
|
||||
return () => { lebt = false }
|
||||
}, [file.id])
|
||||
|
||||
/**
|
||||
* Hat jemand anders die Datei inzwischen geändert?
|
||||
*
|
||||
* Ohne diese Frage überschreibt der Zweite den Ersten – **lautlos**, und das
|
||||
* ist die unangenehmste Sorte Datenverlust: Niemand merkt sie, bis jemand
|
||||
* seine Arbeit sucht. Es gibt keinen Endpunkt für eine einzelne Datei,
|
||||
* deshalb über die Ordnerliste; das ist eine Anfrage und keine neue
|
||||
* Schnittstelle.
|
||||
*/
|
||||
const pruefeFremdeAenderung = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const d = await filesApi.list(file.folder_id ?? undefined)
|
||||
const jetzt = (d.files ?? []).find(f => f.id === file.id)
|
||||
return !!jetzt && jetzt.version !== basis.current
|
||||
} catch {
|
||||
// Lässt sich die Frage nicht beantworten, wird nicht geraten: Wir
|
||||
// speichern und verlassen uns auf die Versionshistorie.
|
||||
return false
|
||||
}
|
||||
}, [file.id, file.folder_id])
|
||||
|
||||
async function speichern(trotzFremderAenderung = false) {
|
||||
if (!aktuell.current) return
|
||||
setSpeichert(true)
|
||||
try {
|
||||
if (!trotzFremderAenderung && await pruefeFremdeAenderung()) {
|
||||
setSpeichert(false)
|
||||
setFrage({
|
||||
titel: 'Someone else saved this drawing',
|
||||
text: 'Saving now replaces their version. The previous one stays in '
|
||||
+ 'the version history either way.',
|
||||
label: 'Save anyway',
|
||||
danger: true,
|
||||
tun: () => { setFrage(null); void speichern(true) },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// **Einmal festhalten, was hochgeht.** Während der Upload läuft, kann
|
||||
// weitergezeichnet werden – `aktuell.current` ist dann ein anderer
|
||||
// Stand. Läse man die Fassungsnummer hinterher von dort, gälten die
|
||||
// neuen Striche als gespeichert, obwohl sie nie hochgingen. Beim
|
||||
// Schliessen wären sie lautlos weg.
|
||||
const stand = aktuell.current
|
||||
|
||||
// Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder,
|
||||
// die eine Zeichnung ausmachen – nicht den Laufzeitzustand.
|
||||
const inhalt = serializeAsJSON(
|
||||
stand.elements as never, stand.appState as never, stand.files as never, 'local')
|
||||
const blob = new File([inhalt], file.name, { type: 'application/json' })
|
||||
const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
|
||||
basis.current = neu.version
|
||||
gespeicherteFassung.current = getSceneVersion(stand.elements as never)
|
||||
// Ist inzwischen weitergezeichnet worden, steht „Unsaved" sofort wieder
|
||||
// da – richtig so, es ist ja etwas offen.
|
||||
const jetzt = aktuell.current ? getSceneVersion(aktuell.current.elements as never) : null
|
||||
setGeaendert(jetzt !== null && jetzt !== gespeicherteFassung.current)
|
||||
onSaved?.(neu)
|
||||
toast.info('Drawing saved')
|
||||
void vorschaubild(neu, stand)
|
||||
} catch (err) {
|
||||
toast.error(fehlertext(err, 'Could not save the drawing'))
|
||||
} finally {
|
||||
setSpeichert(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Vorschaubild erzeugen und hochladen.
|
||||
*
|
||||
* Der Server kann das nicht: In der Datei steht JSON, und daraus wird ohne
|
||||
* Browser kein Bild. Hier ist der Browser, der die Zeichnung ohnehin gerade
|
||||
* gezeichnet hat.
|
||||
*
|
||||
* **Nach** dem Speichern und ohne darauf zu warten: Ein fehlgeschlagenes
|
||||
* Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern
|
||||
* scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst
|
||||
* ist heil, und eine rote Meldung dafür wäre verwirrend.
|
||||
*/
|
||||
async function vorschaubild(neu: FileItem, stand: NonNullable<typeof aktuell.current>) {
|
||||
try {
|
||||
const bild = await exportToBlob({
|
||||
// Derselbe Stand, der hochgegangen ist – nicht der, der gerade auf
|
||||
// dem Zeichenbrett liegt. Sonst zeigte die Kachel etwas, das so in
|
||||
// keiner Fassung steht.
|
||||
elements: stand.elements as never,
|
||||
appState: { ...(stand.appState as object), exportBackground: true } as never,
|
||||
files: stand.files as never,
|
||||
mimeType: 'image/png',
|
||||
exportPadding: 16,
|
||||
// Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen
|
||||
// kostet nur Bandbreite.
|
||||
getDimensions: (w: number, h: number) => {
|
||||
const faktor = Math.min(1, 512 / Math.max(w, h))
|
||||
return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor }
|
||||
},
|
||||
})
|
||||
await filesApi.setThumbnail(neu.id, bild)
|
||||
// **Der Liste Bescheid geben.** Sie kennt nur die Antwort des Uploads,
|
||||
// und die entstand, bevor es das Bild gab – ohne diese Zeile zeigte die
|
||||
// Kachel bis zum nächsten Neuladen nichts.
|
||||
onSaved?.({ ...neu, has_thumbnail: true })
|
||||
} catch {
|
||||
/* Schönheitsfehler – die Zeichnung ist gespeichert. */
|
||||
}
|
||||
}
|
||||
|
||||
// Strg+S / Cmd+S, wie überall sonst auch.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
if (geaendert && !speichert) void speichern()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
// Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen
|
||||
// erst fragen – `false` hält die Ansicht offen (siehe useBackClose).
|
||||
useBackClose({
|
||||
active: true,
|
||||
onClose: () => {
|
||||
if (!geaendert) { onClose(); return }
|
||||
// `false` hält die Ansicht offen, solange der Dialog steht – sonst wäre
|
||||
// der Verlaufseintrag schon verbraucht, und ein „Cancel" liesse die
|
||||
// nächste Zurück-Geste aus der Anwendung führen.
|
||||
frageSchliessen()
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
/** Die eine Rückfrage vor dem Verwerfen – an drei Stellen dieselbe. */
|
||||
function frageSchliessen() {
|
||||
setFrage({
|
||||
titel: 'Close without saving?',
|
||||
text: 'Your changes to this drawing will be lost.',
|
||||
label: 'Discard changes',
|
||||
danger: true,
|
||||
tun: () => { setFrage(null); onClose() },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-fullscreen="" style={{
|
||||
position: 'fixed', inset: 'var(--honeybar-h) 0 0 0', zIndex: 120,
|
||||
background: 'var(--color-background)', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', flexShrink: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{ display: 'flex', flexShrink: 0 }}>
|
||||
<FileTypeIcon mime={file.mime_type} name={file.name} size={18} />
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
minWidth: 0, fontSize: 14, fontWeight: 500, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name.replace(/\.excalidraw$/i, '')}
|
||||
</span>
|
||||
|
||||
{/* Ein Punkt hinter dem Namen ist leicht zu übersehen – und das
|
||||
Übersehene ist hier die ungespeicherte Arbeit. */}
|
||||
{geaendert && (
|
||||
<span style={{
|
||||
flexShrink: 0, padding: '2px 8px', borderRadius: 999,
|
||||
background: 'rgba(var(--color-primary-rgb), 0.18)',
|
||||
color: 'var(--color-primary)', fontSize: 11, fontWeight: 600,
|
||||
}}>
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<button
|
||||
onClick={() => void speichern()}
|
||||
disabled={speichert || !geaendert}
|
||||
title={geaendert ? 'Save (Ctrl+S)' : 'Nothing to save'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||||
background: geaendert ? 'var(--color-primary)' : 'var(--color-surface-2)',
|
||||
color: geaendert ? 'var(--color-primary-fg)' : 'var(--color-text-muted)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
cursor: speichert || !geaendert ? 'default' : 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{speichert ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { if (geaendert) frageSchliessen(); else onClose() }}
|
||||
// **Kein Escape.** Das Zeichenbrett braucht die Taste selbst – zum
|
||||
// Abwählen und zum Verlassen eines Werkzeugs. Wer sie hier abfängt,
|
||||
// schliesst die Zeichnung, während jemand nur ein Rechteck abwählen
|
||||
// wollte. Hinaus geht es über diesen Knopf oder die Zurück-Geste.
|
||||
title="Close"
|
||||
style={{
|
||||
padding: 6, borderRadius: 8, border: 'none', background: 'none',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{frage && (
|
||||
<ConfirmDialog
|
||||
title={frage.titel}
|
||||
message={frage.text}
|
||||
confirmLabel={frage.label}
|
||||
danger={frage.danger}
|
||||
onConfirm={frage.tun}
|
||||
onCancel={() => setFrage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{fehler && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
color: 'var(--color-danger)', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
{fehler}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fehler && !szene && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-text-muted)', fontSize: 13,
|
||||
}}>
|
||||
Opening…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{szene && (
|
||||
<Excalidraw
|
||||
initialData={szene}
|
||||
// Die Oberfläche ist dunkel; ein weisses Zeichenbrett darin war
|
||||
// der auffälligste Bruch. Umschalten kann man es im Menü – für
|
||||
// einen Ausdruck ist Hell die bessere Wahl.
|
||||
theme="dark"
|
||||
name={file.name.replace(/\.excalidraw$/i, '')}
|
||||
autoFocus
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
// **Aus.** Beide laden oder speichern eine Datei am Browser
|
||||
// vorbei: „Open" ersetzte den Inhalt dieser Zeichnung durch
|
||||
// eine fremde, „Save to…" schriebe eine zweite Fassung in den
|
||||
// Download-Ordner, die niemand mehr wiederfindet. In Aether
|
||||
// gehört die Datei dorthin, wo sie liegt.
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
},
|
||||
}}
|
||||
onChange={(elements, appState, files) => {
|
||||
// **Nicht bei jedem Aufruf in den Zustand schreiben.** onChange
|
||||
// feuert bei jeder Mausbewegung; ein setState hier würde das
|
||||
// Zeichenbrett bei jedem Strich neu rendern.
|
||||
aktuell.current = { elements, appState: appState as unknown as Record<string, unknown>, files }
|
||||
const fassung = getSceneVersion(elements)
|
||||
if (gespeicherteFassung.current === null) gespeicherteFassung.current = fassung
|
||||
const offen = fassung !== gespeicherteFassung.current
|
||||
if (offen !== geaendert) setGeaendert(offen)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Was dasteht, wenn nichts dasteht.
|
||||
*
|
||||
* Bisher war das je nach Seite ein grauer Halbsatz („Trash is empty") oder gar
|
||||
* nichts. Beides lässt offen, ob die Liste leer ist oder das Laden
|
||||
* fehlgeschlagen – und beides verschenkt die Stelle, an der jemand am
|
||||
* ehesten Hilfe gebrauchen kann: Er steht vor etwas Leerem und weiß nicht,
|
||||
* wie es sich füllt.
|
||||
*
|
||||
* Deshalb drei Teile, und der dritte ist der wichtige:
|
||||
*
|
||||
* 1. ein Zeichen, damit die Fläche nicht kaputt aussieht
|
||||
* 2. ein Satz, der sagt, **warum** hier nichts ist
|
||||
* 3. der Knopf, der genau hier weiterhilft – wo es einen gibt
|
||||
*
|
||||
* Wo es keinen sinnvollen Knopf gibt (ein leerer Papierkorb ist ein guter
|
||||
* Zustand, kein Mangel), bleibt es bei zweien. Ein Knopf, der nur da ist,
|
||||
* damit einer da ist, führt nirgendwohin.
|
||||
*/
|
||||
export default function EmptyState({ icon, title, hint, action }: {
|
||||
/** Ein Emoji oder ein SVG. Gedämpft – es soll die Fläche tragen, nicht sie beherrschen. */
|
||||
icon: ReactNode
|
||||
title: string
|
||||
hint?: ReactNode
|
||||
action?: { label: string; onClick: () => void }
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', textAlign: 'center',
|
||||
padding: '72px 24px', gap: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 48, opacity: 0.28, lineHeight: 1 }}>{icon}</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{hint && (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 380 }}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: '8px 16px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: 'var(--color-primary)', color: 'var(--color-primary-fg)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { formatBytes } from '../format'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
import TagChip from './TagChip'
|
||||
|
||||
|
||||
@@ -19,17 +19,6 @@ interface Props {
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* Ein Office-Dokument als PDF zeigen statt im Editor.
|
||||
*
|
||||
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
|
||||
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
|
||||
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
|
||||
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
|
||||
*/
|
||||
asOfficePdf?: boolean
|
||||
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
|
||||
onEdit?: () => void
|
||||
/**
|
||||
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
||||
@@ -53,7 +42,7 @@ type Kind = 'image' | 'pdf' | 'text' | 'video' | 'audio' | 'none'
|
||||
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
|
||||
* sich eine vorhandene MP3 also nie abspielen.
|
||||
*/
|
||||
function kindOf(mime: string, name = ''): Kind {
|
||||
export function kindOf(mime: string, name = ''): Kind {
|
||||
const m = (mime || '').toLowerCase()
|
||||
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
|
||||
// SVG-Datei Skripte enthalten darf.
|
||||
@@ -101,12 +90,10 @@ export function canPreview(file: FileItem): boolean {
|
||||
return kindOf(file.mime_type, file.name) !== 'none'
|
||||
}
|
||||
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
|
||||
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
|
||||
// gewandeltes PDF ist es eins.
|
||||
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||
const kind = kindOf(file.mime_type, file.name)
|
||||
const narrow = useNarrow()
|
||||
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
|
||||
const src = filesApi.inline(file.id)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [tooBig, setTooBig] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
|
||||
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
|
||||
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
|
||||
die Ausnahme. */}
|
||||
{asOfficePdf && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{kind === 'text' && !tooBig && text !== null && (
|
||||
editing ? (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TagRow } from './TagDialog'
|
||||
import TagChip from './TagChip'
|
||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||
import FileCover, { FolderCover } from './FileCover'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { formatBytes } from '../format'
|
||||
|
||||
/**
|
||||
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { useCoarsePointer } from './FileViews'
|
||||
|
||||
/**
|
||||
* Ein Dokument im Office-Server bearbeiten.
|
||||
@@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: {
|
||||
const [session, setSession] = useState<OfficeSession | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
// Am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem
|
||||
// Schreibtisch ist immer noch eine Maus und eine Tastatur.
|
||||
const coarse = useCoarsePointer()
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
officeApi.session(file.id)
|
||||
officeApi.session(file.id, coarse)
|
||||
.then(s => {
|
||||
if (!alive) return
|
||||
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
|
||||
@@ -35,7 +39,7 @@ export default function OfficeEditor({ file, onClose }: {
|
||||
setError(err instanceof Error ? err.message : 'Could not open the editor')
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [file.id])
|
||||
}, [file.id, coarse])
|
||||
|
||||
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
||||
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import UserMenu, { useSidebarToggle } from './UserMenu'
|
||||
import { wideEnough } from '../breakpoints'
|
||||
|
||||
/**
|
||||
* Die Kopfleiste jeder Ansicht.
|
||||
@@ -23,8 +24,12 @@ export const headerHeight = 57
|
||||
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
||||
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
||||
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
||||
*
|
||||
* Dieselbe Grenze schaltet die Seitenleiste um; sie steht deshalb in
|
||||
* `breakpoints.ts` und nicht mehr hier. Re-exportiert, weil sie hier gesucht
|
||||
* wird.
|
||||
*/
|
||||
export const wideEnough = '(min-width: 720px)'
|
||||
export { wideEnough }
|
||||
|
||||
interface Props {
|
||||
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
||||
|
||||
@@ -3,6 +3,10 @@ import { filesApi } from '../api/client'
|
||||
import * as pdfjs from 'pdfjs-dist'
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
import PdfToolbar from './pdf/PdfToolbar'
|
||||
import { commonWidth, padOf, pageGap, type PageSize } from './pdf/geometry'
|
||||
import { countIn, wordAround, type Hit } from './pdf/text'
|
||||
import { nextFine, nextStep } from './pdf/zoom'
|
||||
import './pdf.css'
|
||||
|
||||
/**
|
||||
@@ -23,6 +27,10 @@ import './pdf.css'
|
||||
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
||||
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
||||
* schreiben kann, wäre ein Widerspruch dazu.
|
||||
*
|
||||
* Hier steht, was am DOM hängt: laden, zeichnen, rollen, zoomen, färben. Was
|
||||
* sich ohne Browser ausrechnen lässt, liegt daneben in `pdf/` – Zoomstufen,
|
||||
* Seitenmaße, Suche im Text – und wird dort auch geprüft.
|
||||
*/
|
||||
|
||||
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
|
||||
@@ -39,63 +47,45 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
|
||||
*/
|
||||
const standardFontDataUrl = '/pdfjs/standard_fonts/'
|
||||
|
||||
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
|
||||
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
|
||||
/**
|
||||
* Wie viele Seitenmaße gleichzeitig geholt werden.
|
||||
*
|
||||
* Vorher lief die Schleife streng nacheinander: Bei einem 200-seitigen Scan
|
||||
* waren das 200 Runden, bevor überhaupt etwas zu sehen war. Alle auf einmal
|
||||
* wäre das andere Extrem – PDF.js hält dann 200 Seitenobjekte zugleich.
|
||||
*/
|
||||
const sizeBatch = 24
|
||||
|
||||
/**
|
||||
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
|
||||
* Zahlen gerastet – 65, 70, 75, 80 …
|
||||
* Wartezeit, bevor eine Eingabe im Suchfeld das ganze Dokument durchsucht.
|
||||
*
|
||||
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
|
||||
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
|
||||
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
|
||||
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
|
||||
* Raster, und das hier ist eines.
|
||||
*
|
||||
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
|
||||
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
|
||||
* Ohne sie las jeder einzelne Tastendruck den Text **aller** Seiten neu aus.
|
||||
* Wer „Rechnung" tippt, löste damit acht vollständige Durchläufe aus, von
|
||||
* denen sieben schon veraltet waren, bevor sie fertig wurden. Ein Sechstel
|
||||
* einer Sekunde bemerkt beim Tippen niemand.
|
||||
*/
|
||||
const zoomStepPercent = 5
|
||||
const minScale = 0.25
|
||||
const maxScale = 4
|
||||
const searchDelay = 160
|
||||
|
||||
/**
|
||||
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
|
||||
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
|
||||
* als ein Zehntel der Darstellungsgröße.
|
||||
*/
|
||||
function padOf(width: number): number {
|
||||
return width < 560 ? 4 : 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
|
||||
* Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet
|
||||
* wird.
|
||||
*
|
||||
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
|
||||
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
|
||||
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
|
||||
* nicht mittig. Macht 2 × 275 + 492.
|
||||
* **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die
|
||||
* Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht
|
||||
* zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter
|
||||
* Pixeldichte sind zwanzig Bitmaps zu je rund acht Megabyte – der Abbruch der
|
||||
* vorigen half nur gegen die Rechenzeit, nicht gegen das Belegen und Freigeben.
|
||||
*
|
||||
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
|
||||
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
|
||||
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
|
||||
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
|
||||
* der Steuerung nichts mehr zu sehen.
|
||||
* Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und
|
||||
* der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also
|
||||
* von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen
|
||||
* weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen.
|
||||
*
|
||||
* Die Textebene wandert korrekt mit: PDF.js setzt ihre Stücke in **Prozent**
|
||||
* des Kastens, und die Schriftgröße hängt an `--total-scale-factor`. Beides
|
||||
* folgt dem Layout, nicht der Leinwand.
|
||||
*/
|
||||
const minGridWidth = 2 * 275 + 492
|
||||
|
||||
/**
|
||||
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
|
||||
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
|
||||
*/
|
||||
const counterWidth = 54
|
||||
const hitButtonsWidth = 60
|
||||
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
|
||||
const hitsWidth = counterWidth + hitButtonsWidth + 6
|
||||
|
||||
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
|
||||
* wird, wenn die sichtbare Seite bestimmt wird. */
|
||||
const pageGap = 12
|
||||
const zoomSettle = 120
|
||||
|
||||
interface Props {
|
||||
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
||||
@@ -109,23 +99,22 @@ interface Props {
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
|
||||
*
|
||||
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
|
||||
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
|
||||
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
|
||||
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
|
||||
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
|
||||
*/
|
||||
interface Hit { page: number; nth: number; exact: boolean }
|
||||
|
||||
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
|
||||
const [sizes, setSizes] = useState<PageSize[]>([])
|
||||
const [scale, setScale] = useState<number | 'fit'>('fit')
|
||||
const [fitScale, setFitScale] = useState(1)
|
||||
/**
|
||||
* Ist die Einpassung schon gemessen?
|
||||
*
|
||||
* Ohne diese Wache zeichnete jedes Öffnen **einmal umsonst**: Die Maße
|
||||
* kommen an, der Zeichner läuft los – und erst danach steht fest, dass das
|
||||
* Blatt nicht auf 100 %, sondern auf 65 % gehört. Die halbfertige Leinwand
|
||||
* wurde weggeworfen. Bei einem A4-Blatt in doppelter Pixeldichte sind das
|
||||
* acht Megabyte, die niemand je zu sehen bekam.
|
||||
*/
|
||||
const [fitReady, setFitReady] = useState(false)
|
||||
const [current, setCurrent] = useState(1)
|
||||
|
||||
const [query, setQuery] = useState(searchTerm ?? '')
|
||||
@@ -141,16 +130,33 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
/** Der Maßstab, in dem das Blatt **liegt** – Kastengröße, Textebene, Anzeige. */
|
||||
const effScale = scale === 'fit' ? fitScale : scale
|
||||
/**
|
||||
* Der Maßstab, in dem zuletzt **gemalt** wurde. Er hinkt während einer Geste
|
||||
* hinterher; solange streckt der Browser das vorhandene Bild. Siehe
|
||||
* `zoomSettle`.
|
||||
*/
|
||||
const [renderScale, setRenderScale] = useState(effScale)
|
||||
useEffect(() => {
|
||||
if (renderScale === effScale) return
|
||||
const t = setTimeout(() => setRenderScale(effScale), zoomSettle)
|
||||
return () => clearTimeout(t)
|
||||
}, [effScale, renderScale])
|
||||
/** Läuft gerade eine Geste – ist das Blatt also nur gestreckt? */
|
||||
const stretched = renderScale !== effScale
|
||||
|
||||
// ---------------------------------------------------------------- Laden
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let task: ReturnType<typeof pdfjs.getDocument> | null = null
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1)
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
|
||||
// Die Verweise auf die Seitenkästen gehören zum alten Dokument. Ohne das
|
||||
// blieben bei einem kürzeren nächsten Dokument Einträge stehen, die auf
|
||||
// längst entfernte Elemente zeigen.
|
||||
pageRefs.current = []
|
||||
|
||||
task = pdfjs.getDocument({
|
||||
const task = pdfjs.getDocument({
|
||||
url,
|
||||
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
||||
withCredentials: true,
|
||||
@@ -165,10 +171,15 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
|
||||
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
||||
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
||||
const s: { w: number; h: number }[] = []
|
||||
for (let i = 1; i <= d.numPages; i++) {
|
||||
const v = (await d.getPage(i)).getViewport({ scale: 1 })
|
||||
s.push({ w: v.width, h: v.height })
|
||||
const s: PageSize[] = []
|
||||
for (let from = 1; from <= d.numPages; from += sizeBatch) {
|
||||
const to = Math.min(from + sizeBatch - 1, d.numPages)
|
||||
const batch = await Promise.all(
|
||||
Array.from({ length: to - from + 1 }, (_, i) =>
|
||||
d.getPage(from + i).then(p => p.getViewport({ scale: 1 }))),
|
||||
)
|
||||
if (!alive) return
|
||||
for (const v of batch) s.push({ w: v.width, h: v.height })
|
||||
}
|
||||
if (!alive) return
|
||||
setSizes(s)
|
||||
@@ -185,7 +196,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
)
|
||||
return () => {
|
||||
alive = false
|
||||
task?.destroy()
|
||||
task.destroy()
|
||||
}
|
||||
}, [url])
|
||||
|
||||
@@ -210,9 +221,6 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
*
|
||||
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
|
||||
* schmaler als das Blatt, die Breite also unter 100 %.
|
||||
*
|
||||
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
|
||||
* breiteste – warum, steht bei `commonWidth`.
|
||||
*/
|
||||
const recomputeFit = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -227,6 +235,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
||||
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
||||
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
||||
setFitReady(true)
|
||||
}, [sizes])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -248,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
||||
* ihre Leinwand frei.
|
||||
*/
|
||||
/**
|
||||
* Steht der Maßstab fest? Bewusst als **ein Wahrheitswert** in der
|
||||
* Abhängigkeitsliste und nicht als `scale`: Der Zeichner darf sich nicht bei
|
||||
* jedem Zoomschritt neu aufbauen, sonst wäre die Geste wieder so teuer wie
|
||||
* vorher. Dieser Wert kippt einmal von falsch auf wahr.
|
||||
*/
|
||||
const scaleKnown = scale !== 'fit' || fitReady
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || sizes.length === 0) return
|
||||
if (!doc || sizes.length === 0 || !scaleKnown) return
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
|
||||
@@ -263,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!doc || !host || drawn.has(n)) return
|
||||
drawn.add(n)
|
||||
const page = await doc.getPage(n)
|
||||
const viewport = page.getViewport({ scale: effScale })
|
||||
const viewport = page.getViewport({ scale: renderScale })
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -330,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
io.disconnect()
|
||||
for (const t of running.values()) t.cancel()
|
||||
}
|
||||
}, [doc, sizes, effScale])
|
||||
}, [doc, sizes, renderScale, scaleKnown])
|
||||
|
||||
// --------------------------------------------------- Sichtbare Seitenzahl
|
||||
|
||||
@@ -403,10 +420,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
*/
|
||||
useEffect(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
|
||||
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); setSearching(false); return }
|
||||
let alive = true
|
||||
setSearching(true)
|
||||
;(async () => {
|
||||
|
||||
// Erst abwarten, ob noch ein Zeichen kommt – siehe `searchDelay`.
|
||||
const timer = setTimeout(() => void (async () => {
|
||||
const found: Hit[] = []
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (!alive) return
|
||||
@@ -446,9 +465,9 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!alive) return
|
||||
setHits(found)
|
||||
setHitIndex(0)
|
||||
setSearching(false)
|
||||
})()
|
||||
return () => { alive = false; setSearching(false) }
|
||||
})().finally(() => { if (alive) setSearching(false) }), searchDelay)
|
||||
|
||||
return () => { alive = false; clearTimeout(timer) }
|
||||
}, [doc, query, fileId])
|
||||
|
||||
/**
|
||||
@@ -466,12 +485,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
||||
*
|
||||
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
|
||||
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
|
||||
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
|
||||
* Textebene.
|
||||
* Kästen, die der Browser auch beim Markieren mit der Maus malt.
|
||||
*
|
||||
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
|
||||
* warum, steht bei `wordAround` unten.
|
||||
* warum, steht bei `wordAround` in pdf/text.ts.
|
||||
*
|
||||
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
|
||||
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
|
||||
@@ -555,6 +572,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
}
|
||||
}, [query, hits])
|
||||
|
||||
/**
|
||||
* Während einer Zoomgeste die Färbung wegnehmen.
|
||||
*
|
||||
* Die Kästen sind das einzige im Seitenkasten, das in **Pixeln** sitzt –
|
||||
* Leinwand und Textebene rechnen in Prozent und wandern von selbst mit. Ein
|
||||
* stehengebliebener Kasten läge nach zwei Stufen sichtbar neben dem Wort.
|
||||
* Nach dem Zeichnen kommt er von selbst zurück (`pdf-text-ready`).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!stretched) return
|
||||
for (const host of pageRefs.current) {
|
||||
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
|
||||
}
|
||||
}, [stretched])
|
||||
|
||||
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
||||
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
||||
const target = hits[hitIndex]
|
||||
@@ -604,6 +636,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
|
||||
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
|
||||
|
||||
const stepHit = useCallback((delta: number) => {
|
||||
// Ohne die Wache teilte der Rest durch null und der Zeiger stünde auf
|
||||
// NaN. Die Leiste ruft das zwar nur mit Treffern, aber ein Zähler, der
|
||||
// sich nicht mehr erholt, ist ein teurer Preis für eine Annahme.
|
||||
if (hits.length === 0) return
|
||||
setHitIndex(i => (i + delta + hits.length) % hits.length)
|
||||
}, [hits.length])
|
||||
|
||||
// ------------------------------------------------------------------ Zoom
|
||||
|
||||
/**
|
||||
@@ -625,6 +665,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
||||
const scaleRef = useRef(effScale)
|
||||
scaleRef.current = effScale
|
||||
|
||||
useEffect(() => {
|
||||
const anchor = keepAnchor.current
|
||||
if (anchor) {
|
||||
@@ -643,41 +684,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
goToPage(n)
|
||||
}, [effScale, goToPage])
|
||||
|
||||
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
|
||||
function nextStep(now: number, dir: 1 | -1): number | undefined {
|
||||
return dir > 0
|
||||
? zoomSteps.find(s => s > now + 0.001)
|
||||
: [...zoomSteps].reverse().find(s => s < now - 0.001)
|
||||
}
|
||||
/** Merkt den Punkt unter Zeiger oder Fingermitte, damit er dort bleibt. */
|
||||
const anchorAt = useCallback((cx: number, cy: number, from: number) => {
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
const r = root.getBoundingClientRect()
|
||||
keepAnchor.current = {
|
||||
px: root.scrollLeft + (cx - r.left), py: root.scrollTop + (cy - r.top),
|
||||
cx: cx - r.left, cy: cy - r.top, from,
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
|
||||
* Anschlag.
|
||||
*
|
||||
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
|
||||
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
|
||||
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
|
||||
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
|
||||
* Fünfern weiter.
|
||||
*/
|
||||
function nextFine(now: number, dir: 1 | -1): number | undefined {
|
||||
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
|
||||
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
|
||||
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
|
||||
// genau eine Stufe, danach nichts mehr.
|
||||
const p = Math.round(now * 100 * 1e6) / 1e6
|
||||
const step = zoomStepPercent
|
||||
const raw = dir > 0
|
||||
? Math.floor(p / step + 1) * step
|
||||
: Math.ceil(p / step - 1) * step
|
||||
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
|
||||
return Math.abs(next - now) < 0.001 ? undefined : next
|
||||
}
|
||||
|
||||
function zoom(dir: 1 | -1) {
|
||||
const next = nextStep(effScale, dir)
|
||||
const zoom = useCallback((dir: 1 | -1) => {
|
||||
const next = nextStep(scaleRef.current, dir)
|
||||
if (next) { keepPage.current = current; setScale(next) }
|
||||
}
|
||||
}, [current])
|
||||
|
||||
/**
|
||||
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
|
||||
@@ -694,31 +715,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
const root2 = scrollRef.current
|
||||
if (!root2) return
|
||||
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
|
||||
const next = nextFine(effScale, dir)
|
||||
const from = scaleRef.current
|
||||
const next = nextFine(from, e.deltaY < 0 ? 1 : -1)
|
||||
if (!next) return
|
||||
const r = root2.getBoundingClientRect()
|
||||
const cx = e.clientX - r.left
|
||||
const cy = e.clientY - r.top
|
||||
keepAnchor.current = {
|
||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
||||
cx, cy, from: effScale,
|
||||
}
|
||||
anchorAt(e.clientX, e.clientY, from)
|
||||
setScale(next)
|
||||
}
|
||||
root.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => root.removeEventListener('wheel', onWheel)
|
||||
}, [effScale])
|
||||
}, [anchorAt])
|
||||
|
||||
/**
|
||||
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
||||
*
|
||||
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
||||
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
|
||||
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
|
||||
* bisher nur die Knöpfe blieben.
|
||||
* hinterher stand alles verschoben.
|
||||
*
|
||||
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
|
||||
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
|
||||
@@ -744,9 +756,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!root) return
|
||||
|
||||
function distance(t: TouchList) {
|
||||
const dx = t[0].clientX - t[1].clientX
|
||||
const dy = t[0].clientY - t[1].clientY
|
||||
return Math.hypot(dx, dy)
|
||||
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
|
||||
}
|
||||
|
||||
function onStart(e: TouchEvent) {
|
||||
@@ -783,17 +793,13 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (dir < 0 && ratio > Math.min(0.98, needed)) return
|
||||
|
||||
pinchBase.current = now
|
||||
const root2 = scrollRef.current
|
||||
if (!root2) return
|
||||
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
||||
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
||||
const r = root2.getBoundingClientRect()
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
|
||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
|
||||
keepAnchor.current = {
|
||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
||||
cx, cy, from,
|
||||
}
|
||||
anchorAt(
|
||||
(e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
(e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
from,
|
||||
)
|
||||
setScale(next)
|
||||
}
|
||||
|
||||
@@ -812,60 +818,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
root.removeEventListener('touchend', onEnd)
|
||||
root.removeEventListener('touchcancel', onEnd)
|
||||
}
|
||||
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
|
||||
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
|
||||
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
|
||||
// dann über genau eine Stufe nicht hinaus.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
// `anchorAt` ist über useCallback stabil, der Zuhörer meldet sich also
|
||||
// **einmal** an. Hinge er am Maßstab, meldete er sich nach jeder Stufe neu
|
||||
// an – mitten in der Geste, und der gemerkte Fingerabstand wäre weg.
|
||||
// Gemessen: Eine lange Geste kam dann über genau eine Stufe nicht hinaus.
|
||||
}, [anchorAt])
|
||||
|
||||
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
||||
|
||||
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
|
||||
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
|
||||
* ersten Zeile. */
|
||||
const zoomGroup = (
|
||||
<>
|
||||
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
|
||||
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
|
||||
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
|
||||
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
|
||||
tut, ist schlimmer als keiner. */}
|
||||
<button
|
||||
onClick={() => { keepPage.current = current; setScale('fit') }}
|
||||
disabled={scale === 'fit'}
|
||||
title="Fit width"
|
||||
style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
|
||||
border: 'none', background: 'none',
|
||||
cursor: scale === 'fit' ? 'default' : 'pointer',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{Math.round(effScale * 100)}%
|
||||
</button>
|
||||
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
|
||||
</>
|
||||
)
|
||||
/**
|
||||
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
|
||||
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
|
||||
*/
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const [barNarrow, setBarNarrow] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = barRef.current
|
||||
if (!el) return
|
||||
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
|
||||
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
|
||||
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
|
||||
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
|
||||
set()
|
||||
const ro = new ResizeObserver(set)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const [pad, setPad] = useState(16)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -892,137 +852,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
||||
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
||||
}}>
|
||||
{/*
|
||||
Werkzeugleiste.
|
||||
|
||||
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
|
||||
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
|
||||
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
|
||||
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
|
||||
landete gemessen 79 bzw. 142 Pixel daneben.
|
||||
|
||||
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
|
||||
beim Umbruch, und die Suche steht in der zweiten Zeile.
|
||||
*/}
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
display: barNarrow ? 'flex' : 'grid',
|
||||
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
|
||||
alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||||
padding: '6px 10px', flexShrink: 0,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<ToolButton label="Previous page" disabled={current <= 1}
|
||||
onClick={() => goToPage(current - 1)} glyph="up" />
|
||||
<span style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)',
|
||||
minWidth: 62, flexShrink: 0,
|
||||
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{doc ? `${current} / ${doc.numPages}` : '…'}
|
||||
</span>
|
||||
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
|
||||
onClick={() => goToPage(current + 1)} glyph="down" />
|
||||
{barNarrow && (
|
||||
<>
|
||||
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
|
||||
{zoomGroup}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
|
||||
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
|
||||
wie ein Zusatz, der nicht recht dazugehört. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
|
||||
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
|
||||
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
|
||||
ansieht, nicht der Kasten drumherum.
|
||||
|
||||
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
|
||||
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
|
||||
rechts der zweite Sprungknopf aus dem Bild fiel. */}
|
||||
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
|
||||
}
|
||||
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
|
||||
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
|
||||
}}
|
||||
placeholder="Find in document"
|
||||
style={{
|
||||
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
|
||||
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
|
||||
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
|
||||
// jetzt auch hinein, ohne zu wandern.
|
||||
width: 240, minWidth: 0,
|
||||
padding: '5px 8px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-background)', color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 11, color: 'var(--color-text-muted)',
|
||||
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
|
||||
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
|
||||
width: counterWidth, flexShrink: 0,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{searching ? 'Searching…'
|
||||
: query.trim().length < 2 ? ''
|
||||
: hits.length === 0 ? 'No hits'
|
||||
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
|
||||
</span>
|
||||
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
|
||||
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
|
||||
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
|
||||
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
|
||||
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
|
||||
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
|
||||
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
|
||||
schon. */}
|
||||
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
|
||||
{hits.length > 0 && (
|
||||
<>
|
||||
<ToolButton label="Previous hit" glyph="hitUp"
|
||||
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
|
||||
<ToolButton label="Next hit" glyph="hitDown"
|
||||
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Die dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
|
||||
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
|
||||
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
|
||||
auch etwas.
|
||||
|
||||
Schmal steht der Zoom **nicht** hier, sondern oben neben der
|
||||
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
|
||||
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
|
||||
Dokument ab. */}
|
||||
{!barNarrow && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
justifyContent: 'flex-end', minWidth: 0,
|
||||
}}>
|
||||
{zoomGroup}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PdfToolbar
|
||||
page={current}
|
||||
pageCount={doc?.numPages ?? null}
|
||||
onGoPage={goToPage}
|
||||
percent={Math.round(effScale * 100)}
|
||||
isFit={scale === 'fit'}
|
||||
onZoom={zoom}
|
||||
onFit={() => { keepPage.current = current; setScale('fit') }}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
searching={searching}
|
||||
hitCount={hits.length}
|
||||
hitIndex={hitIndex}
|
||||
pageOnlyHits={!!hits[0] && !hits[0].exact}
|
||||
onStepHit={stepHit}
|
||||
/>
|
||||
|
||||
{/* Seiten */}
|
||||
<div
|
||||
@@ -1070,124 +915,3 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die häufigste Seitenbreite eines Dokuments.
|
||||
*
|
||||
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
|
||||
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
|
||||
* stimmt – nur ist der Preis höher als der Gewinn.
|
||||
*
|
||||
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
|
||||
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
|
||||
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
|
||||
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
|
||||
* so die Darstellung des ganzen Dokuments.
|
||||
*
|
||||
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
|
||||
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
|
||||
* ohnehin tun will.
|
||||
*
|
||||
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
|
||||
*/
|
||||
function commonWidth(sizes: { w: number; h: number }[]): number {
|
||||
const count = new Map<number, number>()
|
||||
for (const s of sizes) {
|
||||
const w = Math.round(s.w)
|
||||
count.set(w, (count.get(w) ?? 0) + 1)
|
||||
}
|
||||
let best = 0, bestN = 0
|
||||
for (const [w, n] of count) {
|
||||
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
|
||||
}
|
||||
return best || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Weitet eine Fundstelle auf das **ganze Wort** aus.
|
||||
*
|
||||
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
|
||||
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
|
||||
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
|
||||
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
|
||||
* um ein bis drei Pixel anders als die gemalten.
|
||||
*
|
||||
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
|
||||
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
|
||||
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
|
||||
*
|
||||
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
|
||||
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
|
||||
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
|
||||
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
|
||||
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
|
||||
*
|
||||
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
|
||||
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
|
||||
*/
|
||||
const maxWordSnap = 40
|
||||
|
||||
function wordAround(text: string, at: number, to: number): [number, number] {
|
||||
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
|
||||
let s = at, e = to
|
||||
while (s > 0 && isWord(text[s - 1])) s--
|
||||
while (e < text.length && isWord(text[e])) e++
|
||||
return e - s > maxWordSnap ? [at, to] : [s, e]
|
||||
}
|
||||
|
||||
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
|
||||
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
|
||||
const out: Hit[] = []
|
||||
let from = 0, nth = 0
|
||||
for (;;) {
|
||||
const at = haystack.indexOf(needle, from)
|
||||
if (at < 0) break
|
||||
out.push({ page, nth: nth++, exact })
|
||||
from = at + needle.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
|
||||
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
|
||||
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
|
||||
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
|
||||
*
|
||||
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
|
||||
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
|
||||
* wenn es Fundstellen gibt.
|
||||
*/
|
||||
const glyphs: Record<string, string> = {
|
||||
up: '18 15 12 9 6 15',
|
||||
down: '6 9 12 15 18 9',
|
||||
}
|
||||
|
||||
function ToolButton({ label, onClick, disabled, glyph }: {
|
||||
label: string; onClick: () => void; disabled?: boolean
|
||||
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{
|
||||
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
|
||||
background: 'none', color: 'var(--color-text-muted)',
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
|
||||
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
|
||||
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
|
||||
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
|
||||
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
||||
resource: { type: 'folder' | 'file'; id: string; name: string }
|
||||
resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [list, people] = await Promise.all([
|
||||
resource.type === 'folder'
|
||||
? aclApi.listForFolder(resource.id)
|
||||
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
|
||||
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
|
||||
: aclApi.listForFile(resource.id),
|
||||
aclApi.recipients(),
|
||||
])
|
||||
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (resource.type === 'folder') {
|
||||
if (resource.type === 'calendar') {
|
||||
await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission)
|
||||
} else if (resource.type === 'folder') {
|
||||
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
||||
} else {
|
||||
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
||||
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resource.type === 'calendar' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
The calendar shows up in their app too — web and CalDAV alike.
|
||||
With “can edit” they may add and change events; renaming or
|
||||
deleting the calendar stays with you.
|
||||
</p>
|
||||
)}
|
||||
{resource.type === 'folder' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Everything inside this folder is shared too, including anything added later.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { filesApi } from '../api/client'
|
||||
import type { FolderItem, User } from '../api/client'
|
||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { Toaster } from './Toasts'
|
||||
import Sidebar from './Sidebar'
|
||||
import FileBrowser from '../pages/FileBrowser'
|
||||
import TrashPage from '../pages/TrashPage'
|
||||
@@ -12,7 +13,9 @@ import SharedWithMePage from '../pages/SharedWithMePage'
|
||||
import AdminPage from '../pages/AdminPage'
|
||||
import ProfilePage from '../pages/ProfilePage'
|
||||
import TagsPage from '../pages/TagsPage'
|
||||
import CalendarPage from '../pages/CalendarPage'
|
||||
import { UserMenuProvider } from './UserMenu'
|
||||
import { wideEnough } from '../breakpoints'
|
||||
|
||||
export type { View }
|
||||
|
||||
@@ -43,6 +46,19 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
||||
})
|
||||
|
||||
// Wird das Gerät aufgeklappt, während die Leiste aufgeschoben war, steht sie
|
||||
// ab da ohnehin fest im Bild. Bliebe „aufgeschoben" dabei stehen, schluckte
|
||||
// die Zurück-Geste einmal ins Leere – sie zielte auf etwas, das nichts mehr
|
||||
// verdeckt.
|
||||
useEffect(() => {
|
||||
if (typeof matchMedia !== 'function') return
|
||||
const mq = matchMedia(wideEnough)
|
||||
const auf = () => { if (mq.matches) setSidebarOpen(false) }
|
||||
auf()
|
||||
mq.addEventListener('change', auf)
|
||||
return () => mq.removeEventListener('change', auf)
|
||||
}, [])
|
||||
|
||||
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||||
// hängt daran und holt sich daraufhin neu.
|
||||
const [trashBump, setTrashBump] = useState(0)
|
||||
@@ -80,9 +96,18 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
onOpenSidebar: () => setSidebarOpen(true),
|
||||
}}>
|
||||
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
|
||||
{/* Mobile overlay */}
|
||||
{/* Die Meldungen liegen über allem und sind an genau einer Stelle
|
||||
eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur
|
||||
diese eine Anzeige. */}
|
||||
<Toaster />
|
||||
|
||||
{/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste.
|
||||
Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht:
|
||||
Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber
|
||||
machte sie unanklickbar – sichtbar, aber taub. */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="aether-scrim"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
@@ -105,13 +130,13 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
{view === 'files' && (
|
||||
<FileBrowser
|
||||
user={user}
|
||||
folderPath={folderPath}
|
||||
onNavigate={nav.setFolderPath}
|
||||
onUserChanged={onUserChanged}
|
||||
reloadSignal={trashBump}
|
||||
/>
|
||||
)}
|
||||
{view === 'calendar' && <CalendarPage />}
|
||||
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
|
||||
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
|
||||
{view === 'shared' && <SharedPage />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RenameIcon, TrashIcon } from './icons'
|
||||
import type { User } from '../api/client'
|
||||
import type { View } from './Shell'
|
||||
import { useAppSettings } from '../hooks/useAppSettings'
|
||||
import { wideEnough } from '../breakpoints'
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
@@ -36,6 +37,16 @@ const nav: { id: View; label: string; icon: React.ReactNode }[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Calendar',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
label: 'Trash',
|
||||
@@ -765,7 +776,11 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag, onTrashDro
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Schmal: Die Leiste liegt über dem Inhalt statt neben ihm.
|
||||
Die Grenze ist dieselbe, die in der Kopfleiste den Knopf zum
|
||||
Aufschieben zeigt – hätte sie eine eigene, gäbe es dazwischen einen
|
||||
Streifen ohne Menü und ohne Knopf dorthin. */
|
||||
@media not all and ${wideEnough} {
|
||||
.aether-sidebar {
|
||||
position: fixed;
|
||||
top: var(--honeybar-h); left: 0;
|
||||
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user