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
|
# Hier steht, was die ANWENDUNG liest. Nichts davon hängt daran, ob du das
|
||||||
# (docker-compose.example.yml hier im Wurzelverzeichnis) und die Entwicklung
|
# fertige Abbild ziehst (docker-compose.example.yml) oder selbst baust
|
||||||
# aus dem Quelltext (build/docker-compose.yml, liest build/.env).
|
# (build/docker-compose.yml) – zum Entwickeln: cp .env.example build/.env
|
||||||
# Für die Entwicklung: 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:
|
# Zwei Fallen beim Bearbeiten:
|
||||||
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
||||||
@@ -48,9 +52,17 @@ ADMIN_PASSWORD=
|
|||||||
TRUSTED_PROXIES=172.18.0.1
|
TRUSTED_PROXIES=172.18.0.1
|
||||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe, davor
|
# Wer den Port erreichen darf.
|
||||||
# gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
# 0.0.0.0 alles, was den Server im Netz erreicht (Vorgabe)
|
||||||
APP_BIND=127.0.0.1
|
# 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
|
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
|
||||||
|
|
||||||
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
|
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -68,6 +80,20 @@ TMP_PATH=/data/tmp
|
|||||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||||
MAX_VERSIONS_KEPT=5
|
MAX_VERSIONS_KEPT=5
|
||||||
|
|
||||||
|
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||||
|
# Standardmäßig an. Fehlen die Programme im Abbild, schaltet sich der Arbeiter
|
||||||
|
# beim Start selbst ab, statt den Start zu verhindern.
|
||||||
|
OCR_ENABLED=true
|
||||||
|
# Sprachen für Tesseract. Jede zusätzliche kostet Zeit – nimm nur, was vorkommt.
|
||||||
|
OCR_LANGUAGES=deu+eng
|
||||||
|
# Seiten je Datei; 0 = unbegrenzt. Die Grenze ist Absicht: OCR ist das einzige
|
||||||
|
# Stück dieser Anwendung, das ohne Zutun eines Menschen Platz und Rechenzeit
|
||||||
|
# verbraucht. Wer regelmäßig lange Scans ablegt, setzt sie herauf.
|
||||||
|
OCR_MAX_PAGES=50
|
||||||
|
# Abbruch je Datei. Ein Scan, der so lange braucht, wird auch beim vierten
|
||||||
|
# Versuch nicht fertig.
|
||||||
|
OCR_TIMEOUT=10m
|
||||||
|
|
||||||
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
||||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
||||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
||||||
@@ -86,35 +112,27 @@ OIDC_LINK_BY_USERNAME=false
|
|||||||
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
|
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
|
||||||
|
|
||||||
# ══ Office (optional) ═════════════════════════════════════════════════════════
|
# ══ Office (optional) ═════════════════════════════════════════════════════════
|
||||||
# Leer = aus, auch wenn ein Office-Container läuft.
|
# Leer = aus. Compose-Schnipsel, Adressen und Fallstricke: README.md.
|
||||||
# Compose-Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
|
# Bei Collabora muss OFFICE_WOPI_URL die öffentliche Adresse der Cloud sein,
|
||||||
# collabora | onlyoffice (auch Euro-Office)
|
# sonst verweigert der Browser den Rahmen.
|
||||||
OFFICE_PROVIDER=
|
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
|
||||||
|
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
|
||||||
# Drei Adressen für drei Richtungen – die häufigste Fehlerquelle.
|
# Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
|
||||||
#
|
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
|
||||||
# Backend → Office. Port je nach Anbieter:
|
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
|
||||||
# Collabora http://office:9980
|
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
|
||||||
# OnlyOffice/Euro-Office http://office
|
# ist fast immer falsch.
|
||||||
OFFICE_URL=http://office:9980
|
OFFICE_PUBLIC_URL=
|
||||||
# Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name.
|
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
|
||||||
OFFICE_PUBLIC_URL=https://office.example.com
|
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
|
||||||
# Office → Aether.
|
# Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet
|
||||||
#
|
# die Zertifikatsprüfung **ab** – im Internet nichts zu suchen.
|
||||||
# **Bei Collabora die öffentliche Adresse der Cloud**, nicht app:8080: Aus
|
OFFICE_INSECURE_TLS=false
|
||||||
# diesem Wert baut Collabora sein frame-ancestors, und der Browser darf den
|
# Liest **der Office-Container**, nicht Aether: Es schützt seine eigene API.
|
||||||
# Rahmen sonst nicht zeigen ("Framing … violates 'frame-ancestors …'").
|
# Der WOPI-Weg braucht es nicht, der hat eigene Token. Bei OnlyOffice und
|
||||||
# Collabora holt die Datei dann über deinen Reverse Proxy.
|
# Euro-Office mit JWT_ENABLED=true gehört es gesetzt, sonst steht deren API
|
||||||
OFFICE_WOPI_URL=https://cloud.example.com
|
# offen. Collabora kennt es nicht. openssl rand -hex 32
|
||||||
# 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.
|
|
||||||
OFFICE_JWT_SECRET=
|
OFFICE_JWT_SECRET=
|
||||||
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
|
||||||
|
|
||||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||||
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
|
# 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_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
|
||||||
# OCR_TIMEOUT=10m # Notbremse je Datei
|
# 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) ═════════════════════════════════════════════════
|
# ══ HoneyOS-Leiste (optional) ═════════════════════════════════════════════════
|
||||||
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
||||||
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
|
# 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.
|
# Wirkt beim **Neustart** – kein Neubau nötig.
|
||||||
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
||||||
HONEYBAR_ORIGIN=
|
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 ════════════════════════════════════════════════════════════════════
|
# ══ 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_URL=http://meilisearch:7700
|
||||||
# MEILISEARCH_KEY=
|
# MEILISEARCH_KEY=
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
# Secrets
|
# Secrets
|
||||||
build/.env
|
build/.env*
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
||||||
@@ -29,3 +29,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
||||||
frontend/public/pdfjs/
|
frontend/public/pdfjs/
|
||||||
|
# Excalidraws Schriften – aus dem Paket kopiert, nicht eingecheckt (siehe
|
||||||
|
# vite.config.ts). Fremde Binaerdateien gehoeren nicht in den Quelltext.
|
||||||
|
frontend/public/excalidraw/
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# Bestandsaufnahme, 12.09.2026
|
||||||
|
|
||||||
|
> **Stand 24.09.2026.** Dieses Dokument ist die Aufnahme vom 12.09. Was seither
|
||||||
|
> erledigt wurde, steht an Ort und Stelle durchgestrichen oder vermerkt; die
|
||||||
|
> Kurzfassung:
|
||||||
|
>
|
||||||
|
> | | |
|
||||||
|
> |---|---|
|
||||||
|
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
|
||||||
|
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
|
||||||
|
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
|
||||||
|
> | Go-Tests `rescan` | ✅ 5 Testfunktionen (26.09.) – dabei eine fehlende Notbremse gefunden |
|
||||||
|
> | Go-Tests `audit`, `thumbnails` | **bewusst nicht** – siehe unten |
|
||||||
|
> | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests |
|
||||||
|
>
|
||||||
|
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
|
||||||
|
> sie keine Befunde abarbeitete: Rückmeldungen mit Rückgängig, Platzhalter,
|
||||||
|
> Leerzustände, Sprache vereinheitlicht, `Intl`. Steht in
|
||||||
|
> [TESTING.md](TESTING.md).
|
||||||
|
|
||||||
|
Einmal durch die ganze Cloud: Code, Sicherheit, offene Enden. Geordnet nach
|
||||||
|
dem, was passieren kann — nicht nach Aufwand.
|
||||||
|
|
||||||
|
Was hier steht, ist **gemessen**, nicht geschätzt: Routen aus `main.go`
|
||||||
|
extrahiert und gegen die Rechteprüfungen in den Handlern abgeglichen,
|
||||||
|
`govulncheck` über den ganzen Baum, Zeilen und Testumfang je Paket gezählt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Ein Bild-Upload kann den Prozess töten
|
||||||
|
|
||||||
|
**Schwere: hoch. Jeder Kontoinhaber, normale Bedienung.**
|
||||||
|
|
||||||
|
`cmd/aether/main.go:702` erzeugt das Vorschaubild in einer **nackten
|
||||||
|
Goroutine**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
go func() {
|
||||||
|
…
|
||||||
|
thumbnails.Generate(ctx, st, …) // dekodiert eine hochgeladene Datei
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
Chis `Recoverer` hängt am Anfragebehandler. Eine Panik in einer *eigenen*
|
||||||
|
Goroutine fängt er nicht — sie nimmt den **ganzen Go-Prozess** mit. Docker
|
||||||
|
startet ihn neu (`restart: unless-stopped`), aber jeder gerade laufende Upload
|
||||||
|
stirbt mit, und wer die Datei noch einmal hochlädt, wiederholt das Spiel.
|
||||||
|
|
||||||
|
Die passende Lücke war offen: **GO-2026-5061**, Panik im WebP-Dekodierer bei
|
||||||
|
unstimmiger Alphakanal-Größe. Behoben am 12.09.2026 durch `x/image` 0.42 → 0.45
|
||||||
|
(`ef60497`).
|
||||||
|
|
||||||
|
**Damit ist die Hälfte erledigt, nicht das Problem.** Die Bauweise bleibt: Der
|
||||||
|
nächste Dekodierer-Fehler trifft genauso, und Bilddekodierer sind eine Gattung,
|
||||||
|
in der regelmäßig etwas gefunden wird. Vier weitere Goroutinen dieser Art gibt
|
||||||
|
es: `internal/sharing/handler.go:232` und `main.go:185` (beide nur Datenbank,
|
||||||
|
harmlos), `internal/gc/gc.go:257`, und der OCR-Arbeiter (`go worker.Run(ctx)`),
|
||||||
|
der Fremdprogramme aufruft und deren Ausgabe liest.
|
||||||
|
|
||||||
|
**✅ Erledigt am 12.09.2026.** `pkg/bg` mit zwei Funktionen:
|
||||||
|
|
||||||
|
* `bg.Go(name, fn)` – startet eine Goroutine, die eine Panik überlebt.
|
||||||
|
* `bg.Run(name, fn)` – dasselbe ohne eigene Goroutine, für Schleifen.
|
||||||
|
|
||||||
|
Der Unterschied ist keine Feinheit: Fängt man eine Panik um den **ganzen**
|
||||||
|
Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und von da
|
||||||
|
an wird still keine Datei mehr erkannt. Der OCR-Arbeiter sichert deshalb jeden
|
||||||
|
**Auftrag** einzeln ab; ein unverdauliches Dokument kostet dann genau sich
|
||||||
|
selbst.
|
||||||
|
|
||||||
|
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zähler,
|
||||||
|
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit Begründung
|
||||||
|
im Code – die Signalbehandlung beim Herunterfahren; ein recover brächte dort
|
||||||
|
nichts, weil `main` ohnehin wartete, und sie fasst keine fremden Daten an.
|
||||||
|
|
||||||
|
Eine aufgefangene Panik wird mit Stapel und Herkunftsname protokolliert. Sie
|
||||||
|
ist ein Fehler und bleibt einer – sie reißt nur nichts mehr mit. Vier Tests in
|
||||||
|
`pkg/bg/bg_test.go`, darunter der, auf den es ankommt: Eine Schleife läuft nach
|
||||||
|
einer Panik im dritten Durchgang weiter.
|
||||||
|
|
||||||
|
> Der Unterschied ist wichtig: Die Version zu heben schloss **diese** Lücke.
|
||||||
|
> Der `recover` schließt die **Gattung**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Beschädigte Downloads werden erkannt und verschwiegen
|
||||||
|
|
||||||
|
**Schwere: mittel. Datenintegrität, still.**
|
||||||
|
|
||||||
|
`internal/files/handler.go:496` rechnet beim Ausliefern die Prüfsumme mit —
|
||||||
|
genau so, wie es die Projektregel verlangt („SHA-256: beim Upload berechnen,
|
||||||
|
beim Download verifizieren"). Nur passiert bei einer Abweichung nichts:
|
||||||
|
|
||||||
|
```go
|
||||||
|
actual := hex.EncodeToString(h256.Sum(nil))
|
||||||
|
if actual != f.Checksum {
|
||||||
|
// Log corruption; headers already sent, can't change status.
|
||||||
|
// TODO: alert / observability hook
|
||||||
|
_ = actual
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein Logeintrag, kein Zähler, keine Meldung. Der Kommentar sagt, was fehlt.
|
||||||
|
|
||||||
|
Dass der Statuscode nicht mehr zu ändern ist, stimmt — die Kopfzeilen sind
|
||||||
|
längst draußen. Aber **still** muss es deshalb nicht sein. Ein `log.Error()`
|
||||||
|
mit Datei-ID, erwarteter und tatsächlicher Summe kostet nichts und ist der
|
||||||
|
Unterschied zwischen „ich habe eine kaputte Datei" und „ich habe eine kaputte
|
||||||
|
Datei und weiß es seit drei Monaten nicht".
|
||||||
|
|
||||||
|
Zweitens ließe sich die Verbindung abbrechen, statt sie sauber zu beenden: Der
|
||||||
|
Browser zeigt dann einen abgebrochenen Download statt einer heilen Datei mit
|
||||||
|
falschem Inhalt. Das ist die ehrlichere Ausgabe.
|
||||||
|
|
||||||
|
**✅ Erledigt am 12.09.2026.** Beides – und ein Drittes, das dabei herauskam.
|
||||||
|
|
||||||
|
Der Logeintrag nennt Datei-ID, Speicherschlüssel, erwartete und gelesene Summe.
|
||||||
|
Dazu wird die Verbindung fallen gelassen (`panic(http.ErrAbortHandler)`, den
|
||||||
|
chis Recoverer ausdrücklich durchreicht).
|
||||||
|
|
||||||
|
**Damit das überhaupt wirkt, mussten die letzten 32 KiB zurückgehalten
|
||||||
|
werden.** Sonst wäre die Datei beim Erkennen des Fehlers längst vollständig
|
||||||
|
beim Empfänger, und der Abbruch käme zu spät: Die Kopfzeilen sind draußen, die
|
||||||
|
Bytes auch. Ein `tailWriter` reicht deshalb alles durch außer dem Schluss; erst
|
||||||
|
wenn die Summe stimmt, darf der hinterher. Stimmt sie nicht, bekommt die
|
||||||
|
Gegenstelle weniger Bytes als im `Content-Length` angekündigt – und wertet das
|
||||||
|
als Fehler statt als Datei.
|
||||||
|
|
||||||
|
Geprüft in `scripts/pruefsumme-selbsttest.py` (14 Prüfungen): Der Test kippt
|
||||||
|
ein Bit **auf der Platte**, nicht im Programm, und zwar in der Mitte der Datei,
|
||||||
|
damit der Fehler nicht schon an der Länge auffällt. Zwei Größen – über und
|
||||||
|
unter den 32 KiB, denn bei einer kleinen Datei wird gar nichts herausgegeben.
|
||||||
|
Und er prüft zuerst, dass eine **heile** Datei vollständig durchkommt; ohne das
|
||||||
|
bewiese der Rest nichts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b · Und der Fund dahinter: 34 Logaufrufe schrieben ins Nichts
|
||||||
|
|
||||||
|
**Schwere: hoch, indirekt. Ein Fehler, der andere Fehler versteckt.**
|
||||||
|
|
||||||
|
Der neue Logeintrag blieb beim ersten Testlauf aus, obwohl der Abbruch
|
||||||
|
funktionierte. Ursache:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Ctx(ctx context.Context) *Logger {
|
||||||
|
if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { return l }
|
||||||
|
else if l = DefaultContextLogger; l != nil { return l }
|
||||||
|
return disabledLogger // ← hier landete alles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`zerolog.Ctx` liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||||
|
steckt und kein Standard gesetzt ist. Aethers `middleware.Logger` hängte nie
|
||||||
|
einen hinein, und `DefaultContextLogger` war nie gesetzt.
|
||||||
|
|
||||||
|
**Alle 34 `log.Ctx(...)`-Aufrufe im Baum schrieben damit ins Nichts.** Darunter:
|
||||||
|
|
||||||
|
* die Warnung, dass beim Zurückrollen einer Fassung ein Blob verwaist ist
|
||||||
|
(`files/handler.go`, `files/replace.go`, `handler_versions.go`)
|
||||||
|
* gescheiterte Suchen
|
||||||
|
* Fehler der Speicherbereinigung
|
||||||
|
* die neue Meldung über beschädigte Dateien
|
||||||
|
|
||||||
|
Das ist die unangenehmste Art Fehler: Der Aufruf sieht richtig aus, `go vet`
|
||||||
|
schweigt, und auffallen kann es nur, wenn man den Fehler herbeiführt, den die
|
||||||
|
Zeile melden soll.
|
||||||
|
|
||||||
|
**Behoben zweifach:** `zerolog.DefaultContextLogger` beim Start gesetzt (deckt
|
||||||
|
auch Hintergrundarbeit mit eigenem Kontext ab), und `middleware.Logger` hängt
|
||||||
|
zusätzlich einen Logger mit `request_id` in den Anfragekontext – damit bei zwei
|
||||||
|
gleichzeitigen Downloads erkennbar bleibt, welche Meldung zu welchem gehört.
|
||||||
|
Zwei Tests in `pkg/middleware/logger_test.go` halten es fest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Vier Pakete ohne einen einzigen Go-Test
|
||||||
|
|
||||||
|
**Schwere: mittel. Nicht was heute kaputt ist, sondern was morgen still kaputtgeht.**
|
||||||
|
|
||||||
|
| Paket | Zeilen | Test (24.09.) | Aussenpruefung |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `internal/tags` | 1338 | **4** ✅ | keine |
|
||||||
|
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
|
||||||
|
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
|
||||||
|
| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen |
|
||||||
|
| `internal/audit` | 180 | **0** | keine |
|
||||||
|
| `internal/thumbnails` | 95 | **0** | keine |
|
||||||
|
|
||||||
|
Bei `sharing` und `rescan` deckt wenigstens ein Selbsttest die Außenseite ab.
|
||||||
|
Bei **`documents` und `tags` gar nichts** — und `documents` ist das Stück, das
|
||||||
|
aus `scan_0042.pdf` ein auffindbares Dokument macht. Genau der Grund, aus dem
|
||||||
|
Aether das Rechnungsarchiv ist.
|
||||||
|
|
||||||
|
**Teilweise erledigt am 12.09.2026:** `internal/documents` hat jetzt acht
|
||||||
|
Tests — gezielt auf die drei Zustände, in denen die Warteschlange
|
||||||
|
hängenbleiben kann, ohne dass es auffällt (Details in
|
||||||
|
[TESTING.md](TESTING.md)). Dazu `scripts/db-tests.sh`, damit sie nicht wie
|
||||||
|
`vitest` zur Dekoration werden: Sie überspringen sich ohne `TEST_DSN` und
|
||||||
|
liefen sonst nie.
|
||||||
|
|
||||||
|
Gegengeprüft an absichtlich kaputtem Code — und **der zweite Versuch blieb
|
||||||
|
grün**: `maxAttempts` von 3 auf 9999 gesetzt, alle Tests weiter in Ordnung,
|
||||||
|
nur die Laufzeit stieg von 0,2 auf 27 Sekunden. Die Tests schleiften über die
|
||||||
|
Konstante und setzten die Zahl voraus, statt sie zu prüfen. Dagegen steht jetzt
|
||||||
|
ein eigener Test.
|
||||||
|
|
||||||
|
**`internal/tags` ebenfalls erledigt am 12.09.2026.** Fünf Tests, alle auf
|
||||||
|
dieselbe Frage: Bleibt ein fremdes Etikett fremd – in der Liste, beim
|
||||||
|
Umbenennen, beim Löschen, beim Anhängen an die eigene Datei, und vor allem beim
|
||||||
|
**Auflisten der Dateien darunter**. Der letzte Weg gibt Dateinamen, Ordner und
|
||||||
|
Größen heraus; wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||||
|
fremden Unterlagen.
|
||||||
|
|
||||||
|
Auch hier gegen kaputten Code gegengeprüft: Eigentümerprüfung aus
|
||||||
|
`FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf"*.
|
||||||
|
Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests rot.
|
||||||
|
|
||||||
|
**Ein Verdacht erwies sich dabei als falsch, und das ist der nützlichere
|
||||||
|
Ausgang:** `List` bietet Gruppen-Etiketten an, `SetFieldsForFile` vergleicht
|
||||||
|
gegen den Prinzipal der *Datei* – das sah nach einem Widerspruch aus, bei dem
|
||||||
|
die Oberfläche etwas anbietet, was der Server ablehnt. Ist keiner: Die
|
||||||
|
Oberfläche filtert bereits (`TagDialog.tsx:69`). Und die Ablehnung ist nicht
|
||||||
|
Förmlichkeit, sondern die tragende Absicherung – `FilesWithTag` prüft, ob man
|
||||||
|
das *Etikett* sehen darf, **nicht** die *Datei*. Das ist nur deshalb harmlos,
|
||||||
|
weil ein Etikett stets denselben Prinzipal hat wie die Dateien darunter. Fiele
|
||||||
|
die Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei, und jedes
|
||||||
|
Gruppenmitglied sähe ihren Namen. Der Test hält jetzt diesen Zusammenhang fest,
|
||||||
|
nicht bloß eine Fehlermeldung.
|
||||||
|
|
||||||
|
Nebenbei: `internal/tags` hat **zwei** Fehlermarken für dieselbe Aussage –
|
||||||
|
`ErrNotFound` und `ErrCorrespondentNotFound`. Kein Fehler, aber eine
|
||||||
|
Stolperstelle; jetzt wenigstens festgehalten.
|
||||||
|
|
||||||
|
**Offen bleiben** `internal/sharing`, `internal/rescan` (beide mit
|
||||||
|
Python-Selbsttest), `internal/audit` und `internal/thumbnails`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `audit` und `thumbnails`: bewusst ohne Go-Tests (26.09.2026)
|
||||||
|
|
||||||
|
Nicht vergessen, sondern entschieden. Ein Fehler in `thumbnails` (95 Zeilen)
|
||||||
|
erzeugt ein hässliches Vorschaubild; einer in `audit` (180 Zeilen) kostet einen
|
||||||
|
Protokolleintrag. Beide schreiben nichts kaputt und löschen nichts.
|
||||||
|
|
||||||
|
Tests dafür wären Häkchen, keine Absicherung – und sie kosten dieselbe
|
||||||
|
Aufmerksamkeit wie die Stellen, an denen wirklich etwas passieren kann. Wer sie
|
||||||
|
später doch will, hat damit wenigstens die Gegenrede.
|
||||||
|
|
||||||
|
## 4 · Was in Ordnung ist
|
||||||
|
|
||||||
|
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
|
||||||
|
gibt:
|
||||||
|
|
||||||
|
* **Alle 27 Admin-Routen prüfen serverseitig auf Adminrecht.** Zwei
|
||||||
|
Ausnahmen sind Absicht und dokumentiert: `GET /admin/settings` liest jeder
|
||||||
|
Angemeldete (Theming — und es liegen dort nur sechs Branding-Schlüssel,
|
||||||
|
nachgesehen), `GET /ocr` ist der eigene Erkennungsstand ohne Adminrecht.
|
||||||
|
* **Kein einziges zusammengesetztes SQL.** Alles parametrisiert.
|
||||||
|
* **Inline-Auslieferung nur über eine Positivliste** harmloser Typen, mit
|
||||||
|
`sandbox`, und `allow-scripts` ausschließlich für PDF.
|
||||||
|
* **Ratenbegrenzung an jeder unangemeldeten Tür**, die eines braucht: Anmeldung
|
||||||
|
(20/min), WebDAV, CalDAV, Freigabelinks (je 10 Fehlversuche / 15 Minuten).
|
||||||
|
Die übrigen offenen Wege (`/health`, `/branding`, `/manifest.webmanifest`,
|
||||||
|
`/api/v1/public/config`) geben nichts preis.
|
||||||
|
* **Kein `dangerouslySetInnerHTML`, kein `eval`** im Frontend.
|
||||||
|
* **Sechs Selbsttest-Suiten, 220 Prüfungen** gegen die laufende Instanz, dazu
|
||||||
|
37 Frontend-Tests und die Go-Tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Kleinere offene Enden
|
||||||
|
|
||||||
|
* **`internal/groups` ist ein leerer Stub** (0 Zeilen). Gruppenordner liegen in
|
||||||
|
`files/groupfolder.go`. Entweder das Verzeichnis weg oder der Code dorthin —
|
||||||
|
ein leeres Paket im Baum ist eine Frage, die jeder einmal stellt.
|
||||||
|
* **`favicon_storage_key`** steht als leere Zeile in den Einstellungen; die
|
||||||
|
Funktion heißt seit dem App-Symbol anders. Schadet nichts, wird ignoriert.
|
||||||
|
* **Die Fehlversuchssperren liegen im Arbeitsspeicher.** Ein Neustart des
|
||||||
|
Containers hebt jede Sperre auf. Für den Hausgebrauch vertretbar; wer es
|
||||||
|
anders will, braucht eine Tabelle.
|
||||||
|
* ~~**Frontend ohne Komponententests.**~~ ✅ **18.09.2026:** `jsdom` ist in den
|
||||||
|
devDependencies, die Umgebung bleibt `node` (schnell); wer ein DOM braucht,
|
||||||
|
schreibt `// @vitest-environment jsdom` an den Anfang seiner Datei. 46 Tests,
|
||||||
|
davon vier gegen ein DOM. Der erste davon hat sofort einen Fehler gefunden –
|
||||||
|
in der Behebung vom Vortag (`useBackClose`).
|
||||||
|
|
||||||
|
**Was er nicht leistet, steht dabei:** Den ursprünglichen Fehler bildet jsdom
|
||||||
|
nicht ab; es verwirft einen Verlaufssprung, sobald `pushState` folgt. Dafür
|
||||||
|
bräuchte es einen echten Browser (Playwright). Nachgeprüft, nicht vermutet:
|
||||||
|
mit der alten, kaputten Fassung blieben alle Tests grün.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reihenfolge, die ich vorschlagen würde
|
||||||
|
|
||||||
|
1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026
|
||||||
|
2. ~~Prüfsummen-Abweichung melden~~ ✅ 12.09.2026 – und dabei fiel auf, dass
|
||||||
|
**alle** kontextgebundenen Logzeilen ins Nichts gingen (2b).
|
||||||
|
3. ~~Tests für `documents`~~ ✅ und ~~`tags`~~ ✅ 12.09.2026.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Ideen
|
||||||
|
|
||||||
|
Was noch kommen könnte. **Nichts davon ist beschlossen** — beschlossen ist,
|
||||||
|
was im [README](README.md) und in den Plänen steht
|
||||||
|
([cloud-projekt-plan.md](cloud-projekt-plan.md), [kalender-plan.md](kalender-plan.md)).
|
||||||
|
|
||||||
|
Hier landet, was einen guten Grund hat, aber gerade keinen Anlass. Der
|
||||||
|
Unterschied ist wichtig: Eine Funktion ohne Anlass zu bauen ist der Weg, auf
|
||||||
|
dem eine Anwendung unübersichtlich wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kalender
|
||||||
|
|
||||||
|
### Öffentlicher Abo-Link — vertagt (12.09.2026)
|
||||||
|
|
||||||
|
Ein Nur-Lese-Link auf einen Kalender, den jemand **ohne Konto** abonnieren
|
||||||
|
kann. Nicht dasselbe wie die Freigabe an ein Konto (die steht seit dem
|
||||||
|
12.09.2026): Dort ist der Empfänger bekannt, hier ist er es nicht.
|
||||||
|
|
||||||
|
**Warum vertagt:** kein Anlass. Marc ist der einzige Nutzer seiner Cloud, und
|
||||||
|
für den eigenen Gebrauch gibt es bereits zwei Wege an dieselben Termine — die
|
||||||
|
Weboberfläche und CalDAV mit App-Passwort.
|
||||||
|
|
||||||
|
**Wofür es gut wäre, wenn der Anlass kommt:**
|
||||||
|
|
||||||
|
* Den eigenen Kalender in einer *fremden* Anwendung einblenden, die kein
|
||||||
|
CalDAV mit Anmeldung kann, sondern nur eine `.ics`-Adresse frisst — etwa
|
||||||
|
ein Kalender auf der Arbeit.
|
||||||
|
* Einen Terminplan an Familie oder Verein geben, ohne für jeden ein Konto
|
||||||
|
anzulegen.
|
||||||
|
|
||||||
|
**Wie es gebaut würde** (damit die Überlegung nicht verloren geht):
|
||||||
|
|
||||||
|
Aether hat die Freigabelinks für Dateien schon, samt Token, optionalem
|
||||||
|
Passwort, Ablaufdatum und dem Zähler. Ein Kalender-Abo ist dieselbe Sache mit
|
||||||
|
einer anderen Ausgabe: `GET /share/kalender/{token}.ics` liefert den Export,
|
||||||
|
den es schon gibt (`api.go`, `Export`).
|
||||||
|
|
||||||
|
Drei Dinge wären dabei zu entscheiden, und sie sind der eigentliche Inhalt:
|
||||||
|
|
||||||
|
1. **Nur lesend, ohne Ausnahme.** Ein schreibbarer Link wäre ein Konto ohne
|
||||||
|
Passwort.
|
||||||
|
2. **Was steht drin?** Ein Abo zeigt Titel, Ort und Beschreibung jedes
|
||||||
|
Termins. Wer „Arzttermin, Dr. Müller" im Kalender stehen hat und den Link
|
||||||
|
weitergibt, gibt das mit weiter. Ein Schalter „nur Belegt/Frei" (Titel
|
||||||
|
durch „Belegt" ersetzen) wäre deshalb kein Zierrat, sondern der Grund,
|
||||||
|
warum man den Link überhaupt zu teilen wagt.
|
||||||
|
3. **Ein Abo wird regelmäßig abgerufen**, nicht einmal. Ein Kalenderprogramm
|
||||||
|
fragt alle paar Minuten nach. Der Zähler der Freigabelinks zählte dann
|
||||||
|
Maschinen statt Menschen, und die Sperre nach zehn Fehlversuchen träfe
|
||||||
|
einen Client, der bloß ein falsch gespeichertes Passwort wiederholt.
|
||||||
|
Beides müsste für diesen Linktyp anders sein.
|
||||||
|
|
||||||
|
### Aufgabenansicht (VTODO) — vertagt (12.09.2026)
|
||||||
|
|
||||||
|
Aether nimmt Aufgaben bereits an, speichert sie und gleicht sie ab; die Spalte
|
||||||
|
`component` in `cal_objects` unterscheidet sie von Terminen. Es fehlt **nur**
|
||||||
|
die Ansicht in der Weboberfläche.
|
||||||
|
|
||||||
|
**Warum vertagt:** Auf Marcs Telefon gibt es nichts, was sie zeigen würde.
|
||||||
|
|
||||||
|
Android hat von Haus aus **keinen Speicher für Aufgaben**. Für Termine gibt es
|
||||||
|
einen Systemanbieter — den füllt DAVx5, und Samsung Kalender liest daraus. Für
|
||||||
|
Aufgaben gibt es das nicht. DAVx5 kann Aufgabenlisten deshalb nur abgleichen,
|
||||||
|
wenn eine App installiert ist, die selbst den Speicher mitbringt:
|
||||||
|
**`tasks.org`**, OpenTasks oder jtx Board.
|
||||||
|
|
||||||
|
**Samsungs „Erinnerungen" gehört nicht dazu.** Der Bereich „Erinnerungen" im
|
||||||
|
Samsung Kalender ist kein Teil des Kalenders, sondern ein Fenster in die
|
||||||
|
Samsung-Erinnerungen-App; die hängt am Samsung-Konto (bzw. an Microsoft To Do),
|
||||||
|
nicht an CalDAV. Der Unterschied ist am Gerät ablesbar: Beim Anlegen eines
|
||||||
|
**Termins** fragt Samsung Kalender, in welchen Kalender — dort steht Aether zur
|
||||||
|
Auswahl. Beim Anlegen einer **Erinnerung** fragt es das nicht, weil es nur
|
||||||
|
einen Ort gibt, an den sie gehen kann.
|
||||||
|
|
||||||
|
Eine Ansicht in Aether wäre heute also eine reine Weboberflächen-Aufgabenliste –
|
||||||
|
ohne Weg, vom Telefon aus etwas hineinzuschreiben. Das ist ein eigenes
|
||||||
|
Vorhaben, kein Nebenprodukt des Kalenders.
|
||||||
|
|
||||||
|
**Der Anlass wäre: `tasks.org`.** Kostenlos, quelloffen, arbeitet mit DAVx5
|
||||||
|
zusammen und bringt den Aufgabenspeicher mit, der Android fehlt. Ist die App
|
||||||
|
installiert und das Aether-Konto in DAVx5 für Aufgabenlisten eingeschaltet,
|
||||||
|
landen Aufgaben vom Telefon in Aether — und dann hat die Ansicht in der
|
||||||
|
Weboberfläche einen Anlass. Der Unterbau steht bereits: Aether meldet seinen
|
||||||
|
Kalendern `VTODO` als unterstützt, nimmt sie an, speichert sie und gleicht sie
|
||||||
|
über `sync-collection` ab. Zu bauen bliebe die Ansicht selbst — eine Liste mit
|
||||||
|
Häkchen, Fälligkeit und Priorität, sinnvollerweise als vierte Ansicht neben
|
||||||
|
Monat, Woche und Liste.
|
||||||
|
|
||||||
|
**Was beim Bauen zu bedenken wäre:** Eine Aufgabe hat *kein* Pflichtdatum. Alle
|
||||||
|
drei heutigen Ansichten ordnen nach Zeit — eine Aufgabe ohne Fälligkeit hätte
|
||||||
|
darin keinen Platz. Das ist der Grund, warum es eine eigene Ansicht braucht und
|
||||||
|
kein Einstreuen in den Monat.
|
||||||
|
|
||||||
|
**Gegentest, falls die Einschätzung falsch ist:** DAVx5 öffnen, Aether-Konto
|
||||||
|
antippen. Gibt es dort einen Reiter „Aufgabenlisten" mit Einträgen, ohne dass
|
||||||
|
eine der genannten Apps installiert ist, stimmt das oben nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eine Android-App — notiert (15.09.2026)
|
||||||
|
|
||||||
|
Eine einfache eigene App fürs Telefon, statt der installierten Weboberfläche.
|
||||||
|
|
||||||
|
**Warum das nicht offensichtlich ist:** Die Weboberfläche *ist* am Telefon
|
||||||
|
schon installierbar, hat ein eigenes Symbol, läuft ohne Adressleiste und kennt
|
||||||
|
die sicheren Bereiche neben Uhr und Wischbalken. Eine App, die nur dasselbe in
|
||||||
|
einem Rahmen zeigt, wäre Arbeit ohne Gewinn.
|
||||||
|
|
||||||
|
**Was eine echte App kann, was die Weboberfläche nicht kann** — und nur dafür
|
||||||
|
lohnt sie sich:
|
||||||
|
|
||||||
|
* **Fotos automatisch hochladen.** Der eigentliche Grund, warum Leute die
|
||||||
|
Nextcloud-App installieren. Der Browser darf im Hintergrund nicht auf die
|
||||||
|
Galerie, eine App schon.
|
||||||
|
* **„Teilen mit → Aether"** aus jeder anderen App heraus. Ein Anhang aus dem
|
||||||
|
Mailprogramm landet in einem Tippen in der Cloud.
|
||||||
|
* **Hochladen, das weiterläuft**, wenn der Schirm aus ist. Ein Browser-Tab
|
||||||
|
wird nach Sekunden angehalten; ein großer Upload bricht deshalb ab.
|
||||||
|
* **Dateien im Android-Dateimanager** über einen Storage Access Provider.
|
||||||
|
|
||||||
|
**Was es kosten würde:** Ein zweiter Stapel (Kotlin oder Flutter), eine zweite
|
||||||
|
Veröffentlichungskette, und jede Serveränderung ist ab dann an zwei Stellen
|
||||||
|
nachzuziehen. Die bestehende REST-Schnittstelle plus App-Passwörter genügen
|
||||||
|
als Unterbau, ein neues Backend wäre es nicht.
|
||||||
|
|
||||||
|
**Der Anlass wäre: automatischer Fotoupload.** Solange das nicht gebraucht
|
||||||
|
wird, ist die installierte Weboberfläche der bessere Weg — sie ist da, sie ist
|
||||||
|
gepflegt, und sie ist genau eine Codebasis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mehrsprachigkeit — notiert (18.09.2026)
|
||||||
|
|
||||||
|
Die Oberfläche spricht seit dem 18.09.2026 durchgehend Englisch. Deutsch (oder
|
||||||
|
irgendeine weitere Sprache) daneben wäre eine eigene Baustelle.
|
||||||
|
|
||||||
|
**Der Umfang, gemessen statt geschätzt:**
|
||||||
|
|
||||||
|
~986 sichtbare Zeichenketten in 55 Frontend-Dateien
|
||||||
|
144 Fehlermeldungen im Backend
|
||||||
|
|
||||||
|
**Der Punkt, den man dabei vergisst: Es sind zwei Übersetzungen, nicht eine.**
|
||||||
|
|
||||||
|
Die 144 Meldungen des Servers landen seit dieser Woche in den Toasts – „You
|
||||||
|
only have read access to this file" kommt aus Go, nicht aus React. Wer nur das
|
||||||
|
Frontend übersetzt, bekommt eine deutsche Oberfläche, die bei jedem Fehler auf
|
||||||
|
Englisch umschaltet. Das wirkt kaputter als durchgehend Englisch.
|
||||||
|
|
||||||
|
Der saubere Weg: Der Server schickt keinen fertigen Satz mehr, sondern einen
|
||||||
|
Schlüssel samt Werten (`{"error":"file.read_only"}`); übersetzt wird vorne, wo
|
||||||
|
der Katalog ohnehin liegt. Das ist eine Protokolländerung an 144 Stellen, kein
|
||||||
|
Wörterbuch.
|
||||||
|
|
||||||
|
**Wie es gebaut würde:**
|
||||||
|
|
||||||
|
* Jede Zeichenkette wird ein Schlüssel (`t('calendar.new')`). Rund 1000
|
||||||
|
Stellen – stumpfe Arbeit, gut mit Werkzeug zu machen.
|
||||||
|
* `react-i18next` statt Eigenbau. Eigenbau scheitert an den **Pluralformen**:
|
||||||
|
„1 Datei" / „5 Dateien", und andere Sprachen haben mehr als zwei Formen.
|
||||||
|
* Die Sprache kommt aus `navigator.language`, ist im Profil überschreibbar und
|
||||||
|
liegt **in der Datenbank** – damit sie am Telefon dieselbe ist wie am
|
||||||
|
Rechner. Dieselbe Entscheidung wie bei den Favoriten.
|
||||||
|
|
||||||
|
**Warum vertagt:** Marc ist der einzige Nutzer und liest Englisch. Der Preis
|
||||||
|
ist nicht die einmalige Umstellung, sondern dass ab dann *jeder neue Text zwei
|
||||||
|
Änderungen kostet* – für immer. Der Anlass wäre eine zweite Person, die kein
|
||||||
|
Englisch mag.
|
||||||
|
|
||||||
|
### Unabhängig davon lohnend: `Intl`
|
||||||
|
|
||||||
|
Aether pflegt **sechs handgeschriebene Arrays** mit Wochentagen und
|
||||||
|
Monatsnamen (CalendarPage, DayDialog, EventDialog), und `Intl` wird im ganzen
|
||||||
|
Projekt **nirgends** benutzt. Dabei kann der Browser das seit Jahren:
|
||||||
|
|
||||||
|
new Intl.DateTimeFormat(sprache, { weekday: 'short' })
|
||||||
|
|
||||||
|
Das ersetzt alle sechs, kostet keine Abhängigkeit und entschärft eine Falle,
|
||||||
|
die beim Übersetzen auffiel: `DayDialog` beginnt seine Woche am **Sonntag**,
|
||||||
|
die anderen am **Montag** – jeweils passend zu ihrer Indizierung. Heute
|
||||||
|
korrekt, aber wer eines dieser Arrays kopiert, baut sich einen
|
||||||
|
Off-by-one-Fehler.
|
||||||
|
|
||||||
|
Lohnt sich auch ohne jede Übersetzung, und wäre die Vorarbeit, falls sie
|
||||||
|
kommt.
|
||||||
@@ -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. |
|
| **Betreiben** | `docker-compose.example.yml` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||||||
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
|
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
|
||||||
|
|
||||||
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – eine Datei, alle
|
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – sie beschreibt,
|
||||||
Einstellungen. Sie lag bis zum 02.09.2026 doppelt vor (einmal schlank hier,
|
was die Anwendung liest, und das ist in beiden Fällen dasselbe. Sie lag bis zum
|
||||||
einmal vollständig im Bau-Ordner), und die beiden liefen auseinander: Der
|
02.09.2026 doppelt vor (einmal schlank hier, einmal vollständig im Bau-Ordner),
|
||||||
produktiven fehlten am Ende zwölf Schlüssel, darunter `SESSION_MAX_AGE`, und
|
und die beiden liefen auseinander: Der produktiven fehlten am Ende zwölf
|
||||||
sie empfahl für `OFFICE_WOPI_URL` einen Wert, der mit Collabora nicht
|
Schlüssel, darunter `SESSION_MAX_AGE`.
|
||||||
funktioniert.
|
|
||||||
|
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
|
### 1. Dateien holen
|
||||||
|
|
||||||
@@ -90,10 +94,9 @@ ADMIN_USERNAME=admin
|
|||||||
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
|
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
|
||||||
```
|
```
|
||||||
|
|
||||||
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, App-Umschalter – steht mit
|
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, HoneyOS-Leiste – steht mit
|
||||||
Erklärung in derselben Datei, nach Abschnitten geordnet. Der letzte Abschnitt
|
Erklärung in derselben Datei, nach Abschnitten geordnet. Absichtlich
|
||||||
gilt nur für den Entwicklungs-Compose; beim Betrieb mit eigenem
|
ausführlich: Sie ist auch die Übersicht darüber, was überhaupt geht.
|
||||||
Office-Container stehen diese Werte in dessen Compose-Datei.
|
|
||||||
|
|
||||||
### 3. Starten
|
### 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
|
Übernahme fremder Konten. Verbunden wird ohnehin nur, was noch keine Bindung
|
||||||
hat.
|
hat.
|
||||||
|
|
||||||
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Die Vorgabe
|
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Der richtige Wert
|
||||||
`127.0.0.1` heißt: **nur dieser Rechner.** Von einem anderen Gerät kommt dann
|
hängt davon ab, **wo dein Reverse Proxy steht:**
|
||||||
kein Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt
|
|
||||||
ab, bevor irgendetwas die Anwendung erreicht. Das ist Absicht: Davor gehört
|
| Proxy läuft … | Wert |
|
||||||
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.
|
| 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
|
**`DATA_PATH`** ist ein gewöhnliches Verzeichnis auf dem Wirt, kein
|
||||||
Docker-Volume – deshalb kommst du per SFTP, rsync oder im Dateimanager daran.
|
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)
|
## Office (Collabora / OnlyOffice / Euro-Office)
|
||||||
|
|
||||||
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
|
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,
|
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
|
||||||
OnlyOffice und Euro-Office auf 80.**
|
OnlyOffice und Euro-Office auf 80.**
|
||||||
|
|
||||||
|
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
|
||||||
|
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
|
||||||
|
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
|
||||||
|
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
|
||||||
|
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
|
||||||
|
> den Proxy die öffentliche Adresse bedienen.
|
||||||
|
|
||||||
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
||||||
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
||||||
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
||||||
@@ -555,7 +708,7 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
|
|||||||
cap_add: [MKNOD]
|
cap_add: [MKNOD]
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:9980:9980"
|
- "${APP_BIND:-0.0.0.0}:9980:9980"
|
||||||
```
|
```
|
||||||
|
|
||||||
```env
|
```env
|
||||||
@@ -603,7 +756,7 @@ OFFICE_ALLOWED_HOSTS=office
|
|||||||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
ports:
|
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
|
```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
|
ALLOW_PRIVATE_IP_ADDRESS: "true" # es muss app:8080 erreichen
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
ports:
|
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.
|
Abbild.
|
||||||
|
|
||||||
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
|
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
|
||||||
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
|
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
|
||||||
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
|
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
|
||||||
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
|
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
|
||||||
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
|
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
|
||||||
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
|
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
|
||||||
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
|
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
|
||||||
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
|
> über die Ermittlung hinaus.
|
||||||
>
|
>
|
||||||
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
|
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
|
||||||
> nach Aktenlage hakt:
|
> betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
|
||||||
|
> Collabora daneben weiterlief.
|
||||||
|
>
|
||||||
|
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
|
||||||
|
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
|
||||||
|
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
|
||||||
|
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
|
||||||
|
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
|
||||||
|
> Fehler meldet sich nicht, er zeigt nur nichts.
|
||||||
|
>
|
||||||
|
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
|
||||||
|
> an denen es nach Aktenlage hakt:
|
||||||
>
|
>
|
||||||
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
||||||
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
||||||
@@ -661,53 +825,14 @@ Abbild.
|
|||||||
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||||||
> das der Ort.
|
> das der Ort.
|
||||||
>
|
>
|
||||||
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
|
> **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
|
||||||
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
|
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
|
||||||
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
|
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
|
||||||
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
|
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
|
||||||
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
|
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
|
||||||
> gesetzt – sonst läuft die API offen.
|
> **Der Office-Container liest es**, und ohne steht seine API offen, wenn
|
||||||
|
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
|
||||||
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
|
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
|
||||||
|
|
||||||
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
|
|
||||||
was aufgeht:
|
|
||||||
|
|
||||||
| Anbieter | am Telefon |
|
|
||||||
|---|---|
|
|
||||||
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
|
|
||||||
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
|
|
||||||
|
|
||||||
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
|
|
||||||
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
|
|
||||||
eine Tastatur.
|
|
||||||
|
|
||||||
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
|
|
||||||
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
|
|
||||||
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
|
|
||||||
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
|
|
||||||
nicht.
|
|
||||||
|
|
||||||
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
|
|
||||||
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
|
|
||||||
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
|
|
||||||
entscheidet der Anbieter.
|
|
||||||
|
|
||||||
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
|
|
||||||
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
|
|
||||||
LibreOffice-Layout desselben Dokuments sehen.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
|
|
||||||
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
|
|
||||||
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
|
|
||||||
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
|
|
||||||
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
|
|
||||||
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
|
|
||||||
|
|
||||||
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
|
|
||||||
Bereinigung, zusammen mit Vorschau- und Profilbildern.
|
|
||||||
|
|
||||||
### Welche Dateien, und wie gut
|
### Welche Dateien, und wie gut
|
||||||
|
|
||||||
@@ -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,
|
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
|
||||||
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
|
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
|
||||||
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
|
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
|
||||||
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
|
| **Euro-Office** | wie OnlyOffice, Abbild **6,99 GB** (hier nachgemessen) | derselbe Unterbau |
|
||||||
|
|
||||||
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||||||
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
||||||
@@ -808,7 +933,8 @@ kurzzeitig mehrere hundert Megabyte.
|
|||||||
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
||||||
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
||||||
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
||||||
Arbeitsspeicher, und das Abbild wiegt rund 5 GB.
|
Arbeitsspeicher, und das Abbild wiegt **6,99 GB** – nachgemessen, nicht
|
||||||
|
geschätzt; die Ankündigung nennt weniger.
|
||||||
|
|
||||||
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
|
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
|
||||||
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
||||||
@@ -832,5 +958,6 @@ kostet einen Container und eine Zeile `.env`, nicht mehr.
|
|||||||
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
|
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
|
||||||
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
|
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
|
||||||
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
|
| 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).
|
> 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/audit"
|
||||||
"aether/internal/auth"
|
"aether/internal/auth"
|
||||||
"aether/internal/avatar"
|
"aether/internal/avatar"
|
||||||
|
"aether/internal/calendar"
|
||||||
"aether/internal/documents"
|
"aether/internal/documents"
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/gc"
|
"aether/internal/gc"
|
||||||
"aether/internal/office"
|
"aether/internal/office"
|
||||||
appoidc "aether/internal/oidc"
|
appoidc "aether/internal/oidc"
|
||||||
|
"aether/internal/rescan"
|
||||||
"aether/internal/sharing"
|
"aether/internal/sharing"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
"aether/internal/tags"
|
"aether/internal/tags"
|
||||||
"aether/internal/thumbnails"
|
"aether/internal/thumbnails"
|
||||||
appwebdav "aether/internal/webdav"
|
appwebdav "aether/internal/webdav"
|
||||||
|
"aether/pkg/bg"
|
||||||
"aether/pkg/config"
|
"aether/pkg/config"
|
||||||
appdb "aether/pkg/db"
|
appdb "aether/pkg/db"
|
||||||
"aether/pkg/middleware"
|
"aether/pkg/middleware"
|
||||||
@@ -68,6 +71,7 @@ func spaHandler(root string) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||||||
|
setStaticCaching(w, upath)
|
||||||
fs.ServeHTTP(w, r)
|
fs.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,10 +84,37 @@ func spaHandler(root string) http.Handler {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setStaticCaching(w, "/index.html")
|
||||||
http.ServeFile(w, r, index)
|
http.ServeFile(w, r, index)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setStaticCaching entscheidet, was der Browser behalten darf.
|
||||||
|
//
|
||||||
|
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
|
||||||
|
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
|
||||||
|
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
|
||||||
|
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
|
||||||
|
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
|
||||||
|
// als rätselhafter Fehler mitten in der Anwendung.
|
||||||
|
//
|
||||||
|
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
|
||||||
|
//
|
||||||
|
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
|
||||||
|
// ändert sich der Name. Solche Dateien darf der Browser für immer
|
||||||
|
// behalten – "immutable" spart ihm sogar die Rückfrage.
|
||||||
|
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
|
||||||
|
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
|
||||||
|
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
|
||||||
|
// der Server dann meist nur mit 304 und ein paar Byte.
|
||||||
|
func setStaticCaching(w http.ResponseWriter, upath string) {
|
||||||
|
if strings.HasPrefix(upath, "/assets/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,6 +127,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
|
||||||
|
// **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.**
|
||||||
|
//
|
||||||
|
// zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext
|
||||||
|
// keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher
|
||||||
|
// Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein
|
||||||
|
// Blob verwaist ist, und die Meldung über eine beschädigte Datei beim
|
||||||
|
// Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos:
|
||||||
|
// Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem
|
||||||
|
// man den Fehler herbeiführt, den er melden soll.
|
||||||
|
//
|
||||||
|
// Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue
|
||||||
|
// Logeintrag blieb aus, obwohl der Abbruch funktionierte.
|
||||||
|
zerolog.DefaultContextLogger = &log.Logger
|
||||||
|
|
||||||
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -146,13 +191,16 @@ func main() {
|
|||||||
Timeout: cfg.OCRTimeout,
|
Timeout: cfg.OCRTimeout,
|
||||||
TmpDir: cfg.TmpPath,
|
TmpDir: cfg.TmpPath,
|
||||||
}, log.Logger)
|
}, log.Logger)
|
||||||
go worker.Run(ctx)
|
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
|
||||||
|
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
|
||||||
|
// Aufräumen der Zwischendateien beim Start.
|
||||||
|
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
|
||||||
|
|
||||||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||||
// Anfang der gesamte Bestand.
|
// Anfang der gesamte Bestand.
|
||||||
go func() {
|
bg.Go("Bestand nachtragen", func() {
|
||||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||||
@@ -175,7 +223,7 @@ func main() {
|
|||||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,12 +236,15 @@ func main() {
|
|||||||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||||||
|
|
||||||
shareStore := sharing.NewStore(pool)
|
shareStore := sharing.NewStore(pool)
|
||||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
|
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
|
||||||
|
|
||||||
collector := gc.New(pool, blobStore)
|
collector := gc.New(pool, blobStore)
|
||||||
|
|
||||||
adminStore := admin.NewStore(pool)
|
adminStore := admin.NewStore(pool)
|
||||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
|
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
|
||||||
|
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
|
||||||
|
rescanner := rescan.New(pool, fileStore, blobStore)
|
||||||
|
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
|
||||||
|
|
||||||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||||
@@ -216,7 +267,7 @@ func main() {
|
|||||||
var officeHandler *office.Handler
|
var officeHandler *office.Handler
|
||||||
if cfg.OfficeEnabled() {
|
if cfg.OfficeEnabled() {
|
||||||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
|
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||||||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||||||
@@ -246,6 +297,11 @@ func main() {
|
|||||||
// WebDAV
|
// WebDAV
|
||||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
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)
|
// OIDC (optional – disabled if OIDC_ISSUER not set)
|
||||||
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
||||||
Issuer: cfg.OIDCIssuer,
|
Issuer: cfg.OIDCIssuer,
|
||||||
@@ -306,7 +362,7 @@ func main() {
|
|||||||
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
||||||
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
||||||
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
|
// 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)
|
chi.RegisterMethod(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +408,12 @@ func main() {
|
|||||||
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
||||||
r.Mount("/dav", davHandler)
|
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
|
// 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) {
|
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
cloudName := "Aether"
|
cloudName := "Aether"
|
||||||
@@ -374,16 +436,6 @@ func main() {
|
|||||||
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
|
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
|
||||||
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
|
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{
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
"oidc_enabled": oidcHandler.IsEnabled(),
|
"oidc_enabled": oidcHandler.IsEnabled(),
|
||||||
"cloud_name": cloudName,
|
"cloud_name": cloudName,
|
||||||
@@ -392,7 +444,6 @@ func main() {
|
|||||||
"logo_url": logoURL,
|
"logo_url": logoURL,
|
||||||
"favicon_url": faviconURL,
|
"favicon_url": faviconURL,
|
||||||
"apple_touch_icon_url": appleTouchURL,
|
"apple_touch_icon_url": appleTouchURL,
|
||||||
"external_apps": apps,
|
|
||||||
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
||||||
// erst anbietet, wenn es keinen Office-Server gibt.
|
// erst anbietet, wenn es keinen Office-Server gibt.
|
||||||
"office_enabled": officeHandler.Enabled(),
|
"office_enabled": officeHandler.Enabled(),
|
||||||
@@ -419,7 +470,6 @@ func main() {
|
|||||||
// .env umgezogen werden kann — und damit derselbe Ursprung
|
// .env umgezogen werden kann — und damit derselbe Ursprung
|
||||||
// gilt, den die CSP erlaubt.
|
// gilt, den die CSP erlaubt.
|
||||||
"honeybar_origin": cfg.HoneybarOrigin,
|
"honeybar_origin": cfg.HoneybarOrigin,
|
||||||
"honeybar_key": honeybarKey,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,6 +504,7 @@ func main() {
|
|||||||
r.Get("/search", fileHandler.Search)
|
r.Get("/search", fileHandler.Search)
|
||||||
r.Get("/files/{id}/download", fileHandler.Download)
|
r.Get("/files/{id}/download", fileHandler.Download)
|
||||||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
||||||
|
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
|
||||||
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||||||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||||
@@ -461,7 +512,6 @@ func main() {
|
|||||||
r.Get("/office/session/{id}", officeHandler.Session)
|
r.Get("/office/session/{id}", officeHandler.Session)
|
||||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||||
// Telefon der Normalfall.
|
// Telefon der Normalfall.
|
||||||
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
|
||||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||||
@@ -518,6 +568,10 @@ func main() {
|
|||||||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||||||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||||||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
||||||
|
// Kalender liegen in derselben Rechtetabelle – deshalb dieselben
|
||||||
|
// Endpunkte, und /acl/{id} widerruft alle drei Arten.
|
||||||
|
r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL)
|
||||||
|
r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL)
|
||||||
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||||||
|
|
||||||
// App passwords (WebDAV/sync clients)
|
// App passwords (WebDAV/sync clients)
|
||||||
@@ -525,6 +579,21 @@ func main() {
|
|||||||
// es steht ohnehin an Freigaben und in Nutzerlisten.
|
// es steht ohnehin an Freigaben und in Nutzerlisten.
|
||||||
r.Get("/users/{id}/avatar", avatarHandler.Serve)
|
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.Get("/app-passwords", authHandler.ListAppPasswords)
|
||||||
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
||||||
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
|
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
|
||||||
@@ -542,6 +611,7 @@ func main() {
|
|||||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||||
r.Post("/admin/gc", adminHandler.RunGC)
|
r.Post("/admin/gc", adminHandler.RunGC)
|
||||||
|
r.Post("/admin/rescan", adminHandler.Rescan)
|
||||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||||
// Platz, der in keinem Kontingent auftaucht.
|
// Platz, der in keinem Kontingent auftaucht.
|
||||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||||
@@ -581,6 +651,10 @@ func main() {
|
|||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
|
||||||
|
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
|
||||||
|
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
|
||||||
|
// Sie berührt außerdem keine fremden Daten.
|
||||||
go func() {
|
go func() {
|
||||||
<-quit
|
<-quit
|
||||||
log.Info().Msg("shutting down...")
|
log.Info().Msg("shutting down...")
|
||||||
@@ -646,8 +720,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate thumbnail in background – non-fatal if it fails.
|
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||||||
go func() {
|
//
|
||||||
|
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
|
||||||
|
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
|
||||||
|
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
|
||||||
|
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
|
||||||
|
// gewöhnlichen Upload.
|
||||||
|
bg.Go("Vorschaubild", func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -663,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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 (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0
|
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/chi/v5 v5.1.0
|
||||||
github.com/go-chi/httprate v0.15.0
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
@@ -11,8 +13,9 @@ require (
|
|||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/rs/zerolog v1.33.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/crypto v0.53.0
|
||||||
golang.org/x/image v0.42.0
|
golang.org/x/image v0.45.0
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
@@ -28,7 +31,7 @@ require (
|
|||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
+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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
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=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Kalender ─────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Kalender liegen in derselben Rechtetabelle wie Ordner und Dateien. Nicht aus
|
||||||
|
// Sparsamkeit, sondern weil zwei Tabellen zwei Antworten auf dieselbe Frage
|
||||||
|
// wären – und die Lücke zwischen ihnen fällt erst auf, wenn jemand sieht, was
|
||||||
|
// er nicht sehen soll. Die Auflösung über Gruppen ist deshalb wörtlich
|
||||||
|
// dieselbe Bedingung wie bei Ordnern.
|
||||||
|
|
||||||
|
// ResolveCalendar klärt, was userID mit diesem Kalender darf.
|
||||||
|
//
|
||||||
|
// Der Eigentümer darf schreiben. Alle anderen nur, wenn es eine Freigabe gibt –
|
||||||
|
// direkt oder über eine Gruppe, in der sie sind. Gibt es keine, ist die
|
||||||
|
// Antwort ErrNoAccess, und der Aufrufer macht daraus ein 404: Ein 403 verriete,
|
||||||
|
// dass es den Kalender gibt.
|
||||||
|
func (s *Store) ResolveCalendar(ctx context.Context, userID, calendarID uuid.UUID) (Permission, error) {
|
||||||
|
var ownerID uuid.UUID
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&ownerID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load calendar owner: %w", err)
|
||||||
|
}
|
||||||
|
if ownerID == userID {
|
||||||
|
return PermWrite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(ctx,
|
||||||
|
`SELECT e.permission
|
||||||
|
FROM acl_entries e
|
||||||
|
WHERE e.resource_calendar_id = $1
|
||||||
|
AND ( e.subject_user_id = $2
|
||||||
|
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2) )`,
|
||||||
|
calendarID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve calendar permission: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Mehrere Wege zum selben Kalender sind möglich – persönlich *und* über
|
||||||
|
// eine Gruppe. Dann gilt das weitergehende Recht; alles andere wäre für
|
||||||
|
// den Betroffenen nicht nachvollziehbar.
|
||||||
|
var best Permission
|
||||||
|
for rows.Next() {
|
||||||
|
var p Permission
|
||||||
|
if err := rows.Scan(&p); err != nil {
|
||||||
|
return "", fmt.Errorf("scan permission: %w", err)
|
||||||
|
}
|
||||||
|
if p == PermWrite {
|
||||||
|
best = PermWrite
|
||||||
|
} else if best == "" {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if best == "" {
|
||||||
|
return "", ErrNoAccess
|
||||||
|
}
|
||||||
|
return best, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedCalendar ist ein fremder Kalender, den jemand sehen darf.
|
||||||
|
type SharedCalendar struct {
|
||||||
|
CalendarID uuid.UUID
|
||||||
|
Permission Permission
|
||||||
|
OwnerName string
|
||||||
|
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||||
|
// man sich über einen Kalender, den einem nie jemand persönlich gab.
|
||||||
|
ViaGroup string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedCalendars liefert alle **fremden** Kalender, die userID sehen darf.
|
||||||
|
// Die eigenen stehen nicht darin; die kennt der Kalenderteil selbst.
|
||||||
|
func (s *Store) SharedCalendars(ctx context.Context, userID uuid.UUID) ([]SharedCalendar, error) {
|
||||||
|
rows, err := s.db.Query(ctx,
|
||||||
|
`SELECT c.id, e.permission,
|
||||||
|
COALESCE(NULLIF(o.display_name, ''), o.username, ''),
|
||||||
|
COALESCE(g.name, '')
|
||||||
|
FROM acl_entries e
|
||||||
|
JOIN cal_calendars c ON c.id = e.resource_calendar_id
|
||||||
|
JOIN users o ON o.id = c.owner_id
|
||||||
|
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||||
|
WHERE c.owner_id <> $1
|
||||||
|
AND ( e.subject_user_id = $1
|
||||||
|
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1) )
|
||||||
|
ORDER BY o.username, c.created_at`,
|
||||||
|
userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Zwei Wege zum selben Kalender ergeben zwei Zeilen – angezeigt wird er
|
||||||
|
// einmal, mit dem weitergehenden Recht.
|
||||||
|
byID := map[uuid.UUID]int{}
|
||||||
|
out := []SharedCalendar{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sc SharedCalendar
|
||||||
|
if err := rows.Scan(&sc.CalendarID, &sc.Permission, &sc.OwnerName, &sc.ViaGroup); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan shared calendar: %w", err)
|
||||||
|
}
|
||||||
|
if i, da := byID[sc.CalendarID]; da {
|
||||||
|
if sc.Permission == PermWrite {
|
||||||
|
out[i].Permission = PermWrite
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byID[sc.CalendarID] = len(out)
|
||||||
|
out = append(out, sc)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -89,6 +89,63 @@ func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID,
|
|||||||
return id, user.ID, true
|
return id, user.ID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders.
|
||||||
|
// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht
|
||||||
|
// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat,
|
||||||
|
// ist etwas anderes als es zu benutzen.
|
||||||
|
func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid calendar id")
|
||||||
|
return uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
owner, err := h.store.CalendarOwner(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
|
return uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
if owner != user.ID {
|
||||||
|
// 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort
|
||||||
|
// nicht erfahren, dass es ihn gibt.
|
||||||
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
|
return uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
return id, user.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/calendars/{id}/acl
|
||||||
|
func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := h.ownedCalendar(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := h.store.ListForCalendar(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/calendars/{id}/acl
|
||||||
|
func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, owner, ok := h.ownedCalendar(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, ok := decodeGrant(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||||
|
if err == nil {
|
||||||
|
h.logShare(r, audit.ActionShareGrant, "calendar", id.String(),
|
||||||
|
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||||
|
}
|
||||||
|
writeGrantResult(w, entry, err)
|
||||||
|
}
|
||||||
|
|
||||||
func userFrom(r *http.Request) uuid.UUID {
|
func userFrom(r *http.Request) uuid.UUID {
|
||||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||||
return u.ID
|
return u.ID
|
||||||
|
|||||||
@@ -41,17 +41,30 @@ type SharedResource struct {
|
|||||||
|
|
||||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// resource benennt, worum es bei einer Freigabe geht: genau eines der drei
|
||||||
|
// Felder ist gesetzt. Als Typ statt als drei Zeigerparameter, seit der
|
||||||
|
// Kalender dazukam – drei nebeneinanderstehende `*uuid.UUID` in einer
|
||||||
|
// Signatur sind eine Einladung, sie zu vertauschen.
|
||||||
|
type resource struct{ folder, file, calendar *uuid.UUID }
|
||||||
|
|
||||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||||
// ein Nutzer oder eine Gruppe ist.
|
// ein Nutzer oder eine Gruppe ist.
|
||||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
return s.grant(ctx, resource{folder: &folderID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
// GrantCalendar gibt einen Kalender frei – an ein Konto oder an eine Gruppe.
|
||||||
|
// Dass Gruppen ohne Zutun mitfunktionieren, ist der Grund, warum Kalender in
|
||||||
|
// *dieser* Tabelle liegen und nicht in einer eigenen.
|
||||||
|
func (s *Store) GrantCalendar(ctx context.Context, calendarID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||||
|
return s.grant(ctx, resource{calendar: &calendarID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) grant(ctx context.Context, res resource, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||||
if !perm.valid() {
|
if !perm.valid() {
|
||||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||||
}
|
}
|
||||||
@@ -71,10 +84,11 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
|||||||
var e Entry
|
var e Entry
|
||||||
err := s.db.QueryRow(ctx,
|
err := s.db.QueryRow(ctx,
|
||||||
`INSERT INTO acl_entries
|
`INSERT INTO acl_entries
|
||||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
(subject_user_id, subject_group_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
resource_folder_id, resource_file_id, resource_calendar_id, permission, granted_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, permission, created_at`,
|
RETURNING id, permission, created_at`,
|
||||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
subjUser, subjGroup, res.folder, res.file, res.calendar, perm, grantedBy,
|
||||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||||
|
|
||||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||||
@@ -84,7 +98,7 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
|||||||
// über den Fehlercode.
|
// über den Fehlercode.
|
||||||
var pgErr *pgconn.PgError
|
var pgErr *pgconn.PgError
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("grant access: %w", err)
|
return nil, fmt.Errorf("grant access: %w", err)
|
||||||
@@ -95,16 +109,17 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
|||||||
return &e, nil
|
return &e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
func (s *Store) updateExisting(ctx context.Context, res resource, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||||
var e Entry
|
var e Entry
|
||||||
err := s.db.QueryRow(ctx,
|
err := s.db.QueryRow(ctx,
|
||||||
`UPDATE acl_entries SET permission = $1
|
`UPDATE acl_entries SET permission = $1
|
||||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
AND resource_calendar_id IS NOT DISTINCT FROM $4
|
||||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
AND subject_user_id IS NOT DISTINCT FROM $5
|
||||||
|
AND subject_group_id IS NOT DISTINCT FROM $6
|
||||||
RETURNING id, permission, created_at`,
|
RETURNING id, permission, created_at`,
|
||||||
perm, folderID, fileID, subjUser, subjGroup,
|
perm, res.folder, res.file, res.calendar, subjUser, subjGroup,
|
||||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -127,7 +142,8 @@ func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
|||||||
`DELETE FROM acl_entries e
|
`DELETE FROM acl_entries e
|
||||||
WHERE e.id = $1
|
WHERE e.id = $1
|
||||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2)
|
||||||
|
OR EXISTS (SELECT 1 FROM cal_calendars c WHERE c.id = e.resource_calendar_id AND c.owner_id = $2) )`,
|
||||||
entryID, ownerID)
|
entryID, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("revoke access: %w", err)
|
return fmt.Errorf("revoke access: %w", err)
|
||||||
@@ -147,6 +163,10 @@ func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, err
|
|||||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListForCalendar(ctx context.Context, calendarID uuid.UUID) ([]Entry, error) {
|
||||||
|
return s.list(ctx, `e.resource_calendar_id = $1`, calendarID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT e.id,
|
SELECT e.id,
|
||||||
@@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string {
|
|||||||
}
|
}
|
||||||
return "user"
|
return "user"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalendarOwner liefert, wem ein Kalender gehört – die Frage vor jeder
|
||||||
|
// Verwaltung seiner Freigaben.
|
||||||
|
func (s *Store) CalendarOwner(ctx context.Context, calendarID uuid.UUID) (uuid.UUID, error) {
|
||||||
|
var owner uuid.UUID
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&owner)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("load calendar owner: %w", err)
|
||||||
|
}
|
||||||
|
return owner, nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"aether/internal/audit"
|
"aether/internal/audit"
|
||||||
|
"aether/internal/rescan"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,3 +119,59 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
response.JSON(w, http.StatusOK, usage)
|
response.JSON(w, http.StatusOK, usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank
|
||||||
|
// abgleichen.
|
||||||
|
//
|
||||||
|
// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag
|
||||||
|
// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer
|
||||||
|
// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in
|
||||||
|
// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten.
|
||||||
|
//
|
||||||
|
// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas.
|
||||||
|
// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was
|
||||||
|
// passieren wird, kann es auch nicht verhindern.
|
||||||
|
func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := requireAdmin(r); !ok {
|
||||||
|
response.Error(w, http.StatusForbidden, "admin required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.rescan == nil {
|
||||||
|
response.Error(w, http.StatusServiceUnavailable, "rescan not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
anwenden := r.URL.Query().Get("anwenden") == "true"
|
||||||
|
// Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen
|
||||||
|
// soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint
|
||||||
|
// aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen.
|
||||||
|
bestaetigt := r.URL.Query().Get("bestaetigt") == "true"
|
||||||
|
var (
|
||||||
|
bef *rescan.Report
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if anwenden {
|
||||||
|
bef, err = h.rescan.Apply(r.Context(), bestaetigt)
|
||||||
|
} else {
|
||||||
|
bef, err = h.rescan.Check(r.Context())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "rescan failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if anwenden {
|
||||||
|
// Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein
|
||||||
|
// ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit
|
||||||
|
// dem Datenordner etwas nicht stimmt.
|
||||||
|
if bef.Aborted {
|
||||||
|
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||||
|
"rescan aborted, nothing removed: "+bef.AbortReason)
|
||||||
|
} else {
|
||||||
|
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||||
|
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
|
||||||
|
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, bef)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"aether/internal/auth"
|
"aether/internal/auth"
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/gc"
|
"aether/internal/gc"
|
||||||
|
"aether/internal/rescan"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
"aether/pkg/middleware"
|
"aether/pkg/middleware"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
@@ -30,10 +31,11 @@ type Handler struct {
|
|||||||
auditor *audit.Logger
|
auditor *audit.Logger
|
||||||
gc *gc.Collector
|
gc *gc.Collector
|
||||||
files *files.Store
|
files *files.Store
|
||||||
|
rescan *rescan.Scanner
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler {
|
||||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
|
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner}
|
||||||
}
|
}
|
||||||
|
|
||||||
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"aether/internal/acl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wer darf was mit einem Kalender.
|
||||||
|
//
|
||||||
|
// **Alle Wege laufen hier zusammen.** CalDAV und die Weboberfläche sind zwei
|
||||||
|
// völlig verschiedene Zugänge zu denselben Daten – ein Recht, das nur einer
|
||||||
|
// von beiden prüft, ist kein Recht. Deshalb gibt es genau eine Antwort auf
|
||||||
|
// „darf dieses Konto diesen Kalender", und beide Zugänge holen sie hier.
|
||||||
|
|
||||||
|
// Access ist ein Kalender samt dem, was der Anfragende damit darf.
|
||||||
|
type Access struct {
|
||||||
|
Calendar
|
||||||
|
Permission acl.Permission `json:"permission"`
|
||||||
|
// Shared: Der Kalender gehört jemand anderem.
|
||||||
|
Shared bool `json:"shared"`
|
||||||
|
// OwnerName steht in der Oberfläche neben dem Namen – ein fremder
|
||||||
|
// Kalender ohne sichtbaren Absender ist ein Rätsel.
|
||||||
|
OwnerName string `json:"owner_name,omitempty"`
|
||||||
|
// ViaGroup nennt die Gruppe, über die der Zugriff kommt.
|
||||||
|
ViaGroup string `json:"via_group,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Access) CanWrite() bool { return a.Permission == acl.PermWrite }
|
||||||
|
|
||||||
|
// Ref ist die Adresse in CalDAV.
|
||||||
|
//
|
||||||
|
// Bei einem **fremden** Kalender immer die UUID, nie das Pfadsegment: Das
|
||||||
|
// Segment hat sich der Eigentümer ausgesucht ("…/arbeit/"), und zwei Leute
|
||||||
|
// dürfen denselben Namen wählen. Unter dem Kalenderbestand des Empfängers
|
||||||
|
// stünden dann zwei Kalender an derselben Adresse – und welcher gemeint ist,
|
||||||
|
// entschiede der Zufall.
|
||||||
|
func (a *Access) Ref() string {
|
||||||
|
if a.Shared {
|
||||||
|
return a.Calendar.ID.String()
|
||||||
|
}
|
||||||
|
return a.Calendar.Ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAccessible liefert alles, was userID sehen darf: die eigenen Kalender
|
||||||
|
// und die ihm freigegebenen. Eigene zuerst – sie sind die, die man sucht.
|
||||||
|
func (s *Store) ListAccessible(ctx context.Context, userID uuid.UUID) ([]Access, error) {
|
||||||
|
own, err := s.ListCalendars(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Access, 0, len(own))
|
||||||
|
for i := range own {
|
||||||
|
out = append(out, Access{Calendar: own[i], Permission: acl.PermWrite})
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.rights == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
geteilt, err := s.rights.SharedCalendars(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||||
|
}
|
||||||
|
for _, sc := range geteilt {
|
||||||
|
cal, err := s.getCalendarAny(ctx, sc.CalendarID)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
continue // zwischenzeitlich gelöscht
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, Access{
|
||||||
|
Calendar: *cal,
|
||||||
|
Permission: sc.Permission,
|
||||||
|
Shared: true,
|
||||||
|
OwnerName: sc.OwnerName,
|
||||||
|
ViaGroup: sc.ViaGroup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRef findet den Kalender hinter einer CalDAV-Adresse – eigener oder
|
||||||
|
// freigegebener.
|
||||||
|
//
|
||||||
|
// Die Reihenfolge ist Absicht: **erst die eigenen.** Sonst könnte ein fremder
|
||||||
|
// Kalender, dessen UUID zufällig wie ein eigenes Pfadsegment aussieht, den
|
||||||
|
// eigenen verdecken.
|
||||||
|
func (s *Store) ResolveRef(ctx context.Context, userID uuid.UUID, ref string) (*Access, error) {
|
||||||
|
cal, err := s.GetCalendarByRef(ctx, userID, ref)
|
||||||
|
if err == nil {
|
||||||
|
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein freigegebener Kalender steht unter seiner UUID. Alles andere kann
|
||||||
|
// hier nichts mehr treffen.
|
||||||
|
id, parseErr := uuid.Parse(ref)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.RequireCalendar(ctx, userID, id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireCalendar ist die Prüfung, die jeder schreibende und lesende Zugriff
|
||||||
|
// durchläuft – aus CalDAV wie aus der Weboberfläche.
|
||||||
|
//
|
||||||
|
// Ohne Zugriff kommt **ErrNotFound**, nicht „verboten": Ein 403 verriete, dass
|
||||||
|
// es den Kalender gibt. Dieselbe Regel wie überall sonst in Aether.
|
||||||
|
func (s *Store) RequireCalendar(ctx context.Context, userID, calendarID uuid.UUID, needWrite bool) (*Access, error) {
|
||||||
|
cal, err := s.getCalendarAny(ctx, calendarID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cal.OwnerID == userID {
|
||||||
|
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||||
|
}
|
||||||
|
if s.rights == nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
perm, err := s.rights.ResolveCalendar(ctx, userID, calendarID)
|
||||||
|
if errors.Is(err, acl.ErrNoAccess) || errors.Is(err, acl.ErrNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if needWrite && perm != acl.PermWrite {
|
||||||
|
// Hier **darf** es ein 403 sein: Dass es den Kalender gibt, weiß der
|
||||||
|
// Anfragende bereits – er darf ihn ja lesen.
|
||||||
|
return nil, ErrReadOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &Access{Calendar: *cal, Permission: perm, Shared: true}
|
||||||
|
// Name des Eigentümers nur nachschlagen, wenn er auch angezeigt wird.
|
||||||
|
if name, err := s.ownerName(ctx, cal.OwnerID); err == nil {
|
||||||
|
a.OwnerName = name
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrReadOnly: Der Kalender ist sichtbar, aber nur lesend freigegeben.
|
||||||
|
var ErrReadOnly = errors.New("calendar: read-only share")
|
||||||
|
|
||||||
|
// getCalendarAny lädt einen Kalender **ohne** Rechteprüfung. Privat, und das
|
||||||
|
// muss so bleiben: Jeder Aufruf von außen ginge an der Prüfung vorbei.
|
||||||
|
func (s *Store) getCalendarAny(ctx context.Context, id uuid.UUID) (*Calendar, error) {
|
||||||
|
var c Calendar
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`SELECT id, owner_id, name, color, description, supports_todo, timezone,
|
||||||
|
COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||||
|
FROM cal_calendars WHERE id = $1`, id,
|
||||||
|
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo,
|
||||||
|
&c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ownerName(ctx context.Context, ownerID uuid.UUID) (string, error) {
|
||||||
|
var name string
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = $1`,
|
||||||
|
ownerID).Scan(&name)
|
||||||
|
return name, err
|
||||||
|
}
|
||||||
@@ -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"
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
|
"aether/pkg/bg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||||
@@ -67,7 +68,13 @@ func (w *Worker) Run(ctx context.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
wait := idleInterval
|
wait := idleInterval
|
||||||
if w.step(ctx) {
|
// **Je Auftrag abgesichert, nicht je Arbeiter.** Fängt man eine Panik
|
||||||
|
// um die ganze Schleife ab, überlebt zwar der Prozess – aber der
|
||||||
|
// Arbeiter ist tot, und von da an wird still keine Datei mehr erkannt.
|
||||||
|
// So kostet ein unverdauliches Dokument genau sich selbst.
|
||||||
|
var etwasGetan bool
|
||||||
|
bg.Run("Texterkennung", func() { etwasGetan = w.step(ctx) })
|
||||||
|
if etwasGetan {
|
||||||
wait = busyInterval
|
wait = busyInterval
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -486,20 +484,25 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||||
|
|
||||||
// Verify checksum while streaming.
|
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
|
||||||
h256 := sha256.New()
|
// zurückhalten**, bis sie stimmt.
|
||||||
reader := io.TeeReader(rc, h256)
|
//
|
||||||
|
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
|
||||||
if _, err := io.Copy(w, reader); err != nil {
|
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
|
||||||
return // client disconnected
|
// dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte
|
||||||
}
|
// wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv,
|
||||||
|
// in das man einmal im Jahr sieht, die teuerste Art von Stille.
|
||||||
actual := hex.EncodeToString(h256.Sum(nil))
|
//
|
||||||
if actual != f.Checksum {
|
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
|
||||||
// Log corruption; headers already sent, can't change status.
|
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
|
||||||
// TODO: alert / observability hook
|
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
|
||||||
_ = actual
|
// Verbindung noch fallen lassen, und der Browser sieht einen
|
||||||
}
|
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
|
||||||
|
ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{
|
||||||
|
FileID: f.ID.String(),
|
||||||
|
StorageKey: f.StorageKey,
|
||||||
|
Size: f.Size,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
||||||
@@ -762,6 +765,9 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
|
|||||||
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
||||||
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
||||||
var extraMIMETypes = map[string]string{
|
var extraMIMETypes = map[string]string{
|
||||||
|
// Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit
|
||||||
|
// die Oberflaeche sie vom Texteditor fernhaelt.
|
||||||
|
".excalidraw": "application/json",
|
||||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||||
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
||||||
".wav": "audio/wav", ".flac": "audio/flac",
|
".wav": "audio/wav", ".flac": "audio/flac",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"aether/internal/thumbnails"
|
||||||
|
"aether/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein
|
||||||
|
// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und
|
||||||
|
// halten trotzdem jeden Unfug vom Dekodierer fern.
|
||||||
|
const maxThumbUpload = 2 << 20
|
||||||
|
|
||||||
|
// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen
|
||||||
|
// kann.
|
||||||
|
//
|
||||||
|
// POST /api/v1/files/{id}/thumbnail
|
||||||
|
//
|
||||||
|
// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst
|
||||||
|
// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort
|
||||||
|
// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung
|
||||||
|
// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann.
|
||||||
|
//
|
||||||
|
// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt,
|
||||||
|
// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch
|
||||||
|
// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren,
|
||||||
|
// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
|
||||||
|
// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen.
|
||||||
|
func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie
|
||||||
|
// sie in der Liste aussieht.
|
||||||
|
owner, ok := h.resolveFile(w, r, id, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
typ := r.Header.Get("Content-Type")
|
||||||
|
if !thumbnails.SupportedMIME(typ) {
|
||||||
|
response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung
|
||||||
|
// ab, statt erst alles zu lesen und dann abzulehnen.
|
||||||
|
rc := http.MaxBytesReader(w, r.Body, maxThumbUpload)
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil {
|
||||||
|
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||||
|
Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten")
|
||||||
|
response.Error(w, http.StatusBadRequest, "could not read that image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -37,10 +38,12 @@ func scanFile(row interface{ Scan(...any) error }, f *File) error {
|
|||||||
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||||
var out File
|
var out File
|
||||||
err := scanFile(s.db.QueryRow(ctx,
|
err := scanFile(s.db.QueryRow(ctx,
|
||||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
versioning_enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING `+fileCols,
|
RETURNING `+fileCols,
|
||||||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||||||
|
versioniertVonAnfang(f.Name),
|
||||||
), &out)
|
), &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
|||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
|
||||||
|
// behalten.
|
||||||
|
//
|
||||||
|
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
|
||||||
|
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
|
||||||
|
// unbemerkt voll.
|
||||||
|
//
|
||||||
|
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
|
||||||
|
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
|
||||||
|
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
|
||||||
|
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
|
||||||
|
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
|
||||||
|
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
|
||||||
|
// ohne Deckung.
|
||||||
|
func versioniertVonAnfang(name string) bool {
|
||||||
|
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
|
||||||
|
}
|
||||||
|
|
||||||
// GetFileByID fetches a file without owner check (used by public share downloads).
|
// GetFileByID fetches a file without owner check (used by public share downloads).
|
||||||
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
||||||
var f File
|
var f File
|
||||||
@@ -537,29 +558,6 @@ func isUniqueViolation(err error) bool {
|
|||||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||||
// entstanden ist – 0 heißt: es gibt keins.
|
// entstanden ist – 0 heißt: es gibt keins.
|
||||||
//
|
//
|
||||||
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
|
|
||||||
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
|
|
||||||
// Liste und jede Suche mit.
|
|
||||||
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
|
|
||||||
var v int
|
|
||||||
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return 0, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("preview version: %w", err)
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
|
|
||||||
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
|
|
||||||
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set preview version: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Der tailWriter ist die Stelle, an der ein beschädigter Download noch
|
||||||
|
// aufzuhalten ist. Was er falsch macht, merkt man sonst erst an einer Datei,
|
||||||
|
// die heil aussieht und keine ist.
|
||||||
|
|
||||||
|
func TestTailWriterHaeltDenSchlussZurueck(t *testing.T) {
|
||||||
|
var raus bytes.Buffer
|
||||||
|
tw := &tailWriter{w: &raus, keep: 4}
|
||||||
|
|
||||||
|
if _, err := tw.Write([]byte("0123456789")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := raus.String(); got != "012345" {
|
||||||
|
t.Fatalf("durchgereicht %q, erwartet %q", got, "012345")
|
||||||
|
}
|
||||||
|
if err := tw.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := raus.String(); got != "0123456789" {
|
||||||
|
t.Fatalf("nach Flush %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Fall, auf den es ankommt: Ohne Flush darf der Empfänger die Datei
|
||||||
|
// **nicht** vollständig haben.
|
||||||
|
func TestOhneFlushBleibtEsUnvollstaendig(t *testing.T) {
|
||||||
|
var raus bytes.Buffer
|
||||||
|
tw := &tailWriter{w: &raus, keep: 4}
|
||||||
|
io.Copy(tw, bytes.NewReader([]byte("0123456789")))
|
||||||
|
if raus.Len() != 6 {
|
||||||
|
t.Fatalf("%d Bytes draußen, erwartet 6", raus.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Datei, die kleiner ist als der zurückgehaltene Schluss, darf ohne
|
||||||
|
// Flush gar nichts hinauslassen.
|
||||||
|
func TestKleineDateiBleibtGanzZurueck(t *testing.T) {
|
||||||
|
var raus bytes.Buffer
|
||||||
|
tw := &tailWriter{w: &raus, keep: 32}
|
||||||
|
tw.Write([]byte("kurz"))
|
||||||
|
if raus.Len() != 0 {
|
||||||
|
t.Fatalf("%d Bytes zu früh draußen", raus.Len())
|
||||||
|
}
|
||||||
|
tw.Flush()
|
||||||
|
if raus.String() != "kurz" {
|
||||||
|
t.Fatalf("nach Flush %q", raus.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viele kleine Schreibvorgänge dürfen sich nicht anders verhalten als einer
|
||||||
|
// großer – io.Copy zerlegt in Häppchen.
|
||||||
|
func TestHaeppchenweiseGleichesErgebnis(t *testing.T) {
|
||||||
|
var raus bytes.Buffer
|
||||||
|
tw := &tailWriter{w: &raus, keep: 5}
|
||||||
|
for _, c := range []byte("abcdefghijklmnop") {
|
||||||
|
if _, err := tw.Write([]byte{c}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raus.String() != "abcdefghijk" {
|
||||||
|
t.Fatalf("durchgereicht %q", raus.String())
|
||||||
|
}
|
||||||
|
tw.Flush()
|
||||||
|
if raus.String() != "abcdefghijklmnop" {
|
||||||
|
t.Fatalf("nach Flush %q", raus.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Zwischenspeicher darf nicht mitwachsen: Bei einer 5-GB-Datei läge sonst
|
||||||
|
// alles im Arbeitsspeicher.
|
||||||
|
func TestZwischenspeicherWaechstNicht(t *testing.T) {
|
||||||
|
tw := &tailWriter{w: io.Discard, keep: 1024}
|
||||||
|
block := make([]byte, 32*1024)
|
||||||
|
for range 100 {
|
||||||
|
tw.Write(block)
|
||||||
|
if len(tw.buf) > len(block)+tw.keep {
|
||||||
|
t.Fatalf("Zwischenspeicher auf %d Bytes gewachsen", len(tw.buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bricht die Gegenstelle weg, muss der Fehler nach oben durchkommen – sonst
|
||||||
|
// liefe io.Copy weiter und läse die ganze Datei ins Nichts.
|
||||||
|
type kaputterWriter struct{}
|
||||||
|
|
||||||
|
func (kaputterWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||||
|
|
||||||
|
func TestSchreibfehlerKommtDurch(t *testing.T) {
|
||||||
|
tw := &tailWriter{w: kaputterWriter{}, keep: 2}
|
||||||
|
if _, err := tw.Write([]byte("abcdef")); err == nil {
|
||||||
|
t.Fatal("Schreibfehler wurde verschluckt")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ausliefern mit Prüfung der SHA-256-Summe.
|
||||||
|
//
|
||||||
|
// Stand bis zum 25.09.2026 nur im angemeldeten Download. Der **öffentliche**
|
||||||
|
// Freigabelink lieferte ungeprüft aus – ausgerechnet der Weg, auf dem eine
|
||||||
|
// Datei das Haus verlässt. Wer sie angemeldet holt, ist der Eigentümer und
|
||||||
|
// merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht.
|
||||||
|
//
|
||||||
|
// In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen,
|
||||||
|
// **beim Download verifizieren**. Die Regel galt an einer von zwei Stellen.
|
||||||
|
// Deshalb liegt sie jetzt hier und wird von beiden gerufen.
|
||||||
|
|
||||||
|
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
|
||||||
|
//
|
||||||
|
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
|
||||||
|
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
|
||||||
|
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
|
||||||
|
const verifyTail = 32 * 1024
|
||||||
|
|
||||||
|
// VerifyInfo ist nur für den Logeintrag – wer hinterher sucht, braucht die
|
||||||
|
// Datei und ihren Platz auf der Platte, nicht bloß „stimmt nicht".
|
||||||
|
type VerifyInfo struct {
|
||||||
|
FileID string
|
||||||
|
StorageKey string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeVerified kopiert `src` nach `w` und prüft dabei die Summe.
|
||||||
|
//
|
||||||
|
// Stimmt sie nicht, wird der letzte Rest **nicht** herausgegeben und die
|
||||||
|
// Verbindung fallen gelassen. Die Kopfzeilen sind da längst draußen, der
|
||||||
|
// Statuscode also nicht mehr zu ändern – aber die Gegenstelle bekommt weniger
|
||||||
|
// Bytes als im Content-Length angekündigt und wertet das als Fehler. Besser
|
||||||
|
// ein abgebrochener Download als eine heile Datei mit falschem Inhalt.
|
||||||
|
func ServeVerified(ctx context.Context, w io.Writer, src io.Reader, erwartet string, info VerifyInfo) {
|
||||||
|
h256 := sha256.New()
|
||||||
|
zurueck := &tailWriter{w: w, keep: verifyTail}
|
||||||
|
|
||||||
|
if _, err := io.Copy(zurueck, io.TeeReader(src, h256)); err != nil {
|
||||||
|
return // Gegenstelle ist weg
|
||||||
|
}
|
||||||
|
|
||||||
|
gelesen := hex.EncodeToString(h256.Sum(nil))
|
||||||
|
if gelesen != erwartet {
|
||||||
|
log.Ctx(ctx).Error().
|
||||||
|
Str("file_id", info.FileID).
|
||||||
|
Str("storage_key", info.StorageKey).
|
||||||
|
Str("erwartet", erwartet).
|
||||||
|
Str("gelesen", gelesen).
|
||||||
|
Int64("size", info.Size).
|
||||||
|
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
|
||||||
|
|
||||||
|
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
|
||||||
|
// fallen zu lassen: net/http schließt die Verbindung und schreibt
|
||||||
|
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
|
||||||
|
// Panik ausdrücklich durch).
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stimmt sie, darf der Rest hinterher.
|
||||||
|
_ = zurueck.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
|
||||||
|
type tailWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
keep int
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailWriter) Write(p []byte) (int, error) {
|
||||||
|
t.buf = append(t.buf, p...)
|
||||||
|
if ueber := len(t.buf) - t.keep; ueber > 0 {
|
||||||
|
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
t.buf = append(t.buf[:0], t.buf[ueber:]...)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
|
||||||
|
// bleibt die Antwort unvollständig – genau das ist der Zweck.
|
||||||
|
func (t *tailWriter) Flush() error {
|
||||||
|
if len(t.buf) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := t.w.Write(t.buf)
|
||||||
|
t.buf = t.buf[:0]
|
||||||
|
return err
|
||||||
|
}
|
||||||
+10
-23
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
|
"aether/pkg/bg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -72,6 +73,11 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
|
|||||||
|
|
||||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||||
|
// "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die
|
||||||
|
// als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und
|
||||||
|
// die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so
|
||||||
|
// sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste,
|
||||||
|
// bleiben sie für immer liegen.
|
||||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||||
res.Scanned++
|
res.Scanned++
|
||||||
@@ -153,27 +159,6 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
|||||||
return nil, fmt.Errorf("iterate file keys: %w", err)
|
return nil, fmt.Errorf("iterate file keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
|
|
||||||
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
|
|
||||||
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
|
|
||||||
prevRows, err := c.db.Query(ctx,
|
|
||||||
`SELECT id::text FROM files WHERE preview_version > 0`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query preview keys: %w", err)
|
|
||||||
}
|
|
||||||
for prevRows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := prevRows.Scan(&id); err != nil {
|
|
||||||
prevRows.Close()
|
|
||||||
return nil, fmt.Errorf("scan preview key: %w", err)
|
|
||||||
}
|
|
||||||
keys["previews/"+id+".pdf"] = true
|
|
||||||
}
|
|
||||||
prevRows.Close()
|
|
||||||
if err := prevRows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("iterate preview keys: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
||||||
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
||||||
@@ -254,7 +239,9 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
|||||||
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||||
// Hochfahren konkurriert.
|
// Hochfahren konkurriert.
|
||||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||||
go func() {
|
// Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein
|
||||||
|
// Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll.
|
||||||
|
bg.Go("Speicherbereinigung", func() {
|
||||||
timer := time.NewTimer(5 * time.Minute)
|
timer := time.NewTimer(5 * time.Minute)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
@@ -282,7 +269,7 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
|||||||
}
|
}
|
||||||
timer.Reset(Interval)
|
timer.Reset(Interval)
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
package office
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ein Dokument nach PDF wandeln – dafür, es *anzusehen*, statt es zu
|
|
||||||
// bearbeiten.
|
|
||||||
//
|
|
||||||
// **Warum überhaupt:** Am Telefon ist der Office-Editor unbrauchbar. Er ist
|
|
||||||
// eine vollständige Textverarbeitung; Menüband, Formatleisten und
|
|
||||||
// Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt. Auf 400 Pixeln
|
|
||||||
// bleibt für das Dokument übrig, was die Leisten nicht brauchen. Das gilt für
|
|
||||||
// Collabora wie für OnlyOffice – Marc hat beide ausprobiert, mit demselben
|
|
||||||
// Ergebnis. Ein PDF im eigenen Betrachter ist für „kurz nachsehen" das
|
|
||||||
// bessere Werkzeug, und für „kurz nachsehen" ist man unterwegs da.
|
|
||||||
//
|
|
||||||
// **Warum derselbe Server, der auch bearbeitet:** Wer OnlyOffice wegen der
|
|
||||||
// Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe,
|
|
||||||
// sähe unterwegs ein LibreOffice-Layout desselben Dokuments. Zwei Geräte,
|
|
||||||
// zwei Umbrüche – ausgerechnet bei Unterlagen, bei denen es auf die Seite
|
|
||||||
// ankommt. Die Umwandlung gehört deshalb zu dem Anbieter, der auch bearbeitet.
|
|
||||||
//
|
|
||||||
// Die beiden Anbieter gehen dabei verschiedene Wege, und der Unterschied ist
|
|
||||||
// grundsätzlich:
|
|
||||||
//
|
|
||||||
// - **Collabora** nimmt die Datei entgegen und antwortet mit dem PDF. Ein
|
|
||||||
// Aufruf, keine Anmeldung.
|
|
||||||
// - **OnlyOffice** nimmt eine *Adresse* entgegen, holt die Datei selbst ab
|
|
||||||
// und antwortet mit einer Adresse, unter der das Ergebnis liegt. Also
|
|
||||||
// zwei Aufrufe, eine Signatur, und ein zweiter Ort, dem man nicht trauen
|
|
||||||
// darf (siehe unten).
|
|
||||||
|
|
||||||
// convertTimeout: So lange darf eine Umwandlung dauern.
|
|
||||||
//
|
|
||||||
// Gemessen an dieser Anlage braucht ein gewöhnliches Textdokument etwa eine
|
|
||||||
// Sekunde. Eine Minute ist keine Erwartung, sondern eine Notbremse: Hängt der
|
|
||||||
// Office-Server, soll die Anfrage enden und nicht der Server mit ihr.
|
|
||||||
const convertTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// jwtLifetime: Wie lange die Signatur an OnlyOffice gilt. Sie wird für genau
|
|
||||||
// einen Aufruf ausgestellt.
|
|
||||||
const jwtLifetime = 2 * time.Minute
|
|
||||||
|
|
||||||
// ConvertToPDF wandelt ein Dokument um.
|
|
||||||
//
|
|
||||||
// `src` ist die Adresse, unter der der Office-Server die Datei abholen kann –
|
|
||||||
// nur OnlyOffice braucht sie. `body` sind die Bytes selbst – nur Collabora
|
|
||||||
// braucht die. Der Aufrufer stellt beides bereit; welcher Weg genommen wird,
|
|
||||||
// entscheidet der Anbieter, nicht der Aufrufer.
|
|
||||||
func (c *Client) ConvertToPDF(ctx context.Context, ext, key, name, src string, body io.Reader) ([]byte, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
switch c.provider {
|
|
||||||
case "collabora":
|
|
||||||
return c.convertCollabora(ctx, name, body)
|
|
||||||
case "onlyoffice":
|
|
||||||
return c.convertOnlyOffice(ctx, ext, key, name, src)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("office: provider %q cannot convert", c.provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanConvert sagt, ob dieser Anbieter das Wandeln überhaupt anbietet. Für die
|
|
||||||
// Oberfläche: Ohne das gäbe es einen Knopf, der nur Fehler erzeugt.
|
|
||||||
func (c *Client) CanConvert() bool {
|
|
||||||
return c != nil && (c.provider == "collabora" || c.provider == "onlyoffice")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Collabora ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (c *Client) convertCollabora(ctx context.Context, name string, body io.Reader) ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
mw := multipart.NewWriter(&buf)
|
|
||||||
// Der Feldname ist vorgeschrieben („data"), der Dateiname bestimmt, wie
|
|
||||||
// Collabora den Typ erkennt – ohne Endung rät es und liegt daneben.
|
|
||||||
part, err := mw.CreateFormFile("data", name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(part, body); err != nil {
|
|
||||||
return nil, fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
|
||||||
if err := mw.Close(); err != nil {
|
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
||||||
c.baseURL+"/cool/convert-to/pdf", &buf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("convert: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("convert: office server answered %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
// Collabora antwortet mit dem PDF selbst, nicht mit einer Auskunft.
|
|
||||||
return readPDF(resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── OnlyOffice / Euro-Office ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type convertResponse struct {
|
|
||||||
FileURL string `json:"fileUrl"`
|
|
||||||
FileType string `json:"fileType"`
|
|
||||||
Percent int `json:"percent"`
|
|
||||||
EndConvert bool `json:"endConvert"`
|
|
||||||
// Error ist eine Zahl, keine Meldung: -8 heißt „Signatur fehlt oder ist
|
|
||||||
// falsch", -4 „Quelle nicht erreichbar". Die Zahl steht deshalb im
|
|
||||||
// Fehlertext, sonst sucht man im Log des anderen Servers.
|
|
||||||
Error int `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) convertOnlyOffice(ctx context.Context, ext, key, name, src string) ([]byte, error) {
|
|
||||||
if src == "" {
|
|
||||||
return nil, fmt.Errorf("convert: no source address for the document server")
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
// Synchron: Die Umwandlung dauert Sekunden, und ein zweiter Aufruf zum
|
|
||||||
// Nachfragen wäre ein Zustand mehr, den jemand verwalten muss.
|
|
||||||
"async": false,
|
|
||||||
"filetype": strings.TrimPrefix(ext, "."),
|
|
||||||
"key": key,
|
|
||||||
"outputtype": "pdf",
|
|
||||||
"title": name,
|
|
||||||
"url": src,
|
|
||||||
}
|
|
||||||
if c.jwtSecret != "" {
|
|
||||||
// Die Signatur trägt genau die Nutzlast, die auch gesendet wird –
|
|
||||||
// gemessen: ohne sie antwortet der Server `{"error":-8}`.
|
|
||||||
payload["token"] = signJWT(c.jwtSecret, payload)
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
||||||
c.baseURL+"/ConvertService.ashx", bytes.NewReader(raw))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
// Ohne diesen Kopf antwortet der Server in XML.
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("convert: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("convert: document server answered %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
var out convertResponse
|
|
||||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
|
||||||
return nil, fmt.Errorf("convert: unreadable answer: %w", err)
|
|
||||||
}
|
|
||||||
if out.Error != 0 {
|
|
||||||
return nil, fmt.Errorf("convert: document server error %d", out.Error)
|
|
||||||
}
|
|
||||||
if !out.EndConvert || out.FileURL == "" {
|
|
||||||
return nil, fmt.Errorf("convert: document server did not finish (%d%%)", out.Percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** Sie kommt aus
|
|
||||||
// der Antwort eines anderen Servers, und wir würden sie gleich abrufen –
|
|
||||||
// das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf
|
|
||||||
// 169.254.169.254 würde. Der Wähler des Transports lässt ohnehin nur
|
|
||||||
// Adressen aus OFFICE_ALLOWED_HOSTS zu; hier steht die Prüfung trotzdem
|
|
||||||
// noch einmal ausdrücklich, weil eine klare Meldung mehr wert ist als ein
|
|
||||||
// abgewiesener Verbindungsversuch tief im Netzstapel.
|
|
||||||
if err := c.sameHostAsServer(out.FileURL); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
get, err := http.NewRequestWithContext(ctx, http.MethodGet, out.FileURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res2, err := c.http.Do(get)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetch result: %w", err)
|
|
||||||
}
|
|
||||||
defer res2.Body.Close()
|
|
||||||
if res2.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("fetch result: answered %d", res2.StatusCode)
|
|
||||||
}
|
|
||||||
return readPDF(res2.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sameHostAsServer prüft, dass eine Adresse auf denselben Rechner zeigt wie
|
|
||||||
// der Office-Server selbst.
|
|
||||||
func (c *Client) sameHostAsServer(raw string) error {
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("convert: unreadable result address")
|
|
||||||
}
|
|
||||||
base, err := url.Parse(c.baseURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("convert: OFFICE_URL is not a valid address")
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(u.Hostname(), base.Hostname()) {
|
|
||||||
return fmt.Errorf("convert: result address points elsewhere (%q), refused", u.Hostname())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── gemeinsam ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// maxPDFBytes begrenzt, was als Ergebnis angenommen wird. Ohne Grenze
|
|
||||||
// bestimmte der andere Server, wie viel Speicher wir belegen.
|
|
||||||
const maxPDFBytes = 256 << 20
|
|
||||||
|
|
||||||
func readPDF(r io.Reader) ([]byte, error) {
|
|
||||||
data, err := io.ReadAll(io.LimitReader(r, maxPDFBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read result: %w", err)
|
|
||||||
}
|
|
||||||
if len(data) > maxPDFBytes {
|
|
||||||
return nil, fmt.Errorf("read result: converted file is too large")
|
|
||||||
}
|
|
||||||
// Kein Vertrauen auf den Content-Type: Collabora liefert
|
|
||||||
// `application/octet-stream`. Die ersten Bytes sind die Auskunft, die
|
|
||||||
// zählt – und sie verhindert, dass eine Fehlerseite als PDF abgelegt wird.
|
|
||||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
|
||||||
return nil, fmt.Errorf("read result: answer is not a PDF")
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// signJWT baut ein HS256-Token über die Nutzlast.
|
|
||||||
//
|
|
||||||
// Von Hand statt mit einer Bibliothek: Es sind zwölf Zeilen, und die einzige
|
|
||||||
// Alternative wäre eine weitere Abhängigkeit für einen einzigen Aufruf.
|
|
||||||
func signJWT(secret string, payload map[string]any) string {
|
|
||||||
claims := make(map[string]any, len(payload)+1)
|
|
||||||
for k, v := range payload {
|
|
||||||
claims[k] = v
|
|
||||||
}
|
|
||||||
claims["exp"] = time.Now().Add(jwtLifetime).Unix()
|
|
||||||
|
|
||||||
enc := func(v any) string {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
head := enc(map[string]string{"alg": "HS256", "typ": "JWT"})
|
|
||||||
body := enc(claims)
|
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
|
||||||
mac.Write([]byte(head + "." + body))
|
|
||||||
return head + "." + body + "." +
|
|
||||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package office
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
|
|
||||||
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
|
|
||||||
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
|
|
||||||
|
|
||||||
func TestSignJWTIsVerifiable(t *testing.T) {
|
|
||||||
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
|
|
||||||
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
|
|
||||||
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
|
||||||
mac.Write([]byte(parts[0] + "." + parts[1]))
|
|
||||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
if parts[2] != want {
|
|
||||||
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
|
|
||||||
}
|
|
||||||
|
|
||||||
head, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Kopf nicht dekodierbar: %v", err)
|
|
||||||
}
|
|
||||||
var h map[string]string
|
|
||||||
if err := json.Unmarshal(head, &h); err != nil {
|
|
||||||
t.Fatalf("Kopf ist kein JSON: %v", err)
|
|
||||||
}
|
|
||||||
if h["alg"] != "HS256" {
|
|
||||||
t.Errorf("alg = %q, erwartet HS256", h["alg"])
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
var claims map[string]any
|
|
||||||
if err := json.Unmarshal(body, &claims); err != nil {
|
|
||||||
t.Fatalf("Nutzlast ist kein JSON: %v", err)
|
|
||||||
}
|
|
||||||
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
|
|
||||||
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
|
|
||||||
}
|
|
||||||
// Ohne Ablauf gälte ein abgefangenes Token für immer.
|
|
||||||
if _, ok := claims["exp"]; !ok {
|
|
||||||
t.Error("exp fehlt – das Token liefe nie ab")
|
|
||||||
}
|
|
||||||
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
|
|
||||||
if _, ok := claims["token"]; ok {
|
|
||||||
t.Error("die Signatur signiert sich selbst mit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadPDFRejectsNonPDF(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
|
|
||||||
"leer": "",
|
|
||||||
"JSON-Fehler": `{"error":-4}`,
|
|
||||||
"fast ein PDF": "%PD",
|
|
||||||
}
|
|
||||||
for name, body := range cases {
|
|
||||||
if _, err := readPDF(strings.NewReader(body)); err == nil {
|
|
||||||
t.Errorf("%s wurde als PDF angenommen", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
|
|
||||||
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
|
|
||||||
t.Errorf("echtes PDF abgelehnt: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
|
|
||||||
c := &Client{baseURL: "http://office:80"}
|
|
||||||
|
|
||||||
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
|
|
||||||
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
|
|
||||||
foreign := []string{
|
|
||||||
"http://169.254.169.254/latest/meta-data/",
|
|
||||||
"http://localhost:8080/api/v1/admin/users",
|
|
||||||
"http://office.evil.example.com/x.pdf",
|
|
||||||
"file:///etc/passwd",
|
|
||||||
"://kaputt",
|
|
||||||
}
|
|
||||||
for _, raw := range foreign {
|
|
||||||
if err := c.sameHostAsServer(raw); err == nil {
|
|
||||||
t.Errorf("fremde Adresse angenommen: %s", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ok := range []string{
|
|
||||||
"http://office/cache/files/x.pdf",
|
|
||||||
"http://office:80/cache/files/x.pdf",
|
|
||||||
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
|
|
||||||
} {
|
|
||||||
if err := c.sameHostAsServer(ok); err != nil {
|
|
||||||
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeFilename(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
`Rechnung "2026".docx`: "Rechnung 2026",
|
|
||||||
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
|
|
||||||
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
|
|
||||||
".odt": ".odt",
|
|
||||||
"": "document",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := sanitizeFilename(in); got != want {
|
|
||||||
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,9 +48,6 @@ type Client struct {
|
|||||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||||
// keinem Browser.
|
// keinem Browser.
|
||||||
publicBase string
|
publicBase string
|
||||||
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
|
|
||||||
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
|
|
||||||
jwtSecret string
|
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -65,7 +62,7 @@ type Client struct {
|
|||||||
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
||||||
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
||||||
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
||||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
|
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
|
||||||
u, err := url.Parse(baseURL)
|
u, err := url.Parse(baseURL)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
||||||
@@ -99,7 +96,6 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
|
|||||||
provider: provider,
|
provider: provider,
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
publicBase: strings.TrimRight(publicURL, "/"),
|
publicBase: strings.TrimRight(publicURL, "/"),
|
||||||
jwtSecret: jwtSecret,
|
|
||||||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -128,15 +124,33 @@ func (c *Client) Provider() string { return c.provider }
|
|||||||
//
|
//
|
||||||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||||||
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
|
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
|
||||||
if err := c.refresh(ctx); err != nil {
|
if err := c.refresh(ctx); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// Am Finger zuerst die Handy-Fassung.
|
||||||
|
//
|
||||||
|
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
|
||||||
|
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
|
||||||
|
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
|
||||||
|
// Schirme gebaute Oberfläche. Aether hat bisher immer die
|
||||||
|
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
|
||||||
|
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
|
||||||
|
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
|
||||||
|
// wir selbst falsch bestellt hatten.
|
||||||
|
//
|
||||||
|
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
|
||||||
|
// Eintrag, und es bleibt beim bisherigen Verhalten.
|
||||||
order := []string{"edit", "view"}
|
order := []string{"edit", "view"}
|
||||||
if !write {
|
switch {
|
||||||
|
case mobile && write:
|
||||||
|
order = []string{"mobileEdit", "edit", "mobileView", "view"}
|
||||||
|
case mobile:
|
||||||
|
order = []string{"mobileView", "view", "mobileEdit", "edit"}
|
||||||
|
case !write:
|
||||||
order = []string{"view", "edit"}
|
order = []string{"view", "edit"}
|
||||||
}
|
}
|
||||||
for _, action := range order {
|
for _, action := range order {
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write := access.Permission == acl.PermWrite
|
write := access.Permission == acl.PermWrite
|
||||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
// Finger oder Maus? Das entscheidet die Oberfläche, nicht der Server:
|
||||||
|
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus.
|
||||||
|
// Deshalb fragen wir nicht die Kennung des Browsers, sondern lassen die
|
||||||
|
// Anwendung sagen, was sie über ihr Zeigegerät weiß.
|
||||||
|
mobile := r.URL.Query().Get("mobile") == "1"
|
||||||
|
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write, mobile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
package office
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
|
|
||||||
"aether/internal/auth"
|
|
||||||
"aether/internal/files"
|
|
||||||
"aether/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
|
|
||||||
//
|
|
||||||
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
|
|
||||||
// bleibt einen Knopfdruck entfernt.
|
|
||||||
//
|
|
||||||
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
|
|
||||||
//
|
|
||||||
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
|
|
||||||
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
|
|
||||||
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
|
|
||||||
// Frage des Aufräumens, sondern des Schlüssels.
|
|
||||||
//
|
|
||||||
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
|
|
||||||
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
|
|
||||||
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
|
|
||||||
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
|
|
||||||
//
|
|
||||||
// ── Was das für die Platte heißt ────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
|
|
||||||
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
|
|
||||||
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
|
|
||||||
// den Ordner – beides ist nötig, und beides einzeln geprüft.
|
|
||||||
|
|
||||||
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
|
|
||||||
// ebenfalls kennen müssen.
|
|
||||||
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
|
|
||||||
|
|
||||||
// PreviewPrefix ist der Ordner, den der GC durchgeht.
|
|
||||||
const PreviewPrefix = "previews"
|
|
||||||
|
|
||||||
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
|
|
||||||
//
|
|
||||||
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
|
|
||||||
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
|
|
||||||
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
|
|
||||||
const maxPreviewSource = 100 << 20
|
|
||||||
|
|
||||||
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
|
|
||||||
//
|
|
||||||
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
|
|
||||||
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
|
|
||||||
// Umwandlung, nicht die Richtigkeit.
|
|
||||||
var previewLocks sync.Map // fileID -> *sync.Mutex
|
|
||||||
|
|
||||||
func lockPreview(id string) func() {
|
|
||||||
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
|
|
||||||
mu := m.(*sync.Mutex)
|
|
||||||
mu.Lock()
|
|
||||||
return mu.Unlock
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/files/{id}/preview.pdf
|
|
||||||
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !h.Enabled() || !h.client.CanConvert() {
|
|
||||||
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f, userID, ok := h.resolveSession(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !Editable(f.Name) {
|
|
||||||
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f.Size > maxPreviewSource {
|
|
||||||
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
|
|
||||||
// Editor anbieten soll oder einen Fehler zeigt.
|
|
||||||
response.Error(w, http.StatusRequestEntityTooLarge,
|
|
||||||
"this document is too large to be shown as a PDF")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
unlock := lockPreview(f.ID.String())
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
key := PreviewKey(f.ID.String())
|
|
||||||
have, err := h.files.PreviewVersion(r.Context(), f.ID)
|
|
||||||
if err != nil && !errors.Is(err, files.ErrNotFound) {
|
|
||||||
response.Error(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if have == f.Version {
|
|
||||||
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
|
|
||||||
defer rc.Close()
|
|
||||||
servePDF(w, f.Name, f.Version)
|
|
||||||
_, _ = io.Copy(w, rc)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
|
|
||||||
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
|
|
||||||
// sie neu zu erzeugen.
|
|
||||||
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
|
|
||||||
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf, err := h.convert(r.Context(), f, userID)
|
|
||||||
if err != nil {
|
|
||||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
|
||||||
Msg("office preview: Umwandlung fehlgeschlagen")
|
|
||||||
response.Error(w, http.StatusBadGateway, "the document could not be converted")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
|
|
||||||
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
|
|
||||||
// ohne es zu merken.
|
|
||||||
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
|
|
||||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
|
|
||||||
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
|
|
||||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
|
|
||||||
}
|
|
||||||
|
|
||||||
servePDF(w, f.Name, f.Version)
|
|
||||||
_, _ = w.Write(pdf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
|
|
||||||
// versteht.
|
|
||||||
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
|
|
||||||
ext := Extension(f.Name)
|
|
||||||
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
|
|
||||||
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
|
|
||||||
// alte Dokument aus.
|
|
||||||
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
|
|
||||||
|
|
||||||
switch h.client.Provider() {
|
|
||||||
case "collabora":
|
|
||||||
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
|
|
||||||
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
|
|
||||||
// Erreichbarkeit – und ein Token mehr in der Welt.
|
|
||||||
rc, err := h.blobs.Get(ctx, f.StorageKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
|
|
||||||
|
|
||||||
default:
|
|
||||||
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
|
|
||||||
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
|
|
||||||
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
|
|
||||||
token, _ := h.signer.Issue(f.ID, userID, false)
|
|
||||||
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
|
|
||||||
h.wopiURL, f.ID, token)
|
|
||||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func servePDF(w http.ResponseWriter, name string, version int) {
|
|
||||||
w.Header().Set("Content-Type", "application/pdf")
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
|
|
||||||
w.Header().Set("Content-Disposition",
|
|
||||||
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
|
|
||||||
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
|
|
||||||
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
|
|
||||||
// der Browser holt es nach einer Änderung also von selbst neu.
|
|
||||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
|
||||||
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
|
|
||||||
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
|
|
||||||
func sanitizeFilename(name string) string {
|
|
||||||
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
|
|
||||||
if i := strings.LastIndexByte(name, '.'); i > 0 {
|
|
||||||
name = name[:i]
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
name = "document"
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
|
|
||||||
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
|
|
||||||
//
|
|
||||||
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
|
|
||||||
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
|
|
||||||
// Token für genau eine Datei.
|
|
||||||
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "not found")
|
|
||||||
return nil, uuid.Nil, false
|
|
||||||
}
|
|
||||||
user := auth.UserFromContext(r.Context())
|
|
||||||
if user == nil {
|
|
||||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
|
||||||
return nil, uuid.Nil, false
|
|
||||||
}
|
|
||||||
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
|
|
||||||
// Datei gibt.
|
|
||||||
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "not found")
|
|
||||||
return nil, uuid.Nil, false
|
|
||||||
}
|
|
||||||
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "not found")
|
|
||||||
return nil, uuid.Nil, false
|
|
||||||
}
|
|
||||||
return f, user.ID, true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package rescan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checksumOf liest die Datei einmal ganz und liefert Prüfsum und Größe.
|
||||||
|
//
|
||||||
|
// Ohne sie ginge es schneller – aber eine Datei ohne Prüfsum in der
|
||||||
|
// Datenbank ist eine Datei, deren Beschädigung beim Herunterladen niemand
|
||||||
|
// bemerkt. Das ist der Punkt, an dem Aether sonst gerade nicht spart.
|
||||||
|
// Der Anfang der Datei genügt zur Typerkennung – http.DetectContentType
|
||||||
|
// sieht sich ohnehin nur die ersten 512 Bytes an.
|
||||||
|
const sniffLen = 512
|
||||||
|
|
||||||
|
func (s *Scanner) checksumOf(ctx context.Context, key string) (sum string, size int64, head []byte, err error) {
|
||||||
|
rc, err := s.storage.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
// Die Datei wird für die Prüfsumme ohnehin einmal ganz gelesen; den
|
||||||
|
// Anfang dabei mitzunehmen kostet nichts und erspart einen zweiten Weg
|
||||||
|
// über die Platte.
|
||||||
|
kopf := &bytes.Buffer{}
|
||||||
|
n, err := io.Copy(h, io.TeeReader(rc, &begrenzterSchreiber{buf: kopf, rest: sniffLen}))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), n, kopf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// begrenzterSchreiber nimmt nur die ersten rest Bytes an und wirft den Rest
|
||||||
|
// weg – ohne das läge bei einer 5-GB-Datei alles im Arbeitsspeicher.
|
||||||
|
type begrenzterSchreiber struct {
|
||||||
|
buf *bytes.Buffer
|
||||||
|
rest int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *begrenzterSchreiber) Write(p []byte) (int, error) {
|
||||||
|
if w.rest > 0 {
|
||||||
|
n := min(w.rest, len(p))
|
||||||
|
w.buf.Write(p[:n])
|
||||||
|
w.rest -= n
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeOf bestimmt den Dateityp – erst über die Endung, sonst über den
|
||||||
|
// Inhalt.
|
||||||
|
//
|
||||||
|
// Der Umweg über den Inhalt ist nötig, nicht schmückend: Das Alpine-Abbild
|
||||||
|
// bringt keine /etc/mime.types mit, und Gos eingebaute Liste kennt gerade
|
||||||
|
// einmal ein Dutzend Endungen – .txt gehört nicht dazu. Ohne den zweiten
|
||||||
|
// Anlauf bekäme jede Textdatei "application/octet-stream" und würde in der
|
||||||
|
// Vorschau nicht angezeigt.
|
||||||
|
//
|
||||||
|
// Beim Hochladen fällt das nie auf, weil der Browser den Typ mitschickt.
|
||||||
|
// Eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt.
|
||||||
|
func mimeOf(name string, head []byte) string {
|
||||||
|
if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); t != "" {
|
||||||
|
return ohneParameter(t)
|
||||||
|
}
|
||||||
|
if len(head) > 0 {
|
||||||
|
return ohneParameter(http.DetectContentType(head))
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// "text/plain; charset=utf-8" → "text/plain", so wie es der Upload-Weg auch
|
||||||
|
// speichert.
|
||||||
|
func ohneParameter(t string) string {
|
||||||
|
if i := strings.Index(t, ";"); i > 0 {
|
||||||
|
return strings.TrimSpace(t[:i])
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOwner findet heraus, wem eine gefundene Datei gehört: das Konto
|
||||||
|
// mit diesem Namen, oder die Gruppe mit dieser Kennung.
|
||||||
|
func (s *Scanner) resolveOwner(ctx context.Context, root, owner string) (ownerID, groupID *uuid.UUID, err error) {
|
||||||
|
if root == "groups" {
|
||||||
|
id, err := uuid.Parse(owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("keine Gruppenkennung: %q", owner)
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM groups WHERE id = $1)`, id).Scan(&exists); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, nil, fmt.Errorf("Gruppe %s gibt es nicht", owner)
|
||||||
|
}
|
||||||
|
return nil, &id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id uuid.UUID
|
||||||
|
err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE username = $1`, owner).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, fmt.Errorf("Konto %q gibt es nicht", owner)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return &id, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureFolders legt die Ordnerkette an, in der die Datei liegt, und liefert
|
||||||
|
// den innersten Ordner. Ein Ordner, den es schon gibt, wird benutzt.
|
||||||
|
func (s *Scanner) ensureFolders(ctx context.Context, ownerID, groupID *uuid.UUID, folderPath string) (*uuid.UUID, error) {
|
||||||
|
if folderPath == "" {
|
||||||
|
return nil, nil // liegt direkt im Wurzelverzeichnis
|
||||||
|
}
|
||||||
|
var parent *uuid.UUID
|
||||||
|
for _, name := range strings.Split(folderPath, "/") {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
// COALESCE statt zweier Abfragen: Ein Ordner hängt entweder an einem
|
||||||
|
// Konto oder an einer Gruppe, nie an beidem.
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`SELECT id FROM folders
|
||||||
|
WHERE COALESCE(owner_id, group_id) = COALESCE($1::uuid, $2::uuid)
|
||||||
|
AND name = $3
|
||||||
|
AND parent_id IS NOT DISTINCT FROM $4`,
|
||||||
|
ownerID, groupID, name, parent,
|
||||||
|
).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
err = s.db.QueryRow(ctx,
|
||||||
|
`INSERT INTO folders (owner_id, group_id, parent_id, name)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
ownerID, groupID, parent, name,
|
||||||
|
).Scan(&id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Ordner %q: %w", name, err)
|
||||||
|
}
|
||||||
|
copy := id
|
||||||
|
parent = ©
|
||||||
|
}
|
||||||
|
return parent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adopt macht aus einer Datei auf der Platte einen Entry in Aether.
|
||||||
|
func (s *Scanner) adopt(ctx context.Context, root, owner, folder, name, key string) error {
|
||||||
|
ownerID, groupID, err := s.resolveOwner(ctx, root, owner)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
folderID, err := s.ensureFolders(ctx, ownerID, groupID, folder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sum, size, head, err := s.checksumOf(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(ctx,
|
||||||
|
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
ownerID, groupID, folderID, name, mimeOf(name, head), size, sum, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Entry anlegen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// correct übernimmt Größe und Prüfsum einer von außen ersetzten Datei.
|
||||||
|
func (s *Scanner) correct(ctx context.Context, id uuid.UUID, key string) error {
|
||||||
|
sum, size, _, err := s.checksumOf(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(ctx,
|
||||||
|
`UPDATE files SET size = $2, checksum = $3, updated_at = now() WHERE id = $1`,
|
||||||
|
id, size, sum)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove räumt einen Entry weg, dessen Datei nicht mehr exists ist – und mit
|
||||||
|
// ihm alles, was nur wegen dieser Datei existierte: frühere Fassungen,
|
||||||
|
// Vorschaubild, umgewandelte Ansicht.
|
||||||
|
//
|
||||||
|
// Die Reihenfolge ist Absicht: erst die Schlüssel der früheren Fassungen
|
||||||
|
// sample, dann die Zeile löschen (die Kaskade nimmt die Versionszeilen mit),
|
||||||
|
// dann die Bytes. Andersherum bliebe bei einem Abbruch eine Zeile stehen, die
|
||||||
|
// auf nichts mehr zeigt – also genau das, was hier aufgeräumt werden soll.
|
||||||
|
func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error {
|
||||||
|
rows, err := s.db.Query(ctx, `SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("frühere Fassungen lesen: %w", err)
|
||||||
|
}
|
||||||
|
var versionKeys []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
versionKeys = append(versionKeys, k)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE id = $1`, id); err != nil {
|
||||||
|
return fmt.Errorf("Entry remove: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier ist der Entry weg; was jetzt noch scheitert, ist ein Rest auf
|
||||||
|
// der Platte, den der GC beim nächsten Lauf ohnehin einsammelt.
|
||||||
|
for _, k := range versionKeys {
|
||||||
|
_ = s.storage.Delete(ctx, k)
|
||||||
|
}
|
||||||
|
_ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package rescan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"aether/internal/files"
|
||||||
|
"aether/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Der Abgleich ist das einzige Stück Aether, das **hart** löscht:
|
||||||
|
// `DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang über
|
||||||
|
// die Platte nicht gesehen wurde.
|
||||||
|
//
|
||||||
|
// Der Ernstfall ist nicht ausgedacht: `LocalFS.Walk` gibt für ein nicht
|
||||||
|
// vorhandenes Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||||
|
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam.
|
||||||
|
// Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit
|
||||||
|
// `?anwenden=true` löschte den ganzen Bestand aus der Datenbank.
|
||||||
|
//
|
||||||
|
// Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der
|
||||||
|
// gefährlicheren von beiden Stellen.
|
||||||
|
|
||||||
|
func TestNotbremseBeiLeeremSpeicher(t *testing.T) {
|
||||||
|
// Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar.
|
||||||
|
grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false)
|
||||||
|
if grund == "" {
|
||||||
|
t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen")
|
||||||
|
}
|
||||||
|
if uebersteuerbar {
|
||||||
|
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||||
|
}
|
||||||
|
// Auch mit ausdruecklicher Bestaetigung nicht.
|
||||||
|
if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" {
|
||||||
|
t.Fatal("auch mit Bestaetigung muss die Notbremse halten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeereInstallationDarfDurch(t *testing.T) {
|
||||||
|
// Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu
|
||||||
|
// verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie
|
||||||
|
// abgleichen.
|
||||||
|
if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" {
|
||||||
|
t.Fatalf("leere Installation darf nicht bremsen: %s", grund)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMengenbremseLaesstSichBestaetigen(t *testing.T) {
|
||||||
|
// Mehr als die Hälfte auf einmal: erst nachfragen.
|
||||||
|
grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false)
|
||||||
|
if grund == "" {
|
||||||
|
t.Fatal("mehr als die Haelfte muss nachfragen")
|
||||||
|
}
|
||||||
|
if !uebersteuerbar {
|
||||||
|
t.Fatal("die Mengenbremse muss sich bestaetigen lassen")
|
||||||
|
}
|
||||||
|
if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" {
|
||||||
|
t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) {
|
||||||
|
// Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die
|
||||||
|
// Funktion unbrauchbar – und jemand baute die Bremse wieder aus.
|
||||||
|
for _, f := range []struct {
|
||||||
|
gescannt, lebende, fallen int
|
||||||
|
}{
|
||||||
|
{1000, 1000, 1},
|
||||||
|
{1000, 1000, 17},
|
||||||
|
{1000, 1000, 500}, // genau die Hälfte: noch erlaubt
|
||||||
|
{1, 1, 0},
|
||||||
|
} {
|
||||||
|
if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" {
|
||||||
|
t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein
|
||||||
|
// Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch
|
||||||
|
// da sein.
|
||||||
|
//
|
||||||
|
// Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/
|
||||||
|
func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) {
|
||||||
|
dsn := os.Getenv("TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verbinden: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
var nutzer uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO users (username, display_name, password, is_admin)
|
||||||
|
VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8],
|
||||||
|
).Scan(&nutzer); err != nil {
|
||||||
|
t.Fatalf("konto: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer)
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||||
|
VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`,
|
||||||
|
nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("datei: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das
|
||||||
|
// sieht der Server, wenn der Bind-Mount nicht hochkam.
|
||||||
|
wurzel := t.TempDir()
|
||||||
|
st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("speicher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := New(pool, files.NewStore(pool), st)
|
||||||
|
bericht, err := scanner.Apply(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anwenden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bericht.Aborted {
|
||||||
|
t.Fatal("der Lauf haette abbrechen muessen")
|
||||||
|
}
|
||||||
|
if bericht.Removed != 0 {
|
||||||
|
t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed)
|
||||||
|
}
|
||||||
|
if bericht.Overridable {
|
||||||
|
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||||
|
}
|
||||||
|
|
||||||
|
var uebrig int
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||||
|
t.Fatalf("zaehlen: %v", err)
|
||||||
|
}
|
||||||
|
if uebrig != 5 {
|
||||||
|
t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen.
|
||||||
|
if _, err := scanner.Apply(ctx, true); err != nil {
|
||||||
|
t.Fatalf("anwenden mit Bestaetigung: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||||
|
t.Fatalf("zaehlen: %v", err)
|
||||||
|
}
|
||||||
|
if uebrig != 5 {
|
||||||
|
t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung,
|
||||||
|
// die der GC nicht abdeckt.
|
||||||
|
//
|
||||||
|
// Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr
|
||||||
|
// gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für
|
||||||
|
// Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer
|
||||||
|
// Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar
|
||||||
|
// – und der GC hält sie nach 24 Stunden für Reste.
|
||||||
|
//
|
||||||
|
// Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf,
|
||||||
|
// der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen.
|
||||||
|
// Hier geht es um Dateien.
|
||||||
|
package rescan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"aether/internal/files"
|
||||||
|
"aether/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die
|
||||||
|
// Antwort nicht ins Uferlose wächst.
|
||||||
|
const (
|
||||||
|
maxSamples = 200
|
||||||
|
maxDuration = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank.
|
||||||
|
// Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen
|
||||||
|
// Verzeichnis sie liegt.
|
||||||
|
KindNew Kind = "new"
|
||||||
|
// KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt
|
||||||
|
// Vorschaubild und früheren Fassungen.
|
||||||
|
KindOrphaned Kind = "orphaned"
|
||||||
|
// KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei
|
||||||
|
// von außen ersetzt. Apply übernimmt Größe und Prüfsumme.
|
||||||
|
KindChanged Kind = "changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Kind Kind `json:"kind"`
|
||||||
|
StorageKey string `json:"storage_key"`
|
||||||
|
// Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte.
|
||||||
|
Path string `json:"path"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
New int `json:"new"`
|
||||||
|
Orphaned int `json:"orphaned"`
|
||||||
|
Changed int `json:"changed"`
|
||||||
|
Scanned int `json:"scanned"`
|
||||||
|
Samples []Entry `json:"samples"`
|
||||||
|
Aborted bool `json:"aborted,omitempty"`
|
||||||
|
// AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true"
|
||||||
|
// ohne Grund lässt genau die Frage offen, die man dann hat.
|
||||||
|
AbortReason string `json:"abort_reason,omitempty"`
|
||||||
|
// Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt
|
||||||
|
// sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen
|
||||||
|
// Knopf anbieten, der nichts bewirkt.
|
||||||
|
Overridable bool `json:"overridable,omitempty"`
|
||||||
|
|
||||||
|
// Nur bei Apply gefüllt.
|
||||||
|
Adopted int `json:"adopted,omitempty"`
|
||||||
|
Removed int `json:"removed,omitempty"`
|
||||||
|
Corrected int `json:"corrected,omitempty"`
|
||||||
|
Errors int `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scanner struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
store *files.Store
|
||||||
|
storage storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner {
|
||||||
|
return &Scanner{db: db, store: store, storage: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
// known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über
|
||||||
|
// den Ablagepfad auffindbar.
|
||||||
|
type known struct {
|
||||||
|
id uuid.UUID
|
||||||
|
size int64
|
||||||
|
deleted bool
|
||||||
|
seen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read known files: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make(map[string]*known, 1024)
|
||||||
|
for rows.Next() {
|
||||||
|
var k known
|
||||||
|
var key string
|
||||||
|
if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan file row: %w", err)
|
||||||
|
}
|
||||||
|
row := k
|
||||||
|
out[key] = &row
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitKey liest aus einem Ablagepfad, wohin die Datei gehört:
|
||||||
|
//
|
||||||
|
// users/{name}/home/{ordner...}/{datei}
|
||||||
|
// groups/{id}/home/{ordner...}/{datei}
|
||||||
|
//
|
||||||
|
// Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp)
|
||||||
|
// ist kein Nutzerinhalt und wird nicht angefasst.
|
||||||
|
func splitKey(key string) (root, owner, folderPath, name string, ok bool) {
|
||||||
|
parts := strings.Split(key, "/")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return "", "", "", "", false
|
||||||
|
}
|
||||||
|
if parts[0] != "users" && parts[0] != "groups" {
|
||||||
|
return "", "", "", "", false
|
||||||
|
}
|
||||||
|
if parts[2] != "home" {
|
||||||
|
return "", "", "", "", false // .trash und .versions gehören dem System
|
||||||
|
}
|
||||||
|
name = parts[len(parts)-1]
|
||||||
|
folderPath = strings.Join(parts[3:len(parts)-1], "/")
|
||||||
|
return parts[0], parts[1], folderPath, name, name != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
||||||
|
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) }
|
||||||
|
|
||||||
|
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
||||||
|
// Einträge.
|
||||||
|
// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse.
|
||||||
|
func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) {
|
||||||
|
return s.run(ctx, true, trotzdem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
known, err := s.knownFiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null,
|
||||||
|
// und kein Aufrufer braucht dafür einen Sonderfall.
|
||||||
|
rep := &Report{Samples: []Entry{}}
|
||||||
|
sample := func(e Entry) {
|
||||||
|
if len(rep.Samples) < maxSamples {
|
||||||
|
rep.Samples = append(rep.Samples, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"users/", "groups/"} {
|
||||||
|
err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
rep.Aborted = true
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
root, owner, folder, name, ok := splitKey(key)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rep.Scanned++
|
||||||
|
|
||||||
|
if row, exists := known[key]; exists {
|
||||||
|
row.seen = true
|
||||||
|
if row.deleted {
|
||||||
|
return nil // liegt im Papierkorb – kein Widerspruch
|
||||||
|
}
|
||||||
|
if row.size != info.Size {
|
||||||
|
rep.Changed++
|
||||||
|
e := Entry{Kind: KindChanged, StorageKey: key,
|
||||||
|
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||||
|
if apply {
|
||||||
|
if err := s.correct(ctx, row.id, key); err != nil {
|
||||||
|
e.Reason = err.Error()
|
||||||
|
rep.Errors++
|
||||||
|
} else {
|
||||||
|
rep.Corrected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample(e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rep.New++
|
||||||
|
e := Entry{Kind: KindNew, StorageKey: key,
|
||||||
|
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||||
|
if apply {
|
||||||
|
if err := s.adopt(ctx, root, owner, folder, name, key); err != nil {
|
||||||
|
e.Reason = err.Error()
|
||||||
|
rep.Errors++
|
||||||
|
} else {
|
||||||
|
rep.Adopted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample(e)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
||||||
|
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
||||||
|
// pauschal für verwaist zu erklären.
|
||||||
|
// **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in
|
||||||
|
// der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die
|
||||||
|
// Hälfte schon weg ist, und eine Bremse käme zu spät.
|
||||||
|
type waise struct {
|
||||||
|
key string
|
||||||
|
id uuid.UUID
|
||||||
|
}
|
||||||
|
var waisen []waise
|
||||||
|
var lebende int
|
||||||
|
for key, row := range known {
|
||||||
|
if !row.deleted {
|
||||||
|
lebende++
|
||||||
|
}
|
||||||
|
if row.seen || row.deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, _, _, _, ok := splitKey(key); !ok {
|
||||||
|
if _, err := s.storage.Stat(ctx, key); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waisen = append(waisen, waise{key: key, id: row.id})
|
||||||
|
}
|
||||||
|
rep.Orphaned = len(waisen)
|
||||||
|
|
||||||
|
if apply {
|
||||||
|
if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" {
|
||||||
|
rep.Aborted = true
|
||||||
|
rep.AbortReason = grund
|
||||||
|
rep.Overridable = uebersteuerbar
|
||||||
|
log.Ctx(ctx).Error().
|
||||||
|
Int("scanned", rep.Scanned).
|
||||||
|
Int("lebende_eintraege", lebende).
|
||||||
|
Int("wuerden_fallen", len(waisen)).
|
||||||
|
Str("grund", grund).
|
||||||
|
Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus")
|
||||||
|
// Die Waisen trotzdem in die Stichprobe, damit man im Bericht
|
||||||
|
// sieht, worum es gegangen wäre.
|
||||||
|
for _, o := range waisen {
|
||||||
|
sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key})
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, o := range waisen {
|
||||||
|
e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}
|
||||||
|
if apply {
|
||||||
|
if err := s.remove(ctx, o.id); err != nil {
|
||||||
|
e.Reason = err.Error()
|
||||||
|
rep.Errors++
|
||||||
|
} else {
|
||||||
|
rep.Removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Die Bremsen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr
|
||||||
|
// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist
|
||||||
|
// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner
|
||||||
|
// entfernt hat, bestätigt einmal und kommt durch.
|
||||||
|
const mengenAnteil = 2
|
||||||
|
|
||||||
|
// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe-
|
||||||
|
// wert heißt: ja.
|
||||||
|
//
|
||||||
|
// **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen
|
||||||
|
// Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben
|
||||||
|
// denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC
|
||||||
|
// löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt
|
||||||
|
// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die
|
||||||
|
// Datei gab. Die gefährlichere Seite war die ungesicherte.
|
||||||
|
//
|
||||||
|
// Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht
|
||||||
|
// vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||||
|
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam,
|
||||||
|
// oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede
|
||||||
|
// Datei als verwaist.
|
||||||
|
func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) {
|
||||||
|
// **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen,
|
||||||
|
// obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der
|
||||||
|
// verschwunden ist, das ist ein Speicher, der nicht da war.
|
||||||
|
if gescannt == 0 && lebende > 0 {
|
||||||
|
return "auf der Platte wurde keine einzige Datei gefunden, " +
|
||||||
|
"obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " +
|
||||||
|
"nicht eingehaengt. Es wurde nichts geloescht.", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die
|
||||||
|
// Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor.
|
||||||
|
if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+
|
||||||
|
"Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.",
|
||||||
|
wuerdenFallen, lebende), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -1,33 +1,58 @@
|
|||||||
package sharing
|
package sharing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"aether/internal/acl"
|
||||||
"aether/internal/audit"
|
"aether/internal/audit"
|
||||||
"aether/internal/auth"
|
"aether/internal/auth"
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
|
"aether/pkg/bg"
|
||||||
"aether/pkg/middleware"
|
"aether/pkg/middleware"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Ein öffentlicher Endpunkt mit Passwort braucht dieselbe Bremse wie /dav
|
||||||
|
// und /caldav: Ohne sie kann man Passwörter unbegrenzt durchprobieren – und
|
||||||
|
// weil jeder Versuch Argon2 rechnet, ist das zugleich ein billiger Weg, den
|
||||||
|
// Server zu beschäftigen.
|
||||||
|
const (
|
||||||
|
shareMaxFailures = 10
|
||||||
|
shareWindow = 15 * time.Minute
|
||||||
|
shareLockout = 15 * time.Minute
|
||||||
|
|
||||||
|
// Zehn Jahre. Grosszügig gemeint – es geht nicht darum, kurze Fristen zu
|
||||||
|
// erzwingen, sondern den Überlauf abzufangen.
|
||||||
|
maxExpiryHours = 10 * 365 * 24
|
||||||
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *Store
|
store *Store
|
||||||
fileStore *files.Store
|
fileStore *files.Store
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
auditor *audit.Logger
|
auditor *audit.Logger
|
||||||
|
// acl löst auf, in wessen Namen die Datei liegt. Ohne ihn ging nur, was
|
||||||
|
// dem Anfragenden persönlich gehört – siehe Create.
|
||||||
|
acl *acl.Store
|
||||||
|
limiter *middleware.FailureLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
|
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler {
|
||||||
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor}
|
return &Handler{
|
||||||
|
store: store, fileStore: fileStore, storage: st, auditor: auditor,
|
||||||
|
acl: aclStore,
|
||||||
|
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/files/{id}/share
|
// POST /api/v1/files/{id}/share
|
||||||
@@ -38,7 +63,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); err != nil {
|
// **Prinzipal statt Konto.** Vorher stand hier `user.ID` – damit liess sich
|
||||||
|
// eine Datei in einem Gruppenordner nicht per Link teilen, auch nicht von
|
||||||
|
// einem Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete
|
||||||
|
// „file not found", was zwar sicher, aber schlicht falsch war: Der
|
||||||
|
// Empfänger eines Links sieht ohnehin nur die Datei, nie den Ordner
|
||||||
|
// darum.
|
||||||
|
//
|
||||||
|
// **Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst,
|
||||||
|
// die Datei öffentlich zu machen. Wer sie nur geliehen lesen darf, soll
|
||||||
|
// sie nicht weiterreichen können.
|
||||||
|
zugriff, ok := h.resolve(w, r, fileID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.fileStore.GetFile(r.Context(), fileID, zugriff.OwnerID); err != nil {
|
||||||
response.Error(w, http.StatusNotFound, "file not found")
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,6 +99,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var expiresAt *time.Time
|
var expiresAt *time.Time
|
||||||
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
||||||
|
// Obergrenze, weil `time.Duration` in Nanosekunden rechnet: Ab etwa
|
||||||
|
// 2,6 Millionen Stunden läuft int64 über, die Dauer wird **negativ**,
|
||||||
|
// und der Link ist in dem Moment abgelaufen, in dem er entsteht – ohne
|
||||||
|
// Fehlermeldung. Wer eine unsinnige Zahl schickt, soll das erfahren,
|
||||||
|
// statt einen toten Link zu bekommen.
|
||||||
|
if *body.ExpiresIn > maxExpiryHours {
|
||||||
|
response.Error(w, http.StatusBadRequest,
|
||||||
|
"expires_in_hours is too large (at most 10 years)")
|
||||||
|
return
|
||||||
|
}
|
||||||
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
||||||
expiresAt = &t
|
expiresAt = &t
|
||||||
}
|
}
|
||||||
@@ -147,8 +196,16 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusNotFound, "file not found")
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Nur, was die öffentliche Seite wirklich braucht. Das ganze
|
||||||
|
// Datenbankobjekt durchzureichen war bequem, gab aber owner_id und die
|
||||||
|
// internen Kennungen an jeden heraus, der den Link hat: Zwei Links
|
||||||
|
// desselben Besitzers ließen sich so einander zuordnen, ohne dass
|
||||||
|
// irgendwer angemeldet ist.
|
||||||
response.JSON(w, http.StatusOK, map[string]any{
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
"link": link,
|
"has_password": link.HasPassword,
|
||||||
|
"expires_at": link.ExpiresAt,
|
||||||
|
"download_count": link.DownloadCount,
|
||||||
|
"created_at": link.CreatedAt,
|
||||||
"file_name": f.Name,
|
"file_name": f.Name,
|
||||||
"file_size": f.Size,
|
"file_size": f.Size,
|
||||||
"mime_type": f.MimeType,
|
"mime_type": f.MimeType,
|
||||||
@@ -169,15 +226,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if link.HasPassword {
|
if link.HasPassword {
|
||||||
|
clientIP := middleware.RealIP(r)
|
||||||
|
if erlaubt, retryAfter := h.limiter.Allowed(clientIP); !erlaubt {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
|
||||||
|
response.Error(w, http.StatusTooManyRequests, "too many failed attempts")
|
||||||
|
return
|
||||||
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
h.limiter.Fail(clientIP)
|
||||||
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.limiter.Reset(clientIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
||||||
@@ -193,10 +258,53 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer rc.Close()
|
defer rc.Close()
|
||||||
|
|
||||||
go h.store.IncrementDownloads(r.Context(), token)
|
// context.WithoutCancel, nicht r.Context(): Der Request-Kontext wird
|
||||||
|
// abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das
|
||||||
|
// Rennen fast immer und der Zähler blieb auf 0 stehen.
|
||||||
|
zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||||
|
bg.Go("Freigabe-Zaehler", func() {
|
||||||
|
defer abbrechen()
|
||||||
|
h.store.IncrementDownloads(zaehlCtx, token)
|
||||||
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", f.MimeType)
|
w.Header().Set("Content-Type", f.MimeType)
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||||
_, _ = io.Copy(w, rc)
|
// Kein Typ-Raten. `attachment` trägt die Hauptlast, aber der angemeldete
|
||||||
|
// Weg setzt beides – und dies ist der Weg nach draussen.
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
// **Dieselbe Prüfung wie beim angemeldeten Download.** Bis zum 25.09.2026
|
||||||
|
// lief hier ein blankes io.Copy: Wer die Datei über einen Link bekam,
|
||||||
|
// bekam sie ungeprüft. Ausgerechnet auf dem Weg, auf dem sie das Haus
|
||||||
|
// verlässt – und wo der Empfänger es am wenigsten merken kann.
|
||||||
|
files.ServeVerified(r.Context(), w, rc, f.Checksum, files.VerifyInfo{
|
||||||
|
FileID: f.ID.String(),
|
||||||
|
StorageKey: f.StorageKey,
|
||||||
|
Size: f.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve klärt, in wessen Namen die Datei liegt, und verlangt Schreibrecht.
|
||||||
|
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, fileID uuid.UUID) (acl.Access, bool) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if h.acl == nil {
|
||||||
|
return acl.Access{OwnerID: user.ID, Permission: acl.PermWrite}, true
|
||||||
|
}
|
||||||
|
a, err := h.acl.ResolveFile(r.Context(), user.ID, fileID)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess):
|
||||||
|
// 404 statt 403, wie überall: Ein „verboten" verriete, dass es die ID
|
||||||
|
// gibt.
|
||||||
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
|
return acl.Access{}, false
|
||||||
|
case err != nil:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "database error")
|
||||||
|
return acl.Access{}, false
|
||||||
|
}
|
||||||
|
if !a.CanWrite() {
|
||||||
|
response.Error(w, http.StatusForbidden, "you only have read access to this file")
|
||||||
|
return acl.Access{}, false
|
||||||
|
}
|
||||||
|
return a, true
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package sharing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ein Freigabelink ist der einzige Weg, auf dem eine Datei dieses Haus ohne
|
||||||
|
// Anmeldung verlässt. Wer das Token hat, ist drin – also entscheidet sich hier
|
||||||
|
// alles an zwei Fragen: Gilt der Link noch, und gehört er dem, der ihn
|
||||||
|
// anfasst?
|
||||||
|
//
|
||||||
|
// Beides steckt nicht in einer if-Zeile, sondern in WHERE-Klauseln und einem
|
||||||
|
// Zeitvergleich. Ein Fehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||||
|
// läuft, sie liefert nur zu viel.
|
||||||
|
//
|
||||||
|
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/sharing/
|
||||||
|
|
||||||
|
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verbinden: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
return NewStore(pool), pool, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
var id uuid.UUID
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO users (username, display_name, password, is_admin)
|
||||||
|
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("konto %s: %v", name, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
var id uuid.UUID
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||||
|
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||||
|
owner, name, "k/"+uuid.NewString(),
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("datei %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbgelaufenerLinkGiltNichtMehr(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
anna := konto(t, pool, ctx, "anna")
|
||||||
|
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||||
|
|
||||||
|
gestern := time.Now().Add(-1 * time.Hour)
|
||||||
|
abgelaufen, err := s.Create(ctx, f, anna, "", &gestern)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.GetByToken(ctx, abgelaufen.Token); !errors.Is(err, ErrExpired) {
|
||||||
|
t.Fatalf("abgelaufener Link muss ErrExpired liefern, kam: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe – sonst prüfte der Test nur, dass irgendetwas fehlschlägt.
|
||||||
|
morgen := time.Now().Add(1 * time.Hour)
|
||||||
|
gueltig, err := s.Create(ctx, f, anna, "", &morgen)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.GetByToken(ctx, gueltig.Token); err != nil {
|
||||||
|
t.Fatalf("gültiger Link muss durchgehen, kam: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Ablauf gilt er unbegrenzt.
|
||||||
|
ewig, err := s.Create(ctx, f, anna, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.GetByToken(ctx, ewig.Token); err != nil {
|
||||||
|
t.Fatalf("Link ohne Ablauf muss gelten, kam: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFremderLinkLaesstSichNichtLoeschen(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
anna := konto(t, pool, ctx, "anna")
|
||||||
|
bert := konto(t, pool, ctx, "bert")
|
||||||
|
f := datei(t, pool, ctx, anna, "Gehaltsabrechnung.pdf")
|
||||||
|
|
||||||
|
link, err := s.Create(ctx, f, anna, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bert kennt die ID – etwa, weil sie einmal irgendwo auftauchte.
|
||||||
|
if err := s.Delete(ctx, link.ID, bert); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Bert darf Annas Link nicht löschen, kam: %v", err)
|
||||||
|
}
|
||||||
|
// Und er ist wirklich noch da.
|
||||||
|
if _, _, err := s.GetByToken(ctx, link.Token); err != nil {
|
||||||
|
t.Fatalf("Link muss nach dem Fehlversuch noch gelten, kam: %v", err)
|
||||||
|
}
|
||||||
|
// Anna darf.
|
||||||
|
if err := s.Delete(ctx, link.ID, anna); err != nil {
|
||||||
|
t.Fatalf("Anna muss ihren Link löschen dürfen: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.GetByToken(ctx, link.Token); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("gelöschter Link muss ErrNotFound liefern, kam: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListeZeigtNurEigeneLinks(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
anna := konto(t, pool, ctx, "anna")
|
||||||
|
bert := konto(t, pool, ctx, "bert")
|
||||||
|
fa := datei(t, pool, ctx, anna, "Annas.pdf")
|
||||||
|
fb := datei(t, pool, ctx, bert, "Berts.pdf")
|
||||||
|
|
||||||
|
if _, err := s.Create(ctx, fa, anna, "", nil); err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(ctx, fb, bert, "", nil); err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
annas, err := s.ListByOwner(ctx, anna)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("liste: %v", err)
|
||||||
|
}
|
||||||
|
if len(annas) != 1 {
|
||||||
|
t.Fatalf("Anna soll genau einen Link sehen, sah %d", len(annas))
|
||||||
|
}
|
||||||
|
if annas[0].FileID != fa {
|
||||||
|
t.Fatalf("Anna sieht einen fremden Link: %v", annas[0].FileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswortVerlaesstDenSpeicherNichtVersehentlich(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
anna := konto(t, pool, ctx, "anna")
|
||||||
|
f := datei(t, pool, ctx, anna, "Vertrag.pdf")
|
||||||
|
|
||||||
|
mitPw, err := s.Create(ctx, f, anna, "argon2-hash-platzhalter", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
// Das Anlegen gibt den Hash **nicht** zurück, nur die Tatsache.
|
||||||
|
if !mitPw.HasPassword {
|
||||||
|
t.Fatal("HasPassword muss true sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Abruf über das Token kommt er heraus – dort wird er gebraucht,
|
||||||
|
// um das eingegebene Passwort zu prüfen. Aber nur dort.
|
||||||
|
link, hash, err := s.GetByToken(ctx, mitPw.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abrufen: %v", err)
|
||||||
|
}
|
||||||
|
if hash != "argon2-hash-platzhalter" {
|
||||||
|
t.Fatalf("Hash muss beim Tokenabruf vorliegen, kam %q", hash)
|
||||||
|
}
|
||||||
|
if !link.HasPassword {
|
||||||
|
t.Fatal("HasPassword muss auch beim Abruf true sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Liste für die Oberfläche kennt ihn nicht.
|
||||||
|
liste, err := s.ListByOwner(ctx, anna)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("liste: %v", err)
|
||||||
|
}
|
||||||
|
if len(liste) != 1 || !liste[0].HasPassword {
|
||||||
|
t.Fatalf("Liste soll den Link mit HasPassword zeigen, kam %+v", liste)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Passwort bleibt es false – sonst prüfte das oben nichts.
|
||||||
|
ohnePw, err := s.Create(ctx, f, anna, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
if ohnePw.HasPassword {
|
||||||
|
t.Fatal("HasPassword muss ohne Passwort false sein")
|
||||||
|
}
|
||||||
|
if _, hash, _ := s.GetByToken(ctx, ohnePw.Token); hash != "" {
|
||||||
|
t.Fatalf("ohne Passwort darf kein Hash kommen, kam %q", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenIstNichtZuErraten(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
anna := konto(t, pool, ctx, "anna")
|
||||||
|
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||||
|
|
||||||
|
gesehen := map[string]bool{}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
l, err := s.Create(ctx, f, anna, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
// 24 Zufallsbytes, base64url ohne Auffüllen = 32 Zeichen. Wird das
|
||||||
|
// jemals kürzer, ist das Token ratbar – und der Link ist das einzige
|
||||||
|
// Geheimnis, das ihn schützt.
|
||||||
|
if len(l.Token) != 32 {
|
||||||
|
t.Fatalf("Token soll 32 Zeichen haben, hat %d: %q", len(l.Token), l.Token)
|
||||||
|
}
|
||||||
|
if gesehen[l.Token] {
|
||||||
|
t.Fatalf("Token doppelt vergeben: %q", l.Token)
|
||||||
|
}
|
||||||
|
gesehen[l.Token] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZaehlerZaehlt(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
anna := konto(t, pool, ctx, "anna")
|
||||||
|
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||||
|
|
||||||
|
link, err := s.Create(ctx, f, anna, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
if link.DownloadCount != 0 {
|
||||||
|
t.Fatalf("frischer Link soll bei 0 stehen, stand bei %d", link.DownloadCount)
|
||||||
|
}
|
||||||
|
s.IncrementDownloads(ctx, link.Token)
|
||||||
|
s.IncrementDownloads(ctx, link.Token)
|
||||||
|
|
||||||
|
nach, _, err := s.GetByToken(ctx, link.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abrufen: %v", err)
|
||||||
|
}
|
||||||
|
if nach.DownloadCount != 2 {
|
||||||
|
t.Fatalf("Zähler soll 2 sein, ist %d", nach.DownloadCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Etiketten und Absender gehören einem Konto oder einer Gruppe. Dass ein
|
||||||
|
// fremdes Etikett fremd **bleibt**, prüfte bis zum 12.09.2026 nichts nach –
|
||||||
|
// und die Prüfung steckt hier nicht in einer if-Zeile, sondern in
|
||||||
|
// WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||||
|
// läuft, sie liefert nur zu viel.
|
||||||
|
//
|
||||||
|
// Der teuerste Fall ist FilesWithTag: Es gibt **Dateinamen und Ordner**
|
||||||
|
// heraus. Wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||||
|
// fremden Unterlagen.
|
||||||
|
//
|
||||||
|
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/tags/
|
||||||
|
|
||||||
|
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verbinden: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
return NewStore(pool), pool, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
var id uuid.UUID
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO users (username, display_name, password, is_admin)
|
||||||
|
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("konto %s: %v", name, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
var id uuid.UUID
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||||
|
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||||
|
owner, name, "k/"+uuid.NewString(),
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("datei: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ein fremdes Etikett bleibt fremd ────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestFremdesEtikettBleibtFremd(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
a := konto(t, pool, ctx, "anna")
|
||||||
|
b := konto(t, pool, ctx, "bert")
|
||||||
|
|
||||||
|
annas, err := s.Create(ctx, a, nil, "Steuer 2026", "#ff0000", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("taucht nicht in Berts Liste auf", func(t *testing.T) {
|
||||||
|
liste, err := s.List(ctx, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, tg := range liste {
|
||||||
|
if tg.ID == annas.ID {
|
||||||
|
t.Fatal("Bert sieht Annas Etikett")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Bert kann es nicht umbenennen", func(t *testing.T) {
|
||||||
|
if _, err := s.Update(ctx, b, annas.ID, "Uebernommen", "#00ff00", ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Update lieferte %v, erwartet ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// Und der Name steht wirklich noch da – der Fehlercode allein sagt
|
||||||
|
// nichts darüber, ob nebenbei geschrieben wurde.
|
||||||
|
var name string
|
||||||
|
_ = pool.QueryRow(ctx, `SELECT name FROM tags WHERE id = $1`, annas.ID).Scan(&name)
|
||||||
|
if name != "Steuer 2026" {
|
||||||
|
t.Fatalf("Name ist jetzt %q", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Bert kann es nicht löschen", func(t *testing.T) {
|
||||||
|
if err := s.Delete(ctx, b, annas.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Delete lieferte %v, erwartet ErrNotFound", err)
|
||||||
|
}
|
||||||
|
var da bool
|
||||||
|
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1)`, annas.ID).Scan(&da)
|
||||||
|
if !da {
|
||||||
|
t.Fatal("Annas Etikett ist weg")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Der teuerste Fall: Hier kämen Dateinamen und Ordner heraus.
|
||||||
|
t.Run("Bert sieht die Dateien darunter nicht", func(t *testing.T) {
|
||||||
|
f := datei(t, pool, ctx, a, "Gehaltsabrechnung.pdf")
|
||||||
|
if err := s.SetForFile(ctx, f, a, a, []uuid.UUID{annas.ID}, nil); err != nil {
|
||||||
|
t.Fatalf("etikettieren: %v", err)
|
||||||
|
}
|
||||||
|
// Anna sieht sie – sonst prüfte der nächste Teil eine leere Liste.
|
||||||
|
meine, err := s.FilesWithTag(ctx, a, annas.ID)
|
||||||
|
if err != nil || len(meine) != 1 {
|
||||||
|
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d Treffer", err, len(meine))
|
||||||
|
}
|
||||||
|
fremde, err := s.FilesWithTag(ctx, b, annas.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FilesWithTag: %v", err)
|
||||||
|
}
|
||||||
|
if len(fremde) != 0 {
|
||||||
|
t.Fatalf("Bert sieht %d fremde Dateien: %v", len(fremde), fremde[0].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Bert kann es nicht an seine eigene Datei hängen", func(t *testing.T) {
|
||||||
|
f := datei(t, pool, ctx, b, "Berts Datei.pdf")
|
||||||
|
err := s.SetForFile(ctx, f, b, b, []uuid.UUID{annas.ID}, nil)
|
||||||
|
if !errors.Is(err, ErrWrongOwner) {
|
||||||
|
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gruppen ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func gruppeMit(t *testing.T, pool *pgxpool.Pool, ctx context.Context, mitglieder ...uuid.UUID) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
var g uuid.UUID
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO groups (name) VALUES ($1) RETURNING id`, "probe-"+uuid.NewString()[:8],
|
||||||
|
).Scan(&g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gruppe: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, g) })
|
||||||
|
for _, m := range mitglieder {
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)`, g, m); err != nil {
|
||||||
|
t.Fatalf("mitglied: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGruppenEtikettSiehtNurDieGruppe(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
drin := konto(t, pool, ctx, "drin")
|
||||||
|
draussen := konto(t, pool, ctx, "draussen")
|
||||||
|
g := gruppeMit(t, pool, ctx, drin)
|
||||||
|
|
||||||
|
tag, err := s.Create(ctx, drin, &g, "Buchhaltung", "#0000ff", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sieht := func(user uuid.UUID) bool {
|
||||||
|
liste, err := s.List(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, x := range liste {
|
||||||
|
if x.ID == tag.ID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !sieht(drin) {
|
||||||
|
t.Fatal("das Mitglied sieht das Gruppen-Etikett nicht")
|
||||||
|
}
|
||||||
|
if sieht(draussen) {
|
||||||
|
t.Fatal("ein Nichtmitglied sieht das Gruppen-Etikett")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mitgliedschaft ersetzt die Freigabe: Wer die Gruppe verlässt, verliert
|
||||||
|
// den Zugriff im selben Moment.
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, g, drin); err != nil {
|
||||||
|
t.Fatalf("austragen: %v", err)
|
||||||
|
}
|
||||||
|
if sieht(drin) {
|
||||||
|
t.Fatal("nach dem Austritt weiterhin sichtbar")
|
||||||
|
}
|
||||||
|
if _, err := s.Update(ctx, drin, tag.ID, "Umbenannt", "#111111", ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("nach dem Austritt noch änderbar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Gruppen-Etikett gehört **nicht** an eine persönliche Datei – und das ist
|
||||||
|
// keine Förmlichkeit, sondern die Stelle, an der die Rechte hängen.
|
||||||
|
//
|
||||||
|
// Der Anlass war ein Verdacht, der sich als falsch erwies: `List` bietet dem
|
||||||
|
// Nutzer Gruppen-Etiketten mit an, `SetFieldsForFile` vergleicht aber gegen
|
||||||
|
// den *Prinzipal der Datei*. Sieht nach Widerspruch aus. Ist keiner – die
|
||||||
|
// Oberfläche filtert bereits (`TagDialog.tsx`: bei einer persönlichen Datei
|
||||||
|
// nur Etiketten ohne group_id).
|
||||||
|
//
|
||||||
|
// **Warum die Ablehnung bleiben muss:** `FilesWithTag` prüft, ob der
|
||||||
|
// Anfragende das *Etikett* sehen darf – aber **nicht**, ob er die *Datei*
|
||||||
|
// sehen darf. Das ist nur deshalb harmlos, weil ein Etikett stets denselben
|
||||||
|
// Prinzipal hat wie die Dateien darunter: ein persönliches nur an eigenen
|
||||||
|
// Dateien, ein Gruppen-Etikett nur an Gruppendateien, die alle Mitglieder
|
||||||
|
// ohnehin sehen.
|
||||||
|
//
|
||||||
|
// Fiele diese Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei –
|
||||||
|
// und jedes Gruppenmitglied bekäme über die Etikettenansicht ihren Namen,
|
||||||
|
// Ordner und ihre Größe zu sehen. Deshalb steht dieser Test hier: Er hält
|
||||||
|
// nicht eine Fehlermeldung fest, sondern eine Absicherung.
|
||||||
|
func TestGruppenEtikettGehoertNichtAnEinePersoenlicheDatei(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
u := konto(t, pool, ctx, "mitglied")
|
||||||
|
g := gruppeMit(t, pool, ctx, u)
|
||||||
|
|
||||||
|
tag, err := s.Create(ctx, u, &g, "Gemeinsam", "#00ffff", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
f := datei(t, pool, ctx, u, "Privat.pdf")
|
||||||
|
|
||||||
|
// Prinzipal der Datei ist der Nutzer, nicht die Gruppe.
|
||||||
|
if err := s.SetForFile(ctx, f, u, u, []uuid.UUID{tag.ID}, nil); !errors.Is(err, ErrWrongOwner) {
|
||||||
|
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner – ohne diese "+
|
||||||
|
"Prüfung sähe jedes Gruppenmitglied diese private Datei", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Gegenprobe: An einer **Gruppendatei** geht dasselbe Etikett.
|
||||||
|
var gf uuid.UUID
|
||||||
|
err = pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||||
|
VALUES (NULL, $1, NULL, 'Gemeinsam.pdf', 'application/pdf', 1, 'x', $2) RETURNING id`,
|
||||||
|
g, "k/"+uuid.NewString()).Scan(&gf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gruppendatei: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetForFile(ctx, gf, g, u, []uuid.UUID{tag.ID}, nil); err != nil {
|
||||||
|
t.Fatalf("an einer Gruppendatei muss es gehen: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Absender ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestFremderAbsenderBleibtFremd(t *testing.T) {
|
||||||
|
s, pool, ctx := testStore(t)
|
||||||
|
a := konto(t, pool, ctx, "anna")
|
||||||
|
b := konto(t, pool, ctx, "bert")
|
||||||
|
|
||||||
|
k, err := s.CreateCorrespondent(ctx, a, nil, "Finanzamt", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListCorrespondents(ctx, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, x := range liste {
|
||||||
|
if x.ID == k.ID {
|
||||||
|
t.Fatal("Bert sieht Annas Absender")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Absender haben ihre eigene Fehlermarke – ErrCorrespondentNotFound, nicht
|
||||||
|
// ErrNotFound. Zwei Marken für dieselbe Aussage in einem Paket sind eine
|
||||||
|
// Stolperstelle; hier festgehalten, damit sie wenigstens nicht unbemerkt
|
||||||
|
// die Seite wechselt.
|
||||||
|
if _, err := s.UpdateCorrespondent(ctx, b, k.ID, "Uebernommen", ""); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||||
|
t.Fatalf("Update lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteCorrespondent(ctx, b, k.ID); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||||
|
t.Fatalf("Delete lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch hier: Der Weg, der Dateinamen herausgibt.
|
||||||
|
f := datei(t, pool, ctx, a, "Bescheid.pdf")
|
||||||
|
if err := s.SetFieldsForFile(ctx, f, a, a, FileFields{CorrespondentID: &k.ID}); err != nil {
|
||||||
|
t.Fatalf("zuordnen: %v", err)
|
||||||
|
}
|
||||||
|
meine, err := s.FilesWithCorrespondent(ctx, a, k.ID)
|
||||||
|
if err != nil || len(meine) != 1 {
|
||||||
|
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d", err, len(meine))
|
||||||
|
}
|
||||||
|
fremde, err := s.FilesWithCorrespondent(ctx, b, k.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FilesWithCorrespondent: %v", err)
|
||||||
|
}
|
||||||
|
if len(fremde) != 0 {
|
||||||
|
t.Fatalf("Bert sieht %d fremde Dateien", len(fremde))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Package bg startet Hintergrundarbeit, die den Prozess nicht mitreißen kann.
|
||||||
|
//
|
||||||
|
// **Der Anlass ist gemessen, nicht theoretisch.** Aether erzeugt das
|
||||||
|
// Vorschaubild einer hochgeladenen Datei in einer eigenen Goroutine. Chis
|
||||||
|
// `Recoverer` hängt am Anfragebehandler und sieht davon nichts – eine Panik
|
||||||
|
// dort nimmt den **ganzen Go-Prozess** mit. Am 12.09.2026 stand dafür sogar
|
||||||
|
// eine passende Lücke offen (GO-2026-5061, Panik im WebP-Dekodierer): Ein
|
||||||
|
// gewöhnlicher Bild-Upload hätte den Server umgelegt.
|
||||||
|
//
|
||||||
|
// Die Bibliothek zu aktualisieren schließt **diese** Lücke. Diese Datei
|
||||||
|
// schließt die **Gattung**: Bilddekodierer, Fremdformate und externe Programme
|
||||||
|
// sind Felder, in denen regelmäßig etwas gefunden wird, und keines davon darf
|
||||||
|
// über einen Weg erreichbar sein, an dessen Ende der Prozess stirbt.
|
||||||
|
//
|
||||||
|
// Was hier *nicht* passiert: Fehler verschlucken. Eine aufgefangene Panik wird
|
||||||
|
// mit Stapel protokolliert. Sie ist ein Fehler und bleibt einer – sie reißt
|
||||||
|
// nur nichts mehr mit.
|
||||||
|
package bg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go startet fn in einer Goroutine, die eine Panik überlebt.
|
||||||
|
//
|
||||||
|
// name steht im Logeintrag und sollte sagen, *was* da lief ("Vorschaubild",
|
||||||
|
// "Texterkennung") – eine Zeile „panic recovered" ohne Herkunft hilft um drei
|
||||||
|
// Uhr nachts niemandem.
|
||||||
|
func Go(name string, fn func()) {
|
||||||
|
go Run(name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ist dasselbe ohne eigene Goroutine: für Schleifen, in denen **ein**
|
||||||
|
// Durchgang scheitern darf, ohne dass die Schleife endet.
|
||||||
|
//
|
||||||
|
// Der Unterschied ist keine Feinheit. Fängt man eine Panik um den ganzen
|
||||||
|
// Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und
|
||||||
|
// von da an wird still keine Datei mehr erkannt. Fängt man sie um den
|
||||||
|
// einzelnen Auftrag ab, verliert man den einen Auftrag und macht weiter.
|
||||||
|
func Run(name string, fn func()) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("wo", name).
|
||||||
|
Interface("panik", r).
|
||||||
|
Str("stapel", string(debug.Stack())).
|
||||||
|
Msg("Hintergrundarbeit abgestürzt – aufgefangen")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package bg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunFaengtPanikAuf(t *testing.T) {
|
||||||
|
// Ohne das recover in Run stürbe hier der Testprozess – das ist zugleich
|
||||||
|
// die Aussage des Tests.
|
||||||
|
Run("test", func() { panic("absichtlich") })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunLaesstErgebnisDurch(t *testing.T) {
|
||||||
|
gelaufen := false
|
||||||
|
Run("test", func() { gelaufen = true })
|
||||||
|
if !gelaufen {
|
||||||
|
t.Fatal("fn wurde nicht ausgeführt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoLaeuftUndUeberlebt(t *testing.T) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
Go("heil", func() { defer wg.Done() })
|
||||||
|
Go("panisch", func() { defer wg.Done(); panic("absichtlich") })
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Schleife, in der ein Durchgang scheitert, muss weiterlaufen – das ist
|
||||||
|
// der Grund, warum es Run neben Go gibt.
|
||||||
|
func TestSchleifeLaeuftNachPanikWeiter(t *testing.T) {
|
||||||
|
durchgaenge := 0
|
||||||
|
for i := range 5 {
|
||||||
|
Run("schritt", func() {
|
||||||
|
durchgaenge++
|
||||||
|
if i == 2 {
|
||||||
|
panic("mittendrin")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if durchgaenge != 5 {
|
||||||
|
t.Fatalf("nur %d von 5 Durchgängen", durchgaenge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,6 @@ type Config struct {
|
|||||||
AdminUsername string
|
AdminUsername string
|
||||||
AdminPassword 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.
|
// OCR – Volltextsuche über den Inhalt von PDFs und Bildern.
|
||||||
// Standardmäßig an; fehlen die Programme im Image, schaltet sich der
|
// Standardmäßig an; fehlen die Programme im Image, schaltet sich der
|
||||||
// Arbeiter beim Start selbst ab, statt den Start zu verhindern.
|
// 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.
|
// Leer heißt: keine Leiste, und die CSP bleibt so eng wie bisher.
|
||||||
HoneybarOrigin string
|
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
|
// OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether
|
||||||
// erreicht.
|
// erreicht.
|
||||||
//
|
//
|
||||||
@@ -113,11 +100,6 @@ type Config struct {
|
|||||||
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
|
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
|
||||||
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
||||||
OfficeInsecureTLS bool
|
OfficeInsecureTLS bool
|
||||||
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
|
|
||||||
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
|
|
||||||
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
|
|
||||||
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
|
|
||||||
OfficeJWTSecret string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
|
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
|
||||||
@@ -138,45 +120,6 @@ func (c *Config) OfficeEnabled() bool {
|
|||||||
return (c.OfficeProvider == "collabora" || c.OfficeProvider == "onlyoffice") && c.OfficeURL != ""
|
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) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
AppEnv: getEnv("APP_ENV", "development"),
|
AppEnv: getEnv("APP_ENV", "development"),
|
||||||
@@ -198,16 +141,12 @@ func Load() (*Config, error) {
|
|||||||
AdminUsername: getEnv("ADMIN_USERNAME", ""),
|
AdminUsername: getEnv("ADMIN_USERNAME", ""),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||||
|
|
||||||
ExternalApps: parseExternalApps(getEnv("EXTERNAL_APPS", "")),
|
|
||||||
|
|
||||||
OCREnabled: getEnv("OCR_ENABLED", "true") != "false",
|
OCREnabled: getEnv("OCR_ENABLED", "true") != "false",
|
||||||
|
|
||||||
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
||||||
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
||||||
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
|
|
||||||
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
||||||
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
||||||
HoneybarKey: getEnv("HONEYBAR_KEY", ""),
|
|
||||||
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
|
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
|
||||||
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
|
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
|
||||||
OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true",
|
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"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -13,6 +15,17 @@ func Logger(next http.Handler) http.Handler {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
|
||||||
|
// Den Logger in den Kontext hängen, damit log.Ctx(r.Context()) in den
|
||||||
|
// Handlern etwas findet – und damit jede Zeile aus einer Anfrage die
|
||||||
|
// Kennung dieser Anfrage trägt. Ohne das schreibt log.Ctx gar nicht
|
||||||
|
// (zerolog gibt dann einen abgeschalteten Logger zurück), und mit
|
||||||
|
// einem globalen Standard allein ließe sich nicht mehr zuordnen,
|
||||||
|
// welche Meldung zu welchem Aufruf gehört.
|
||||||
|
l := log.Logger.With().
|
||||||
|
Str("request_id", chimiddleware.GetReqID(r.Context())).
|
||||||
|
Logger()
|
||||||
|
r = r.WithContext(l.WithContext(r.Context()))
|
||||||
|
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
level := zerolog.InfoLevel
|
level := zerolog.InfoLevel
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Der Test, der gefehlt hat.
|
||||||
|
//
|
||||||
|
// zerolog.Ctx liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||||
|
// steckt. 34 Aufrufe von log.Ctx(...) im Baum schrieben deshalb ins Nichts –
|
||||||
|
// darunter Warnungen über verwaiste Blobs und beschädigte Dateien. Der Aufruf
|
||||||
|
// sieht dabei völlig richtig aus; auffallen kann es nur, wenn man den Fehler
|
||||||
|
// herbeiführt, den er melden soll.
|
||||||
|
func TestLoggerHaengtLoggerInDenKontext(t *testing.T) {
|
||||||
|
var aus bytes.Buffer
|
||||||
|
alt := log.Logger
|
||||||
|
log.Logger = zerolog.New(&aus)
|
||||||
|
defer func() { log.Logger = alt }()
|
||||||
|
|
||||||
|
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Genau der Aufruf, der überall im Baum steht.
|
||||||
|
log.Ctx(r.Context()).Error().Msg("kaputte-datei")
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||||
|
|
||||||
|
if !strings.Contains(aus.String(), "kaputte-datei") {
|
||||||
|
t.Fatalf("log.Ctx im Handler schrieb nichts. Ausgabe: %q", aus.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Zeile aus einer Anfrage soll deren Kennung tragen – sonst weiß man bei
|
||||||
|
// zwei gleichzeitigen Downloads nicht, welche Meldung zu welchem gehört.
|
||||||
|
func TestZeilenTragenDieAnfrageKennung(t *testing.T) {
|
||||||
|
var aus bytes.Buffer
|
||||||
|
alt := log.Logger
|
||||||
|
log.Logger = zerolog.New(&aus)
|
||||||
|
defer func() { log.Logger = alt }()
|
||||||
|
|
||||||
|
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Ctx(r.Context()).Error().Msg("x")
|
||||||
|
}))
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||||
|
|
||||||
|
if !strings.Contains(aus.String(), "request_id") {
|
||||||
|
t.Fatalf("keine request_id in der Zeile: %q", aus.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package response
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -13,7 +14,31 @@ type envelope struct {
|
|||||||
func JSON(w http.ResponseWriter, status int, data any) {
|
func JSON(w http.ResponseWriter, status int, data any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(envelope{Data: data})
|
json.NewEncoder(w).Encode(envelope{Data: leereListeStattNull(data)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// leereListeStattNull macht aus einer nicht belegten Liste eine leere.
|
||||||
|
//
|
||||||
|
// In Go ist eine Liste ohne Einträge oft nil, und `encoding/json` schreibt
|
||||||
|
// dafür `null` statt `[]`. Jede Store-Funktion, die ihre Liste mit
|
||||||
|
// `var out []T` beginnt, liefert das bei leerem Ergebnis – und jede
|
||||||
|
// Oberfläche, die darüber läuft, bricht dann ab.
|
||||||
|
//
|
||||||
|
// Genau so ist die Kalenderansicht bei einem Konto ohne Kalender
|
||||||
|
// weiß geblieben: `for (const cal of calendars)` über `null`.
|
||||||
|
//
|
||||||
|
// Der Ort dafür ist hier und nicht in den Stores: Es gibt Dutzende
|
||||||
|
// Listenendpunkte, und der nächste wird wieder mit `var out []T` anfangen.
|
||||||
|
// Eine Regel, an die man sich erinnern muss, ist keine.
|
||||||
|
func leereListeStattNull(data any) any {
|
||||||
|
if data == nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(data)
|
||||||
|
if v.Kind() == reflect.Slice && v.IsNil() {
|
||||||
|
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func Error(w http.ResponseWriter, status int, msg string) {
|
func Error(w http.ResponseWriter, status int, msg string) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Eine leere Liste muss als [] herauskommen, nicht als null. Jede
|
||||||
|
// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die
|
||||||
|
// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben.
|
||||||
|
func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) {
|
||||||
|
type eintrag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
var leer []eintrag
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
JSON(w, 200, leer)
|
||||||
|
|
||||||
|
if got := w.Body.String(); !contains(got, `"data":[]`) {
|
||||||
|
t.Fatalf("erwartet \"data\":[], bekam %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
JSON(w, 200, []string{"a", "b"})
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Data) != 2 || out.Data[0] != "a" {
|
||||||
|
t.Fatalf("Inhalt verändert: %v", out.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst
|
||||||
|
// verwandelte die Regel jede fehlende Antwort in eine leere Liste.
|
||||||
|
func TestJSONObjekteBleibenObjekte(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
JSON(w, 200, map[string]int{"n": 1})
|
||||||
|
if got := w.Body.String(); !contains(got, `"n":1`) {
|
||||||
|
t.Fatalf("Objekt verändert: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
JSON(w, 200, nil)
|
||||||
|
if got := w.Body.String(); contains(got, "[]") {
|
||||||
|
t.Fatalf("nil sollte keine Liste werden: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
+4
-1
@@ -4,7 +4,10 @@ WORKDIR /app/frontend
|
|||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
# Erst prüfen, dann bauen: Die Tests decken die Stellen ab, die sich ohne
|
||||||
|
# Browser prüfen lassen – Reihenfolgen, Zoomstufen, Suche im Text. Sie laufen
|
||||||
|
# in einer halben Sekunde; ein Abbild mit einer kaputten Sortierung kostet mehr.
|
||||||
|
RUN npm test && npm run build
|
||||||
|
|
||||||
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
||||||
FROM golang:1.25-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ services:
|
|||||||
profiles: ["office"]
|
profiles: ["office"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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}
|
aliasgroup1: ${OFFICE_ALIASGROUP:-http://app:8080}
|
||||||
# ssl.termination=true nur, wenn davor wirklich HTTPS endet – sonst
|
# ssl.termination=true nur, wenn davor wirklich HTTPS endet – sonst
|
||||||
# verlangt Collabora eine wss://-Verbindung, die es nicht gibt, und der
|
# verlangt Collabora eine wss://-Verbindung, die es nicht gibt, und der
|
||||||
@@ -92,6 +102,37 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
|
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
|
||||||
|
|
||||||
|
# Euro-Office – OnlyOffice-Fork (Nextcloud/IONOS, seit 09.06.2026). Für
|
||||||
|
# Aether ist es derselbe Anbieter wie OnlyOffice (`OFFICE_PROVIDER=onlyoffice`),
|
||||||
|
# nur ein anderes Abbild: Die WOPI-Schnittstelle und die Variablen heißen
|
||||||
|
# gleich.
|
||||||
|
#
|
||||||
|
# Eigenes Profil, damit es neben Collabora stehen kann, ohne sich den Port
|
||||||
|
# zu teilen: ... --profile euro up -d
|
||||||
|
#
|
||||||
|
# Rechne mit rund 5 GB fürs Abbild und 400 MB im Leerlauf.
|
||||||
|
euro:
|
||||||
|
container_name: aether-euro
|
||||||
|
image: ghcr.io/euro-office/documentserver:latest
|
||||||
|
profiles: ["euro"]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
WOPI_ENABLED: "true"
|
||||||
|
# Ohne Signatur antwortet der Server mit {"error":-8} – und zwar erst
|
||||||
|
# im Editor, nicht schon bei der Ermittlung. Der Rahmen bleibt dann leer.
|
||||||
|
JWT_ENABLED: "true"
|
||||||
|
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||||
|
# Die Beispiel-App gehört nicht ins Netz.
|
||||||
|
EXAMPLE_ENABLED: "false"
|
||||||
|
# Es muss app:8080 erreichen – eine private Adresse, die es sonst
|
||||||
|
# von sich aus ablehnt.
|
||||||
|
ALLOW_PRIVATE_IP_ADDRESS: "true"
|
||||||
|
networks: [internal]
|
||||||
|
ports:
|
||||||
|
# 9981, damit Collabora auf 9980 bleiben kann. Anders als dort ist der
|
||||||
|
# Port hier **80** im Container, nicht 9980.
|
||||||
|
- "${APP_BIND:-127.0.0.1}:${EURO_PORT:-9981}:80"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ services:
|
|||||||
- ${DATA_PATH:-./data}:/data
|
- ${DATA_PATH:-./data}:/data
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
ports:
|
ports:
|
||||||
# Standardmäßig nur loopback – davor gehört dein Reverse Proxy mit TLS.
|
# Vorgabe 0.0.0.0: erreichbar für alles, was den Server im Netz sieht –
|
||||||
# Zum Ausprobieren im LAN: APP_BIND=0.0.0.0 in der .env. Dann liegt die
|
# denn der Reverse Proxy steht meist auf einem anderen Rechner, und
|
||||||
# Anmeldung allerdings unverschlüsselt im Netz.
|
# loopback ließe ihn nicht heran. Davor gehört er trotzdem, mit TLS.
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-8080}:8080"
|
# 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:
|
postgres:
|
||||||
container_name: aether-db
|
container_name: aether-db
|
||||||
|
|||||||
Generated
+3598
-9
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@excalidraw/excalidraw": "^0.18.1",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -22,8 +23,10 @@
|
|||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ function AuthedApp() {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
animation: 'spin 0.7s linear infinite',
|
animation: 'spin 0.7s linear infinite',
|
||||||
}} />
|
}} />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-23
@@ -90,13 +90,49 @@ export interface CreateShareRequest {
|
|||||||
expires_in_hours?: number
|
expires_in_hours?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst
|
||||||
|
* nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden
|
||||||
|
* etwas an, der bloß den Link hat. */
|
||||||
export interface PublicShareInfo {
|
export interface PublicShareInfo {
|
||||||
link: ShareLink
|
has_password: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
download_count: number
|
||||||
|
created_at: string
|
||||||
file_name: string
|
file_name: string
|
||||||
file_size: number
|
file_size: number
|
||||||
mime_type: string
|
mime_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
|
||||||
|
export interface RescanEntry {
|
||||||
|
kind: 'new' | 'orphaned' | 'changed'
|
||||||
|
storage_key: string
|
||||||
|
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
|
||||||
|
path: string
|
||||||
|
owner: string
|
||||||
|
size: number
|
||||||
|
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RescanReport {
|
||||||
|
new: number
|
||||||
|
orphaned: number
|
||||||
|
changed: number
|
||||||
|
scanned: number
|
||||||
|
samples: RescanEntry[]
|
||||||
|
aborted?: boolean
|
||||||
|
/** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */
|
||||||
|
abort_reason?: string
|
||||||
|
/** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */
|
||||||
|
overridable?: boolean
|
||||||
|
/** Nur nach dem Anwenden gefüllt. */
|
||||||
|
adopted?: number
|
||||||
|
removed?: number
|
||||||
|
corrected?: number
|
||||||
|
errors?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileVersion {
|
export interface FileVersion {
|
||||||
id: string
|
id: string
|
||||||
file_id: string
|
file_id: string
|
||||||
@@ -153,12 +189,25 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// XHR-based upload with real progress reporting.
|
// XHR-based upload with real progress reporting.
|
||||||
|
/**
|
||||||
|
* Ein vom Nutzer abgebrochener Upload – kein Fehler, sondern eine Absicht.
|
||||||
|
*
|
||||||
|
* Eigene Klasse, damit die Warteschlange die beiden unterscheiden kann: Ein
|
||||||
|
* Fehler bleibt als Meldung stehen, weil er eine Auskunft trägt. „Ich wollte
|
||||||
|
* das nicht" trägt keine und verschwindet von selbst.
|
||||||
|
*/
|
||||||
|
export class UploadAborted extends Error {
|
||||||
|
constructor() { super('Upload aborted') }
|
||||||
|
}
|
||||||
|
|
||||||
export function uploadWithProgress(
|
export function uploadWithProgress(
|
||||||
file: File,
|
file: File,
|
||||||
folderId: string | undefined,
|
folderId: string | undefined,
|
||||||
onProgress: (p: UploadProgress) => void
|
onProgress: (p: UploadProgress) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<FileItem> {
|
): Promise<FileItem> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new UploadAborted()); return }
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
// folder_id MUST come before the file so the server can stream the file
|
// folder_id MUST come before the file so the server can stream the file
|
||||||
// straight to storage without buffering the whole body first.
|
// straight to storage without buffering the whole body first.
|
||||||
@@ -197,7 +246,17 @@ export function uploadWithProgress(
|
|||||||
})
|
})
|
||||||
|
|
||||||
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
||||||
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
|
xhr.addEventListener('abort', () => reject(new UploadAborted()))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abbrechen heißt hier: die Verbindung fallen lassen.
|
||||||
|
*
|
||||||
|
* Der Server merkt das mitten im Lesen. Er schreibt in eine Temp-Datei und
|
||||||
|
* benennt sie erst am Ende um – ein abgebrochener Upload hinterlässt
|
||||||
|
* deshalb weder Bytes noch eine Zeile in der Datenbank. Es braucht also
|
||||||
|
* keinen zweiten Ruf zum Aufräumen; das Aufräumen ist die Bauweise.
|
||||||
|
*/
|
||||||
|
signal?.addEventListener('abort', () => xhr.abort(), { once: true })
|
||||||
|
|
||||||
xhr.send(form)
|
xhr.send(form)
|
||||||
})
|
})
|
||||||
@@ -205,12 +264,6 @@ export function uploadWithProgress(
|
|||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ExternalApp {
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PublicConfig {
|
export interface PublicConfig {
|
||||||
oidc_enabled: boolean
|
oidc_enabled: boolean
|
||||||
cloud_name: string
|
cloud_name: string
|
||||||
@@ -235,10 +288,6 @@ export interface PublicConfig {
|
|||||||
accent_color?: string
|
accent_color?: string
|
||||||
/** Ursprung der HoneyOS-Leiste (HONEYBAR_ORIGIN). Leer = keine Leiste. */
|
/** Ursprung der HoneyOS-Leiste (HONEYBAR_ORIGIN). Leer = keine Leiste. */
|
||||||
honeybar_origin?: string
|
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 = {
|
export const publicApi = {
|
||||||
@@ -310,12 +359,15 @@ export const aclApi = {
|
|||||||
|
|
||||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
||||||
|
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
|
||||||
|
|
||||||
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||||
|
|
||||||
|
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
|
||||||
|
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||||
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,8 +595,21 @@ export interface OfficeSession {
|
|||||||
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
||||||
* entschieden wird serverseitig.
|
* entschieden wird serverseitig.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Endungen, die im Office-Server aufgehen sollen.
|
||||||
|
*
|
||||||
|
* **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es
|
||||||
|
* einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur
|
||||||
|
* hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server
|
||||||
|
* braucht und direkt speichert. Mit `txt` in dieser Liste lief eine
|
||||||
|
* 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht
|
||||||
|
* auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie.
|
||||||
|
*
|
||||||
|
* `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in
|
||||||
|
* einem Textfeld.
|
||||||
|
*/
|
||||||
const officeExtensions = new Set([
|
const officeExtensions = new Set([
|
||||||
'odt', 'docx', 'doc', 'rtf', 'txt',
|
'odt', 'docx', 'doc', 'rtf',
|
||||||
'ods', 'xlsx', 'xls', 'csv',
|
'ods', 'xlsx', 'xls', 'csv',
|
||||||
'odp', 'pptx', 'ppt',
|
'odp', 'pptx', 'ppt',
|
||||||
'odg',
|
'odg',
|
||||||
@@ -556,8 +621,11 @@ export function isOfficeFile(name: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const officeApi = {
|
export const officeApi = {
|
||||||
session: (fileId: string) =>
|
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
|
||||||
request<OfficeSession>('GET', `/office/session/${fileId}`),
|
* anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie
|
||||||
|
* nicht und bleibt unverändert. */
|
||||||
|
session: (fileId: string, mobile = false) =>
|
||||||
|
request<OfficeSession>('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ocrApi = {
|
export const ocrApi = {
|
||||||
@@ -604,15 +672,25 @@ export const filesApi = {
|
|||||||
* mit den Fassungen 1, 2, 3.
|
* mit den Fassungen 1, 2, 3.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
|
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
|
||||||
*
|
*
|
||||||
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
|
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
|
||||||
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
|
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
|
||||||
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
|
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
|
||||||
* Vorschaubild.
|
*
|
||||||
|
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
|
||||||
|
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
|
||||||
|
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
|
||||||
*/
|
*/
|
||||||
officePreview: (id: string, version?: number) =>
|
setThumbnail: async (id: string, bild: Blob) => {
|
||||||
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
|
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
|
||||||
|
body: bild,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
|
||||||
|
},
|
||||||
|
|
||||||
thumbnail: (id: string, version?: number | string) =>
|
thumbnail: (id: string, version?: number | string) =>
|
||||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||||
@@ -855,6 +933,12 @@ export const adminApi = {
|
|||||||
derivedStorage: () =>
|
derivedStorage: () =>
|
||||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||||
|
|
||||||
|
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||||
|
/** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde
|
||||||
|
* fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */
|
||||||
|
rescan: (apply = false, bestaetigt = false) =>
|
||||||
|
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`),
|
||||||
|
|
||||||
runCleanup: () => request<{
|
runCleanup: () => request<{
|
||||||
scanned: number; orphans: number; deleted: number
|
scanned: number; orphans: number; deleted: number
|
||||||
freed_bytes: number; skipped_young: number
|
freed_bytes: number; skipped_young: number
|
||||||
@@ -894,4 +978,141 @@ export const adminApi = {
|
|||||||
request<AppSettings>('PATCH', '/admin/settings', data),
|
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 }
|
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 { useEffect, useState } from 'react'
|
||||||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||||||
import { formatBytes } from '../pages/FileBrowser'
|
import { formatBytes } from '../format'
|
||||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||||
import TagChip from './TagChip'
|
import TagChip from './TagChip'
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,6 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||||
onSaved?: () => void
|
onSaved?: () => void
|
||||||
/**
|
|
||||||
* Ein Office-Dokument als PDF zeigen statt im Editor.
|
|
||||||
*
|
|
||||||
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
|
|
||||||
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
|
|
||||||
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
|
|
||||||
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
|
|
||||||
*/
|
|
||||||
asOfficePdf?: boolean
|
|
||||||
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
|
|
||||||
onEdit?: () => void
|
|
||||||
/**
|
/**
|
||||||
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
||||||
@@ -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
|
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
|
||||||
* sich eine vorhandene MP3 also nie abspielen.
|
* sich eine vorhandene MP3 also nie abspielen.
|
||||||
*/
|
*/
|
||||||
function kindOf(mime: string, name = ''): Kind {
|
export function kindOf(mime: string, name = ''): Kind {
|
||||||
const m = (mime || '').toLowerCase()
|
const m = (mime || '').toLowerCase()
|
||||||
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
|
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
|
||||||
// SVG-Datei Skripte enthalten darf.
|
// SVG-Datei Skripte enthalten darf.
|
||||||
@@ -101,12 +90,10 @@ export function canPreview(file: FileItem): boolean {
|
|||||||
return kindOf(file.mime_type, file.name) !== 'none'
|
return kindOf(file.mime_type, file.name) !== 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
|
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||||
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
|
const kind = kindOf(file.mime_type, file.name)
|
||||||
// gewandeltes PDF ist es eins.
|
|
||||||
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
|
|
||||||
const narrow = useNarrow()
|
const narrow = useNarrow()
|
||||||
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
|
const src = filesApi.inline(file.id)
|
||||||
const [text, setText] = useState<string | null>(null)
|
const [text, setText] = useState<string | null>(null)
|
||||||
const [tooBig, setTooBig] = useState(false)
|
const [tooBig, setTooBig] = useState(false)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
|||||||
{file.name}
|
{file.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
|
|
||||||
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
|
|
||||||
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
|
|
||||||
die Ausnahme. */}
|
|
||||||
{asOfficePdf && onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
|
|
||||||
border: '1px solid var(--color-border)', background: 'transparent',
|
|
||||||
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{kind === 'text' && !tooBig && text !== null && (
|
{kind === 'text' && !tooBig && text !== null && (
|
||||||
editing ? (
|
editing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TagRow } from './TagDialog'
|
|||||||
import TagChip from './TagChip'
|
import TagChip from './TagChip'
|
||||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||||
import FileCover, { FolderCover } from './FileCover'
|
import FileCover, { FolderCover } from './FileCover'
|
||||||
import { formatBytes } from '../pages/FileBrowser'
|
import { formatBytes } from '../format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||||
import { useBackClose } from '../hooks/useBackClose'
|
import { useBackClose } from '../hooks/useBackClose'
|
||||||
|
import { useCoarsePointer } from './FileViews'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Dokument im Office-Server bearbeiten.
|
* Ein Dokument im Office-Server bearbeiten.
|
||||||
@@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: {
|
|||||||
const [session, setSession] = useState<OfficeSession | null>(null)
|
const [session, setSession] = useState<OfficeSession | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
// Am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem
|
||||||
|
// Schreibtisch ist immer noch eine Maus und eine Tastatur.
|
||||||
|
const coarse = useCoarsePointer()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
officeApi.session(file.id)
|
officeApi.session(file.id, coarse)
|
||||||
.then(s => {
|
.then(s => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
|
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
|
||||||
@@ -35,7 +39,7 @@ export default function OfficeEditor({ file, onClose }: {
|
|||||||
setError(err instanceof Error ? err.message : 'Could not open the editor')
|
setError(err instanceof Error ? err.message : 'Could not open the editor')
|
||||||
})
|
})
|
||||||
return () => { alive = false }
|
return () => { alive = false }
|
||||||
}, [file.id])
|
}, [file.id, coarse])
|
||||||
|
|
||||||
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
||||||
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import UserMenu, { useSidebarToggle } from './UserMenu'
|
import UserMenu, { useSidebarToggle } from './UserMenu'
|
||||||
|
import { wideEnough } from '../breakpoints'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Kopfleiste jeder Ansicht.
|
* Die Kopfleiste jeder Ansicht.
|
||||||
@@ -23,8 +24,12 @@ export const headerHeight = 57
|
|||||||
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
||||||
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
||||||
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
||||||
|
*
|
||||||
|
* Dieselbe Grenze schaltet die Seitenleiste um; sie steht deshalb in
|
||||||
|
* `breakpoints.ts` und nicht mehr hier. Re-exportiert, weil sie hier gesucht
|
||||||
|
* wird.
|
||||||
*/
|
*/
|
||||||
export const wideEnough = '(min-width: 720px)'
|
export { wideEnough }
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { filesApi } from '../api/client'
|
|||||||
import * as pdfjs from 'pdfjs-dist'
|
import * as pdfjs from 'pdfjs-dist'
|
||||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
||||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
import PdfToolbar from './pdf/PdfToolbar'
|
||||||
|
import { commonWidth, padOf, pageGap, type PageSize } from './pdf/geometry'
|
||||||
|
import { countIn, wordAround, type Hit } from './pdf/text'
|
||||||
|
import { nextFine, nextStep } from './pdf/zoom'
|
||||||
import './pdf.css'
|
import './pdf.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +27,10 @@ import './pdf.css'
|
|||||||
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
||||||
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
||||||
* schreiben kann, wäre ein Widerspruch dazu.
|
* schreiben kann, wäre ein Widerspruch dazu.
|
||||||
|
*
|
||||||
|
* Hier steht, was am DOM hängt: laden, zeichnen, rollen, zoomen, färben. Was
|
||||||
|
* sich ohne Browser ausrechnen lässt, liegt daneben in `pdf/` – Zoomstufen,
|
||||||
|
* Seitenmaße, Suche im Text – und wird dort auch geprüft.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
|
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
|
||||||
@@ -39,63 +47,45 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
|
|||||||
*/
|
*/
|
||||||
const standardFontDataUrl = '/pdfjs/standard_fonts/'
|
const standardFontDataUrl = '/pdfjs/standard_fonts/'
|
||||||
|
|
||||||
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
|
/**
|
||||||
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
|
* Wie viele Seitenmaße gleichzeitig geholt werden.
|
||||||
|
*
|
||||||
|
* Vorher lief die Schleife streng nacheinander: Bei einem 200-seitigen Scan
|
||||||
|
* waren das 200 Runden, bevor überhaupt etwas zu sehen war. Alle auf einmal
|
||||||
|
* wäre das andere Extrem – PDF.js hält dann 200 Seitenobjekte zugleich.
|
||||||
|
*/
|
||||||
|
const sizeBatch = 24
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
|
* Wartezeit, bevor eine Eingabe im Suchfeld das ganze Dokument durchsucht.
|
||||||
* Zahlen gerastet – 65, 70, 75, 80 …
|
|
||||||
*
|
*
|
||||||
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
|
* Ohne sie las jeder einzelne Tastendruck den Text **aller** Seiten neu aus.
|
||||||
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
|
* Wer „Rechnung" tippt, löste damit acht vollständige Durchläufe aus, von
|
||||||
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
|
* denen sieben schon veraltet waren, bevor sie fertig wurden. Ein Sechstel
|
||||||
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
|
* einer Sekunde bemerkt beim Tippen niemand.
|
||||||
* Raster, und das hier ist eines.
|
|
||||||
*
|
|
||||||
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
|
|
||||||
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
|
|
||||||
*/
|
*/
|
||||||
const zoomStepPercent = 5
|
const searchDelay = 160
|
||||||
const minScale = 0.25
|
|
||||||
const maxScale = 4
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
|
* Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet
|
||||||
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
|
* wird.
|
||||||
* als ein Zehntel der Darstellungsgröße.
|
|
||||||
*/
|
|
||||||
function padOf(width: number): number {
|
|
||||||
return width < 560 ? 4 : 16
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
|
|
||||||
*
|
*
|
||||||
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
|
* **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die
|
||||||
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
|
* Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht
|
||||||
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
|
* zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter
|
||||||
* nicht mittig. Macht 2 × 275 + 492.
|
* Pixeldichte sind zwanzig Bitmaps zu je rund acht Megabyte – der Abbruch der
|
||||||
|
* vorigen half nur gegen die Rechenzeit, nicht gegen das Belegen und Freigeben.
|
||||||
*
|
*
|
||||||
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
|
* Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und
|
||||||
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
|
* der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also
|
||||||
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
|
* von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen
|
||||||
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
|
* weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen.
|
||||||
* der Steuerung nichts mehr zu sehen.
|
*
|
||||||
|
* Die Textebene wandert korrekt mit: PDF.js setzt ihre Stücke in **Prozent**
|
||||||
|
* des Kastens, und die Schriftgröße hängt an `--total-scale-factor`. Beides
|
||||||
|
* folgt dem Layout, nicht der Leinwand.
|
||||||
*/
|
*/
|
||||||
const minGridWidth = 2 * 275 + 492
|
const zoomSettle = 120
|
||||||
|
|
||||||
/**
|
|
||||||
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
|
|
||||||
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
|
|
||||||
*/
|
|
||||||
const counterWidth = 54
|
|
||||||
const hitButtonsWidth = 60
|
|
||||||
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
|
|
||||||
const hitsWidth = counterWidth + hitButtonsWidth + 6
|
|
||||||
|
|
||||||
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
|
|
||||||
* wird, wenn die sichtbare Seite bestimmt wird. */
|
|
||||||
const pageGap = 12
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
||||||
@@ -109,23 +99,22 @@ interface Props {
|
|||||||
searchTerm?: string
|
searchTerm?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
|
|
||||||
*
|
|
||||||
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
|
|
||||||
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
|
|
||||||
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
|
|
||||||
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
|
|
||||||
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
|
|
||||||
*/
|
|
||||||
interface Hit { page: number; nth: number; exact: boolean }
|
|
||||||
|
|
||||||
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
|
const [sizes, setSizes] = useState<PageSize[]>([])
|
||||||
const [scale, setScale] = useState<number | 'fit'>('fit')
|
const [scale, setScale] = useState<number | 'fit'>('fit')
|
||||||
const [fitScale, setFitScale] = useState(1)
|
const [fitScale, setFitScale] = useState(1)
|
||||||
|
/**
|
||||||
|
* Ist die Einpassung schon gemessen?
|
||||||
|
*
|
||||||
|
* Ohne diese Wache zeichnete jedes Öffnen **einmal umsonst**: Die Maße
|
||||||
|
* kommen an, der Zeichner läuft los – und erst danach steht fest, dass das
|
||||||
|
* Blatt nicht auf 100 %, sondern auf 65 % gehört. Die halbfertige Leinwand
|
||||||
|
* wurde weggeworfen. Bei einem A4-Blatt in doppelter Pixeldichte sind das
|
||||||
|
* acht Megabyte, die niemand je zu sehen bekam.
|
||||||
|
*/
|
||||||
|
const [fitReady, setFitReady] = useState(false)
|
||||||
const [current, setCurrent] = useState(1)
|
const [current, setCurrent] = useState(1)
|
||||||
|
|
||||||
const [query, setQuery] = useState(searchTerm ?? '')
|
const [query, setQuery] = useState(searchTerm ?? '')
|
||||||
@@ -141,16 +130,33 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
/** Der Maßstab, in dem das Blatt **liegt** – Kastengröße, Textebene, Anzeige. */
|
||||||
const effScale = scale === 'fit' ? fitScale : scale
|
const effScale = scale === 'fit' ? fitScale : scale
|
||||||
|
/**
|
||||||
|
* Der Maßstab, in dem zuletzt **gemalt** wurde. Er hinkt während einer Geste
|
||||||
|
* hinterher; solange streckt der Browser das vorhandene Bild. Siehe
|
||||||
|
* `zoomSettle`.
|
||||||
|
*/
|
||||||
|
const [renderScale, setRenderScale] = useState(effScale)
|
||||||
|
useEffect(() => {
|
||||||
|
if (renderScale === effScale) return
|
||||||
|
const t = setTimeout(() => setRenderScale(effScale), zoomSettle)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [effScale, renderScale])
|
||||||
|
/** Läuft gerade eine Geste – ist das Blatt also nur gestreckt? */
|
||||||
|
const stretched = renderScale !== effScale
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Laden
|
// ---------------------------------------------------------------- Laden
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
let task: ReturnType<typeof pdfjs.getDocument> | null = null
|
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
|
||||||
setDoc(null); setSizes([]); setError(''); setCurrent(1)
|
// Die Verweise auf die Seitenkästen gehören zum alten Dokument. Ohne das
|
||||||
|
// blieben bei einem kürzeren nächsten Dokument Einträge stehen, die auf
|
||||||
|
// längst entfernte Elemente zeigen.
|
||||||
|
pageRefs.current = []
|
||||||
|
|
||||||
task = pdfjs.getDocument({
|
const task = pdfjs.getDocument({
|
||||||
url,
|
url,
|
||||||
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
@@ -165,10 +171,15 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
|
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
|
||||||
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
||||||
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
||||||
const s: { w: number; h: number }[] = []
|
const s: PageSize[] = []
|
||||||
for (let i = 1; i <= d.numPages; i++) {
|
for (let from = 1; from <= d.numPages; from += sizeBatch) {
|
||||||
const v = (await d.getPage(i)).getViewport({ scale: 1 })
|
const to = Math.min(from + sizeBatch - 1, d.numPages)
|
||||||
s.push({ w: v.width, h: v.height })
|
const batch = await Promise.all(
|
||||||
|
Array.from({ length: to - from + 1 }, (_, i) =>
|
||||||
|
d.getPage(from + i).then(p => p.getViewport({ scale: 1 }))),
|
||||||
|
)
|
||||||
|
if (!alive) return
|
||||||
|
for (const v of batch) s.push({ w: v.width, h: v.height })
|
||||||
}
|
}
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
setSizes(s)
|
setSizes(s)
|
||||||
@@ -185,7 +196,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
)
|
)
|
||||||
return () => {
|
return () => {
|
||||||
alive = false
|
alive = false
|
||||||
task?.destroy()
|
task.destroy()
|
||||||
}
|
}
|
||||||
}, [url])
|
}, [url])
|
||||||
|
|
||||||
@@ -210,9 +221,6 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
*
|
*
|
||||||
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
|
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
|
||||||
* schmaler als das Blatt, die Breite also unter 100 %.
|
* schmaler als das Blatt, die Breite also unter 100 %.
|
||||||
*
|
|
||||||
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
|
|
||||||
* breiteste – warum, steht bei `commonWidth`.
|
|
||||||
*/
|
*/
|
||||||
const recomputeFit = useCallback(() => {
|
const recomputeFit = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -227,6 +235,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
||||||
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
||||||
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
||||||
|
setFitReady(true)
|
||||||
}, [sizes])
|
}, [sizes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -248,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
||||||
* ihre Leinwand frei.
|
* ihre Leinwand frei.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Steht der Maßstab fest? Bewusst als **ein Wahrheitswert** in der
|
||||||
|
* Abhängigkeitsliste und nicht als `scale`: Der Zeichner darf sich nicht bei
|
||||||
|
* jedem Zoomschritt neu aufbauen, sonst wäre die Geste wieder so teuer wie
|
||||||
|
* vorher. Dieser Wert kippt einmal von falsch auf wahr.
|
||||||
|
*/
|
||||||
|
const scaleKnown = scale !== 'fit' || fitReady
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!doc || sizes.length === 0) return
|
if (!doc || sizes.length === 0 || !scaleKnown) return
|
||||||
const root = scrollRef.current
|
const root = scrollRef.current
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
@@ -263,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
if (!doc || !host || drawn.has(n)) return
|
if (!doc || !host || drawn.has(n)) return
|
||||||
drawn.add(n)
|
drawn.add(n)
|
||||||
const page = await doc.getPage(n)
|
const page = await doc.getPage(n)
|
||||||
const viewport = page.getViewport({ scale: effScale })
|
const viewport = page.getViewport({ scale: renderScale })
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -330,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
io.disconnect()
|
io.disconnect()
|
||||||
for (const t of running.values()) t.cancel()
|
for (const t of running.values()) t.cancel()
|
||||||
}
|
}
|
||||||
}, [doc, sizes, effScale])
|
}, [doc, sizes, renderScale, scaleKnown])
|
||||||
|
|
||||||
// --------------------------------------------------- Sichtbare Seitenzahl
|
// --------------------------------------------------- Sichtbare Seitenzahl
|
||||||
|
|
||||||
@@ -403,10 +420,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
|
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); setSearching(false); return }
|
||||||
let alive = true
|
let alive = true
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
;(async () => {
|
|
||||||
|
// Erst abwarten, ob noch ein Zeichen kommt – siehe `searchDelay`.
|
||||||
|
const timer = setTimeout(() => void (async () => {
|
||||||
const found: Hit[] = []
|
const found: Hit[] = []
|
||||||
for (let n = 1; n <= doc.numPages; n++) {
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
@@ -446,9 +465,9 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
if (!alive) return
|
if (!alive) return
|
||||||
setHits(found)
|
setHits(found)
|
||||||
setHitIndex(0)
|
setHitIndex(0)
|
||||||
setSearching(false)
|
})().finally(() => { if (alive) setSearching(false) }), searchDelay)
|
||||||
})()
|
|
||||||
return () => { alive = false; setSearching(false) }
|
return () => { alive = false; clearTimeout(timer) }
|
||||||
}, [doc, query, fileId])
|
}, [doc, query, fileId])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -466,12 +485,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
||||||
*
|
*
|
||||||
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
|
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
|
||||||
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
|
* Kästen, die der Browser auch beim Markieren mit der Maus malt.
|
||||||
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
|
|
||||||
* Textebene.
|
|
||||||
*
|
*
|
||||||
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
|
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
|
||||||
* warum, steht bei `wordAround` unten.
|
* warum, steht bei `wordAround` in pdf/text.ts.
|
||||||
*
|
*
|
||||||
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
|
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
|
||||||
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
|
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
|
||||||
@@ -555,6 +572,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
}
|
}
|
||||||
}, [query, hits])
|
}, [query, hits])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Während einer Zoomgeste die Färbung wegnehmen.
|
||||||
|
*
|
||||||
|
* Die Kästen sind das einzige im Seitenkasten, das in **Pixeln** sitzt –
|
||||||
|
* Leinwand und Textebene rechnen in Prozent und wandern von selbst mit. Ein
|
||||||
|
* stehengebliebener Kasten läge nach zwei Stufen sichtbar neben dem Wort.
|
||||||
|
* Nach dem Zeichnen kommt er von selbst zurück (`pdf-text-ready`).
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stretched) return
|
||||||
|
for (const host of pageRefs.current) {
|
||||||
|
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
|
||||||
|
}
|
||||||
|
}, [stretched])
|
||||||
|
|
||||||
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
||||||
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
||||||
const target = hits[hitIndex]
|
const target = hits[hitIndex]
|
||||||
@@ -604,6 +636,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
|
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
|
||||||
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
|
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
|
||||||
|
|
||||||
|
const stepHit = useCallback((delta: number) => {
|
||||||
|
// Ohne die Wache teilte der Rest durch null und der Zeiger stünde auf
|
||||||
|
// NaN. Die Leiste ruft das zwar nur mit Treffern, aber ein Zähler, der
|
||||||
|
// sich nicht mehr erholt, ist ein teurer Preis für eine Annahme.
|
||||||
|
if (hits.length === 0) return
|
||||||
|
setHitIndex(i => (i + delta + hits.length) % hits.length)
|
||||||
|
}, [hits.length])
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Zoom
|
// ------------------------------------------------------------------ Zoom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -625,6 +665,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
||||||
const scaleRef = useRef(effScale)
|
const scaleRef = useRef(effScale)
|
||||||
scaleRef.current = effScale
|
scaleRef.current = effScale
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchor = keepAnchor.current
|
const anchor = keepAnchor.current
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
@@ -643,41 +684,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
goToPage(n)
|
goToPage(n)
|
||||||
}, [effScale, goToPage])
|
}, [effScale, goToPage])
|
||||||
|
|
||||||
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
|
/** Merkt den Punkt unter Zeiger oder Fingermitte, damit er dort bleibt. */
|
||||||
function nextStep(now: number, dir: 1 | -1): number | undefined {
|
const anchorAt = useCallback((cx: number, cy: number, from: number) => {
|
||||||
return dir > 0
|
const root = scrollRef.current
|
||||||
? zoomSteps.find(s => s > now + 0.001)
|
if (!root) return
|
||||||
: [...zoomSteps].reverse().find(s => s < now - 0.001)
|
const r = root.getBoundingClientRect()
|
||||||
|
keepAnchor.current = {
|
||||||
|
px: root.scrollLeft + (cx - r.left), py: root.scrollTop + (cy - r.top),
|
||||||
|
cx: cx - r.left, cy: cy - r.top, from,
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
const zoom = useCallback((dir: 1 | -1) => {
|
||||||
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
|
const next = nextStep(scaleRef.current, dir)
|
||||||
* Anschlag.
|
|
||||||
*
|
|
||||||
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
|
|
||||||
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
|
|
||||||
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
|
|
||||||
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
|
|
||||||
* Fünfern weiter.
|
|
||||||
*/
|
|
||||||
function nextFine(now: number, dir: 1 | -1): number | undefined {
|
|
||||||
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
|
|
||||||
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
|
|
||||||
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
|
|
||||||
// genau eine Stufe, danach nichts mehr.
|
|
||||||
const p = Math.round(now * 100 * 1e6) / 1e6
|
|
||||||
const step = zoomStepPercent
|
|
||||||
const raw = dir > 0
|
|
||||||
? Math.floor(p / step + 1) * step
|
|
||||||
: Math.ceil(p / step - 1) * step
|
|
||||||
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
|
|
||||||
return Math.abs(next - now) < 0.001 ? undefined : next
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoom(dir: 1 | -1) {
|
|
||||||
const next = nextStep(effScale, dir)
|
|
||||||
if (next) { keepPage.current = current; setScale(next) }
|
if (next) { keepPage.current = current; setScale(next) }
|
||||||
}
|
}, [current])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
|
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
|
||||||
@@ -694,31 +715,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
|
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
|
||||||
if (!e.ctrlKey && !e.metaKey) return
|
if (!e.ctrlKey && !e.metaKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const root2 = scrollRef.current
|
const from = scaleRef.current
|
||||||
if (!root2) return
|
const next = nextFine(from, e.deltaY < 0 ? 1 : -1)
|
||||||
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
|
|
||||||
const next = nextFine(effScale, dir)
|
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const r = root2.getBoundingClientRect()
|
anchorAt(e.clientX, e.clientY, from)
|
||||||
const cx = e.clientX - r.left
|
|
||||||
const cy = e.clientY - r.top
|
|
||||||
keepAnchor.current = {
|
|
||||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
|
||||||
cx, cy, from: effScale,
|
|
||||||
}
|
|
||||||
setScale(next)
|
setScale(next)
|
||||||
}
|
}
|
||||||
root.addEventListener('wheel', onWheel, { passive: false })
|
root.addEventListener('wheel', onWheel, { passive: false })
|
||||||
return () => root.removeEventListener('wheel', onWheel)
|
return () => root.removeEventListener('wheel', onWheel)
|
||||||
}, [effScale])
|
}, [anchorAt])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
||||||
*
|
*
|
||||||
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
||||||
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
|
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
|
||||||
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
|
* hinterher stand alles verschoben.
|
||||||
* bisher nur die Knöpfe blieben.
|
|
||||||
*
|
*
|
||||||
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
|
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
|
||||||
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
|
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
|
||||||
@@ -744,9 +756,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
function distance(t: TouchList) {
|
function distance(t: TouchList) {
|
||||||
const dx = t[0].clientX - t[1].clientX
|
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
|
||||||
const dy = t[0].clientY - t[1].clientY
|
|
||||||
return Math.hypot(dx, dy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStart(e: TouchEvent) {
|
function onStart(e: TouchEvent) {
|
||||||
@@ -783,17 +793,13 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
if (dir < 0 && ratio > Math.min(0.98, needed)) return
|
if (dir < 0 && ratio > Math.min(0.98, needed)) return
|
||||||
|
|
||||||
pinchBase.current = now
|
pinchBase.current = now
|
||||||
const root2 = scrollRef.current
|
|
||||||
if (!root2) return
|
|
||||||
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
||||||
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
||||||
const r = root2.getBoundingClientRect()
|
anchorAt(
|
||||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
|
(e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
|
(e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||||
keepAnchor.current = {
|
from,
|
||||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
)
|
||||||
cx, cy, from,
|
|
||||||
}
|
|
||||||
setScale(next)
|
setScale(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,60 +818,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
root.removeEventListener('touchend', onEnd)
|
root.removeEventListener('touchend', onEnd)
|
||||||
root.removeEventListener('touchcancel', onEnd)
|
root.removeEventListener('touchcancel', onEnd)
|
||||||
}
|
}
|
||||||
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
|
// `anchorAt` ist über useCallback stabil, der Zuhörer meldet sich also
|
||||||
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
|
// **einmal** an. Hinge er am Maßstab, meldete er sich nach jeder Stufe neu
|
||||||
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
|
// an – mitten in der Geste, und der gemerkte Fingerabstand wäre weg.
|
||||||
// dann über genau eine Stufe nicht hinaus.
|
// Gemessen: Eine lange Geste kam dann über genau eine Stufe nicht hinaus.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [anchorAt])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
||||||
|
|
||||||
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
|
|
||||||
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
|
|
||||||
* ersten Zeile. */
|
|
||||||
const zoomGroup = (
|
|
||||||
<>
|
|
||||||
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
|
|
||||||
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
|
|
||||||
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
|
|
||||||
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
|
|
||||||
tut, ist schlimmer als keiner. */}
|
|
||||||
<button
|
|
||||||
onClick={() => { keepPage.current = current; setScale('fit') }}
|
|
||||||
disabled={scale === 'fit'}
|
|
||||||
title="Fit width"
|
|
||||||
style={{
|
|
||||||
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
|
|
||||||
border: 'none', background: 'none',
|
|
||||||
cursor: scale === 'fit' ? 'default' : 'pointer',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Math.round(effScale * 100)}%
|
|
||||||
</button>
|
|
||||||
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
|
|
||||||
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
|
|
||||||
*/
|
|
||||||
const barRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [barNarrow, setBarNarrow] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
const el = barRef.current
|
|
||||||
if (!el) return
|
|
||||||
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
|
|
||||||
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
|
|
||||||
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
|
|
||||||
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
|
|
||||||
set()
|
|
||||||
const ro = new ResizeObserver(set)
|
|
||||||
ro.observe(el)
|
|
||||||
return () => ro.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [pad, setPad] = useState(16)
|
const [pad, setPad] = useState(16)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -892,137 +852,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
||||||
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
||||||
}}>
|
}}>
|
||||||
{/*
|
<PdfToolbar
|
||||||
Werkzeugleiste.
|
page={current}
|
||||||
|
pageCount={doc?.numPages ?? null}
|
||||||
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
|
onGoPage={goToPage}
|
||||||
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
|
percent={Math.round(effScale * 100)}
|
||||||
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
|
isFit={scale === 'fit'}
|
||||||
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
|
onZoom={zoom}
|
||||||
landete gemessen 79 bzw. 142 Pixel daneben.
|
onFit={() => { keepPage.current = current; setScale('fit') }}
|
||||||
|
query={query}
|
||||||
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
|
onQuery={setQuery}
|
||||||
beim Umbruch, und die Suche steht in der zweiten Zeile.
|
searching={searching}
|
||||||
*/}
|
hitCount={hits.length}
|
||||||
<div
|
hitIndex={hitIndex}
|
||||||
ref={barRef}
|
pageOnlyHits={!!hits[0] && !hits[0].exact}
|
||||||
style={{
|
onStepHit={stepHit}
|
||||||
display: barNarrow ? 'flex' : 'grid',
|
|
||||||
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
|
|
||||||
alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
|
||||||
padding: '6px 10px', flexShrink: 0,
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
||||||
<ToolButton label="Previous page" disabled={current <= 1}
|
|
||||||
onClick={() => goToPage(current - 1)} glyph="up" />
|
|
||||||
<span style={{
|
|
||||||
fontSize: 12, color: 'var(--color-text-muted)',
|
|
||||||
minWidth: 62, flexShrink: 0,
|
|
||||||
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}>
|
|
||||||
{doc ? `${current} / ${doc.numPages}` : '…'}
|
|
||||||
</span>
|
|
||||||
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
|
|
||||||
onClick={() => goToPage(current + 1)} glyph="down" />
|
|
||||||
{barNarrow && (
|
|
||||||
<>
|
|
||||||
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
|
|
||||||
{zoomGroup}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
|
|
||||||
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
|
|
||||||
wie ein Zusatz, der nicht recht dazugehört. */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
|
|
||||||
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
|
|
||||||
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
|
|
||||||
ansieht, nicht der Kasten drumherum.
|
|
||||||
|
|
||||||
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
|
|
||||||
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
|
|
||||||
rechts der zweite Sprungknopf aus dem Bild fiel. */}
|
|
||||||
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={e => setQuery(e.target.value)}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
|
|
||||||
}
|
|
||||||
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
|
|
||||||
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
|
|
||||||
}}
|
|
||||||
placeholder="Find in document"
|
|
||||||
style={{
|
|
||||||
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
|
|
||||||
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
|
|
||||||
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
|
|
||||||
// jetzt auch hinein, ohne zu wandern.
|
|
||||||
width: 240, minWidth: 0,
|
|
||||||
padding: '5px 8px', borderRadius: 8, fontSize: 12,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-background)', color: 'var(--color-text)',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span style={{
|
|
||||||
fontSize: 11, color: 'var(--color-text-muted)',
|
|
||||||
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
|
|
||||||
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
|
|
||||||
width: counterWidth, flexShrink: 0,
|
|
||||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}>
|
|
||||||
{searching ? 'Searching…'
|
|
||||||
: query.trim().length < 2 ? ''
|
|
||||||
: hits.length === 0 ? 'No hits'
|
|
||||||
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
|
|
||||||
</span>
|
|
||||||
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
|
|
||||||
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
|
|
||||||
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
|
|
||||||
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
|
|
||||||
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
|
|
||||||
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
|
|
||||||
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
|
|
||||||
schon. */}
|
|
||||||
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
|
|
||||||
{hits.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ToolButton label="Previous hit" glyph="hitUp"
|
|
||||||
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
|
|
||||||
<ToolButton label="Next hit" glyph="hitDown"
|
|
||||||
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Die dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
|
|
||||||
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
|
|
||||||
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
|
|
||||||
auch etwas.
|
|
||||||
|
|
||||||
Schmal steht der Zoom **nicht** hier, sondern oben neben der
|
|
||||||
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
|
|
||||||
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
|
|
||||||
Dokument ab. */}
|
|
||||||
{!barNarrow && (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
justifyContent: 'flex-end', minWidth: 0,
|
|
||||||
}}>
|
|
||||||
{zoomGroup}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Seiten */}
|
{/* Seiten */}
|
||||||
<div
|
<div
|
||||||
@@ -1070,124 +915,3 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Die häufigste Seitenbreite eines Dokuments.
|
|
||||||
*
|
|
||||||
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
|
|
||||||
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
|
|
||||||
* stimmt – nur ist der Preis höher als der Gewinn.
|
|
||||||
*
|
|
||||||
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
|
|
||||||
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
|
|
||||||
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
|
|
||||||
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
|
|
||||||
* so die Darstellung des ganzen Dokuments.
|
|
||||||
*
|
|
||||||
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
|
|
||||||
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
|
|
||||||
* ohnehin tun will.
|
|
||||||
*
|
|
||||||
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
|
|
||||||
*/
|
|
||||||
function commonWidth(sizes: { w: number; h: number }[]): number {
|
|
||||||
const count = new Map<number, number>()
|
|
||||||
for (const s of sizes) {
|
|
||||||
const w = Math.round(s.w)
|
|
||||||
count.set(w, (count.get(w) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
let best = 0, bestN = 0
|
|
||||||
for (const [w, n] of count) {
|
|
||||||
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
|
|
||||||
}
|
|
||||||
return best || 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weitet eine Fundstelle auf das **ganze Wort** aus.
|
|
||||||
*
|
|
||||||
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
|
|
||||||
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
|
|
||||||
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
|
|
||||||
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
|
|
||||||
* um ein bis drei Pixel anders als die gemalten.
|
|
||||||
*
|
|
||||||
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
|
|
||||||
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
|
|
||||||
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
|
|
||||||
*
|
|
||||||
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
|
|
||||||
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
|
|
||||||
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
|
|
||||||
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
|
|
||||||
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
|
|
||||||
*
|
|
||||||
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
|
|
||||||
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
|
|
||||||
*/
|
|
||||||
const maxWordSnap = 40
|
|
||||||
|
|
||||||
function wordAround(text: string, at: number, to: number): [number, number] {
|
|
||||||
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
|
|
||||||
let s = at, e = to
|
|
||||||
while (s > 0 && isWord(text[s - 1])) s--
|
|
||||||
while (e < text.length && isWord(text[e])) e++
|
|
||||||
return e - s > maxWordSnap ? [at, to] : [s, e]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
|
|
||||||
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
|
|
||||||
const out: Hit[] = []
|
|
||||||
let from = 0, nth = 0
|
|
||||||
for (;;) {
|
|
||||||
const at = haystack.indexOf(needle, from)
|
|
||||||
if (at < 0) break
|
|
||||||
out.push({ page, nth: nth++, exact })
|
|
||||||
from = at + needle.length
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
|
|
||||||
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
|
|
||||||
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
|
|
||||||
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
|
|
||||||
*
|
|
||||||
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
|
|
||||||
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
|
|
||||||
* wenn es Fundstellen gibt.
|
|
||||||
*/
|
|
||||||
const glyphs: Record<string, string> = {
|
|
||||||
up: '18 15 12 9 6 15',
|
|
||||||
down: '6 9 12 15 18 9',
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolButton({ label, onClick, disabled, glyph }: {
|
|
||||||
label: string; onClick: () => void; disabled?: boolean
|
|
||||||
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
title={label}
|
|
||||||
aria-label={label}
|
|
||||||
style={{
|
|
||||||
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
|
|
||||||
background: 'none', color: 'var(--color-text-muted)',
|
|
||||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
||||||
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
|
|
||||||
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
|
|
||||||
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
|
|
||||||
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
|
|
||||||
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
||||||
resource: { type: 'folder' | 'file'; id: string; name: string }
|
resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [list, people] = await Promise.all([
|
const [list, people] = await Promise.all([
|
||||||
resource.type === 'folder'
|
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
|
||||||
? aclApi.listForFolder(resource.id)
|
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
|
||||||
: aclApi.listForFile(resource.id),
|
: aclApi.listForFile(resource.id),
|
||||||
aclApi.recipients(),
|
aclApi.recipients(),
|
||||||
])
|
])
|
||||||
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
if (resource.type === 'folder') {
|
if (resource.type === 'calendar') {
|
||||||
|
await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission)
|
||||||
|
} else if (resource.type === 'folder') {
|
||||||
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
||||||
} else {
|
} else {
|
||||||
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
||||||
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{resource.type === 'calendar' && (
|
||||||
|
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||||
|
The calendar shows up in their app too — web and CalDAV alike.
|
||||||
|
With “can edit” they may add and change events; renaming or
|
||||||
|
deleting the calendar stays with you.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{resource.type === 'folder' && (
|
{resource.type === 'folder' && (
|
||||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||||
Everything inside this folder is shared too, including anything added later.
|
Everything inside this folder is shared too, including anything added later.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { filesApi } from '../api/client'
|
import { filesApi } from '../api/client'
|
||||||
import type { FolderItem, User } from '../api/client'
|
import type { FolderItem, User } from '../api/client'
|
||||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||||
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
||||||
import { useBackClose } from '../hooks/useBackClose'
|
import { useBackClose } from '../hooks/useBackClose'
|
||||||
|
import { Toaster } from './Toasts'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import FileBrowser from '../pages/FileBrowser'
|
import FileBrowser from '../pages/FileBrowser'
|
||||||
import TrashPage from '../pages/TrashPage'
|
import TrashPage from '../pages/TrashPage'
|
||||||
@@ -12,7 +13,9 @@ import SharedWithMePage from '../pages/SharedWithMePage'
|
|||||||
import AdminPage from '../pages/AdminPage'
|
import AdminPage from '../pages/AdminPage'
|
||||||
import ProfilePage from '../pages/ProfilePage'
|
import ProfilePage from '../pages/ProfilePage'
|
||||||
import TagsPage from '../pages/TagsPage'
|
import TagsPage from '../pages/TagsPage'
|
||||||
|
import CalendarPage from '../pages/CalendarPage'
|
||||||
import { UserMenuProvider } from './UserMenu'
|
import { UserMenuProvider } from './UserMenu'
|
||||||
|
import { wideEnough } from '../breakpoints'
|
||||||
|
|
||||||
export type { View }
|
export type { View }
|
||||||
|
|
||||||
@@ -43,6 +46,19 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
|||||||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wird das Gerät aufgeklappt, während die Leiste aufgeschoben war, steht sie
|
||||||
|
// ab da ohnehin fest im Bild. Bliebe „aufgeschoben" dabei stehen, schluckte
|
||||||
|
// die Zurück-Geste einmal ins Leere – sie zielte auf etwas, das nichts mehr
|
||||||
|
// verdeckt.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof matchMedia !== 'function') return
|
||||||
|
const mq = matchMedia(wideEnough)
|
||||||
|
const auf = () => { if (mq.matches) setSidebarOpen(false) }
|
||||||
|
auf()
|
||||||
|
mq.addEventListener('change', auf)
|
||||||
|
return () => mq.removeEventListener('change', auf)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||||||
// hängt daran und holt sich daraufhin neu.
|
// hängt daran und holt sich daraufhin neu.
|
||||||
const [trashBump, setTrashBump] = useState(0)
|
const [trashBump, setTrashBump] = useState(0)
|
||||||
@@ -80,9 +96,18 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
|||||||
onOpenSidebar: () => setSidebarOpen(true),
|
onOpenSidebar: () => setSidebarOpen(true),
|
||||||
}}>
|
}}>
|
||||||
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
|
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
|
||||||
{/* Mobile overlay */}
|
{/* Die Meldungen liegen über allem und sind an genau einer Stelle
|
||||||
|
eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur
|
||||||
|
diese eine Anzeige. */}
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
|
{/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste.
|
||||||
|
Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht:
|
||||||
|
Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber
|
||||||
|
machte sie unanklickbar – sichtbar, aber taub. */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
|
className="aether-scrim"
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
@@ -105,13 +130,13 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
|||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||||
{view === 'files' && (
|
{view === 'files' && (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
user={user}
|
|
||||||
folderPath={folderPath}
|
folderPath={folderPath}
|
||||||
onNavigate={nav.setFolderPath}
|
onNavigate={nav.setFolderPath}
|
||||||
onUserChanged={onUserChanged}
|
onUserChanged={onUserChanged}
|
||||||
reloadSignal={trashBump}
|
reloadSignal={trashBump}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{view === 'calendar' && <CalendarPage />}
|
||||||
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
|
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
|
||||||
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
|
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
|
||||||
{view === 'shared' && <SharedPage />}
|
{view === 'shared' && <SharedPage />}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { RenameIcon, TrashIcon } from './icons'
|
|||||||
import type { User } from '../api/client'
|
import type { User } from '../api/client'
|
||||||
import type { View } from './Shell'
|
import type { View } from './Shell'
|
||||||
import { useAppSettings } from '../hooks/useAppSettings'
|
import { useAppSettings } from '../hooks/useAppSettings'
|
||||||
|
import { wideEnough } from '../breakpoints'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
@@ -36,6 +37,16 @@ const nav: { id: View; label: string; icon: React.ReactNode }[] = [
|
|||||||
</svg>
|
</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',
|
id: 'trash',
|
||||||
label: 'Trash',
|
label: 'Trash',
|
||||||
@@ -765,7 +776,11 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag, onTrashDro
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
/* Schmal: Die Leiste liegt über dem Inhalt statt neben ihm.
|
||||||
|
Die Grenze ist dieselbe, die in der Kopfleiste den Knopf zum
|
||||||
|
Aufschieben zeigt – hätte sie eine eigene, gäbe es dazwischen einen
|
||||||
|
Streifen ohne Menü und ohne Knopf dorthin. */
|
||||||
|
@media not all and ${wideEnough} {
|
||||||
.aether-sidebar {
|
.aether-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--honeybar-h); left: 0;
|
top: var(--honeybar-h); left: 0;
|
||||||
|
|||||||
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