Compare commits
81
Commits
8d95088271
..
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 | ||
|
|
471679ff64 | ||
|
|
a7a67da040 | ||
|
|
aa8af418f2 | ||
|
|
093ff5140e | ||
|
|
6c53651568 | ||
|
|
065372359b | ||
|
|
ded1134f6a | ||
|
|
9c5cda75d7 | ||
|
|
84ae570305 | ||
|
|
0723d853f0 | ||
|
|
574ba3e6c5 | ||
|
|
face318131 | ||
|
|
105b1a0e74 | ||
|
|
451f359734 |
No files matched your search
+123
-46
@@ -1,14 +1,25 @@
|
||||
# Aether – das Nötigste für eine neue Umgebung. cp .env.example .env
|
||||
# Aether – alle Einstellungen des Betriebs. cp .env.example .env
|
||||
#
|
||||
# Hier steht, was die ANWENDUNG liest. Nichts davon hängt daran, ob du das
|
||||
# fertige Abbild ziehst (docker-compose.example.yml) oder selbst baust
|
||||
# (build/docker-compose.yml) – zum Entwickeln: cp .env.example build/.env
|
||||
#
|
||||
# Was NICHT hier steht: die Schalter des mitgelieferten Collabora-Containers
|
||||
# (aliasgroup1, ssl, Port). Die gehören zum Compose, das ihn startet, und
|
||||
# stehen kommentiert in build/docker-compose.yml. Wer Office selbst betreibt,
|
||||
# richtet sie in dessen eigener Compose-Datei ein.
|
||||
#
|
||||
# Zwei Fallen beim Bearbeiten:
|
||||
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
||||
# Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert
|
||||
# Erklärungen: README.md, Abschnitt „Einstellungen"
|
||||
# Alle Schalter: deploy/.env.example
|
||||
# Erklärungen und Fallstricke: README.md, Abschnitt „Einstellungen"
|
||||
|
||||
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
||||
APP_ENV=production
|
||||
# Adresse, unter der der Browser die Cloud sieht. Ohne Schrägstrich am Ende.
|
||||
# ══ Pflicht ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
APP_ENV=production # development | production
|
||||
# Adresse, unter der der BROWSER die Cloud sieht. Ohne Schrägstrich am Ende.
|
||||
# Ein falscher Wert startet klaglos und fällt erst auf, wenn ein Freigabe-Link
|
||||
# oder ein OIDC-Rücksprung ins Leere zeigt.
|
||||
PUBLIC_URL=https://cloud.example.com
|
||||
# openssl rand -hex 32
|
||||
SESSION_SECRET=
|
||||
@@ -28,57 +39,123 @@ DATA_PATH=/pfad/zu/aether-data
|
||||
APP_UID=1000
|
||||
APP_GID=1000
|
||||
|
||||
# ── Reverse Proxy ─────────────────────────────────────────────────────────────
|
||||
# Nur diesen Adressen wird X-Forwarded-For/-Proto geglaubt.
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe,
|
||||
# davor gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
||||
APP_BIND=127.0.0.1
|
||||
|
||||
# Wie viele frühere Fassungen einer Datei aufgehoben werden. Gilt nur für
|
||||
# Dateien, an denen „Keep versions" eingeschaltet ist – Standard ist aus.
|
||||
MAX_VERSIONS_KEPT=5
|
||||
|
||||
# ── Erster Start ──────────────────────────────────────────────────────────────
|
||||
# Legt den Admin an. Danach ADMIN_PASSWORD leeren.
|
||||
# ══ Erster Start ══════════════════════════════════════════════════════════════
|
||||
# Legt den Admin an. Danach ADMIN_PASSWORD leeren – das Konto bleibt.
|
||||
# Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD).
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# ── Anmeldung über einen Identitätsanbieter (optional) ───────────────────────
|
||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige Adresse des Anbieters,
|
||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht
|
||||
# der Start ab.
|
||||
# ══ Reverse Proxy ═════════════════════════════════════════════════════════════
|
||||
# IPs/CIDRs der Proxys, denen X-Forwarded-For/-Proto geglaubt wird. Steht hier
|
||||
# das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit und
|
||||
# Audit-Log werden damit wertlos, ohne dass etwas nach Fehler aussieht.
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf.
|
||||
# 0.0.0.0 alles, was den Server im Netz erreicht (Vorgabe)
|
||||
# 127.0.0.1 nur dieser Rechner – richtig, wenn der Reverse Proxy hier läuft
|
||||
# 10.0.0.5 eine bestimmte Adresse, etwa das Ende eines WireGuard-Tunnels
|
||||
#
|
||||
# Kommentar NIE hinter einen leeren Wert setzen: `OIDC_ISSUER= # text` macht
|
||||
# den Text zum Wert. Hinter einem gefüllten Wert ist er dagegen harmlos.
|
||||
# Die Vorgabe ist 0.0.0.0, weil der Proxy typischerweise woanders steht und
|
||||
# 127.0.0.1 ihn gar nicht erst herankommen ließe. Der Preis: Der Port ist auch
|
||||
# aus dem eigenen Netz offen, und TLS endet erst am Proxy – bis dahin liegt die
|
||||
# Anmeldung unverschlüsselt im Netz. Wer den Proxy auf demselben Rechner hat,
|
||||
# setzt hier 127.0.0.1 und ist besser dran.
|
||||
APP_BIND=0.0.0.0
|
||||
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
|
||||
|
||||
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
|
||||
# Wie lange eine Anmeldung gilt – **ab der letzten Benutzung**, nicht ab dem
|
||||
# Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie so
|
||||
# lange nicht anfasst, meldet sich neu an. 2592000 = 30 Tage.
|
||||
#
|
||||
# Kürzer ist sinnvoll, wenn mehrere Leute denselben Rechner benutzen. Alle
|
||||
# Sitzungen sofort beenden: SESSION_SECRET wechseln und neu starten.
|
||||
SESSION_MAX_AGE=2592000 # Sekunden
|
||||
|
||||
# ══ Speicher ══════════════════════════════════════════════════════════════════
|
||||
STORAGE_PATH=/data # im Container
|
||||
TMP_PATH=/data/tmp
|
||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||
MAX_VERSIONS_KEPT=5
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
# Standardmäßig an. Fehlen die Programme im Abbild, schaltet sich der Arbeiter
|
||||
# beim Start selbst ab, statt den Start zu verhindern.
|
||||
OCR_ENABLED=true
|
||||
# Sprachen für Tesseract. Jede zusätzliche kostet Zeit – nimm nur, was vorkommt.
|
||||
OCR_LANGUAGES=deu+eng
|
||||
# Seiten je Datei; 0 = unbegrenzt. Die Grenze ist Absicht: OCR ist das einzige
|
||||
# Stück dieser Anwendung, das ohne Zutun eines Menschen Platz und Rechenzeit
|
||||
# verbraucht. Wer regelmäßig lange Scans ablegt, setzt sie herauf.
|
||||
OCR_MAX_PAGES=50
|
||||
# Abbruch je Datei. Ein Scan, der so lange braucht, wird auch beim vierten
|
||||
# Versuch nicht fertig.
|
||||
OCR_TIMEOUT=10m
|
||||
|
||||
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
||||
# Start ab.
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=aether
|
||||
OIDC_CLIENT_SECRET=
|
||||
# Muss exakt <PUBLIC_URL>/api/v1/auth/oidc/callback sein.
|
||||
# Muss EXAKT <PUBLIC_URL>/api/v1/auth/oidc/callback sein.
|
||||
OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
|
||||
# Bestehendes Konto verbinden statt ein zweites anzulegen.
|
||||
# VOR der ersten Anmeldung setzen – danach ist es zu spät.
|
||||
# Bestehendes Konto gleichen Namens mit der Identität verbinden, statt ein
|
||||
# zweites anzulegen. VOR der ersten Anmeldung setzen – danach ist es zu spät.
|
||||
# Nur bei einem Anbieter, dem die Namen gehören: Bei freier Registrierung wäre
|
||||
# es die Übernahme fremder Konten.
|
||||
OIDC_LINK_BY_USERNAME=false
|
||||
# Beschriftung des Knopfes auf der Anmeldeseite.
|
||||
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
|
||||
|
||||
# ── Office (optional) ─────────────────────────────────────────────────────────
|
||||
# Leer = aus, auch wenn ein Office-Container läuft.
|
||||
# Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
|
||||
OFFICE_PROVIDER=
|
||||
|
||||
# Backend → Office. Port je nach Anbieter:
|
||||
# Collabora http://office:9980
|
||||
# OnlyOffice/Euro-Office http://office
|
||||
OFFICE_URL=http://office:9980
|
||||
# Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name.
|
||||
OFFICE_PUBLIC_URL=https://office.example.com
|
||||
# Office → Aether. Weder localhost noch der veröffentlichte Port.
|
||||
OFFICE_WOPI_URL=http://app:8080
|
||||
# Gegen SSRF: was das Backend anfragen darf.
|
||||
OFFICE_ALLOWED_HOSTS=office
|
||||
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen: openssl rand -hex 32
|
||||
# ══ Office (optional) ═════════════════════════════════════════════════════════
|
||||
# Leer = aus. Compose-Schnipsel, Adressen und Fallstricke: README.md.
|
||||
# Bei Collabora muss OFFICE_WOPI_URL die öffentliche Adresse der Cloud sein,
|
||||
# sonst verweigert der Browser den Rahmen.
|
||||
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
|
||||
# Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
|
||||
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
|
||||
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
|
||||
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
|
||||
# ist fast immer falsch.
|
||||
OFFICE_PUBLIC_URL=
|
||||
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
|
||||
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
|
||||
# Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet
|
||||
# die Zertifikatsprüfung **ab** – im Internet nichts zu suchen.
|
||||
OFFICE_INSECURE_TLS=false
|
||||
# Liest **der Office-Container**, nicht Aether: Es schützt seine eigene API.
|
||||
# Der WOPI-Weg braucht es nicht, der hat eigene Token. Bei OnlyOffice und
|
||||
# Euro-Office mit JWT_ENABLED=true gehört es gesetzt, sonst steht deren API
|
||||
# offen. Collabora kennt es nicht. openssl rand -hex 32
|
||||
OFFICE_JWT_SECRET=
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
|
||||
# OCR_ENABLED=false
|
||||
# OCR_LANGUAGES=deu+eng # weitere brauchen ein apk-Paket im Dockerfile
|
||||
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
|
||||
# OCR_TIMEOUT=10m # Notbremse je Datei
|
||||
|
||||
# ══ HoneyOS-Leiste (optional) ═════════════════════════════════════════════════
|
||||
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
||||
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
|
||||
# connect-src), und die Oberfläche lädt das Skript von dort nach.
|
||||
# Wirkt beim **Neustart** – kein Neubau nötig.
|
||||
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
||||
HONEYBAR_ORIGIN=
|
||||
|
||||
# ══ Später ════════════════════════════════════════════════════════════════════
|
||||
# NOCH NICHT GEBAUT – Notiz, kein Schalter. Gesucht wird derzeit mit der
|
||||
# Volltextsuche von Postgres; diese beiden Werte liest niemand.
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_KEY=
|
||||
|
||||
# ══ WebDAV ════════════════════════════════════════════════════════════════════
|
||||
# Läuft unter /dav/, kein Schalter nötig. Der Proxy muss
|
||||
# PROPFIND/MKCOL/MOVE/COPY durchreichen. Anmeldung ausschließlich mit
|
||||
# App-Passwörtern, nie mit dem Kontopasswort.
|
||||
+4
-1
@@ -1,5 +1,5 @@
|
||||
# Secrets
|
||||
deploy/.env
|
||||
build/.env*
|
||||
.env
|
||||
|
||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
||||
@@ -29,3 +29,6 @@ Thumbs.db
|
||||
|
||||
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
||||
frontend/public/pdfjs/
|
||||
# Excalidraws Schriften – aus dem Paket kopiert, nicht eingecheckt (siehe
|
||||
# vite.config.ts). Fremde Binaerdateien gehoeren nicht in den Quelltext.
|
||||
frontend/public/excalidraw/
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# Bestandsaufnahme, 12.09.2026
|
||||
|
||||
> **Stand 24.09.2026.** Dieses Dokument ist die Aufnahme vom 12.09. Was seither
|
||||
> erledigt wurde, steht an Ort und Stelle durchgestrichen oder vermerkt; die
|
||||
> Kurzfassung:
|
||||
>
|
||||
> | | |
|
||||
> |---|---|
|
||||
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
|
||||
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
|
||||
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
|
||||
> | Go-Tests `rescan` | ✅ 5 Testfunktionen (26.09.) – dabei eine fehlende Notbremse gefunden |
|
||||
> | Go-Tests `audit`, `thumbnails` | **bewusst nicht** – siehe unten |
|
||||
> | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests |
|
||||
>
|
||||
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
|
||||
> sie keine Befunde abarbeitete: Rückmeldungen mit Rückgängig, Platzhalter,
|
||||
> Leerzustände, Sprache vereinheitlicht, `Intl`. Steht in
|
||||
> [TESTING.md](TESTING.md).
|
||||
|
||||
Einmal durch die ganze Cloud: Code, Sicherheit, offene Enden. Geordnet nach
|
||||
dem, was passieren kann — nicht nach Aufwand.
|
||||
|
||||
Was hier steht, ist **gemessen**, nicht geschätzt: Routen aus `main.go`
|
||||
extrahiert und gegen die Rechteprüfungen in den Handlern abgeglichen,
|
||||
`govulncheck` über den ganzen Baum, Zeilen und Testumfang je Paket gezählt.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Ein Bild-Upload kann den Prozess töten
|
||||
|
||||
**Schwere: hoch. Jeder Kontoinhaber, normale Bedienung.**
|
||||
|
||||
`cmd/aether/main.go:702` erzeugt das Vorschaubild in einer **nackten
|
||||
Goroutine**:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
…
|
||||
thumbnails.Generate(ctx, st, …) // dekodiert eine hochgeladene Datei
|
||||
}()
|
||||
```
|
||||
|
||||
Chis `Recoverer` hängt am Anfragebehandler. Eine Panik in einer *eigenen*
|
||||
Goroutine fängt er nicht — sie nimmt den **ganzen Go-Prozess** mit. Docker
|
||||
startet ihn neu (`restart: unless-stopped`), aber jeder gerade laufende Upload
|
||||
stirbt mit, und wer die Datei noch einmal hochlädt, wiederholt das Spiel.
|
||||
|
||||
Die passende Lücke war offen: **GO-2026-5061**, Panik im WebP-Dekodierer bei
|
||||
unstimmiger Alphakanal-Größe. Behoben am 12.09.2026 durch `x/image` 0.42 → 0.45
|
||||
(`ef60497`).
|
||||
|
||||
**Damit ist die Hälfte erledigt, nicht das Problem.** Die Bauweise bleibt: Der
|
||||
nächste Dekodierer-Fehler trifft genauso, und Bilddekodierer sind eine Gattung,
|
||||
in der regelmäßig etwas gefunden wird. Vier weitere Goroutinen dieser Art gibt
|
||||
es: `internal/sharing/handler.go:232` und `main.go:185` (beide nur Datenbank,
|
||||
harmlos), `internal/gc/gc.go:257`, und der OCR-Arbeiter (`go worker.Run(ctx)`),
|
||||
der Fremdprogramme aufruft und deren Ausgabe liest.
|
||||
|
||||
**✅ Erledigt am 12.09.2026.** `pkg/bg` mit zwei Funktionen:
|
||||
|
||||
* `bg.Go(name, fn)` – startet eine Goroutine, die eine Panik überlebt.
|
||||
* `bg.Run(name, fn)` – dasselbe ohne eigene Goroutine, für Schleifen.
|
||||
|
||||
Der Unterschied ist keine Feinheit: Fängt man eine Panik um den **ganzen**
|
||||
Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und von da
|
||||
an wird still keine Datei mehr erkannt. Der OCR-Arbeiter sichert deshalb jeden
|
||||
**Auftrag** einzeln ab; ein unverdauliches Dokument kostet dann genau sich
|
||||
selbst.
|
||||
|
||||
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zähler,
|
||||
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit Begründung
|
||||
im Code – die Signalbehandlung beim Herunterfahren; ein recover brächte dort
|
||||
nichts, weil `main` ohnehin wartete, und sie fasst keine fremden Daten an.
|
||||
|
||||
Eine aufgefangene Panik wird mit Stapel und Herkunftsname protokolliert. Sie
|
||||
ist ein Fehler und bleibt einer – sie reißt nur nichts mehr mit. Vier Tests in
|
||||
`pkg/bg/bg_test.go`, darunter der, auf den es ankommt: Eine Schleife läuft nach
|
||||
einer Panik im dritten Durchgang weiter.
|
||||
|
||||
> Der Unterschied ist wichtig: Die Version zu heben schloss **diese** Lücke.
|
||||
> Der `recover` schließt die **Gattung**.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Beschädigte Downloads werden erkannt und verschwiegen
|
||||
|
||||
**Schwere: mittel. Datenintegrität, still.**
|
||||
|
||||
`internal/files/handler.go:496` rechnet beim Ausliefern die Prüfsumme mit —
|
||||
genau so, wie es die Projektregel verlangt („SHA-256: beim Upload berechnen,
|
||||
beim Download verifizieren"). Nur passiert bei einer Abweichung nichts:
|
||||
|
||||
```go
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
// Log corruption; headers already sent, can't change status.
|
||||
// TODO: alert / observability hook
|
||||
_ = actual
|
||||
}
|
||||
```
|
||||
|
||||
Kein Logeintrag, kein Zähler, keine Meldung. Der Kommentar sagt, was fehlt.
|
||||
|
||||
Dass der Statuscode nicht mehr zu ändern ist, stimmt — die Kopfzeilen sind
|
||||
längst draußen. Aber **still** muss es deshalb nicht sein. Ein `log.Error()`
|
||||
mit Datei-ID, erwarteter und tatsächlicher Summe kostet nichts und ist der
|
||||
Unterschied zwischen „ich habe eine kaputte Datei" und „ich habe eine kaputte
|
||||
Datei und weiß es seit drei Monaten nicht".
|
||||
|
||||
Zweitens ließe sich die Verbindung abbrechen, statt sie sauber zu beenden: Der
|
||||
Browser zeigt dann einen abgebrochenen Download statt einer heilen Datei mit
|
||||
falschem Inhalt. Das ist die ehrlichere Ausgabe.
|
||||
|
||||
**✅ Erledigt am 12.09.2026.** Beides – und ein Drittes, das dabei herauskam.
|
||||
|
||||
Der Logeintrag nennt Datei-ID, Speicherschlüssel, erwartete und gelesene Summe.
|
||||
Dazu wird die Verbindung fallen gelassen (`panic(http.ErrAbortHandler)`, den
|
||||
chis Recoverer ausdrücklich durchreicht).
|
||||
|
||||
**Damit das überhaupt wirkt, mussten die letzten 32 KiB zurückgehalten
|
||||
werden.** Sonst wäre die Datei beim Erkennen des Fehlers längst vollständig
|
||||
beim Empfänger, und der Abbruch käme zu spät: Die Kopfzeilen sind draußen, die
|
||||
Bytes auch. Ein `tailWriter` reicht deshalb alles durch außer dem Schluss; erst
|
||||
wenn die Summe stimmt, darf der hinterher. Stimmt sie nicht, bekommt die
|
||||
Gegenstelle weniger Bytes als im `Content-Length` angekündigt – und wertet das
|
||||
als Fehler statt als Datei.
|
||||
|
||||
Geprüft in `scripts/pruefsumme-selbsttest.py` (14 Prüfungen): Der Test kippt
|
||||
ein Bit **auf der Platte**, nicht im Programm, und zwar in der Mitte der Datei,
|
||||
damit der Fehler nicht schon an der Länge auffällt. Zwei Größen – über und
|
||||
unter den 32 KiB, denn bei einer kleinen Datei wird gar nichts herausgegeben.
|
||||
Und er prüft zuerst, dass eine **heile** Datei vollständig durchkommt; ohne das
|
||||
bewiese der Rest nichts.
|
||||
|
||||
---
|
||||
|
||||
## 2b · Und der Fund dahinter: 34 Logaufrufe schrieben ins Nichts
|
||||
|
||||
**Schwere: hoch, indirekt. Ein Fehler, der andere Fehler versteckt.**
|
||||
|
||||
Der neue Logeintrag blieb beim ersten Testlauf aus, obwohl der Abbruch
|
||||
funktionierte. Ursache:
|
||||
|
||||
```go
|
||||
func Ctx(ctx context.Context) *Logger {
|
||||
if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { return l }
|
||||
else if l = DefaultContextLogger; l != nil { return l }
|
||||
return disabledLogger // ← hier landete alles
|
||||
}
|
||||
```
|
||||
|
||||
`zerolog.Ctx` liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||
steckt und kein Standard gesetzt ist. Aethers `middleware.Logger` hängte nie
|
||||
einen hinein, und `DefaultContextLogger` war nie gesetzt.
|
||||
|
||||
**Alle 34 `log.Ctx(...)`-Aufrufe im Baum schrieben damit ins Nichts.** Darunter:
|
||||
|
||||
* die Warnung, dass beim Zurückrollen einer Fassung ein Blob verwaist ist
|
||||
(`files/handler.go`, `files/replace.go`, `handler_versions.go`)
|
||||
* gescheiterte Suchen
|
||||
* Fehler der Speicherbereinigung
|
||||
* die neue Meldung über beschädigte Dateien
|
||||
|
||||
Das ist die unangenehmste Art Fehler: Der Aufruf sieht richtig aus, `go vet`
|
||||
schweigt, und auffallen kann es nur, wenn man den Fehler herbeiführt, den die
|
||||
Zeile melden soll.
|
||||
|
||||
**Behoben zweifach:** `zerolog.DefaultContextLogger` beim Start gesetzt (deckt
|
||||
auch Hintergrundarbeit mit eigenem Kontext ab), und `middleware.Logger` hängt
|
||||
zusätzlich einen Logger mit `request_id` in den Anfragekontext – damit bei zwei
|
||||
gleichzeitigen Downloads erkennbar bleibt, welche Meldung zu welchem gehört.
|
||||
Zwei Tests in `pkg/middleware/logger_test.go` halten es fest.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Vier Pakete ohne einen einzigen Go-Test
|
||||
|
||||
**Schwere: mittel. Nicht was heute kaputt ist, sondern was morgen still kaputtgeht.**
|
||||
|
||||
| Paket | Zeilen | Test (24.09.) | Aussenpruefung |
|
||||
|---|---:|---:|---|
|
||||
| `internal/tags` | 1338 | **4** ✅ | keine |
|
||||
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
|
||||
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
|
||||
| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen |
|
||||
| `internal/audit` | 180 | **0** | keine |
|
||||
| `internal/thumbnails` | 95 | **0** | keine |
|
||||
|
||||
Bei `sharing` und `rescan` deckt wenigstens ein Selbsttest die Außenseite ab.
|
||||
Bei **`documents` und `tags` gar nichts** — und `documents` ist das Stück, das
|
||||
aus `scan_0042.pdf` ein auffindbares Dokument macht. Genau der Grund, aus dem
|
||||
Aether das Rechnungsarchiv ist.
|
||||
|
||||
**Teilweise erledigt am 12.09.2026:** `internal/documents` hat jetzt acht
|
||||
Tests — gezielt auf die drei Zustände, in denen die Warteschlange
|
||||
hängenbleiben kann, ohne dass es auffällt (Details in
|
||||
[TESTING.md](TESTING.md)). Dazu `scripts/db-tests.sh`, damit sie nicht wie
|
||||
`vitest` zur Dekoration werden: Sie überspringen sich ohne `TEST_DSN` und
|
||||
liefen sonst nie.
|
||||
|
||||
Gegengeprüft an absichtlich kaputtem Code — und **der zweite Versuch blieb
|
||||
grün**: `maxAttempts` von 3 auf 9999 gesetzt, alle Tests weiter in Ordnung,
|
||||
nur die Laufzeit stieg von 0,2 auf 27 Sekunden. Die Tests schleiften über die
|
||||
Konstante und setzten die Zahl voraus, statt sie zu prüfen. Dagegen steht jetzt
|
||||
ein eigener Test.
|
||||
|
||||
**`internal/tags` ebenfalls erledigt am 12.09.2026.** Fünf Tests, alle auf
|
||||
dieselbe Frage: Bleibt ein fremdes Etikett fremd – in der Liste, beim
|
||||
Umbenennen, beim Löschen, beim Anhängen an die eigene Datei, und vor allem beim
|
||||
**Auflisten der Dateien darunter**. Der letzte Weg gibt Dateinamen, Ordner und
|
||||
Größen heraus; wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||
fremden Unterlagen.
|
||||
|
||||
Auch hier gegen kaputten Code gegengeprüft: Eigentümerprüfung aus
|
||||
`FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf"*.
|
||||
Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests rot.
|
||||
|
||||
**Ein Verdacht erwies sich dabei als falsch, und das ist der nützlichere
|
||||
Ausgang:** `List` bietet Gruppen-Etiketten an, `SetFieldsForFile` vergleicht
|
||||
gegen den Prinzipal der *Datei* – das sah nach einem Widerspruch aus, bei dem
|
||||
die Oberfläche etwas anbietet, was der Server ablehnt. Ist keiner: Die
|
||||
Oberfläche filtert bereits (`TagDialog.tsx:69`). Und die Ablehnung ist nicht
|
||||
Förmlichkeit, sondern die tragende Absicherung – `FilesWithTag` prüft, ob man
|
||||
das *Etikett* sehen darf, **nicht** die *Datei*. Das ist nur deshalb harmlos,
|
||||
weil ein Etikett stets denselben Prinzipal hat wie die Dateien darunter. Fiele
|
||||
die Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei, und jedes
|
||||
Gruppenmitglied sähe ihren Namen. Der Test hält jetzt diesen Zusammenhang fest,
|
||||
nicht bloß eine Fehlermeldung.
|
||||
|
||||
Nebenbei: `internal/tags` hat **zwei** Fehlermarken für dieselbe Aussage –
|
||||
`ErrNotFound` und `ErrCorrespondentNotFound`. Kein Fehler, aber eine
|
||||
Stolperstelle; jetzt wenigstens festgehalten.
|
||||
|
||||
**Offen bleiben** `internal/sharing`, `internal/rescan` (beide mit
|
||||
Python-Selbsttest), `internal/audit` und `internal/thumbnails`.
|
||||
|
||||
---
|
||||
|
||||
### `audit` und `thumbnails`: bewusst ohne Go-Tests (26.09.2026)
|
||||
|
||||
Nicht vergessen, sondern entschieden. Ein Fehler in `thumbnails` (95 Zeilen)
|
||||
erzeugt ein hässliches Vorschaubild; einer in `audit` (180 Zeilen) kostet einen
|
||||
Protokolleintrag. Beide schreiben nichts kaputt und löschen nichts.
|
||||
|
||||
Tests dafür wären Häkchen, keine Absicherung – und sie kosten dieselbe
|
||||
Aufmerksamkeit wie die Stellen, an denen wirklich etwas passieren kann. Wer sie
|
||||
später doch will, hat damit wenigstens die Gegenrede.
|
||||
|
||||
## 4 · Was in Ordnung ist
|
||||
|
||||
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
|
||||
gibt:
|
||||
|
||||
* **Alle 27 Admin-Routen prüfen serverseitig auf Adminrecht.** Zwei
|
||||
Ausnahmen sind Absicht und dokumentiert: `GET /admin/settings` liest jeder
|
||||
Angemeldete (Theming — und es liegen dort nur sechs Branding-Schlüssel,
|
||||
nachgesehen), `GET /ocr` ist der eigene Erkennungsstand ohne Adminrecht.
|
||||
* **Kein einziges zusammengesetztes SQL.** Alles parametrisiert.
|
||||
* **Inline-Auslieferung nur über eine Positivliste** harmloser Typen, mit
|
||||
`sandbox`, und `allow-scripts` ausschließlich für PDF.
|
||||
* **Ratenbegrenzung an jeder unangemeldeten Tür**, die eines braucht: Anmeldung
|
||||
(20/min), WebDAV, CalDAV, Freigabelinks (je 10 Fehlversuche / 15 Minuten).
|
||||
Die übrigen offenen Wege (`/health`, `/branding`, `/manifest.webmanifest`,
|
||||
`/api/v1/public/config`) geben nichts preis.
|
||||
* **Kein `dangerouslySetInnerHTML`, kein `eval`** im Frontend.
|
||||
* **Sechs Selbsttest-Suiten, 220 Prüfungen** gegen die laufende Instanz, dazu
|
||||
37 Frontend-Tests und die Go-Tests.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Kleinere offene Enden
|
||||
|
||||
* **`internal/groups` ist ein leerer Stub** (0 Zeilen). Gruppenordner liegen in
|
||||
`files/groupfolder.go`. Entweder das Verzeichnis weg oder der Code dorthin —
|
||||
ein leeres Paket im Baum ist eine Frage, die jeder einmal stellt.
|
||||
* **`favicon_storage_key`** steht als leere Zeile in den Einstellungen; die
|
||||
Funktion heißt seit dem App-Symbol anders. Schadet nichts, wird ignoriert.
|
||||
* **Die Fehlversuchssperren liegen im Arbeitsspeicher.** Ein Neustart des
|
||||
Containers hebt jede Sperre auf. Für den Hausgebrauch vertretbar; wer es
|
||||
anders will, braucht eine Tabelle.
|
||||
* ~~**Frontend ohne Komponententests.**~~ ✅ **18.09.2026:** `jsdom` ist in den
|
||||
devDependencies, die Umgebung bleibt `node` (schnell); wer ein DOM braucht,
|
||||
schreibt `// @vitest-environment jsdom` an den Anfang seiner Datei. 46 Tests,
|
||||
davon vier gegen ein DOM. Der erste davon hat sofort einen Fehler gefunden –
|
||||
in der Behebung vom Vortag (`useBackClose`).
|
||||
|
||||
**Was er nicht leistet, steht dabei:** Den ursprünglichen Fehler bildet jsdom
|
||||
nicht ab; es verwirft einen Verlaufssprung, sobald `pushState` folgt. Dafür
|
||||
bräuchte es einen echten Browser (Playwright). Nachgeprüft, nicht vermutet:
|
||||
mit der alten, kaputten Fassung blieben alle Tests grün.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge, die ich vorschlagen würde
|
||||
|
||||
1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026
|
||||
2. ~~Prüfsummen-Abweichung melden~~ ✅ 12.09.2026 – und dabei fiel auf, dass
|
||||
**alle** kontextgebundenen Logzeilen ins Nichts gingen (2b).
|
||||
3. ~~Tests für `documents`~~ ✅ und ~~`tags`~~ ✅ 12.09.2026.
|
||||
@@ -36,7 +36,7 @@ aether/
|
||||
│ ├── pages/
|
||||
│ ├── api/ # Typed API-Client
|
||||
│ └── hooks/
|
||||
├── deploy/ # Docker Compose, Dockerfile, .env.example
|
||||
├── build/ # Dockerfile + Compose zum Bauen aus dem Quelltext
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
@@ -156,7 +156,7 @@ Kein Commit ohne grüne Tests.
|
||||
|
||||
## Docker & Secrets
|
||||
|
||||
- **Backend immer via Docker starten/laufen lassen (`docker compose ...` in `deploy/`), nie `go run`.** Der Host hat kein Go-Toolchain installiert; Builds/Compile-Checks laufen über das `golang:1.25-alpine`-Image. Migrations + DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
|
||||
- **Backend immer via Docker starten/laufen lassen (`docker compose -f build/docker-compose.yml ...`), nie `go run`.** Der Host hat kein Go-Toolchain installiert; Builds/Compile-Checks laufen über das `golang:1.25-alpine`-Image. Migrations + DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
|
||||
- Secrets **nur** über `.env` / Compose-Env – nie im Image oder Repo
|
||||
- Multi-Stage-Build: Builder-Stage (Go-Compiler) → Alpine final
|
||||
- `.env` ist in `.gitignore`; `.env.example` zeigt alle Keys ohne Werte
|
||||
|
||||
@@ -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.
|
||||
@@ -41,8 +41,19 @@ Zwei Wege, je nachdem was du vorhast:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Betreiben** | `docker-compose.example.yml` + `.env.example` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||||
| **Entwickeln** | `deploy/docker-compose.yml` + `deploy/.env.example`. Baut aus dem Quelltext, kennt alle Schalter. |
|
||||
| **Betreiben** | `docker-compose.example.yml` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||||
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
|
||||
|
||||
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – sie beschreibt,
|
||||
was die Anwendung liest, und das ist in beiden Fällen dasselbe. Sie lag bis zum
|
||||
02.09.2026 doppelt vor (einmal schlank hier, einmal vollständig im Bau-Ordner),
|
||||
und die beiden liefen auseinander: Der produktiven fehlten am Ende zwölf
|
||||
Schlüssel, darunter `SESSION_MAX_AGE`.
|
||||
|
||||
Die Schalter des **mitgelieferten Collabora-Containers** (`aliasgroup1`, SSL,
|
||||
Port) stehen nicht dort, sondern kommentiert in `build/docker-compose.yml` –
|
||||
sie konfigurieren einen fremden Container, nicht Aether. Wer Office selbst
|
||||
betreibt, richtet sie in dessen eigener Compose-Datei ein.
|
||||
|
||||
### 1. Dateien holen
|
||||
|
||||
@@ -66,7 +77,7 @@ cd aether
|
||||
|
||||
```bash
|
||||
cp .env.example .env # beim Betreiben
|
||||
cp deploy/.env.example deploy/.env # beim Entwickeln
|
||||
cp .env.example build/.env # beim Entwickeln (dorthin schaut das Compose)
|
||||
```
|
||||
|
||||
Ausfüllen musst du mindestens:
|
||||
@@ -83,9 +94,9 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
|
||||
```
|
||||
|
||||
Die schlanke `.env.example` im Wurzelverzeichnis führt nur diese Pflichtwerte
|
||||
und Office. Alles Weitere – OIDC, OCR, Vorschaubilder, Kontingente – steht mit
|
||||
Erklärung in `deploy/.env.example`.
|
||||
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, HoneyOS-Leiste – steht mit
|
||||
Erklärung in derselben Datei, nach Abschnitten geordnet. Absichtlich
|
||||
ausführlich: Sie ist auch die Übersicht darüber, was überhaupt geht.
|
||||
|
||||
### 3. Starten
|
||||
|
||||
@@ -102,8 +113,8 @@ Beim ersten Start:
|
||||
Aus dem Quelltext bauen statt ziehen:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml build app
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
docker compose -f build/docker-compose.yml build app
|
||||
docker compose -f build/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Aktualisieren:
|
||||
@@ -121,14 +132,8 @@ Direkt (ohne Proxy): http://localhost:8080/health
|
||||
|
||||
## Einstellungen
|
||||
|
||||
Zwei Beispieldateien, je nach Zweck:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `.env.example` (Wurzel) | das Nötigste zum Betreiben |
|
||||
| `deploy/.env.example` | alle Schalter, auch die optionalen |
|
||||
|
||||
Beide sind knapp gehalten – was dahintersteckt, steht hier.
|
||||
**Eine** Beispieldatei: `.env.example` im Wurzelverzeichnis, für Betrieb wie
|
||||
Entwicklung. Sie ist knapp gehalten – was dahintersteckt, steht hier.
|
||||
|
||||
### Was schiefgeht, wenn man es falsch setzt
|
||||
|
||||
@@ -181,6 +186,18 @@ auf, wenn ein Freigabe-Link oder ein OIDC-Rücksprung ins Leere zeigt.
|
||||
hier das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit
|
||||
und Audit-Log werden damit wertlos, ohne dass irgendetwas nach Fehler aussieht.
|
||||
|
||||
**`SESSION_MAX_AGE`** ist die Frist **ab der letzten Benutzung**, nicht ab dem
|
||||
Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie 30 Tage
|
||||
nicht anfasst, meldet sich neu an. Verlängert wird höchstens einmal pro
|
||||
Stunde – sonst trüge jede Antwort ein `Set-Cookie`.
|
||||
|
||||
Was das kostet, damit es niemanden überrascht: Ein Cookie, das jemand aus
|
||||
einem fremden Browser kopiert, verlängert sich beim Benutzen mit. Es gibt
|
||||
keine Sitzungsliste auf dem Server, die man widerrufen könnte – das ist der
|
||||
Preis dafür, ohne Sitzungstabelle auszukommen. **Alle** Sitzungen sofort
|
||||
beenden heißt: `SESSION_SECRET` ändern und neu starten. Bei einem gemeinsam
|
||||
genutzten Rechner ist eine kürzere Frist die bessere Wahl.
|
||||
|
||||
**`ADMIN_PASSWORD`** legt beim allerersten Start den Admin an. Danach leeren:
|
||||
Das Konto bleibt, das Passwort stünde sonst dauerhaft in einer Datei.
|
||||
|
||||
@@ -218,12 +235,27 @@ selbst anlegst (PocketID, Authentik); bei freier Registrierung wäre es die
|
||||
Übernahme fremder Konten. Verbunden wird ohnehin nur, was noch keine Bindung
|
||||
hat.
|
||||
|
||||
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Die Vorgabe
|
||||
`127.0.0.1` heißt: **nur dieser Rechner.** Von einem anderen Gerät kommt dann
|
||||
kein Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt
|
||||
ab, bevor irgendetwas die Anwendung erreicht. Das ist Absicht: Davor gehört
|
||||
dein Reverse Proxy mit TLS. Zum Ausprobieren im eigenen Netz `APP_BIND=0.0.0.0`
|
||||
setzen – dann geht die Anmeldung allerdings unverschlüsselt über das Netz.
|
||||
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Der richtige Wert
|
||||
hängt davon ab, **wo dein Reverse Proxy steht:**
|
||||
|
||||
| Proxy läuft … | Wert |
|
||||
|---|---|
|
||||
| auf einem anderen Rechner (VPS, Router) | `0.0.0.0` – die Vorgabe |
|
||||
| hinter einem Tunnel (WireGuard, Tailscale) | die Tunnel-Adresse, z. B. `10.0.0.5` |
|
||||
| auf demselben Rechner | `127.0.0.1` |
|
||||
| als Container im selben Docker-Netz | dann braucht es `ports:` gar nicht – der Proxy erreicht `app:8080` intern |
|
||||
|
||||
`127.0.0.1` heißt **nur dieser Rechner**: Von einem anderen Gerät kommt kein
|
||||
Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt ab,
|
||||
bevor irgendetwas die Anwendung erreicht. Wer den Proxy daneben stehen hat,
|
||||
sollte genau das nehmen.
|
||||
|
||||
Die Vorgabe ist trotzdem `0.0.0.0`, weil der häufigere Fall ein Proxy auf einem
|
||||
anderen Rechner ist, den loopback gar nicht erst herankommen ließe. Was das
|
||||
kostet, damit es niemanden überrascht: Der Port ist dann auch aus dem eigenen
|
||||
Netz offen, und **TLS endet erst am Proxy** – auf dem Weg dorthin liegt die
|
||||
Anmeldung unverschlüsselt im Netz. Wer das nicht will, engt den Wert ein oder
|
||||
lässt nur den Proxy durch die Firewall.
|
||||
|
||||
**`DATA_PATH`** ist ein gewöhnliches Verzeichnis auf dem Wirt, kein
|
||||
Docker-Volume – deshalb kommst du per SFTP, rsync oder im Dateimanager daran.
|
||||
@@ -299,9 +331,9 @@ nur `GET`/`POST`, und dann scheitert das Einbinden mit einem `405`.
|
||||
## Abbild bauen und veröffentlichen
|
||||
|
||||
```bash
|
||||
# im Wurzelverzeichnis des Projekts, nicht in deploy/
|
||||
# im Wurzelverzeichnis des Projekts, nicht in build/
|
||||
IMG=gitea.honeyy.duckdns.org/docker/aether
|
||||
docker build -f deploy/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
|
||||
docker build -f build/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
|
||||
docker push $IMG:$(date +%Y.%m.%d)
|
||||
docker push $IMG:latest
|
||||
```
|
||||
@@ -381,7 +413,7 @@ aether/
|
||||
│ ├── components/
|
||||
│ ├── hooks/
|
||||
│ └── pages/
|
||||
├── deploy/
|
||||
├── build/
|
||||
│ ├── docker-compose.yml # app + postgres (kein Proxy-Container)
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
@@ -475,6 +507,134 @@ hunderte pro Minute.
|
||||
|
||||
---
|
||||
|
||||
## Dateien von außen: Abgleich mit der Datenbank
|
||||
|
||||
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
|
||||
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
|
||||
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
|
||||
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
|
||||
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
|
||||
und löscht sie.
|
||||
|
||||
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
|
||||
|
||||
| Fall | Was der Abgleich tut |
|
||||
|---|---|
|
||||
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
|
||||
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
|
||||
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
|
||||
|
||||
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
|
||||
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
|
||||
Papierkorb – es ist endgültig.
|
||||
|
||||
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
|
||||
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
|
||||
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
|
||||
eigenen Datei.
|
||||
|
||||
### Für Sicherungen heißt das
|
||||
|
||||
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
|
||||
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
|
||||
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
|
||||
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
|
||||
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
|
||||
|
||||
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
|
||||
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
|
||||
auf Wunsch weg.
|
||||
|
||||
### Der große Fall: alles zurück, ohne Postgres
|
||||
|
||||
Einzelne Dateien zurückzuholen ist das eine. Der Ernstfall ist der andere:
|
||||
Der Server ist fort, aus der Sicherung kommen nur die **Dateien** zurück, die
|
||||
Datenbank ist leer.
|
||||
|
||||
Das geht – aber nur in dieser Reihenfolge, und der zweite Schritt ist der,
|
||||
den man vergisst:
|
||||
|
||||
1. **Dateien zurücklegen** nach `DATA_PATH`, mit der Struktur aus der
|
||||
Sicherung (`users/<konto>/home/…`). Aether starten.
|
||||
2. **Konten neu anlegen – mit exakt denselben Benutzernamen.** Der Ordnername
|
||||
auf der Platte *ist* die Zuordnung; ein Konto `mueller` findet nichts, was
|
||||
unter `users/Mueller/` liegt. Passwörter dürfen neu sein, Namen nicht.
|
||||
3. **Admin → Audit → Rescan storage**, erst *Check*, dann *Apply*.
|
||||
|
||||
Ohne Schritt 2 bleibt der Abgleich stehen und sagt es je Datei:
|
||||
`Konto "altnutzer" gibt es nicht`. Er rät nicht und legt nichts an – ein
|
||||
falsch zugeordnetes Dokument wäre schlimmer als ein unsichtbares.
|
||||
|
||||
**Einmal geprobt, nicht behauptet** (10.09.2026): Auf einer eigens
|
||||
aufgesetzten Instanz Dateien gesichert, Container *und* Datenbank
|
||||
weggeworfen, nur die Dateien zurückgeholt. Nach den drei Schritten standen
|
||||
Ordnerkette, Unterordner, Umlaute im Dateinamen, Größen, Prüfsummen und
|
||||
Dateitypen wieder da, und der Download lieferte den ursprünglichen Inhalt.
|
||||
`.trash/` und `.versions/` blieben zu Recht außen vor. Ein dritter Abgleich
|
||||
meldete nichts Offenes mehr.
|
||||
|
||||
**Was dabei nicht zurückkommt**, weil es nur in der Datenbank steht:
|
||||
Etiketten, Absender, Belegdatum, Freigabelinks, frühere Fassungen, der
|
||||
erkannte Text der Volltextsuche (er wird neu erzeugt), Kalender und Termine.
|
||||
Wem das wehtut, der sichert Postgres mit – die Dateien allein sind die
|
||||
Rückfallebene, nicht der volle Umfang.
|
||||
|
||||
### Aktualisieren einer laufenden Instanz
|
||||
|
||||
Neues Abbild ziehen und neu starten, mehr nicht: Die Migrationen laufen beim
|
||||
Start selbst. Geprobt am 10.09.2026 von Schema 19 auf 23 mit Daten in der
|
||||
Datenbank – Konten, Ordner, Dateien und Etikettenzuordnungen standen danach
|
||||
unverändert da, kein `dirty`-Zustand. **Ein Neuaufbau ist dafür nicht nötig
|
||||
und bringt nichts**, was das Update nicht auch bringt.
|
||||
|
||||
---
|
||||
|
||||
## Kalender (CalDAV)
|
||||
|
||||
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
|
||||
automatische Einrichtung über `/.well-known/caldav` laufen lassen, die jeder
|
||||
verbreitete Client von selbst versucht.
|
||||
|
||||
Angemeldet wird sich wie bei WebDAV mit **Benutzername und App-Passwort**; das
|
||||
Kontopasswort wird auch hier abgewiesen. Dieselbe Sperre nach zehn
|
||||
Fehlanmeldungen gilt ebenfalls.
|
||||
|
||||
Was geht:
|
||||
|
||||
- **Termine** lesen und schreiben, mit Wiederholungen, Ausnahmen einzelner
|
||||
Termine (`EXDATE`), Ganztagsterminen und Zeitzonen.
|
||||
- **Mehrere Kalender**, jeder mit Name und Farbe. Anlegen, umbenennen,
|
||||
umfärben und löschen geht in der Weboberfläche **und** vom Client aus
|
||||
(`MKCALENDAR`, `MKCOL`, `PROPPATCH`, `DELETE`).
|
||||
- **Termin in einen anderen Kalender umhängen**, ohne dass er dabei seine
|
||||
Kennung verliert – für Clients derselbe Termin an neuer Adresse, kein neuer.
|
||||
- **Farben** in beide Richtungen: die des Kalenders (`calendar-color`) und die
|
||||
einzelner Termine (`COLOR` nach RFC 7986, als CSS3-Name, weil DAVx5 sie so
|
||||
liest).
|
||||
- **Im- und Export** ganzer Kalender als `.ics`.
|
||||
|
||||
Zwei Dinge, die man beim Einrichten wissen sollte:
|
||||
|
||||
**Die Kalenderfarbe holt DAVx5 nur beim Erfassen der Sammlungen**, nicht bei
|
||||
jedem Abgleich. Ändert sich die Farbe hier, bleibt sie im Telefon so lange
|
||||
alt, bis man dort einmal *Kalenderliste aktualisieren* (⟳) antippt. Das ist
|
||||
kein Fehler, sondern DAVx5' Sparsamkeit – kostet aber eine halbe Stunde
|
||||
Suche, wenn man es nicht weiß.
|
||||
|
||||
**Kalender verwalten kann die Kalender-App des Telefons nicht.** Android
|
||||
überlässt das der Sync-App: Anlegen und Löschen geschieht in DAVx5 selbst,
|
||||
nicht in der Samsung- oder Google-Kalender-App.
|
||||
|
||||
Noch nicht da: `sync-collection` (RFC 6578). Jeder Abgleich holt deshalb die
|
||||
volle Terminliste statt nur der Änderungen – richtig, aber sparsamer ginge es.
|
||||
Ebenso offen: Aufgaben (`VTODO`) in der Weboberfläche und das Teilen von
|
||||
Kalendern mit anderen Nutzern.
|
||||
|
||||
Zwei Selbsttests decken den Bereich ab, einer je Zugangsweg – siehe
|
||||
[`TESTING.md`](TESTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Office (Collabora / OnlyOffice / Euro-Office)
|
||||
|
||||
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
|
||||
@@ -505,6 +665,13 @@ Container heraus er selbst.
|
||||
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
|
||||
OnlyOffice und Euro-Office auf 80.**
|
||||
|
||||
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
|
||||
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
|
||||
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
|
||||
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
|
||||
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
|
||||
> den Proxy die öffentliche Adresse bedienen.
|
||||
|
||||
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
||||
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
||||
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
||||
@@ -520,9 +687,14 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
|
||||
image: collabora/code:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Wer seine Dateien hier öffnen darf. Punkte maskieren – Collabora liest
|
||||
# den Wert als regulären Ausdruck.
|
||||
aliasgroup1: http://app:8080
|
||||
# Wer seine Dateien hier öffnen darf – **die Adresse, unter der dein
|
||||
# Browser die Cloud sieht**, nicht der interne Dienstname. Collabora
|
||||
# liest den Wert als regulären Ausdruck, Punkte müssen also maskiert
|
||||
# werden, und der Port gehört dazu (443 bei HTTPS).
|
||||
#
|
||||
# Steht hier der falsche Wert, meldet das Log beim Öffnen:
|
||||
# No authorized hosts found matching the target host […] in config
|
||||
aliasgroup1: https://cloud\.example\.com:443
|
||||
# ssl.enable – spricht Collabora selbst HTTPS?
|
||||
# ssl.termination – endet HTTPS davor, an deinem Reverse Proxy?
|
||||
# Steht termination auf true ohne HTTPS davor, verlangt Collabora eine
|
||||
@@ -536,17 +708,41 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
|
||||
cap_add: [MKNOD]
|
||||
networks: [internal]
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:9980:9980"
|
||||
- "${APP_BIND:-0.0.0.0}:9980:9980"
|
||||
```
|
||||
|
||||
```env
|
||||
OFFICE_PROVIDER=collabora
|
||||
OFFICE_URL=http://office:9980
|
||||
OFFICE_PUBLIC_URL=https://office.example.com
|
||||
OFFICE_WOPI_URL=http://app:8080
|
||||
# **Öffentliche Adresse, nicht app:8080** – Begründung direkt darunter.
|
||||
OFFICE_WOPI_URL=https://cloud.example.com
|
||||
OFFICE_ALLOWED_HOSTS=office
|
||||
```
|
||||
|
||||
> **Warum bei Collabora die öffentliche Adresse und nicht `app:8080`?**
|
||||
>
|
||||
> Weil Collabora aus dieser Adresse seine eigene Sicherheitsrichtlinie baut.
|
||||
> Gemessen: Es setzt `frame-ancestors` auf *seinen* Host **plus den Host aus
|
||||
> `WOPISrc`* – und `WOPISrc` ist genau `OFFICE_WOPI_URL`. Steht dort der
|
||||
> interne Dienstname, erlaubt der Editor die Einbettung nur einer Seite, die
|
||||
> unter `app:8080` läuft. Dein Browser kommt aber von `cloud.example.com`, und
|
||||
> er lehnt ab:
|
||||
>
|
||||
> ```
|
||||
> Framing 'https://office.example.com/' violates
|
||||
> 'frame-ancestors office.example.com:* app:8080'
|
||||
> ```
|
||||
>
|
||||
> Die Folge davon muss man wollen: Collabora holt die Datei dann **über deinen
|
||||
> Reverse Proxy**, nicht quer durchs Docker-Netz. Der Container braucht dafür
|
||||
> Namensauflösung nach draußen und einen Weg zurück – bei einem Proxy auf
|
||||
> einem anderen Rechner ist das der normale Weg über das Internet.
|
||||
>
|
||||
> `aliasgroup1` oben muss **denselben** Host nennen. Zwei Schalter, eine
|
||||
> Wahrheit: Der eine entscheidet, ob der Browser den Rahmen zeigen darf, der
|
||||
> andere, ob Collabora die Anfrage überhaupt annimmt.
|
||||
|
||||
### Schnipsel 2: OnlyOffice Docs
|
||||
|
||||
```yaml
|
||||
@@ -560,7 +756,7 @@ OFFICE_ALLOWED_HOSTS=office
|
||||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||
networks: [internal]
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
|
||||
- "${APP_BIND:-0.0.0.0}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
|
||||
```
|
||||
|
||||
```env
|
||||
@@ -592,19 +788,34 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
|
||||
ALLOW_PRIVATE_IP_ADDRESS: "true" # es muss app:8080 erreichen
|
||||
networks: [internal]
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:9980:80"
|
||||
- "${APP_BIND:-0.0.0.0}:9980:80"
|
||||
```
|
||||
|
||||
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das
|
||||
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das
|
||||
Abbild.
|
||||
|
||||
> **Ungeprüft.** Collabora ist hier gemessen worden, den Kreis von Tippen bis
|
||||
> Speichern eingeschlossen. Die beiden anderen Schnipsel stehen nach ihrer
|
||||
> Dokumentation und dem gemeinsamen WOPI-Weg, sind auf dieser Anlage aber nie
|
||||
> gelaufen – für OnlyOffice fehlt schlicht der Arbeitsspeicher.
|
||||
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
|
||||
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
|
||||
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
|
||||
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
|
||||
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
|
||||
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
|
||||
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
|
||||
> über die Ermittlung hinaus.
|
||||
>
|
||||
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
|
||||
> nach Aktenlage hakt:
|
||||
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
|
||||
> betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
|
||||
> Collabora daneben weiterlief.
|
||||
>
|
||||
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
|
||||
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
|
||||
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
|
||||
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
|
||||
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
|
||||
> Fehler meldet sich nicht, er zeigt nur nichts.
|
||||
>
|
||||
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
|
||||
> an denen es nach Aktenlage hakt:
|
||||
>
|
||||
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
||||
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
||||
@@ -614,9 +825,14 @@ Abbild.
|
||||
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||||
> das der Ort.
|
||||
>
|
||||
> `JWT_SECRET` betrifft nach der Dokumentation die eigene API des
|
||||
> Dokumentenservers, nicht den WOPI-Weg – unser Adapter signiert damit nichts.
|
||||
> Der Schlüssel gehört trotzdem gesetzt, sonst läuft der Server offen.
|
||||
> **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
|
||||
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
|
||||
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
|
||||
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
|
||||
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
|
||||
> **Der Office-Container liest es**, und ohne steht seine API offen, wenn
|
||||
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
|
||||
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
|
||||
|
||||
### Welche Dateien, und wie gut
|
||||
|
||||
@@ -671,6 +887,8 @@ Alles hier ist beim Testen tatsächlich passiert, in dieser Reihenfolge:
|
||||
| Rahmen bleibt leer, Konsole: *„Framing … violates default-src 'self'"* | Unsere eigene Sicherheitsrichtlinie. `frame-src` fällt auf `default-src` zurück; der Office-Ursprung muss ausdrücklich erlaubt sein. Passiert automatisch, sobald `OFFICE_PROVIDER` gesetzt ist. |
|
||||
| Editor lädt, Dokument bleibt weiß | `ssl.termination=true` ohne HTTPS davor. Collabora sagt dem Browser dann `wss://`, das es nicht gibt. Im HTTP-Test `OFFICE_SSL_TERMINATION=false`. |
|
||||
| Log: *„CheckFileInfo returned 0 … ECONNREFUSED"* | `OFFICE_WOPI_URL` zeigt auf `127.0.0.1:8090`. Der Port ist auf loopback gebunden – aus einem Container nicht erreichbar. Im Compose den Dienstnamen nehmen. |
|
||||
| Konsole: *„Framing … violates 'frame-ancestors … app:8080'"* | `OFFICE_WOPI_URL` steht auf dem internen Dienstnamen. Collabora baut daraus seine Richtlinie und erlaubt dann nur eine Einbettung von dort. Die öffentliche Adresse der Cloud eintragen. |
|
||||
| Log: *„No authorized hosts found matching the target host …"* | `aliasgroup1` kennt diesen Host nicht. Öffentliche Adresse eintragen, Punkte maskieren, Port anhängen – und den **Office**-Container neu erzeugen, nicht nur die App. Prüfen mit `docker compose logs office \| grep "trusted WOPI host"`. |
|
||||
| Log: *„WOPISrc must be URI-encoded"* | Behoben; die Adresse wird vollständig kodiert. |
|
||||
| Log: *„Failed to exec coolmount … needs CAP_SYS_ADMIN"* | Gelöst über `--o:mount_jail_tree=false` im Compose. |
|
||||
| Fehlende Symbole (404 auf `.svg`) | Lücken im Collabora-Image selbst. Kosmetisch. |
|
||||
@@ -692,9 +910,15 @@ Der Office-Server ist der mit Abstand hungrigste Teil dieser Anlage:
|
||||
|
||||
| | Bedarf | Bemerkung |
|
||||
|---|---|---|
|
||||
| **Collabora (CODE)** | ~1 GB im Leerlauf, +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
|
||||
| **OnlyOffice Docs** | 2 GB Minimum, **4 GB empfohlen**, dazu Swap | bringt eigenes Postgres, RabbitMQ und Node mit |
|
||||
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
|
||||
| **Collabora (CODE)** | **51 MB im Leerlauf** (gemessen), +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
|
||||
| **OnlyOffice Docs** | **396 MB im Leerlauf** (gemessen), 2 GB Minimum unter Last, **4 GB empfohlen** | bringt eigenes Postgres, RabbitMQ und Node mit |
|
||||
|
||||
Die Leerlaufwerte sind auf dieser Anlage nachgemessen und liegen deutlich
|
||||
unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
|
||||
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
|
||||
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
|
||||
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
|
||||
| **Euro-Office** | wie OnlyOffice, Abbild **6,99 GB** (hier nachgemessen) | derselbe Unterbau |
|
||||
|
||||
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||||
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
||||
@@ -709,7 +933,8 @@ kurzzeitig mehrere hundert Megabyte.
|
||||
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
||||
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
||||
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
||||
Arbeitsspeicher, und das Abbild wiegt rund 5 GB.
|
||||
Arbeitsspeicher, und das Abbild wiegt **6,99 GB** – nachgemessen, nicht
|
||||
geschätzt; die Ankündigung nennt weniger.
|
||||
|
||||
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
|
||||
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
||||
@@ -733,5 +958,6 @@ kostet einen Container und eine Zeile `.env`, nicht mehr.
|
||||
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
|
||||
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
|
||||
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
|
||||
| — | [Kalender](#kalender-caldav): CalDAV, mehrere Kalender, Weboberfläche (Monat/Woche/Liste) – `sync-collection`, Aufgaben und Teilen offen | ✅ (im Test) |
|
||||
|
||||
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).
|
||||
+1039
-4
File diff suppressed because it is too large.
Load diff
+117
-21
@@ -26,16 +26,19 @@ import (
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/avatar"
|
||||
"aether/internal/calendar"
|
||||
"aether/internal/documents"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/office"
|
||||
appoidc "aether/internal/oidc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/sharing"
|
||||
"aether/internal/storage"
|
||||
"aether/internal/tags"
|
||||
"aether/internal/thumbnails"
|
||||
appwebdav "aether/internal/webdav"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/config"
|
||||
appdb "aether/pkg/db"
|
||||
"aether/pkg/middleware"
|
||||
@@ -68,6 +71,7 @@ func spaHandler(root string) http.Handler {
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||||
setStaticCaching(w, upath)
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -80,10 +84,37 @@ func spaHandler(root string) http.Handler {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
setStaticCaching(w, "/index.html")
|
||||
http.ServeFile(w, r, index)
|
||||
})
|
||||
}
|
||||
|
||||
// setStaticCaching entscheidet, was der Browser behalten darf.
|
||||
//
|
||||
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
|
||||
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
|
||||
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
|
||||
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
|
||||
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
|
||||
// als rätselhafter Fehler mitten in der Anwendung.
|
||||
//
|
||||
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
|
||||
//
|
||||
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
|
||||
// ändert sich der Name. Solche Dateien darf der Browser für immer
|
||||
// behalten – "immutable" spart ihm sogar die Rückfrage.
|
||||
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
|
||||
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
|
||||
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
|
||||
// der Server dann meist nur mit 304 und ein paar Byte.
|
||||
func setStaticCaching(w http.ResponseWriter, upath string) {
|
||||
if strings.HasPrefix(upath, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -96,6 +127,20 @@ func main() {
|
||||
}
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
// **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.**
|
||||
//
|
||||
// zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext
|
||||
// keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher
|
||||
// Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein
|
||||
// Blob verwaist ist, und die Meldung über eine beschädigte Datei beim
|
||||
// Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos:
|
||||
// Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem
|
||||
// man den Fehler herbeiführt, den er melden soll.
|
||||
//
|
||||
// Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue
|
||||
// Logeintrag blieb aus, obwohl der Abbruch funktionierte.
|
||||
zerolog.DefaultContextLogger = &log.Logger
|
||||
|
||||
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -146,13 +191,16 @@ func main() {
|
||||
Timeout: cfg.OCRTimeout,
|
||||
TmpDir: cfg.TmpPath,
|
||||
}, log.Logger)
|
||||
go worker.Run(ctx)
|
||||
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
|
||||
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
|
||||
// Aufräumen der Zwischendateien beim Start.
|
||||
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
|
||||
|
||||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||
// Anfang der gesamte Bestand.
|
||||
go func() {
|
||||
bg.Go("Bestand nachtragen", func() {
|
||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||
@@ -175,7 +223,7 @@ func main() {
|
||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +236,15 @@ func main() {
|
||||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||||
|
||||
shareStore := sharing.NewStore(pool)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
|
||||
|
||||
collector := gc.New(pool, blobStore)
|
||||
|
||||
adminStore := admin.NewStore(pool)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
|
||||
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
|
||||
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
|
||||
rescanner := rescan.New(pool, fileStore, blobStore)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
|
||||
|
||||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||
@@ -246,6 +297,11 @@ func main() {
|
||||
// WebDAV
|
||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
||||
|
||||
// CalDAV
|
||||
calStore := calendar.NewStore(pool, aclStore)
|
||||
calHandler := calendar.NewHandler(authStore, calStore)
|
||||
calAPIHandler := calendar.NewAPIHandler(calStore)
|
||||
|
||||
// OIDC (optional – disabled if OIDC_ISSUER not set)
|
||||
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
||||
Issuer: cfg.OIDCIssuer,
|
||||
@@ -306,7 +362,7 @@ func main() {
|
||||
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
||||
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
||||
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
|
||||
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"} {
|
||||
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT", "MKCALENDAR"} {
|
||||
chi.RegisterMethod(m)
|
||||
}
|
||||
|
||||
@@ -352,6 +408,12 @@ func main() {
|
||||
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
||||
r.Mount("/dav", davHandler)
|
||||
|
||||
// CalDAV endpoint – eigene Basic Auth (App-Passwörter), wie /dav.
|
||||
// .well-known/caldav liegt bewusst außerhalb von /caldav: RFC 6764 sieht
|
||||
// diesen Pfad an der Wurzel vor, Clients wie DAVx5 fragen dort zuerst an.
|
||||
r.Mount("/caldav", calHandler)
|
||||
r.Get("/.well-known/caldav", calHandler.ServeWellKnown)
|
||||
|
||||
// Public config (no auth) – used by login page to discover OIDC availability
|
||||
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cloudName := "Aether"
|
||||
@@ -374,16 +436,6 @@ func main() {
|
||||
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
|
||||
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
|
||||
}
|
||||
// Ohne Ursprung gibt es keine Leiste — dann hat auch der Schluessel
|
||||
// nichts auf der Seite zu suchen.
|
||||
honeybarKey := ""
|
||||
if cfg.HoneybarOrigin != "" {
|
||||
honeybarKey = cfg.HoneybarKey
|
||||
}
|
||||
apps := cfg.ExternalApps
|
||||
if apps == nil {
|
||||
apps = []config.ExternalApp{} // nie null – das Frontend erwartet eine Liste
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"oidc_enabled": oidcHandler.IsEnabled(),
|
||||
"cloud_name": cloudName,
|
||||
@@ -392,10 +444,19 @@ func main() {
|
||||
"logo_url": logoURL,
|
||||
"favicon_url": faviconURL,
|
||||
"apple_touch_icon_url": appleTouchURL,
|
||||
"external_apps": apps,
|
||||
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
||||
// erst anbietet, wenn es keinen Office-Server gibt.
|
||||
"office_enabled": officeHandler.Enabled(),
|
||||
// Welcher Anbieter – die Oberflaeche entscheidet daran, ob ein
|
||||
// Dokument am Telefon im Editor oder als PDF aufgeht. Collabora
|
||||
// hat eine brauchbare Handy-Ansicht, OnlyOffice baut dort die
|
||||
// PC-Oberflaeche auf. Ohne Office-Server ein leerer Wert.
|
||||
"office_provider": func() string {
|
||||
if officeHandler.Enabled() {
|
||||
return cfg.OfficeProvider
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
// Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server
|
||||
// anwendet. Vorher stand sie zweimal im Quelltext, mit einem
|
||||
// Kommentar „muss zusammenpassen" – das ist keine Zusicherung.
|
||||
@@ -409,7 +470,6 @@ func main() {
|
||||
// .env umgezogen werden kann — und damit derselbe Ursprung
|
||||
// gilt, den die CSP erlaubt.
|
||||
"honeybar_origin": cfg.HoneybarOrigin,
|
||||
"honeybar_key": honeybarKey,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -444,11 +504,14 @@ func main() {
|
||||
r.Get("/search", fileHandler.Search)
|
||||
r.Get("/files/{id}/download", fileHandler.Download)
|
||||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
||||
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
|
||||
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||
// angemeldeten Zusammenhang.
|
||||
r.Get("/office/session/{id}", officeHandler.Session)
|
||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||
// Telefon der Normalfall.
|
||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||
@@ -505,6 +568,10 @@ func main() {
|
||||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
||||
// Kalender liegen in derselben Rechtetabelle – deshalb dieselben
|
||||
// Endpunkte, und /acl/{id} widerruft alle drei Arten.
|
||||
r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL)
|
||||
r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL)
|
||||
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||||
|
||||
// App passwords (WebDAV/sync clients)
|
||||
@@ -512,6 +579,21 @@ func main() {
|
||||
// es steht ohnehin an Freigaben und in Nutzerlisten.
|
||||
r.Get("/users/{id}/avatar", avatarHandler.Serve)
|
||||
|
||||
// Kalender – die Termine selbst laufen über CalDAV (/caldav/), das
|
||||
// hier ist nur Verwaltung: mehrere Kalender, umbenennen, Farbe,
|
||||
// löschen, sowie Export/Import als .ics.
|
||||
r.Get("/calendars", calAPIHandler.List)
|
||||
r.Post("/calendars", calAPIHandler.Create)
|
||||
r.Patch("/calendars/{id}", calAPIHandler.Update)
|
||||
r.Delete("/calendars/{id}", calAPIHandler.Delete)
|
||||
r.Get("/calendars/{id}/export", calAPIHandler.Export)
|
||||
r.Post("/calendars/{id}/import", calAPIHandler.Import)
|
||||
r.Get("/calendars/{id}/events", calAPIHandler.ListEvents)
|
||||
r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent)
|
||||
r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent)
|
||||
r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent)
|
||||
r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent)
|
||||
|
||||
r.Get("/app-passwords", authHandler.ListAppPasswords)
|
||||
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
||||
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
|
||||
@@ -529,6 +611,10 @@ func main() {
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
r.Post("/admin/rescan", adminHandler.Rescan)
|
||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||
// Platz, der in keinem Kontingent auftaucht.
|
||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
|
||||
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
|
||||
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
|
||||
@@ -565,6 +651,10 @@ func main() {
|
||||
done := make(chan struct{})
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
|
||||
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
|
||||
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
|
||||
// Sie berührt außerdem keine fremden Daten.
|
||||
go func() {
|
||||
<-quit
|
||||
log.Info().Msg("shutting down...")
|
||||
@@ -630,8 +720,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
|
||||
// Generate thumbnail in background – non-fatal if it fails.
|
||||
go func() {
|
||||
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||||
//
|
||||
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
|
||||
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
|
||||
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
|
||||
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
|
||||
// gewöhnlichen Upload.
|
||||
bg.Go("Vorschaubild", func() {
|
||||
ctx := context.Background()
|
||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
@@ -647,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
|
||||
--
|
||||
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
|
||||
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
|
||||
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
|
||||
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
|
||||
--
|
||||
-- 0 heißt "keine". Dateifassungen zählen ab 1.
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS cal_objects;
|
||||
DROP TABLE IF EXISTS cal_calendars;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
|
||||
--
|
||||
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
|
||||
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
|
||||
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
|
||||
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_calendars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
|
||||
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
|
||||
color TEXT NOT NULL DEFAULT '#4285f4',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
|
||||
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
|
||||
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
|
||||
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
|
||||
-- einzelne Objekt abzufragen.
|
||||
ctag BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
|
||||
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
|
||||
uid TEXT NOT NULL,
|
||||
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
|
||||
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
|
||||
component TEXT NOT NULL,
|
||||
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
|
||||
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
|
||||
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
|
||||
data TEXT NOT NULL,
|
||||
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
|
||||
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
|
||||
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
|
||||
etag TEXT NOT NULL,
|
||||
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
|
||||
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
|
||||
dtstart TIMESTAMPTZ,
|
||||
dtend TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (calendar_id, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS timezone;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Standard-Zeitzone pro Kalender. Wird von CalDAV selbst nicht gebraucht
|
||||
-- (jeder Termin trägt seine Zeitzone im .ics), sondern erst von der
|
||||
-- künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3, siehe
|
||||
-- kalender-plan.md) – ein neuer Termin braucht eine Vorgabe, wenn der
|
||||
-- Nutzer keine eigene wählt. Jetzt schon angelegt, damit später keine
|
||||
-- weitere Migration für ein einzelnes Feld nötig ist.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus
|
||||
-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer
|
||||
-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen
|
||||
-- und der Client bekäme auf seine eigene Adresse ein 404.
|
||||
--
|
||||
-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle
|
||||
-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar,
|
||||
-- unter der die eingerichteten Geräte sie schon kennen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT;
|
||||
|
||||
-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe
|
||||
-- Adresse haben, aber beliebig viele dürfen gar keine haben.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment
|
||||
ON cal_calendars (owner_id, path_segment)
|
||||
WHERE path_segment IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS cal_tombstones;
|
||||
DROP INDEX IF EXISTS cal_objects_sync;
|
||||
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
|
||||
--
|
||||
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
|
||||
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
|
||||
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
|
||||
--
|
||||
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
|
||||
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
|
||||
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
|
||||
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
|
||||
-- bei dem es zuletzt angefasst wurde.
|
||||
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
|
||||
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
|
||||
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
|
||||
UPDATE cal_objects o SET sync_seq = c.ctag
|
||||
FROM cal_calendars c WHERE c.id = o.calendar_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
|
||||
|
||||
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
|
||||
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
|
||||
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
|
||||
-- Art, weil er unsichtbar ist.
|
||||
CREATE TABLE IF NOT EXISTS cal_tombstones (
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
sync_seq BIGINT NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
|
||||
|
||||
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
|
||||
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
|
||||
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_acl_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_group_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_user_calendar;
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Kalender an andere Konten freigeben.
|
||||
--
|
||||
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
|
||||
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
|
||||
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
|
||||
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
|
||||
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
|
||||
-- auf, wenn jemand sieht, was er nicht sehen soll.
|
||||
--
|
||||
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
|
||||
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
|
||||
ALTER TABLE acl_entries
|
||||
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
|
||||
|
||||
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
|
||||
|
||||
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
|
||||
-- mehrdeutig: welches Recht gilt dann?
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
|
||||
ON acl_entries (subject_user_id, resource_calendar_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
|
||||
ON acl_entries (subject_group_id, resource_calendar_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
|
||||
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
|
||||
CREATE INDEX IF NOT EXISTS idx_acl_calendar
|
||||
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Persönlich'
|
||||
WHERE name = 'Personal'
|
||||
AND color = '#4285f4';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
|
||||
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
|
||||
-- zieht die bereits angelegten nach.
|
||||
--
|
||||
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
|
||||
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
|
||||
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Personal'
|
||||
WHERE name = 'Persönlich'
|
||||
AND color = '#4285f4';
|
||||
+7
-4
@@ -4,6 +4,8 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
@@ -11,8 +13,9 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
@@ -28,7 +31,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
+16
-8
@@ -22,6 +22,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
@@ -87,6 +93,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
@@ -103,21 +111,21 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Kalender ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Kalender liegen in derselben Rechtetabelle wie Ordner und Dateien. Nicht aus
|
||||
// Sparsamkeit, sondern weil zwei Tabellen zwei Antworten auf dieselbe Frage
|
||||
// wären – und die Lücke zwischen ihnen fällt erst auf, wenn jemand sieht, was
|
||||
// er nicht sehen soll. Die Auflösung über Gruppen ist deshalb wörtlich
|
||||
// dieselbe Bedingung wie bei Ordnern.
|
||||
|
||||
// ResolveCalendar klärt, was userID mit diesem Kalender darf.
|
||||
//
|
||||
// Der Eigentümer darf schreiben. Alle anderen nur, wenn es eine Freigabe gibt –
|
||||
// direkt oder über eine Gruppe, in der sie sind. Gibt es keine, ist die
|
||||
// Antwort ErrNoAccess, und der Aufrufer macht daraus ein 404: Ein 403 verriete,
|
||||
// dass es den Kalender gibt.
|
||||
func (s *Store) ResolveCalendar(ctx context.Context, userID, calendarID uuid.UUID) (Permission, error) {
|
||||
var ownerID uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load calendar owner: %w", err)
|
||||
}
|
||||
if ownerID == userID {
|
||||
return PermWrite, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT e.permission
|
||||
FROM acl_entries e
|
||||
WHERE e.resource_calendar_id = $1
|
||||
AND ( e.subject_user_id = $2
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2) )`,
|
||||
calendarID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve calendar permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Mehrere Wege zum selben Kalender sind möglich – persönlich *und* über
|
||||
// eine Gruppe. Dann gilt das weitergehende Recht; alles andere wäre für
|
||||
// den Betroffenen nicht nachvollziehbar.
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
if p == PermWrite {
|
||||
best = PermWrite
|
||||
} else if best == "" {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if best == "" {
|
||||
return "", ErrNoAccess
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// SharedCalendar ist ein fremder Kalender, den jemand sehen darf.
|
||||
type SharedCalendar struct {
|
||||
CalendarID uuid.UUID
|
||||
Permission Permission
|
||||
OwnerName string
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||
// man sich über einen Kalender, den einem nie jemand persönlich gab.
|
||||
ViaGroup string
|
||||
}
|
||||
|
||||
// SharedCalendars liefert alle **fremden** Kalender, die userID sehen darf.
|
||||
// Die eigenen stehen nicht darin; die kennt der Kalenderteil selbst.
|
||||
func (s *Store) SharedCalendars(ctx context.Context, userID uuid.UUID) ([]SharedCalendar, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT c.id, e.permission,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username, ''),
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN cal_calendars c ON c.id = e.resource_calendar_id
|
||||
JOIN users o ON o.id = c.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE c.owner_id <> $1
|
||||
AND ( e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1) )
|
||||
ORDER BY o.username, c.created_at`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Zwei Wege zum selben Kalender ergeben zwei Zeilen – angezeigt wird er
|
||||
// einmal, mit dem weitergehenden Recht.
|
||||
byID := map[uuid.UUID]int{}
|
||||
out := []SharedCalendar{}
|
||||
for rows.Next() {
|
||||
var sc SharedCalendar
|
||||
if err := rows.Scan(&sc.CalendarID, &sc.Permission, &sc.OwnerName, &sc.ViaGroup); err != nil {
|
||||
return nil, fmt.Errorf("scan shared calendar: %w", err)
|
||||
}
|
||||
if i, da := byID[sc.CalendarID]; da {
|
||||
if sc.Permission == PermWrite {
|
||||
out[i].Permission = PermWrite
|
||||
}
|
||||
continue
|
||||
}
|
||||
byID[sc.CalendarID] = len(out)
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -89,6 +89,63 @@ func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID,
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders.
|
||||
// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht
|
||||
// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat,
|
||||
// ist etwas anderes als es zu benutzen.
|
||||
func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid calendar id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
owner, err := h.store.CalendarOwner(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if owner != user.ID {
|
||||
// 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort
|
||||
// nicht erfahren, dass es ihn gibt.
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/acl
|
||||
func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForCalendar(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/acl
|
||||
func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "calendar", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) uuid.UUID {
|
||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||
return u.ID
|
||||
|
||||
@@ -41,17 +41,30 @@ type SharedResource struct {
|
||||
|
||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||
|
||||
// resource benennt, worum es bei einer Freigabe geht: genau eines der drei
|
||||
// Felder ist gesetzt. Als Typ statt als drei Zeigerparameter, seit der
|
||||
// Kalender dazukam – drei nebeneinanderstehende `*uuid.UUID` in einer
|
||||
// Signatur sind eine Einladung, sie zu vertauschen.
|
||||
type resource struct{ folder, file, calendar *uuid.UUID }
|
||||
|
||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||
// ein Nutzer oder eine Gruppe ist.
|
||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
return s.grant(ctx, resource{folder: &folderID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
// GrantCalendar gibt einen Kalender frei – an ein Konto oder an eine Gruppe.
|
||||
// Dass Gruppen ohne Zutun mitfunktionieren, ist der Grund, warum Kalender in
|
||||
// *dieser* Tabelle liegen und nicht in einer eigenen.
|
||||
func (s *Store) GrantCalendar(ctx context.Context, calendarID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, resource{calendar: &calendarID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, res resource, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
if !perm.valid() {
|
||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||
}
|
||||
@@ -71,10 +84,11 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO acl_entries
|
||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
(subject_user_id, subject_group_id,
|
||||
resource_folder_id, resource_file_id, resource_calendar_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, permission, created_at`,
|
||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
||||
subjUser, subjGroup, res.folder, res.file, res.calendar, perm, grantedBy,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
|
||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||
@@ -84,7 +98,7 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
// über den Fehlercode.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
||||
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant access: %w", err)
|
||||
@@ -95,16 +109,17 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
func (s *Store) updateExisting(ctx context.Context, res resource, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE acl_entries SET permission = $1
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND resource_calendar_id IS NOT DISTINCT FROM $4
|
||||
AND subject_user_id IS NOT DISTINCT FROM $5
|
||||
AND subject_group_id IS NOT DISTINCT FROM $6
|
||||
RETURNING id, permission, created_at`,
|
||||
perm, folderID, fileID, subjUser, subjGroup,
|
||||
perm, res.folder, res.file, res.calendar, subjUser, subjGroup,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -126,8 +141,9 @@ func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM acl_entries e
|
||||
WHERE e.id = $1
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM cal_calendars c WHERE c.id = e.resource_calendar_id AND c.owner_id = $2) )`,
|
||||
entryID, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke access: %w", err)
|
||||
@@ -147,6 +163,10 @@ func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, err
|
||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) ListForCalendar(ctx context.Context, calendarID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_calendar_id = $1`, calendarID)
|
||||
}
|
||||
|
||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT e.id,
|
||||
@@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string {
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
// CalendarOwner liefert, wem ein Kalender gehört – die Frage vor jeder
|
||||
// Verwaltung seiner Freigaben.
|
||||
func (s *Store) CalendarOwner(ctx context.Context, calendarID uuid.UUID) (uuid.UUID, error) {
|
||||
var owner uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&owner)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("load calendar owner: %w", err)
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/rescan"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
@@ -97,3 +98,80 @@ func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
|
||||
"skipped_young": res.SkippedYoung,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/storage/derived – Platzbedarf der abgeleiteten Daten.
|
||||
//
|
||||
// Getrennt vom Aufräumen, weil es eine Auskunft ist und keine Handlung: Man
|
||||
// soll nachsehen können, ohne etwas anzustoßen.
|
||||
func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.gc == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "not available")
|
||||
return
|
||||
}
|
||||
usage, err := h.gc.Derived(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not read storage")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank
|
||||
// abgleichen.
|
||||
//
|
||||
// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag
|
||||
// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer
|
||||
// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in
|
||||
// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten.
|
||||
//
|
||||
// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas.
|
||||
// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was
|
||||
// passieren wird, kann es auch nicht verhindern.
|
||||
func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.rescan == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "rescan not available")
|
||||
return
|
||||
}
|
||||
|
||||
anwenden := r.URL.Query().Get("anwenden") == "true"
|
||||
// Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen
|
||||
// soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint
|
||||
// aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen.
|
||||
bestaetigt := r.URL.Query().Get("bestaetigt") == "true"
|
||||
var (
|
||||
bef *rescan.Report
|
||||
err error
|
||||
)
|
||||
if anwenden {
|
||||
bef, err = h.rescan.Apply(r.Context(), bestaetigt)
|
||||
} else {
|
||||
bef, err = h.rescan.Check(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "rescan failed")
|
||||
return
|
||||
}
|
||||
|
||||
if anwenden {
|
||||
// Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein
|
||||
// ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit
|
||||
// dem Datenordner etwas nicht stimmt.
|
||||
if bef.Aborted {
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
"rescan aborted, nothing removed: "+bef.AbortReason)
|
||||
} else {
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
|
||||
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
|
||||
}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, bef)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
@@ -30,10 +31,11 @@ type Handler struct {
|
||||
auditor *audit.Logger
|
||||
gc *gc.Collector
|
||||
files *files.Store
|
||||
rescan *rescan.Scanner
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner}
|
||||
}
|
||||
|
||||
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
||||
|
||||
@@ -26,6 +26,12 @@ func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
// Die Frist läuft ab der letzten Benutzung, nicht ab dem
|
||||
// Anmelden. **Vor** dem Handler, weil danach der Rumpf der
|
||||
// Antwort schon geschrieben sein kann – ein Set-Cookie käme dann
|
||||
// zu spät und fiele stillschweigend unter den Tisch.
|
||||
ss.Touch(w, r)
|
||||
|
||||
ctx := context.WithValue(r.Context(), keyUser, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
@@ -15,10 +16,22 @@ import (
|
||||
const (
|
||||
sessionName = "aether_session"
|
||||
sessionKeyUser = "user_id"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
// sessionKeyTouched hält fest, wann die Sitzung zuletzt verlängert wurde.
|
||||
// Ohne diesen Wert müsste jede einzelne Anfrage ein neues Cookie setzen.
|
||||
sessionKeyTouched = "touched_at"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
// refreshInterval: Wie oft eine benutzte Sitzung höchstens erneuert wird.
|
||||
//
|
||||
// Die Frist gleitet – aber sie muss nicht bei jedem Klick nachgezogen werden.
|
||||
// Ohne diese Bremse trüge **jede** Antwort ein `Set-Cookie`, und beim Laden
|
||||
// einer Dateiliste sind das schnell dreißig Stück. Einmal pro Stunde reicht:
|
||||
// Der Unterschied zur Frist ist eine Stunde, und die fällt bei Tagen nicht
|
||||
// ins Gewicht.
|
||||
var refreshInterval = time.Hour
|
||||
|
||||
type SessionStore struct {
|
||||
store *sessions.CookieStore
|
||||
maxAge int
|
||||
@@ -32,6 +45,15 @@ func NewSessionStore(secret string, maxAge int) *SessionStore {
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
// **Zwei Fristen, nicht eine.** `Options.MaxAge` steht nur im Cookie – der
|
||||
// Browser wirft es dann weg. Wie lange der *Inhalt* gilt, entscheidet die
|
||||
// Signatur, und die hängt an den Codecs.
|
||||
//
|
||||
// Ohne diesen Aufruf blieb dort gorillas Voreinstellung von 30 Tagen
|
||||
// stehen: Ein Cookie, das jemand aus einem Browser kopiert hat, wäre einen
|
||||
// Monat lang gültig geblieben, obwohl SESSION_MAX_AGE auf einen Tag stand.
|
||||
// Wer die Frist kürzt, um sie zu kürzen, hätte nichts gekürzt.
|
||||
store.MaxAge(maxAge)
|
||||
return &SessionStore{store: store, maxAge: maxAge}
|
||||
}
|
||||
|
||||
@@ -41,10 +63,45 @@ func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID u
|
||||
sess, _ = ss.store.New(r, sessionName)
|
||||
}
|
||||
sess.Values[sessionKeyUser] = userID.String()
|
||||
sess.Values[sessionKeyTouched] = time.Now().Unix()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
// Touch verlängert eine benutzte Sitzung.
|
||||
//
|
||||
// **Warum überhaupt:** Bisher lief die Frist ab dem *Anmelden* – wer die Cloud
|
||||
// täglich benutzte, flog nach 24 Stunden trotzdem heraus, mitten in der
|
||||
// Arbeit. Das ist die Sorte Ablauf, die keine Sicherheit bringt: Sie trifft
|
||||
// den, der da ist, und nicht den, der weg ist.
|
||||
//
|
||||
// Jetzt läuft sie ab der *letzten Benutzung*. Wer die Cloud regelmäßig
|
||||
// öffnet, bleibt angemeldet; wer sie einen Monat nicht anfasst, muss sich neu
|
||||
// anmelden – und genau dieser zweite Fall ist der, um den es bei einem
|
||||
// abgelaufenen Cookie geht.
|
||||
//
|
||||
// **Was das kostet, ehrlich gesagt:** Ein entwendetes Cookie, das weiter
|
||||
// benutzt wird, verlängert sich mit. Ein Sitzungscookie hat keine Liste auf
|
||||
// dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne
|
||||
// Sitzungstabelle auszukommen. Wer eine Sitzung sofort beenden will, ändert
|
||||
// SESSION_SECRET; dann sind alle ungültig.
|
||||
func (ss *SessionStore) Touch(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil || sess.IsNew {
|
||||
return
|
||||
}
|
||||
if last, ok := sess.Values[sessionKeyTouched].(int64); ok {
|
||||
if time.Since(time.Unix(last, 0)) < refreshInterval {
|
||||
return
|
||||
}
|
||||
}
|
||||
sess.Values[sessionKeyTouched] = time.Now().Unix()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
// Ein Fehlschlag ist keiner, der den Aufruf scheitern lassen darf: Die
|
||||
// Sitzung gilt weiter, sie wurde nur nicht verlängert.
|
||||
_ = ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const testSecret = "test-geheimnis-mit-mehr-als-32-zeichen!!"
|
||||
|
||||
// Anmelden und die gesetzten Cookies zurückgeben.
|
||||
func signIn(t *testing.T, ss *SessionStore) []*http.Cookie {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if err := ss.SetUser(w, r, uuid.New()); err != nil {
|
||||
t.Fatalf("SetUser: %v", err)
|
||||
}
|
||||
return w.Result().Cookies()
|
||||
}
|
||||
|
||||
// Eine **frische** Anfrage mit diesen Cookies.
|
||||
//
|
||||
// Jedes Mal eine neue, und das ist keine Umständlichkeit: gorilla legt die
|
||||
// entschlüsselte Sitzung im Zusammenhang der Anfrage ab und gibt sie beim
|
||||
// zweiten Zugriff unverändert zurück. Zweimal dieselbe Anfrage zu befragen
|
||||
// prüfte also den Zwischenspeicher und nicht die Frist – der erste Entwurf
|
||||
// dieses Tests ist genau darauf hereingefallen.
|
||||
func withCookies(cookies []*http.Cookie) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func signedIn(t *testing.T, ss *SessionStore) *http.Request {
|
||||
t.Helper()
|
||||
return withCookies(signIn(t, ss))
|
||||
}
|
||||
|
||||
// Die Frist muss auch für den **Inhalt** gelten, nicht nur für das Cookie.
|
||||
//
|
||||
// Der Browser wirft ein abgelaufenes Cookie von selbst weg – wer es vorher
|
||||
// herauskopiert hat, aber nicht. Ohne store.MaxAge() blieb die Signatur 30
|
||||
// Tage gültig, egal was in SESSION_MAX_AGE stand.
|
||||
func TestSessionExpiresAfterMaxAge(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 1) // eine Sekunde
|
||||
cookies := signIn(t, ss)
|
||||
|
||||
if _, ok := ss.GetUserID(withCookies(cookies)); !ok {
|
||||
t.Fatal("frische Sitzung wurde nicht erkannt")
|
||||
}
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
if _, ok := ss.GetUserID(withCookies(cookies)); ok {
|
||||
t.Error("abgelaufene Sitzung gilt weiter – die Frist wirkt nur im Browser")
|
||||
}
|
||||
}
|
||||
|
||||
// Nicht bei jedem Aufruf ein neues Cookie: Sonst trüge jede Antwort ein
|
||||
// Set-Cookie, und beim Laden einer Dateiliste sind das dreißig Stück.
|
||||
func TestTouchIsThrottled(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
r := signedIn(t, ss)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ss.Touch(w, r)
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Error("Sitzung wurde sofort erneuert, obwohl sie eben erst entstand")
|
||||
}
|
||||
}
|
||||
|
||||
// Nach Ablauf der Bremse verlängert sie sich – das ist der eigentliche Zweck.
|
||||
func TestTouchExtendsSession(t *testing.T) {
|
||||
old := refreshInterval
|
||||
refreshInterval = 0
|
||||
defer func() { refreshInterval = old }()
|
||||
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
r := signedIn(t, ss)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ss.Touch(w, r)
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("Sitzung wurde nicht verlängert")
|
||||
}
|
||||
// Das erneuerte Cookie muss dieselbe Anmeldung tragen.
|
||||
next := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range cookies {
|
||||
next.AddCookie(c)
|
||||
}
|
||||
if _, ok := ss.GetUserID(next); !ok {
|
||||
t.Error("nach dem Verlängern ist niemand mehr angemeldet")
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Anfrage ohne Cookie darf keine Sitzung erfinden.
|
||||
func TestTouchIgnoresAnonymous(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
w := httptest.NewRecorder()
|
||||
ss.Touch(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Error("für einen Unangemeldeten wurde ein Cookie gesetzt")
|
||||
}
|
||||
}
|
||||
|
||||
// Abmelden muss stärker sein als Verlängern.
|
||||
func TestClearEndsSession(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
r := signedIn(t, ss)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
if err := ss.Clear(w, r); err != nil {
|
||||
t.Fatalf("Clear: %v", err)
|
||||
}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionName && c.MaxAge >= 0 {
|
||||
t.Errorf("Abmelden löscht das Cookie nicht (MaxAge=%d)", c.MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
)
|
||||
|
||||
// Wer darf was mit einem Kalender.
|
||||
//
|
||||
// **Alle Wege laufen hier zusammen.** CalDAV und die Weboberfläche sind zwei
|
||||
// völlig verschiedene Zugänge zu denselben Daten – ein Recht, das nur einer
|
||||
// von beiden prüft, ist kein Recht. Deshalb gibt es genau eine Antwort auf
|
||||
// „darf dieses Konto diesen Kalender", und beide Zugänge holen sie hier.
|
||||
|
||||
// Access ist ein Kalender samt dem, was der Anfragende damit darf.
|
||||
type Access struct {
|
||||
Calendar
|
||||
Permission acl.Permission `json:"permission"`
|
||||
// Shared: Der Kalender gehört jemand anderem.
|
||||
Shared bool `json:"shared"`
|
||||
// OwnerName steht in der Oberfläche neben dem Namen – ein fremder
|
||||
// Kalender ohne sichtbaren Absender ist ein Rätsel.
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt.
|
||||
ViaGroup string `json:"via_group,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Access) CanWrite() bool { return a.Permission == acl.PermWrite }
|
||||
|
||||
// Ref ist die Adresse in CalDAV.
|
||||
//
|
||||
// Bei einem **fremden** Kalender immer die UUID, nie das Pfadsegment: Das
|
||||
// Segment hat sich der Eigentümer ausgesucht ("…/arbeit/"), und zwei Leute
|
||||
// dürfen denselben Namen wählen. Unter dem Kalenderbestand des Empfängers
|
||||
// stünden dann zwei Kalender an derselben Adresse – und welcher gemeint ist,
|
||||
// entschiede der Zufall.
|
||||
func (a *Access) Ref() string {
|
||||
if a.Shared {
|
||||
return a.Calendar.ID.String()
|
||||
}
|
||||
return a.Calendar.Ref()
|
||||
}
|
||||
|
||||
// ListAccessible liefert alles, was userID sehen darf: die eigenen Kalender
|
||||
// und die ihm freigegebenen. Eigene zuerst – sie sind die, die man sucht.
|
||||
func (s *Store) ListAccessible(ctx context.Context, userID uuid.UUID) ([]Access, error) {
|
||||
own, err := s.ListCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Access, 0, len(own))
|
||||
for i := range own {
|
||||
out = append(out, Access{Calendar: own[i], Permission: acl.PermWrite})
|
||||
}
|
||||
|
||||
if s.rights == nil {
|
||||
return out, nil
|
||||
}
|
||||
geteilt, err := s.rights.SharedCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared calendars: %w", err)
|
||||
}
|
||||
for _, sc := range geteilt {
|
||||
cal, err := s.getCalendarAny(ctx, sc.CalendarID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue // zwischenzeitlich gelöscht
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, Access{
|
||||
Calendar: *cal,
|
||||
Permission: sc.Permission,
|
||||
Shared: true,
|
||||
OwnerName: sc.OwnerName,
|
||||
ViaGroup: sc.ViaGroup,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveRef findet den Kalender hinter einer CalDAV-Adresse – eigener oder
|
||||
// freigegebener.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: **erst die eigenen.** Sonst könnte ein fremder
|
||||
// Kalender, dessen UUID zufällig wie ein eigenes Pfadsegment aussieht, den
|
||||
// eigenen verdecken.
|
||||
func (s *Store) ResolveRef(ctx context.Context, userID uuid.UUID, ref string) (*Access, error) {
|
||||
cal, err := s.GetCalendarByRef(ctx, userID, ref)
|
||||
if err == nil {
|
||||
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ein freigegebener Kalender steht unter seiner UUID. Alles andere kann
|
||||
// hier nichts mehr treffen.
|
||||
id, parseErr := uuid.Parse(ref)
|
||||
if parseErr != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.RequireCalendar(ctx, userID, id, false)
|
||||
}
|
||||
|
||||
// RequireCalendar ist die Prüfung, die jeder schreibende und lesende Zugriff
|
||||
// durchläuft – aus CalDAV wie aus der Weboberfläche.
|
||||
//
|
||||
// Ohne Zugriff kommt **ErrNotFound**, nicht „verboten": Ein 403 verriete, dass
|
||||
// es den Kalender gibt. Dieselbe Regel wie überall sonst in Aether.
|
||||
func (s *Store) RequireCalendar(ctx context.Context, userID, calendarID uuid.UUID, needWrite bool) (*Access, error) {
|
||||
cal, err := s.getCalendarAny(ctx, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cal.OwnerID == userID {
|
||||
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
|
||||
}
|
||||
if s.rights == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
perm, err := s.rights.ResolveCalendar(ctx, userID, calendarID)
|
||||
if errors.Is(err, acl.ErrNoAccess) || errors.Is(err, acl.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if needWrite && perm != acl.PermWrite {
|
||||
// Hier **darf** es ein 403 sein: Dass es den Kalender gibt, weiß der
|
||||
// Anfragende bereits – er darf ihn ja lesen.
|
||||
return nil, ErrReadOnly
|
||||
}
|
||||
|
||||
a := &Access{Calendar: *cal, Permission: perm, Shared: true}
|
||||
// Name des Eigentümers nur nachschlagen, wenn er auch angezeigt wird.
|
||||
if name, err := s.ownerName(ctx, cal.OwnerID); err == nil {
|
||||
a.OwnerName = name
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ErrReadOnly: Der Kalender ist sichtbar, aber nur lesend freigegeben.
|
||||
var ErrReadOnly = errors.New("calendar: read-only share")
|
||||
|
||||
// getCalendarAny lädt einen Kalender **ohne** Rechteprüfung. Privat, und das
|
||||
// muss so bleiben: Jeder Aufruf von außen ginge an der Prüfung vorbei.
|
||||
func (s *Store) getCalendarAny(ctx context.Context, id uuid.UUID) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone,
|
||||
COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE id = $1`, id,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo,
|
||||
&c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) ownerName(ctx context.Context, ownerID uuid.UUID) (string, error) {
|
||||
var name string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = $1`,
|
||||
ownerID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// APIHandler bedient die angemeldete Web-Oberfläche unter /api/v1/calendars
|
||||
// (Session-Auth, wie jeder andere Bereich). Getrennt vom CalDAV-Handler
|
||||
// (Handler in handler.go), der eigene Basic-Auth mit App-Passwörtern nutzt –
|
||||
// unterschiedliche Auth-Modelle, unterschiedliche Handler, wie /dav vs. der
|
||||
// Rest der API.
|
||||
type APIHandler struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewAPIHandler(store *Store) *APIHandler {
|
||||
return &APIHandler{store: store}
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars
|
||||
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
// Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst
|
||||
// steht ein neues Konto vor einer leeren Ansicht und muss erst erraten,
|
||||
// dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich
|
||||
// vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht
|
||||
// zeigt.
|
||||
if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||
return
|
||||
}
|
||||
// Eigene **und** freigegebene. Jeder Eintrag trägt sein Recht mit, damit
|
||||
// die Oberfläche einen nur lesbaren Kalender gar nicht erst zum
|
||||
// Bearbeiten anbietet.
|
||||
cals, err := h.store.ListAccessible(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, cals)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars
|
||||
func (h *APIHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
cal, err := h.store.CreateCalendar(r.Context(), user.ID, body.Name, body.Color, body.Description)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "create calendar failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, cal)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/calendars/{id}
|
||||
func (h *APIHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Description *string `json:"description"`
|
||||
TimeZone *string `json:"timezone"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
cal, err := h.store.UpdateCalendar(r.Context(), user.ID, id, body.Name, body.Color, body.Description, body.TimeZone)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update calendar failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, cal)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendars/{id}
|
||||
func (h *APIHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteCalendar(r.Context(), user.ID, id); err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "delete calendar failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/export – ein .ics mit allen Terminen/Aufgaben
|
||||
// des Kalenders zusammen, zum Sichern oder Umziehen in einen anderen Dienst.
|
||||
func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
// Ausgeben darf, wer lesen darf – auch bei einem freigegebenen Kalender.
|
||||
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, false)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||
return
|
||||
}
|
||||
cal := &zugriff.Calendar
|
||||
objs, err := h.store.ListObjects(r.Context(), cal.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list objects failed")
|
||||
return
|
||||
}
|
||||
|
||||
out := ical.NewCalendar()
|
||||
out.Props.SetText(ical.PropVersion, "2.0")
|
||||
out.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
|
||||
// VTIMEZONE-Blöcke nur einmal je TZID mitnehmen – jedes einzeln
|
||||
// gespeicherte Objekt trägt seine eigenen mit, sonst stünde dieselbe
|
||||
// Definition mehrfach in der Ausgabedatei.
|
||||
seenTZ := map[string]bool{}
|
||||
for _, o := range objs {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
c, err := dec.Decode()
|
||||
if err != nil {
|
||||
continue // eine kaputte Einzeldatei darf den Export nicht abbrechen
|
||||
}
|
||||
for _, child := range c.Children {
|
||||
if child.Name == ical.CompTimezone {
|
||||
tzid, _ := child.Props.Text(ical.PropTimezoneID)
|
||||
if seenTZ[tzid] {
|
||||
continue
|
||||
}
|
||||
seenTZ[tzid] = true
|
||||
}
|
||||
out.Children = append(out.Children, child)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(out); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "encode calendar failed")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.ics"`, sanitizeFilename(cal.Name)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/import – eine hochgeladene .ics-Datei
|
||||
// einlesen. Jede darin enthaltene VEVENT/VTODO-Komponente wird ein eigenes
|
||||
// Objekt, wie beim einzelnen PUT über CalDAV – nur eben viele auf einmal.
|
||||
func (h *APIHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
// Hineinschreiben darf nur, wer schreiben darf.
|
||||
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, true)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
if err == ErrReadOnly {
|
||||
response.Error(w, http.StatusForbidden, "calendar is shared read-only")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load calendar failed")
|
||||
return
|
||||
}
|
||||
|
||||
cal := &zugriff.Calendar
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MiB – reicht für sehr große Kalender
|
||||
src, err := ical.NewDecoder(r.Body).Decode()
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid .ics file")
|
||||
return
|
||||
}
|
||||
|
||||
var tzBlocks []*ical.Component
|
||||
for _, child := range src.Children {
|
||||
if child.Name == ical.CompTimezone {
|
||||
tzBlocks = append(tzBlocks, child)
|
||||
}
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, child := range src.Children {
|
||||
if child.Name != "VEVENT" && child.Name != "VTODO" {
|
||||
continue
|
||||
}
|
||||
uid, err := child.Props.Text("UID")
|
||||
if err != nil || uid == "" {
|
||||
continue // ohne UID kein gültiges CalDAV-Objekt – überspringen statt den ganzen Import abzubrechen
|
||||
}
|
||||
|
||||
single := ical.NewCalendar()
|
||||
single.Props.SetText(ical.PropVersion, "2.0")
|
||||
single.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
single.Children = append(single.Children, tzBlocks...)
|
||||
single.Children = append(single.Children, child)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(single); err != nil {
|
||||
continue
|
||||
}
|
||||
data := buf.String()
|
||||
start, end := timeRange(single)
|
||||
if _, err := h.store.PutObject(r.Context(), cal.ID, uid, child.Name, data, etagFor(data), start, end); err != nil {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]int{"imported": imported})
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '"':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
if name == "" {
|
||||
return "kalender"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Pfadschema. Zwei Eigenheiten der Bibliothek bestimmen es:
|
||||
//
|
||||
// 1. Zurückgegebene Pfade (CurrentUserPrincipal, CalendarHomeSetPath,
|
||||
// Calendar.Path, ...) vergleicht sie direkt mit r.URL.Path – also
|
||||
// inklusive Präfix. Deshalb tragen alle hier gebauten Pfade davPrefix
|
||||
// von Anfang an mit.
|
||||
//
|
||||
// 2. Welche Art Ressource ein Pfad ist, entscheidet sie allein an der
|
||||
// Anzahl der Pfadsegmente hinter dem Präfix (resourceTypeAtPath in
|
||||
// server.go) – nicht am Namen. Ein Segment = Principal, zwei = Calendar-
|
||||
// Home-Set, drei = Kalender, vier = Objekt. Deshalb steckt der
|
||||
// Nutzername beim Principal *in* dem einen Segment ("principals-max"),
|
||||
// nicht als eigenes Unterverzeichnis – sonst läse die Bibliothek ihn als
|
||||
// Calendar-Home-Set und die PROPFIND-Antwort bliebe leer.
|
||||
//
|
||||
// /caldav/principals-{username}/ – 1 Segment
|
||||
// /caldav/calendars/{username}/ – 2 Segmente, Calendar-Home-Set
|
||||
// /caldav/calendars/{username}/{calendar-id}/ – 3 Segmente, ein Kalender
|
||||
// /caldav/calendars/{username}/{calendar-id}/{uid}.ics – 4 Segmente, ein Objekt
|
||||
const davPrefix = "/caldav"
|
||||
|
||||
type contextKey int
|
||||
|
||||
const keyUser contextKey = iota
|
||||
|
||||
func contextWithUser(ctx context.Context, u *auth.User) context.Context {
|
||||
return context.WithValue(ctx, keyUser, u)
|
||||
}
|
||||
|
||||
func userFromContext(ctx context.Context) (*auth.User, error) {
|
||||
u, _ := ctx.Value(keyUser).(*auth.User)
|
||||
if u == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusUnauthorized, errors.New("no authenticated user"))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Backend implementiert caldav.Backend. Phase 1: jeder Nutzer sieht nur die
|
||||
// eigenen Kalender – Freigaben (cal_shares) kommen erst in einer späteren
|
||||
// Phase, siehe kalender-plan.md.
|
||||
type Backend struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewBackend(store *Store) *Backend {
|
||||
return &Backend{store: store}
|
||||
}
|
||||
|
||||
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return davPrefix + "/principals-" + u.Username + "/", nil
|
||||
}
|
||||
|
||||
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return davPrefix + "/calendars/" + u.Username + "/", nil
|
||||
}
|
||||
|
||||
// calendarPath baut die Adresse eines Kalenders. ref ist sein Pfadsegment –
|
||||
// die vom Client gewählte Adresse, sonst die UUID (siehe Calendar.Ref).
|
||||
func calendarPath(username, ref string) string {
|
||||
return davPrefix + "/calendars/" + username + "/" + ref + "/"
|
||||
}
|
||||
|
||||
func objectPath(username, ref, uid string) string {
|
||||
return calendarPath(username, ref) + uid + ".ics"
|
||||
}
|
||||
|
||||
// parseCalendarPath erwartet "/caldav/calendars/{username}/{segment}(/...)"
|
||||
// und liefert das Pfadsegment des Kalenders – nicht dessen UUID: Legt ein
|
||||
// Client per MKCALENDAR einen Kalender an, sucht **er** die Adresse aus
|
||||
// ("…/calendars/max/arbeit/"). Aufgelöst wird sie in resolveCalendar.
|
||||
//
|
||||
// Der Benutzername im Pfad wird hier nicht geprüft; das tut der Handler,
|
||||
// bevor die Anfrage überhaupt hier ankommt (pathBelongsTo).
|
||||
func parseCalendarPath(p string) (ref string, rest string, err error) {
|
||||
// Kommt vor: ein Browser macht GET auf eine Sammlung (Principal,
|
||||
// Home-Set) statt PROPFIND – go-webdav reicht das trotzdem unverändert
|
||||
// an GetCalendarObject durch, siehe backend.go-Kommentar zum Pfadschema.
|
||||
// Ohne diese Prüfung würde z. B. "/caldav/principals-max/" das Wort
|
||||
// "caldav" für eine Kalender-ID halten und einen kryptischen
|
||||
// UUID-Parsing-Fehler nach außen geben.
|
||||
const calPrefix = davPrefix + "/calendars/"
|
||||
if !strings.HasPrefix(p, calPrefix) {
|
||||
return "", "", fmt.Errorf("calendar: not a calendar path: %q", p)
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(p, calPrefix), "/", 3)
|
||||
if len(parts) < 2 || parts[1] == "" {
|
||||
return "", "", fmt.Errorf("calendar: malformed path %q", p)
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
rest = parts[2]
|
||||
}
|
||||
return parts[1], rest, nil
|
||||
}
|
||||
|
||||
// resolveCalendar ist der **eine** Weg von einer Adresse zu einem Kalender –
|
||||
// und damit die Stelle, an der die Rechteprüfung sitzt. Jede CalDAV-Operation
|
||||
// läuft hier durch; eine zweite Auflösung daneben wäre eine Tür ohne Schloss.
|
||||
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Access, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ref, _, err := parseCalendarPath(p)
|
||||
if err != nil {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
cal, err := b.store.ResolveRef(ctx, u.ID, ref)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, cal, nil
|
||||
}
|
||||
|
||||
// resolveWritable: dasselbe, aber der Aufrufer will schreiben. Ein nur lesend
|
||||
// freigegebener Kalender darf sichtbar bleiben und trotzdem ablehnen – 403
|
||||
// verrät hier nichts, was der Anfragende nicht ohnehin sieht.
|
||||
func (b *Backend) resolveWritable(ctx context.Context, p string) (*auth.User, *Access, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !cal.CanWrite() {
|
||||
return nil, nil, webdav.NewHTTPError(http.StatusForbidden,
|
||||
errors.New("calendar is shared read-only"))
|
||||
}
|
||||
return u, cal, nil
|
||||
}
|
||||
|
||||
func toCalDAVCalendar(username string, c *Access) caldav.Calendar {
|
||||
comps := []string{"VEVENT"}
|
||||
if c.SupportsTodo {
|
||||
comps = append(comps, "VTODO")
|
||||
}
|
||||
// Ein fremder Kalender trägt den Eigentümer im Namen. Ohne das stehen in
|
||||
// der Liste des Clients zwei „Privat" nebeneinander, und welches welches
|
||||
// ist, weiß niemand.
|
||||
name := c.Name
|
||||
if c.Shared && c.OwnerName != "" {
|
||||
name = c.Name + " (" + c.OwnerName + ")"
|
||||
}
|
||||
return caldav.Calendar{
|
||||
Path: calendarPath(username, c.Ref()),
|
||||
Name: name,
|
||||
Description: c.Description,
|
||||
MaxResourceSize: 5 * 1024 * 1024,
|
||||
SupportedComponentSet: comps,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) error {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := cal.Name
|
||||
if name == "" {
|
||||
name = "Kalender"
|
||||
}
|
||||
// Die Adresse sucht der Client aus (MKCOL/MKCALENDAR). Sie zu ersetzen
|
||||
// wäre der eine Fehler, den man hier machen kann: Der Client legt an
|
||||
// unter "…/arbeit/", fragt gleich darauf dieselbe Adresse ab – und bekäme
|
||||
// ein 404, weil der Kalender in Wahrheit unter seiner UUID läge.
|
||||
ref, rest, err := parseCalendarPath(cal.Path)
|
||||
if err != nil || rest != "" {
|
||||
return webdav.NewHTTPError(http.StatusForbidden,
|
||||
fmt.Errorf("calendar: cannot create a calendar at %q", cal.Path))
|
||||
}
|
||||
created, err := b.store.CreateCalendarAt(ctx, u.ID, ref, name, "", cal.Description)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
// RFC 4918: Anlegen, wo schon etwas liegt, ist 405 – nicht stilles
|
||||
// Überschreiben.
|
||||
return webdav.NewHTTPError(http.StatusMethodNotAllowed,
|
||||
fmt.Errorf("calendar: %q already exists", ref))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("create calendar: %w", err)
|
||||
}
|
||||
cal.Path = calendarPath(u.Username, created.Ref())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||||
u, err := userFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := b.store.EnsureDefaultCalendar(ctx, u.ID); err != nil {
|
||||
return nil, fmt.Errorf("ensure default calendar: %w", err)
|
||||
}
|
||||
|
||||
all, err := b.store.ListAccessible(ctx, u.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list calendars: %w", err)
|
||||
}
|
||||
out := make([]caldav.Calendar, len(all))
|
||||
for i := range all {
|
||||
out[i] = toCalDAVCalendar(u.Username, &all[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *Backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := toCalDAVCalendar(u.Username, cal)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func toCalendarObject(username, calRef string, o *Object) (*caldav.CalendarObject, error) {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
data, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode object %s: %w", o.UID, err)
|
||||
}
|
||||
return &caldav.CalendarObject{
|
||||
Path: objectPath(username, calRef, o.UID),
|
||||
ModTime: o.UpdatedAt,
|
||||
ContentLength: int64(len(o.Data)),
|
||||
ETag: o.ETag,
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) GetCalendarObject(ctx context.Context, p string, _ *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, rest, err := parseCalendarPath(p)
|
||||
if err != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
uid := strings.TrimSuffix(rest, ".ics")
|
||||
obj, err := b.store.GetObjectByUID(ctx, cal.ID, uid)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toCalendarObject(u.Username, cal.Ref(), obj)
|
||||
}
|
||||
|
||||
func (b *Backend) ListCalendarObjects(ctx context.Context, p string, _ *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
||||
u, cal, err := b.resolveCalendar(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objs, err := b.store.ListObjects(ctx, cal.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list objects: %w", err)
|
||||
}
|
||||
out := make([]caldav.CalendarObject, 0, len(objs))
|
||||
for i := range objs {
|
||||
co, err := toCalendarObject(u.Username, cal.Ref(), &objs[i])
|
||||
if err != nil {
|
||||
// Eine kaputte Einzeldatei darf nicht die gesamte Synchronisation
|
||||
// blockieren; der Client bekommt die übrigen Termine trotzdem.
|
||||
continue
|
||||
}
|
||||
out = append(out, *co)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *Backend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||
all, err := b.ListCalendarObjects(ctx, p, &query.CompRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return caldav.Filter(query, all)
|
||||
}
|
||||
|
||||
// firstUID/firstComponent lesen UID und Komponententyp (VEVENT/VTODO) aus
|
||||
// dem ersten Termin-/Aufgaben-Kind der .ics-Datei. VTIMEZONE-Kinder werden
|
||||
// übersprungen, die tragen keine UID.
|
||||
func firstUIDAndComponent(cal *ical.Calendar) (uid, component string, err error) {
|
||||
for _, child := range cal.Children {
|
||||
if child.Name != "VEVENT" && child.Name != "VTODO" {
|
||||
continue
|
||||
}
|
||||
v, err := child.Props.Text("UID")
|
||||
if err != nil || v == "" {
|
||||
return "", "", fmt.Errorf("calendar object missing UID")
|
||||
}
|
||||
return v, child.Name, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("calendar object has no VEVENT/VTODO component")
|
||||
}
|
||||
|
||||
func timeRange(cal *ical.Calendar) (start, end *time.Time) {
|
||||
for _, ev := range cal.Events() {
|
||||
if t, err := ev.DateTimeStart(time.Local); err == nil {
|
||||
start = &t
|
||||
}
|
||||
if t, err := ev.DateTimeEnd(time.Local); err == nil {
|
||||
end = &t
|
||||
}
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func encodeICal(cal *ical.Calendar) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||||
return "", fmt.Errorf("encode object: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func etagFor(data string) string {
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||
u, calRow, err := b.resolveWritable(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, component, err := firstUIDAndComponent(cal)
|
||||
if err != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
existing, err := b.store.GetObjectByUID(ctx, calRow.ID, uid)
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if opts != nil {
|
||||
if opts.IfNoneMatch.IsSet() && opts.IfNoneMatch.IsWildcard() && existing != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object already exists"))
|
||||
}
|
||||
if opts.IfMatch.IsSet() {
|
||||
if existing == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object not found"))
|
||||
}
|
||||
ok, err := opts.IfMatch.MatchETag(existing.ETag)
|
||||
if err != nil || !ok {
|
||||
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("etag mismatch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag := etagFor(data)
|
||||
start, end := timeRange(cal)
|
||||
|
||||
obj, err := b.store.PutObject(ctx, calRow.ID, uid, component, data, etag, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("put object: %w", err)
|
||||
}
|
||||
return toCalendarObject(u.Username, calRow.Ref(), obj)
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error {
|
||||
_, calRow, err := b.resolveWritable(ctx, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, rest, err := parseCalendarPath(p)
|
||||
if err != nil {
|
||||
return webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
uid := strings.TrimSuffix(rest, ".ics")
|
||||
if err := b.store.DeleteObject(ctx, calRow.ID, uid); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return webdav.NewHTTPError(http.StatusNotFound, err)
|
||||
}
|
||||
return fmt.Errorf("delete object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Kalender verwalten – anlegen, umbenennen, umfärben, löschen –, und zwar
|
||||
// vom Client aus.
|
||||
//
|
||||
// go-webdav deckt davon nur MKCOL ab. MKCALENDAR (RFC 4791, das Verb, das
|
||||
// Apple-Clients benutzen) kennt es gar nicht, PROPPATCH beantwortet es mit
|
||||
// 501, und ein DELETE auf einen Kalender reicht es an das Löschen eines
|
||||
// *Termins* weiter – das findet dort natürlich nichts und endet im 404.
|
||||
//
|
||||
// Alle drei landen deshalb hier, bevor die Bibliothek sie zu sehen bekommt.
|
||||
|
||||
// Eigenschaften, die ein Client an einem Kalender ändern darf. Alles andere
|
||||
// wird höflich mit 403 beantwortet, statt es stillschweigend zu schlucken:
|
||||
// Ein Client, der glaubt, seine Änderung sei angekommen, ist schlimmer dran
|
||||
// als einer, der eine klare Absage bekommt.
|
||||
var (
|
||||
propDisplayName = xml.Name{Space: "DAV:", Local: "displayname"}
|
||||
propCalColor = xml.Name{Space: nsApple, Local: "calendar-color"}
|
||||
propCalDesc = xml.Name{Space: "urn:ietf:params:xml:ns:caldav", Local: "calendar-description"}
|
||||
)
|
||||
|
||||
// mkcalendarReq deckt MKCALENDAR und den erweiterten MKCOL ab: Beide tragen
|
||||
// die Anfangswerte in <set><prop>.
|
||||
type mkcalendarReq struct {
|
||||
Sets []struct {
|
||||
Prop struct {
|
||||
DisplayName string `xml:"DAV: displayname"`
|
||||
Color string `xml:"http://apple.com/ns/ical/ calendar-color"`
|
||||
Description string `xml:"urn:ietf:params:xml:ns:caldav calendar-description"`
|
||||
} `xml:"prop"`
|
||||
} `xml:"set"`
|
||||
}
|
||||
|
||||
// handleMkcalendar legt einen Kalender unter der Adresse an, die der Client
|
||||
// gewählt hat.
|
||||
func (h *Handler) handleMkcalendar(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
http.Error(w, "calendars can only be created directly in the calendar home set",
|
||||
http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req mkcalendarReq
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "malformed MKCALENDAR body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
var name, color, desc string
|
||||
for _, set := range req.Sets {
|
||||
if set.Prop.DisplayName != "" {
|
||||
name = set.Prop.DisplayName
|
||||
}
|
||||
if set.Prop.Color != "" {
|
||||
color = set.Prop.Color
|
||||
}
|
||||
if set.Prop.Description != "" {
|
||||
desc = set.Prop.Description
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
// Ohne Namen wäre der Kalender in der Oberfläche eine leere Zeile.
|
||||
// Die gewählte Adresse ist der beste Anhaltspunkt, den es gibt.
|
||||
name = ref
|
||||
}
|
||||
|
||||
created, err := h.store.CreateCalendarAt(r.Context(), user.ID, ref, name, fromAppleColor(color), desc)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
http.Error(w, "a calendar already exists at this address", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not create calendar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Location", calendarPath(user.Username, created.Ref()))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// handleProppatch ändert Name, Farbe und Beschreibung eines Kalenders.
|
||||
//
|
||||
// Die Antwort ist immer ein 207 mit einem Status **je Eigenschaft** – auch
|
||||
// wenn alles geklappt hat. Ein nacktes 200 wäre hier falsch: PROPPATCH kann
|
||||
// teilweise gelingen, und der Client will wissen, welcher Teil.
|
||||
func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||||
gesetzt, abgelehnt, err := parseProppatch(body)
|
||||
if err != nil {
|
||||
http.Error(w, "malformed PROPPATCH body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var name, color, desc *string
|
||||
for n, v := range gesetzt {
|
||||
switch n {
|
||||
case propDisplayName:
|
||||
s := v
|
||||
name = &s
|
||||
case propCalColor:
|
||||
s := fromAppleColor(v)
|
||||
if s == "" {
|
||||
// Unlesbare Farbe: nicht raten, sondern absagen.
|
||||
abgelehnt = append(abgelehnt, n)
|
||||
continue
|
||||
}
|
||||
color = &s
|
||||
case propCalDesc:
|
||||
s := v
|
||||
desc = &s
|
||||
default:
|
||||
abgelehnt = append(abgelehnt, n)
|
||||
}
|
||||
}
|
||||
|
||||
if name != nil || color != nil || desc != nil {
|
||||
if _, err := h.store.UpdateCalendar(r.Context(), user.ID, cal.ID, name, color, desc, nil); err != nil {
|
||||
http.Error(w, "could not update calendar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<multistatus xmlns="DAV:"><response><href>` + xmlText(r.URL.Path) + `</href>`)
|
||||
writePropstat := func(namen []xml.Name, status string) {
|
||||
if len(namen) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("<propstat><prop>")
|
||||
for _, n := range namen {
|
||||
if n.Space == "DAV:" {
|
||||
fmt.Fprintf(&b, "<%s/>", xmlName(n.Local))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space))
|
||||
}
|
||||
}
|
||||
b.WriteString("</prop><status>" + status + "</status></propstat>")
|
||||
}
|
||||
var ok []xml.Name
|
||||
for n := range gesetzt {
|
||||
if !enthalten(abgelehnt, n) {
|
||||
ok = append(ok, n)
|
||||
}
|
||||
}
|
||||
writePropstat(ok, "HTTP/1.1 200 OK")
|
||||
writePropstat(abgelehnt, "HTTP/1.1 403 Forbidden")
|
||||
b.WriteString("</response></multistatus>")
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, b.String())
|
||||
}
|
||||
|
||||
func enthalten(list []xml.Name, n xml.Name) bool {
|
||||
for _, x := range list {
|
||||
if x == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseProppatch liest <set> und <remove> eines propertyupdate.
|
||||
//
|
||||
// Entfernen wird nicht unterstützt: Ein Kalender ohne Namen oder ohne Farbe
|
||||
// hat keine sinnvolle Entsprechung – die Felder haben immer einen Wert.
|
||||
// Solche Eigenschaften landen unter „abgelehnt".
|
||||
func parseProppatch(body []byte) (gesetzt map[xml.Name]string, abgelehnt []xml.Name, err error) {
|
||||
gesetzt = map[xml.Name]string{}
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
|
||||
var modus string // "set" | "remove" | ""
|
||||
inProp := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch {
|
||||
case t.Name.Space == "DAV:" && (t.Name.Local == "set" || t.Name.Local == "remove"):
|
||||
modus = t.Name.Local
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "prop":
|
||||
inProp = true
|
||||
case inProp && modus == "set":
|
||||
var wert string
|
||||
if err := dec.DecodeElement(&wert, &t); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
gesetzt[t.Name] = strings.TrimSpace(wert)
|
||||
case inProp && modus == "remove":
|
||||
abgelehnt = append(abgelehnt, t.Name)
|
||||
if err := dec.Skip(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||||
inProp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return gesetzt, abgelehnt, nil
|
||||
}
|
||||
|
||||
// handleDeleteCalendar löscht einen Kalender **mitsamt allen Terminen** –
|
||||
// das ist in WebDAV die Bedeutung eines DELETE auf eine Sammlung.
|
||||
func (h *Handler) handleDeleteCalendar(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
return false // kein Kalender, sondern ein Termin: weiter zur Bibliothek
|
||||
}
|
||||
cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if err := h.store.DeleteCalendar(r.Context(), user.ID, cal.ID); err != nil {
|
||||
http.Error(w, "could not delete calendar", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return true
|
||||
}
|
||||
|
||||
// fromAppleColor nimmt "#RRGGBB" oder "#RRGGBBAA" entgegen und gibt die
|
||||
// Schreibweise zurück, die intern gilt. Der Alpha-Anteil fällt weg: Ein
|
||||
// halbdurchsichtiger Kalender ist in der Oberfläche nicht darstellbar, und
|
||||
// ihn zu behalten hieße, ihn irgendwo später doch zu verlieren.
|
||||
func fromAppleColor(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 9 && strings.HasPrefix(s, "#") {
|
||||
s = s[:7]
|
||||
}
|
||||
return normalizeHex(s)
|
||||
}
|
||||
|
||||
func xmlText(s string) string {
|
||||
var b bytes.Buffer
|
||||
xml.EscapeText(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Terminfarben und die Synchronisierung.
|
||||
//
|
||||
// RFC 7986 schreibt für COLOR einen **CSS3-Farbnamen** vor ("tomato"), keinen
|
||||
// Hex-Wert. Genau daran hängt, ob die Farbe eines Termins auf dem Telefon
|
||||
// ankommt: DAVx5 liest die Eigenschaft über eine Namenstabelle und wirft
|
||||
// alles weg, was es dort nicht findet – ein "#ea4335" landet also nirgends,
|
||||
// und im Samsung-Kalender bliebe der Termin farblos.
|
||||
//
|
||||
// Deshalb zwei Eigenschaften nebeneinander:
|
||||
//
|
||||
// COLOR:tomato – der Standard, damit fremde Clients es verstehen
|
||||
// X-AETHER-COLOR:#ea4335 – der genaue Wert, damit *hier* die Palette stimmt
|
||||
//
|
||||
// Beim Lesen gewinnt X-AETHER-COLOR, wenn vorhanden; sonst wird der Name in
|
||||
// Hex zurückübersetzt. So geht in beide Richtungen nichts verloren: Ein
|
||||
// Termin, den das Telefon einfärbt, kommt hier mit seiner Farbe an, und
|
||||
// unsere Palette bleibt auf dem Telefon erkennbar – wenn auch auf den
|
||||
// nächstgelegenen benannten Ton gerundet.
|
||||
const (
|
||||
propColor = "COLOR"
|
||||
propColorX = "X-AETHER-COLOR"
|
||||
)
|
||||
|
||||
// css3Colors ist eine Auswahl aus der CSS3-Namensliste – über den Farbkreis
|
||||
// verteilt, dazu die Töne, die Kalender-Clients üblicherweise selbst
|
||||
// vergeben. Nicht die vollständige Liste (147 Namen): Für "nächstgelegener
|
||||
// Ton" bringt jeder weitere Zwischenwert kaum etwas, und die Liste will
|
||||
// gepflegt werden.
|
||||
var css3Colors = map[string]string{
|
||||
"black": "#000000", "dimgray": "#696969", "gray": "#808080",
|
||||
"darkgray": "#a9a9a9", "silver": "#c0c0c0", "white": "#ffffff",
|
||||
"maroon": "#800000", "brown": "#a52a2a", "firebrick": "#b22222",
|
||||
"crimson": "#dc143c", "red": "#ff0000", "tomato": "#ff6347",
|
||||
"coral": "#ff7f50", "orangered": "#ff4500", "darkorange": "#ff8c00",
|
||||
"orange": "#ffa500", "goldenrod": "#daa520", "gold": "#ffd700",
|
||||
"yellow": "#ffff00", "olive": "#808000", "yellowgreen": "#9acd32",
|
||||
"darkolivegreen": "#556b2f", "lawngreen": "#7cfc00", "limegreen": "#32cd32",
|
||||
"green": "#008000", "darkgreen": "#006400", "seagreen": "#2e8b57",
|
||||
"mediumseagreen": "#3cb371", "teal": "#008080", "darkcyan": "#008b8b",
|
||||
"lightseagreen": "#20b2aa", "turquoise": "#40e0d0", "cyan": "#00ffff",
|
||||
"deepskyblue": "#00bfff", "steelblue": "#4682b4", "cornflowerblue": "#6495ed",
|
||||
"royalblue": "#4169e1", "dodgerblue": "#1e90ff", "blue": "#0000ff",
|
||||
"mediumblue": "#0000cd", "navy": "#000080", "slateblue": "#6a5acd",
|
||||
"mediumpurple": "#9370db", "blueviolet": "#8a2be2", "darkviolet": "#9400d3",
|
||||
"purple": "#800080", "orchid": "#da70d6", "magenta": "#ff00ff",
|
||||
"mediumvioletred": "#c71585", "deeppink": "#ff1493", "hotpink": "#ff69b4",
|
||||
"pink": "#ffc0cb", "slategray": "#708090", "lightslategray": "#778899",
|
||||
"tan": "#d2b48c", "peru": "#cd853f", "sienna": "#a0522d",
|
||||
}
|
||||
|
||||
func parseHex(s string) (r, g, b int, ok bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(s), "#"))
|
||||
if len(s) == 3 { // Kurzform #abc
|
||||
s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]})
|
||||
}
|
||||
if len(s) == 8 { // mit Alpha – der Kanal interessiert hier nicht
|
||||
s = s[:6]
|
||||
}
|
||||
if len(s) != 6 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return int(v>>16) & 0xff, int(v>>8) & 0xff, int(v) & 0xff, true
|
||||
}
|
||||
|
||||
// nearestCSS3 findet den nächstgelegenen benannten Ton. Der Abstand wird im
|
||||
// RGB-Würfel gemessen – grob, aber für "welcher Name kommt dem am nächsten"
|
||||
// vollkommen ausreichend, und ohne Farbmetrik-Bibliothek im Gepäck.
|
||||
func nearestCSS3(hex string) string {
|
||||
r, g, b, ok := parseHex(hex)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
best, bestDist := "", 1<<30
|
||||
for name, ref := range css3Colors {
|
||||
rr, rg, rb, _ := parseHex(ref)
|
||||
d := (r-rr)*(r-rr) + (g-rg)*(g-rg) + (b-rb)*(b-rb)
|
||||
// Bei gleichem Abstand der alphabetisch erste Name – sonst wechselt
|
||||
// die Ausgabe zwischen zwei Läufen, weil map-Reihenfolge zufällig ist.
|
||||
if d < bestDist || (d == bestDist && name < best) {
|
||||
best, bestDist = name, d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// css3ToHex übersetzt zurück. Unbekannter Name: leer – der Termin erbt dann
|
||||
// die Kalenderfarbe, was allemal besser ist als eine geratene.
|
||||
func css3ToHex(name string) string {
|
||||
return css3Colors[strings.ToLower(strings.TrimSpace(name))]
|
||||
}
|
||||
|
||||
// normalizeHex bringt eine Farbe auf die Schreibweise "#rrggbb".
|
||||
func normalizeHex(s string) string {
|
||||
r, g, b, ok := parseHex(s)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/teambition/rrule-go"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
|
||||
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
||||
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
||||
const maxOccurrences = 750
|
||||
|
||||
// RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche
|
||||
// anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer
|
||||
// Anzahl oder an einem Datum.
|
||||
//
|
||||
// Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im
|
||||
// August"). Solche Regeln werden gelesen und unverändert behalten, aber
|
||||
// nicht in dieses Formular gezwängt – siehe recurrenceFromRule.
|
||||
type RecurrenceDTO struct {
|
||||
Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY
|
||||
Interval int `json:"interval"` // >= 1
|
||||
Count int `json:"count,omitempty"`
|
||||
Until *time.Time `json:"until,omitempty"`
|
||||
// WEEKLY: an welchen Wochentagen (MO…SU). Leer = Wochentag des Starts.
|
||||
Byday []string `json:"byday,omitempty"`
|
||||
// MONTHLY/YEARLY: "DAY" wiederholt am festen Kalendertag (am 4.),
|
||||
// "WEEKDAY" am n-ten Wochentag (am 1. Freitag). Leer zählt als DAY.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Nur bei Mode=WEEKDAY und nur zum **Anzeigen**: der wievielte (−1 =
|
||||
// letzter) und welcher Wochentag. Beim Schreiben rechnet der Server
|
||||
// beides aus dem Startdatum aus – zwei Quellen für dieselbe Angabe wären
|
||||
// eine zu viel, und sie liefen irgendwann auseinander.
|
||||
Nth int `json:"nth,omitempty"`
|
||||
Weekday string `json:"weekday,omitempty"`
|
||||
}
|
||||
|
||||
var weekdayCodes = []string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
|
||||
|
||||
// weekdayIndex: 0 = Montag. Gos time.Weekday fängt beim Sonntag an, rrule-go
|
||||
// beim Montag – diese eine Zeile ist der Übersetzer dazwischen.
|
||||
func weekdayIndex(t time.Time) int { return (int(t.Weekday()) + 6) % 7 }
|
||||
|
||||
func weekdayCode(t time.Time) string { return weekdayCodes[weekdayIndex(t)] }
|
||||
|
||||
func rruleWeekday(code string) (rrule.Weekday, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||
case "MO":
|
||||
return rrule.MO, true
|
||||
case "TU":
|
||||
return rrule.TU, true
|
||||
case "WE":
|
||||
return rrule.WE, true
|
||||
case "TH":
|
||||
return rrule.TH, true
|
||||
case "FR":
|
||||
return rrule.FR, true
|
||||
case "SA":
|
||||
return rrule.SA, true
|
||||
case "SU":
|
||||
return rrule.SU, true
|
||||
}
|
||||
return rrule.MO, false
|
||||
}
|
||||
|
||||
// nthOfMonth: der wievielte Wochentag seiner Art der Tag im Monat ist.
|
||||
//
|
||||
// Der fünfte wird zu −1 ("letzter"), und das ist kein Schönheitsfehler: Einen
|
||||
// fünften Freitag hat nicht jeder Monat. FREQ=MONTHLY;BYDAY=5FR ließe die
|
||||
// Reihe in etwa der Hälfte aller Monate stillschweigend ausfallen – ein
|
||||
// Fehler, den man erst Wochen später bemerkt.
|
||||
func nthOfMonth(t time.Time) int {
|
||||
if n := (t.Day()-1)/7 + 1; n < 5 {
|
||||
return n
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
|
||||
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
|
||||
// ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt
|
||||
// im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur,
|
||||
// was das Formular auch wirklich kennt.
|
||||
type EventDTO struct {
|
||||
UID string `json:"uid"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
// Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten
|
||||
// belegten** Tages – in beide Richtungen gleich. Die exklusive
|
||||
// DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig
|
||||
// hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag.
|
||||
Start time.Time `json:"start"`
|
||||
End *time.Time `json:"end,omitempty"`
|
||||
AllDay bool `json:"all_day"`
|
||||
// Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur
|
||||
// für diesen einen Termin.
|
||||
Color string `json:"color,omitempty"`
|
||||
// Recurring: es hängt überhaupt eine Wiederholung dran.
|
||||
// Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt.
|
||||
// Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum
|
||||
// Bearbeiten" – dann ist Recurring true und Recurrence nil.
|
||||
Recurring bool `json:"recurring"`
|
||||
Recurrence *RecurrenceDTO `json:"recurrence,omitempty"`
|
||||
// Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen*
|
||||
// Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular
|
||||
// bearbeitet immer die Reihe und braucht daher deren Anfang.
|
||||
SeriesStart *time.Time `json:"series_start,omitempty"`
|
||||
SeriesEnd *time.Time `json:"series_end,omitempty"`
|
||||
ETag string `json:"etag"`
|
||||
}
|
||||
|
||||
func firstEventComponent(cal *ical.Calendar) *ical.Component {
|
||||
for _, child := range cal.Children {
|
||||
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
|
||||
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
|
||||
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
|
||||
func isDateOnly(prop *ical.Prop) bool {
|
||||
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
|
||||
}
|
||||
|
||||
var freqToString = map[rrule.Frequency]string{
|
||||
rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY",
|
||||
rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY",
|
||||
}
|
||||
|
||||
var stringToFreq = map[string]rrule.Frequency{
|
||||
"DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY,
|
||||
"MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY,
|
||||
}
|
||||
|
||||
// recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder
|
||||
// gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig
|
||||
// ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb
|
||||
// verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das
|
||||
// Speichern die verlorenen Teile still weg.
|
||||
func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
|
||||
if opt == nil {
|
||||
return nil
|
||||
}
|
||||
freq, ok := freqToString[opt.Freq]
|
||||
if !ok {
|
||||
return nil // stündlich und feiner bietet die Oberfläche nicht an
|
||||
}
|
||||
// Diese Bestandteile kennt das Formular in keiner Betriebsart.
|
||||
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
||||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
|
||||
len(opt.Byeaster) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||||
|
||||
switch freq {
|
||||
case "DAILY":
|
||||
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
case "WEEKLY":
|
||||
if len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||||
return nil
|
||||
}
|
||||
for _, wd := range opt.Byweekday {
|
||||
if wd.N() != 0 {
|
||||
return nil // "jeder 2. Dienstag" ist keine Wochenregel
|
||||
}
|
||||
dto.Byday = append(dto.Byday, weekdayCodes[wd.Day()])
|
||||
}
|
||||
if len(dto.Byday) == 0 {
|
||||
dto.Byday = []string{weekdayCode(start)}
|
||||
}
|
||||
|
||||
case "MONTHLY", "YEARLY":
|
||||
// Bei jährlich darf der Monat genannt sein – aber nur der des Starts.
|
||||
if freq == "YEARLY" {
|
||||
if len(opt.Bymonth) > 1 || (len(opt.Bymonth) == 1 && opt.Bymonth[0] != int(start.Month())) {
|
||||
return nil
|
||||
}
|
||||
} else if len(opt.Bymonth) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(opt.Byweekday) == 1 && opt.Byweekday[0].N() != 0:
|
||||
// „am n-ten Wochentag". Nur bearbeitbar, wenn die Regel auch
|
||||
// wirklich auf den Starttag zeigt – sonst zeigte das Formular
|
||||
// etwas anderes an, als gespeichert ist.
|
||||
wd := opt.Byweekday[0]
|
||||
if len(opt.Bymonthday) > 0 || wd.Day() != weekdayIndex(start) || wd.N() != nthOfMonth(start) {
|
||||
return nil
|
||||
}
|
||||
dto.Mode, dto.Nth, dto.Weekday = "WEEKDAY", wd.N(), weekdayCode(start)
|
||||
case len(opt.Byweekday) == 0:
|
||||
// „am festen Kalendertag" – ausgeschrieben oder implizit.
|
||||
if len(opt.Bymonthday) > 1 || (len(opt.Bymonthday) == 1 && opt.Bymonthday[0] != start.Day()) {
|
||||
return nil
|
||||
}
|
||||
dto.Mode = "DAY"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if dto.Interval < 1 {
|
||||
dto.Interval = 1
|
||||
}
|
||||
if !opt.Until.IsZero() {
|
||||
u := opt.Until.UTC()
|
||||
dto.Until = &u
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return nil, errNoEventComponent
|
||||
}
|
||||
|
||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||
description, _ := ev.Props.Text(ical.PropDescription)
|
||||
location, _ := ev.Props.Text(ical.PropLocation)
|
||||
|
||||
// Farbe: unser genauer Wert schlägt den benannten Ton, siehe color.go.
|
||||
// Ein Termin, den ein anderer Client eingefärbt hat, bringt nur COLOR
|
||||
// mit – der wird dann übersetzt.
|
||||
color, _ := ev.Props.Text(propColorX)
|
||||
if color == "" {
|
||||
if name, _ := ev.Props.Text(propColor); name != "" {
|
||||
if strings.HasPrefix(strings.TrimSpace(name), "#") {
|
||||
color = normalizeHex(name)
|
||||
} else {
|
||||
color = css3ToHex(name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
color = normalizeHex(color)
|
||||
}
|
||||
|
||||
dto := &EventDTO{
|
||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||
Color: color, ETag: o.ETag,
|
||||
}
|
||||
|
||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||
if startProp != nil {
|
||||
dto.AllDay = isDateOnly(startProp)
|
||||
if t, err := startProp.DateTime(time.UTC); err == nil {
|
||||
dto.Start = t
|
||||
}
|
||||
}
|
||||
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
|
||||
if t, err := endProp.DateTime(time.UTC); err == nil {
|
||||
// Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein
|
||||
// eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt
|
||||
// diese Schnittstelle den letzten *belegten* Tag zurück – sonst
|
||||
// müsste jede Oberfläche dieselbe Rechnung noch einmal machen
|
||||
// und eine davon würde sie vergessen.
|
||||
if dto.AllDay {
|
||||
t = t.AddDate(0, 0, -1)
|
||||
if t.Before(dto.Start) {
|
||||
t = dto.Start
|
||||
}
|
||||
}
|
||||
dto.End = &t
|
||||
}
|
||||
}
|
||||
if dto.AllDay && dto.End == nil {
|
||||
// Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag.
|
||||
end := dto.Start
|
||||
dto.End = &end
|
||||
}
|
||||
|
||||
if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil {
|
||||
dto.Recurring = true
|
||||
dto.Recurrence = recurrenceFromRule(opt, dto.Start)
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
// expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne
|
||||
// Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau
|
||||
// **einen** Eintrag – nämlich den ersten.
|
||||
func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO {
|
||||
if !base.Recurring {
|
||||
// Einzeltermin: nur, wenn er das Fenster überhaupt berührt.
|
||||
end := base.Start
|
||||
if base.End != nil {
|
||||
end = *base.End
|
||||
}
|
||||
if end.Before(from) || base.Start.After(to) {
|
||||
return nil
|
||||
}
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
set, err := ev.RecurrenceSet(time.UTC)
|
||||
if err != nil || set == nil {
|
||||
return []EventDTO{*base}
|
||||
}
|
||||
|
||||
// Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert.
|
||||
var dur time.Duration
|
||||
if base.End != nil {
|
||||
dur = base.End.Sub(base.Start)
|
||||
}
|
||||
seriesStart, seriesEnd := base.Start, base.End
|
||||
|
||||
out := make([]EventDTO, 0, 16)
|
||||
for i, occ := range set.Between(from, to, true) {
|
||||
if i >= maxOccurrences {
|
||||
break
|
||||
}
|
||||
item := *base
|
||||
item.Start = occ.UTC()
|
||||
if seriesEnd != nil {
|
||||
end := item.Start.Add(dur)
|
||||
item.End = &end
|
||||
}
|
||||
item.SeriesStart = &seriesStart
|
||||
item.SeriesEnd = seriesEnd
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
||||
|
||||
type icalError struct{ msg string }
|
||||
|
||||
func (e *icalError) Error() string { return e.msg }
|
||||
|
||||
// parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges
|
||||
// Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem
|
||||
// etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird.
|
||||
func parseWindow(r *http.Request) (from, to time.Time) {
|
||||
now := time.Now().UTC()
|
||||
from = now.AddDate(0, -1, 0)
|
||||
to = now.AddDate(1, 0, 0)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t.UTC()
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t.UTC()
|
||||
}
|
||||
}
|
||||
if to.Before(from) {
|
||||
to = from
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
// GET /api/v1/calendars/{id}/events?from=…&to=…
|
||||
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
// Lesen genügt: Ein freigegebener Kalender soll sich ansehen lassen.
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, false); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
objs, err := h.store.ListObjects(r.Context(), calID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list events failed")
|
||||
return
|
||||
}
|
||||
from, to := parseWindow(r)
|
||||
|
||||
out := make([]EventDTO, 0, len(objs))
|
||||
for i := range objs {
|
||||
base, err := eventFromObject(&objs[i])
|
||||
if err != nil {
|
||||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||||
}
|
||||
out = append(out, expand(&objs[i], base, from, to)...)
|
||||
}
|
||||
response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type eventBody struct {
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
Start time.Time `json:"start"`
|
||||
End *time.Time `json:"end"`
|
||||
AllDay bool `json:"all_day"`
|
||||
Color string `json:"color"`
|
||||
Recurrence *RecurrenceDTO `json:"recurrence"`
|
||||
}
|
||||
|
||||
func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||
root := ical.NewCalendar()
|
||||
root.Props.SetText(ical.PropVersion, "2.0")
|
||||
root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
|
||||
ev := ical.NewEvent()
|
||||
ev.Props.SetText(ical.PropUID, uid)
|
||||
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||||
if body.Summary != "" {
|
||||
ev.Props.SetText(ical.PropSummary, body.Summary)
|
||||
}
|
||||
if body.Description != "" {
|
||||
ev.Props.SetText(ical.PropDescription, body.Description)
|
||||
}
|
||||
if body.Location != "" {
|
||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||
}
|
||||
if hex := normalizeHex(body.Color); hex != "" {
|
||||
// Beides schreiben: den benannten Ton für fremde Clients (DAVx5
|
||||
// überträgt daraus die Terminfarbe ins Telefon), den genauen Wert
|
||||
// für die eigene Palette. Siehe color.go.
|
||||
if name := nearestCSS3(hex); name != "" {
|
||||
ev.Props.SetText(propColor, name)
|
||||
}
|
||||
// Ohne SetText geschrieben: Das hängte an die unbekannte
|
||||
// X-Eigenschaft ein überflüssiges VALUE=TEXT an.
|
||||
p := ical.NewProp(propColorX)
|
||||
p.Value = hex
|
||||
ev.Props.Set(p)
|
||||
}
|
||||
|
||||
if body.AllDay {
|
||||
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
|
||||
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
|
||||
// Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine
|
||||
// Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der
|
||||
// 3. wurde zum 2.).
|
||||
start := body.Start.UTC()
|
||||
ev.Props.SetDate(ical.PropDateTimeStart, start)
|
||||
// DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten
|
||||
// belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen
|
||||
// (siehe eventFromObject), also hier +1. Ohne das verletzt ein
|
||||
// eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und
|
||||
// Clients zeigen ihn unterschiedlich an.
|
||||
last := start
|
||||
if body.End != nil {
|
||||
last = body.End.UTC()
|
||||
if last.Before(start) {
|
||||
last = start
|
||||
}
|
||||
}
|
||||
ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1))
|
||||
} else {
|
||||
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
|
||||
if body.End != nil {
|
||||
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
if rule := ruleFromRecurrence(body); rule != nil {
|
||||
ev.Props.SetRecurrenceRule(rule)
|
||||
}
|
||||
|
||||
root.Children = append(root.Children, ev.Component)
|
||||
return root
|
||||
}
|
||||
|
||||
func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
||||
rec := body.Recurrence
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
freq, ok := stringToFreq[strings.ToUpper(rec.Freq)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
interval := rec.Interval
|
||||
if interval < 1 {
|
||||
interval = 1
|
||||
}
|
||||
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
|
||||
|
||||
// Der Start ist die Bezugsgröße für alle BY-Angaben: „am 4." und „am 1.
|
||||
// Freitag" leiten sich beide aus ihm ab, statt getrennt mitgeschickt zu
|
||||
// werden. So kann die Regel gar nicht erst auf einen anderen Tag zeigen
|
||||
// als der Termin selbst.
|
||||
start := body.Start.UTC()
|
||||
switch strings.ToUpper(rec.Freq) {
|
||||
case "WEEKLY":
|
||||
codes := rec.Byday
|
||||
if len(codes) == 0 {
|
||||
codes = []string{weekdayCode(start)}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range codes {
|
||||
wd, ok := rruleWeekday(c)
|
||||
if !ok || seen[strings.ToUpper(c)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToUpper(c)] = true
|
||||
opt.Byweekday = append(opt.Byweekday, wd)
|
||||
}
|
||||
|
||||
case "MONTHLY", "YEARLY":
|
||||
if strings.EqualFold(rec.Mode, "WEEKDAY") {
|
||||
wd, _ := rruleWeekday(weekdayCode(start))
|
||||
opt.Byweekday = []rrule.Weekday{wd.Nth(nthOfMonth(start))}
|
||||
} else {
|
||||
opt.Bymonthday = []int{start.Day()}
|
||||
}
|
||||
if strings.EqualFold(rec.Freq, "YEARLY") {
|
||||
opt.Bymonth = []int{int(start.Month())}
|
||||
}
|
||||
}
|
||||
|
||||
if rec.Until != nil {
|
||||
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
||||
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
||||
// Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen
|
||||
// später bemerkt.
|
||||
u := rec.Until.UTC()
|
||||
opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC)
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
// calendarOf steht vor **jedem schreibenden** Zugriff der Weboberfläche –
|
||||
// Anlegen, Ändern, Umhängen, Löschen. Deshalb verlangt es das Schreibrecht:
|
||||
// Ein nur lesend freigegebener Kalender darf sich ansehen lassen und trotzdem
|
||||
// nichts annehmen.
|
||||
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, true); err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
return calID, true
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/events
|
||||
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
|
||||
uid := uuid.New().String()
|
||||
dto, err := h.writeEvent(r, calID, uid, body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "create event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, dto)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/calendars/{id}/events/{uid}
|
||||
//
|
||||
// Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne
|
||||
// Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine
|
||||
// eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne
|
||||
// die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen.
|
||||
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
uid := chi.URLParam(r, "uid")
|
||||
existing, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load event failed")
|
||||
return
|
||||
}
|
||||
if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil {
|
||||
// Wiederholung, die das Formular nicht vollständig abbildet (etwa
|
||||
// „jeden zweiten Dienstag"). Speichern würde die Regel auf das
|
||||
// reduzieren, was das Formular kennt – also lieber ablehnen.
|
||||
response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client")
|
||||
return
|
||||
}
|
||||
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
dto, err := h.writeEvent(r, calID, uid, body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/events/{uid}/move {"calendar_id": "…"}
|
||||
//
|
||||
// Umhängen in einen anderen Kalender. Bewusst ein eigener Endpunkt und nicht
|
||||
// ein weiteres Feld im PATCH: Ein Umzug lässt sich auch **ohne** jede
|
||||
// Änderung am Termin auslösen (Ziehen mit der Maus, langes Drücken am Handy),
|
||||
// und dafür den ganzen Termin mitschicken zu müssen wäre eine Einladung,
|
||||
// nebenbei etwas zu überschreiben.
|
||||
//
|
||||
// Bei einer Reihe zieht die ganze Reihe um – sie ist eine einzige Datei, kein
|
||||
// Bündel von Einzelterminen.
|
||||
func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
fromCal, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CalendarID string `json:"calendar_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||||
return
|
||||
}
|
||||
toCal, err := uuid.Parse(strings.TrimSpace(body.CalendarID))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||||
return
|
||||
}
|
||||
// In den Zielkalender muss geschrieben werden dürfen. Ohne diese Prüfung
|
||||
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
|
||||
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
|
||||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, toCal, true); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := h.store.MoveObject(r.Context(), fromCal, toCal, chi.URLParam(r, "uid"))
|
||||
switch {
|
||||
case err == ErrNotFound:
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
case err == ErrConflict:
|
||||
response.Error(w, http.StatusConflict, "an event with this uid already exists in the target calendar")
|
||||
return
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "move event failed")
|
||||
return
|
||||
}
|
||||
|
||||
dto, err := eventFromObject(obj)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "move event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
var errNotRecurring = &icalError{"calendar: event has no recurrence"}
|
||||
|
||||
// excludeOccurrence streicht einen einzelnen Termin aus einer Reihe, indem
|
||||
// er als EXDATE vermerkt wird – die Art, wie das iCalendar-Format eine
|
||||
// Ausnahme ausdrückt. Die Reihe selbst bleibt mit ihrer Regel bestehen, und
|
||||
// jeder CalDAV-Client versteht das ohne Zutun.
|
||||
//
|
||||
// Bewusst **nicht** so gelöst, dass die Reihe in zwei Reihen zerlegt wird:
|
||||
// Das änderte die UID, und alle Geräte hielten den Rest für einen neuen
|
||||
// Termin.
|
||||
func (h *APIHandler) excludeOccurrence(r *http.Request, calID uuid.UUID, uid string, at time.Time) error {
|
||||
obj, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cal, err := ical.NewDecoder(strings.NewReader(obj.Data)).Decode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return errNoEventComponent
|
||||
}
|
||||
if rule, err := ev.Props.RecurrenceRule(); err != nil || rule == nil {
|
||||
return errNotRecurring
|
||||
}
|
||||
|
||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||
allDay := startProp != nil && isDateOnly(startProp)
|
||||
|
||||
// Schon ausgetragen? Dann nichts tun – ein zweites EXDATE auf denselben
|
||||
// Zeitpunkt wäre harmlos, aber die Datei liefe mit der Zeit voll.
|
||||
for _, ex := range ev.Props[ical.PropExceptionDates] {
|
||||
if t, err := ex.DateTime(time.UTC); err == nil && t.Equal(at) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ex := ical.NewProp(ical.PropExceptionDates)
|
||||
if allDay {
|
||||
ex.SetDate(at)
|
||||
} else {
|
||||
ex.SetDateTime(at)
|
||||
}
|
||||
ev.Props.Add(ex)
|
||||
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start, end := timeRange(cal)
|
||||
_, err = h.store.PutObject(r.Context(), calID, uid, obj.Component, data, etagFor(data), start, end)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) {
|
||||
cal := buildEventICal(uid, body)
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end := timeRange(cal)
|
||||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return eventFromObject(obj)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendars/{id}/events/{uid}[?occurrence=<RFC3339>]
|
||||
//
|
||||
// Ohne occurrence verschwindet der Termin ganz – bei einer Reihe also die
|
||||
// **ganze** Reihe. Mit occurrence fällt nur dieser eine Termin aus; die Reihe
|
||||
// bleibt bestehen und behält ihre Regel.
|
||||
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
uid := chi.URLParam(r, "uid")
|
||||
|
||||
if occ := r.URL.Query().Get("occurrence"); occ != "" {
|
||||
at, err := time.Parse(time.RFC3339, occ)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid occurrence")
|
||||
return
|
||||
}
|
||||
switch err := h.excludeOccurrence(r, calID, uid, at.UTC()); err {
|
||||
case nil:
|
||||
response.NoContent(w)
|
||||
case ErrNotFound:
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
case errNotRecurring:
|
||||
// Kein Serientermin: Dann ist „nur dieser" dasselbe wie „ganz
|
||||
// weg" – also einfach löschen, statt den Aufrufer mit einer
|
||||
// Unterscheidung zu behelligen, die es hier nicht gibt.
|
||||
if err := h.store.DeleteObject(r.Context(), calID, uid); err != nil && err != ErrNotFound {
|
||||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "delete occurrence failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/middleware"
|
||||
)
|
||||
|
||||
// Brute-force-Schutz für /caldav/, aus denselben Gründen wie beim
|
||||
// WebDAV-Endpunkt: Basic Auth läuft am normalen Login-Rate-Limit vorbei.
|
||||
const (
|
||||
davMaxFailures = 10
|
||||
davWindow = 15 * time.Minute
|
||||
davLockout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// Handler serves CalDAV at /caldav/ with HTTP Basic Auth (App-Passwörter).
|
||||
type Handler struct {
|
||||
authStore *auth.Store
|
||||
store *Store
|
||||
dav *caldav.Handler
|
||||
limiter *middleware.FailureLimiter
|
||||
}
|
||||
|
||||
func NewHandler(authStore *auth.Store, store *Store) *Handler {
|
||||
return &Handler{
|
||||
authStore: authStore,
|
||||
store: store,
|
||||
dav: &caldav.Handler{Backend: NewBackend(store), Prefix: "/caldav"},
|
||||
limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := middleware.RealIP(r)
|
||||
if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
|
||||
http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username == "" || password == "" {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Nur App-Passwörter, aus demselben Grund wie bei /dav/: das
|
||||
// Kontopasswort öffnet auch den Admin-Bereich.
|
||||
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
|
||||
if err != nil {
|
||||
h.limiter.Fail(clientIP)
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(clientIP)
|
||||
|
||||
// Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit
|
||||
// GET statt PROPFIND, oder ein Mensch öffnet die Adresse zum Testen im
|
||||
// Browser. Ohne diese Abkürzung würde das an GetCalendarObject
|
||||
// durchgereicht, das für Sammlungen (Principal, Home-Set, Kalender) mit
|
||||
// 404 antwortet – ein Client, der so den Dienst prüft, bräche die
|
||||
// Einrichtung ab, bevor er je ein PROPFIND schickt.
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !strings.HasSuffix(r.URL.Path, ".ics") {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodGet {
|
||||
io.WriteString(w, "Dies ist eine CalDAV-Sammlung. Mit einem CalDAV-Client verbinden (PROPFIND), nicht mit dem Browser.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ein Pfad, der auf einen *anderen* Benutzer zeigt, ist ein Fehler und
|
||||
// muss auch so aussehen. Ohne diese Prüfung antwortet go-webdav mit
|
||||
// einem leeren 207: Der Client sieht dann einen Kalenderbestand ohne
|
||||
// Kalender und meldet „nichts gefunden", statt „falsche Adresse" – die
|
||||
// stille Variante desselben Fehlers, die bei der Einrichtung Stunden
|
||||
// kostet.
|
||||
if !pathBelongsTo(r.URL.Path, user.Username) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
r = r.WithContext(contextWithUser(r.Context(), user))
|
||||
|
||||
// Drei Wege gehen an der Bibliothek vorbei, weil sie sie nicht oder
|
||||
// falsch bedient – siehe propfind.go und collections.go.
|
||||
switch r.Method {
|
||||
case "PROPFIND":
|
||||
h.servePropfind(w, r, user)
|
||||
return
|
||||
case "MKCALENDAR":
|
||||
h.handleMkcalendar(w, r, user)
|
||||
return
|
||||
case "PROPPATCH":
|
||||
h.handleProppatch(w, r, user)
|
||||
return
|
||||
case "REPORT":
|
||||
// Nur sync-collection gehört uns; calendar-query und
|
||||
// calendar-multiget beantwortet die Bibliothek weiterhin selbst.
|
||||
if h.handleReport(w, r, user) {
|
||||
return
|
||||
}
|
||||
case http.MethodDelete:
|
||||
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
|
||||
// die Bibliothek weiterhin selbst.
|
||||
if h.handleDeleteCalendar(w, r, user) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.dav.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem
|
||||
// Clients wie DAVx5 die automatische Einrichtung starten (RFC 6764). Er
|
||||
// liegt außerhalb von /caldav/, deshalb braucht es diese eigene Route statt
|
||||
// caldav.Handler's eingebauter Behandlung, die nur den exakten Pfad
|
||||
// "/.well-known/caldav" innerhalb des eigenen Präfix kennt.
|
||||
func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username == "" || password == "" {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
|
||||
if err != nil {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
// pathBelongsTo erkennt Pfade, die auf einen anderen Benutzer zeigen.
|
||||
// Bewusst nur ablehnen, was sicher fremd ist: Alles Unbekannte geht weiter
|
||||
// an go-webdav, damit diese Prüfung keine Pfade abschneidet, die sie gar
|
||||
// nicht kennt.
|
||||
func pathBelongsTo(p, username string) bool {
|
||||
rest := strings.Trim(strings.TrimPrefix(p, davPrefix), "/")
|
||||
if rest == "" {
|
||||
return true
|
||||
}
|
||||
seg := strings.Split(rest, "/")
|
||||
switch {
|
||||
case strings.HasPrefix(seg[0], "principals-"):
|
||||
return strings.EqualFold(strings.TrimPrefix(seg[0], "principals-"), username)
|
||||
case seg[0] == "calendars" && len(seg) >= 2:
|
||||
return strings.EqualFold(seg[1], username)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func challenge(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`)
|
||||
http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package calendar ist ein eigenständiges Modul, kein Kernbestandteil:
|
||||
// CalDAV für Termine und Aufgaben. Es importiert nichts aus files, sharing
|
||||
// oder admin – nur auth und acl, wie jeder andere Bereich auch. Ist es
|
||||
// abgeschaltet, gibt es keine Route und keinen Menüpunkt; die Tabellen
|
||||
// bleiben dann einfach ungenutzt liegen. Siehe kalender-plan.md.
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Calendar ist eine Sammlung von Terminen/Aufgaben – das, was ein Client als
|
||||
// einen Kalender in seiner Liste zeigt.
|
||||
type Calendar struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
SupportsTodo bool `json:"supports_todo"`
|
||||
// TimeZone ist die Vorgabe für neue Termine, die die Web-Oberfläche
|
||||
// künftig hier anlegt (Phase 3). CalDAV selbst braucht sie nicht –
|
||||
// jeder Termin trägt seine Zeitzone bereits im .ics.
|
||||
TimeZone string `json:"timezone"`
|
||||
// PathSegment ist die Adresse, unter der ein Client diesen Kalender
|
||||
// angelegt hat ("…/calendars/max/arbeit/"). Leer heißt: keine gewählte
|
||||
// Adresse, es gilt die UUID. Siehe Ref.
|
||||
PathSegment string `json:"-"`
|
||||
CTag int64 `json:"ctag"`
|
||||
// SyncFloor: Bis zu diesem Stand sind Grabsteine weggeräumt. Ein Token
|
||||
// darunter lässt sich nicht mehr vollständig beantworten – der Client
|
||||
// bekommt dann eine Absage und gleicht vollständig ab. Siehe
|
||||
// Store.ChangesSince.
|
||||
SyncFloor int64 `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Ref ist das Pfadsegment, unter dem dieser Kalender in CalDAV erreichbar
|
||||
// ist: die vom Client gewählte Adresse, sonst die UUID.
|
||||
func (c *Calendar) Ref() string {
|
||||
if c.PathSegment != "" {
|
||||
return c.PathSegment
|
||||
}
|
||||
return c.ID.String()
|
||||
}
|
||||
|
||||
// Object ist ein einzelner Termin oder eine Aufgabe. Data ist die
|
||||
// vollständige .ics-Datei, unverändert wie vom Client geschickt – siehe
|
||||
// kalender-plan.md, Abschnitt "Felder".
|
||||
type Object struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CalendarID uuid.UUID `json:"calendar_id"`
|
||||
UID string `json:"uid"`
|
||||
Component string `json:"component"` // "VEVENT" | "VTODO"
|
||||
Data string `json:"-"`
|
||||
ETag string `json:"etag"`
|
||||
DTStart *time.Time `json:"dtstart,omitempty"`
|
||||
DTEnd *time.Time `json:"dtend,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Zwei Eigenschaften, die jeder verbreitete CalDAV-Client abfragt und die
|
||||
// go-webdav v0.7.0 nicht kennt:
|
||||
//
|
||||
// - getctag (Calendarserver): die billige Frage „hat sich in diesem
|
||||
// Kalender etwas geändert?". Ohne sie lädt ein Client bei jedem Abgleich
|
||||
// die Liste aller Termine herunter, um sie zu vergleichen.
|
||||
// - calendar-color (Apple): die Farbe des Kalenders. Ohne sie zeigt der
|
||||
// Client ihn in seiner eigenen Vorgabefarbe, und die Farbgebung im
|
||||
// Telefon hat mit der in der Cloud nichts mehr zu tun.
|
||||
//
|
||||
// Die Bibliothek beantwortet in PROPFIND einen festen Satz Eigenschaften und
|
||||
// bietet keinen Haken, eigene dazuzulegen (server.go, propFindCalendar).
|
||||
// Deshalb dieser Umweg: Wir nehmen die beiden aus der Anfrage heraus, bevor
|
||||
// go-webdav sie zu sehen bekommt, und hängen sie der Antwort wieder an.
|
||||
//
|
||||
// Warum herausnehmen und nicht einfach anhängen? Weil go-webdav sie sonst
|
||||
// als „nicht gefunden" meldet und dieselbe Eigenschaft in derselben Antwort
|
||||
// zweimal stünde – einmal mit 404, einmal mit 200. Was ein Client daraus
|
||||
// macht, ist Glückssache.
|
||||
const (
|
||||
nsCalendarServer = "http://calendarserver.org/ns/"
|
||||
nsApple = "http://apple.com/ns/ical/"
|
||||
)
|
||||
|
||||
// Ein PROPFIND-Rumpf ist eine Liste leerer Elemente. Alles darüber ist kein
|
||||
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
|
||||
const maxPropfindBody = 1 << 20
|
||||
|
||||
// Vier Eigenschaften, die go-webdav nicht kennt. Die ersten beiden sind
|
||||
// herstellereigen, die letzten beiden stehen im RFC – die Bibliothek liefert
|
||||
// sie trotzdem nicht.
|
||||
type wantedExtras struct{ ctag, color, syncToken, reports bool }
|
||||
|
||||
func (w wantedExtras) any() bool { return w.ctag || w.color || w.syncToken || w.reports }
|
||||
|
||||
// alleExtras: Antwort auf <allprop> und auf einen leeren Rumpf.
|
||||
func alleExtras() wantedExtras {
|
||||
return wantedExtras{ctag: true, color: true, syncToken: true, reports: true}
|
||||
}
|
||||
|
||||
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
|
||||
// und baut den Rumpf ohne sie neu auf.
|
||||
//
|
||||
// Der Neuaufbau ist verlustfrei, weil in einem PROPFIND ausschließlich leere
|
||||
// Elemente stehen: Name und Namensraum sind alles, was eine Eigenschaft dort
|
||||
// ausmacht.
|
||||
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
|
||||
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return alleExtras(), body
|
||||
}
|
||||
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
var (
|
||||
inProp bool
|
||||
depth int
|
||||
keep []xml.Name
|
||||
)
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
|
||||
return alleExtras(), body
|
||||
}
|
||||
if !inProp {
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||||
inProp, depth = true, 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
depth++
|
||||
if depth > 1 {
|
||||
continue // verschachtelte Inhalte gibt es in PROPFIND nicht
|
||||
}
|
||||
switch {
|
||||
case t.Name.Space == nsCalendarServer && t.Name.Local == "getctag":
|
||||
want.ctag = true
|
||||
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
|
||||
want.color = true
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "sync-token":
|
||||
want.syncToken = true
|
||||
case t.Name.Space == "DAV:" && t.Name.Local == "supported-report-set":
|
||||
want.reports = true
|
||||
default:
|
||||
keep = append(keep, t.Name)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if inProp {
|
||||
if depth == 0 {
|
||||
inProp = false
|
||||
} else {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !want.any() {
|
||||
return want, body // nichts zu tun – Rumpf unverändert weiterreichen
|
||||
}
|
||||
return want, buildPropfind(keep)
|
||||
}
|
||||
|
||||
func buildPropfind(props []xml.Name) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop>`)
|
||||
for _, n := range props {
|
||||
if n.Space == "DAV:" {
|
||||
fmt.Fprintf(&b, "<%s/>", xmlName(n.Local))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space))
|
||||
}
|
||||
}
|
||||
// Fragt ein Client *nur* nach unseren beiden Eigenschaften, bliebe hier
|
||||
// ein leeres <prop> stehen – damit weist go-webdav die Anfrage ab.
|
||||
// resourcetype kennt jede Ressource und stört keinen Client.
|
||||
if len(props) == 0 {
|
||||
b.WriteString("<resourcetype/>")
|
||||
}
|
||||
b.WriteString("</prop></propfind>")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// Elementnamen und Namensräume stammen aus fremdem XML und wandern hier
|
||||
// zurück in XML. Alles, was dort etwas anderes bedeuten könnte, fliegt raus –
|
||||
// gültige Namen und URIs enthalten davon ohnehin nichts.
|
||||
func xmlName(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.':
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, s)
|
||||
}
|
||||
|
||||
func xmlAttr(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "\"", """, "<", "<", ">", ">")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// appleColor bringt eine Farbe in die Schreibweise, die Apple-Clients und
|
||||
// DAVx5 erwarten: #RRGGBBAA. Ohne den Alpha-Anteil zeigen manche Clients
|
||||
// die Farbe gar nicht an.
|
||||
func appleColor(hex string) string {
|
||||
h := normalizeHex(hex)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(h) + "FF"
|
||||
}
|
||||
|
||||
// Die Berichte, die dieser Server beantwortet. Ohne diese Liste probiert ein
|
||||
// Client sync-collection gar nicht erst aus – DAVx5 sieht hier nach, bevor es
|
||||
// entscheidet, ob es die volle Terminliste holt oder nur die Änderungen.
|
||||
const supportedReports = `<supported-report-set xmlns="DAV:">` +
|
||||
`<supported-report><report><sync-collection/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-query xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-multiget xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`</supported-report-set>`
|
||||
|
||||
func extrasPropstat(cal *Calendar, want wantedExtras) string {
|
||||
var b strings.Builder
|
||||
if want.ctag {
|
||||
fmt.Fprintf(&b, `<getctag xmlns="%s">%d</getctag>`, nsCalendarServer, cal.CTag)
|
||||
}
|
||||
if want.color {
|
||||
if c := appleColor(cal.Color); c != "" {
|
||||
fmt.Fprintf(&b, `<calendar-color xmlns="%s">%s</calendar-color>`, nsApple, c)
|
||||
}
|
||||
}
|
||||
if want.syncToken {
|
||||
// Derselbe Zähler wie im ctag – eine Zahl, zwei Namen, weil die
|
||||
// Clients historisch nach beidem fragen.
|
||||
fmt.Fprintf(&b, `<sync-token xmlns="DAV:">%s</sync-token>`, syncToken(cal.CTag))
|
||||
}
|
||||
if want.reports {
|
||||
b.WriteString(supportedReports)
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
// Mehrere propstat je Antwort sind der Normalfall in WebDAV – so trennt
|
||||
// go-webdav ohnehin schon gefundene von nicht gefundenen Eigenschaften.
|
||||
return `<propstat xmlns="DAV:"><prop>` + b.String() +
|
||||
`</prop><status>HTTP/1.1 200 OK</status></propstat>`
|
||||
}
|
||||
|
||||
// injectExtras hängt jeder Antwort, die einen Kalender beschreibt, unsere
|
||||
// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine
|
||||
// bleiben unangetastet.
|
||||
func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte {
|
||||
// **Accessible, nicht nur eigene**: Ein freigegebener Kalender steht mit
|
||||
// in der Liste und braucht dieselben Eigenschaften – ohne ctag und
|
||||
// sync-token hielte ihn der Client für unveränderlich.
|
||||
cals, err := h.store.ListAccessible(ctx, user.ID)
|
||||
if err != nil {
|
||||
return body // im Zweifel die Antwort der Bibliothek, unverändert
|
||||
}
|
||||
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
|
||||
// erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat.
|
||||
// Bei fremden Kalendern ist es immer die UUID – siehe Access.Ref.
|
||||
byRef := make(map[string]*Calendar, len(cals))
|
||||
for i := range cals {
|
||||
byRef[cals[i].Ref()] = &cals[i].Calendar
|
||||
}
|
||||
|
||||
const closeTag = "</response>"
|
||||
parts := strings.Split(string(body), closeTag)
|
||||
var out strings.Builder
|
||||
for i, part := range parts {
|
||||
out.WriteString(part)
|
||||
if i == len(parts)-1 {
|
||||
break // hinter dem letzten </response> steht nur noch </multistatus>
|
||||
}
|
||||
if cal := calendarOfResponse(part, byRef); cal != nil {
|
||||
out.WriteString(extrasPropstat(cal, want))
|
||||
}
|
||||
out.WriteString(closeTag)
|
||||
}
|
||||
return []byte(out.String())
|
||||
}
|
||||
|
||||
// calendarOfResponse findet den Kalender, den ein <response>-Abschnitt
|
||||
// beschreibt – oder nil, wenn er etwas anderes beschreibt.
|
||||
func calendarOfResponse(part string, byRef map[string]*Calendar) *Calendar {
|
||||
start := strings.Index(part, "<href")
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
open := strings.Index(part[start:], ">")
|
||||
if open < 0 {
|
||||
return nil
|
||||
}
|
||||
rest := part[start+open+1:]
|
||||
end := strings.Index(rest, "</href>")
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
href := rest[:end]
|
||||
if p, err := url.PathUnescape(href); err == nil {
|
||||
href = p
|
||||
}
|
||||
ref, objekt, err := parseCalendarPath(href)
|
||||
if err != nil || objekt != "" {
|
||||
return nil // Home-Set, Principal oder ein einzelner Termin
|
||||
}
|
||||
return byRef[ref]
|
||||
}
|
||||
|
||||
// recorder hält die Antwort der Bibliothek fest, bis feststeht, ob noch
|
||||
// etwas hineingehört. Nötig, weil die Länge sich ändert und Content-Length
|
||||
// erst danach stimmt.
|
||||
type recorder struct {
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
}
|
||||
|
||||
func (rec *recorder) Header() http.Header { return rec.header }
|
||||
|
||||
func (rec *recorder) WriteHeader(code int) {
|
||||
if rec.status == 0 {
|
||||
rec.status = code
|
||||
}
|
||||
}
|
||||
|
||||
func (rec *recorder) Write(p []byte) (int, error) {
|
||||
if rec.status == 0 {
|
||||
rec.status = http.StatusOK
|
||||
}
|
||||
return rec.body.Write(p)
|
||||
}
|
||||
|
||||
// servePropfind reicht die Anfrage an go-webdav weiter und ergänzt die
|
||||
// Antwort um die Eigenschaften, die die Bibliothek nicht kennt.
|
||||
func (h *Handler) servePropfind(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||||
if err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
want, rewritten := parsePropfind(body)
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewReader(rewritten))
|
||||
r.ContentLength = int64(len(rewritten))
|
||||
if !want.any() {
|
||||
h.dav.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rec := &recorder{header: http.Header{}}
|
||||
h.dav.ServeHTTP(rec, r)
|
||||
|
||||
out := rec.body.Bytes()
|
||||
if rec.status == http.StatusMultiStatus {
|
||||
out = h.injectExtras(r.Context(), user, out, want)
|
||||
}
|
||||
|
||||
for k, v := range rec.header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||
w.WriteHeader(rec.status)
|
||||
if _, err := w.Write(out); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const propfindHead = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:i="http://apple.com/ns/ical/">`
|
||||
|
||||
func TestParsePropfindTrenntUnsereEigenschaftenAb(t *testing.T) {
|
||||
body := []byte(propfindHead + `<d:prop><d:displayname/><cs:getctag/><i:calendar-color/></d:prop></d:propfind>`)
|
||||
|
||||
want, rewritten := parsePropfind(body)
|
||||
if !want.ctag || !want.color {
|
||||
t.Fatalf("beide Eigenschaften sollten erkannt werden, bekam %+v", want)
|
||||
}
|
||||
out := string(rewritten)
|
||||
if !strings.Contains(out, "<displayname/>") {
|
||||
t.Errorf("die übrigen Eigenschaften müssen erhalten bleiben: %s", out)
|
||||
}
|
||||
// Der Kern: go-webdav darf sie nicht zu sehen bekommen, sonst meldet es
|
||||
// sie als 404 und dieselbe Eigenschaft stünde zweimal in der Antwort.
|
||||
if strings.Contains(out, "getctag") || strings.Contains(out, "calendar-color") {
|
||||
t.Errorf("unsere Eigenschaften müssen entfernt sein: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePropfindLaesstFremdeAnfragenInRuhe(t *testing.T) {
|
||||
body := []byte(propfindHead + `<d:prop><d:resourcetype/><d:getetag/></d:prop></d:propfind>`)
|
||||
|
||||
want, rewritten := parsePropfind(body)
|
||||
if want.any() {
|
||||
t.Errorf("nichts von uns gefragt, trotzdem %+v", want)
|
||||
}
|
||||
if string(rewritten) != string(body) {
|
||||
t.Errorf("Rumpf muss unverändert durchgereicht werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePropfindNurUnsereEigenschaftHinterlaesstKeinLeeresProp(t *testing.T) {
|
||||
// Ein leeres <prop> weist go-webdav ab – dann käme auf eine gültige
|
||||
// Anfrage ein Fehler statt einer Antwort.
|
||||
body := []byte(propfindHead + `<d:prop><cs:getctag/></d:prop></d:propfind>`)
|
||||
|
||||
_, rewritten := parsePropfind(body)
|
||||
if !strings.Contains(string(rewritten), "<resourcetype/>") {
|
||||
t.Errorf("Platzhalter fehlt: %s", rewritten)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePropfindAllpropUndLeererRumpf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{"allprop", []byte(propfindHead + `<d:allprop/></d:propfind>`)},
|
||||
{"leerer Rumpf", nil}, // RFC 4918, 9.1: leer bedeutet allprop
|
||||
} {
|
||||
want, rewritten := parsePropfind(tc.body)
|
||||
if !want.ctag || !want.color {
|
||||
t.Errorf("%s: sollte alles liefern, bekam %+v", tc.name, want)
|
||||
}
|
||||
if string(rewritten) != string(tc.body) {
|
||||
t.Errorf("%s: Rumpf darf nicht umgebaut werden", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarOfResponseTrifftNurKalender(t *testing.T) {
|
||||
id := uuid.New()
|
||||
cal := &Calendar{ID: id, Color: "#a142f4", CTag: 7}
|
||||
// Ein Kalender unter seiner UUID, einer unter einer vom Client
|
||||
// gewählten Adresse – beide müssen gefunden werden.
|
||||
eigen := &Calendar{ID: uuid.New(), PathSegment: "arbeit", Color: "#34a853"}
|
||||
byRef := map[string]*Calendar{id.String(): cal, "arbeit": eigen}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
will *Calendar
|
||||
}{
|
||||
{"Kalender", "/caldav/calendars/max/" + id.String() + "/", cal},
|
||||
{"Home-Set", "/caldav/calendars/max/", nil},
|
||||
{"Principal", "/caldav/principals-max/", nil},
|
||||
{"einzelner Termin", "/caldav/calendars/max/" + id.String() + "/abc.ics", nil},
|
||||
{"fremder Kalender", "/caldav/calendars/max/" + uuid.New().String() + "/", nil},
|
||||
{"selbst gewählte Adresse", "/caldav/calendars/max/arbeit/", eigen},
|
||||
{"Termin darin", "/caldav/calendars/max/arbeit/abc.ics", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
part := `<response xmlns="DAV:"><href>` + tc.href + `</href><propstat/>`
|
||||
if got := calendarOfResponse(part, byRef); got != tc.will {
|
||||
t.Errorf("%s: bekam %v, erwartet %v", tc.name, got, tc.will)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtrasPropstat(t *testing.T) {
|
||||
cal := &Calendar{Color: "#a142f4", CTag: 12}
|
||||
|
||||
got := extrasPropstat(cal, wantedExtras{ctag: true, color: true})
|
||||
if !strings.Contains(got, ">12</getctag>") {
|
||||
t.Errorf("ctag fehlt: %s", got)
|
||||
}
|
||||
// Apple-Clients und DAVx5 erwarten den Alpha-Anteil.
|
||||
if !strings.Contains(got, ">#A142F4FF</calendar-color>") {
|
||||
t.Errorf("Farbe nicht in Apple-Schreibweise: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "HTTP/1.1 200 OK") {
|
||||
t.Errorf("Status fehlt: %s", got)
|
||||
}
|
||||
|
||||
// Ohne Farbe bleibt die Eigenschaft weg – lieber gar nicht melden als
|
||||
// leer, sonst färbt der Client den Kalender schwarz.
|
||||
if got := extrasPropstat(&Calendar{CTag: 1}, wantedExtras{color: true}); got != "" {
|
||||
t.Errorf("ohne Farbe sollte nichts entstehen, bekam %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/acl"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("calendar: not found")
|
||||
|
||||
// ErrConflict: im Zielkalender liegt bereits ein Termin mit dieser UID. Zwei
|
||||
// Objekte gleicher UID in einer Sammlung wären für CalDAV-Clients nicht
|
||||
// unterscheidbar – deshalb bleibt der Termin dann lieber, wo er ist.
|
||||
var ErrConflict = errors.New("calendar: uid already exists in target calendar")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
// rights beantwortet „wer darf was" – dieselbe Tabelle, die auch über
|
||||
// Ordner und Dateien entscheidet. Darf nil sein: Dann gibt es schlicht
|
||||
// keine freigegebenen Kalender, und alles Übrige funktioniert weiter.
|
||||
rights *acl.Store
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool, rights *acl.Store) *Store {
|
||||
return &Store{db: db, rights: rights}
|
||||
}
|
||||
|
||||
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||||
ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendars: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Calendar
|
||||
for rows.Next() {
|
||||
var c Calendar
|
||||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
|
||||
ownerID, id,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetCalendarByRef findet einen Kalender über sein Pfadsegment: entweder die
|
||||
// vom Client gewählte Adresse oder – wenn keine gewählt wurde – die UUID.
|
||||
//
|
||||
// Beides in einer Abfrage, damit der Aufruf nicht raten muss, welche Art
|
||||
// Adresse er gerade in der Hand hält. Die gewählte Adresse hat Vorrang: Nur
|
||||
// so bleibt eine Adresse eindeutig, selbst wenn sie zufällig aussieht wie
|
||||
// die UUID eines anderen Kalenders.
|
||||
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||||
FROM cal_calendars
|
||||
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
||||
ORDER BY path_segment NULLS LAST
|
||||
LIMIT 1`,
|
||||
ownerID, ref,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar by ref: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateCalendarAt legt einen Kalender unter einer **vom Client gewählten**
|
||||
// Adresse an – der Fall MKCALENDAR/MKCOL. Ist die Adresse schon vergeben,
|
||||
// kommt ErrConflict: In WebDAV ist das ein 405, kein stilles Überschreiben.
|
||||
func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment, name, color, description string) (*Calendar, error) {
|
||||
if color == "" {
|
||||
color = "#4285f4"
|
||||
}
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, name, color, description, segment,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert calendar at %q: %w", segment, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
|
||||
// Bedarf "Personal" an. So findet ein frisch eingerichteter Client sofort
|
||||
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
|
||||
// CalDAV-Client kann das.
|
||||
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
|
||||
cals, err := s.ListCalendars(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cals) > 0 {
|
||||
return &cals[0], nil
|
||||
}
|
||||
return s.CreateCalendar(ctx, ownerID, "Personal", "#4285f4", "")
|
||||
}
|
||||
|
||||
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
|
||||
if color == "" {
|
||||
color = "#4285f4"
|
||||
}
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, name, color, description,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert calendar: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpdateCalendar ändert Name/Farbe/Beschreibung/Zeitzone. Ein nil-Feld lässt
|
||||
// die bestehende Spalte unangetastet – so kann der Aufrufer nur das Feld
|
||||
// schicken, das sich wirklich ändert, ohne vorher den ganzen Datensatz zu
|
||||
// laden.
|
||||
func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, color, description, timezone *string) (*Calendar, error) {
|
||||
var c Calendar
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE cal_calendars SET
|
||||
name = COALESCE($3, name),
|
||||
color = COALESCE($4, color),
|
||||
description = COALESCE($5, description),
|
||||
timezone = COALESCE($6, timezone)
|
||||
WHERE owner_id = $1 AND id = $2
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||||
ownerID, id, name, color, description, timezone,
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update calendar: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete calendar: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||||
FROM cal_objects WHERE calendar_id = $1 ORDER BY uid`,
|
||||
calendarID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Object
|
||||
for rows.Next() {
|
||||
o, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) {
|
||||
row := s.db.QueryRow(ctx,
|
||||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||||
FROM cal_objects WHERE calendar_id = $1 AND uid = $2`,
|
||||
calendarID, uid,
|
||||
)
|
||||
o, err := scanObject(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanObject(row rowScanner) (Object, error) {
|
||||
var o Object
|
||||
err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||||
if err != nil {
|
||||
return Object{}, fmt.Errorf("scan object: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben
|
||||
// Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der
|
||||
// Aufrufer vorher – hier zählt nur noch das Schreiben selbst.
|
||||
func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den
|
||||
// Stand tragen, auf den der Kalender damit springt.
|
||||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var o Object
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
|
||||
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
|
||||
sync_seq = EXCLUDED.sync_seq, updated_at = now()
|
||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||||
calendarID, uid, component, data, etag, dtstart, dtend, seq,
|
||||
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert object: %w", err)
|
||||
}
|
||||
|
||||
// Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
|
||||
// Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten
|
||||
// Abgleich beides zu sehen -- die Datei und ihre Todesmeldung.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// MoveObject hängt einen Termin an einen anderen Kalender um. Die UID und der
|
||||
// gesamte .ics-Inhalt bleiben dabei unangetastet – es wechselt nur die
|
||||
// Sammlung, so wie ein MOVE in WebDAV.
|
||||
//
|
||||
// Für CalDAV-Clients ändert sich damit die Adresse des Termins: Sie sehen ihn
|
||||
// in der Quelle verschwinden und im Ziel auftauchen. Deshalb bekommen
|
||||
// **beide** Kalender ein neues ctag – mit nur einem holte der Client die
|
||||
// andere Seite erst beim nächsten Vollabgleich, und der Termin stünde
|
||||
// zwischenzeitlich doppelt oder gar nicht im Telefon.
|
||||
func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid string) (*Object, error) {
|
||||
if fromCal == toCal {
|
||||
return s.GetObjectByUID(ctx, fromCal, uid)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var taken bool
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM cal_objects WHERE calendar_id = $1 AND uid = $2)`,
|
||||
toCal, uid,
|
||||
).Scan(&taken); err != nil {
|
||||
return nil, fmt.Errorf("check target: %w", err)
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
|
||||
// Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin
|
||||
// fort (Grabstein), im Ziel ist er neu.
|
||||
fromSeq, err := bumpSeq(ctx, tx, fromCal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toSeq, err := bumpSeq(ctx, tx, toCal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx,
|
||||
`UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
|
||||
WHERE calendar_id = $3 AND uid = $4
|
||||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||||
toCal, toSeq, fromCal, uid,
|
||||
)
|
||||
o, err := scanObject(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Im Ziel darf kein alter Grabstein derselben UID stehen bleiben.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete object: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pruneTombstones(ctx, tx, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ── Abgleich nur der Änderungen (RFC 6578) ───────────────────────────────────
|
||||
|
||||
// Wie lange ein Grabstein stehen bleibt.
|
||||
//
|
||||
// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann
|
||||
// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen;
|
||||
// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer.
|
||||
// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine
|
||||
// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät.
|
||||
const tombstoneTTL = 180 * 24 * time.Hour
|
||||
|
||||
type execer interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen.
|
||||
//
|
||||
// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen
|
||||
// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über
|
||||
// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander.
|
||||
func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) {
|
||||
var seq int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID,
|
||||
).Scan(&seq); err != nil {
|
||||
return 0, fmt.Errorf("bump ctag: %w", err)
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||||
SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`,
|
||||
calendarID, uid, seq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tombstone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an.
|
||||
//
|
||||
// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis
|
||||
// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die
|
||||
// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort,
|
||||
// in der eine Löschung einfach fehlt, und behielte den Termin für immer.
|
||||
//
|
||||
// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen
|
||||
// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt.
|
||||
func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error {
|
||||
// Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der
|
||||
// ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die
|
||||
// erste Zeile greift, setzt den Boden auf irgendeinen der geräumten
|
||||
// Stände – und ein Client mit einem Token dazwischen bekäme dann eine
|
||||
// Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele.
|
||||
var hoechste *int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`WITH geraeumt AS (
|
||||
DELETE FROM cal_tombstones
|
||||
WHERE calendar_id = $1 AND deleted_at < now() - $2::interval
|
||||
RETURNING sync_seq
|
||||
)
|
||||
SELECT max(sync_seq) FROM geraeumt`,
|
||||
calendarID, tombstoneTTL.String(),
|
||||
).Scan(&hoechste); err != nil {
|
||||
return fmt.Errorf("prune tombstones: %w", err)
|
||||
}
|
||||
if hoechste == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`,
|
||||
calendarID, *hoechste,
|
||||
); err != nil {
|
||||
return fmt.Errorf("raise sync floor: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Changes ist die Antwort auf „was hat sich seit Stand N getan".
|
||||
type Changes struct {
|
||||
// Angelegt oder geändert – beides sieht für den Client gleich aus, er
|
||||
// holt das Objekt in beiden Fällen neu.
|
||||
Changed []Object
|
||||
// UIDs, die es nicht mehr gibt.
|
||||
Deleted []string
|
||||
// Der Stand, auf dem der Client danach steht.
|
||||
Token int64
|
||||
}
|
||||
|
||||
// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan
|
||||
// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen
|
||||
// Bestand – genau das, was ein Client beim ersten Abgleich braucht.
|
||||
//
|
||||
// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind
|
||||
// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre
|
||||
// unvollständig, ohne dass es jemandem auffiele.
|
||||
func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) {
|
||||
if since < 0 || since > cal.CTag {
|
||||
// Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von
|
||||
// einer anderen Instanz oder aus einer wiederhergestellten Sicherung.
|
||||
return nil, false, nil
|
||||
}
|
||||
if since > 0 && since < cal.SyncFloor {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}}
|
||||
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||||
FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||||
cal.ID, since)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("query changed objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
o, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out.Changed = append(out.Changed, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt:
|
||||
// Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm
|
||||
// nur Löschungen für Termine schicken, die er nie gesehen hat.
|
||||
if since == 0 {
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
trows, err := s.db.Query(ctx,
|
||||
`SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||||
cal.ID, since)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("query tombstones: %w", err)
|
||||
}
|
||||
defer trows.Close()
|
||||
for trows.Next() {
|
||||
var uid string
|
||||
if err := trows.Scan(&uid); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out.Deleted = append(out.Deleted, uid)
|
||||
}
|
||||
return out, true, trows.Err()
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aether/internal/auth"
|
||||
)
|
||||
|
||||
// Abgleich nur der Änderungen: REPORT sync-collection, RFC 6578.
|
||||
//
|
||||
// **Wozu.** Bis hierher lief jeder Durchgang so: Der Client fragt das ctag
|
||||
// ab, sieht es verändert und holt daraufhin die *volle* Terminliste. Eine
|
||||
// verschobene Besprechung kostet damit denselben Verkehr wie ein Neuanfang –
|
||||
// bei einem Jahr Termine jedes Mal. Am Telefon zahlt man das in Akku und
|
||||
// Datenvolumen, und zwar bei jedem der viertelstündlichen Durchgänge.
|
||||
//
|
||||
// Mit sync-collection sagt der Client stattdessen „ich bin auf Stand 41" und
|
||||
// bekommt zurück, was seither geschah – und ein neues Token.
|
||||
//
|
||||
// **Warum hier und nicht in der Bibliothek.** go-webdav kennt für REPORT nur
|
||||
// calendar-query und calendar-multiget; alles andere beantwortet es mit 400.
|
||||
// Der Weg geht deshalb an ihr vorbei, wie schon bei PROPFIND und MKCALENDAR.
|
||||
//
|
||||
// **Was ein Token ist.** Eine Zahl: der Stand des Kalenders, also sein ctag.
|
||||
// Kein Zeitstempel – zwei Änderungen in derselben Sekunde wären damit nicht
|
||||
// zu trennen, und eine Uhr, die zurückspringt, ließe Änderungen verschwinden.
|
||||
|
||||
// Präfix des Tokens. Ein Token ist laut RFC ein URI; der Präfix macht
|
||||
// zugleich erkennbar, dass ein Token einer *fremden* Instanz nicht passt.
|
||||
const syncTokenPrefix = "urn:aether:sync:"
|
||||
|
||||
// Ein sync-collection-Rumpf ist eine Handvoll Elemente. Alles darüber ist
|
||||
// kein Abgleich mehr.
|
||||
const maxReportBody = 1 << 20
|
||||
|
||||
type syncReport struct {
|
||||
XMLName xml.Name `xml:"DAV: sync-collection"`
|
||||
SyncToken string `xml:"DAV: sync-token"`
|
||||
SyncLevel string `xml:"DAV: sync-level"`
|
||||
Limit *struct {
|
||||
NResults int `xml:"DAV: nresults"`
|
||||
} `xml:"DAV: limit"`
|
||||
Prop struct {
|
||||
Any []struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
} `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
// wantsCalendarData: Fragt der Client die Termindaten gleich mit ab? Die
|
||||
// meisten holen erst die ETags und danach gezielt per multiget – wer aber
|
||||
// beides in einem Zug will, soll es bekommen.
|
||||
func (s *syncReport) wantsCalendarData() bool {
|
||||
for _, el := range s.Prop.Any {
|
||||
if el.XMLName.Space == nsCalDAV && el.XMLName.Local == "calendar-data" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const nsCalDAV = "urn:ietf:params:xml:ns:caldav"
|
||||
|
||||
// handleReport nimmt sync-collection an sich und lässt alles andere durch.
|
||||
// Rückgabe: true, wenn die Antwort geschrieben wurde.
|
||||
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxReportBody))
|
||||
r.Body.Close()
|
||||
// Was wir nicht selbst beantworten, muss die Bibliothek unverändert
|
||||
// vorfinden – also den gelesenen Rumpf wieder unterlegen.
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err != nil || !isSyncCollection(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
var req syncReport
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "malformed sync-collection request", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
// sync-level ist Pflicht (RFC 6578, 3.3). Bei einem Kalender gibt es nur
|
||||
// eine Ebene; "infinite" bedeutet hier dasselbe wie "1" und wird
|
||||
// angenommen, statt den Client an einer Formalie scheitern zu lassen.
|
||||
if lvl := strings.TrimSpace(req.SyncLevel); lvl != "1" && lvl != "infinite" {
|
||||
http.Error(w, "sync-level must be 1", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||||
if err != nil || rest != "" {
|
||||
// sync-collection gilt für eine Sammlung, nicht für einen Termin.
|
||||
http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
zugriff, err := h.store.ResolveRef(r.Context(), user.ID, ref)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
cal := &zugriff.Calendar
|
||||
|
||||
since, ok := parseSyncToken(req.SyncToken)
|
||||
if !ok {
|
||||
writeInvalidToken(w)
|
||||
return true
|
||||
}
|
||||
|
||||
changes, gueltig, err := h.store.ChangesSince(r.Context(), cal, since)
|
||||
if err != nil {
|
||||
http.Error(w, "sync failed", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if !gueltig {
|
||||
// Das Token ist zu alt oder stammt nicht von uns. Eine unvollständige
|
||||
// Antwort wäre hier das Schlimmste: Der Client hielte sie für den
|
||||
// ganzen Unterschied und behielte gelöschte Termine für immer.
|
||||
writeInvalidToken(w)
|
||||
return true
|
||||
}
|
||||
|
||||
writeSyncResponse(w, user, ref, changes, req.wantsCalendarData())
|
||||
return true
|
||||
}
|
||||
|
||||
// isSyncCollection sieht nach, ob das Wurzelelement sync-collection ist –
|
||||
// ohne den ganzen Rumpf zu deuten. Alles andere geht unverändert weiter an
|
||||
// go-webdav.
|
||||
func isSyncCollection(body []byte) bool {
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if start, ok := tok.(xml.StartElement); ok {
|
||||
return start.Name.Space == "DAV:" && start.Name.Local == "sync-collection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSyncToken liest den Stand aus dem Token. Ein leeres Token heißt
|
||||
// „ich fange bei null an" – der erste Abgleich eines Clients.
|
||||
func parseSyncToken(tok string) (int64, bool) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
return 0, true
|
||||
}
|
||||
if !strings.HasPrefix(tok, syncTokenPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(tok, syncTokenPrefix), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func syncToken(seq int64) string {
|
||||
return syncTokenPrefix + strconv.FormatInt(seq, 10)
|
||||
}
|
||||
|
||||
// writeInvalidToken schickt die Absage, die der RFC dafür vorsieht. Sie ist
|
||||
// kein Fehler im üblichen Sinn: Jeder Client kennt sie und macht daraufhin
|
||||
// einen Vollabgleich – genau das, was wir wollen.
|
||||
func writeInvalidToken(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, xml.Header+
|
||||
`<error xmlns="DAV:"><valid-sync-token/></error>`)
|
||||
}
|
||||
|
||||
// ref ist die Adresse, unter der **dieser** Anfragende den Kalender sieht –
|
||||
// bei einem freigegebenen also dessen UUID, nicht das Pfadsegment des
|
||||
// Eigentümers. Die Antwort muss Adressen nennen, die der Client auch abrufen
|
||||
// kann.
|
||||
func writeSyncResponse(w http.ResponseWriter, user *auth.User,
|
||||
ref string, changes *Changes, withData bool) {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString(`<multistatus xmlns="DAV:" xmlns:C="` + nsCalDAV + `">`)
|
||||
|
||||
for i := range changes.Changed {
|
||||
o := &changes.Changed[i]
|
||||
// Die Anführungszeichen stehen **außerhalb** der Maskierung: Sonst
|
||||
// macht xml.EscapeText daraus `"`. Gültiges XML – aber genug
|
||||
// CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die
|
||||
// sähen dann bei jedem Durchgang eine Änderung, wo keine ist.
|
||||
fmt.Fprintf(&b, `<response><href>%s</href><propstat><prop><getetag>%s</getetag>`,
|
||||
xmlText(objectPath(user.Username, ref, o.UID)), quoteETag(o.ETag))
|
||||
if withData {
|
||||
fmt.Fprintf(&b, `<C:calendar-data>%s</C:calendar-data>`, xmlText(o.Data))
|
||||
}
|
||||
b.WriteString(`</prop><status>HTTP/1.1 200 OK</status></propstat></response>`)
|
||||
}
|
||||
|
||||
// Gelöschtes trägt **keinen** propstat, sondern einen Status direkt an der
|
||||
// Antwort – so unterscheidet der Client „ist weg" von „gibt es, konnte
|
||||
// aber eine Eigenschaft nicht liefern".
|
||||
for _, uid := range changes.Deleted {
|
||||
fmt.Fprintf(&b, `<response><href>%s</href><status>HTTP/1.1 404 Not Found</status></response>`,
|
||||
xmlText(objectPath(user.Username, ref, uid)))
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<sync-token>%s</sync-token></multistatus>`, syncToken(changes.Token))
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, b.String())
|
||||
}
|
||||
|
||||
// quoteETag: In der Datenbank steht der nackte Wert, über die Leitung gehört
|
||||
// er in Anführungszeichen (RFC 9110). go-webdav macht das an anderer Stelle
|
||||
// selbst – hier bauen wir die Antwort von Hand, also hier auch.
|
||||
//
|
||||
// Der Inhalt wird maskiert, die Anführungszeichen nicht: Sie sind Teil der
|
||||
// Syntax, nicht des Werts.
|
||||
func quoteETag(etag string) string {
|
||||
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/`) {
|
||||
return xmlText(etag)
|
||||
}
|
||||
return `"` + xmlText(etag) + `"`
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Tests der Warteschlange für die Texterkennung.
|
||||
//
|
||||
// **Warum ausgerechnet hier.** Ein Fehler in diesem Paket ist *still*: Die
|
||||
// Volltextsuche findet dann einfach nichts – und das sieht genauso aus wie
|
||||
// „steht nicht drin". Niemand bekommt eine Fehlermeldung; man merkt es erst,
|
||||
// wenn man eine Rechnung sucht, von der man weiß, dass man sie hat.
|
||||
//
|
||||
// Geprüft werden deshalb nicht die Abfragen, sondern die drei Zustände, in
|
||||
// denen die Schlange hängenbleiben kann, ohne dass es auffällt:
|
||||
//
|
||||
// 1. Ein Auftrag, der nie aufgibt (Endlosschleife auf einer kaputten Datei).
|
||||
// 2. Ein Auftrag, der nach einem harten Abbruch für immer auf 'running'
|
||||
// steht und nie wieder angefasst wird.
|
||||
// 3. Ein Text nach alter Regel, der nie erneuert wird.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN gegen eine echte Datenbank; ohne wird übersprungen,
|
||||
// damit `go test ./...` überall grün bleibt.
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
// probeDatei legt Konto und Datei an, auf die sich ein Auftrag beziehen kann.
|
||||
// Claim verbindet mit `files`, ein Auftrag ohne Datei wäre also unsichtbar –
|
||||
// und ein Test, der das übersieht, prüft eine leere Schlange.
|
||||
func probeDatei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, marke string) uuid.UUID {
|
||||
t.Helper()
|
||||
var userID uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, 'OCR Probe', 'x', false) RETURNING id`, "ocrprobe-"+marke,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatalf("konto anlegen: %v", err)
|
||||
}
|
||||
var fileID uuid.UUID
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1234, 'deadbeef', $3) RETURNING id`,
|
||||
userID, "probe-"+marke+".pdf", "users/ocrprobe-"+marke+"/home/probe.pdf",
|
||||
).Scan(&fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("datei anlegen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
return fileID
|
||||
}
|
||||
|
||||
func status(t *testing.T, pool *pgxpool.Pool, ctx context.Context, fileID uuid.UUID) (string, int) {
|
||||
t.Helper()
|
||||
var s string
|
||||
var a int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT status, attempts FROM ocr_jobs WHERE file_id = $1`, fileID,
|
||||
).Scan(&s, &a); err != nil {
|
||||
t.Fatalf("stand lesen: %v", err)
|
||||
}
|
||||
return s, a
|
||||
}
|
||||
|
||||
// ── 1. Aufgeben ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Die Zahl selbst ist eine Entscheidung, kein Zufall – und ein Test, der nur
|
||||
// gegen die Konstante läuft, kann sie nicht schützen.
|
||||
//
|
||||
// **Das kam aus einem Mutationsversuch:** Setzt man maxAttempts auf 9999,
|
||||
// blieben alle übrigen Tests grün (sie schleifen ja über die Konstante) und
|
||||
// brauchten nur plötzlich 27 statt 0,2 Sekunden. Genau so verschwindet eine
|
||||
// Entscheidung unbemerkt aus einer Anwendung.
|
||||
//
|
||||
// Zwei Wiederholungen fangen Vorübergehendes ab – Neustart mitten im Lauf,
|
||||
// kurz fehlender Plattenplatz. Was dreimal scheitert, scheitert an der Datei.
|
||||
func TestDreiVersucheSindEineEntscheidung(t *testing.T) {
|
||||
if maxAttempts != 3 {
|
||||
t.Fatalf("maxAttempts steht auf %d. Wenn das Absicht ist, gehört die "+
|
||||
"Begründung an die Konstante und diese Zahl hierher.", maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Datei, die den Erkenner reproduzierbar scheitern lässt – ein kaputtes
|
||||
// oder passwortgeschütztes PDF –, darf die Schlange nicht dauerhaft besetzen.
|
||||
// Ohne Obergrenze liefe der Arbeiter für immer auf derselben Datei und käme an
|
||||
// die übrigen nie heran: still, denn nach außen „läuft" die Erkennung.
|
||||
func TestGibtNachDreiVersuchenAuf(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "aufgeben")
|
||||
|
||||
if err := s.Enqueue(ctx, fileID); err != nil {
|
||||
t.Fatalf("einreihen: %v", err)
|
||||
}
|
||||
|
||||
for i := 1; i <= maxAttempts; i++ {
|
||||
// Obergrenze gegen eine aus dem Ruder gelaufene Konstante: Ohne sie
|
||||
// liefe dieser Test bei maxAttempts = 9999 minutenlang, statt etwas
|
||||
// zu sagen.
|
||||
if i > 10 {
|
||||
t.Fatalf("maxAttempts steht auf %d – das ist keine Wiederholung mehr", maxAttempts)
|
||||
}
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("versuch %d: kein Auftrag mehr (%v) – zu früh aufgegeben", i, err)
|
||||
}
|
||||
if job.FileID != fileID {
|
||||
t.Fatalf("versuch %d: fremder Auftrag", i)
|
||||
}
|
||||
if job.Attempts != i {
|
||||
t.Fatalf("versuch %d: Zähler steht auf %d", i, job.Attempts)
|
||||
}
|
||||
if err := s.Fail(ctx, job, fmt.Errorf("kaputtes PDF")); err != nil {
|
||||
t.Fatalf("versuch %d: Fail: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Jetzt ist Schluss – und das muss *sichtbar* sein, nicht bloß still.
|
||||
if st, a := status(t, pool, ctx, fileID); st != "failed" || a != maxAttempts {
|
||||
t.Fatalf("nach %d Versuchen: status=%q attempts=%d, erwartet failed/%d",
|
||||
maxAttempts, st, a, maxAttempts)
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("der Auftrag wird weiter angefasst – Endlosschleife auf einer kaputten Datei")
|
||||
}
|
||||
|
||||
// Der Grund muss drinstehen. Ein 'failed' ohne Text wäre eine Sackgasse:
|
||||
// Man sieht, dass etwas nicht ging, aber nie was.
|
||||
var fehler string
|
||||
_ = pool.QueryRow(ctx, `SELECT last_error FROM ocr_jobs WHERE file_id = $1`, fileID).Scan(&fehler)
|
||||
if fehler == "" {
|
||||
t.Fatal("kein Fehlertext gespeichert")
|
||||
}
|
||||
}
|
||||
|
||||
// Wird dieselbe Datei erneut hochgeladen, verdient sie eine neue Chance –
|
||||
// vielleicht war genau der kaputte Scan das Problem.
|
||||
func TestNeuerUploadGibtEineNeueChance(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "neuechance")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
for range maxAttempts {
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
_ = s.Fail(ctx, job, fmt.Errorf("kaputt"))
|
||||
}
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "failed" {
|
||||
t.Fatalf("vorbedingung: status=%q", st)
|
||||
}
|
||||
|
||||
// Erneuter Upload.
|
||||
if err := s.Enqueue(ctx, fileID); err != nil {
|
||||
t.Fatalf("erneut einreihen: %v", err)
|
||||
}
|
||||
if st, a := status(t, pool, ctx, fileID); st != "pending" || a != 0 {
|
||||
t.Fatalf("nach erneutem Upload: status=%q attempts=%d, erwartet pending/0", st, a)
|
||||
}
|
||||
if _, err := s.Claim(ctx); err != nil {
|
||||
t.Fatalf("der neue Versuch wird nicht angefasst: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Nach einem harten Abbruch ────────────────────────────────────────────
|
||||
|
||||
// Stirbt der Prozess mitten in einem Auftrag, bleibt dessen Zeile auf
|
||||
// 'running' stehen. Würde Claim nur 'pending' nehmen, wäre diese Datei für
|
||||
// immer verloren – und niemand erführe es, denn die Zahl der offenen Aufträge
|
||||
// sähe gesund aus.
|
||||
func TestNimmtHaengengebliebeneAuftraegeWiederAuf(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "abbruch")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "running" {
|
||||
t.Fatalf("vorbedingung: status=%q", st)
|
||||
}
|
||||
|
||||
// Hier stirbt der Prozess: kein Complete, kein Fail. Die Zeile bleibt
|
||||
// stehen, wie sie ist.
|
||||
_ = job
|
||||
|
||||
wieder, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("hängengebliebener Auftrag wird nie wieder angefasst: %v", err)
|
||||
}
|
||||
if wieder.FileID != fileID {
|
||||
t.Fatal("ein anderer Auftrag kam zurück")
|
||||
}
|
||||
if wieder.Attempts != 2 {
|
||||
t.Fatalf("Zähler steht auf %d, erwartet 2 – sonst zählt der Abbruch nicht mit",
|
||||
wieder.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Wiederaufnahme darf keine Endlosschleife werden: Eine Datei, die den
|
||||
// Arbeiter reproduzierbar umbringt, wäre sonst eine Schleife aus Absturz und
|
||||
// Neustart.
|
||||
func TestWiederaufnahmeIstBegrenzt(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "begrenzt")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
for i := 1; i <= maxAttempts; i++ {
|
||||
if _, err := s.Claim(ctx); err != nil {
|
||||
t.Fatalf("versuch %d: %v", i, err)
|
||||
}
|
||||
// Jedes Mal stirbt der Prozess – der Auftrag bleibt auf 'running'.
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("unbegrenzte Wiederaufnahme: Absturz, Neustart, Absturz …")
|
||||
}
|
||||
}
|
||||
|
||||
// Eine gelöschte Datei darf nicht mehr erkannt werden – der Arbeiter käme
|
||||
// sonst an einen Blob, den es nicht mehr gibt.
|
||||
func TestGeloeschteDateienWerdenNichtGenommen(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "geloescht")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
if _, err := pool.Exec(ctx, `UPDATE files SET is_deleted = true WHERE id = $1`, fileID); err != nil {
|
||||
t.Fatalf("löschen: %v", err)
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("eine gelöschte Datei wurde zur Erkennung genommen")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Text nach alter Regel ────────────────────────────────────────────────
|
||||
|
||||
// Ändert sich die Extraktionsregel, müssen alte Einträge erneuert werden.
|
||||
// Sonst bleibt ein stiller Rest: auffindbar, aber im Betrachter nicht auf der
|
||||
// richtigen Seite – und niemand sieht, dass diese Dateien anders sind.
|
||||
func TestErneuertTextNachAltererRegel(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "veraltet")
|
||||
|
||||
// Ein Text, der nach einer älteren Regel entstanden ist.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
|
||||
VALUES ($1, 'alter text', 'ocr', 'deu', 10, 1, $2)`,
|
||||
fileID, ExtractVersion-1); err != nil {
|
||||
t.Fatalf("alten text anlegen: %v", err)
|
||||
}
|
||||
|
||||
n, err := s.EnqueueOutdated(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueOutdated: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("der veraltete Eintrag wurde nicht erneut eingeplant")
|
||||
}
|
||||
if st, a := status(t, pool, ctx, fileID); st != "pending" || a != 0 {
|
||||
t.Fatalf("status=%q attempts=%d, erwartet pending/0", st, a)
|
||||
}
|
||||
|
||||
// Und einer auf aktuellem Stand darf **nicht** erneut eingeplant werden –
|
||||
// sonst erkennt der Server bei jedem Start den ganzen Bestand neu.
|
||||
_, _ = pool.Exec(ctx, `UPDATE file_text SET extract_version = $2 WHERE file_id = $1`,
|
||||
fileID, ExtractVersion)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ocr_jobs WHERE file_id = $1`, fileID)
|
||||
n, err = s.EnqueueOutdated(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueOutdated (aktuell): %v", err)
|
||||
}
|
||||
var da bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM ocr_jobs WHERE file_id = $1)`, fileID).Scan(&da)
|
||||
if da {
|
||||
t.Fatalf("ein aktueller Eintrag wurde erneut eingeplant (%d Zeilen) – der Bestand würde bei jedem Start neu erkannt", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Abschluss ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Complete muss Text **und** Auftragsende zusammen schreiben. Ginge nur eines
|
||||
// durch, wäre die Datei entweder unauffindbar oder würde bei jedem Durchgang
|
||||
// erneut erkannt.
|
||||
func TestCompleteSchreibtTextUndSchliesstAb(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
fileID := probeDatei(t, pool, ctx, "complete")
|
||||
|
||||
_ = s.Enqueue(ctx, fileID)
|
||||
job, err := s.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
res := Result{Text: "Seite eins\fSeite zwei", Source: "ocr", Lang: "deu", Chars: 20, Pages: 2}
|
||||
if err := s.Complete(ctx, job, res); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
if st, _ := status(t, pool, ctx, fileID); st != "done" {
|
||||
t.Fatalf("status=%q, erwartet done", st)
|
||||
}
|
||||
seiten, quelle, err := s.TextPages(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("TextPages: %v", err)
|
||||
}
|
||||
if quelle != "ocr" {
|
||||
t.Fatalf("quelle=%q", quelle)
|
||||
}
|
||||
// Die Seitenmarke ist der Grund, aus dem es ExtractVersion gibt: Ohne sie
|
||||
// kann der Betrachter eine Fundstelle nicht auf einer Seite verorten.
|
||||
if len(seiten) != 2 {
|
||||
t.Fatalf("%d Seiten, erwartet 2 – Seitenmarke ging verloren", len(seiten))
|
||||
}
|
||||
if _, err := s.Claim(ctx); !errors.Is(err, ErrNoJob) {
|
||||
t.Fatal("ein fertiger Auftrag wird erneut genommen")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||
@@ -67,7 +68,13 @@ func (w *Worker) Run(ctx context.Context) {
|
||||
|
||||
for {
|
||||
wait := idleInterval
|
||||
if w.step(ctx) {
|
||||
// **Je Auftrag abgesichert, nicht je Arbeiter.** Fängt man eine Panik
|
||||
// um die ganze Schleife ab, überlebt zwar der Prozess – aber der
|
||||
// Arbeiter ist tot, und von da an wird still keine Datei mehr erkannt.
|
||||
// So kostet ein unverdauliches Dokument genau sich selbst.
|
||||
var etwasGetan bool
|
||||
bg.Run("Texterkennung", func() { etwasGetan = w.step(ctx) })
|
||||
if etwasGetan {
|
||||
wait = busyInterval
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -486,20 +484,25 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
|
||||
// Verify checksum while streaming.
|
||||
h256 := sha256.New()
|
||||
reader := io.TeeReader(rc, h256)
|
||||
|
||||
if _, err := io.Copy(w, reader); err != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
// Log corruption; headers already sent, can't change status.
|
||||
// TODO: alert / observability hook
|
||||
_ = actual
|
||||
}
|
||||
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
|
||||
// zurückhalten**, bis sie stimmt.
|
||||
//
|
||||
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
|
||||
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
|
||||
// dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte
|
||||
// wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv,
|
||||
// in das man einmal im Jahr sieht, die teuerste Art von Stille.
|
||||
//
|
||||
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
|
||||
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
|
||||
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
|
||||
// Verbindung noch fallen lassen, und der Browser sieht einen
|
||||
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
|
||||
ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
||||
@@ -715,6 +718,13 @@ func (h *Handler) GetThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Auch aus dem Papierkorb ausliefern. Das Vorschaubild überlebt das
|
||||
// weiche Löschen (entfernt wird es erst beim endgültigen), und ohne
|
||||
// diesen zweiten Griff stünde dort nur ein graues Quadrat – man
|
||||
// entschiede über „endgültig löschen", ohne zu sehen, was es war.
|
||||
f, err = h.store.GetTrashedFile(r.Context(), id, owner.OwnerID)
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
@@ -755,7 +765,10 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
|
||||
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
|
||||
// auch wenn das Grundbild wechselt, und lässt sich testen.
|
||||
var extraMIMETypes = map[string]string{
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
// Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit
|
||||
// die Oberflaeche sie vom Texteditor fernhaelt.
|
||||
".excalidraw": "application/json",
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
|
||||
".wav": "audio/wav", ".flac": "audio/flac",
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/thumbnails"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein
|
||||
// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und
|
||||
// halten trotzdem jeden Unfug vom Dekodierer fern.
|
||||
const maxThumbUpload = 2 << 20
|
||||
|
||||
// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen
|
||||
// kann.
|
||||
//
|
||||
// POST /api/v1/files/{id}/thumbnail
|
||||
//
|
||||
// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst
|
||||
// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort
|
||||
// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung
|
||||
// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann.
|
||||
//
|
||||
// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt,
|
||||
// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch
|
||||
// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren,
|
||||
// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
|
||||
// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen.
|
||||
func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
// Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie
|
||||
// sie in der Liste aussieht.
|
||||
owner, ok := h.resolveFile(w, r, id, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
typ := r.Header.Get("Content-Type")
|
||||
if !thumbnails.SupportedMIME(typ) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image")
|
||||
return
|
||||
}
|
||||
|
||||
// MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung
|
||||
// ab, statt erst alles zu lesen und dann abzulehnen.
|
||||
rc := http.MaxBytesReader(w, r.Body, maxThumbUpload)
|
||||
defer rc.Close()
|
||||
|
||||
if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten")
|
||||
response.Error(w, http.StatusBadRequest, "could not read that image")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -104,6 +104,10 @@ func (h *Handler) EmptyTrash(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, id := range fileIDs {
|
||||
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
|
||||
// Das als PDF gewandelte Office-Dokument. Es trägt denselben Inhalt
|
||||
// wie die Datei – bleibt es liegen, überlebt der Inhalt das endgültige
|
||||
// Löschen.
|
||||
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
|
||||
}
|
||||
|
||||
log.Ctx(r.Context()).Info().
|
||||
@@ -139,6 +143,7 @@ func (h *Handler) HardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_ = h.storage.Delete(r.Context(), key)
|
||||
}
|
||||
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
|
||||
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
|
||||
h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash")
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -37,10 +38,12 @@ func scanFile(row interface{ Scan(...any) error }, f *File) error {
|
||||
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||
var out File
|
||||
err := scanFile(s.db.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
|
||||
versioning_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING `+fileCols,
|
||||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||||
versioniertVonAnfang(f.Name),
|
||||
), &out)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
|
||||
// behalten.
|
||||
//
|
||||
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
|
||||
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
|
||||
// unbemerkt voll.
|
||||
//
|
||||
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
|
||||
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
|
||||
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
|
||||
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
|
||||
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
|
||||
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
|
||||
// ohne Deckung.
|
||||
func versioniertVonAnfang(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
|
||||
}
|
||||
|
||||
// GetFileByID fetches a file without owner check (used by public share downloads).
|
||||
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
||||
var f File
|
||||
@@ -533,3 +554,27 @@ func isUniqueViolation(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||
// entstanden ist – 0 heißt: es gibt keins.
|
||||
//
|
||||
|
||||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||||
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
|
||||
func (s *Store) FilesWithPreview(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT id::text FROM files WHERE preview_version > 0`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("files with preview: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,7 +73,12 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars"} {
|
||||
// "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die
|
||||
// als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und
|
||||
// die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so
|
||||
// sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste,
|
||||
// bleiben sie für immer liegen.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
res.Scanned++
|
||||
if referenced[key] {
|
||||
@@ -233,7 +239,9 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||
// Hochfahren konkurriert.
|
||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
go func() {
|
||||
// Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein
|
||||
// Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll.
|
||||
bg.Go("Speicherbereinigung", func() {
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -261,5 +269,40 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
}
|
||||
timer.Reset(Interval)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
||||
//
|
||||
// Vorschaubilder, Profilbilder und die als PDF gewandelten Office-Dokumente
|
||||
// zählen nicht gegen das Kontingent eines Kontos – niemand hat sie
|
||||
// hochgeladen, und sie entstehen jederzeit neu. Platte kosten sie trotzdem,
|
||||
// und in der Anzeige „X von Y GB belegt" tauchen sie nicht auf.
|
||||
//
|
||||
// Genau deshalb steht die Zahl hier: Ein Ordner, der wächst, ohne dass ihn
|
||||
// irgendwo jemand sieht, ist eine stille Überraschung. Sichtbar ist sie eine
|
||||
// Größe, über die man entscheiden kann.
|
||||
type DerivedUsage struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Derived zählt die abgeleiteten Ordner durch. Reines Lesen – dieser Aufruf
|
||||
// löscht nichts.
|
||||
func (c *Collector) Derived(ctx context.Context) ([]DerivedUsage, error) {
|
||||
out := make([]DerivedUsage, 0, 3)
|
||||
for _, prefix := range []string{"thumbs", "previews", "avatars"} {
|
||||
u := DerivedUsage{Prefix: prefix}
|
||||
err := c.storage.Walk(ctx, prefix, func(_ string, info storage.FileInfo) error {
|
||||
u.Files++
|
||||
u.Bytes += info.Size
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -48,7 +48,7 @@ type Client struct {
|
||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||
// keinem Browser.
|
||||
publicBase string
|
||||
http *http.Client
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
actions map[string]string // ".docx|edit" -> urlsrc
|
||||
@@ -124,15 +124,33 @@ func (c *Client) Provider() string { return c.provider }
|
||||
//
|
||||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Am Finger zuerst die Handy-Fassung.
|
||||
//
|
||||
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
|
||||
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
|
||||
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
|
||||
// Schirme gebaute Oberfläche. Aether hat bisher immer die
|
||||
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
|
||||
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
|
||||
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
|
||||
// wir selbst falsch bestellt hatten.
|
||||
//
|
||||
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
|
||||
// Eintrag, und es bleibt beim bisherigen Verhalten.
|
||||
order := []string{"edit", "view"}
|
||||
if !write {
|
||||
switch {
|
||||
case mobile && write:
|
||||
order = []string{"mobileEdit", "edit", "mobileView", "view"}
|
||||
case mobile:
|
||||
order = []string{"mobileView", "view", "mobileEdit", "edit"}
|
||||
case !write:
|
||||
order = []string{"view", "edit"}
|
||||
}
|
||||
for _, action := range order {
|
||||
|
||||
@@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
write := access.Permission == acl.PermWrite
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
||||
// Finger oder Maus? Das entscheidet die Oberfläche, nicht der Server:
|
||||
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus.
|
||||
// Deshalb fragen wir nicht die Kennung des Browsers, sondern lassen die
|
||||
// Anwendung sagen, was sie über ihr Zeigegerät weiß.
|
||||
mobile := r.URL.Query().Get("mobile") == "1"
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write, mobile)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// checksumOf liest die Datei einmal ganz und liefert Prüfsum und Größe.
|
||||
//
|
||||
// Ohne sie ginge es schneller – aber eine Datei ohne Prüfsum in der
|
||||
// Datenbank ist eine Datei, deren Beschädigung beim Herunterladen niemand
|
||||
// bemerkt. Das ist der Punkt, an dem Aether sonst gerade nicht spart.
|
||||
// Der Anfang der Datei genügt zur Typerkennung – http.DetectContentType
|
||||
// sieht sich ohnehin nur die ersten 512 Bytes an.
|
||||
const sniffLen = 512
|
||||
|
||||
func (s *Scanner) checksumOf(ctx context.Context, key string) (sum string, size int64, head []byte, err error) {
|
||||
rc, err := s.storage.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
h := sha256.New()
|
||||
// Die Datei wird für die Prüfsumme ohnehin einmal ganz gelesen; den
|
||||
// Anfang dabei mitzunehmen kostet nichts und erspart einen zweiten Weg
|
||||
// über die Platte.
|
||||
kopf := &bytes.Buffer{}
|
||||
n, err := io.Copy(h, io.TeeReader(rc, &begrenzterSchreiber{buf: kopf, rest: sniffLen}))
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, kopf.Bytes(), nil
|
||||
}
|
||||
|
||||
// begrenzterSchreiber nimmt nur die ersten rest Bytes an und wirft den Rest
|
||||
// weg – ohne das läge bei einer 5-GB-Datei alles im Arbeitsspeicher.
|
||||
type begrenzterSchreiber struct {
|
||||
buf *bytes.Buffer
|
||||
rest int
|
||||
}
|
||||
|
||||
func (w *begrenzterSchreiber) Write(p []byte) (int, error) {
|
||||
if w.rest > 0 {
|
||||
n := min(w.rest, len(p))
|
||||
w.buf.Write(p[:n])
|
||||
w.rest -= n
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// mimeOf bestimmt den Dateityp – erst über die Endung, sonst über den
|
||||
// Inhalt.
|
||||
//
|
||||
// Der Umweg über den Inhalt ist nötig, nicht schmückend: Das Alpine-Abbild
|
||||
// bringt keine /etc/mime.types mit, und Gos eingebaute Liste kennt gerade
|
||||
// einmal ein Dutzend Endungen – .txt gehört nicht dazu. Ohne den zweiten
|
||||
// Anlauf bekäme jede Textdatei "application/octet-stream" und würde in der
|
||||
// Vorschau nicht angezeigt.
|
||||
//
|
||||
// Beim Hochladen fällt das nie auf, weil der Browser den Typ mitschickt.
|
||||
// Eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt.
|
||||
func mimeOf(name string, head []byte) string {
|
||||
if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); t != "" {
|
||||
return ohneParameter(t)
|
||||
}
|
||||
if len(head) > 0 {
|
||||
return ohneParameter(http.DetectContentType(head))
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// "text/plain; charset=utf-8" → "text/plain", so wie es der Upload-Weg auch
|
||||
// speichert.
|
||||
func ohneParameter(t string) string {
|
||||
if i := strings.Index(t, ";"); i > 0 {
|
||||
return strings.TrimSpace(t[:i])
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resolveOwner findet heraus, wem eine gefundene Datei gehört: das Konto
|
||||
// mit diesem Namen, oder die Gruppe mit dieser Kennung.
|
||||
func (s *Scanner) resolveOwner(ctx context.Context, root, owner string) (ownerID, groupID *uuid.UUID, err error) {
|
||||
if root == "groups" {
|
||||
id, err := uuid.Parse(owner)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("keine Gruppenkennung: %q", owner)
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM groups WHERE id = $1)`, id).Scan(&exists); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("Gruppe %s gibt es nicht", owner)
|
||||
}
|
||||
return nil, &id, nil
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE username = $1`, owner).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, fmt.Errorf("Konto %q gibt es nicht", owner)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &id, nil, nil
|
||||
}
|
||||
|
||||
// ensureFolders legt die Ordnerkette an, in der die Datei liegt, und liefert
|
||||
// den innersten Ordner. Ein Ordner, den es schon gibt, wird benutzt.
|
||||
func (s *Scanner) ensureFolders(ctx context.Context, ownerID, groupID *uuid.UUID, folderPath string) (*uuid.UUID, error) {
|
||||
if folderPath == "" {
|
||||
return nil, nil // liegt direkt im Wurzelverzeichnis
|
||||
}
|
||||
var parent *uuid.UUID
|
||||
for _, name := range strings.Split(folderPath, "/") {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
// COALESCE statt zweier Abfragen: Ein Ordner hängt entweder an einem
|
||||
// Konto oder an einer Gruppe, nie an beidem.
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id FROM folders
|
||||
WHERE COALESCE(owner_id, group_id) = COALESCE($1::uuid, $2::uuid)
|
||||
AND name = $3
|
||||
AND parent_id IS NOT DISTINCT FROM $4`,
|
||||
ownerID, groupID, name, parent,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO folders (owner_id, group_id, parent_id, name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
ownerID, groupID, parent, name,
|
||||
).Scan(&id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ordner %q: %w", name, err)
|
||||
}
|
||||
copy := id
|
||||
parent = ©
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// adopt macht aus einer Datei auf der Platte einen Entry in Aether.
|
||||
func (s *Scanner) adopt(ctx context.Context, root, owner, folder, name, key string) error {
|
||||
ownerID, groupID, err := s.resolveOwner(ctx, root, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := s.ensureFolders(ctx, ownerID, groupID, folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, size, head, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ownerID, groupID, folderID, name, mimeOf(name, head), size, sum, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Entry anlegen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// correct übernimmt Größe und Prüfsum einer von außen ersetzten Datei.
|
||||
func (s *Scanner) correct(ctx context.Context, id uuid.UUID, key string) error {
|
||||
sum, size, _, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(ctx,
|
||||
`UPDATE files SET size = $2, checksum = $3, updated_at = now() WHERE id = $1`,
|
||||
id, size, sum)
|
||||
return err
|
||||
}
|
||||
|
||||
// remove räumt einen Entry weg, dessen Datei nicht mehr exists ist – und mit
|
||||
// ihm alles, was nur wegen dieser Datei existierte: frühere Fassungen,
|
||||
// Vorschaubild, umgewandelte Ansicht.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: erst die Schlüssel der früheren Fassungen
|
||||
// sample, dann die Zeile löschen (die Kaskade nimmt die Versionszeilen mit),
|
||||
// dann die Bytes. Andersherum bliebe bei einem Abbruch eine Zeile stehen, die
|
||||
// auf nichts mehr zeigt – also genau das, was hier aufgeräumt werden soll.
|
||||
func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error {
|
||||
rows, err := s.db.Query(ctx, `SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("frühere Fassungen lesen: %w", err)
|
||||
}
|
||||
var versionKeys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
versionKeys = append(versionKeys, k)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("Entry remove: %w", err)
|
||||
}
|
||||
|
||||
// Ab hier ist der Entry weg; was jetzt noch scheitert, ist ein Rest auf
|
||||
// der Platte, den der GC beim nächsten Lauf ohnehin einsammelt.
|
||||
for _, k := range versionKeys {
|
||||
_ = s.storage.Delete(ctx, k)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Der Abgleich ist das einzige Stück Aether, das **hart** löscht:
|
||||
// `DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang über
|
||||
// die Platte nicht gesehen wurde.
|
||||
//
|
||||
// Der Ernstfall ist nicht ausgedacht: `LocalFS.Walk` gibt für ein nicht
|
||||
// vorhandenes Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam.
|
||||
// Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit
|
||||
// `?anwenden=true` löschte den ganzen Bestand aus der Datenbank.
|
||||
//
|
||||
// Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der
|
||||
// gefährlicheren von beiden Stellen.
|
||||
|
||||
func TestNotbremseBeiLeeremSpeicher(t *testing.T) {
|
||||
// Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar.
|
||||
grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false)
|
||||
if grund == "" {
|
||||
t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen")
|
||||
}
|
||||
if uebersteuerbar {
|
||||
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||
}
|
||||
// Auch mit ausdruecklicher Bestaetigung nicht.
|
||||
if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" {
|
||||
t.Fatal("auch mit Bestaetigung muss die Notbremse halten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeereInstallationDarfDurch(t *testing.T) {
|
||||
// Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu
|
||||
// verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie
|
||||
// abgleichen.
|
||||
if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" {
|
||||
t.Fatalf("leere Installation darf nicht bremsen: %s", grund)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMengenbremseLaesstSichBestaetigen(t *testing.T) {
|
||||
// Mehr als die Hälfte auf einmal: erst nachfragen.
|
||||
grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false)
|
||||
if grund == "" {
|
||||
t.Fatal("mehr als die Haelfte muss nachfragen")
|
||||
}
|
||||
if !uebersteuerbar {
|
||||
t.Fatal("die Mengenbremse muss sich bestaetigen lassen")
|
||||
}
|
||||
if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" {
|
||||
t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) {
|
||||
// Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die
|
||||
// Funktion unbrauchbar – und jemand baute die Bremse wieder aus.
|
||||
for _, f := range []struct {
|
||||
gescannt, lebende, fallen int
|
||||
}{
|
||||
{1000, 1000, 1},
|
||||
{1000, 1000, 17},
|
||||
{1000, 1000, 500}, // genau die Hälfte: noch erlaubt
|
||||
{1, 1, 0},
|
||||
} {
|
||||
if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" {
|
||||
t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein
|
||||
// Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch
|
||||
// da sein.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/
|
||||
func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var nutzer uuid.UUID
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8],
|
||||
).Scan(&nutzer); err != nil {
|
||||
t.Fatalf("konto: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`,
|
||||
nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(),
|
||||
); err != nil {
|
||||
t.Fatalf("datei: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das
|
||||
// sieht der Server, wenn der Bind-Mount nicht hochkam.
|
||||
wurzel := t.TempDir()
|
||||
st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp"))
|
||||
if err != nil {
|
||||
t.Fatalf("speicher: %v", err)
|
||||
}
|
||||
|
||||
scanner := New(pool, files.NewStore(pool), st)
|
||||
bericht, err := scanner.Apply(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("anwenden: %v", err)
|
||||
}
|
||||
|
||||
if !bericht.Aborted {
|
||||
t.Fatal("der Lauf haette abbrechen muessen")
|
||||
}
|
||||
if bericht.Removed != 0 {
|
||||
t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed)
|
||||
}
|
||||
if bericht.Overridable {
|
||||
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||
}
|
||||
|
||||
var uebrig int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||
t.Fatalf("zaehlen: %v", err)
|
||||
}
|
||||
if uebrig != 5 {
|
||||
t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig)
|
||||
}
|
||||
|
||||
// Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen.
|
||||
if _, err := scanner.Apply(ctx, true); err != nil {
|
||||
t.Fatalf("anwenden mit Bestaetigung: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||
t.Fatalf("zaehlen: %v", err)
|
||||
}
|
||||
if uebrig != 5 {
|
||||
t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung,
|
||||
// die der GC nicht abdeckt.
|
||||
//
|
||||
// Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr
|
||||
// gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für
|
||||
// Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer
|
||||
// Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar
|
||||
// – und der GC hält sie nach 24 Stunden für Reste.
|
||||
//
|
||||
// Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf,
|
||||
// der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen.
|
||||
// Hier geht es um Dateien.
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die
|
||||
// Antwort nicht ins Uferlose wächst.
|
||||
const (
|
||||
maxSamples = 200
|
||||
maxDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank.
|
||||
// Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen
|
||||
// Verzeichnis sie liegt.
|
||||
KindNew Kind = "new"
|
||||
// KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt
|
||||
// Vorschaubild und früheren Fassungen.
|
||||
KindOrphaned Kind = "orphaned"
|
||||
// KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei
|
||||
// von außen ersetzt. Apply übernimmt Größe und Prüfsumme.
|
||||
KindChanged Kind = "changed"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Kind Kind `json:"kind"`
|
||||
StorageKey string `json:"storage_key"`
|
||||
// Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte.
|
||||
Path string `json:"path"`
|
||||
Owner string `json:"owner"`
|
||||
Size int64 `json:"size"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
New int `json:"new"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
Changed int `json:"changed"`
|
||||
Scanned int `json:"scanned"`
|
||||
Samples []Entry `json:"samples"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
// AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true"
|
||||
// ohne Grund lässt genau die Frage offen, die man dann hat.
|
||||
AbortReason string `json:"abort_reason,omitempty"`
|
||||
// Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt
|
||||
// sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen
|
||||
// Knopf anbieten, der nichts bewirkt.
|
||||
Overridable bool `json:"overridable,omitempty"`
|
||||
|
||||
// Nur bei Apply gefüllt.
|
||||
Adopted int `json:"adopted,omitempty"`
|
||||
Removed int `json:"removed,omitempty"`
|
||||
Corrected int `json:"corrected,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
db *pgxpool.Pool
|
||||
store *files.Store
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner {
|
||||
return &Scanner{db: db, store: store, storage: st}
|
||||
}
|
||||
|
||||
// known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über
|
||||
// den Ablagepfad auffindbar.
|
||||
type known struct {
|
||||
id uuid.UUID
|
||||
size int64
|
||||
deleted bool
|
||||
seen bool
|
||||
}
|
||||
|
||||
func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read known files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]*known, 1024)
|
||||
for rows.Next() {
|
||||
var k known
|
||||
var key string
|
||||
if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil {
|
||||
return nil, fmt.Errorf("scan file row: %w", err)
|
||||
}
|
||||
row := k
|
||||
out[key] = &row
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// splitKey liest aus einem Ablagepfad, wohin die Datei gehört:
|
||||
//
|
||||
// users/{name}/home/{ordner...}/{datei}
|
||||
// groups/{id}/home/{ordner...}/{datei}
|
||||
//
|
||||
// Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp)
|
||||
// ist kein Nutzerinhalt und wird nicht angefasst.
|
||||
func splitKey(key string) (root, owner, folderPath, name string, ok bool) {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) < 4 {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[0] != "users" && parts[0] != "groups" {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[2] != "home" {
|
||||
return "", "", "", "", false // .trash und .versions gehören dem System
|
||||
}
|
||||
name = parts[len(parts)-1]
|
||||
folderPath = strings.Join(parts[3:len(parts)-1], "/")
|
||||
return parts[0], parts[1], folderPath, name, name != ""
|
||||
}
|
||||
|
||||
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
||||
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) }
|
||||
|
||||
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
||||
// Einträge.
|
||||
// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse.
|
||||
func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) {
|
||||
return s.run(ctx, true, trotzdem)
|
||||
}
|
||||
|
||||
func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
||||
defer cancel()
|
||||
|
||||
known, err := s.knownFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null,
|
||||
// und kein Aufrufer braucht dafür einen Sonderfall.
|
||||
rep := &Report{Samples: []Entry{}}
|
||||
sample := func(e Entry) {
|
||||
if len(rep.Samples) < maxSamples {
|
||||
rep.Samples = append(rep.Samples, e)
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range []string{"users/", "groups/"} {
|
||||
err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
if ctx.Err() != nil {
|
||||
rep.Aborted = true
|
||||
return ctx.Err()
|
||||
}
|
||||
root, owner, folder, name, ok := splitKey(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rep.Scanned++
|
||||
|
||||
if row, exists := known[key]; exists {
|
||||
row.seen = true
|
||||
if row.deleted {
|
||||
return nil // liegt im Papierkorb – kein Widerspruch
|
||||
}
|
||||
if row.size != info.Size {
|
||||
rep.Changed++
|
||||
e := Entry{Kind: KindChanged, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.correct(ctx, row.id, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Corrected++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rep.New++
|
||||
e := Entry{Kind: KindNew, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.adopt(ctx, root, owner, folder, name, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Adopted++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
||||
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
||||
// pauschal für verwaist zu erklären.
|
||||
// **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in
|
||||
// der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die
|
||||
// Hälfte schon weg ist, und eine Bremse käme zu spät.
|
||||
type waise struct {
|
||||
key string
|
||||
id uuid.UUID
|
||||
}
|
||||
var waisen []waise
|
||||
var lebende int
|
||||
for key, row := range known {
|
||||
if !row.deleted {
|
||||
lebende++
|
||||
}
|
||||
if row.seen || row.deleted {
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, ok := splitKey(key); !ok {
|
||||
if _, err := s.storage.Stat(ctx, key); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
waisen = append(waisen, waise{key: key, id: row.id})
|
||||
}
|
||||
rep.Orphaned = len(waisen)
|
||||
|
||||
if apply {
|
||||
if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" {
|
||||
rep.Aborted = true
|
||||
rep.AbortReason = grund
|
||||
rep.Overridable = uebersteuerbar
|
||||
log.Ctx(ctx).Error().
|
||||
Int("scanned", rep.Scanned).
|
||||
Int("lebende_eintraege", lebende).
|
||||
Int("wuerden_fallen", len(waisen)).
|
||||
Str("grund", grund).
|
||||
Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus")
|
||||
// Die Waisen trotzdem in die Stichprobe, damit man im Bericht
|
||||
// sieht, worum es gegangen wäre.
|
||||
for _, o := range waisen {
|
||||
sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key})
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range waisen {
|
||||
e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}
|
||||
if apply {
|
||||
if err := s.remove(ctx, o.id); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Removed++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── Die Bremsen ──────────────────────────────────────────────────────────────
|
||||
|
||||
// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr
|
||||
// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist
|
||||
// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner
|
||||
// entfernt hat, bestätigt einmal und kommt durch.
|
||||
const mengenAnteil = 2
|
||||
|
||||
// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe-
|
||||
// wert heißt: ja.
|
||||
//
|
||||
// **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen
|
||||
// Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben
|
||||
// denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC
|
||||
// löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt
|
||||
// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die
|
||||
// Datei gab. Die gefährlichere Seite war die ungesicherte.
|
||||
//
|
||||
// Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht
|
||||
// vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
|
||||
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam,
|
||||
// oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede
|
||||
// Datei als verwaist.
|
||||
func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) {
|
||||
// **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen,
|
||||
// obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der
|
||||
// verschwunden ist, das ist ein Speicher, der nicht da war.
|
||||
if gescannt == 0 && lebende > 0 {
|
||||
return "auf der Platte wurde keine einzige Datei gefunden, " +
|
||||
"obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " +
|
||||
"nicht eingehaengt. Es wurde nichts geloescht.", false
|
||||
}
|
||||
|
||||
// **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die
|
||||
// Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor.
|
||||
if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende {
|
||||
return fmt.Sprintf(
|
||||
"%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+
|
||||
"Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.",
|
||||
wuerdenFallen, lebende), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,33 +1,58 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Ein öffentlicher Endpunkt mit Passwort braucht dieselbe Bremse wie /dav
|
||||
// und /caldav: Ohne sie kann man Passwörter unbegrenzt durchprobieren – und
|
||||
// weil jeder Versuch Argon2 rechnet, ist das zugleich ein billiger Weg, den
|
||||
// Server zu beschäftigen.
|
||||
const (
|
||||
shareMaxFailures = 10
|
||||
shareWindow = 15 * time.Minute
|
||||
shareLockout = 15 * time.Minute
|
||||
|
||||
// Zehn Jahre. Grosszügig gemeint – es geht nicht darum, kurze Fristen zu
|
||||
// erzwingen, sondern den Überlauf abzufangen.
|
||||
maxExpiryHours = 10 * 365 * 24
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
fileStore *files.Store
|
||||
storage storage.Storage
|
||||
auditor *audit.Logger
|
||||
// acl löst auf, in wessen Namen die Datei liegt. Ohne ihn ging nur, was
|
||||
// dem Anfragenden persönlich gehört – siehe Create.
|
||||
acl *acl.Store
|
||||
limiter *middleware.FailureLimiter
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
|
||||
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor}
|
||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler {
|
||||
return &Handler{
|
||||
store: store, fileStore: fileStore, storage: st, auditor: auditor,
|
||||
acl: aclStore,
|
||||
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/files/{id}/share
|
||||
@@ -38,7 +63,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); err != nil {
|
||||
// **Prinzipal statt Konto.** Vorher stand hier `user.ID` – damit liess sich
|
||||
// eine Datei in einem Gruppenordner nicht per Link teilen, auch nicht von
|
||||
// einem Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete
|
||||
// „file not found", was zwar sicher, aber schlicht falsch war: Der
|
||||
// Empfänger eines Links sieht ohnehin nur die Datei, nie den Ordner
|
||||
// darum.
|
||||
//
|
||||
// **Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst,
|
||||
// die Datei öffentlich zu machen. Wer sie nur geliehen lesen darf, soll
|
||||
// sie nicht weiterreichen können.
|
||||
zugriff, ok := h.resolve(w, r, fileID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, zugriff.OwnerID); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
@@ -60,6 +99,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var expiresAt *time.Time
|
||||
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
||||
// Obergrenze, weil `time.Duration` in Nanosekunden rechnet: Ab etwa
|
||||
// 2,6 Millionen Stunden läuft int64 über, die Dauer wird **negativ**,
|
||||
// und der Link ist in dem Moment abgelaufen, in dem er entsteht – ohne
|
||||
// Fehlermeldung. Wer eine unsinnige Zahl schickt, soll das erfahren,
|
||||
// statt einen toten Link zu bekommen.
|
||||
if *body.ExpiresIn > maxExpiryHours {
|
||||
response.Error(w, http.StatusBadRequest,
|
||||
"expires_in_hours is too large (at most 10 years)")
|
||||
return
|
||||
}
|
||||
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
@@ -147,11 +196,19 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
// Nur, was die öffentliche Seite wirklich braucht. Das ganze
|
||||
// Datenbankobjekt durchzureichen war bequem, gab aber owner_id und die
|
||||
// internen Kennungen an jeden heraus, der den Link hat: Zwei Links
|
||||
// desselben Besitzers ließen sich so einander zuordnen, ohne dass
|
||||
// irgendwer angemeldet ist.
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"link": link,
|
||||
"file_name": f.Name,
|
||||
"file_size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
"has_password": link.HasPassword,
|
||||
"expires_at": link.ExpiresAt,
|
||||
"download_count": link.DownloadCount,
|
||||
"created_at": link.CreatedAt,
|
||||
"file_name": f.Name,
|
||||
"file_size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,15 +226,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if link.HasPassword {
|
||||
clientIP := middleware.RealIP(r)
|
||||
if erlaubt, retryAfter := h.limiter.Allowed(clientIP); !erlaubt {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
|
||||
response.Error(w, http.StatusTooManyRequests, "too many failed attempts")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
||||
if !ok {
|
||||
h.limiter.Fail(clientIP)
|
||||
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(clientIP)
|
||||
}
|
||||
|
||||
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
||||
@@ -193,10 +258,53 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
go h.store.IncrementDownloads(r.Context(), token)
|
||||
// context.WithoutCancel, nicht r.Context(): Der Request-Kontext wird
|
||||
// abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das
|
||||
// Rennen fast immer und der Zähler blieb auf 0 stehen.
|
||||
zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||
bg.Go("Freigabe-Zaehler", func() {
|
||||
defer abbrechen()
|
||||
h.store.IncrementDownloads(zaehlCtx, token)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", f.MimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
_, _ = io.Copy(w, rc)
|
||||
// Kein Typ-Raten. `attachment` trägt die Hauptlast, aber der angemeldete
|
||||
// Weg setzt beides – und dies ist der Weg nach draussen.
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// **Dieselbe Prüfung wie beim angemeldeten Download.** Bis zum 25.09.2026
|
||||
// lief hier ein blankes io.Copy: Wer die Datei über einen Link bekam,
|
||||
// bekam sie ungeprüft. Ausgerechnet auf dem Weg, auf dem sie das Haus
|
||||
// verlässt – und wo der Empfänger es am wenigsten merken kann.
|
||||
files.ServeVerified(r.Context(), w, rc, f.Checksum, files.VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// resolve klärt, in wessen Namen die Datei liegt, und verlangt Schreibrecht.
|
||||
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, fileID uuid.UUID) (acl.Access, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if h.acl == nil {
|
||||
return acl.Access{OwnerID: user.ID, Permission: acl.PermWrite}, true
|
||||
}
|
||||
a, err := h.acl.ResolveFile(r.Context(), user.ID, fileID)
|
||||
switch {
|
||||
case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess):
|
||||
// 404 statt 403, wie überall: Ein „verboten" verriete, dass es die ID
|
||||
// gibt.
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return acl.Access{}, false
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return acl.Access{}, false
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
response.Error(w, http.StatusForbidden, "you only have read access to this file")
|
||||
return acl.Access{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Ein Freigabelink ist der einzige Weg, auf dem eine Datei dieses Haus ohne
|
||||
// Anmeldung verlässt. Wer das Token hat, ist drin – also entscheidet sich hier
|
||||
// alles an zwei Fragen: Gilt der Link noch, und gehört er dem, der ihn
|
||||
// anfasst?
|
||||
//
|
||||
// Beides steckt nicht in einer if-Zeile, sondern in WHERE-Klauseln und einem
|
||||
// Zeitvergleich. Ein Fehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
// läuft, sie liefert nur zu viel.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/sharing/
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("konto %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||
owner, name, "k/"+uuid.NewString(),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("datei %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestAbgelaufenerLinkGiltNichtMehr(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
gestern := time.Now().Add(-1 * time.Hour)
|
||||
abgelaufen, err := s.Create(ctx, f, anna, "", &gestern)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, abgelaufen.Token); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("abgelaufener Link muss ErrExpired liefern, kam: %v", err)
|
||||
}
|
||||
|
||||
// Gegenprobe – sonst prüfte der Test nur, dass irgendetwas fehlschlägt.
|
||||
morgen := time.Now().Add(1 * time.Hour)
|
||||
gueltig, err := s.Create(ctx, f, anna, "", &morgen)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, gueltig.Token); err != nil {
|
||||
t.Fatalf("gültiger Link muss durchgehen, kam: %v", err)
|
||||
}
|
||||
|
||||
// Ohne Ablauf gilt er unbegrenzt.
|
||||
ewig, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, ewig.Token); err != nil {
|
||||
t.Fatalf("Link ohne Ablauf muss gelten, kam: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFremderLinkLaesstSichNichtLoeschen(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
bert := konto(t, pool, ctx, "bert")
|
||||
f := datei(t, pool, ctx, anna, "Gehaltsabrechnung.pdf")
|
||||
|
||||
link, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
// Bert kennt die ID – etwa, weil sie einmal irgendwo auftauchte.
|
||||
if err := s.Delete(ctx, link.ID, bert); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Bert darf Annas Link nicht löschen, kam: %v", err)
|
||||
}
|
||||
// Und er ist wirklich noch da.
|
||||
if _, _, err := s.GetByToken(ctx, link.Token); err != nil {
|
||||
t.Fatalf("Link muss nach dem Fehlversuch noch gelten, kam: %v", err)
|
||||
}
|
||||
// Anna darf.
|
||||
if err := s.Delete(ctx, link.ID, anna); err != nil {
|
||||
t.Fatalf("Anna muss ihren Link löschen dürfen: %v", err)
|
||||
}
|
||||
if _, _, err := s.GetByToken(ctx, link.Token); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("gelöschter Link muss ErrNotFound liefern, kam: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeZeigtNurEigeneLinks(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
bert := konto(t, pool, ctx, "bert")
|
||||
fa := datei(t, pool, ctx, anna, "Annas.pdf")
|
||||
fb := datei(t, pool, ctx, bert, "Berts.pdf")
|
||||
|
||||
if _, err := s.Create(ctx, fa, anna, "", nil); err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if _, err := s.Create(ctx, fb, bert, "", nil); err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
annas, err := s.ListByOwner(ctx, anna)
|
||||
if err != nil {
|
||||
t.Fatalf("liste: %v", err)
|
||||
}
|
||||
if len(annas) != 1 {
|
||||
t.Fatalf("Anna soll genau einen Link sehen, sah %d", len(annas))
|
||||
}
|
||||
if annas[0].FileID != fa {
|
||||
t.Fatalf("Anna sieht einen fremden Link: %v", annas[0].FileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVerlaesstDenSpeicherNichtVersehentlich(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Vertrag.pdf")
|
||||
|
||||
mitPw, err := s.Create(ctx, f, anna, "argon2-hash-platzhalter", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
// Das Anlegen gibt den Hash **nicht** zurück, nur die Tatsache.
|
||||
if !mitPw.HasPassword {
|
||||
t.Fatal("HasPassword muss true sein")
|
||||
}
|
||||
|
||||
// Beim Abruf über das Token kommt er heraus – dort wird er gebraucht,
|
||||
// um das eingegebene Passwort zu prüfen. Aber nur dort.
|
||||
link, hash, err := s.GetByToken(ctx, mitPw.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("abrufen: %v", err)
|
||||
}
|
||||
if hash != "argon2-hash-platzhalter" {
|
||||
t.Fatalf("Hash muss beim Tokenabruf vorliegen, kam %q", hash)
|
||||
}
|
||||
if !link.HasPassword {
|
||||
t.Fatal("HasPassword muss auch beim Abruf true sein")
|
||||
}
|
||||
|
||||
// Die Liste für die Oberfläche kennt ihn nicht.
|
||||
liste, err := s.ListByOwner(ctx, anna)
|
||||
if err != nil {
|
||||
t.Fatalf("liste: %v", err)
|
||||
}
|
||||
if len(liste) != 1 || !liste[0].HasPassword {
|
||||
t.Fatalf("Liste soll den Link mit HasPassword zeigen, kam %+v", liste)
|
||||
}
|
||||
|
||||
// Ohne Passwort bleibt es false – sonst prüfte das oben nichts.
|
||||
ohnePw, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if ohnePw.HasPassword {
|
||||
t.Fatal("HasPassword muss ohne Passwort false sein")
|
||||
}
|
||||
if _, hash, _ := s.GetByToken(ctx, ohnePw.Token); hash != "" {
|
||||
t.Fatalf("ohne Passwort darf kein Hash kommen, kam %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIstNichtZuErraten(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
gesehen := map[string]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
l, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
// 24 Zufallsbytes, base64url ohne Auffüllen = 32 Zeichen. Wird das
|
||||
// jemals kürzer, ist das Token ratbar – und der Link ist das einzige
|
||||
// Geheimnis, das ihn schützt.
|
||||
if len(l.Token) != 32 {
|
||||
t.Fatalf("Token soll 32 Zeichen haben, hat %d: %q", len(l.Token), l.Token)
|
||||
}
|
||||
if gesehen[l.Token] {
|
||||
t.Fatalf("Token doppelt vergeben: %q", l.Token)
|
||||
}
|
||||
gesehen[l.Token] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestZaehlerZaehlt(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
anna := konto(t, pool, ctx, "anna")
|
||||
f := datei(t, pool, ctx, anna, "Rechnung.pdf")
|
||||
|
||||
link, err := s.Create(ctx, f, anna, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
if link.DownloadCount != 0 {
|
||||
t.Fatalf("frischer Link soll bei 0 stehen, stand bei %d", link.DownloadCount)
|
||||
}
|
||||
s.IncrementDownloads(ctx, link.Token)
|
||||
s.IncrementDownloads(ctx, link.Token)
|
||||
|
||||
nach, _, err := s.GetByToken(ctx, link.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("abrufen: %v", err)
|
||||
}
|
||||
if nach.DownloadCount != 2 {
|
||||
t.Fatalf("Zähler soll 2 sein, ist %d", nach.DownloadCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package tags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Etiketten und Absender gehören einem Konto oder einer Gruppe. Dass ein
|
||||
// fremdes Etikett fremd **bleibt**, prüfte bis zum 12.09.2026 nichts nach –
|
||||
// und die Prüfung steckt hier nicht in einer if-Zeile, sondern in
|
||||
// WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage
|
||||
// läuft, sie liefert nur zu viel.
|
||||
//
|
||||
// Der teuerste Fall ist FilesWithTag: Es gibt **Dateinamen und Ordner**
|
||||
// heraus. Wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
|
||||
// fremden Unterlagen.
|
||||
//
|
||||
// Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/tags/
|
||||
|
||||
func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool, ctx
|
||||
}
|
||||
|
||||
func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8],
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("konto %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`,
|
||||
owner, name, "k/"+uuid.NewString(),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("datei: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Ein fremdes Etikett bleibt fremd ────────────────────────────────────────
|
||||
|
||||
func TestFremdesEtikettBleibtFremd(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
a := konto(t, pool, ctx, "anna")
|
||||
b := konto(t, pool, ctx, "bert")
|
||||
|
||||
annas, err := s.Create(ctx, a, nil, "Steuer 2026", "#ff0000", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
t.Run("taucht nicht in Berts Liste auf", func(t *testing.T) {
|
||||
liste, err := s.List(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, tg := range liste {
|
||||
if tg.ID == annas.ID {
|
||||
t.Fatal("Bert sieht Annas Etikett")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht umbenennen", func(t *testing.T) {
|
||||
if _, err := s.Update(ctx, b, annas.ID, "Uebernommen", "#00ff00", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Update lieferte %v, erwartet ErrNotFound", err)
|
||||
}
|
||||
// Und der Name steht wirklich noch da – der Fehlercode allein sagt
|
||||
// nichts darüber, ob nebenbei geschrieben wurde.
|
||||
var name string
|
||||
_ = pool.QueryRow(ctx, `SELECT name FROM tags WHERE id = $1`, annas.ID).Scan(&name)
|
||||
if name != "Steuer 2026" {
|
||||
t.Fatalf("Name ist jetzt %q", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht löschen", func(t *testing.T) {
|
||||
if err := s.Delete(ctx, b, annas.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Delete lieferte %v, erwartet ErrNotFound", err)
|
||||
}
|
||||
var da bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1)`, annas.ID).Scan(&da)
|
||||
if !da {
|
||||
t.Fatal("Annas Etikett ist weg")
|
||||
}
|
||||
})
|
||||
|
||||
// Der teuerste Fall: Hier kämen Dateinamen und Ordner heraus.
|
||||
t.Run("Bert sieht die Dateien darunter nicht", func(t *testing.T) {
|
||||
f := datei(t, pool, ctx, a, "Gehaltsabrechnung.pdf")
|
||||
if err := s.SetForFile(ctx, f, a, a, []uuid.UUID{annas.ID}, nil); err != nil {
|
||||
t.Fatalf("etikettieren: %v", err)
|
||||
}
|
||||
// Anna sieht sie – sonst prüfte der nächste Teil eine leere Liste.
|
||||
meine, err := s.FilesWithTag(ctx, a, annas.ID)
|
||||
if err != nil || len(meine) != 1 {
|
||||
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d Treffer", err, len(meine))
|
||||
}
|
||||
fremde, err := s.FilesWithTag(ctx, b, annas.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesWithTag: %v", err)
|
||||
}
|
||||
if len(fremde) != 0 {
|
||||
t.Fatalf("Bert sieht %d fremde Dateien: %v", len(fremde), fremde[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bert kann es nicht an seine eigene Datei hängen", func(t *testing.T) {
|
||||
f := datei(t, pool, ctx, b, "Berts Datei.pdf")
|
||||
err := s.SetForFile(ctx, f, b, b, []uuid.UUID{annas.ID}, nil)
|
||||
if !errors.Is(err, ErrWrongOwner) {
|
||||
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner", err)
|
||||
}
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Gruppen ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func gruppeMit(t *testing.T, pool *pgxpool.Pool, ctx context.Context, mitglieder ...uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
var g uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO groups (name) VALUES ($1) RETURNING id`, "probe-"+uuid.NewString()[:8],
|
||||
).Scan(&g)
|
||||
if err != nil {
|
||||
t.Fatalf("gruppe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, g) })
|
||||
for _, m := range mitglieder {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)`, g, m); err != nil {
|
||||
t.Fatalf("mitglied: %v", err)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestGruppenEtikettSiehtNurDieGruppe(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
drin := konto(t, pool, ctx, "drin")
|
||||
draussen := konto(t, pool, ctx, "draussen")
|
||||
g := gruppeMit(t, pool, ctx, drin)
|
||||
|
||||
tag, err := s.Create(ctx, drin, &g, "Buchhaltung", "#0000ff", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
sieht := func(user uuid.UUID) bool {
|
||||
liste, err := s.List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, x := range liste {
|
||||
if x.ID == tag.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !sieht(drin) {
|
||||
t.Fatal("das Mitglied sieht das Gruppen-Etikett nicht")
|
||||
}
|
||||
if sieht(draussen) {
|
||||
t.Fatal("ein Nichtmitglied sieht das Gruppen-Etikett")
|
||||
}
|
||||
|
||||
// Mitgliedschaft ersetzt die Freigabe: Wer die Gruppe verlässt, verliert
|
||||
// den Zugriff im selben Moment.
|
||||
if _, err := pool.Exec(ctx,
|
||||
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, g, drin); err != nil {
|
||||
t.Fatalf("austragen: %v", err)
|
||||
}
|
||||
if sieht(drin) {
|
||||
t.Fatal("nach dem Austritt weiterhin sichtbar")
|
||||
}
|
||||
if _, err := s.Update(ctx, drin, tag.ID, "Umbenannt", "#111111", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("nach dem Austritt noch änderbar: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Gruppen-Etikett gehört **nicht** an eine persönliche Datei – und das ist
|
||||
// keine Förmlichkeit, sondern die Stelle, an der die Rechte hängen.
|
||||
//
|
||||
// Der Anlass war ein Verdacht, der sich als falsch erwies: `List` bietet dem
|
||||
// Nutzer Gruppen-Etiketten mit an, `SetFieldsForFile` vergleicht aber gegen
|
||||
// den *Prinzipal der Datei*. Sieht nach Widerspruch aus. Ist keiner – die
|
||||
// Oberfläche filtert bereits (`TagDialog.tsx`: bei einer persönlichen Datei
|
||||
// nur Etiketten ohne group_id).
|
||||
//
|
||||
// **Warum die Ablehnung bleiben muss:** `FilesWithTag` prüft, ob der
|
||||
// Anfragende das *Etikett* sehen darf – aber **nicht**, ob er die *Datei*
|
||||
// sehen darf. Das ist nur deshalb harmlos, weil ein Etikett stets denselben
|
||||
// Prinzipal hat wie die Dateien darunter: ein persönliches nur an eigenen
|
||||
// Dateien, ein Gruppen-Etikett nur an Gruppendateien, die alle Mitglieder
|
||||
// ohnehin sehen.
|
||||
//
|
||||
// Fiele diese Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei –
|
||||
// und jedes Gruppenmitglied bekäme über die Etikettenansicht ihren Namen,
|
||||
// Ordner und ihre Größe zu sehen. Deshalb steht dieser Test hier: Er hält
|
||||
// nicht eine Fehlermeldung fest, sondern eine Absicherung.
|
||||
func TestGruppenEtikettGehoertNichtAnEinePersoenlicheDatei(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
u := konto(t, pool, ctx, "mitglied")
|
||||
g := gruppeMit(t, pool, ctx, u)
|
||||
|
||||
tag, err := s.Create(ctx, u, &g, "Gemeinsam", "#00ffff", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
f := datei(t, pool, ctx, u, "Privat.pdf")
|
||||
|
||||
// Prinzipal der Datei ist der Nutzer, nicht die Gruppe.
|
||||
if err := s.SetForFile(ctx, f, u, u, []uuid.UUID{tag.ID}, nil); !errors.Is(err, ErrWrongOwner) {
|
||||
t.Fatalf("SetForFile lieferte %v, erwartet ErrWrongOwner – ohne diese "+
|
||||
"Prüfung sähe jedes Gruppenmitglied diese private Datei", err)
|
||||
}
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM file_tags WHERE file_id = $1`, f).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d Zuordnungen entstanden trotzdem", n)
|
||||
}
|
||||
|
||||
// Die Gegenprobe: An einer **Gruppendatei** geht dasselbe Etikett.
|
||||
var gf uuid.UUID
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES (NULL, $1, NULL, 'Gemeinsam.pdf', 'application/pdf', 1, 'x', $2) RETURNING id`,
|
||||
g, "k/"+uuid.NewString()).Scan(&gf)
|
||||
if err != nil {
|
||||
t.Fatalf("gruppendatei: %v", err)
|
||||
}
|
||||
if err := s.SetForFile(ctx, gf, g, u, []uuid.UUID{tag.ID}, nil); err != nil {
|
||||
t.Fatalf("an einer Gruppendatei muss es gehen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Absender ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFremderAbsenderBleibtFremd(t *testing.T) {
|
||||
s, pool, ctx := testStore(t)
|
||||
a := konto(t, pool, ctx, "anna")
|
||||
b := konto(t, pool, ctx, "bert")
|
||||
|
||||
k, err := s.CreateCorrespondent(ctx, a, nil, "Finanzamt", "")
|
||||
if err != nil {
|
||||
t.Fatalf("anlegen: %v", err)
|
||||
}
|
||||
|
||||
liste, err := s.ListCorrespondents(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, x := range liste {
|
||||
if x.ID == k.ID {
|
||||
t.Fatal("Bert sieht Annas Absender")
|
||||
}
|
||||
}
|
||||
// Absender haben ihre eigene Fehlermarke – ErrCorrespondentNotFound, nicht
|
||||
// ErrNotFound. Zwei Marken für dieselbe Aussage in einem Paket sind eine
|
||||
// Stolperstelle; hier festgehalten, damit sie wenigstens nicht unbemerkt
|
||||
// die Seite wechselt.
|
||||
if _, err := s.UpdateCorrespondent(ctx, b, k.ID, "Uebernommen", ""); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||
t.Fatalf("Update lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||
}
|
||||
if err := s.DeleteCorrespondent(ctx, b, k.ID); !errors.Is(err, ErrCorrespondentNotFound) {
|
||||
t.Fatalf("Delete lieferte %v, erwartet ErrCorrespondentNotFound", err)
|
||||
}
|
||||
|
||||
// Auch hier: Der Weg, der Dateinamen herausgibt.
|
||||
f := datei(t, pool, ctx, a, "Bescheid.pdf")
|
||||
if err := s.SetFieldsForFile(ctx, f, a, a, FileFields{CorrespondentID: &k.ID}); err != nil {
|
||||
t.Fatalf("zuordnen: %v", err)
|
||||
}
|
||||
meine, err := s.FilesWithCorrespondent(ctx, a, k.ID)
|
||||
if err != nil || len(meine) != 1 {
|
||||
t.Fatalf("Anna sieht ihre eigene Datei nicht: %v, %d", err, len(meine))
|
||||
}
|
||||
fremde, err := s.FilesWithCorrespondent(ctx, b, k.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesWithCorrespondent: %v", err)
|
||||
}
|
||||
if len(fremde) != 0 {
|
||||
t.Fatalf("Bert sieht %d fremde Dateien", len(fremde))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package bg startet Hintergrundarbeit, die den Prozess nicht mitreißen kann.
|
||||
//
|
||||
// **Der Anlass ist gemessen, nicht theoretisch.** Aether erzeugt das
|
||||
// Vorschaubild einer hochgeladenen Datei in einer eigenen Goroutine. Chis
|
||||
// `Recoverer` hängt am Anfragebehandler und sieht davon nichts – eine Panik
|
||||
// dort nimmt den **ganzen Go-Prozess** mit. Am 12.09.2026 stand dafür sogar
|
||||
// eine passende Lücke offen (GO-2026-5061, Panik im WebP-Dekodierer): Ein
|
||||
// gewöhnlicher Bild-Upload hätte den Server umgelegt.
|
||||
//
|
||||
// Die Bibliothek zu aktualisieren schließt **diese** Lücke. Diese Datei
|
||||
// schließt die **Gattung**: Bilddekodierer, Fremdformate und externe Programme
|
||||
// sind Felder, in denen regelmäßig etwas gefunden wird, und keines davon darf
|
||||
// über einen Weg erreichbar sein, an dessen Ende der Prozess stirbt.
|
||||
//
|
||||
// Was hier *nicht* passiert: Fehler verschlucken. Eine aufgefangene Panik wird
|
||||
// mit Stapel protokolliert. Sie ist ein Fehler und bleibt einer – sie reißt
|
||||
// nur nichts mehr mit.
|
||||
package bg
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Go startet fn in einer Goroutine, die eine Panik überlebt.
|
||||
//
|
||||
// name steht im Logeintrag und sollte sagen, *was* da lief ("Vorschaubild",
|
||||
// "Texterkennung") – eine Zeile „panic recovered" ohne Herkunft hilft um drei
|
||||
// Uhr nachts niemandem.
|
||||
func Go(name string, fn func()) {
|
||||
go Run(name, fn)
|
||||
}
|
||||
|
||||
// Run ist dasselbe ohne eigene Goroutine: für Schleifen, in denen **ein**
|
||||
// Durchgang scheitern darf, ohne dass die Schleife endet.
|
||||
//
|
||||
// Der Unterschied ist keine Feinheit. Fängt man eine Panik um den ganzen
|
||||
// Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und
|
||||
// von da an wird still keine Datei mehr erkannt. Fängt man sie um den
|
||||
// einzelnen Auftrag ab, verliert man den einen Auftrag und macht weiter.
|
||||
func Run(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().
|
||||
Str("wo", name).
|
||||
Interface("panik", r).
|
||||
Str("stapel", string(debug.Stack())).
|
||||
Msg("Hintergrundarbeit abgestürzt – aufgefangen")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunFaengtPanikAuf(t *testing.T) {
|
||||
// Ohne das recover in Run stürbe hier der Testprozess – das ist zugleich
|
||||
// die Aussage des Tests.
|
||||
Run("test", func() { panic("absichtlich") })
|
||||
}
|
||||
|
||||
func TestRunLaesstErgebnisDurch(t *testing.T) {
|
||||
gelaufen := false
|
||||
Run("test", func() { gelaufen = true })
|
||||
if !gelaufen {
|
||||
t.Fatal("fn wurde nicht ausgeführt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoLaeuftUndUeberlebt(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
Go("heil", func() { defer wg.Done() })
|
||||
Go("panisch", func() { defer wg.Done(); panic("absichtlich") })
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Eine Schleife, in der ein Durchgang scheitert, muss weiterlaufen – das ist
|
||||
// der Grund, warum es Run neben Go gibt.
|
||||
func TestSchleifeLaeuftNachPanikWeiter(t *testing.T) {
|
||||
durchgaenge := 0
|
||||
for i := range 5 {
|
||||
Run("schritt", func() {
|
||||
durchgaenge++
|
||||
if i == 2 {
|
||||
panic("mittendrin")
|
||||
}
|
||||
})
|
||||
}
|
||||
if durchgaenge != 5 {
|
||||
t.Fatalf("nur %d von 5 Durchgängen", durchgaenge)
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,6 @@ type Config struct {
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
|
||||
// ExternalApps erscheinen im App-Umschalter der Oberfläche. Kommt aus der
|
||||
// Umgebung, damit neue Apps ohne Neubau des Frontends dazukommen können.
|
||||
ExternalApps []ExternalApp
|
||||
|
||||
// OCR – Volltextsuche über den Inhalt von PDFs und Bildern.
|
||||
// Standardmäßig an; fehlen die Programme im Image, schaltet sich der
|
||||
// Arbeiter beim Start selbst ab, statt den Start zu verhindern.
|
||||
@@ -89,15 +85,6 @@ type Config struct {
|
||||
//
|
||||
// Leer heißt: keine Leiste, und die CSP bleibt so eng wie bisher.
|
||||
HoneybarOrigin string
|
||||
// HoneybarKey ist der Ausweis, mit dem sich die Leiste bei HoneyOS die
|
||||
// App-Liste holt (dort HONEYOS_APPS_KEY). Nötig, wenn HoneyOS auf einer
|
||||
// anderen Site liegt als Aether: dann schickt der Browser die
|
||||
// HoneyOS-Sitzung nicht mit.
|
||||
//
|
||||
// Er steht anschließend im Quelltext der Seite — das ist unvermeidlich,
|
||||
// die Leiste läuft im Browser. Er gibt nur die App-Liste her, nichts
|
||||
// Schreibendes.
|
||||
HoneybarKey string
|
||||
// OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether
|
||||
// erreicht.
|
||||
//
|
||||
@@ -133,45 +120,6 @@ func (c *Config) OfficeEnabled() bool {
|
||||
return (c.OfficeProvider == "collabora" || c.OfficeProvider == "onlyoffice") && c.OfficeURL != ""
|
||||
}
|
||||
|
||||
// ExternalApp ist ein Eintrag im App-Umschalter.
|
||||
type ExternalApp struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"` // ein Emoji, optional
|
||||
}
|
||||
|
||||
// parseExternalApps liest EXTERNAL_APPS im Format
|
||||
//
|
||||
// Name|https://url|Emoji , Name|https://url|Emoji
|
||||
//
|
||||
// Einträge ohne Namen oder URL werden übersprungen, damit ein Tippfehler in der
|
||||
// .env nicht den Start verhindert.
|
||||
func parseExternalApps(raw string) []ExternalApp {
|
||||
var apps []ExternalApp
|
||||
for _, entry := range strings.Split(raw, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(entry, "|")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
app := ExternalApp{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
URL: strings.TrimSpace(parts[1]),
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
app.Icon = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if app.Name == "" || app.URL == "" {
|
||||
continue
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
@@ -193,15 +141,12 @@ func Load() (*Config, error) {
|
||||
AdminUsername: getEnv("ADMIN_USERNAME", ""),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
|
||||
ExternalApps: parseExternalApps(getEnv("EXTERNAL_APPS", "")),
|
||||
|
||||
OCREnabled: getEnv("OCR_ENABLED", "true") != "false",
|
||||
|
||||
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
||||
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
||||
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
||||
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
||||
HoneybarKey: getEnv("HONEYBAR_KEY", ""),
|
||||
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
|
||||
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
|
||||
OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true",
|
||||
@@ -216,10 +161,21 @@ func Load() (*Config, error) {
|
||||
OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true",
|
||||
}
|
||||
|
||||
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "86400"))
|
||||
// 30 Tage, und die Frist **gleitet**: Sie zählt ab der letzten Benutzung,
|
||||
// nicht ab dem Anmelden (siehe auth.SessionStore.Touch). Vorher stand hier
|
||||
// ein Tag ab Anmeldung – wer täglich arbeitete, flog trotzdem täglich
|
||||
// heraus. Ein Ablauf, der den Anwesenden trifft und nicht den Abwesenden,
|
||||
// erkauft keine Sicherheit, sondern nur Ärger.
|
||||
//
|
||||
// Kürzer ist jederzeit einstellbar; bei einem gemeinsam genutzten Rechner
|
||||
// wäre das sinnvoll.
|
||||
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "2592000"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err)
|
||||
}
|
||||
if maxAge <= 0 {
|
||||
return nil, fmt.Errorf("SESSION_MAX_AGE must be greater than 0 (seconds)")
|
||||
}
|
||||
cfg.SessionMaxAge = maxAge
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
|
||||
--
|
||||
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
|
||||
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
|
||||
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
|
||||
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
|
||||
--
|
||||
-- 0 heißt "keine". Dateifassungen zählen ab 1.
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS cal_objects;
|
||||
DROP TABLE IF EXISTS cal_calendars;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
|
||||
--
|
||||
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
|
||||
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
|
||||
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
|
||||
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_calendars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
|
||||
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
|
||||
color TEXT NOT NULL DEFAULT '#4285f4',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
|
||||
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
|
||||
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
|
||||
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
|
||||
-- einzelne Objekt abzufragen.
|
||||
ctag BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cal_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
|
||||
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
|
||||
uid TEXT NOT NULL,
|
||||
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
|
||||
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
|
||||
component TEXT NOT NULL,
|
||||
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
|
||||
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
|
||||
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
|
||||
data TEXT NOT NULL,
|
||||
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
|
||||
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
|
||||
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
|
||||
etag TEXT NOT NULL,
|
||||
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
|
||||
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
|
||||
dtstart TIMESTAMPTZ,
|
||||
dtend TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (calendar_id, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS timezone;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Standard-Zeitzone pro Kalender. Wird von CalDAV selbst nicht gebraucht
|
||||
-- (jeder Termin trägt seine Zeitzone im .ics), sondern erst von der
|
||||
-- künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3, siehe
|
||||
-- kalender-plan.md) – ein neuer Termin braucht eine Vorgabe, wenn der
|
||||
-- Nutzer keine eigene wählt. Jetzt schon angelegt, damit später keine
|
||||
-- weitere Migration für ein einzelnes Feld nötig ist.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus
|
||||
-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer
|
||||
-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen
|
||||
-- und der Client bekäme auf seine eigene Adresse ein 404.
|
||||
--
|
||||
-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle
|
||||
-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar,
|
||||
-- unter der die eingerichteten Geräte sie schon kennen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT;
|
||||
|
||||
-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe
|
||||
-- Adresse haben, aber beliebig viele dürfen gar keine haben.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment
|
||||
ON cal_calendars (owner_id, path_segment)
|
||||
WHERE path_segment IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS cal_tombstones;
|
||||
DROP INDEX IF EXISTS cal_objects_sync;
|
||||
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
|
||||
--
|
||||
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
|
||||
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
|
||||
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
|
||||
--
|
||||
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
|
||||
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
|
||||
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
|
||||
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
|
||||
-- bei dem es zuletzt angefasst wurde.
|
||||
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
|
||||
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
|
||||
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
|
||||
UPDATE cal_objects o SET sync_seq = c.ctag
|
||||
FROM cal_calendars c WHERE c.id = o.calendar_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
|
||||
|
||||
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
|
||||
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
|
||||
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
|
||||
-- Art, weil er unsichtbar ist.
|
||||
CREATE TABLE IF NOT EXISTS cal_tombstones (
|
||||
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
sync_seq BIGINT NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
|
||||
|
||||
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
|
||||
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
|
||||
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
|
||||
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_acl_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_group_calendar;
|
||||
DROP INDEX IF EXISTS idx_acl_user_calendar;
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Kalender an andere Konten freigeben.
|
||||
--
|
||||
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
|
||||
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
|
||||
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
|
||||
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
|
||||
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
|
||||
-- auf, wenn jemand sieht, was er nicht sehen soll.
|
||||
--
|
||||
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
|
||||
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
|
||||
ALTER TABLE acl_entries
|
||||
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
|
||||
|
||||
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
|
||||
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
|
||||
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
|
||||
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
|
||||
|
||||
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
|
||||
-- mehrdeutig: welches Recht gilt dann?
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
|
||||
ON acl_entries (subject_user_id, resource_calendar_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
|
||||
ON acl_entries (subject_group_id, resource_calendar_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
|
||||
|
||||
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
|
||||
CREATE INDEX IF NOT EXISTS idx_acl_calendar
|
||||
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Persönlich'
|
||||
WHERE name = 'Personal'
|
||||
AND color = '#4285f4';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
|
||||
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
|
||||
-- zieht die bereits angelegten nach.
|
||||
--
|
||||
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
|
||||
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
|
||||
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
|
||||
UPDATE cal_calendars
|
||||
SET name = 'Personal'
|
||||
WHERE name = 'Persönlich'
|
||||
AND color = '#4285f4';
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -13,6 +15,17 @@ func Logger(next http.Handler) http.Handler {
|
||||
start := time.Now()
|
||||
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
// Den Logger in den Kontext hängen, damit log.Ctx(r.Context()) in den
|
||||
// Handlern etwas findet – und damit jede Zeile aus einer Anfrage die
|
||||
// Kennung dieser Anfrage trägt. Ohne das schreibt log.Ctx gar nicht
|
||||
// (zerolog gibt dann einen abgeschalteten Logger zurück), und mit
|
||||
// einem globalen Standard allein ließe sich nicht mehr zuordnen,
|
||||
// welche Meldung zu welchem Aufruf gehört.
|
||||
l := log.Logger.With().
|
||||
Str("request_id", chimiddleware.GetReqID(r.Context())).
|
||||
Logger()
|
||||
r = r.WithContext(l.WithContext(r.Context()))
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := zerolog.InfoLevel
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Der Test, der gefehlt hat.
|
||||
//
|
||||
// zerolog.Ctx liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
|
||||
// steckt. 34 Aufrufe von log.Ctx(...) im Baum schrieben deshalb ins Nichts –
|
||||
// darunter Warnungen über verwaiste Blobs und beschädigte Dateien. Der Aufruf
|
||||
// sieht dabei völlig richtig aus; auffallen kann es nur, wenn man den Fehler
|
||||
// herbeiführt, den er melden soll.
|
||||
func TestLoggerHaengtLoggerInDenKontext(t *testing.T) {
|
||||
var aus bytes.Buffer
|
||||
alt := log.Logger
|
||||
log.Logger = zerolog.New(&aus)
|
||||
defer func() { log.Logger = alt }()
|
||||
|
||||
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Genau der Aufruf, der überall im Baum steht.
|
||||
log.Ctx(r.Context()).Error().Msg("kaputte-datei")
|
||||
}))
|
||||
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||
|
||||
if !strings.Contains(aus.String(), "kaputte-datei") {
|
||||
t.Fatalf("log.Ctx im Handler schrieb nichts. Ausgabe: %q", aus.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Jede Zeile aus einer Anfrage soll deren Kennung tragen – sonst weiß man bei
|
||||
// zwei gleichzeitigen Downloads nicht, welche Meldung zu welchem gehört.
|
||||
func TestZeilenTragenDieAnfrageKennung(t *testing.T) {
|
||||
var aus bytes.Buffer
|
||||
alt := log.Logger
|
||||
log.Logger = zerolog.New(&aus)
|
||||
defer func() { log.Logger = alt }()
|
||||
|
||||
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Ctx(r.Context()).Error().Msg("x")
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
|
||||
|
||||
if !strings.Contains(aus.String(), "request_id") {
|
||||
t.Fatalf("keine request_id in der Zeile: %q", aus.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package response
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
@@ -13,7 +14,31 @@ type envelope struct {
|
||||
func JSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(envelope{Data: data})
|
||||
json.NewEncoder(w).Encode(envelope{Data: leereListeStattNull(data)})
|
||||
}
|
||||
|
||||
// leereListeStattNull macht aus einer nicht belegten Liste eine leere.
|
||||
//
|
||||
// In Go ist eine Liste ohne Einträge oft nil, und `encoding/json` schreibt
|
||||
// dafür `null` statt `[]`. Jede Store-Funktion, die ihre Liste mit
|
||||
// `var out []T` beginnt, liefert das bei leerem Ergebnis – und jede
|
||||
// Oberfläche, die darüber läuft, bricht dann ab.
|
||||
//
|
||||
// Genau so ist die Kalenderansicht bei einem Konto ohne Kalender
|
||||
// weiß geblieben: `for (const cal of calendars)` über `null`.
|
||||
//
|
||||
// Der Ort dafür ist hier und nicht in den Stores: Es gibt Dutzende
|
||||
// Listenendpunkte, und der nächste wird wieder mit `var out []T` anfangen.
|
||||
// Eine Regel, an die man sich erinnern muss, ist keine.
|
||||
func leereListeStattNull(data any) any {
|
||||
if data == nil {
|
||||
return data
|
||||
}
|
||||
v := reflect.ValueOf(data)
|
||||
if v.Kind() == reflect.Slice && v.IsNil() {
|
||||
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Eine leere Liste muss als [] herauskommen, nicht als null. Jede
|
||||
// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die
|
||||
// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben.
|
||||
func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) {
|
||||
type eintrag struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var leer []eintrag
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, leer)
|
||||
|
||||
if got := w.Body.String(); !contains(got, `"data":[]`) {
|
||||
t.Fatalf("erwartet \"data\":[], bekam %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, []string{"a", "b"})
|
||||
|
||||
var out struct {
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Data) != 2 || out.Data[0] != "a" {
|
||||
t.Fatalf("Inhalt verändert: %v", out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst
|
||||
// verwandelte die Regel jede fehlende Antwort in eine leere Liste.
|
||||
func TestJSONObjekteBleibenObjekte(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
JSON(w, 200, map[string]int{"n": 1})
|
||||
if got := w.Body.String(); !contains(got, `"n":1`) {
|
||||
t.Fatalf("Objekt verändert: %s", got)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
JSON(w, 200, nil)
|
||||
if got := w.Body.String(); contains(got, "[]") {
|
||||
t.Fatalf("nil sollte keine Liste werden: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -4,7 +4,10 @@ WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Erst prüfen, dann bauen: Die Tests decken die Stellen ab, die sich ohne
|
||||
# Browser prüfen lassen – Reihenfolgen, Zoomstufen, Suche im Text. Sie laufen
|
||||
# in einer halben Sekunde; ein Abbild mit einer kaputten Sortierung kostet mehr.
|
||||
RUN npm test && npm run build
|
||||
|
||||
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
@@ -1,13 +1,22 @@
|
||||
# Aether – Entwicklung: aus dem Quelltext bauen.
|
||||
#
|
||||
# docker compose -f deploy/docker-compose.yml build app
|
||||
# docker compose -f deploy/docker-compose.yml up -d
|
||||
# docker compose -f build/docker-compose.yml build app
|
||||
# docker compose -f build/docker-compose.yml up -d
|
||||
#
|
||||
# Zum **Betreiben** ist docker-compose.example.yml im Wurzelverzeichnis
|
||||
# gedacht: schlanker und ohne Quelltext, es zieht das fertige Abbild.
|
||||
#
|
||||
# Die Einstellungen kommen aus build/.env – als Vorlage dient die eine
|
||||
# .env.example im Wurzelverzeichnis: cp .env.example build/.env
|
||||
#
|
||||
# Erklärungen zu allem hier: README.md
|
||||
|
||||
# Der Projektname steht hier fest und wird **nicht** aus dem Ordnernamen
|
||||
# abgeleitet. Sonst hinge er daran, wie der Ordner gerade heißt: Beim
|
||||
# Umbenennen von deploy/ nach build/ hätte Compose ein neues, leeres Projekt
|
||||
# angelegt – mit einer neuen, leeren Datenbank.
|
||||
name: aether
|
||||
|
||||
services:
|
||||
app:
|
||||
container_name: aether-app
|
||||
@@ -15,7 +24,7 @@ services:
|
||||
# Kontext ist das Wurzelverzeichnis – das Abbild braucht backend/ und
|
||||
# frontend/, nicht nur diesen Ordner.
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
dockerfile: build/Dockerfile
|
||||
image: gitea.honeyy.duckdns.org/docker/aether:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
@@ -67,6 +76,16 @@ services:
|
||||
profiles: ["office"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Wer seine Dateien in Collabora öffnen darf. Derselbe Host wie in
|
||||
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
|
||||
# anhängen – etwa OFFICE_ALIASGROUP=https://cloud\.example\.com:443.
|
||||
# Fehlt er, lehnt Collabora ab: "No authorized hosts found matching the
|
||||
# target host […] in config".
|
||||
#
|
||||
# Diese vier Werte (aliasgroup, ssl, ssl.termination, Port) stehen
|
||||
# bewusst NICHT in .env.example: Die beschreibt, was die Anwendung liest.
|
||||
# Hier konfigurierst du einen fremden Container – wer Office selbst
|
||||
# betreibt, tut das in dessen eigener Compose-Datei.
|
||||
aliasgroup1: ${OFFICE_ALIASGROUP:-http://app:8080}
|
||||
# ssl.termination=true nur, wenn davor wirklich HTTPS endet – sonst
|
||||
# verlangt Collabora eine wss://-Verbindung, die es nicht gibt, und der
|
||||
@@ -83,8 +102,43 @@ services:
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
|
||||
|
||||
# Euro-Office – OnlyOffice-Fork (Nextcloud/IONOS, seit 09.06.2026). Für
|
||||
# Aether ist es derselbe Anbieter wie OnlyOffice (`OFFICE_PROVIDER=onlyoffice`),
|
||||
# nur ein anderes Abbild: Die WOPI-Schnittstelle und die Variablen heißen
|
||||
# gleich.
|
||||
#
|
||||
# Eigenes Profil, damit es neben Collabora stehen kann, ohne sich den Port
|
||||
# zu teilen: ... --profile euro up -d
|
||||
#
|
||||
# Rechne mit rund 5 GB fürs Abbild und 400 MB im Leerlauf.
|
||||
euro:
|
||||
container_name: aether-euro
|
||||
image: ghcr.io/euro-office/documentserver:latest
|
||||
profiles: ["euro"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WOPI_ENABLED: "true"
|
||||
# Ohne Signatur antwortet der Server mit {"error":-8} – und zwar erst
|
||||
# im Editor, nicht schon bei der Ermittlung. Der Rahmen bleibt dann leer.
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||||
# Die Beispiel-App gehört nicht ins Netz.
|
||||
EXAMPLE_ENABLED: "false"
|
||||
# Es muss app:8080 erreichen – eine private Adresse, die es sonst
|
||||
# von sich aus ablehnt.
|
||||
ALLOW_PRIVATE_IP_ADDRESS: "true"
|
||||
networks: [internal]
|
||||
ports:
|
||||
# 9981, damit Collabora auf 9980 bleiben kann. Anders als dort ist der
|
||||
# Port hier **80** im Container, nicht 9980.
|
||||
- "${APP_BIND:-127.0.0.1}:${EURO_PORT:-9981}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
||||
# Ordner „deploy" hieß – umbenennen hieße hier: neues Volume, leere
|
||||
# Datenbank. Der unschöne Name ist der Preis dafür, die Daten zu behalten.
|
||||
name: deploy_postgres_data
|
||||
# "data" ist bewusst weg – die Dateien liegen unter DATA_PATH auf dem Wirt.
|
||||
# Die Datenbank bleibt ein benanntes Volume: An ihre Dateien geht man nicht
|
||||
# von Hand, und Postgres mag fremde Besitzer nicht.
|
||||
@@ -273,6 +273,27 @@ Geplante Ideen (noch nicht umgesetzt):
|
||||
- Glasmorphismus-Theme (transluzente Sidebar/Cards mit Blur-Effekt)
|
||||
- Logo und App-Symbol bereits in Phase 4.5/D umgesetzt
|
||||
|
||||
- ✅ **Gleitende Sitzung** (`internal/auth/session.go`, 01.09.2026, Wunsch von Marc: „der Login speichert sich nicht"). Die Frist zählt ab der **letzten Benutzung** statt ab dem Anmelden; Vorgabe 30 Tage. Verlängert wird höchstens einmal pro Stunde – ohne diese Bremse trüge jede Antwort ein `Set-Cookie`, und beim Laden einer Dateiliste sind das dreißig.
|
||||
- **Warum das keine Bequemlichkeit auf Kosten der Sicherheit ist:** Der alte Ablauf traf den *Anwesenden* und nicht den *Abwesenden* – wer täglich arbeitete, flog täglich heraus, wer das Konto vergaß, blieb genauso lange angemeldet. Ein Ablauf ab letzter Benutzung trifft genau den Fall, um den es geht.
|
||||
- **Der Preis steht in der Doku, nicht nur im Kopf:** Ein aus einem fremden Browser kopiertes Cookie verlängert sich beim Benutzen mit. Es gibt keine Sitzungsliste auf dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne Sitzungstabelle auszukommen. Alle Sitzungen beenden = `SESSION_SECRET` wechseln.
|
||||
- ⚠️ **Dabei gefunden, war schon vorher drin:** `NewSessionStore` ersetzte `store.Options`, ohne `store.MaxAge()` zu rufen. Es gibt aber **zwei** Fristen – die im Cookie (der Browser wirft es weg) und die in der Signatur (der Server lehnt es ab). Nur die erste war gesetzt, die zweite stand auf gorillas Voreinstellung von 30 Tagen. Ein herauskopiertes Cookie blieb damit einen Monat gültig, obwohl `SESSION_MAX_AGE` auf einen Tag stand: Wer die Frist kürzte, um sie zu kürzen, hatte nichts gekürzt. Behoben und mit Test festgehalten – der Test ist gegengeprüft, ohne den Aufruf schlägt er fehl.
|
||||
- **Bestehende `.env`-Werte bleiben unangetastet.** Wer die 30 Tage will, trägt sie selbst ein; die neue Vorgabe gilt nur für frische Installationen.
|
||||
|
||||
- ✅ **Office-Dokumente am Telefon als PDF ansehen** (`internal/office/convert.go` + `preview.go`, Migration 000020, 31.08.2026, Wunsch von Marc). Am Finger öffnet ein `.docx` **bei OnlyOffice** nicht mehr den Editor, sondern das gewandelte PDF im eigenen Betrachter; „Edit" daneben führt trotzdem hinein. Am Schreibtisch bleibt alles wie es war.
|
||||
- **Bei Collabora bleibt der Editor.** Zuerst galt die Regel für beide Anbieter – falsch, und Marc hat es sofort gesehen: Seine Kritik betraf nur OnlyOffice. Collabora bringt für kleine Schirme eine eigene Ansicht mit, die taugt; dort wäre der Umweg über ein PDF ein Rückschritt. Der Anbieter steht deshalb in der öffentlichen Konfiguration (`office_provider`), und die Oberfläche entscheidet daran. Der Endpunkt selbst kann beides und ist mit beiden gemessen (Collabora 0,35 s, OnlyOffice 1,05 s).
|
||||
- **Warum überhaupt:** Der Web-Editor ist eine vollständige Textverarbeitung. Menüband, Formatleisten und Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt – auf 400 Pixeln bleibt für das Dokument ein Rechteck. Marc hat Collabora **und** OnlyOffice ausprobiert, mit demselben Ergebnis; das ist der Grund, aus dem hier kein besserer Bedienmodus gesucht wurde, sondern ein anderes Werkzeug. Nextcloud löst es in seiner App genauso: ansehen statt bearbeiten.
|
||||
- **Gewandelt wird von dem 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. Beide Wege sind deshalb gebaut: Collabora nimmt die Bytes (`/cool/convert-to/pdf`), OnlyOffice eine Adresse, holt die Datei selbst und antwortet mit einer Adresse (`/ConvertService.ashx`, HS256-signiert).
|
||||
- **Je Datei gibt es genau ein PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels:** `previews/<id>.pdf` trägt die Fassung **nicht** im Namen. Eine neue Fassung überschreibt, statt danebenzulegen. Ob das Abgelegte noch passt, sagt `files.preview_version`. Gemessen: Nach dem erneuten Hochladen stand die Datei auf Fassung 2, `preview_version` auf 2, und im Ordner lag weiterhin genau eine Datei.
|
||||
- **Der GC kennt den Ordner – und die Referenz.** Beides musste sein: ohne Präfix wächst er ewig, ohne Referenz löscht der GC das PDF nach einem Tag weg. Genau dieser zweite Fehler ist beim Logo und beim Favicon schon einmal passiert. Beide Richtungen einzeln geprüft (herrenloses PDF von vorgestern → weg; echtes → bleibt).
|
||||
- **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** OnlyOffice antwortet nicht mit dem PDF, sondern mit einer Adresse, die wir 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 `OFFICE_ALLOWED_HOSTS` zu; die ausdrückliche Prüfung steht zusätzlich da und ist mit Test abgedeckt.
|
||||
- **Zählt gegen kein Kontingent** (Entscheidung von Marc): Es ist abgeleitet, niemand hat es hochgeladen. Platte kostet es trotzdem – deshalb steht der Platzbedarf jetzt im Admin-Bereich neben der Bereinigung, zusammen mit Vorschau- und Profilbildern. Ein Ordner, der wächst, ohne dass ihn irgendwo jemand sieht, ist eine stille Überraschung.
|
||||
- Gemessen an dieser Anlage: erster Abruf 1,05 s, zweiter 0,009 s. Fremde Kennung `404`, ohne Anmeldung `401`, Nicht-Office-Datei `415`, zu große Datei `413` (die Oberfläche bietet daraufhin den Editor an).
|
||||
|
||||
- ⏸️ **Im Papierkorb ansehen, was es war – vertagt** (26.08.2026, Wunsch von Marc: „erstmal noch nicht wichtig"). Die kleinen Vorschaubilder sind seit dem 26.08. wieder da (`GetThumbnail` sah nur in nicht-gelöschten Dateien nach). Gemeint ist der Schritt danach: eine Datei im Papierkorb **öffnen** und ansehen, bevor man „Delete forever" drückt.
|
||||
- **Warum es überhaupt eine Idee ist:** Der Papierkorb ist die einzige Ansicht ohne Vorschau – ausgerechnet die, in der die Entscheidung unwiderruflich ist. Wer nach zwei Wochen aufräumt, sieht einen Namen wie `IMG_2481.jpg` und rät.
|
||||
- **Wo es hängt:** an derselben Stelle wie eben das Vorschaubild. `Download` schlägt die Datei mit `is_deleted = false` nach (`handler.go:411`), der Vorschau-Dialog hängt außerdem an der Dateiliste, nicht an einem einzelnen Eintrag – er blättert zum nächsten Bild und kennt Umbenennen, Teilen, Löschen. Im Papierkorb wäre davon fast nichts sinnvoll.
|
||||
- **Der Zuschnitt wäre deshalb enger:** nur ansehen, kein Blättern, keine Bearbeitung – und der Download-Pfad müsste für Gelöschtes gezielt geöffnet werden, nicht pauschal. Sonst ließe sich eine gelöschte Datei über ihre Kennung genauso herunterladen wie eine lebende, und der Papierkorb wäre kein Zwischenschritt mehr, sondern ein zweiter Ordner.
|
||||
|
||||
**OIDC-Durchgang (23.08.2026)**
|
||||
|
||||
Vor dem Produktivgang durchgesehen. Zwei Befunde, beide vorher unsichtbar:
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# Aether – alle Schalter. cp deploy/.env.example deploy/.env
|
||||
#
|
||||
# Zwei Fallen beim Bearbeiten:
|
||||
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
||||
# Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert
|
||||
# Erklärungen und Fallstricke: README.md, Abschnitt „Einstellungen"
|
||||
#
|
||||
# Für den reinen Betrieb genügt die kürzere .env.example im
|
||||
# Wurzelverzeichnis – hier steht zusätzlich alles Optionale.
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
APP_ENV=production # development | production
|
||||
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
|
||||
PUBLIC_URL=https://cloud.example.com
|
||||
# IPs/CIDRs der Proxys, denen X-Forwarded-For/-Proto geglaubt wird.
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
|
||||
# ── Datenbank ─────────────────────────────────────────────────────────────────
|
||||
# DB_HOST ist der Dienstname aus dem Compose, nicht localhost.
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=aether
|
||||
DB_USER=aether
|
||||
# Jedes Zeichen erlaubt – nur ein $ muss $$ heißen oder der Wert in
|
||||
# 'einfache Anführungszeichen'. Sonst schneidet Compose lautlos ab. (README)
|
||||
DB_PASSWORD=changeme_strong_password
|
||||
|
||||
# ── Sitzung ───────────────────────────────────────────────────────────────────
|
||||
SESSION_SECRET=changeme_generate_with_openssl_rand_hex_32 # openssl rand -hex 32
|
||||
SESSION_MAX_AGE=86400 # Sekunden
|
||||
|
||||
# ── Speicher ──────────────────────────────────────────────────────────────────
|
||||
STORAGE_PATH=/data # im Container
|
||||
TMP_PATH=/data/tmp
|
||||
# Verzeichnis auf dem Wirt. Nur lesend anfassen – zum Hineinlegen: WebDAV.
|
||||
DATA_PATH=/pfad/zu/aether-data
|
||||
# `id -u` / `id -g` – sonst gehören die Dateien einem Container-Nutzer.
|
||||
APP_UID=1000
|
||||
APP_GID=1000
|
||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||
MAX_VERSIONS_KEPT=5
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe).
|
||||
# 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
||||
APP_BIND=127.0.0.1
|
||||
|
||||
# ── Erster Start ──────────────────────────────────────────────────────────────
|
||||
# Legt den Admin an. Danach ADMIN_PASSWORD leeren.
|
||||
# Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD).
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme_strong_admin_password
|
||||
|
||||
# ── OIDC (optional) ───────────────────────────────────────────────────────────
|
||||
# Leerer OIDC_ISSUER = aus. OIDC_REDIRECT_URL muss exakt
|
||||
# <PUBLIC_URL>/api/v1/auth/oidc/callback sein und beim Anbieter eingetragen.
|
||||
# OIDC_ISSUER=https://your-idp.example.com # Adresse, nicht der Name
|
||||
# OIDC_CLIENT_ID=aether
|
||||
# OIDC_CLIENT_SECRET=
|
||||
# OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
|
||||
# Bestehendes Konto verbinden statt ein zweites anzulegen.
|
||||
# VOR der ersten Anmeldung setzen – danach ist es zu spät.
|
||||
# OIDC_LINK_BY_USERNAME=true
|
||||
# Beschriftung des Knopfes auf der Anmeldeseite.
|
||||
# OIDC_BUTTON_LABEL=Anmelden mit PocketID
|
||||
|
||||
# ── HoneyOS-Leiste ────────────────────────────────────────────────────────────
|
||||
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
||||
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
|
||||
# connect-src), und die Oberflaeche laedt das Skript von dort nach.
|
||||
#
|
||||
# Wirkt beim **Neustart** — kein Neubau noetig.
|
||||
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
||||
HONEYBAR_ORIGIN=https://os.example.com
|
||||
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
|
||||
# drueben passen. Nur noetig, wenn HoneyOS auf einer anderen Site liegt als
|
||||
# Aether — dann schickt der Browser die HoneyOS-Sitzung nicht mit.
|
||||
#
|
||||
# Gleiche Site (os.beispiel.duckdns.org + cloud.beispiel.duckdns.org): kann leer
|
||||
# bleiben, die Sitzung reicht.
|
||||
HONEYBAR_KEY=
|
||||
|
||||
# ── Volltextsuche (OCR) ───────────────────────────────────────────────────────
|
||||
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
|
||||
# OCR_ENABLED=false
|
||||
# OCR_LANGUAGES=deu+eng # weitere brauchen ein apk-Paket im Dockerfile
|
||||
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
|
||||
# OCR_TIMEOUT=10m # Notbremse je Datei
|
||||
|
||||
# ── App-Umschalter ────────────────────────────────────────────────────────────
|
||||
# Andere selbst gehostete Apps im Menü der Seitenleiste.
|
||||
# Format: Name|URL|Emoji – mehrere durch Komma. Leer = kein Menü.
|
||||
EXTERNAL_APPS=
|
||||
|
||||
# ── Office (optional) ─────────────────────────────────────────────────────────
|
||||
# Leer = aus, auch wenn ein Office-Container läuft. Schnipsel: README.md
|
||||
# collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_PROVIDER=
|
||||
# Backend → Office. Collabora lauscht auf 9980, OnlyOffice/Euro-Office auf 80.
|
||||
OFFICE_URL=
|
||||
# Browser → Office; leer = wie OFFICE_URL
|
||||
OFFICE_PUBLIC_URL=
|
||||
# Office → Aether; leer = PUBLIC_URL.
|
||||
# Weder localhost noch der veröffentlichte Port – beides scheitert.
|
||||
OFFICE_WOPI_URL=
|
||||
# Gegen SSRF; der Host aus OFFICE_URL zählt immer dazu.
|
||||
OFFICE_ALLOWED_HOSTS=
|
||||
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen.
|
||||
OFFICE_JWT_SECRET=
|
||||
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
||||
|
||||
# Nur für den Collabora-Container aus deploy/docker-compose.yml:
|
||||
OFFICE_ALIASGROUP=http://app:8080
|
||||
OFFICE_SSL=false
|
||||
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
|
||||
OFFICE_PORT=9980
|
||||
|
||||
# ── Später ────────────────────────────────────────────────────────────────────
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_KEY=
|
||||
|
||||
# ── WebDAV ────────────────────────────────────────────────────────────────────
|
||||
# Läuft unter /dav/, kein Schalter nötig. Der Proxy muss
|
||||
# PROPFIND/MKCOL/MOVE/COPY durchreichen.
|
||||
@@ -16,7 +16,7 @@
|
||||
# `docker compose stop office`.
|
||||
#
|
||||
# Hintergrund und Fallstricke: README.md
|
||||
# Aus dem Quelltext bauen statt ziehen: deploy/docker-compose.yml
|
||||
# Aus dem Quelltext bauen statt ziehen: build/docker-compose.yml
|
||||
|
||||
services:
|
||||
app:
|
||||
@@ -41,10 +41,12 @@ services:
|
||||
- ${DATA_PATH:-./data}:/data
|
||||
networks: [internal]
|
||||
ports:
|
||||
# Standardmäßig nur loopback – davor gehört dein Reverse Proxy mit TLS.
|
||||
# Zum Ausprobieren im LAN: APP_BIND=0.0.0.0 in der .env. Dann liegt die
|
||||
# Anmeldung allerdings unverschlüsselt im Netz.
|
||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-8080}:8080"
|
||||
# Vorgabe 0.0.0.0: erreichbar für alles, was den Server im Netz sieht –
|
||||
# denn der Reverse Proxy steht meist auf einem anderen Rechner, und
|
||||
# loopback ließe ihn nicht heran. Davor gehört er trotzdem, mit TLS.
|
||||
# Läuft er auf DIESEM Rechner: APP_BIND=127.0.0.1 in der .env, dann ist
|
||||
# der Port von außen dicht.
|
||||
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-8080}:8080"
|
||||
|
||||
postgres:
|
||||
container_name: aether-db
|
||||
|
||||
Generated
+3598
-9
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^19.2.7",
|
||||
@@ -22,8 +23,10 @@
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
|
||||
@@ -53,7 +53,6 @@ function AuthedApp() {
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.7s linear infinite',
|
||||
}} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+254
-16
@@ -90,13 +90,49 @@ export interface CreateShareRequest {
|
||||
expires_in_hours?: number
|
||||
}
|
||||
|
||||
/** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst
|
||||
* nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden
|
||||
* etwas an, der bloß den Link hat. */
|
||||
export interface PublicShareInfo {
|
||||
link: ShareLink
|
||||
has_password: boolean
|
||||
expires_at?: string | null
|
||||
download_count: number
|
||||
created_at: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
}
|
||||
|
||||
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
|
||||
export interface RescanEntry {
|
||||
kind: 'new' | 'orphaned' | 'changed'
|
||||
storage_key: string
|
||||
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
|
||||
path: string
|
||||
owner: string
|
||||
size: number
|
||||
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RescanReport {
|
||||
new: number
|
||||
orphaned: number
|
||||
changed: number
|
||||
scanned: number
|
||||
samples: RescanEntry[]
|
||||
aborted?: boolean
|
||||
/** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */
|
||||
abort_reason?: string
|
||||
/** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */
|
||||
overridable?: boolean
|
||||
/** Nur nach dem Anwenden gefüllt. */
|
||||
adopted?: number
|
||||
removed?: number
|
||||
corrected?: number
|
||||
errors?: number
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string
|
||||
file_id: string
|
||||
@@ -153,12 +189,25 @@ async function request<T>(
|
||||
}
|
||||
|
||||
// XHR-based upload with real progress reporting.
|
||||
/**
|
||||
* Ein vom Nutzer abgebrochener Upload – kein Fehler, sondern eine Absicht.
|
||||
*
|
||||
* Eigene Klasse, damit die Warteschlange die beiden unterscheiden kann: Ein
|
||||
* Fehler bleibt als Meldung stehen, weil er eine Auskunft trägt. „Ich wollte
|
||||
* das nicht" trägt keine und verschwindet von selbst.
|
||||
*/
|
||||
export class UploadAborted extends Error {
|
||||
constructor() { super('Upload aborted') }
|
||||
}
|
||||
|
||||
export function uploadWithProgress(
|
||||
file: File,
|
||||
folderId: string | undefined,
|
||||
onProgress: (p: UploadProgress) => void
|
||||
onProgress: (p: UploadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FileItem> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new UploadAborted()); return }
|
||||
const form = new FormData()
|
||||
// folder_id MUST come before the file so the server can stream the file
|
||||
// straight to storage without buffering the whole body first.
|
||||
@@ -197,7 +246,17 @@ export function uploadWithProgress(
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
|
||||
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
|
||||
xhr.addEventListener('abort', () => reject(new UploadAborted()))
|
||||
|
||||
/**
|
||||
* Abbrechen heißt hier: die Verbindung fallen lassen.
|
||||
*
|
||||
* Der Server merkt das mitten im Lesen. Er schreibt in eine Temp-Datei und
|
||||
* benennt sie erst am Ende um – ein abgebrochener Upload hinterlässt
|
||||
* deshalb weder Bytes noch eine Zeile in der Datenbank. Es braucht also
|
||||
* keinen zweiten Ruf zum Aufräumen; das Aufräumen ist die Bauweise.
|
||||
*/
|
||||
signal?.addEventListener('abort', () => xhr.abort(), { once: true })
|
||||
|
||||
xhr.send(form)
|
||||
})
|
||||
@@ -205,12 +264,6 @@ export function uploadWithProgress(
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExternalApp {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
oidc_enabled: boolean
|
||||
cloud_name: string
|
||||
@@ -225,6 +278,8 @@ export interface PublicConfig {
|
||||
/** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar
|
||||
* nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */
|
||||
office_enabled?: boolean
|
||||
/** 'collabora' | 'onlyoffice' | '' – entscheidet über die Handy-Ansicht. */
|
||||
office_provider?: string
|
||||
/** Wie viele frühere Fassungen der Server aufhebt (MAX_VERSIONS_KEPT). */
|
||||
max_versions_kept?: number
|
||||
/** Beschriftung des SSO-Knopfes (OIDC_BUTTON_LABEL). Leer, wenn OIDC aus ist. */
|
||||
@@ -233,10 +288,6 @@ export interface PublicConfig {
|
||||
accent_color?: string
|
||||
/** Ursprung der HoneyOS-Leiste (HONEYBAR_ORIGIN). Leer = keine Leiste. */
|
||||
honeybar_origin?: string
|
||||
/** Ausweis fuer deren App-Liste (HONEYBAR_KEY). Nur gesetzt, wenn es einen
|
||||
* Ursprung gibt. */
|
||||
honeybar_key?: string
|
||||
external_apps: ExternalApp[]
|
||||
}
|
||||
|
||||
export const publicApi = {
|
||||
@@ -308,12 +359,15 @@ export const aclApi = {
|
||||
|
||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
||||
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
|
||||
|
||||
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
|
||||
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
|
||||
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
|
||||
}
|
||||
|
||||
@@ -541,8 +595,21 @@ export interface OfficeSession {
|
||||
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
|
||||
* entschieden wird serverseitig.
|
||||
*/
|
||||
/**
|
||||
* Endungen, die im Office-Server aufgehen sollen.
|
||||
*
|
||||
* **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es
|
||||
* einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur
|
||||
* hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server
|
||||
* braucht und direkt speichert. Mit `txt` in dieser Liste lief eine
|
||||
* 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht
|
||||
* auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie.
|
||||
*
|
||||
* `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in
|
||||
* einem Textfeld.
|
||||
*/
|
||||
const officeExtensions = new Set([
|
||||
'odt', 'docx', 'doc', 'rtf', 'txt',
|
||||
'odt', 'docx', 'doc', 'rtf',
|
||||
'ods', 'xlsx', 'xls', 'csv',
|
||||
'odp', 'pptx', 'ppt',
|
||||
'odg',
|
||||
@@ -554,8 +621,11 @@ export function isOfficeFile(name: string): boolean {
|
||||
}
|
||||
|
||||
export const officeApi = {
|
||||
session: (fileId: string) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}`),
|
||||
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
|
||||
* anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie
|
||||
* nicht und bleibt unverändert. */
|
||||
session: (fileId: string, mobile = false) =>
|
||||
request<OfficeSession>('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`),
|
||||
}
|
||||
|
||||
export const ocrApi = {
|
||||
@@ -601,6 +671,27 @@ export const filesApi = {
|
||||
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
|
||||
* mit den Fassungen 1, 2, 3.
|
||||
*/
|
||||
/**
|
||||
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
|
||||
*
|
||||
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
|
||||
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
|
||||
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
|
||||
*
|
||||
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
|
||||
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
|
||||
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
|
||||
*/
|
||||
setThumbnail: async (id: string, bild: Blob) => {
|
||||
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
|
||||
body: bild,
|
||||
})
|
||||
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
|
||||
},
|
||||
|
||||
thumbnail: (id: string, version?: number | string) =>
|
||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||
|
||||
@@ -838,6 +929,16 @@ export const adminApi = {
|
||||
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
|
||||
},
|
||||
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
|
||||
/** Platzbedarf der abgeleiteten Daten – zählt gegen kein Kontingent. */
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||
/** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde
|
||||
* fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */
|
||||
rescan: (apply = false, bestaetigt = false) =>
|
||||
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
@@ -877,4 +978,141 @@ export const adminApi = {
|
||||
request<AppSettings>('PATCH', '/admin/settings', data),
|
||||
}
|
||||
|
||||
// ── Calendar ─────────────────────────────────────────────────────────────
|
||||
// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit
|
||||
// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese
|
||||
// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen
|
||||
// Session, wie jeder andere Bereich.
|
||||
|
||||
export interface CalendarItem {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
supports_todo: boolean
|
||||
timezone: string
|
||||
ctag: number
|
||||
created_at: string
|
||||
/** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */
|
||||
permission: 'read' | 'write'
|
||||
/** Der Kalender gehört jemand anderem. */
|
||||
shared: boolean
|
||||
/** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne
|
||||
* sichtbaren Absender ist ein Rätsel. */
|
||||
owner_name?: string
|
||||
/** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */
|
||||
via_group?: string
|
||||
}
|
||||
|
||||
/** Kurz: Darf ich diesen Kalender ändern? */
|
||||
export function canEditCalendar(c: CalendarItem): boolean {
|
||||
return c.permission !== 'read'
|
||||
}
|
||||
|
||||
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
|
||||
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
|
||||
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
|
||||
* sind dann nur lesbar (siehe events_api.go). */
|
||||
export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
|
||||
|
||||
export interface Recurrence {
|
||||
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
||||
interval: number
|
||||
count?: number
|
||||
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
|
||||
until?: string
|
||||
/** Nur WEEKLY: an welchen Wochentagen. Leer = Wochentag des Starts. */
|
||||
byday?: WeekdayCode[]
|
||||
/** Nur MONTHLY/YEARLY: am festen Kalendertag (am 4.) oder am n-ten
|
||||
* Wochentag (am 1. Freitag). */
|
||||
mode?: 'DAY' | 'WEEKDAY'
|
||||
/** Nur zum Anzeigen – der Server rechnet beides aus dem Startdatum aus. */
|
||||
nth?: number
|
||||
weekday?: WeekdayCode
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
uid: string
|
||||
summary: string
|
||||
description: string
|
||||
location: string
|
||||
/** ISO 8601, immer UTC ("...Z"). Bei ganztägigen Terminen Mitternacht UTC
|
||||
* als reine Datumsangabe – nie über new Date(...) in Ortszeit umrechnen,
|
||||
* sondern über eventDay() aus components/calendar/dates.ts. */
|
||||
start: string
|
||||
end?: string
|
||||
all_day: boolean
|
||||
/** Leer = Farbe des Kalenders. */
|
||||
color?: string
|
||||
/** Es hängt überhaupt eine Wiederholung dran. */
|
||||
recurring: boolean
|
||||
/** Die Regel – nur gesetzt, wenn sie sich im Formular abbilden lässt. */
|
||||
recurrence?: Recurrence
|
||||
/** Bei aufgeklappten Wiederholungen: Anfang/Ende der **Reihe**, während
|
||||
* start/end den einzelnen Termin tragen. Das Formular bearbeitet immer
|
||||
* die Reihe. */
|
||||
series_start?: string
|
||||
series_end?: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface EventInput {
|
||||
summary: string
|
||||
description?: string
|
||||
location?: string
|
||||
start: string
|
||||
end?: string
|
||||
all_day: boolean
|
||||
color?: string
|
||||
recurrence?: Recurrence | null
|
||||
}
|
||||
|
||||
export const calendarApi = {
|
||||
list: () => request<CalendarItem[]>('GET', '/calendars'),
|
||||
create: (name: string, color: string, description = '') =>
|
||||
request<CalendarItem>('POST', '/calendars', { name, color, description }),
|
||||
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
|
||||
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
|
||||
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
|
||||
|
||||
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
|
||||
|
||||
import: async (id: string, file: File): Promise<{ imported: number }> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/calendar' }
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
|
||||
const res = await fetch(`/api/v1/calendars/${id}/import`, {
|
||||
method: 'POST', headers, credentials: 'include', body: file,
|
||||
})
|
||||
const json = await res.json().catch(() => ({ error: res.statusText }))
|
||||
if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed')
|
||||
return json.data
|
||||
},
|
||||
|
||||
/** Termine im Zeitfenster. Wiederholungen kommen vom Server bereits in
|
||||
* einzelne Termine aufgeklappt zurück – deshalb braucht es die Grenzen:
|
||||
* eine tägliche Reihe ohne Ende hat sonst kein natürliches Ende. */
|
||||
listEvents: (id: string, from?: Date, to?: Date) => {
|
||||
const qs = from && to ? `?from=${from.toISOString()}&to=${to.toISOString()}` : ''
|
||||
return request<CalendarEvent[]>('GET', `/calendars/${id}/events${qs}`)
|
||||
},
|
||||
createEvent: (id: string, input: EventInput) =>
|
||||
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
|
||||
updateEvent: (id: string, uid: string, input: EventInput) =>
|
||||
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
|
||||
/** Hängt einen Termin in einen anderen Kalender um – UID und Inhalt bleiben,
|
||||
* nur die Sammlung wechselt. Bei einer Reihe zieht die ganze Reihe mit:
|
||||
* Sie ist eine einzige Datei, kein Bündel von Einzelterminen. */
|
||||
moveEvent: (id: string, uid: string, targetId: string) =>
|
||||
request<CalendarEvent>('POST', `/calendars/${id}/events/${encodeURIComponent(uid)}/move`,
|
||||
{ calendar_id: targetId }),
|
||||
/** Ohne `occurrence` verschwindet der Termin ganz – bei einer Reihe also
|
||||
* die ganze Reihe. Mit `occurrence` (ISO-Anfang des einzelnen Termins)
|
||||
* fällt nur dieser eine aus, die Reihe bleibt bestehen. */
|
||||
deleteEvent: (id: string, uid: string, occurrence?: string) => {
|
||||
const qs = occurrence ? `?occurrence=${encodeURIComponent(occurrence)}` : ''
|
||||
return request<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`)
|
||||
},
|
||||
}
|
||||
|
||||
export { APIError }
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Die eine Breite, ab der die Bedienung „breit" ist.
|
||||
*
|
||||
* Sie entscheidet **zwei** Dinge, und beide müssen zusammen umschalten:
|
||||
* die Seitenleiste steht fest im Fluss statt über dem Inhalt zu liegen, und
|
||||
* die Kopfleiste kommt in eine Zeile – der Knopf zum Aufschieben verschwindet
|
||||
* dabei, weil es nichts mehr aufzuschieben gibt.
|
||||
*
|
||||
* Vorher standen dafür zwei Zahlen an zwei Stellen: 720px in der Kopfleiste,
|
||||
* 767px in der Seitenleiste. Dazwischen lag ein Band von 48 Pixeln, in dem der
|
||||
* Knopf schon weg war, die Leiste aber noch nach links aus dem Bild geschoben –
|
||||
* kein Menü, kein Weg dorthin. Ein aufgeklapptes Fold landet mit rund 757 px
|
||||
* genau darin.
|
||||
*
|
||||
* Also: eine Zahl, ein Ort. Wer sie ändert, ändert beides zugleich.
|
||||
*/
|
||||
export const wideEnough = '(min-width: 720px)'
|
||||
|
||||
/** Dieselbe Grenze zur Laufzeit – für Fälle, die kein CSS lösen kann. */
|
||||
export function isWide(): boolean {
|
||||
return typeof matchMedia === 'function' && matchMedia(wideEnough).matches
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { publicApi, type ExternalApp } from '../api/client'
|
||||
|
||||
/**
|
||||
* DERZEIT NICHT EINGEHÄNGT (Stand 2026-07-30).
|
||||
*
|
||||
* Auf Wunsch wieder aus der Seitenleiste entfernt – erst soll Aether für sich
|
||||
* fertig werden. Die Komponente bleibt liegen, weil eine gemeinsame Leiste für
|
||||
* alle Apps noch überlegt wird. Wird sie nirgends importiert, landet sie auch
|
||||
* nicht im Bundle. Zum Reaktivieren: in `Sidebar.tsx` neben dem Markenschriftzug
|
||||
* einsetzen und EXTERNAL_APPS in `deploy/.env` füllen.
|
||||
*
|
||||
* Umschalter zu den anderen selbst gehosteten Apps.
|
||||
*
|
||||
* Die Einträge kommen aus EXTERNAL_APPS in der .env des Servers – so lassen
|
||||
* sich Apps ergänzen, ohne das Frontend neu zu bauen. Ist nichts gesetzt,
|
||||
* rendert die Komponente nichts.
|
||||
*
|
||||
* Die Ziele liegen auf anderen Ursprüngen (eigene Subdomain je App). Deshalb
|
||||
* `target="_blank"`: Ist die andere App als PWA installiert, übernimmt das
|
||||
* Betriebssystem sie als eigene App; sonst öffnet sie in einem neuen Tab.
|
||||
* Eine Navigation im selben Fenster würde die installierte Aether-App
|
||||
* verlassen und in einer Browserleiste landen.
|
||||
*/
|
||||
export default function AppSwitcher() {
|
||||
const [apps, setApps] = useState<ExternalApp[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
publicApi.config()
|
||||
.then(c => setApps(c.external_apps ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDown(e: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onEsc)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (apps.length === 0) return null
|
||||
|
||||
return (
|
||||
<div ref={boxRef} style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-label="Other apps"
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 8, cursor: 'pointer',
|
||||
background: open ? 'var(--color-surface-2)' : 'transparent',
|
||||
border: 'none', color: 'var(--color-text-muted)', padding: 0,
|
||||
}}
|
||||
>
|
||||
{/* Raster aus neun Punkten – das übliche Zeichen für einen App-Wechsel */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
{[5, 12, 19].map(y => [5, 12, 19].map(x => (
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" />
|
||||
)))}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 34, right: 0, zIndex: 60, minWidth: 190,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
|
||||
padding: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '6px 10px 4px', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--color-text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
My apps
|
||||
</div>
|
||||
{apps.map(app => (
|
||||
<a
|
||||
key={app.url}
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--color-surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span style={{ fontSize: 16, width: 20, textAlign: 'center' }}>{app.icon || '🔗'}</span>
|
||||
{app.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Excalidraw, exportToBlob, getSceneVersion, restore, serializeAsJSON } from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import { APIError, filesApi, type FileItem } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { FileTypeIcon } from './icons'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { toast, fehlertext } from './Toasts'
|
||||
|
||||
/**
|
||||
* Zeichnen und Mindmaps – auf Excalidraw.
|
||||
*
|
||||
* **Eine Zeichnung ist eine Datei, kein neues Ding.** Das ist die ganze
|
||||
* Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie alles, was
|
||||
* Aether für Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
|
||||
* Versionen, Freigabelinks, interne Freigaben, Suche nach Namen und Etikett.
|
||||
* Keine neue Tabelle, kein zweites Rechtemodell.
|
||||
*
|
||||
* Gespeichert wird über denselben Weg wie ein Upload – gleicher Name,
|
||||
* gleicher Ordner. Genau wie der Text-Editor in der Vorschau. Ein eigener
|
||||
* Schreib-Endpunkt bräuchte dieselben Prüfungen noch einmal.
|
||||
*
|
||||
* **Kein gleichzeitiges Arbeiten.** Die Komponente könnte es (siehe
|
||||
* `isCollaborating`), aber der Transport gehört nicht dazu – dafür bräuchte
|
||||
* es einen zweiten Dienst neben Aether, so wie Euro-Office einer ist. Der
|
||||
* Preis dafür steht unten bei `pruefeFremdeAenderung`: Ohne Abgleich würden
|
||||
* zwei Leute sich lautlos gegenseitig die Arbeit löschen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wo die Schriften liegen.
|
||||
*
|
||||
* Ohne das laedt Excalidraw sie von einem fremden Ursprung – und die
|
||||
* Sicherheitsrichtlinie (`default-src 'self'`, kein eigenes `font-src`)
|
||||
* verbietet genau das. Die Zeichnung erschiene in einer Ersatzschrift, und man
|
||||
* suchte den Grund im Zeichenbrett statt in der Richtlinie.
|
||||
*
|
||||
* **Hier und nicht in index.html:** Ein Inline-Skript dort waere von derselben
|
||||
* Richtlinie blockiert (`script-src 'self'`, kein `unsafe-inline`). Kopiert
|
||||
* werden die Dateien beim Bauen, siehe vite.config.ts.
|
||||
*/
|
||||
declare global {
|
||||
interface Window { EXCALIDRAW_ASSET_PATH?: string }
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.EXCALIDRAW_ASSET_PATH = '/excalidraw/'
|
||||
}
|
||||
|
||||
/** Was in einer leeren Zeichnung steht. */
|
||||
export function leereZeichnung(): string {
|
||||
return serializeAsJSON([], {}, {}, 'local')
|
||||
}
|
||||
|
||||
export const drawingExtension = '.excalidraw'
|
||||
|
||||
/**
|
||||
* Eine Zeichnung holen und in einen Zustand verwandeln, den das Zeichenbrett
|
||||
* annimmt.
|
||||
*
|
||||
* Steht hier und nicht im Effekt, damit es sich prüfen lässt – der Weg hat
|
||||
* drei Fallen, und zwei davon enden in verlorener Arbeit.
|
||||
*/
|
||||
export async function ladeZeichnung(url: string) {
|
||||
const r = await fetch(url, { credentials: 'include' })
|
||||
// **`fetch` wirft bei 404 oder 403 nicht.** Ohne diese Zeile käme der
|
||||
// Fehlerrumpf `{"error":"file not found"}` weiter, `JSON.parse` gelänge –
|
||||
// es ist gültiges JSON – und `elements` wäre `undefined`. Das Zeichenbrett
|
||||
// ginge **leer** auf; es sähe aus, als sei die Zeichnung verloren. Wer dann
|
||||
// speichert, macht es wahr.
|
||||
if (!r.ok) throw new APIError(r.status, 'could not load the drawing')
|
||||
|
||||
const text = await r.text()
|
||||
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
|
||||
const d = JSON.parse(text.trim() || leereZeichnung())
|
||||
|
||||
// `restore` säubert und ergänzt Fehlendes. Es ist der Weg, den Excalidraw
|
||||
// dafür vorsieht – selbst zu prüfen hiesse, seine Feldliste nachzubauen und
|
||||
// bei jeder Fassung nachzuziehen.
|
||||
//
|
||||
// **Eine Sache erledigt es allerdings nicht** (nachgemessen): Ein bereits
|
||||
// verunglücktes `collaborators: {}` reicht es unverändert durch – die Map
|
||||
// entsteht nur, wenn das Feld ganz fehlt. Neue Dateien haben es nicht mehr,
|
||||
// weil `serializeAsJSON` es gar nicht erst schreibt; die paar aus der ersten
|
||||
// Stunde wären sonst dauerhaft kaputt.
|
||||
if (d?.appState) delete d.appState.collaborators
|
||||
return restore(d, null, null)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste auffrischen, damit Größe und Fassung stimmen. */
|
||||
onSaved?: (aktualisiert: FileItem) => void
|
||||
}
|
||||
|
||||
export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
const [szene, setSzene] = useState<ReturnType<typeof restore> | null>(null)
|
||||
const [fehler, setFehler] = useState('')
|
||||
const [speichert, setSpeichert] = useState(false)
|
||||
const [geaendert, setGeaendert] = useState(false)
|
||||
/** Rückfragen als eigener Dialog statt window.confirm – siehe unten. */
|
||||
const [frage, setFrage] = useState<null | {
|
||||
titel: string; text: string; label: string; danger?: boolean; tun: () => void
|
||||
}>(null)
|
||||
|
||||
/**
|
||||
* Die Fassung der Zeichnung, wie sie zuletzt gespeichert wurde.
|
||||
*
|
||||
* **Nicht einfach ein Schalter „geändert".** `onChange` feuert bei jeder
|
||||
* Kleinigkeit – Mauszeiger, Auswahl, Bildlauf –, nicht nur wenn sich etwas
|
||||
* am Inhalt tut. Ein Schalter stand deshalb schon Sekunden nach dem
|
||||
* Speichern wieder auf „geändert", und beim Schließen kam die Rückfrage,
|
||||
* obwohl nichts offen war.
|
||||
*
|
||||
* `getSceneVersion` zählt den Inhalt, nicht die Bedienung. Stimmt die Zahl
|
||||
* mit der gespeicherten überein, gibt es nichts zu sichern.
|
||||
*/
|
||||
const gespeicherteFassung = useRef<number | null>(null)
|
||||
|
||||
/** Die Fassung, auf der wir arbeiten. Grundlage für den Abgleich unten. */
|
||||
const basis = useRef(file.version)
|
||||
/** Der jeweils letzte Stand aus dem Zeichenbrett, ohne bei jedem Strich zu rendern. */
|
||||
const aktuell = useRef<{ elements: readonly unknown[]; appState: Record<string, unknown>; files: unknown } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let lebt = true
|
||||
ladeZeichnung(filesApi.inline(file.id))
|
||||
.then(szene => { if (lebt) setSzene(szene) })
|
||||
.catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) })
|
||||
return () => { lebt = false }
|
||||
}, [file.id])
|
||||
|
||||
/**
|
||||
* Hat jemand anders die Datei inzwischen geändert?
|
||||
*
|
||||
* Ohne diese Frage überschreibt der Zweite den Ersten – **lautlos**, und das
|
||||
* ist die unangenehmste Sorte Datenverlust: Niemand merkt sie, bis jemand
|
||||
* seine Arbeit sucht. Es gibt keinen Endpunkt für eine einzelne Datei,
|
||||
* deshalb über die Ordnerliste; das ist eine Anfrage und keine neue
|
||||
* Schnittstelle.
|
||||
*/
|
||||
const pruefeFremdeAenderung = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const d = await filesApi.list(file.folder_id ?? undefined)
|
||||
const jetzt = (d.files ?? []).find(f => f.id === file.id)
|
||||
return !!jetzt && jetzt.version !== basis.current
|
||||
} catch {
|
||||
// Lässt sich die Frage nicht beantworten, wird nicht geraten: Wir
|
||||
// speichern und verlassen uns auf die Versionshistorie.
|
||||
return false
|
||||
}
|
||||
}, [file.id, file.folder_id])
|
||||
|
||||
async function speichern(trotzFremderAenderung = false) {
|
||||
if (!aktuell.current) return
|
||||
setSpeichert(true)
|
||||
try {
|
||||
if (!trotzFremderAenderung && await pruefeFremdeAenderung()) {
|
||||
setSpeichert(false)
|
||||
setFrage({
|
||||
titel: 'Someone else saved this drawing',
|
||||
text: 'Saving now replaces their version. The previous one stays in '
|
||||
+ 'the version history either way.',
|
||||
label: 'Save anyway',
|
||||
danger: true,
|
||||
tun: () => { setFrage(null); void speichern(true) },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// **Einmal festhalten, was hochgeht.** Während der Upload läuft, kann
|
||||
// weitergezeichnet werden – `aktuell.current` ist dann ein anderer
|
||||
// Stand. Läse man die Fassungsnummer hinterher von dort, gälten die
|
||||
// neuen Striche als gespeichert, obwohl sie nie hochgingen. Beim
|
||||
// Schliessen wären sie lautlos weg.
|
||||
const stand = aktuell.current
|
||||
|
||||
// Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder,
|
||||
// die eine Zeichnung ausmachen – nicht den Laufzeitzustand.
|
||||
const inhalt = serializeAsJSON(
|
||||
stand.elements as never, stand.appState as never, stand.files as never, 'local')
|
||||
const blob = new File([inhalt], file.name, { type: 'application/json' })
|
||||
const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
|
||||
basis.current = neu.version
|
||||
gespeicherteFassung.current = getSceneVersion(stand.elements as never)
|
||||
// Ist inzwischen weitergezeichnet worden, steht „Unsaved" sofort wieder
|
||||
// da – richtig so, es ist ja etwas offen.
|
||||
const jetzt = aktuell.current ? getSceneVersion(aktuell.current.elements as never) : null
|
||||
setGeaendert(jetzt !== null && jetzt !== gespeicherteFassung.current)
|
||||
onSaved?.(neu)
|
||||
toast.info('Drawing saved')
|
||||
void vorschaubild(neu, stand)
|
||||
} catch (err) {
|
||||
toast.error(fehlertext(err, 'Could not save the drawing'))
|
||||
} finally {
|
||||
setSpeichert(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Vorschaubild erzeugen und hochladen.
|
||||
*
|
||||
* Der Server kann das nicht: In der Datei steht JSON, und daraus wird ohne
|
||||
* Browser kein Bild. Hier ist der Browser, der die Zeichnung ohnehin gerade
|
||||
* gezeichnet hat.
|
||||
*
|
||||
* **Nach** dem Speichern und ohne darauf zu warten: Ein fehlgeschlagenes
|
||||
* Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern
|
||||
* scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst
|
||||
* ist heil, und eine rote Meldung dafür wäre verwirrend.
|
||||
*/
|
||||
async function vorschaubild(neu: FileItem, stand: NonNullable<typeof aktuell.current>) {
|
||||
try {
|
||||
const bild = await exportToBlob({
|
||||
// Derselbe Stand, der hochgegangen ist – nicht der, der gerade auf
|
||||
// dem Zeichenbrett liegt. Sonst zeigte die Kachel etwas, das so in
|
||||
// keiner Fassung steht.
|
||||
elements: stand.elements as never,
|
||||
appState: { ...(stand.appState as object), exportBackground: true } as never,
|
||||
files: stand.files as never,
|
||||
mimeType: 'image/png',
|
||||
exportPadding: 16,
|
||||
// Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen
|
||||
// kostet nur Bandbreite.
|
||||
getDimensions: (w: number, h: number) => {
|
||||
const faktor = Math.min(1, 512 / Math.max(w, h))
|
||||
return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor }
|
||||
},
|
||||
})
|
||||
await filesApi.setThumbnail(neu.id, bild)
|
||||
// **Der Liste Bescheid geben.** Sie kennt nur die Antwort des Uploads,
|
||||
// und die entstand, bevor es das Bild gab – ohne diese Zeile zeigte die
|
||||
// Kachel bis zum nächsten Neuladen nichts.
|
||||
onSaved?.({ ...neu, has_thumbnail: true })
|
||||
} catch {
|
||||
/* Schönheitsfehler – die Zeichnung ist gespeichert. */
|
||||
}
|
||||
}
|
||||
|
||||
// Strg+S / Cmd+S, wie überall sonst auch.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
if (geaendert && !speichert) void speichern()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
// Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen
|
||||
// erst fragen – `false` hält die Ansicht offen (siehe useBackClose).
|
||||
useBackClose({
|
||||
active: true,
|
||||
onClose: () => {
|
||||
if (!geaendert) { onClose(); return }
|
||||
// `false` hält die Ansicht offen, solange der Dialog steht – sonst wäre
|
||||
// der Verlaufseintrag schon verbraucht, und ein „Cancel" liesse die
|
||||
// nächste Zurück-Geste aus der Anwendung führen.
|
||||
frageSchliessen()
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
/** Die eine Rückfrage vor dem Verwerfen – an drei Stellen dieselbe. */
|
||||
function frageSchliessen() {
|
||||
setFrage({
|
||||
titel: 'Close without saving?',
|
||||
text: 'Your changes to this drawing will be lost.',
|
||||
label: 'Discard changes',
|
||||
danger: true,
|
||||
tun: () => { setFrage(null); onClose() },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-fullscreen="" style={{
|
||||
position: 'fixed', inset: 'var(--honeybar-h) 0 0 0', zIndex: 120,
|
||||
background: 'var(--color-background)', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', flexShrink: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{ display: 'flex', flexShrink: 0 }}>
|
||||
<FileTypeIcon mime={file.mime_type} name={file.name} size={18} />
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
minWidth: 0, fontSize: 14, fontWeight: 500, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name.replace(/\.excalidraw$/i, '')}
|
||||
</span>
|
||||
|
||||
{/* Ein Punkt hinter dem Namen ist leicht zu übersehen – und das
|
||||
Übersehene ist hier die ungespeicherte Arbeit. */}
|
||||
{geaendert && (
|
||||
<span style={{
|
||||
flexShrink: 0, padding: '2px 8px', borderRadius: 999,
|
||||
background: 'rgba(var(--color-primary-rgb), 0.18)',
|
||||
color: 'var(--color-primary)', fontSize: 11, fontWeight: 600,
|
||||
}}>
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<button
|
||||
onClick={() => void speichern()}
|
||||
disabled={speichert || !geaendert}
|
||||
title={geaendert ? 'Save (Ctrl+S)' : 'Nothing to save'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||||
background: geaendert ? 'var(--color-primary)' : 'var(--color-surface-2)',
|
||||
color: geaendert ? 'var(--color-primary-fg)' : 'var(--color-text-muted)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
cursor: speichert || !geaendert ? 'default' : 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{speichert ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { if (geaendert) frageSchliessen(); else onClose() }}
|
||||
// **Kein Escape.** Das Zeichenbrett braucht die Taste selbst – zum
|
||||
// Abwählen und zum Verlassen eines Werkzeugs. Wer sie hier abfängt,
|
||||
// schliesst die Zeichnung, während jemand nur ein Rechteck abwählen
|
||||
// wollte. Hinaus geht es über diesen Knopf oder die Zurück-Geste.
|
||||
title="Close"
|
||||
style={{
|
||||
padding: 6, borderRadius: 8, border: 'none', background: 'none',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{frage && (
|
||||
<ConfirmDialog
|
||||
title={frage.titel}
|
||||
message={frage.text}
|
||||
confirmLabel={frage.label}
|
||||
danger={frage.danger}
|
||||
onConfirm={frage.tun}
|
||||
onCancel={() => setFrage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{fehler && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
color: 'var(--color-danger)', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
{fehler}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fehler && !szene && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-text-muted)', fontSize: 13,
|
||||
}}>
|
||||
Opening…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{szene && (
|
||||
<Excalidraw
|
||||
initialData={szene}
|
||||
// Die Oberfläche ist dunkel; ein weisses Zeichenbrett darin war
|
||||
// der auffälligste Bruch. Umschalten kann man es im Menü – für
|
||||
// einen Ausdruck ist Hell die bessere Wahl.
|
||||
theme="dark"
|
||||
name={file.name.replace(/\.excalidraw$/i, '')}
|
||||
autoFocus
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
// **Aus.** Beide laden oder speichern eine Datei am Browser
|
||||
// vorbei: „Open" ersetzte den Inhalt dieser Zeichnung durch
|
||||
// eine fremde, „Save to…" schriebe eine zweite Fassung in den
|
||||
// Download-Ordner, die niemand mehr wiederfindet. In Aether
|
||||
// gehört die Datei dorthin, wo sie liegt.
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
},
|
||||
}}
|
||||
onChange={(elements, appState, files) => {
|
||||
// **Nicht bei jedem Aufruf in den Zustand schreiben.** onChange
|
||||
// feuert bei jeder Mausbewegung; ein setState hier würde das
|
||||
// Zeichenbrett bei jedem Strich neu rendern.
|
||||
aktuell.current = { elements, appState: appState as unknown as Record<string, unknown>, files }
|
||||
const fassung = getSceneVersion(elements)
|
||||
if (gespeicherteFassung.current === null) gespeicherteFassung.current = fassung
|
||||
const offen = fassung !== gespeicherteFassung.current
|
||||
if (offen !== geaendert) setGeaendert(offen)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Was dasteht, wenn nichts dasteht.
|
||||
*
|
||||
* Bisher war das je nach Seite ein grauer Halbsatz („Trash is empty") oder gar
|
||||
* nichts. Beides lässt offen, ob die Liste leer ist oder das Laden
|
||||
* fehlgeschlagen – und beides verschenkt die Stelle, an der jemand am
|
||||
* ehesten Hilfe gebrauchen kann: Er steht vor etwas Leerem und weiß nicht,
|
||||
* wie es sich füllt.
|
||||
*
|
||||
* Deshalb drei Teile, und der dritte ist der wichtige:
|
||||
*
|
||||
* 1. ein Zeichen, damit die Fläche nicht kaputt aussieht
|
||||
* 2. ein Satz, der sagt, **warum** hier nichts ist
|
||||
* 3. der Knopf, der genau hier weiterhilft – wo es einen gibt
|
||||
*
|
||||
* Wo es keinen sinnvollen Knopf gibt (ein leerer Papierkorb ist ein guter
|
||||
* Zustand, kein Mangel), bleibt es bei zweien. Ein Knopf, der nur da ist,
|
||||
* damit einer da ist, führt nirgendwohin.
|
||||
*/
|
||||
export default function EmptyState({ icon, title, hint, action }: {
|
||||
/** Ein Emoji oder ein SVG. Gedämpft – es soll die Fläche tragen, nicht sie beherrschen. */
|
||||
icon: ReactNode
|
||||
title: string
|
||||
hint?: ReactNode
|
||||
action?: { label: string; onClick: () => void }
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', textAlign: 'center',
|
||||
padding: '72px 24px', gap: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 48, opacity: 0.28, lineHeight: 1 }}>{icon}</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{hint && (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 380 }}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: '8px 16px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: 'var(--color-primary)', color: 'var(--color-primary-fg)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { formatBytes } from '../format'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
import TagChip from './TagChip'
|
||||
|
||||
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user