Compare commits

..
67 Commits
Author SHA1 Message Date
marcandClaude Opus 5 f064eb62c7 fix(drawing): zwei stille Wege in den Datenverlust
Feinschliff vor dem Ausrollen, beim Durchlesen mit fremdem Blick gefunden und
beide nachgestellt.

Ein Fehler beim Laden ergab ein leeres Zeichenbrett. fetch wirft bei 404 oder
403 nicht. Der Rumpf {"error":"file not found"} ist gültiges JSON, JSON.parse
gelang, elements war undefined – und die Zeichnung ging leer auf. Sie sah aus,
als wäre sie verloren. Wer dann speichert, macht es wahr. Gegen die laufende
Instanz nachgestellt, bevor ich es behoben habe.

Weiterzeichnen während des Speicherns ging verloren. Der Upload lief mit dem
Stand von Sekunde 0; die Fassungsnummer wurde hinterher aus aktuell.current
gelesen – und das war inzwischen ein anderer Stand. Die neuen Striche galten
als gespeichert, ohne je hochgegangen zu sein, und verschwanden beim Schliessen
ohne Rückfrage. Jetzt wird der Stand einmal festgehalten: Er geht hoch, seine
Nummer wird vermerkt, das Vorschaubild zeigt denselben.

Das Laden steht dafür jetzt in ladeZeichnung() statt im Effekt – der Weg hat
drei Fallen, und zwei enden in verlorener Arbeit; das gehört prüfbar. Vier neue
Tests, unter Mutation nachgewiesen: Ohne die Statusprüfung werden zwei rot.

Bewusst so gelassen: Leserecht fällt erst beim Speichern auf. FileItem trägt
kein Rechtefeld, der Text-Editor verhält sich seit jeher genauso, und das
Zeichenbrett bleibt offen – die Arbeit ist also nicht weg.

Geprüft: 57 Vitest-Tests, alle acht Selbsttest-Suiten grün, alle Go-Pakete
grün, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:30:22 +02:00
marcandClaude Opus 5 cedcb1ba6f fix(drawing): Rückfrage nach dem Speichern, Vorschaubild in der Liste, eigener Dialog
Drei Punkte aus Marcs Test.

„Close without saving?" kam, obwohl gerade gespeichert war. Der Schalter
„geändert" wurde in onChange gesetzt – und das feuert bei jeder Kleinigkeit,
auch bei Mauszeiger, Auswahl und Bildlauf. Sekunden nach dem Speichern stand er
wieder auf offen. Jetzt zählt getSceneVersion den Inhalt statt der Bedienung.

Das Vorschaubild kam nicht in der Liste an. Der Editor lädt es nach dem
Speichern hoch, die Liste kannte aber nur die Antwort des Uploads – und die
entstand, bevor es das Bild gab. Ohne Neuladen blieb die Kachel leer. Sie wird
jetzt ein zweites Mal benachrichtigt.

Rückfragen laufen über den ConfirmDialog des Projekts statt über
window.confirm. Beim Schliessen über die Zurück-Geste gibt der Griff false
zurück, 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.

Nebenbei: Die Beschriftung des Schliessen-Knopfes versprach „Esc". Den Griff
gab es nie – und er wäre falsch, weil das Zeichenbrett die Taste selbst
braucht (Abwählen, Werkzeug verlassen). Wer sie abfängt, schliesst die
Zeichnung, während jemand nur ein Rechteck abwählen wollte. Versprechen
entfernt statt Griff gebaut.

Geprüft: tsc sauber, 53 Vitest-Tests, Zeichnungs-Selbsttest 18/18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 21:23:07 +02:00
marcandClaude Opus 5 dd68bc594d feat(drawing): Vorschaubilder und ein Zeichenbrett, das zur Oberfläche passt
Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei
einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie
gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet.

Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern
ein PNG hinterher.

Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch
denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht
auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der
Liste aussieht.

Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb stumm.

Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche
war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem
Namen, Strg+S, Dateisymbol und Name ohne Endung.

„Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte
den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine
zweite Fassung in den Download-Ordner, die niemand wiederfindet.

Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400,
fremder Typ → 415).

Geprüft: go build/vet sauber, alle Go-Pakete grün, 261 Prüfungen der acht
Selbsttest-Suiten grün, 53 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:13:04 +02:00
marcandClaude Opus 5 2e1eaf9cbf fix(drawing): Zeichnung liess sich nicht wieder oeffnen
Anlegen und bearbeiten ging, beim zweiten Öffnen blieb das Zeichenbrett leer.

Die erste Fassung schrieb den appState roh mit JSON.stringify heraus. Darin
steckt `collaborators`, und das ist eine Map. JSON kennt keine Maps; es wurde
`{}` daraus. Beim Laden bekam Excalidraw ein leeres Objekt, wo es eine Map
erwartet, und brach ab. Beim ersten Öffnen fiel das nicht auf – da war noch
nichts gespeichert.

Jetzt über die Werkzeuge des Pakets: serializeAsJSON schreibt nur, was eine
Zeichnung ausmacht, restore setzt beim Laden zusammen, was fehlt. Selbst zu
prüfen hiesse, Excalidraws Feldliste nachzubauen und bei jeder Fassung
nachzuziehen.

Eine Sache erledigt restore allerdings nicht – nachgemessen, nicht angenommen:
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,
aber die aus jener Stunde wären dauerhaft kaputt geblieben. Deshalb wird es
beim Laden zusätzlich entfernt – Marcs bereits angelegte Zeichnungen öffnen
damit wieder.

Fünf Tests in drawing.test.ts, darunter einer, der festhält, dass restore
allein nicht reicht. Zwei Stolpersteine dabei, in vite.config.ts vermerkt:
Excalidraw importiert roughjs/bin/rough ohne Endung (Nodes Auflösung verlangt
sie, der Bündler nicht) und fasst beim Laden ein Canvas an, das jsdom nicht
hat.

Geprüft: tsc sauber, 53 Vitest-Tests, Zeichnungs-Selbsttest 12/12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 19:55:46 +02:00
marcandClaude Opus 5 6fc157bc07 feat(files): Zeichnen und Mindmaps mit Excalidraw
Eine Zeichnung ist eine Datei, kein neues Ding – das ist die ganze Entscheidung
dahinter. .excalidraw ist JSON; damit erbt sie Ordner, Gruppenordner,
Papierkorb, Versionen, Freigabelinks, interne Freigaben und die Suche. Keine
neue Tabelle, kein zweites Rechtemodell. Gespeichert wird über denselben Upload
wie alles andere, wie beim Text-Editor in der Vorschau.

Drei Sorgen, alle nachgemessen statt gehofft:

Das Hauptbündel wächst von 484,11 auf 485,22 kB – das Zeichenbrett ist ein
eigenes Stück (584 kB + 142 kB CSS) und wird nachgeladen wie der PDF-Betrachter.
Wer nie zeichnet, holt es nie.

Die Schriften: Die Richtlinie ist default-src 'self' ohne font-src, Excalidraw
hätte seine handgezeichneten Schriften von aussen geladen und still eine
Ersatzschrift gezeigt. Sie werden jetzt beim Bauen kopiert und über
window.EXCALIDRAW_ASSET_PATH gefunden – gesetzt im Modul, nicht per
Inline-Skript, das wäre von derselben Richtlinie blockiert. Xiaolai bleibt
draussen: 13 der 14 MB für die chinesische Schrift.

Die Reihenfolge der Dateiarten: .excalidraw wird vor code und text geprüft,
sonst öffnete eine Zeichnung im Texteditor als Zahlenkolonne (json steht in
codeExt).

Kein gleichzeitiges Arbeiten. Die Komponente könnte es, aber den Transport
bringt sie nicht mit – das wäre ein zweiter Dienst neben Aether. Solange es ihn
nicht gibt, überschreibt der Zweite den Ersten. Zwei Dinge fangen das ab: Vor
dem Speichern wird geprüft, ob sich die Fassung geändert hat, und dann gefragt
statt überschrieben. Und Zeichnungen behalten ihre Fassungen von der ersten
Minute an.

Der zweite Punkt ist erst durch den Selbsttest aufgefallen: Versionierung ist
sonst bewusst aus (Migration 000006), damit der Speicher nicht vollläuft. Für
Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder
bearbeitet und sind ein paar Kilobyte gross. Ohne sie wäre die Warnung im
Editor ein Versprechen ohne Deckung gewesen.

Neuer Selbsttest mit 12 Prüfungen: Er prüft die These nach, auf der alles
beruht – wenn eine Zeichnung wirklich eine Datei ist, muss sie können, was
Dateien können.

Nebenbei: db-tests.sh liess je Lauf ein anonymes Postgres-Volume liegen (rund
49 MB). `docker rm -v` nimmt es jetzt mit; zehn verwaiste entfernt.

Geprüft: go build/vet sauber, alle 15 Go-Pakete grün, 255 Prüfungen der acht
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:17:05 +02:00
marcandClaude Opus 5 662c0ebdc1 security(rescan): Notbremse vor dem harten Löschen
internal/rescan 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, oder eines
Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger
Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die
Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC
räumte sie 24 Stunden später ab.

Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab,
nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine
Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank
weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere
Seite war die ungesicherte.

Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt
sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt
&bestaetigt=true. Die leere Installation ist ausgenommen.

Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde
mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die
Hälfte schon weg ist.

Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll
keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in
der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass
mit dem Datenordner etwas nicht stimmt.

Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät:
fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply
stehen alle fünf noch, auch mit Bestätigung.

audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in
BEFUNDE.md statt als ewig offener Punkt.

Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein
App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20
erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt
war.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 15:26:17 +02:00
marcandClaude Opus 5 6fab2388ca security(sharing): Freigabelink prüft die Prüfsumme, Gruppenordner teilbar
Beim Lesen von internal/sharing fiel eine ungleiche Zusage auf: Der
angemeldete Download prüft seit dem 12.09. die SHA-256-Summe beim Ausliefern
und lässt die Verbindung fallen, wenn sie nicht stimmt. Der öffentliche
Freigabelink lief durch ein blankes io.Copy – ungeprüft.

Ausgerechnet der Weg nach draussen. Wer die Datei angemeldet holt, ist der
Eigentümer und merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es
nicht. In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload
berechnen, beim Download verifizieren. Die Regel galt an einer von zwei
Stellen. Die Mechanik liegt jetzt in files/verified.go und wird von beiden
gerufen. Nachgewiesen mit gekipptem Bit auf der Platte: Über den Freigabelink
kommt die Datei ebenfalls nicht mehr vollständig an.

Gruppenordner: Create übergab user.ID statt des aufgelösten Prinzipals – eine
Datei im Gruppenordner liess sich nicht verlinken, auch nicht vom Mitglied.
Marcs Entscheidung: Das soll gehen, der Empfänger sieht ohnehin nur die Datei,
nie den Ordner darum. Verlangt wird Schreibrecht: Einen Link zu erzeugen
heisst, die Datei öffentlich zu machen; wer sie nur geliehen lesen darf, soll
sie nicht weiterreichen können (403).

expires_in_hours hatte keine Obergrenze. time.Duration rechnet in
Nanosekunden; ab etwa 2,6 Mio Stunden läuft int64 über, die Dauer wird negativ,
und der Link ist abgelaufen in dem Moment, in dem er entsteht – ohne
Fehlermeldung. Jetzt zehn Jahre, darüber 400.

Dazu X-Content-Type-Options: nosniff, das der angemeldete Weg längst setzt.

Sechs Go-Tests für internal/sharing, unter Mutation nachgewiesen:
Eigentümerprüfung entfernt, Ablaufprüfung entfernt und Token verkürzt machen
jeweils genau den zugehörigen Test rot.

Selbsttests erweitert: sharing 24 → 31 Prüfungen, pruefsumme 14 → 16.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 241 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:57:10 +02:00
marcandClaude Opus 5 a7528d7365 test(frontend): Wächter gegen deutschen Oberflächentext – und fünf Reste
Konsolidierung: Bestandsaufnahme auf den Stand vom 24.09. gebracht (Punkte 1,
2, 2b und die Tests für documents/tags sind erledigt; sharing, rescan, audit
und thumbnails bleiben offen; jsdom ist seit dem 18.09. drin).

Beim Nachsehen fielen fünf Übersetzungsreste auf: label="Ganztägig",
aria-label="Kalendermenü", label="Bearbeiten" und zweimal "Löschen". Alle in
**doppelt** gequoteten JSX-Attributen – die Ersetzungsliste hatte nur die
einfach gequoteten, und das damalige Prüfskript hielt Einzelwörter ohne
Leerzeichen für Bezeichner.

Die Prüfung steht deshalb jetzt als Test, nicht als Skript. Sie läuft bei
jedem npm test mit.

Der erste Wächter taugte nichts, und das kam nur durch die Mutation heraus: Er
prüfte allein auf Umlaute und liess "Papierkorb ist leer" durch. Wer einen
Wächter baut, muss ihn einmal angreifen. Jetzt Umlaute plus eine Wortliste mit
Wortgrenzen, nachgewiesen an vier Mutationen.

Kommentare werden vorher entfernt – mit einem kleinen Durchlauf statt
zeilenweise: Blockkommentare haben Fortsetzungszeilen ohne führenden Stern,
die beim ersten Versuch fünf Fehlalarme erzeugten.

Ehrlich dazu: Das ist ein Netz, kein Beweis. Ein deutscher Satz ohne Umlaut
und ohne gelistetes Wort käme durch. Steht so im Test.

@types/node kam dazu, weil der Test den Quelltext von der Platte liest.

Geprüft: tsc sauber, 48 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün. Gebautes Bündel gegengeprüft – deutsche Texte weg,
englische da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 18:31:18 +02:00
marcandClaude Opus 5 e1259dd55c refactor(calendar): Wochentage und Monate aus Intl statt aus sechs Arrays
Sechs handgeschriebene Arrays in drei Dateien, und Intl kam im ganzen Projekt
nicht vor – obwohl jeder Browser es seit Jahren mitbringt.

Die Doppelpflege war nicht nur lästig, sie war eine Falle: DayDialog begann
seine Woche am Sonntag, die anderen am Montag, jeweils passend zu ihrer
Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays in eine
neue Ansicht kopiert hätte, hätte sich einen Off-by-one gebaut, der nur an
sieben Tagen im Jahr auffällt.

Jetzt eine Ordnung (Montag zuerst, wie weekdayIndex) plus eine ausdrücklich
benannte zweite Liste für alles, was direkt mit getDay() indiziert – statt
eines Umrechnens am Aufrufort, wo der Off-by-one sonst entsteht.

Verhaltensgleich belegt: Die erzeugten Namen stimmen zeichengenau mit den
gelöschten Arrays überein, vor dem Austausch verglichen.

Die Sprache ist fest auf 'en' gesetzt, nicht die des Browsers.
Intl.DateTimeFormat(undefined, …) nähme die Einstellung des Rechners und
setzte auf einem deutschen System „Montag" neben ein englisches „Repeat" –
genau der Mischmasch von vorgestern, nur durch die Hintertür. Kommt eine
Sprachwahl (IDEAS.md), ist uiLocale die eine Stelle, an der sie andockt.

Fünf neue Tests, unter Mutation nachgewiesen: Nimmt man dem Sonntag seinen
Platz vorn, wird der Test rot.

Geprüft: tsc sauber, 46 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:04:33 +02:00
marcandClaude Opus 5 8586664fae feat(ui): Rückmeldung zu Ende verdrahtet – und zwei Lügen entfernt
Die Meldungen sprachen bisher nur im Dateibrowser. Papierkorb, Freigabe-Links,
„Für mich freigegeben", Profil, Theming, Audit und die Nutzerliste schwiegen
weiter – und das war schlechter als vorher: Wer gelernt hat, dass Aether
Bescheid gibt, hält Schweigen für Erfolg.

Nicht blind verdrahtet. Sechs Stellen bleiben absichtlich still, samt Grund in
TESTING.md: die Suche (bricht bei jedem Anschlag die vorige ab), das Kopieren
der Prüfsumme (ohne HTTPS gibt es keine Zwischenablage, der Wert steht ja da),
die Theming-Vorgaben, die Absender-Vorschläge, die OCR-Abfrage im Hintergrund
und useAuth. Wo ein Dialog seinen Fehler bei sich anzeigt, bleibt es dabei –
dort gehört er hin, nicht an den Bildschirmrand.

Dabei zwei Fälle gefunden, die schlimmer sind als Schweigen:

**ProfilePage meldete „gespeichert", wenn das Speichern fehlgeschlagen war.**
`updateProfile(...).catch(() => {})`, danach ungeprüft das Häkchen.

**TagEditDialog riet die Ursache:** Jeder Fehler meldete „That name is already
taken" – auch ein Netzausfall, auch ein 500er. Jetzt steht dort der Satz des
Servers. Im TagDialog war genau das schon einmal aufgefallen und behoben
worden; hier stand es noch.

Dazu zwei Aufrufe ganz ohne Absicherung: das Wiederherstellen aus dem
Papierkorb und das Löschen eines Freigabe-Links. Und das Kopieren eines
Links scheiterte ohne HTTPS stumm – der Knopf tat schlicht nichts.

Eigener Fehler nebenbei behoben: Ich hatte deutsche Anführungszeichen in
englische Meldungen gesetzt.

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:59:46 +02:00
marcandClaude Opus 5 16258640ad docs(ideas): Mehrsprachigkeit notiert, samt der 144 Server-Meldungen
Gemessen statt geschätzt: ~986 Zeichenketten im Frontend, 144 Fehlermeldungen
im Backend. Der Punkt, den man dabei vergisst, ist der zweite Teil: Die
Server-Meldungen landen seit dieser Woche in den Toasts. Wer nur das Frontend
übersetzt, bekommt eine deutsche Oberfläche, die bei jedem Fehler auf Englisch
umschaltet.

Dazu der Intl-Teil, der sich auch ohne jede Übersetzung lohnt: sechs
handgeschriebene Wochentag- und Monats-Arrays, und Intl wird nirgends benutzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:23:31 +02:00
marcandClaude Opus 5 7005b2ce01 docs(testing): Prüfpunkte gegen die Platzhalter-Geister
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:16:09 +02:00
marcandClaude Opus 5 4208676aa9 fix(ui): Platzhalter blitzt auch beim Tab-Wechsel nicht mehr auf
Die Frist von 220 ms galt nur beim Wechsel *innerhalb* der Dateiansicht. Beim
Wechsel zwischen den Menüpunkten entsteht die Seite jedes Mal neu – damit war
jeder Tab-Wechsel ein „erstes Laden", und genau dort war die Regel „beim ersten
Mal sofort zeigen" falsch herum. Die Frist gilt jetzt immer; 220 ms Leere ist
das ruhigere Bild, man kommt ohnehin von einer anderen Seite.

Als gemeinsamer Haken `useSkeleton`, statt die Logik in fünf Seiten zu
wiederholen.

Dabei wäre beinahe ein schlimmerer Geist entstanden: Im Wartefenster hat kurz
der **Leerzustand** aufgeblitzt – „Nothing shared with you yet", eine
Zehntelsekunde bevor die Liste kam. Der Platzhalter sieht nur nach Daten aus,
ein Satz behauptet etwas. Das Wartefenster bleibt jetzt überall leer.

Dazu drei Reste der Kalender-Übersetzung, die mein Suchmuster übersehen hatte,
weil sie in Template-Strings standen: der Löschen-Dialog des Kalenders und der
Wochentag-Hinweis im Terminfenster. Und das Datum in der Listenansicht stand
noch im deutschen Format („18. September 2026") in einer englischen
Oberfläche – jetzt „September 18, 2026".

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:15:45 +02:00
marcandClaude Opus 5 0591a40d12 feat(calendar): Oberfläche auf Englisch, Standardkalender heißt „Personal"
Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch –
80 Stellen in sechs Dateien. Kommentare bleiben deutsch; das Ersetzen hat
Kommentarzeilen übersprungen.

Shell, TagChip und UserMenu standen nur wegen ihrer Kommentare auf der
Verdachtsliste, deren Oberfläche war längst englisch.

Der automatisch angelegte Kalender hieß „Persönlich" und heißt jetzt
„Personal". Migration 000027 zieht die bestehenden nach, bewusst eng gefasst:
nur Zeilen mit Name *und* Farbe des Automaten. Wer sich selbst einen Kalender
„Persönlich" angelegt hat, behält seinen Namen – das ist seine Beschriftung,
nicht unsere. Nachgeprüft: Die API meldet danach 'Personal', nicht nur
„Migration gelaufen".

Dazu zwei Punkte aus Marcs Rückmeldung:

fix(ui): Der Leerzustand der Freigabe-Links stand auf anderer Höhe als die
übrigen – die Seite hatte ich beim Umbau übersehen. Nutzt jetzt EmptyState
wie alle anderen.

fix(ui): Beim Durchklicken von Ordner zu Ordner blitzte für einen Moment ein
Feld grauer Kästen auf. Sie sahen aus wie Dateien, die gleich wieder
verschwinden – „Geister". Der Platzhalter kommt jetzt erst nach 220 ms; ist
die Liste vorher da, wird er nie sichtbar, und solange bleibt die vorherige
stehen (load() löscht sie nicht). Nur beim allerersten Laden kommt er sofort:
Da gibt es nichts, was stehenbleiben könnte.

Nebenbei: Ein fehlgeschlagenes Laden eines Ordners ist nicht mehr stumm.
Vorher sah es aus wie ein Ordner, der zufällig denselben Inhalt hat wie der
vorige.

Geprüft: go build/vet sauber, tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen
der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:38:30 +02:00
marcandClaude Opus 5 1d10c6bd0e feat(ui): Platzhalter beim Laden und ordentliche Leerzustände
Beim Laden stand ein Kreisel in der Mitte. Der sagt „warte", aber nicht,
worauf – und wenn die Daten kommen, klappt das Layout um. Jetzt steht dort ein
Platzhalter in der Form des Kommenden: Kacheln in der Kachelansicht, Zeilen in
der Liste. Die Maße sind aus FileViews.tsx abgeschrieben, nicht geschätzt;
weichen sie ab, springt es beim Wechsel wieder und der Zweck wäre dahin.

Leerzustände sagen jetzt, warum nichts da ist, und bieten den Knopf an, der an
dieser Stelle weiterhilft. Der leere Papierkorb bekommt bewusst keinen: Er ist
ein guter Zustand, kein Mangel, und ein Knopf, der nur da ist damit einer da
ist, führt nirgendwohin.

Beides respektiert prefers-reduced-motion.

Nebenbei gefunden: `@keyframes spin` lag als Inline-<style> in **sechs**
Komponenten, und zwei Nutzer (AuditTab, SearchBox) hatten gar keine eigene
Kopie. Dass es lief, war Zufall – @keyframes gilt fürs ganze Dokument, es
genügte also, dass irgendeine der sechs gerade im Bild war. Kaputt wäre es an
dem Tag gewesen, an dem jemand die letzte Kopie entfernt. Steht jetzt einmal
in index.css.

Nicht angefasst: der Sprachmix. CalendarPage ist durchgehend deutsch, die
übrige Oberfläche englisch, betroffen sind sechs Dateien. Eine halbe
Übersetzung wäre schlimmer als keine – das gehört in einem Zug gemacht, samt
der Entscheidung, welche Sprache die Oberfläche spricht. In TESTING.md notiert.

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:33:25 +02:00
marcandClaude Opus 5 94a803bf7a feat(ui): Meldungen mit Rückgängig, dazu jsdom und ein Fehler in der letzten Behebung
Vorher bestätigte nichts irgendetwas, und Fehler verschwanden still: Die
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
klappte – nichts.

Neu: components/Toasts.tsx. Kein Context, sondern Modulzustand, damit auch
api/client.ts melden kann, ohne Komponente zu sein. Löschen, Umbenennen und
Verschieben bekommen ein „Undo" – beim Löschen ist das der Papierkorb plus
einen Aufruf, kostet also fast nichts. Ordner löschen bekommt bewusst keines:
Der Ordner ist wirklich weg, nur seine Dateien liegen im Papierkorb, und ein
Knopf, der etwas anderes tut als er verspricht, wäre schlimmer als keiner.

Dazu die erste Animation im Projekt – und gleich die Regel dazu:
prefers-reduced-motion wird respektiert.

**jsdom ist jetzt in den devDependencies**, und der erste DOM-Test hat sofort
einen Fehler in der Behebung von vorgestern gefunden:

Der Zähler für selbst ausgelöste Verlaufsschritte setzt voraus, dass jedes
`back()` auch ein `popstate` nach sich zieht. Das gilt nicht – folgt im selben
Durchlauf ein `pushState`, verwirft der Browser den Sprung. Der Zähler blieb
dann stehen, und die nächste echte Zurück-Geste wurde verschluckt: Statt die
Ansicht zu schließen, hätte man die Anwendung verlassen.

Jetzt wird erst im nächsten Durchlauf entschieden und geprüft, ob der eigene
Eintrag noch obenauf liegt. Nur dann kommt es zum Sprung – und dann folgt das
`popstate` garantiert. Der Zuhörer hängt am Modul statt an der Ansicht; vorher
entfernte ihn deren Aufräumer, bevor der eigene Sprung ausgeführt war.

Ehrlich dokumentiert: Den ursprünglichen Fehler bilden diese Tests **nicht**
ab. jsdom verwirft den Sprung, das verhängnisvolle popstate entsteht dort nie
– mit der alten, kaputten Fassung blieben alle Tests grün. Dafür bräuchte es
einen echten Browser. Steht so in TESTING.md und im Test selbst.

Geprüft: tsc sauber, 41 Vitest-Tests (4 neue unter Mutation nachgewiesen),
alle 234 Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:05:12 +02:00
marcandClaude Opus 5 7d45941c03 refactor(office): toten JWT-Schlüssel entfernen, Doku richtigstellen
Beim Löschen von convert.go ist Aethers einzige Verwendung des JWT-Schlüssels
mitgegangen: signJWT stand dort. Zurück blieb ein Feld auf dem Client, das
niemand liest, plus Config-Eintrag und Aufrufparameter – Go meldet ungenutzte
Strukturfelder nicht, deshalb lief der Build durch. Eine Einstellung, die
nichts mehr tut, ist schlimmer als keine: Wer sie setzt, glaubt, sie wirke.

OFFICE_JWT_SECRET bleibt in der .env, wird aber vom **Office-Container**
gelesen, nicht von Aether. Geprüft mit Euro-Office: JWT_ENABLED=true und
Aether ohne jedes Signieren – der WOPI-Weg läuft.

Doku richtiggestellt, nachdem Marc den Compose-Schnipsel angezweifelt hat:

- „Euro-Office ist hier nie gelaufen" stimmte nicht mehr. Jetzt steht da, was
  tatsächlich geprüft ist (Abbild, Start, Discovery, CheckFileInfo, Ausliefern,
  mobileEdit) und was nicht: PutFile, also das Zurückschreiben.
- Der Absatz zu /ConvertService.ashx beschrieb gelöschten Code.
- OFFICE_PUBLIC_URL: die Falle benannt, die einen Tag gekostet hat. Stand dort
  localhost, lief alles am Docker-Rechner und blieb am Telefon schwarz – dort
  ist localhost das Telefon. Ohne Fehlermeldung.
- Zur Portbindung: Die Office-Schnipsel binden auf 0.0.0.0, anders als Aether
  selbst. Das ist nötig, weil der Browser direkt hin muss – steht jetzt dabei,
  samt dem sauberen Weg über einen Reverse Proxy.

Geprüft: go build/vet sauber, alle 234 Prüfungen der sieben Selbsttest-Suiten
grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:10:17 +02:00
marcandClaude Opus 5 944b2c63e2 feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices
Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war
falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und
OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe
Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche.
`EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus.

Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271)
samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC,
die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen
anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für
diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien
unter previews/ sammelt der GC ein.

Zwei Fehler, die dabei sichtbar wurden:

fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim
Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose
ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte,
den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste
Schritte verschluckt genau diese popstate.

fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und
lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am
Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst`
standen nie drin; das war die Asymmetrie.

Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die
Ankündigung nennt 5) und die Android-App als Idee.

Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234
Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:36:31 +02:00
marcandClaude Opus 5 6042f6f2f6 test(tags): bleibt ein fremdes Etikett fremd?
internal/tags hatte null Tests, und die Rechtepruefung steckt dort nicht in
einer if-Zeile, sondern in WHERE-Klauseln. Ein Tippfehler darin faellt beim
Lesen nicht auf: Die Abfrage laeuft, sie liefert nur zu viel.

Fuenf Tests auf dieselbe Frage -- Liste, Umbenennen, Loeschen, Anhaengen an
die eigene Datei, und vor allem das Auflisten der **Dateien** unter einem
Etikett. Der letzte Weg gibt Dateinamen, Ordner und Groessen heraus; wer
dort eine fremde Etikett-ID erraet, saehe einen Ausschnitt aus fremden
Unterlagen. Dazu Gruppen: Mitglied ja, Nichtmitglied nein, und wer die
Gruppe verlaesst, verliert den Zugriff im selben Moment.

Gegen kaputten Code gegengeprueft: Eigentuemerpruefung aus FilesWithTag
entfernt -> "Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf". Pruefung
in SetFieldsForFile ausgehebelt -> zwei Tests rot.

**Ein Verdacht erwies sich als falsch, und das ist der nuetzlichere
Ausgang:** List bietet Gruppen-Etiketten an, SetFieldsForFile vergleicht
gegen den Prinzipal der Datei -- sah nach einem Widerspruch aus. Ist keiner;
die Oberflaeche filtert bereits (TagDialog.tsx:69). Und die Ablehnung ist
nicht Foermlichkeit, sondern die tragende Absicherung: FilesWithTag prueft,
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. Der Test haelt jetzt diesen Zusammenhang fest, nicht bloss eine
Fehlermeldung.

Nebenbei: tags hat zwei Fehlermarken fuer dieselbe Aussage (ErrNotFound und
ErrCorrespondentNotFound). Kein Fehler, aber eine Stolperstelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:04:04 +02:00
marcandClaude Opus 5 76176a45bc test(documents): die drei stillen Zustaende der OCR-Warteschlange
internal/documents hatte null Tests -- und ist das Paket, dessen Versagen am
teuersten ist, weil es **still** ist: Die Volltextsuche findet dann einfach
nichts, und das sieht aus wie "steht nicht drin".

Acht Tests, nicht auf Abdeckung gezielt, sondern auf die Zustaende, in denen
die Schlange haengenbleiben kann, ohne dass es auffaellt: ein Auftrag, der
nie aufgibt; ein Auftrag, der nach hartem Abbruch fuer immer auf 'running'
steht; ein Text nach alter Regel, der nie erneuert wird. Dazu geloeschte
Dateien, erneuter Upload und Complete als Einheit.

scripts/db-tests.sh baut eine Wegwerf-Datenbank, spielt die Migrationen ein
und laesst die Tests laufen. Ohne das haetten sie sich mangels TEST_DSN
immer nur uebersprungen -- Dekoration, genau wie vitest es war.

**Gegengeprueft an absichtlich kaputtem Code, und der zweite Versuch blieb
gruen:** maxAttempts von 3 auf 9999 gesetzt -- alle Tests weiter in Ordnung,
nur die Laufzeit stieg von 0,2 auf 27 Sekunden. Sie schleiften ueber die
Konstante und setzten die Zahl voraus, statt sie zu pruefen. So verschwindet
eine Entscheidung unbemerkt aus einer Anwendung. Jetzt haelt ein eigener
Test die Zahl fest und sagt, wo die Begruendung hingehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:32:28 +02:00
marcandClaude Opus 5 2cc02941b9 fix(files): beschaedigte Downloads werden gemeldet und abgebrochen
Die Pruefsumme wurde beim Ausliefern mitgerechnet und das Ergebnis
weggeworfen -- ein gekipptes Bit wurde erkannt und verschwiegen.

Jetzt: Logeintrag mit Datei-ID, Speicherschluessel, erwarteter und
gelesener Summe, dazu panic(http.ErrAbortHandler), den chis Recoverer
ausdruecklich durchreicht.

Damit das wirkt, werden die letzten 32 KiB zurueckgehalten. Sonst waere die
Datei beim Erkennen laengst vollstaendig beim Empfaenger und der Abbruch
kaeme zu spaet. Erst wenn die Summe stimmt, darf der Schluss hinterher;
sonst bekommt die Gegenstelle weniger Bytes als angekuendigt und wertet das
als Fehler statt als Datei.

scripts/pruefsumme-selbsttest.py, 14 Pruefungen: kippt ein Bit auf der
Platte (nicht im Programm), in der Mitte der Datei, damit es nicht schon an
der Laenge auffaellt; zwei Groessen ueber und unter den 32 KiB; und zuerst
die Gegenprobe, dass eine heile Datei durchkommt.

**Dabei gefunden:** Der neue Logeintrag blieb aus, obwohl der Abbruch
funktionierte. zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im
Kontext keiner steckt -- und Aether legte nie einen hinein. Alle 34
log.Ctx-Aufrufe im Baum schrieben ins Nichts, darunter die Warnung ueber
verwaiste Blobs beim Zurueckrollen einer Fassung.

Behoben zweifach: zerolog.DefaultContextLogger beim Start (deckt auch
Hintergrundarbeit ab) und ein Logger mit request_id im Anfragekontext. Zwei
Tests halten es fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:15:22 +02:00
marcandClaude Opus 5 c26bcba012 fix(bg): Hintergrundarbeit reisst den Prozess nicht mehr mit
Das Vorschaubild entstand in einer nackten Goroutine. Chis Recoverer haengt
am Anfragebehandler und sieht davon nichts -- eine Panik im Bilddekodierer
nahm den ganzen Go-Prozess mit, ausgeloest durch einen gewoehnlichen
Upload. Die Bibliothek ist seit ef60497 aktuell; das schloss **diese**
Luecke. Dies hier schliesst die **Gattung**.

pkg/bg mit zwei Funktionen, und der Unterschied ist keine Feinheit:

* bg.Go(name, fn)  -- Goroutine, die eine Panik ueberlebt
* bg.Run(name, fn) -- dasselbe ohne eigene Goroutine, fuer Schleifen

Faengt man eine Panik um den **ganzen** Arbeiter ab, ueberlebt 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.

Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zaehler,
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit
Begruendung im Code -- die Signalbehandlung; ein recover braechte dort
nichts, weil main ohnehin wartete.

Aufgefangene Paniken werden mit Stapel und Herkunft protokolliert. Vier
Tests, darunter der wichtige: Eine Schleife laeuft nach einer Panik im
dritten Durchgang weiter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 18:29:02 +02:00
marcandClaude Opus 5 89f0817dcb docs: Bestandsaufnahme 12.09.2026
Einmal durch die ganze Cloud, geordnet nach dem, was passieren kann.
Gemessen statt geschaetzt: Routen aus main.go extrahiert und gegen die
Rechtepruefungen abgeglichen, govulncheck ueber den ganzen Baum, Zeilen und
Testumfang je Paket gezaehlt.

Drei Befunde, ein Abschnitt darueber, was in Ordnung ist (eine Liste nur aus
Maengeln gibt ein falsches Bild), und eine vorgeschlagene Reihenfolge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:22:41 +02:00
marcandClaude Opus 5 ef604973c8 security(deps): drei erreichbare Luecken in x/image und x/text geschlossen
govulncheck meldete drei Luecken, die Aether nicht nur mitschleppt, sondern
auch **aufruft**:

* GO-2026-6222 -- uebermaessige Speicheranforderung beim VP8L-Dekodieren
  (x/image). Erreichbar ueber jeden Bild-Upload.
* GO-2026-5061 -- Panik bei unstimmiger Alphakanal-Groesse in x/image/webp.
  Erreichbar ueber denselben Weg.
* GO-2026-5970 -- Endlosschleife bei ungueltiger Eingabe (x/text), ueber
  pgxpool.New.

x/image 0.42.0 -> 0.45.0, x/text 0.38.0 -> 0.41.0 (0.41 verlangt x/image).
Danach meldet govulncheck: keine.

Alle sechs Selbsttest-Suiten gruen (36 86 24 20 13 41), go vet und go test
sauber.

Die zweite Haelfte des Problems bleibt offen und steht in BEFUNDE.md: Das
Vorschaubild wird in einer **nackten Goroutine** erzeugt, und dort faengt
chis Recoverer nichts. Eine Panik im Dekodierer nimmt den ganzen Prozess
mit -- unabhaengig davon, welche Luecke gerade offen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:21:47 +02:00
marcandClaude Opus 5 9f476c56cf docs(ideas): Aufgabenansicht -- der Weg dorthin heisst tasks.org
Praezisiert, warum sie vertagt ist und was der Anlass waere. Neu darin:

* Android hat keinen eingebauten Aufgabenspeicher -- fuer Termine gibt es
  einen Systemanbieter, fuer Aufgaben nicht. Die Task-App **ist** der
  Speicher, deshalb geht ohne sie gar nichts.
* Der Bereich "Erinnerungen" im Samsung Kalender ist ein Fenster in die
  Samsung-App, kein Teil des Kalenders. Am Geraet ablesbar: Beim Termin
  fragt Samsung nach dem Kalender, bei der Erinnerung nicht -- es gibt nur
  einen Ort, an den sie gehen kann.
* Was beim Bauen zu bedenken waere: Eine Aufgabe hat kein Pflichtdatum. Alle
  drei heutigen Ansichten ordnen nach Zeit; deshalb eine eigene Ansicht statt
  Einstreuen in den Monat.
* Dazu der Gegentest, falls die Einschaetzung falsch ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:14:08 +02:00
marcandClaude Opus 5 c8c4095299 docs: IDEAS.md angelegt; Abo-Link und Aufgabenansicht vertagt
Beide haben einen guten Grund, aber keinen Anlass -- und eine Funktion ohne
Anlass zu bauen ist der Weg, auf dem eine Anwendung unuebersichtlich wird.
Die Ueberlegungen stehen samt dem, was daran nicht trivial ist, in IDEAS.md
statt verloren zu gehen.

Beim Abo-Link sind das drei Dinge: nur lesend ohne Ausnahme; ein Schalter
"nur Belegt/Frei", weil ein Abo sonst jeden Termintitel mitgibt; und dass
ein Abo regelmaessig von einer Maschine abgerufen wird -- Zaehler und
Fehlversuchssperre der Datei-Freigabelinks passen darauf nicht.

Dazu ein Widerspruch, der beim Nachsehen auffiel: "Kalender in der
HoneyOS-Leiste" steht seit dem ersten Entwurf in Phase 4, aber HoneyOS hat
sich am 29.08.2026 ausdruecklich gegen Widgets entschieden, die Daten aus
Aether oder InkNote holen. Der technische Grund dafuer ist inzwischen weg
(gleiche Domain), der inhaltliche nicht. Das gehoert entschieden, nicht
gebaut -- deshalb im Plan vermerkt statt umgesetzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:50:37 +02:00
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00
marcandClaude Opus 5 8fdde182b7 feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578)
Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen,
Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete
damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn
Minuten, auf Akku und Datenvolumen des Telefons.

Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die
bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den
Stand, bei dem es zuletzt angefasst wurde (Migration 24).

Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe
ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden
war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein
Token darunter bekommt 403 <valid-sync-token/> statt einer Antwort mit
fehlenden Loeschungen.

go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und
weder sync-token noch supported-report-set. Beide gehen denselben Weg wie
getctag und calendar-color. Ohne supported-report-set probiert DAVx5
sync-collection gar nicht erst aus.

Zwei Fehler noch vor dem Ausliefern gefunden:

* Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung --
  xml.EscapeText machte daraus &#34;. Gueltig, aber genug Clients
  vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung.
* pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als
  Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig
  gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst.

caldav-selbsttest.py: 62 -> 86 Pruefungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 00:03:08 +02:00
marcandClaude Opus 5 df9d2d6ded docs(env): fuenf gelesene Schluessel fehlten in .env.example
OCR_ENABLED, OCR_LANGUAGES, OCR_MAX_PAGES, OCR_TIMEOUT und
OFFICE_INSECURE_TLS werden von config.go gelesen, standen aber in keiner
Vorlage. Alle haben Vorgaben, es bricht also nichts -- aber OCR_MAX_PAGES=50
deckelt die Texterkennung stillschweigend bei fuenfzig Seiten, und wer lange
Scans ablegt, kann das nicht wissen, wenn es nirgends steht.

Gegenprobe: kein von config.go gelesener Schluessel fehlt jetzt noch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:44:24 +02:00
marcandClaude Opus 5 70c963744c docs: Wiederherstellung ohne Postgres und Update geprobt, nicht behauptet
Beides auf einer eigens aufgesetzten Instanz durchgespielt:

* **Update einer laufenden Instanz**: Schema 19 -> 23 mit Daten in der
  Datenbank. Konten, Ordner, Dateien und Etikettenzuordnungen unveraendert,
  kein dirty-Zustand, /health 200. Ein Neuaufbau bringt nichts, was das
  Update nicht auch bringt.

* **Grosser Wiederherstellungsfall**: Container und Datenbank weggeworfen,
  nur die Dateien aus der Sicherung zurueck. Funktioniert -- aber nur, wenn
  die Konten vorher mit *demselben* Benutzernamen wieder angelegt werden.
  Der Ordnername auf der Platte ist die Zuordnung. Ohne diesen Schritt
  bleibt der Abgleich stehen und sagt es je Datei ("Konto ... gibt es
  nicht"); er raet nicht.

  Danach standen Ordnerkette, Unterordner, Umlaute, Groessen, Pruefsummen
  und Dateitypen wieder da, der Download lieferte den urspruenglichen
  Inhalt, .trash und .versions blieben aussen vor.

Dazu aufgeschrieben, was aus einer reinen Dateisicherung **nicht**
zurueckkommt: Etiketten, Absender, Belegdatum, Freigabelinks, fruehere
Fassungen, Kalender und Termine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:23:32 +02:00
marcandClaude Opus 5 e9f69f7d7f feat(uploads): Fortschritt in MB, Tempo und Restzeit; Abbruchkreuz rot
Das Kreuz faerbt sich beim Darueberfahren rot -- aber nur, solange der
Upload laeuft. Dasselbe Zeichen tut zwei Dinge: eine Meldung wegraeumen
ist folgenlos, einen laufenden Vorgang fallen zu lassen nicht.

Unter dem Balken steht jetzt "4.2 MB / 18.0 MB · 1.8 MB/s · 8 s left".
Die Zahlen kamen schon immer vom XMLHttpRequest, sie wurden nur
weggeworfen. Neu gerechnet wird nur das Tempo -- der Browser meldet
Bytes, nicht deren Ableitung.

Zwei Daempfungen, aus zwei Gruenden:

* Das Tempo aus einem einzelnen Abstand springt zwischen dem Doppelten
  und der Haelfte. Gleitender Mittelwert (0,7/0,3) ueber Messpunkte von
  mindestens 0,2 s.
* Die Anzeige wird hoechstens alle 150 ms nachgezogen. Der Browser meldet
  im 50-ms-Takt; bei drei Dateien waeren das sechzig Neuaufbauten der
  Liste je Sekunde fuer eine Zahl, die niemand so schnell liest.

Jeder Teil erscheint erst, wenn er stimmt: Groesse sofort, Tempo nach dem
zweiten Messpunkt, Restzeit erst mit beidem. Eine Schaetzung aus dem
Nichts waere schlimmer als keine -- man richtet sich danach.

formatDuration in src/format.ts, 5 Tests (37 gesamt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:46:28 +02:00
marcandClaude Opus 5 511213b40d feat(uploads): laufenden Upload abbrechen; fix(pdf): Zoomgeste entlasten
Zoom
----
Jede Zoomstufe warf die Leinwaende weg und liess PDF.js neu malen -- eine
Zusammenziehgeste hat leicht zwanzig davon, bei A4 und doppelter
Pixeldichte je rund acht Megabyte. Noetig war das nie: Die Leinwand liegt
mit width:100% im Seitenkasten, der Browser streckt sie also von selbst.
Gezeichnet wird jetzt 120 ms nach der letzten Stufe. Die Textebene wandert
korrekt mit (PDF.js setzt sie in Prozent), nur die Trefferkaesten sitzen in
Pixeln -- die werden waehrend der Geste geraeumt und danach neu gemalt.

Dabei ein aelterer Verlust: Jedes Oeffnen zeichnete einmal umsonst, weil
der Zeichner lief, bevor die Einpassung gemessen war. Jetzt wartet er.

Upload abbrechen
----------------
Das Kreuz an der Fortschrittsmeldung laesst einen laufenden Upload fallen;
eine noch wartende Datei faengt gar nicht erst an. Ein Abbruch ist kein
Fehler und verschwindet wie eine fertige Meldung.

Auf dem Server bleibt nichts liegen -- das ist die Bauweise (Temp-Datei,
Umbenennen erst am Ende), aber eine Bauweise ist keine Pruefung. Deshalb
scripts/upload-abbruch-selbsttest.py: kappt eine echte Verbindung auf einem
rohen Socket, 13 Pruefungen. Der teuerste Fall ist abgedeckt -- beim
Ueberschreiben mit Versionierung schiebt der Server die alte Fassung
kurzzeitig zur Seite; sie bleibt in Groesse, Pruefsumme und Inhalt heil.

Downloads haben bewusst kein Kreuz: Sie gehen als Anker bzw. Formular an
den Browser, dessen Download-Liste sie besitzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:38:26 +02:00
marcandClaude Opus 5 ea90f43e77 refactor(frontend): PdfViewer und FileBrowser aufteilen, Tests dazu
FileBrowser 1177 -> 712 Zeilen, PdfViewer 1193 -> 848. Herausgeloest
wurde, was fuer sich zusammenhaengt:

  hooks/useUploads            Warteschlange, Ordnerbaeume, Ablage
  components/files/           Werkzeugleiste, Dialoge, Menues, Reihenfolge
  components/pdf/             Werkzeugleiste, Zoomstufen, Seitenmasse, Suche
  src/format.ts               formatBytes -- lag in einer *Seite*

Dabei gefundene und behobene Fehler:

* Ordner-Upload haengte Fortschritt und Fehler an die falsche Zeile,
  sobald in einem Ordner Dateien *nach* einem Unterordner lagen: Die
  Zeilen standen in Fundreihenfolge, hochgeladen wurde nach Ordnern
  gruppiert, und ein mitlaufender Zaehler verband beides falsch.
* formatBytes gab es zweimal, zeichengleich (Dateibrowser + Admin).
* Beide Sortierungen liefen bei *jedem* Bild -- der Rahmen beim Ziehen
  ist eines je Mausbewegung. Jetzt gemerkt.
* Die Suche im PDF las bei jedem Tastendruck den Text aller Seiten neu.
  Jetzt 160 ms Wartezeit; "Searching..." bleibt auch bei einem Fehler
  nicht mehr stehen.
* Seitenmasse werden in Buendeln statt streng nacheinander geholt.

Pruefinfrastruktur: vitest stand auf 'jsdom', ohne dass jsdom je
installiert war -- "npm test" brach ab, bevor es etwas ausfuehrte. Jetzt
'node' und 32 Tests fuer die herausgeloesten reinen Funktionen. Das
Abbild baut nur noch mit gruenen Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:23:21 +02:00
marcandClaude Opus 5 8e27c703d2 fix(ui): eine Breite fuer Seitenleiste und Kopfleiste
Kopfleiste blendete den Menue-Knopf ab 720px aus, die Seitenleiste stand
aber erst ab 768px fest im Bild. Dazwischen lag ein Band von 48 Pixeln
ohne Menue und ohne Knopf dorthin -- ein aufgeklapptes Fold landet mit
rund 757px genau darin.

Die Grenze steht jetzt einmal in src/breakpoints.ts.

Dazu zwei Folgefehler derselben Sache:

* Der abdunkelnde Grund lag mit z-index 40 ueber der fest stehenden
  Leiste (die hat dort keinen). Per Randwisch aufgeschoben, war die
  Leiste danach sichtbar, aber taub. Er blendet sich ab 720px aus.
* Der Randwisch loest breit gar nicht mehr aus, und Aufklappen bei
  aufgeschobener Leiste setzt sie zurueck -- sonst schluckte die
  Zurueck-Geste einmal ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:33:08 +02:00
marcandClaude Opus 5 fbaf24bdbd fix(api): leere Listen als [] statt null -- Kalender blieb weiss
Ein Konto ohne Kalender bekam auf GET /api/v1/calendars die Antwort
`null`, und die Ansicht brach ab:

    for (const cal of calendars)   ->  "e is not iterable"

Nicht der Kalender ist schuld, sondern eine Eigenheit, die jeden
Listenendpunkt betrifft: In Go ist eine Liste ohne Eintraege oft nil,
und encoding/json schreibt dafuer null statt []. Jede Store-Funktion,
die mit `var out []T` anfaengt, liefert das bei leerem Ergebnis --
Favoriten, Papierkorb, Versionen, Freigaben, Etiketten. Der Kalender war
nur der, der daran zerbricht.

Deshalb die Regel dort, wo alle Antworten durchlaufen: response.JSON
macht aus einer nil-Liste eine leere. Einzelne Objekte und nil bleiben
unberuehrt. In den Stores waere es die falsche Stelle -- es gibt
Dutzende Listenendpunkte, und der naechste faengt wieder mit
`var out []T` an. Eine Regel, an die man sich erinnern muss, ist keine.

Dazu zwei Dinge, die derselbe Fall ans Licht brachte:

  - Der Web-Weg legte, anders als CalDAV, keinen Standardkalender an.
    Ein neues Konto stand vor einer leeren Ansicht und musste erraten,
    dass es selbst einen anlegen soll -- waehrend ein CalDAV-Client, der
    sich vorher verbunden hatte, bereits einen sah, den die Oberflaeche
    nicht zeigte.
  - Die Ansicht faengt eine unerwartete Antwort jetzt selbst ab (?? []).
    Der Server schickt kein null mehr; eine Seite sollte daran trotzdem
    nicht zerbrechen.

Drei Tests in pkg/response: leere Liste wird [], befuellte bleibt
unveraendert, Objekte und nil bleiben, was sie sind.

Zur Korrektur meiner frueheren Erklaerung: Die fehlenden Cache-Header
waren ein echtes Problem, aber nicht die Ursache dieses Fehlers. Der
gemeldete Bundle-Name stimmte diesmal mit dem ausgelieferten ueberein --
daran war zu sehen, dass es kein alter Stand sein konnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:56:54 +02:00
marcandClaude Opus 5 55dcf517f2 feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.

Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:

  Datei da, kein Eintrag    Eintrag anlegen, samt Ordnerkette,
                            Pruefsumme und Dateityp
  Eintrag da, Datei fort    Eintrag entfernen, dazu Vorschaubild und
                            fruehere Fassungen
  von aussen ersetzt        Groesse und Pruefsumme uebernehmen

Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.

Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.

Zwei Fallen, die erst der Praxistest zeigte:

  - Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
    schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
    niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
    /etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
    jede Textdatei waere als application/octet-stream gelandet.
  - COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
    Text und bricht mit "operator does not exist: uuid = text" ab. Die
    Ordnerkette wurde nie angelegt.

Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.

scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 01:10:25 +02:00
marcandClaude Opus 5 fce8053d09 fix(web): Updates kommen an, ohne den Cache von Hand zu leeren
Nach einem Update sah man die alte Anwendung -- und wer dann einen
Bereich anklickte, der erst beim Klick nachgeladen wird (Kalender,
PDF-Betrachter), bekam entweder gar nichts oder einen Fehler, der nach
einem Programmfehler aussieht ("e is not iterable"). Nachgemessen: Der
Browser lud index-BlqR-CVf.js, ausgeliefert wurde index-arO1lebV.js.

Ursache: Der Server schickte fuer index.html **gar kein**
Cache-Control. Fehlt es, schaetzt der Browser anhand von Last-Modified
selbst, wie lange die Antwort wohl gilt -- bei einer Datei, die seit
Tagen unveraendert war, sind das schnell Stunden. Er benutzte also
weiter die alte Seite, die auf Bausteine zeigt, die es nach dem Update
nicht mehr gibt. Auch die Fetches des Service Workers laufen durch
diesen Cache, weshalb selbst dessen "Netz zuerst" nicht half.

Jetzt die uebliche Zweiteilung, die sich aus den Dateinamen ergibt:

  /assets/…   public, max-age=31536000, immutable
              Der Inhalts-Hash steht im Namen. Anderer Inhalt = anderer
              Name, also darf der Browser sie fuer immer behalten.

  alles sonst no-cache
              index.html, sw.js, das Manifest. "no-cache" heisst nicht
              "nicht speichern", sondern "vor dem Benutzen rueckfragen" --
              mit ETag antwortet der Server meist mit 304 und ein paar
              Byte.

Dazu ein Sicherheitsnetz fuer den Fall, dass doch einmal ein alter
Baustein angefordert wird: vite:preloadError laedt die Seite einmal neu.
Einmal, per sessionStorage-Merkmal -- laege der Fehler woanders, drehte
sich die Seite sonst endlos im Kreis.

Das ist der Unterschied zwischen "ich leere eben den Cache" und einem
Menschen, der am Handy vor einer Seite sitzt, auf der nichts passiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:59 +02:00
marcandClaude Opus 5 ec8f162fd5 security(sharing): Selbsttest fuer Freigabelinks, drei Befunde behoben
Freigabelinks sind der einzige Teil von Aether, den man ohne Anmeldung
erreicht -- und hatten als einziger Bereich keinen einzigen Test. Der
neue scripts/sharing-selbsttest.py geht sie mit 24 Pruefungen ab, mit
einem eigens angelegten Zweitnutzer, ohne den sich "fremde Dateien
bleiben fremd" gar nicht pruefen laesst.

Beim ersten Lauf sofort drei Befunde:

1. Der Download-Zaehler blieb auf 0. Die Zaehlung lief in einer Goroutine
   mit dem Kontext der Anfrage -- der wird abgebrochen, sobald die
   Antwort draussen ist, und die Zaehlung verlor das Rennen fast immer.
   Jetzt context.WithoutCancel mit eigener Frist.

2. owner_id, file_id und die Link-Kennung standen in der oeffentlichen
   Antwort. Das ganze Datenbankobjekt durchzureichen war bequem, gab aber
   die Kennung des Besitzers an jeden heraus, der den Link hat: Zwei
   Links desselben Besitzers liessen sich einander zuordnen, ohne dass
   irgendwer angemeldet ist. Jetzt nur noch, was die oeffentliche Seite
   braucht.

3. Passwoerter liessen sich unbegrenzt durchprobieren. Der oeffentliche
   Freigabepfad lag ausserhalb jeder Ratenbegrenzung, und weil jeder
   Versuch Argon2 rechnet, war das zugleich ein billiger Weg, den Server
   zu beschaeftigen. Jetzt dieselbe Fehlversuchssperre wie bei /dav und
   /caldav: zehn Fehlschlaege in 15 Minuten, dann 15 Minuten Pause.

Die Sperre hat eine Folge fuers Testen selbst: Nach einem Lauf ist die
eigene IP 15 Minuten fuer passwortgeschuetzte Links gesperrt. Deshalb
steht die Probe am Ende, und der Test erkennt zu Beginn, ob eine Sperre
aus einem frueheren Lauf noch steht, statt reihenweise Fehlschlaege zu
melden, die keine sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:39 +02:00
marcandClaude Opus 5 1e1f35955d fix(preview): Blaettern bleibt bei einer Art Datei
Die Pfeile (und die Pfeiltasten) in der Vorschau liefen bisher ueber
alles Anzeigbare. Wer Bilder durchsah, landete mitten in der Reihe auf
einem PDF -- das ganz anders bedient wird: Seiten statt Dateien, eigene
Zoomleiste, auf schmalen Fenstern gar keine Pfeile.

Jetzt blaettert die Vorschau nur noch durch Dateien derselben Art wie
die gerade offene. Bewusst nicht nur fuer Bilder gesondert geloest,
obwohl es dort auffiel: Dieselbe Ueberraschung gaebe es beim Sprung von
einem PDF in ein Video oder in eine Textdatei.

Solange nichts offen ist, zaehlt weiterhin alles Anzeigbare -- sonst
waere die Liste leer, bevor sie ueberhaupt gebraucht wird.

Nebenwirkung mit Absicht: Gibt es nur ein Bild im Ordner, verschwinden
die Pfeile ganz, statt im Kreis durch fremde Dateitypen zu fuehren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:16:25 +02:00
marcandClaude Opus 5 ac61df9796 docs(calendar): Kalender im README und Stand im Plan nachtragen
Der Kalender kam im README bislang mit keinem Wort vor -- weder in der
Phasentabelle noch als Abschnitt, obwohl er ein eigener Zugangsweg mit
eigener Anmeldung ist. Jetzt steht er neben WebDAV, wo er hingehoert:
gleiche Adresse-plus-App-Passwort-Logik, gleiche Sperre nach zehn
Fehlversuchen.

Zwei Dinge stehen dort ausdruecklich, weil sie beim Einrichten sonst
eine halbe Stunde Suche kosten:

  - Die Kalenderfarbe holt DAVx5 nur beim Erfassen der Sammlungen, nicht
    bei jedem Abgleich. Sie bleibt im Telefon alt, bis man dort einmal
    "Kalenderliste aktualisieren" antippt. Am Geraet nachgemessen.
  - Kalender verwalten kann die Kalender-App des Telefons gar nicht;
    Android ueberlaesst das der Sync-App.

Im Plan die Phasen 1 bis 3 auf erledigt, und darunter das, was ueber die
Phasen hinaus dazukam, weil es sich beim Pruefen als noetig erwies:
Umhaengen, EXDATE, getctag/calendar-color, Kalenderverwaltung vom Client
aus, die beiden Selbsttests.

Abbild veroeffentlicht als 2026.09.06 und latest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:14:40 +02:00
marcandClaude Opus 5 f71ab710e1 feat(calendar): Kalender anlegen, umbenennen, loeschen -- vom Client aus
Bisher konnte ein CalDAV-Client Termine verwalten, aber keine Kalender:

  MKCALENDAR -> 405    anlegen
  PROPPATCH  -> 501    umbenennen, umfaerben
  DELETE     -> 404    loeschen

go-webdav deckt davon nur MKCOL ab, und selbst da verwarf unser Backend
die vom Client gewaehlte Adresse. Alle drei Verben faengt der Handler
jetzt vorher ab (collections.go).

Der Kern steckt aber nicht in den Verben, sondern in der Adresse: Bei
MKCALENDAR sucht *der Client* aus, wo der Kalender liegen soll
("…/calendars/max/arbeit/"). Unsere Kalender lagen bisher zwingend unter
ihrer UUID -- der Client haette angelegt und auf seine eigene Adresse
sofort ein 404 bekommen.

Deshalb Migration 23 mit einer Spalte path_segment: gesetzt = die
gewaehlte Adresse, NULL = es gilt weiterhin die UUID. Bestehende
Kalender bleiben damit unter genau der Adresse erreichbar, unter der die
eingerichteten Geraete sie kennen -- ein Wechsel haette jedes Telefon
dazu gebracht, seine Termine fuer geloescht zu halten. Aufgeloest wird
beides in einer Abfrage (GetCalendarByRef), die gewaehlte Adresse hat
Vorrang.

PROPPATCH antwortet mit einem Status je Eigenschaft, auch wenn alles
geklappt hat: Es kann teilweise gelingen, und ein nacktes 200
verschwiege, welcher Teil angekommen ist. Was wir nicht aendern koennen,
bekommt 403 statt stillem Schlucken -- ein Client, der seine Aenderung
fuer angekommen haelt, ist schlimmer dran als einer mit klarer Absage.
Ebenso eine unlesbare Farbe: absagen statt raten.

DELETE auf eine Sammlung loescht den Kalender mitsamt allen Terminen --
das ist in WebDAV seine Bedeutung.

CalDAV-Selbsttest von 45 auf 62 Pruefungen: anlegen, unter der
gewaehlten Adresse erreichbar, Farbe aus dem MKCALENDAR uebernommen,
Termin darin ablegen und abrufen, zweites Anlegen auf dieselbe Adresse
405, umbenennen und umfaerben mit Status je Eigenschaft, loeschen, danach
404 -- und dasselbe noch einmal ueber MKCOL, das Verb, das DAVx5 nimmt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:37:41 +02:00
marcandClaude Opus 5 820ffdde6a style(calendar): Leerzeile im PROPFIND-Weg
Uebrig geblieben vom Ein- und Ausbau einer Diagnose-Zeile, mit der zu
klaeren war, warum die Kalenderfarbe nicht im Telefon ankam. Die Antwort:
DAVx5 fragt die Farbe beim normalen Sync gar nicht ab, nur beim Erfassen
der Sammlungen -- nach dem Aktualisieren der Kalenderliste war sie da.

Die Diagnose selbst hat es nie ins Repository geschafft, sie lief nur im
gebauten Abbild. Weg musste sie trotzdem: Sie schrieb jede
PROPFIND-Antwort vollstaendig ins Protokoll, auf INFO-Ebene, und
Kalendernamen gehoeren nicht in Logdateien.

Beide Selbsttests gruen (45 CalDAV, 36 JSON).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:25:33 +02:00
marcandClaude Opus 5 128d54035a feat(calendar): getctag und calendar-color fuer CalDAV-Clients
Beide Eigenschaften fragt jeder verbreitete Client ab, und beide kennt
go-webdav v0.7.0 nicht:

  getctag         die billige Frage "hat sich in diesem Kalender etwas
                  geaendert?". Ohne sie laedt ein Client bei jedem
                  Abgleich die Liste aller Termine herunter, um sie zu
                  vergleichen -- richtig, aber teuer an Daten und Akku.
  calendar-color  die Farbe des Kalenders. Ohne sie zeigt das Telefon
                  ihn in seiner eigenen Vorgabefarbe, und die Farbgebung
                  dort hat mit der in der Cloud nichts mehr zu tun.

Die Bibliothek beantwortet einen festen Satz Eigenschaften und bietet
keinen Haken, eigene dazuzulegen (server.go, propFindCalendar). Deshalb
faengt der Handler PROPFIND jetzt ab: Er nimmt die beiden aus der
Anfrage heraus, bevor go-webdav sie zu sehen bekommt, und haengt sie der
Antwort als eigenen propstat-Block wieder an.

Das Herausnehmen ist der Punkt, an dem es sonst schiefgeht: Ohne das
meldet go-webdav sie als "nicht gefunden", und dieselbe Eigenschaft
stuende zweimal in derselben Antwort -- einmal mit 404, einmal mit 200.
Was ein Client daraus macht, ist Gluecksache.

Zwei Fallen dabei, beide abgedeckt: Fragt ein Client *nur* nach diesen
Eigenschaften, bliebe ein leeres <prop> uebrig, das go-webdav abweist --
dann schiebt Aether resourcetype als Platzhalter unter. Und die Farbe
geht als #RRGGBBAA hinaus, mit Alpha-Anteil, weil manche Clients sie
sonst gar nicht anzeigen.

Der Neuaufbau des Anfragerumpfs ist verlustfrei, weil in einem PROPFIND
ausschliesslich leere Elemente stehen: Name und Namensraum sind alles,
was eine Eigenschaft dort ausmacht.

Unit-Tests fuer die XML-Arbeit (propfind_test.go), CalDAV-Selbsttest von
39 auf 45 Pruefungen: beide Eigenschaften mit 200, Apple-Schreibweise,
ctag aendert sich bei Aenderungen (ein festgefrorenes ctag ist schlimmer
als gar keins), Anfrage nur nach ihnen, Farben in der Kalenderliste, und
ohne Nachfrage kommt auch nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:11:25 +02:00
marcandClaude Opus 5 d7b5c1cd5f test(calendar): CalDAV-Selbsttest, und fremde Benutzerpfade sauber abweisen
Der bisherige Selbsttest prueft die JSON-Schnittstelle, also den Weg der
Weboberflaeche. Der CalDAV-Weg -- der, den DAVx5 und Thunderbird gehen --
war nie automatisch geprueft, obwohl er ueber voellig anderen Code
laeuft. Ein gruener Lauf des einen sagt nichts ueber den anderen.

scripts/caldav-selbsttest.py geht ihn jetzt ab: Anmeldung (auch die
Regel, dass das Kontopasswort hier nicht gilt), Auffinden ueber
.well-known samt der Umleitung, der Clients mit GET folgen,
Kalenderliste, PUT/GET/DELETE, If-Match, calendar-query mit Zeitfenster,
calendar-multiget, beide Richtungen zwischen Web und CalDAV
(Ganztagsdatum mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE,
EXDATE) und das Umhaengen aus Client-Sicht.

Dabei gefunden und behoben: Ein Pfad, der auf einen *anderen* Benutzer
zeigt, wurde mit einem leeren 207 beantwortet. Kein Datenleck -- die
Antwort ist leer, und der Backend nimmt den angemeldeten Benutzer, nicht
den aus dem Pfad. Aber der Client sieht dann einen Kalenderbestand ohne
Kalender und meldet "nichts gefunden" statt "falsche Adresse": die
stille Variante genau des Fehlers, der bei der Einrichtung schon einmal
Stunden gekostet hat. Jetzt 404. Bewusst nur ablehnen, was sicher fremd
ist; alles Unbekannte geht weiter an go-webdav.

Zwei Luecken bleiben und werden vom Skript als Hinweis gemeldet, weil
go-webdav v0.7.0 nur einen festen Satz Eigenschaften beantwortet und
keinen Haken bietet, eigene dazuzulegen: getctag fehlt (Clients listen
bei jedem Abgleich alles auf, statt billig nach Aenderungen zu fragen)
und calendar-color fehlt (der Kalender erscheint im Client in dessen
Vorgabefarbe -- Terminfarben sind nicht betroffen, die stehen im .ics).

39 Pruefungen gruen, JSON-Selbsttest weiter 36 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:01:48 +02:00
marcandClaude Opus 5 cc35161e74 feat(calendar): Kalenderwahl wieder als Ausklappliste, weiterhin mit Farbe
Die Plaketten aus dem vorigen Stand nahmen bei mehreren Kalendern viel
Platz im Formular und liefen ueber mehrere Zeilen um. Also zurueck zur
Ausklappliste -- aber nicht zurueck zum reinen Text.

Ein natives <select> kam dafuer nicht in Frage: Dessen Eintraege lassen
sich in keinem Browser verlaesslich einfaerben, und im zugeklappten
Zustand schon gar nicht. Deshalb eine eigene Liste, die im Ruhezustand
aussieht wie die anderen Felder daneben, aber den Farbpunkt des
Kalenders traegt -- gefuellt fuer den gewaehlten, leerer Ring fuer die
anderen, dieselbe Sprache wie in der Kalenderliste.

Eine Falle dabei: Escape muss beim Schliessen der Liste angehalten
werden. Sonst laeuft es weiter bis zum Fenster, wo der Termindialog
lauscht, und macht gleich das ganze Formular zu -- mitsamt allem, was
schon eingetippt war.

Selbsttest weiter 36 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 07:50:19 +02:00
marcandClaude Opus 5 7a1f0837f5 feat(calendar): Kalenderwahl im Termin mit Farbe statt nur Text
Die Farbe ist das, woran man einen Kalender im Raster wiedererkennt --
im Termindialog stand aber nur der Name in einem Auswahlfeld. Wer
"Arbeit" waehlt, soll dabei schon das Blau sehen, in dem der Termin
gleich erscheint.

Deshalb Plaketten statt <select>: Farbpunkt plus Name, alle nebeneinander
sichtbar, kein Aufklappen noetig. Der Punkt spricht dieselbe Sprache wie
ueberall sonst im Kalender -- gefuellt heisst gewaehlt, ein leerer Ring
heisst nicht gewaehlt, genau wie in der Kalenderliste und bei der
Terminfarbe.

Die gewaehlte Plakette bekommt nur einen Hauch der Kalenderfarbe als
Grund. Acht kraeftig ausgefuellte Toene nebeneinander erschluegen sonst
das ganze Formular, in dem die Farbe eigentlich der Nebendarsteller ist.

Als CalendarPicker in controls.tsx, wo schon Toggle und ColorSwatches
liegen -- damit die naechste Stelle, die eine Kalenderwahl braucht,
nicht wieder bei null anfaengt.

Selbsttest weiter 36 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:04:30 +02:00
marcandClaude Opus 5 77bf16b27f feat(calendar): Termin einem Kalender zuordnen und umhaengen
Beim Anlegen war die Kalenderauswahl schon da (der oberste ist die
Vorgabe), beim Bearbeiten war sie festgestellt -- mit der Begruendung,
ein Umhaengen sei Loeschen und Neuanlegen unter neuer Kennung. Das
stimmt so nicht: In CalDAV wechselt dabei nur die Sammlung, UID und
Inhalt bleiben. Genau das macht der neue Endpunkt

    POST /api/v1/calendars/{id}/events/{uid}/move  {"calendar_id": …}

Er aendert nur die Zugehoerigkeit der Zeile und erhoeht das ctag auf
**beiden** Seiten. Mit nur einem holte ein Client die andere Haelfte
erst beim naechsten Vollabgleich, und der Termin stuende
zwischenzeitlich doppelt oder gar nicht im Telefon.

Bewusst ein eigener Endpunkt und kein weiteres Feld im PATCH: Ein Umzug
laesst sich auch ohne jede Aenderung am Termin ausloesen, und dafuer den
ganzen Termin mitschicken zu muessen waere eine Einladung, nebenbei
etwas zu ueberschreiben. Im Formular laeuft deshalb erst das PATCH und
dann der Umzug -- andersherum stuende der Termin nach einem Fehler
verschoben, aber unveraendert da, die verwirrendere Haelfte.

Dazu die Zieh-Geste: einen Termin auf einen Kalender in der
Seitenleiste ziehen, am Handy nach kurzem Gedrueckthalten. Ueber
Pointer-Events statt ueber die Drag-and-Drop-Schnittstelle des
Browsers -- die gibt es auf Android nicht, und genau dort soll die
Geste ja auch funktionieren. Ist die Kalenderliste zugeklappt (am Handy
der Regelfall), erscheint waehrend des Ziehens eine Leiste unten in
Daumennaehe, sonst liefe die Geste ins Leere.

Zwei Faelle, die sonst aergern: Wer in einen ausgeblendeten Kalender
zieht, bekommt ihn eingeblendet -- der Termin waere sonst scheinbar
spurlos verschwunden. Und der Klick, der einem Ziehen hinterherkommt,
wird verschluckt, statt den Termindialog zu oeffnen.

Eine Reihe zieht vollstaendig um; sie ist eine einzige Datei, kein
Buendel von Einzelterminen. Das sagt das Formular auch an.

Selbsttest um elf Pruefungen erweitert (36 gruen): Umzug angenommen,
Kennung bleibt, aus der Quelle weg und im Ziel da, ctag auf beiden
Seiten gestiegen, Umzug auf sich selbst folgenlos, fremder Kalender
404, Reihe vollstaendig umgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:07:36 +02:00
marcandClaude Opus 5 688bb13bde feat(calendar): einzelnen Termin aus einer Reihe streichen
Beim Löschen eines Serientermins gibt es jetzt zwei Wege statt einem:
"Nur diesen Termin" und "Ganze Reihe". Das sind zwei verschiedene
Absichten, und sie sind nicht ineinander umkehrbar -- eine einzelne
Ja/Nein-Rückfrage musste sich bisher für eine davon entscheiden, und die
andere war gar nicht erreichbar.

"Nur diesen" wird als EXDATE in der Reihe vermerkt, so wie das
iCalendar-Format eine Ausnahme ausdrückt: Die Reihe behält ihre Regel
und ihre UID, jeder CalDAV-Client versteht es ohne Zutun. Bewusst nicht
über ein Aufspalten in zwei Reihen gelöst -- das änderte die UID, und
alle Geräte hielten den Rest für einen neuen Termin.

Ein zweites Streichen desselben Termins bleibt folgenlos (sonst liefe
die Datei mit der Zeit mit doppelten EXDATE-Zeilen voll), und bei einem
Einzeltermin ohne Wiederholung sind beide Wege dasselbe -- der Server
löscht dann einfach, statt den Aufrufer mit einer Unterscheidung zu
behelligen, die es dort nicht gibt.

Selbsttest um sechs Prüfungen erweitert (25 grün): Reihe steht,
Streichen liefert 204, der gestrichene Termin fehlt und der Rest bleibt,
EXDATE steht im .ics, zweimal Streichen ist harmlos, ganze Reihe löschen
räumt alles weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:06:53 +02:00
marcandClaude Opus 5 0c9cc69493 feat(calendar): Wiederholungsmuster, Terminfarbe synchronisierbar, Selbsttest
Terminfarbe: Sie kam auf dem Telefon nie an. RFC 7986 schreibt für COLOR
einen CSS3-*Namen* vor, wir schrieben Hex – DAVx5 liest die Eigenschaft
über eine Namenstabelle und verwirft alles andere, also blieb der Termin
im Samsung-Kalender farblos. Jetzt beides nebeneinander:

    COLOR:tomato            für fremde Clients (damit die Farbe ankommt)
    X-AETHER-COLOR:#ea4335  für die eigene Palette (damit sie genau bleibt)

Beim Lesen gewinnt der genaue Wert; ein von außen eingefärbter Termin
wird über die Namenstabelle übersetzt. Nächstgelegener Ton per Abstand im
RGB-Würfel (color.go).

Wiederholungen können jetzt mehr als "alle N":

- wöchentlich an ausgewählten Wochentagen (Mo–So, Mehrfachauswahl). Der
  Wochentag des Starts bleibt fest angehakt – eine Reihe, die ihren
  eigenen ersten Termin auslässt, ist keine.
- monatlich und jährlich wahlweise am festen Kalendertag (am 4.) oder am
  n-ten Wochentag (am 1. Freitag). Beide Angaben leitet der Server aus
  dem Startdatum ab, statt sie getrennt entgegenzunehmen – so kann die
  Regel gar nicht erst auf einen anderen Tag zeigen als der Termin.
- Der fünfte Wochentag eines Monats wird bewusst zu "letzter": Einen
  fünften Freitag hat nicht jeder Monat, BYDAY=5FR ließe die Reihe sonst
  in etwa jedem zweiten Monat stillschweigend ausfallen.

Regeln, die das Formular weiterhin nicht abbildet, bleiben lesbar, aber
nicht bearbeitbar (409) – unverändert.

Dazu scripts/kalender-selbsttest.py, 19 Prüfungen gegen die laufende
Instanz, in TESTING.md verlinkt. Alle grün, unter anderem:

    am 1. Freitag monatlich   -> 04.09. 02.10. 06.11. 04.12.
    am 1. Freitag im September -> 2026-09-04, 2027-09-03
    Mo+Fr wöchentlich          -> 07./11./14./18./21./25.09.
    ganztägig am 3.            -> bleibt der 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:16:06 +02:00
marcandClaude Opus 5 79a7cc6a3d feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter
Fünf Wünsche aus dem Durchtesten:

1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich,
   alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand
   lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe
   erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im
   angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals
   RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an.
   Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt
   neu gerechnet, Fenster einer Woche liefert genau einen.

   Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag
   im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier
   Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein
   halb verstandenes Speichern würde die Regel sonst still beschneiden.

2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick
   auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen
   aus oder legt bewusst einen neuen an.

3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe.

4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter --
   gefüllt heißt sichtbar, leerer Ring ausgeblendet.

5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das
   Formular um, das ist keine Nebenangabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:56:28 +02:00
marcandClaude Opus 5 105ce8de4a fix(calendar): Termine anklickbar, Ganztags-Datum korrekt, Wochenraster mit Stunden
Drei gemeldete Fehler, alle nachgestellt:

1. Ein vorhandener Termin ließ sich nicht öffnen. Der Klick auf den
   Termin reichte an die Tageszelle darunter durch, die sofort 'Neuer
   Termin' öffnete und den Bearbeiten-Dialog überschrieb. Fehlendes
   stopPropagation.

2. Klick auf den 3. legte den Termin am 2. an. Ein ganztägiger Termin
   ist ein *Datum*, kein Zeitpunkt - die Oberfläche rechnete ihn über
   new Date(...).toISOString() trotzdem in UTC um, was östlich von UTC
   den Vortag ergibt. Nachgestellt und bestätigt: DTSTART landete als
   20260902 statt 20260903. Ganztägige Termine laufen jetzt über
   dates.ts (allDayISO/eventDay), damit Dialog und Raster dieselbe
   Rechnung benutzen. Nebenbefund mitgefixt: DTEND war gleich DTSTART,
   was RFC 5545 verbietet (DTEND ist exklusiv) - ein eintägiger Termin
   am 3. schreibt jetzt DTEND 20260904, nach außen bleibt die
   Schnittstelle beim letzten belegten Tag.

3. 'Fühlt sich starr an': Wochenansicht ist jetzt ein echtes
   Stundenraster (Klick trifft die Stunde statt Mitternacht, ganztägige
   in eigener Zeile, überlappende Termine nebeneinander, Jetzt-Linie),
   mehrtägige Termine stehen in jeder Zelle statt nur am Starttag, neue
   Termine schlagen die nächste volle Stunde bzw. 9 Uhr vor,
   Kalenderauswahl im Termindialog, Escape schließt, Hover-Rückmeldung,
   Kalenderliste auf schmalen Schirmen einklappbar.

Außerdem: ein neu angelegter Kalender blieb unsichtbar, weil er in
keiner Sichtbar-Liste stand - die Liste merkt sich jetzt die
ausgeblendeten statt der sichtbaren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:47:08 +02:00
marcandClaude Sonnet 5 ba33706d2e fix(calendar): Symbol in der Kopfleiste ergänzt
Jede andere Seite zeigt neben dem Titel ein Strichsymbol (Papierkorb,
Geteiltes, Etiketten, ...) - beim Kalender fehlte es, PageHeader bekam
keinen icon-Prop. Dasselbe Kalendersymbol wie in der Seitenleiste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:36:21 +02:00
marcandClaude Sonnet 5 27a026421f feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht
Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.

Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.

Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:14:28 +02:00
marcandClaude Sonnet 5 4d635fff20 feat(calendar): Phase 2 – mehrere Kalender, Export/Import als .ics
Neuer APIHandler unter /api/v1/calendars (Session-Auth, wie der Rest
der API) neben dem bestehenden CalDAV-Handler (App-Passwörter,
/caldav/) – zwei Handler, zwei Auth-Modelle, wie schon bei /dav.

- GET/POST /api/v1/calendars – Liste, Anlegen
- PATCH/DELETE /api/v1/calendars/{id} – Umbenennen, Farbe, Zeitzone,
  Löschen (go-webdav kennt PROPPATCH und Kalender-Löschen nicht – dafür
  gibt's im CalDAV-Protokoll selbst keinen Weg)
- GET .../export – alle Termine eines Kalenders als eine .ics-Datei,
  VTIMEZONE-Blöcke dedupliziert
- POST .../import – .ics mit mehreren VEVENT/VTODO einlesen, jede
  Komponente wird ein eigenes Objekt wie bei einem einzelnen CalDAV-PUT

Gegen den laufenden Container getestet (Session-Login, CSRF-Token):
Anlegen, Umbenennen, zwei Termine importieren, sauberer Re-Export,
Löschen, und dass ohne Login 401 statt Daten kommt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:03:26 +02:00
marcandClaude Sonnet 5 3f1b2b8a14 feat(calendar): Standard-Zeitzone pro Kalender vorbereiten
Neue Spalte cal_calendars.timezone (Default UTC). CalDAV selbst braucht
sie nicht - jeder Termin trägt seine Zeitzone bereits im eigenen .ics,
und die TZID-Auflösung darin läuft schon korrekt über die
System-Zeitzonendatenbank. Gebraucht wird das Feld erst von der
künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3) als
Vorgabe, wenn der Nutzer keine eigene Zeitzone wählt - jetzt schon
angelegt, damit dafür keine weitere Migration nötig wird.

Gegen den laufenden Container geprüft: Migration lief durch, Home-Set-
PROPFIND (nutzt die geänderten SELECTs) weiterhin grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:56:28 +02:00
marcandClaude Sonnet 5 469274171c fix(calendar): GET auf CalDAV-Sammlungen antwortet statt 404
Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit GET
statt PROPFIND (curl -L reproduziert das exakt), landen dann auf einer
Sammlung (Principal/Home-Set/Kalender) und bekamen dort bisher ein
hartes 404 von GetCalendarObject – ein Client, der so die
Erreichbarkeit prüft, bricht die Einrichtung dann vorzeitig ab, bevor
er je ein PROPFIND schickt. GET/HEAD auf Nicht-.ics-Pfade antworten
jetzt mit einer schlichten 200-Textantwort statt an den CalDAV-Backend
durchgereicht zu werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:50:07 +02:00
marcandClaude Sonnet 5 d6e1854f1b fix(calendar): klare Fehlermeldung für GET auf CalDAV-Sammlungen
go-webdav leitet GET/HEAD auf jeden Pfad unverändert an
GetCalendarObject weiter, auch auf Principal- oder Home-Set-Pfade (ein
Browser kann nur GET, kein PROPFIND). Ohne Prüfung hielt
parseCalendarPath dann z.B. das Wort 'caldav' aus dem Pfad für eine
Kalender-ID und gab einen internen UUID-Parsing-Fehler nach außen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:45:24 +02:00
marcandClaude Sonnet 5 87788817f8 feat(calendar): Phase 1 – CalDAV lesend und schreibend, ein Kalender pro Nutzer
Neues, eigenständiges Modul internal/calendar: implementiert
caldav.Backend (emersion/go-webdav) gegen zwei neue Tabellen
(cal_calendars, cal_objects). Endpunkt /caldav/ mit eigener Basic Auth
(nur App-Passwörter, wie /dav), plus /.well-known/caldav für die
automatische Einrichtung nach RFC 6764.

Gegen echte Container getestet (nicht nur go build): Discovery-Kette
Principal -> Home-Set -> automatisch angelegter Standardkalender,
PUT/GET/DELETE eines Termins, REPORT mit Zeitraumfilter (Treffer und
Nicht-Treffer), If-Match/If-None-Match-Vorbedingungen (412), und dass
ein fremder Nutzer auf einen Kalender-Pfad mit 404 statt 403 abgewiesen
wird.

Zwei Fallen unterwegs gefunden und dokumentiert:
- go-webdav vergleicht von CurrentUserPrincipal/CalendarHomeSetPath
  zurückgegebene Pfade direkt gegen r.URL.Path (inklusive Prefix) und
  bestimmt den Ressourcentyp allein an der Segmentzahl dahinter – der
  Nutzername beim Principal steht deshalb in einem Segment
  (principals-max), nicht als Unterpfad.
- chi kennt REPORT/MKCALENDAR nicht von Haus aus (nur PROPFIND & Co.
  waren für /dav bereits registriert) und hätte sie sonst mit 405
  abgewiesen, bevor der CalDAV-Handler überhaupt lief.

Migration 000021 nur mit CREATE TABLE IF NOT EXISTS, wie in
kalender-plan.md festgelegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:27:33 +02:00
marcandClaude Sonnet 5 e44bed0a34 docs: Migrationsausfall nachgestellt und Notfallweg dokumentiert
Marcs Sorge ernst genommen und geprüft statt behauptet: eine
absichtlich kaputte Migration in einer Wegwerf-Datenbank laufen lassen.
Ergebnis: schema_migrations bleibt auf dirty=true stehen, und main.go
bricht bei jedem weiteren Start mit log.Fatal ab – das gilt für jede
Migration, nicht nur die des Kalenders, und stand schon vorher so im
Code.

Drei Sicherungen vor der ersten Kalendermigration: nur CREATE TABLE IF
NOT EXISTS (idempotent, nie ALTER an Fremdem), erst auf der Dev-Instanz
hoch und runter, ein pg_dump vor dem ersten produktiven Ausrollen.

Die Notfallzeile (UPDATE schema_migrations SET dirty=false) steht jetzt
ganz oben in TESTING.md – nicht nur für den Kalender, für jede künftige
Migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:08:09 +02:00
marcandClaude Opus 5 0cea81978f docs(kalender): sechs Grenzen statt eines Plugin-Systems
Marcs Linie: kein Plugin-System bauen, aber dafür sorgen, dass der
Kalender wirklich ein Extra bleibt. Das ist als Liste festgehalten, weil
gute Absicht in sechs Monaten niemanden bindet: niemand importiert das
Paket, es importiert nur auth/acl/pkg, alles unter einem Routenpräfix,
eigene Tabellen mit cal_-Präfix, abschaltbar, keine Änderung an
bestehenden Paketen außer einer Zeile in main.go.

Die erste Regel wird ein Test, der die Importe durchgeht – dann fällt ein
Grenzübertritt beim Bauen auf und nicht Jahre später.

Migrationen bleiben in der Kernnummerierung. Eine zweite Versionstabelle
wäre für ein echtes Plugin richtig, für eine persönliche Cloud ist sie
Aufwand ohne Gegenwert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:56:20 +02:00
marcandClaude Opus 5 5808ff5ef8 docs(kalender): Plugin-Frage, Teamkalender über Gruppen, Oberfläche und Farben
Kein Plugin: Der Projektplan legt in Abschnitt 8 selbst fest, dass
Extension-Points erst nach einem stabilen Core kommen und Kernfeatures
nicht als Plugin gebaut werden. Ein Plugin-System vorzuziehen hieße, die
Schnittstelle nach genau einem Nutzer zu entwerfen. Stattdessen ein
eigenes Paket, in einer Zeile angeschlossen, abschaltbar – der natürliche
erste Kandidat, wenn es das System einmal gibt.

Teamkalender brauchen nichts Neues: acl.Access trägt bereits einen
Prinzipal statt eines Eigentümers, Konto oder Gruppe. Ein Kalender, der
einer Gruppe gehört, ist der Teamkalender; persönliche Freigabe bleibt
die Freigabe an ein Konto mit read/write.

Oberfläche festgehalten: mehrere Kalender übereinander, Sichtbarkeit per
Klick auf den farbigen Haken, serverseitig je Nutzer gespeichert – samt
eigener Farbe je Nutzer, die in derselben Zeile fast nichts kostet.

Farben getrennt notiert: Kalenderfarbe (calendar-color, de facto überall
unterstützt) und Terminfarbe (RFC 7986, Weiterreichung ans Telefon
ungeprüft).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:44:34 +02:00
marcandClaude Opus 5 6f0efc8e7b docs(kalender): Festlegungen, Felder, Clients und das Teilen nachgetragen
Teilen war in der ersten Fassung zu pauschal abgeräumt. Es meint drei
Dinge: dasselbe Konto auf mehreren Geräten (ab Phase 1), ein Kalender für
einen anderen Nutzer (Tabelle plus Bedingung in ListCalendars, keine
Protokollarbeit) und ein öffentlicher Abo-Link (Aether hat Freigabelinks
schon). Nur Einladungen per E-Mail bleiben draußen.

Dazu Marcs Festlegungen: mehrere Kalender und VTODO von Anfang an,
Anhänge kein Ziel, Erinnerungen weckt die App auf dem Telefon.

Und die Einschränkung, die sonst später überrascht: Androids
Kalenderspeicher kennt keine Aufgaben. Ohne Tasks.org o. ä. bietet DAVx5
sie nicht an, und Samsung Kalender zeigt sie nie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:33:06 +02:00
marcandClaude Opus 5 8f90a7fbd6 docs: Plan für einen Kalender in Aether
Radicale ist für einen einfachen Kalender zu umständlich, vor allem fehlt
die OIDC-Anbindung. Der Plan hält fest, warum das als Modul in Aether
besser aufgehoben ist: Ein CalDAV-Client kann ohnehin kein OIDC, er will
Benutzername und Passwort – und App-Passwörter für DAV gibt es hier schon.

Vor dem Schreiben nachgesehen statt geschätzt: emersion/go-webdav v0.7.0
beantwortet PROPFIND/REPORT selbst, caldav.Filter() filtert, und der
Zeitraumfilter löst Wiederholungen über go-ical und rrule-go auf. Damit
ist der Teil, vor dem ich gewarnt hatte, in der Bibliothek. Zu erfüllen
bleibt eine Schnittstelle mit neun Methoden – Speicherarbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:43:56 +02:00
marcandClaude Opus 5 9d152e3677 refactor: App-Umschalter und HONEYBAR_KEY entfernt
Beide waren Ballast, aber aus verschiedenen Gründen.

Der App-Umschalter (EXTERNAL_APPS, AppSwitcher.tsx) hing seit dem
30.07.2026 nirgends mehr in der Oberfläche – auf Marcs Wunsch damals aus
der Seitenleiste genommen, "weil eine gemeinsame Leiste für alle Apps
noch überlegt wird". Diese Leiste gibt es inzwischen: die HoneyBar. Das
Backend las die Variable trotzdem weiter und lieferte external_apps in
der öffentlichen Konfiguration aus. Weg damit, samt Typ und Parser.

HONEYBAR_KEY war der Ausweis für die App-Liste, wenn HoneyOS auf einer
fremden Site liegt. Bei Marc liegen honeyos.duckdns.org und
cloud.honeyos.duckdns.org unter derselben Site, das Sitzungscookie fließt
also mit. Nachgesehen, wie HoneyOS entscheidet: Es prüft zuerst die
Sitzung (SubFromRequest) und greift erst danach zum Schlüssel – die
Leiste kommt hier ohne ihn an ihre Liste. Gedacht war er für Widgets;
die sind vertagt.

Aether schickt damit kein data-key mehr; HoneyOS behält seine Seite der
Prüfung, für den Fall, dass später doch eine App auf fremder Domain dazu
kommt.

Nachgeprüft: go build, go vet und die Tests laufen; das Abbild baut ohne
TypeScript-Fehler; die öffentliche Konfiguration liefert weder
honeybar_key noch external_apps; im gebauten Bundle kommt data-key nicht
mehr vor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:15:28 +02:00
marcandClaude Opus 5 31d51f9bc3 docs(config): Office-Abschnitt der .env.example auf die Schlüssel gekürzt
Die Adressen und Fallstricke stehen im README; hier standen sie ein
zweites Mal. Jetzt sechs Zeilen mit knapper Randnotiz, welche Richtung
welche Adresse meint.

Der Hinweis zu Collabora bleibt: OFFICE_WOPI_URL muss dort die
öffentliche Adresse sein, sonst verweigert der Browser den Rahmen. Das
hat einen Abend gekostet und ist beim Überfliegen nicht zu erraten.

Beim Kürzen selbst wieder in die dokumentierte Falle getappt: zwei leere
Werte mit Kommentar dahinter. Diesmal nachgemessen statt geglaubt – ein
Container mit so einer Zeile bekommt LEER=[# Browser → Office], während
ein gefüllter Wert den Kommentar verliert. Beide Kommentare stehen jetzt
in der Zeile darüber, die ganze Datei ist gegengeprüft.

Nebenbei: veralteter deploy/-Verweis im Kommentar von AppSwitcher.tsx
auf build/ gezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:10:08 +02:00
marcandClaude Opus 5 86da975424 config: 0.0.0.0 als Vorgabe für APP_BIND im Betrieb
Auf Marcs Wunsch. Die bisherige Vorgabe 127.0.0.1 passt nur, wenn der
Reverse Proxy auf demselben Rechner läuft – steht er woanders (bei Marc
auf einem VPS), kommt er an loopback gar nicht heran, und die Vorgabe war
schlicht der falsche Wert für den häufigeren Fall.

Der Preis steht jetzt an allen drei Stellen dabei, statt nur gemeint zu
sein: Der Port ist dann auch aus dem eigenen Netz offen, und TLS endet
erst am Proxy. Im README dazu eine Tabelle, welcher Wert zu welchem
Aufbau gehört – eigener Rechner, anderer Rechner, Tunnel, oder Proxy im
selben Docker-Netz (dann braucht es gar kein ports:).

build/docker-compose.yml behält 127.0.0.1: Das ist die
Entwicklungsumgebung auf diesem Rechner, dort ist loopback richtig.

Gegengeprüft: Frische Kopie aus den Beispieldateien veröffentlicht auf
0.0.0.0; APP_BIND=127.0.0.1 in der .env engt wieder auf loopback ein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:06:29 +02:00
marcandClaude Opus 5 69912081dd docs(config): .env.example beschreibt nur noch den Betrieb
Vier Schlüssel darin steuerten den mitgelieferten Collabora-Container
statt Aether: OFFICE_ALIASGROUP, OFFICE_SSL, OFFICE_SSL_TERMINATION,
OFFICE_PORT. Wer Office selbst betreibt, richtet das in dessen eigener
Compose-Datei ein – in einer Vorlage für den Betrieb der Anwendung haben
sie nichts zu suchen.

Sie stehen jetzt kommentiert in build/docker-compose.yml, wo sie wirken;
die Erklärung zu aliasgroup1 (regulärer Ausdruck, Punkte maskieren, Port
anhängen) ist dorthin mitgewandert. Die Rückfallwerte im Compose gab es
schon, ein frischer Aufbau ohne diese vier läuft also weiter.

Meilisearch ist als "noch nicht gebaut" gekennzeichnet, statt einen
Schalter vorzutäuschen: Kein Go-Code liest die beiden Werte, gesucht wird
mit der Volltextsuche von Postgres.

Der Rest bleibt ausführlich – die Vorlage ist auch die Übersicht, was
möglich ist.

Gegengeprüft: Beide Compose-Dateien lösen mit der gekürzten Vorlage als
einziger .env sauber auf; Collabora bekommt aus den Rückfällen
aliasgroup1=http://app:8080 und ssl.enable=false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:03:34 +02:00
167 changed files with 21770 additions and 2586 deletions

No files matched your search

+56 -57
View File
@@ -1,9 +1,13 @@
# Aether – alle Einstellungen. cp .env.example .env
# Aether – alle Einstellungen des Betriebs. cp .env.example .env
#
# Diese Datei gilt für beides: den Betrieb mit dem fertigen Abbild
# (docker-compose.example.yml hier im Wurzelverzeichnis) und die Entwicklung
# aus dem Quelltext (build/docker-compose.yml, liest build/.env).
# Für die Entwicklung: cp .env.example build/.env
# Hier steht, was die ANWENDUNG liest. Nichts davon hängt daran, ob du das
# fertige Abbild ziehst (docker-compose.example.yml) oder selbst baust
# (build/docker-compose.yml) – zum Entwickeln: cp .env.example build/.env
#
# Was NICHT hier steht: die Schalter des mitgelieferten Collabora-Containers
# (aliasgroup1, ssl, Port). Die gehören zum Compose, das ihn startet, und
# stehen kommentiert in build/docker-compose.yml. Wer Office selbst betreibt,
# richtet sie in dessen eigener Compose-Datei ein.
#
# Zwei Fallen beim Bearbeiten:
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
@@ -48,9 +52,17 @@ ADMIN_PASSWORD=
TRUSTED_PROXIES=172.18.0.1
# Port auf dem Wirt. Im Container bleibt es 8080.
APP_PORT=8080
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe, davor
# gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
APP_BIND=127.0.0.1
# Wer den Port erreichen darf.
# 0.0.0.0 alles, was den Server im Netz erreicht (Vorgabe)
# 127.0.0.1 nur dieser Rechner – richtig, wenn der Reverse Proxy hier läuft
# 10.0.0.5 eine bestimmte Adresse, etwa das Ende eines WireGuard-Tunnels
#
# Die Vorgabe ist 0.0.0.0, weil der Proxy typischerweise woanders steht und
# 127.0.0.1 ihn gar nicht erst herankommen ließe. Der Preis: Der Port ist auch
# aus dem eigenen Netz offen, und TLS endet erst am Proxy – bis dahin liegt die
# Anmeldung unverschlüsselt im Netz. Wer den Proxy auf demselben Rechner hat,
# setzt hier 127.0.0.1 und ist besser dran.
APP_BIND=0.0.0.0
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
@@ -68,6 +80,20 @@ TMP_PATH=/data/tmp
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
MAX_VERSIONS_KEPT=5
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
# Standardmäßig an. Fehlen die Programme im Abbild, schaltet sich der Arbeiter
# beim Start selbst ab, statt den Start zu verhindern.
OCR_ENABLED=true
# Sprachen für Tesseract. Jede zusätzliche kostet Zeit – nimm nur, was vorkommt.
OCR_LANGUAGES=deu+eng
# Seiten je Datei; 0 = unbegrenzt. Die Grenze ist Absicht: OCR ist das einzige
# Stück dieser Anwendung, das ohne Zutun eines Menschen Platz und Rechenzeit
# verbraucht. Wer regelmäßig lange Scans ablegt, setzt sie herauf.
OCR_MAX_PAGES=50
# Abbruch je Datei. Ein Scan, der so lange braucht, wird auch beim vierten
# Versuch nicht fertig.
OCR_TIMEOUT=10m
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
@@ -86,35 +112,27 @@ OIDC_LINK_BY_USERNAME=false
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
# ══ Office (optional) ═════════════════════════════════════════════════════════
# Leer = aus, auch wenn ein Office-Container läuft.
# Compose-Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
# collabora | onlyoffice (auch Euro-Office)
OFFICE_PROVIDER=
# Drei Adressen für drei Richtungen – die häufigste Fehlerquelle.
#
# Backend → Office. Port je nach Anbieter:
# Collabora http://office:9980
# OnlyOffice/Euro-Office http://office
OFFICE_URL=http://office:9980
# Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name.
OFFICE_PUBLIC_URL=https://office.example.com
# Office → Aether.
#
# **Bei Collabora die öffentliche Adresse der Cloud**, nicht app:8080: Aus
# diesem Wert baut Collabora sein frame-ancestors, und der Browser darf den
# Rahmen sonst nicht zeigen ("Framing … violates 'frame-ancestors …'").
# Collabora holt die Datei dann über deinen Reverse Proxy.
OFFICE_WOPI_URL=https://cloud.example.com
# Gegen SSRF: was das Backend anfragen darf. Der Host aus OFFICE_URL zählt
# immer dazu.
OFFICE_ALLOWED_HOSTS=office
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen: openssl rand -hex 32
# Muss mit JWT_SECRET des Office-Containers übereinstimmen. Ohne ihn schlägt
# das Wandeln nach PDF fehl (der Server antwortet `{"error":-8}`). Für den
# WOPI-Weg wird er nicht gebraucht, der hat eigene Token.
# Leer = aus. Compose-Schnipsel, Adressen und Fallstricke: README.md.
# Bei Collabora muss OFFICE_WOPI_URL die öffentliche Adresse der Cloud sein,
# sonst verweigert der Browser den Rahmen.
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
# Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
# ist fast immer falsch.
OFFICE_PUBLIC_URL=
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
# Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet
# die Zertifikatsprüfung **ab** – im Internet nichts zu suchen.
OFFICE_INSECURE_TLS=false
# Liest **der Office-Container**, nicht Aether: Es schützt seine eigene API.
# Der WOPI-Weg braucht es nicht, der hat eigene Token. Bei OnlyOffice und
# Euro-Office mit JWT_ENABLED=true gehört es gesetzt, sonst steht deren API
# offen. Collabora kennt es nicht. openssl rand -hex 32
OFFICE_JWT_SECRET=
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
@@ -123,10 +141,6 @@ OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
# OCR_TIMEOUT=10m # Notbremse je Datei
# ══ App-Umschalter (optional) ═════════════════════════════════════════════════
# Andere selbst gehostete Apps im Menü der Seitenleiste.
# EXTERNAL_APPS=InkNote|https://notes.example.com|📝
# ══ HoneyOS-Leiste (optional) ═════════════════════════════════════════════════
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
@@ -134,25 +148,10 @@ OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
# Wirkt beim **Neustart** – kein Neubau nötig.
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
HONEYBAR_ORIGIN=
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
# drüben passen. Nur nötig, wenn HoneyOS auf einer anderen Site liegt als
# Aether – dann schickt der Browser die HoneyOS-Sitzung nicht mit.
HONEYBAR_KEY=
# ══ Nur für build/docker-compose.yml ══════════════════════════════════════════
# Diese vier steuern den Office-Container aus dem Entwicklungs-Compose. Beim
# Betrieb mit eigenem Office-Container stehen sie in dessen Compose-Datei.
#
# Wer seine Dateien in Collabora öffnen darf – derselbe Host wie in
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
# anhängen. Fehlt er, lehnt Collabora ab:
# "No authorized hosts found matching the target host […] in config"
OFFICE_ALIASGROUP=https://cloud\.example\.com:443
OFFICE_SSL=false
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
OFFICE_PORT=9980
# ══ Später ════════════════════════════════════════════════════════════════════
# NOCH NICHT GEBAUT – Notiz, kein Schalter. Gesucht wird derzeit mit der
# Volltextsuche von Postgres; diese beiden Werte liest niemand.
# MEILISEARCH_URL=http://meilisearch:7700
# MEILISEARCH_KEY=
+4 -1
View File
@@ -1,5 +1,5 @@
# Secrets
build/.env
build/.env*
.env
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
@@ -29,3 +29,6 @@ Thumbs.db
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
frontend/public/pdfjs/
# Excalidraws Schriften – aus dem Paket kopiert, nicht eingecheckt (siehe
# vite.config.ts). Fremde Binaerdateien gehoeren nicht in den Quelltext.
frontend/public/excalidraw/
+300
View File
@@ -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.
+186
View File
@@ -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.
+206 -79
View File
@@ -44,12 +44,16 @@ Zwei Wege, je nachdem was du vorhast:
| **Betreiben** | `docker-compose.example.yml` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – eine Datei, alle
Einstellungen. Sie lag bis zum 02.09.2026 doppelt vor (einmal schlank hier,
einmal vollständig im Bau-Ordner), und die beiden liefen auseinander: Der
produktiven fehlten am Ende zwölf Schlüssel, darunter `SESSION_MAX_AGE`, und
sie empfahl für `OFFICE_WOPI_URL` einen Wert, der mit Collabora nicht
funktioniert.
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – sie beschreibt,
was die Anwendung liest, und das ist in beiden Fällen dasselbe. Sie lag bis zum
02.09.2026 doppelt vor (einmal schlank hier, einmal vollständig im Bau-Ordner),
und die beiden liefen auseinander: Der produktiven fehlten am Ende zwölf
Schlüssel, darunter `SESSION_MAX_AGE`.
Die Schalter des **mitgelieferten Collabora-Containers** (`aliasgroup1`, SSL,
Port) stehen nicht dort, sondern kommentiert in `build/docker-compose.yml` –
sie konfigurieren einen fremden Container, nicht Aether. Wer Office selbst
betreibt, richtet sie in dessen eigener Compose-Datei ein.
### 1. Dateien holen
@@ -90,10 +94,9 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
```
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, App-Umschalter – steht mit
Erklärung in derselben Datei, nach Abschnitten geordnet. Der letzte Abschnitt
gilt nur für den Entwicklungs-Compose; beim Betrieb mit eigenem
Office-Container stehen diese Werte in dessen Compose-Datei.
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, HoneyOS-Leiste – steht mit
Erklärung in derselben Datei, nach Abschnitten geordnet. Absichtlich
ausführlich: Sie ist auch die Übersicht darüber, was überhaupt geht.
### 3. Starten
@@ -232,12 +235,27 @@ selbst anlegst (PocketID, Authentik); bei freier Registrierung wäre es die
Übernahme fremder Konten. Verbunden wird ohnehin nur, was noch keine Bindung
hat.
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Die Vorgabe
`127.0.0.1` heißt: **nur dieser Rechner.** Von einem anderen Gerät kommt dann
kein Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt
ab, bevor irgendetwas die Anwendung erreicht. Das ist Absicht: Davor gehört
dein Reverse Proxy mit TLS. Zum Ausprobieren im eigenen Netz `APP_BIND=0.0.0.0`
setzen – dann geht die Anmeldung allerdings unverschlüsselt über das Netz.
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Der richtige Wert
hängt davon ab, **wo dein Reverse Proxy steht:**
| Proxy läuft … | Wert |
|---|---|
| auf einem anderen Rechner (VPS, Router) | `0.0.0.0` – die Vorgabe |
| hinter einem Tunnel (WireGuard, Tailscale) | die Tunnel-Adresse, z. B. `10.0.0.5` |
| auf demselben Rechner | `127.0.0.1` |
| als Container im selben Docker-Netz | dann braucht es `ports:` gar nicht – der Proxy erreicht `app:8080` intern |
`127.0.0.1` heißt **nur dieser Rechner**: Von einem anderen Gerät kommt kein
Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt ab,
bevor irgendetwas die Anwendung erreicht. Wer den Proxy daneben stehen hat,
sollte genau das nehmen.
Die Vorgabe ist trotzdem `0.0.0.0`, weil der häufigere Fall ein Proxy auf einem
anderen Rechner ist, den loopback gar nicht erst herankommen ließe. Was das
kostet, damit es niemanden überrascht: Der Port ist dann auch aus dem eigenen
Netz offen, und **TLS endet erst am Proxy** – auf dem Weg dorthin liegt die
Anmeldung unverschlüsselt im Netz. Wer das nicht will, engt den Wert ein oder
lässt nur den Proxy durch die Firewall.
**`DATA_PATH`** ist ein gewöhnliches Verzeichnis auf dem Wirt, kein
Docker-Volume – deshalb kommst du per SFTP, rsync oder im Dateimanager daran.
@@ -489,6 +507,134 @@ hunderte pro Minute.
---
## Dateien von außen: Abgleich mit der Datenbank
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
und löscht sie.
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
| Fall | Was der Abgleich tut |
|---|---|
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
Papierkorb – es ist endgültig.
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
eigenen Datei.
### Für Sicherungen heißt das
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
auf Wunsch weg.
### Der große Fall: alles zurück, ohne Postgres
Einzelne Dateien zurückzuholen ist das eine. Der Ernstfall ist der andere:
Der Server ist fort, aus der Sicherung kommen nur die **Dateien** zurück, die
Datenbank ist leer.
Das geht – aber nur in dieser Reihenfolge, und der zweite Schritt ist der,
den man vergisst:
1. **Dateien zurücklegen** nach `DATA_PATH`, mit der Struktur aus der
Sicherung (`users/<konto>/home/…`). Aether starten.
2. **Konten neu anlegen – mit exakt denselben Benutzernamen.** Der Ordnername
auf der Platte *ist* die Zuordnung; ein Konto `mueller` findet nichts, was
unter `users/Mueller/` liegt. Passwörter dürfen neu sein, Namen nicht.
3. **Admin → Audit → Rescan storage**, erst *Check*, dann *Apply*.
Ohne Schritt 2 bleibt der Abgleich stehen und sagt es je Datei:
`Konto "altnutzer" gibt es nicht`. Er rät nicht und legt nichts an – ein
falsch zugeordnetes Dokument wäre schlimmer als ein unsichtbares.
**Einmal geprobt, nicht behauptet** (10.09.2026): Auf einer eigens
aufgesetzten Instanz Dateien gesichert, Container *und* Datenbank
weggeworfen, nur die Dateien zurückgeholt. Nach den drei Schritten standen
Ordnerkette, Unterordner, Umlaute im Dateinamen, Größen, Prüfsummen und
Dateitypen wieder da, und der Download lieferte den ursprünglichen Inhalt.
`.trash/` und `.versions/` blieben zu Recht außen vor. Ein dritter Abgleich
meldete nichts Offenes mehr.
**Was dabei nicht zurückkommt**, weil es nur in der Datenbank steht:
Etiketten, Absender, Belegdatum, Freigabelinks, frühere Fassungen, der
erkannte Text der Volltextsuche (er wird neu erzeugt), Kalender und Termine.
Wem das wehtut, der sichert Postgres mit – die Dateien allein sind die
Rückfallebene, nicht der volle Umfang.
### Aktualisieren einer laufenden Instanz
Neues Abbild ziehen und neu starten, mehr nicht: Die Migrationen laufen beim
Start selbst. Geprobt am 10.09.2026 von Schema 19 auf 23 mit Daten in der
Datenbank – Konten, Ordner, Dateien und Etikettenzuordnungen standen danach
unverändert da, kein `dirty`-Zustand. **Ein Neuaufbau ist dafür nicht nötig
und bringt nichts**, was das Update nicht auch bringt.
---
## Kalender (CalDAV)
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
automatische Einrichtung über `/.well-known/caldav` laufen lassen, die jeder
verbreitete Client von selbst versucht.
Angemeldet wird sich wie bei WebDAV mit **Benutzername und App-Passwort**; das
Kontopasswort wird auch hier abgewiesen. Dieselbe Sperre nach zehn
Fehlanmeldungen gilt ebenfalls.
Was geht:
- **Termine** lesen und schreiben, mit Wiederholungen, Ausnahmen einzelner
Termine (`EXDATE`), Ganztagsterminen und Zeitzonen.
- **Mehrere Kalender**, jeder mit Name und Farbe. Anlegen, umbenennen,
umfärben und löschen geht in der Weboberfläche **und** vom Client aus
(`MKCALENDAR`, `MKCOL`, `PROPPATCH`, `DELETE`).
- **Termin in einen anderen Kalender umhängen**, ohne dass er dabei seine
Kennung verliert – für Clients derselbe Termin an neuer Adresse, kein neuer.
- **Farben** in beide Richtungen: die des Kalenders (`calendar-color`) und die
einzelner Termine (`COLOR` nach RFC 7986, als CSS3-Name, weil DAVx5 sie so
liest).
- **Im- und Export** ganzer Kalender als `.ics`.
Zwei Dinge, die man beim Einrichten wissen sollte:
**Die Kalenderfarbe holt DAVx5 nur beim Erfassen der Sammlungen**, nicht bei
jedem Abgleich. Ändert sich die Farbe hier, bleibt sie im Telefon so lange
alt, bis man dort einmal *Kalenderliste aktualisieren* (⟳) antippt. Das ist
kein Fehler, sondern DAVx5' Sparsamkeit – kostet aber eine halbe Stunde
Suche, wenn man es nicht weiß.
**Kalender verwalten kann die Kalender-App des Telefons nicht.** Android
überlässt das der Sync-App: Anlegen und Löschen geschieht in DAVx5 selbst,
nicht in der Samsung- oder Google-Kalender-App.
Noch nicht da: `sync-collection` (RFC 6578). Jeder Abgleich holt deshalb die
volle Terminliste statt nur der Änderungen – richtig, aber sparsamer ginge es.
Ebenso offen: Aufgaben (`VTODO`) in der Weboberfläche und das Teilen von
Kalendern mit anderen Nutzern.
Zwei Selbsttests decken den Bereich ab, einer je Zugangsweg – siehe
[`TESTING.md`](TESTING.md).
---
## Office (Collabora / OnlyOffice / Euro-Office)
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
@@ -519,6 +665,13 @@ Container heraus er selbst.
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
OnlyOffice und Euro-Office auf 80.**
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
> den Proxy die öffentliche Adresse bedienen.
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
@@ -555,7 +708,7 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
cap_add: [MKNOD]
networks: [internal]
ports:
- "${APP_BIND:-127.0.0.1}:9980:9980"
- "${APP_BIND:-0.0.0.0}:9980:9980"
```
```env
@@ -603,7 +756,7 @@ OFFICE_ALLOWED_HOSTS=office
JWT_SECRET: ${OFFICE_JWT_SECRET}
networks: [internal]
ports:
- "${APP_BIND:-127.0.0.1}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
- "${APP_BIND:-0.0.0.0}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
```
```env
@@ -635,23 +788,34 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
ALLOW_PRIVATE_IP_ADDRESS: "true" # es muss app:8080 erreichen
networks: [internal]
ports:
- "${APP_BIND:-127.0.0.1}:9980:80"
- "${APP_BIND:-0.0.0.0}:9980:80"
```
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das
Abbild.
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
> über die Ermittlung hinaus.
>
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
> nach Aktenlage hakt:
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
> betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
> Collabora daneben weiterlief.
>
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
> Fehler meldet sich nicht, er zeigt nur nichts.
>
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
> an denen es nach Aktenlage hakt:
>
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
@@ -661,53 +825,14 @@ Abbild.
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
> das der Ort.
>
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
> gesetzt – sonst läuft die API offen.
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
was aufgeht:
| Anbieter | am Telefon |
|---|---|
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
eine Tastatur.
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
nicht.
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
entscheidet der Anbieter.
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
LibreOffice-Layout desselben Dokuments sehen.
| | |
|---|---|
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
Bereinigung, zusammen mit Vorschau- und Profilbildern.
> **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
> **Der Office-Container liest es**, und ohne steht seine API offen, wenn
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
### Welche Dateien, und wie gut
@@ -793,7 +918,7 @@ unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
| **Euro-Office** | wie OnlyOffice, Abbild **6,99 GB** (hier nachgemessen) | derselbe Unterbau |
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
@@ -808,7 +933,8 @@ kurzzeitig mehrere hundert Megabyte.
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
Arbeitsspeicher, und das Abbild wiegt rund 5 GB.
Arbeitsspeicher, und das Abbild wiegt **6,99 GB** – nachgemessen, nicht
geschätzt; die Ankündigung nennt weniger.
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
@@ -832,5 +958,6 @@ kostet einen Container und eine Zeile `.env`, nicht mehr.
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
| — | [Kalender](#kalender-caldav): CalDAV, mehrere Kalender, Weboberfläche (Monat/Woche/Liste) – `sync-collection`, Aufgaben und Teilen offen | ✅ (im Test) |
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).
+1036 -2
View File
File diff suppressed because it is too large. Load diff
+103 -23
View File
@@ -26,16 +26,19 @@ import (
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/avatar"
"aether/internal/calendar"
"aether/internal/documents"
"aether/internal/files"
"aether/internal/gc"
"aether/internal/office"
appoidc "aether/internal/oidc"
"aether/internal/rescan"
"aether/internal/sharing"
"aether/internal/storage"
"aether/internal/tags"
"aether/internal/thumbnails"
appwebdav "aether/internal/webdav"
"aether/pkg/bg"
"aether/pkg/config"
appdb "aether/pkg/db"
"aether/pkg/middleware"
@@ -68,6 +71,7 @@ func spaHandler(root string) http.Handler {
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
setStaticCaching(w, upath)
fs.ServeHTTP(w, r)
return
}
@@ -80,10 +84,37 @@ func spaHandler(root string) http.Handler {
http.NotFound(w, r)
return
}
setStaticCaching(w, "/index.html")
http.ServeFile(w, r, index)
})
}
// setStaticCaching entscheidet, was der Browser behalten darf.
//
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
// als rätselhafter Fehler mitten in der Anwendung.
//
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
//
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
// ändert sich der Name. Solche Dateien darf der Browser für immer
// behalten – "immutable" spart ihm sogar die Rückfrage.
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
// der Server dann meist nur mit 304 und ein paar Byte.
func setStaticCaching(w http.ResponseWriter, upath string) {
if strings.HasPrefix(upath, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
func main() {
cfg, err := config.Load()
if err != nil {
@@ -96,6 +127,20 @@ func main() {
}
zerolog.SetGlobalLevel(zerolog.InfoLevel)
// **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.**
//
// zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext
// keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher
// Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein
// Blob verwaist ist, und die Meldung über eine beschädigte Datei beim
// Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos:
// Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem
// man den Fehler herbeiführt, den er melden soll.
//
// Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue
// Logeintrag blieb aus, obwohl der Abbruch funktionierte.
zerolog.DefaultContextLogger = &log.Logger
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
ctx := context.Background()
@@ -146,13 +191,16 @@ func main() {
Timeout: cfg.OCRTimeout,
TmpDir: cfg.TmpPath,
}, log.Logger)
go worker.Run(ctx)
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
// Aufräumen der Zwischendateien beim Start.
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
// unauffindbar, was vorher hochgeladen wurde – und das ist am
// Anfang der gesamte Bestand.
go func() {
bg.Go("Bestand nachtragen", func() {
n, err := docStore.EnqueueMissing(ctx, 5000)
if err != nil {
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
@@ -175,7 +223,7 @@ func main() {
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
}
}()
})
}
}
@@ -188,12 +236,15 @@ func main() {
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
shareStore := sharing.NewStore(pool)
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
collector := gc.New(pool, blobStore)
adminStore := admin.NewStore(pool)
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
rescanner := rescan.New(pool, fileStore, blobStore)
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
@@ -216,7 +267,7 @@ func main() {
var officeHandler *office.Handler
if cfg.OfficeEnabled() {
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
if err != nil {
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
@@ -246,6 +297,11 @@ func main() {
// WebDAV
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
// CalDAV
calStore := calendar.NewStore(pool, aclStore)
calHandler := calendar.NewHandler(authStore, calStore)
calAPIHandler := calendar.NewAPIHandler(calStore)
// OIDC (optional – disabled if OIDC_ISSUER not set)
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
Issuer: cfg.OIDCIssuer,
@@ -306,7 +362,7 @@ func main() {
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"} {
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT", "MKCALENDAR"} {
chi.RegisterMethod(m)
}
@@ -352,6 +408,12 @@ func main() {
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
r.Mount("/dav", davHandler)
// CalDAV endpoint – eigene Basic Auth (App-Passwörter), wie /dav.
// .well-known/caldav liegt bewusst außerhalb von /caldav: RFC 6764 sieht
// diesen Pfad an der Wurzel vor, Clients wie DAVx5 fragen dort zuerst an.
r.Mount("/caldav", calHandler)
r.Get("/.well-known/caldav", calHandler.ServeWellKnown)
// Public config (no auth) – used by login page to discover OIDC availability
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
cloudName := "Aether"
@@ -374,16 +436,6 @@ func main() {
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
}
// Ohne Ursprung gibt es keine Leiste — dann hat auch der Schluessel
// nichts auf der Seite zu suchen.
honeybarKey := ""
if cfg.HoneybarOrigin != "" {
honeybarKey = cfg.HoneybarKey
}
apps := cfg.ExternalApps
if apps == nil {
apps = []config.ExternalApp{} // nie null – das Frontend erwartet eine Liste
}
response.JSON(w, http.StatusOK, map[string]any{
"oidc_enabled": oidcHandler.IsEnabled(),
"cloud_name": cloudName,
@@ -392,7 +444,6 @@ func main() {
"logo_url": logoURL,
"favicon_url": faviconURL,
"apple_touch_icon_url": appleTouchURL,
"external_apps": apps,
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
// erst anbietet, wenn es keinen Office-Server gibt.
"office_enabled": officeHandler.Enabled(),
@@ -419,7 +470,6 @@ func main() {
// .env umgezogen werden kann — und damit derselbe Ursprung
// gilt, den die CSP erlaubt.
"honeybar_origin": cfg.HoneybarOrigin,
"honeybar_key": honeybarKey,
})
})
@@ -454,6 +504,7 @@ func main() {
r.Get("/search", fileHandler.Search)
r.Get("/files/{id}/download", fileHandler.Download)
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
r.Get("/files/{id}/text", fileHandler.TextPages)
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
@@ -461,7 +512,6 @@ func main() {
r.Get("/office/session/{id}", officeHandler.Session)
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
// Telefon der Normalfall.
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
r.Get("/files/{id}/versions", fileHandler.ListVersions)
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
@@ -518,6 +568,10 @@ func main() {
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
// Kalender liegen in derselben Rechtetabelle – deshalb dieselben
// Endpunkte, und /acl/{id} widerruft alle drei Arten.
r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL)
r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL)
r.Delete("/acl/{id}", aclHandler.RevokeACL)
// App passwords (WebDAV/sync clients)
@@ -525,6 +579,21 @@ func main() {
// es steht ohnehin an Freigaben und in Nutzerlisten.
r.Get("/users/{id}/avatar", avatarHandler.Serve)
// Kalender – die Termine selbst laufen über CalDAV (/caldav/), das
// hier ist nur Verwaltung: mehrere Kalender, umbenennen, Farbe,
// löschen, sowie Export/Import als .ics.
r.Get("/calendars", calAPIHandler.List)
r.Post("/calendars", calAPIHandler.Create)
r.Patch("/calendars/{id}", calAPIHandler.Update)
r.Delete("/calendars/{id}", calAPIHandler.Delete)
r.Get("/calendars/{id}/export", calAPIHandler.Export)
r.Post("/calendars/{id}/import", calAPIHandler.Import)
r.Get("/calendars/{id}/events", calAPIHandler.ListEvents)
r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent)
r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent)
r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent)
r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent)
r.Get("/app-passwords", authHandler.ListAppPasswords)
r.Post("/app-passwords", authHandler.CreateAppPassword)
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
@@ -542,6 +611,7 @@ func main() {
r.Get("/admin/audit", adminHandler.ListAudit)
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
r.Post("/admin/gc", adminHandler.RunGC)
r.Post("/admin/rescan", adminHandler.Rescan)
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
// Platz, der in keinem Kontingent auftaucht.
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
@@ -581,6 +651,10 @@ func main() {
done := make(chan struct{})
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
// Sie berührt außerdem keine fremden Daten.
go func() {
<-quit
log.Info().Msg("shutting down...")
@@ -646,8 +720,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
return
}
// Generate thumbnail in background – non-fatal if it fails.
go func() {
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
//
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
// gewöhnlichen Upload.
bg.Go("Vorschaubild", func() {
ctx := context.Background()
f, err := fileStore.GetFileByID(ctx, created.ID)
if err != nil {
@@ -663,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
return
}
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
}()
})
}
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS cal_objects;
DROP TABLE IF EXISTS cal_calendars;
@@ -0,0 +1,52 @@
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
--
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS cal_calendars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
color TEXT NOT NULL DEFAULT '#4285f4',
description TEXT NOT NULL DEFAULT '',
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
-- einzelne Objekt abzufragen.
ctag BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cal_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
uid TEXT NOT NULL,
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
component TEXT NOT NULL,
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
data TEXT NOT NULL,
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
etag TEXT NOT NULL,
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
dtstart TIMESTAMPTZ,
dtend TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
@@ -0,0 +1 @@
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS timezone;
@@ -0,0 +1,7 @@
-- Standard-Zeitzone pro Kalender. Wird von CalDAV selbst nicht gebraucht
-- (jeder Termin trägt seine Zeitzone im .ics), sondern erst von der
-- künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3, siehe
-- kalender-plan.md) – ein neuer Termin braucht eine Vorgabe, wenn der
-- Nutzer keine eigene wählt. Jetzt schon angelegt, damit später keine
-- weitere Migration für ein einzelnes Feld nötig ist.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
@@ -0,0 +1,15 @@
-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus
-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer
-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen
-- und der Client bekäme auf seine eigene Adresse ein 404.
--
-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle
-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar,
-- unter der die eingerichteten Geräte sie schon kennen.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT;
-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe
-- Adresse haben, aber beliebig viele dürfen gar keine haben.
CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment
ON cal_calendars (owner_id, path_segment)
WHERE path_segment IS NOT NULL;
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS cal_tombstones;
DROP INDEX IF EXISTS cal_objects_sync;
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
@@ -0,0 +1,38 @@
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
--
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
--
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
-- bei dem es zuletzt angefasst wurde.
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
UPDATE cal_objects o SET sync_seq = c.ctag
FROM cal_calendars c WHERE c.id = o.calendar_id;
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
-- Art, weil er unsichtbar ist.
CREATE TABLE IF NOT EXISTS cal_tombstones (
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
sync_seq BIGINT NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_acl_calendar;
DROP INDEX IF EXISTS idx_acl_group_calendar;
DROP INDEX IF EXISTS idx_acl_user_calendar;
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
@@ -0,0 +1,31 @@
-- Kalender an andere Konten freigeben.
--
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
-- auf, wenn jemand sieht, was er nicht sehen soll.
--
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
ALTER TABLE acl_entries
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
-- mehrdeutig: welches Recht gilt dann?
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
ON acl_entries (subject_user_id, resource_calendar_id)
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
ON acl_entries (subject_group_id, resource_calendar_id)
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
CREATE INDEX IF NOT EXISTS idx_acl_calendar
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
@@ -0,0 +1,4 @@
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
-- „noch nichts gewandelt".
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
@@ -0,0 +1,11 @@
-- Die Vorschau als PDF ist entfallen (16.09.2026).
--
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
-- welcher Fassung das abgelegte PDF gehörte.
--
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,5 @@
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
UPDATE cal_calendars
SET name = 'Persönlich'
WHERE name = 'Personal'
AND color = '#4285f4';
@@ -0,0 +1,11 @@
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
-- zieht die bereits angelegten nach.
--
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
UPDATE cal_calendars
SET name = 'Personal'
WHERE name = 'Persönlich'
AND color = '#4285f4';
+7 -4
View File
@@ -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
View File
@@ -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=
+116
View File
@@ -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()
}
+57
View File
@@ -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
+50 -15
View File
@@ -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
}
+57
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"aether/internal/audit"
"aether/internal/rescan"
"aether/pkg/response"
)
@@ -118,3 +119,59 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
}
response.JSON(w, http.StatusOK, usage)
}
// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank
// abgleichen.
//
// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag
// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer
// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in
// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten.
//
// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas.
// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was
// passieren wird, kann es auch nicht verhindern.
func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
if h.rescan == nil {
response.Error(w, http.StatusServiceUnavailable, "rescan not available")
return
}
anwenden := r.URL.Query().Get("anwenden") == "true"
// Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen
// soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint
// aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen.
bestaetigt := r.URL.Query().Get("bestaetigt") == "true"
var (
bef *rescan.Report
err error
)
if anwenden {
bef, err = h.rescan.Apply(r.Context(), bestaetigt)
} else {
bef, err = h.rescan.Check(r.Context())
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "rescan failed")
return
}
if anwenden {
// Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein
// ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit
// dem Datenordner etwas nicht stimmt.
if bef.Aborted {
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
"rescan aborted, nothing removed: "+bef.AbortReason)
} else {
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
}
}
response.JSON(w, http.StatusOK, bef)
}
+4 -2
View File
@@ -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
+174
View File
@@ -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
}
+268
View File
@@ -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
}
+410
View File
@@ -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
}
+274
View File
@@ -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()
}
+111
View File
@@ -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)
}
+811
View File
@@ -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)
}
+163
View File
@@ -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)
}
+63
View File
@@ -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"`
}
+329
View File
@@ -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("&", "&amp;", "\"", "&quot;", "<", "&lt;", ">", "&gt;")
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
}
}
+121
View File
@@ -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)
}
}
+547
View File
@@ -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()
}
+230
View File
@@ -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 `&#34;`. 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) + `"`
}
+339
View File
@@ -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")
}
}
+8 -1
View File
@@ -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 {
+23 -17
View File
@@ -1,8 +1,6 @@
package files
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -486,20 +484,25 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
// Verify checksum while streaming.
h256 := sha256.New()
reader := io.TeeReader(rc, h256)
if _, err := io.Copy(w, reader); err != nil {
return // client disconnected
}
actual := hex.EncodeToString(h256.Sum(nil))
if actual != f.Checksum {
// Log corruption; headers already sent, can't change status.
// TODO: alert / observability hook
_ = actual
}
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
// zurückhalten**, bis sie stimmt.
//
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
// dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte
// wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv,
// in das man einmal im Jahr sieht, die teuerste Art von Stille.
//
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
// Verbindung noch fallen lassen, und der Browser sieht einen
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{
FileID: f.ID.String(),
StorageKey: f.StorageKey,
Size: f.Size,
})
}
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
@@ -762,7 +765,10 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
// auch wenn das Grundbild wechselt, und lässt sich testen.
var extraMIMETypes = map[string]string{
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
// Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit
// die Oberflaeche sie vom Texteditor fernhaelt.
".excalidraw": "application/json",
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
".wav": "audio/wav", ".flac": "audio/flac",
@@ -0,0 +1,72 @@
package files
import (
"net/http"
"github.com/rs/zerolog/log"
"aether/internal/thumbnails"
"aether/pkg/response"
)
// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein
// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und
// halten trotzdem jeden Unfug vom Dekodierer fern.
const maxThumbUpload = 2 << 20
// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen
// kann.
//
// POST /api/v1/files/{id}/thumbnail
//
// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst
// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort
// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung
// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann.
//
// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt,
// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch
// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren,
// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen.
func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
// Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie
// sie in der Liste aussieht.
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
typ := r.Header.Get("Content-Type")
if !thumbnails.SupportedMIME(typ) {
response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image")
return
}
// MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung
// ab, statt erst alles zu lesen und dann abzulehnen.
rc := http.MaxBytesReader(w, r.Body, maxThumbUpload)
defer rc.Close()
if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten")
response.Error(w, http.StatusBadRequest, "could not read that image")
return
}
if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.NoContent(w)
}
+23 -25
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -37,10 +38,12 @@ func scanFile(row interface{ Scan(...any) error }, f *File) error {
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
var out File
err := scanFile(s.db.QueryRow(ctx,
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
versioning_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+fileCols,
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
versioniertVonAnfang(f.Name),
), &out)
if err != nil {
if isUniqueViolation(err) {
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
return &out, nil
}
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
// behalten.
//
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
// unbemerkt voll.
//
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
// ohne Deckung.
func versioniertVonAnfang(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
}
// GetFileByID fetches a file without owner check (used by public share downloads).
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
var f File
@@ -537,29 +558,6 @@ func isUniqueViolation(err error) bool {
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
// entstanden ist – 0 heißt: es gibt keins.
//
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
// Liste und jede Suche mit.
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
var v int
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrNotFound
}
if err != nil {
return 0, fmt.Errorf("preview version: %w", err)
}
return v, nil
}
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
if err != nil {
return fmt.Errorf("set preview version: %w", err)
}
return nil
}
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
+100
View File
@@ -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")
}
}
+102
View File
@@ -0,0 +1,102 @@
package files
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
// Ausliefern mit Prüfung der SHA-256-Summe.
//
// Stand bis zum 25.09.2026 nur im angemeldeten Download. Der **öffentliche**
// Freigabelink lieferte ungeprüft aus – ausgerechnet der Weg, auf dem eine
// Datei das Haus verlässt. Wer sie angemeldet holt, ist der Eigentümer und
// merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht.
//
// In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen,
// **beim Download verifizieren**. Die Regel galt an einer von zwei Stellen.
// Deshalb liegt sie jetzt hier und wird von beiden gerufen.
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
//
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
const verifyTail = 32 * 1024
// VerifyInfo ist nur für den Logeintrag – wer hinterher sucht, braucht die
// Datei und ihren Platz auf der Platte, nicht bloß „stimmt nicht".
type VerifyInfo struct {
FileID string
StorageKey string
Size int64
}
// ServeVerified kopiert `src` nach `w` und prüft dabei die Summe.
//
// Stimmt sie nicht, wird der letzte Rest **nicht** herausgegeben und die
// Verbindung fallen gelassen. Die Kopfzeilen sind da längst draußen, der
// Statuscode also nicht mehr zu ändern – aber die Gegenstelle bekommt weniger
// Bytes als im Content-Length angekündigt und wertet das als Fehler. Besser
// ein abgebrochener Download als eine heile Datei mit falschem Inhalt.
func ServeVerified(ctx context.Context, w io.Writer, src io.Reader, erwartet string, info VerifyInfo) {
h256 := sha256.New()
zurueck := &tailWriter{w: w, keep: verifyTail}
if _, err := io.Copy(zurueck, io.TeeReader(src, h256)); err != nil {
return // Gegenstelle ist weg
}
gelesen := hex.EncodeToString(h256.Sum(nil))
if gelesen != erwartet {
log.Ctx(ctx).Error().
Str("file_id", info.FileID).
Str("storage_key", info.StorageKey).
Str("erwartet", erwartet).
Str("gelesen", gelesen).
Int64("size", info.Size).
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
// fallen zu lassen: net/http schließt die Verbindung und schreibt
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
// Panik ausdrücklich durch).
panic(http.ErrAbortHandler)
}
// Stimmt sie, darf der Rest hinterher.
_ = zurueck.Flush()
}
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
type tailWriter struct {
w io.Writer
keep int
buf []byte
}
func (t *tailWriter) Write(p []byte) (int, error) {
t.buf = append(t.buf, p...)
if ueber := len(t.buf) - t.keep; ueber > 0 {
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
return 0, err
}
t.buf = append(t.buf[:0], t.buf[ueber:]...)
}
return len(p), nil
}
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
// bleibt die Antwort unvollständig – genau das ist der Zweck.
func (t *tailWriter) Flush() error {
if len(t.buf) == 0 {
return nil
}
_, err := t.w.Write(t.buf)
t.buf = t.buf[:0]
return err
}
+10 -23
View File
@@ -24,6 +24,7 @@ import (
"github.com/rs/zerolog/log"
"aether/internal/storage"
"aether/pkg/bg"
)
const (
@@ -72,6 +73,11 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
// "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die
// als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und
// die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so
// sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste,
// bleiben sie für immer liegen.
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
res.Scanned++
@@ -153,27 +159,6 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
return nil, fmt.Errorf("iterate file keys: %w", err)
}
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
prevRows, err := c.db.Query(ctx,
`SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("query preview keys: %w", err)
}
for prevRows.Next() {
var id string
if err := prevRows.Scan(&id); err != nil {
prevRows.Close()
return nil, fmt.Errorf("scan preview key: %w", err)
}
keys["previews/"+id+".pdf"] = true
}
prevRows.Close()
if err := prevRows.Err(); err != nil {
return nil, fmt.Errorf("iterate preview keys: %w", err)
}
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
@@ -254,7 +239,9 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
// Hochfahren konkurriert.
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
go func() {
// Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein
// Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll.
bg.Go("Speicherbereinigung", func() {
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()
@@ -282,7 +269,7 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
}
timer.Reset(Interval)
}
}()
})
}
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
-271
View File
@@ -1,271 +0,0 @@
package office
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// Ein Dokument nach PDF wandeln – dafür, es *anzusehen*, statt es zu
// bearbeiten.
//
// **Warum überhaupt:** Am Telefon ist der Office-Editor unbrauchbar. Er ist
// eine vollständige Textverarbeitung; Menüband, Formatleisten und
// Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt. Auf 400 Pixeln
// bleibt für das Dokument übrig, was die Leisten nicht brauchen. Das gilt für
// Collabora wie für OnlyOffice – Marc hat beide ausprobiert, mit demselben
// Ergebnis. Ein PDF im eigenen Betrachter ist für „kurz nachsehen" das
// bessere Werkzeug, und für „kurz nachsehen" ist man unterwegs da.
//
// **Warum derselbe Server, der auch bearbeitet:** Wer OnlyOffice wegen der
// Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe,
// sähe unterwegs ein LibreOffice-Layout desselben Dokuments. Zwei Geräte,
// zwei Umbrüche – ausgerechnet bei Unterlagen, bei denen es auf die Seite
// ankommt. Die Umwandlung gehört deshalb zu dem Anbieter, der auch bearbeitet.
//
// Die beiden Anbieter gehen dabei verschiedene Wege, und der Unterschied ist
// grundsätzlich:
//
// - **Collabora** nimmt die Datei entgegen und antwortet mit dem PDF. Ein
// Aufruf, keine Anmeldung.
// - **OnlyOffice** nimmt eine *Adresse* entgegen, holt die Datei selbst ab
// und antwortet mit einer Adresse, unter der das Ergebnis liegt. Also
// zwei Aufrufe, eine Signatur, und ein zweiter Ort, dem man nicht trauen
// darf (siehe unten).
// convertTimeout: So lange darf eine Umwandlung dauern.
//
// Gemessen an dieser Anlage braucht ein gewöhnliches Textdokument etwa eine
// Sekunde. Eine Minute ist keine Erwartung, sondern eine Notbremse: Hängt der
// Office-Server, soll die Anfrage enden und nicht der Server mit ihr.
const convertTimeout = 60 * time.Second
// jwtLifetime: Wie lange die Signatur an OnlyOffice gilt. Sie wird für genau
// einen Aufruf ausgestellt.
const jwtLifetime = 2 * time.Minute
// ConvertToPDF wandelt ein Dokument um.
//
// `src` ist die Adresse, unter der der Office-Server die Datei abholen kann –
// nur OnlyOffice braucht sie. `body` sind die Bytes selbst – nur Collabora
// braucht die. Der Aufrufer stellt beides bereit; welcher Weg genommen wird,
// entscheidet der Anbieter, nicht der Aufrufer.
func (c *Client) ConvertToPDF(ctx context.Context, ext, key, name, src string, body io.Reader) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
defer cancel()
switch c.provider {
case "collabora":
return c.convertCollabora(ctx, name, body)
case "onlyoffice":
return c.convertOnlyOffice(ctx, ext, key, name, src)
}
return nil, fmt.Errorf("office: provider %q cannot convert", c.provider)
}
// CanConvert sagt, ob dieser Anbieter das Wandeln überhaupt anbietet. Für die
// Oberfläche: Ohne das gäbe es einen Knopf, der nur Fehler erzeugt.
func (c *Client) CanConvert() bool {
return c != nil && (c.provider == "collabora" || c.provider == "onlyoffice")
}
// ── Collabora ────────────────────────────────────────────────────────────────
func (c *Client) convertCollabora(ctx context.Context, name string, body io.Reader) ([]byte, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// Der Feldname ist vorgeschrieben („data"), der Dateiname bestimmt, wie
// Collabora den Typ erkennt – ohne Endung rät es und liegt daneben.
part, err := mw.CreateFormFile("data", name)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if _, err := io.Copy(part, body); err != nil {
return nil, fmt.Errorf("read source: %w", err)
}
if err := mw.Close(); err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/cool/convert-to/pdf", &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("convert: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("convert: office server answered %d", resp.StatusCode)
}
// Collabora antwortet mit dem PDF selbst, nicht mit einer Auskunft.
return readPDF(resp.Body)
}
// ── OnlyOffice / Euro-Office ────────────────────────────────────────────────
type convertResponse struct {
FileURL string `json:"fileUrl"`
FileType string `json:"fileType"`
Percent int `json:"percent"`
EndConvert bool `json:"endConvert"`
// Error ist eine Zahl, keine Meldung: -8 heißt „Signatur fehlt oder ist
// falsch", -4 „Quelle nicht erreichbar". Die Zahl steht deshalb im
// Fehlertext, sonst sucht man im Log des anderen Servers.
Error int `json:"error"`
}
func (c *Client) convertOnlyOffice(ctx context.Context, ext, key, name, src string) ([]byte, error) {
if src == "" {
return nil, fmt.Errorf("convert: no source address for the document server")
}
payload := map[string]any{
// Synchron: Die Umwandlung dauert Sekunden, und ein zweiter Aufruf zum
// Nachfragen wäre ein Zustand mehr, den jemand verwalten muss.
"async": false,
"filetype": strings.TrimPrefix(ext, "."),
"key": key,
"outputtype": "pdf",
"title": name,
"url": src,
}
if c.jwtSecret != "" {
// Die Signatur trägt genau die Nutzlast, die auch gesendet wird –
// gemessen: ohne sie antwortet der Server `{"error":-8}`.
payload["token"] = signJWT(c.jwtSecret, payload)
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/ConvertService.ashx", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Ohne diesen Kopf antwortet der Server in XML.
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("convert: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("convert: document server answered %d", resp.StatusCode)
}
var out convertResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
return nil, fmt.Errorf("convert: unreadable answer: %w", err)
}
if out.Error != 0 {
return nil, fmt.Errorf("convert: document server error %d", out.Error)
}
if !out.EndConvert || out.FileURL == "" {
return nil, fmt.Errorf("convert: document server did not finish (%d%%)", out.Percent)
}
// **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** Sie kommt aus
// der Antwort eines anderen Servers, und wir würden sie gleich abrufen –
// das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf
// 169.254.169.254 würde. Der Wähler des Transports lässt ohnehin nur
// Adressen aus OFFICE_ALLOWED_HOSTS zu; hier steht die Prüfung trotzdem
// noch einmal ausdrücklich, weil eine klare Meldung mehr wert ist als ein
// abgewiesener Verbindungsversuch tief im Netzstapel.
if err := c.sameHostAsServer(out.FileURL); err != nil {
return nil, err
}
get, err := http.NewRequestWithContext(ctx, http.MethodGet, out.FileURL, nil)
if err != nil {
return nil, err
}
res2, err := c.http.Do(get)
if err != nil {
return nil, fmt.Errorf("fetch result: %w", err)
}
defer res2.Body.Close()
if res2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch result: answered %d", res2.StatusCode)
}
return readPDF(res2.Body)
}
// sameHostAsServer prüft, dass eine Adresse auf denselben Rechner zeigt wie
// der Office-Server selbst.
func (c *Client) sameHostAsServer(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("convert: unreadable result address")
}
base, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("convert: OFFICE_URL is not a valid address")
}
if !strings.EqualFold(u.Hostname(), base.Hostname()) {
return fmt.Errorf("convert: result address points elsewhere (%q), refused", u.Hostname())
}
return nil
}
// ── gemeinsam ────────────────────────────────────────────────────────────────
// maxPDFBytes begrenzt, was als Ergebnis angenommen wird. Ohne Grenze
// bestimmte der andere Server, wie viel Speicher wir belegen.
const maxPDFBytes = 256 << 20
func readPDF(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxPDFBytes+1))
if err != nil {
return nil, fmt.Errorf("read result: %w", err)
}
if len(data) > maxPDFBytes {
return nil, fmt.Errorf("read result: converted file is too large")
}
// Kein Vertrauen auf den Content-Type: Collabora liefert
// `application/octet-stream`. Die ersten Bytes sind die Auskunft, die
// zählt – und sie verhindert, dass eine Fehlerseite als PDF abgelegt wird.
if !bytes.HasPrefix(data, []byte("%PDF-")) {
return nil, fmt.Errorf("read result: answer is not a PDF")
}
return data, nil
}
// signJWT baut ein HS256-Token über die Nutzlast.
//
// Von Hand statt mit einer Bibliothek: Es sind zwölf Zeilen, und die einzige
// Alternative wäre eine weitere Abhängigkeit für einen einzigen Aufruf.
func signJWT(secret string, payload map[string]any) string {
claims := make(map[string]any, len(payload)+1)
for k, v := range payload {
claims[k] = v
}
claims["exp"] = time.Now().Add(jwtLifetime).Unix()
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
head := enc(map[string]string{"alg": "HS256", "typ": "JWT"})
body := enc(claims)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(head + "." + body))
return head + "." + body + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
-122
View File
@@ -1,122 +0,0 @@
package office
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
func TestSignJWTIsVerifiable(t *testing.T) {
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts[0] + "." + parts[1]))
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if parts[2] != want {
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
}
head, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("Kopf nicht dekodierbar: %v", err)
}
var h map[string]string
if err := json.Unmarshal(head, &h); err != nil {
t.Fatalf("Kopf ist kein JSON: %v", err)
}
if h["alg"] != "HS256" {
t.Errorf("alg = %q, erwartet HS256", h["alg"])
}
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(body, &claims); err != nil {
t.Fatalf("Nutzlast ist kein JSON: %v", err)
}
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
}
// Ohne Ablauf gälte ein abgefangenes Token für immer.
if _, ok := claims["exp"]; !ok {
t.Error("exp fehlt – das Token liefe nie ab")
}
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
if _, ok := claims["token"]; ok {
t.Error("die Signatur signiert sich selbst mit")
}
}
func TestReadPDFRejectsNonPDF(t *testing.T) {
cases := map[string]string{
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
"leer": "",
"JSON-Fehler": `{"error":-4}`,
"fast ein PDF": "%PD",
}
for name, body := range cases {
if _, err := readPDF(strings.NewReader(body)); err == nil {
t.Errorf("%s wurde als PDF angenommen", name)
}
}
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
t.Errorf("echtes PDF abgelehnt: %v", err)
}
}
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
c := &Client{baseURL: "http://office:80"}
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
foreign := []string{
"http://169.254.169.254/latest/meta-data/",
"http://localhost:8080/api/v1/admin/users",
"http://office.evil.example.com/x.pdf",
"file:///etc/passwd",
"://kaputt",
}
for _, raw := range foreign {
if err := c.sameHostAsServer(raw); err == nil {
t.Errorf("fremde Adresse angenommen: %s", raw)
}
}
for _, ok := range []string{
"http://office/cache/files/x.pdf",
"http://office:80/cache/files/x.pdf",
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
} {
if err := c.sameHostAsServer(ok); err != nil {
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
}
}
}
func TestSanitizeFilename(t *testing.T) {
cases := map[string]string{
`Rechnung "2026".docx`: "Rechnung 2026",
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
".odt": ".odt",
"": "document",
}
for in, want := range cases {
if got := sanitizeFilename(in); got != want {
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
}
}
}
+22 -8
View File
@@ -48,10 +48,7 @@ type Client struct {
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
// keinem Browser.
publicBase string
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
jwtSecret string
http *http.Client
http *http.Client
mu sync.Mutex
actions map[string]string // ".docx|edit" -> urlsrc
@@ -65,7 +62,7 @@ type Client struct {
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
@@ -99,7 +96,6 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
provider: provider,
baseURL: strings.TrimRight(baseURL, "/"),
publicBase: strings.TrimRight(publicURL, "/"),
jwtSecret: jwtSecret,
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
}, nil
}
@@ -128,15 +124,33 @@ func (c *Client) Provider() string { return c.provider }
//
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
// anbietet – lesen ist besser als eine Fehlermeldung.
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
if err := c.refresh(ctx); err != nil {
return "", err
}
c.mu.Lock()
defer c.mu.Unlock()
// Am Finger zuerst die Handy-Fassung.
//
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
// Schirme gebaute Oberfläche. Aether hat bisher immer die
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
// wir selbst falsch bestellt hatten.
//
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
// Eintrag, und es bleibt beim bisherigen Verhalten.
order := []string{"edit", "view"}
if !write {
switch {
case mobile && write:
order = []string{"mobileEdit", "edit", "mobileView", "view"}
case mobile:
order = []string{"mobileView", "view", "mobileEdit", "edit"}
case !write:
order = []string{"view", "edit"}
}
for _, action := range order {
+6 -1
View File
@@ -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")
-229
View File
@@ -1,229 +0,0 @@
package office
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/auth"
"aether/internal/files"
"aether/pkg/response"
)
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
//
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
// bleibt einen Knopfdruck entfernt.
//
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
//
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
// Frage des Aufräumens, sondern des Schlüssels.
//
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
//
// ── Was das für die Platte heißt ────────────────────────────────────────────
//
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
// den Ordner – beides ist nötig, und beides einzeln geprüft.
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
// ebenfalls kennen müssen.
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
// PreviewPrefix ist der Ordner, den der GC durchgeht.
const PreviewPrefix = "previews"
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
//
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
const maxPreviewSource = 100 << 20
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
//
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
// Umwandlung, nicht die Richtigkeit.
var previewLocks sync.Map // fileID -> *sync.Mutex
func lockPreview(id string) func() {
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
mu := m.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// GET /api/v1/files/{id}/preview.pdf
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() || !h.client.CanConvert() {
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
return
}
f, userID, ok := h.resolveSession(w, r)
if !ok {
return
}
if !Editable(f.Name) {
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
return
}
if f.Size > maxPreviewSource {
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
// Editor anbieten soll oder einen Fehler zeigt.
response.Error(w, http.StatusRequestEntityTooLarge,
"this document is too large to be shown as a PDF")
return
}
unlock := lockPreview(f.ID.String())
defer unlock()
key := PreviewKey(f.ID.String())
have, err := h.files.PreviewVersion(r.Context(), f.ID)
if err != nil && !errors.Is(err, files.ErrNotFound) {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if have == f.Version {
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
defer rc.Close()
servePDF(w, f.Name, f.Version)
_, _ = io.Copy(w, rc)
return
}
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
// sie neu zu erzeugen.
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
}
pdf, err := h.convert(r.Context(), f, userID)
if err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("office preview: Umwandlung fehlgeschlagen")
response.Error(w, http.StatusBadGateway, "the document could not be converted")
return
}
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
// ohne es zu merken.
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
}
servePDF(w, f.Name, f.Version)
_, _ = w.Write(pdf)
}
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
// versteht.
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
ext := Extension(f.Name)
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
// alte Dokument aus.
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
switch h.client.Provider() {
case "collabora":
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
// Erreichbarkeit – und ein Token mehr in der Welt.
rc, err := h.blobs.Get(ctx, f.StorageKey)
if err != nil {
return nil, fmt.Errorf("read source: %w", err)
}
defer rc.Close()
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
default:
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
token, _ := h.signer.Issue(f.ID, userID, false)
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
h.wopiURL, f.ID, token)
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
}
}
func servePDF(w http.ResponseWriter, name string, version int) {
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("X-Content-Type-Options", "nosniff")
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
w.Header().Set("Content-Disposition",
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
// der Browser holt es nach einer Änderung also von selbst neu.
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
}
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
func sanitizeFilename(name string) string {
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
if i := strings.LastIndexByte(name, '.'); i > 0 {
name = name[:i]
}
if name == "" {
name = "document"
}
return name
}
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
//
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
// Token für genau eine Datei.
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return nil, uuid.Nil, false
}
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
// Datei gibt.
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
return f, user.ID, true
}
+235
View File
@@ -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 = &copy
}
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
}
+164
View File
@@ -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)
}
}
+334
View File
@@ -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
}
+118 -10
View File
@@ -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
}
+251
View File
@@ -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)
}
}
+319
View File
@@ -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))
}
}
+53
View File
@@ -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()
}
+45
View File
@@ -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)
}
}
-61
View File
@@ -40,10 +40,6 @@ type Config struct {
AdminUsername string
AdminPassword string
// ExternalApps erscheinen im App-Umschalter der Oberfläche. Kommt aus der
// Umgebung, damit neue Apps ohne Neubau des Frontends dazukommen können.
ExternalApps []ExternalApp
// OCR – Volltextsuche über den Inhalt von PDFs und Bildern.
// Standardmäßig an; fehlen die Programme im Image, schaltet sich der
// Arbeiter beim Start selbst ab, statt den Start zu verhindern.
@@ -89,15 +85,6 @@ type Config struct {
//
// Leer heißt: keine Leiste, und die CSP bleibt so eng wie bisher.
HoneybarOrigin string
// HoneybarKey ist der Ausweis, mit dem sich die Leiste bei HoneyOS die
// App-Liste holt (dort HONEYOS_APPS_KEY). Nötig, wenn HoneyOS auf einer
// anderen Site liegt als Aether: dann schickt der Browser die
// HoneyOS-Sitzung nicht mit.
//
// Er steht anschließend im Quelltext der Seite — das ist unvermeidlich,
// die Leiste läuft im Browser. Er gibt nur die App-Liste her, nichts
// Schreibendes.
HoneybarKey string
// OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether
// erreicht.
//
@@ -113,11 +100,6 @@ type Config struct {
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
OfficeInsecureTLS bool
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
OfficeJWTSecret string
}
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
@@ -138,45 +120,6 @@ func (c *Config) OfficeEnabled() bool {
return (c.OfficeProvider == "collabora" || c.OfficeProvider == "onlyoffice") && c.OfficeURL != ""
}
// ExternalApp ist ein Eintrag im App-Umschalter.
type ExternalApp struct {
Name string `json:"name"`
URL string `json:"url"`
Icon string `json:"icon"` // ein Emoji, optional
}
// parseExternalApps liest EXTERNAL_APPS im Format
//
// Name|https://url|Emoji , Name|https://url|Emoji
//
// Einträge ohne Namen oder URL werden übersprungen, damit ein Tippfehler in der
// .env nicht den Start verhindert.
func parseExternalApps(raw string) []ExternalApp {
var apps []ExternalApp
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, "|")
if len(parts) < 2 {
continue
}
app := ExternalApp{
Name: strings.TrimSpace(parts[0]),
URL: strings.TrimSpace(parts[1]),
}
if len(parts) > 2 {
app.Icon = strings.TrimSpace(parts[2])
}
if app.Name == "" || app.URL == "" {
continue
}
apps = append(apps, app)
}
return apps
}
func Load() (*Config, error) {
cfg := &Config{
AppEnv: getEnv("APP_ENV", "development"),
@@ -198,16 +141,12 @@ func Load() (*Config, error) {
AdminUsername: getEnv("ADMIN_USERNAME", ""),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
ExternalApps: parseExternalApps(getEnv("EXTERNAL_APPS", "")),
OCREnabled: getEnv("OCR_ENABLED", "true") != "false",
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
HoneybarKey: getEnv("HONEYBAR_KEY", ""),
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true",
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS cal_objects;
DROP TABLE IF EXISTS cal_calendars;
@@ -0,0 +1,52 @@
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
--
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS cal_calendars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
color TEXT NOT NULL DEFAULT '#4285f4',
description TEXT NOT NULL DEFAULT '',
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
-- einzelne Objekt abzufragen.
ctag BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cal_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
uid TEXT NOT NULL,
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
component TEXT NOT NULL,
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
data TEXT NOT NULL,
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
etag TEXT NOT NULL,
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
dtstart TIMESTAMPTZ,
dtend TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
@@ -0,0 +1 @@
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS timezone;
@@ -0,0 +1,7 @@
-- Standard-Zeitzone pro Kalender. Wird von CalDAV selbst nicht gebraucht
-- (jeder Termin trägt seine Zeitzone im .ics), sondern erst von der
-- künftigen Web-Oberfläche zum Anlegen neuer Termine (Phase 3, siehe
-- kalender-plan.md) – ein neuer Termin braucht eine Vorgabe, wenn der
-- Nutzer keine eigene wählt. Jetzt schon angelegt, damit später keine
-- weitere Migration für ein einzelnes Feld nötig ist.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
@@ -0,0 +1,15 @@
-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus
-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer
-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen
-- und der Client bekäme auf seine eigene Adresse ein 404.
--
-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle
-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar,
-- unter der die eingerichteten Geräte sie schon kennen.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT;
-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe
-- Adresse haben, aber beliebig viele dürfen gar keine haben.
CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment
ON cal_calendars (owner_id, path_segment)
WHERE path_segment IS NOT NULL;
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS cal_tombstones;
DROP INDEX IF EXISTS cal_objects_sync;
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
@@ -0,0 +1,38 @@
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
--
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
--
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
-- bei dem es zuletzt angefasst wurde.
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
UPDATE cal_objects o SET sync_seq = c.ctag
FROM cal_calendars c WHERE c.id = o.calendar_id;
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
-- Art, weil er unsichtbar ist.
CREATE TABLE IF NOT EXISTS cal_tombstones (
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
sync_seq BIGINT NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_acl_calendar;
DROP INDEX IF EXISTS idx_acl_group_calendar;
DROP INDEX IF EXISTS idx_acl_user_calendar;
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
@@ -0,0 +1,31 @@
-- Kalender an andere Konten freigeben.
--
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
-- auf, wenn jemand sieht, was er nicht sehen soll.
--
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
ALTER TABLE acl_entries
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
-- mehrdeutig: welches Recht gilt dann?
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
ON acl_entries (subject_user_id, resource_calendar_id)
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
ON acl_entries (subject_group_id, resource_calendar_id)
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
CREATE INDEX IF NOT EXISTS idx_acl_calendar
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
@@ -0,0 +1,4 @@
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
-- „noch nichts gewandelt".
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
@@ -0,0 +1,11 @@
-- Die Vorschau als PDF ist entfallen (16.09.2026).
--
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
-- welcher Fassung das abgelegte PDF gehörte.
--
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,5 @@
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
UPDATE cal_calendars
SET name = 'Persönlich'
WHERE name = 'Personal'
AND color = '#4285f4';
@@ -0,0 +1,11 @@
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
-- zieht die bereits angelegten nach.
--
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
UPDATE cal_calendars
SET name = 'Personal'
WHERE name = 'Persönlich'
AND color = '#4285f4';
+13
View File
@@ -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
+55
View File
@@ -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())
}
}
+26 -1
View File
@@ -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) {
+68
View File
@@ -0,0 +1,68 @@
package response
import (
"encoding/json"
"net/http/httptest"
"testing"
)
// Eine leere Liste muss als [] herauskommen, nicht als null. Jede
// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die
// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben.
func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) {
type eintrag struct {
Name string `json:"name"`
}
var leer []eintrag
w := httptest.NewRecorder()
JSON(w, 200, leer)
if got := w.Body.String(); !contains(got, `"data":[]`) {
t.Fatalf("erwartet \"data\":[], bekam %s", got)
}
}
func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) {
w := httptest.NewRecorder()
JSON(w, 200, []string{"a", "b"})
var out struct {
Data []string `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out.Data) != 2 || out.Data[0] != "a" {
t.Fatalf("Inhalt verändert: %v", out.Data)
}
}
// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst
// verwandelte die Regel jede fehlende Antwort in eine leere Liste.
func TestJSONObjekteBleibenObjekte(t *testing.T) {
w := httptest.NewRecorder()
JSON(w, 200, map[string]int{"n": 1})
if got := w.Body.String(); !contains(got, `"n":1`) {
t.Fatalf("Objekt verändert: %s", got)
}
w = httptest.NewRecorder()
JSON(w, 200, nil)
if got := w.Body.String(); contains(got, "[]") {
t.Fatalf("nil sollte keine Liste werden: %s", got)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+4 -1
View File
@@ -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
+41
View File
@@ -76,6 +76,16 @@ services:
profiles: ["office"]
restart: unless-stopped
environment:
# Wer seine Dateien in Collabora öffnen darf. Derselbe Host wie in
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
# anhängen – etwa OFFICE_ALIASGROUP=https://cloud\.example\.com:443.
# Fehlt er, lehnt Collabora ab: "No authorized hosts found matching the
# target host […] in config".
#
# Diese vier Werte (aliasgroup, ssl, ssl.termination, Port) stehen
# bewusst NICHT in .env.example: Die beschreibt, was die Anwendung liest.
# Hier konfigurierst du einen fremden Container – wer Office selbst
# betreibt, tut das in dessen eigener Compose-Datei.
aliasgroup1: ${OFFICE_ALIASGROUP:-http://app:8080}
# ssl.termination=true nur, wenn davor wirklich HTTPS endet – sonst
# verlangt Collabora eine wss://-Verbindung, die es nicht gibt, und der
@@ -92,6 +102,37 @@ services:
ports:
- "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980"
# Euro-Office – OnlyOffice-Fork (Nextcloud/IONOS, seit 09.06.2026). Für
# Aether ist es derselbe Anbieter wie OnlyOffice (`OFFICE_PROVIDER=onlyoffice`),
# nur ein anderes Abbild: Die WOPI-Schnittstelle und die Variablen heißen
# gleich.
#
# Eigenes Profil, damit es neben Collabora stehen kann, ohne sich den Port
# zu teilen: ... --profile euro up -d
#
# Rechne mit rund 5 GB fürs Abbild und 400 MB im Leerlauf.
euro:
container_name: aether-euro
image: ghcr.io/euro-office/documentserver:latest
profiles: ["euro"]
restart: unless-stopped
environment:
WOPI_ENABLED: "true"
# Ohne Signatur antwortet der Server mit {"error":-8} – und zwar erst
# im Editor, nicht schon bei der Ermittlung. Der Rahmen bleibt dann leer.
JWT_ENABLED: "true"
JWT_SECRET: ${OFFICE_JWT_SECRET}
# Die Beispiel-App gehört nicht ins Netz.
EXAMPLE_ENABLED: "false"
# Es muss app:8080 erreichen – eine private Adresse, die es sonst
# von sich aus ablehnt.
ALLOW_PRIVATE_IP_ADDRESS: "true"
networks: [internal]
ports:
# 9981, damit Collabora auf 9980 bleiben kann. Anders als dort ist der
# Port hier **80** im Container, nicht 9980.
- "${APP_BIND:-127.0.0.1}:${EURO_PORT:-9981}:80"
volumes:
postgres_data:
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
+6 -4
View File
@@ -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
+3598 -9
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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"
-1
View File
@@ -53,7 +53,6 @@ function AuthedApp() {
borderRadius: '50%',
animation: 'spin 0.7s linear infinite',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}
+244 -23
View File
@@ -90,13 +90,49 @@ export interface CreateShareRequest {
expires_in_hours?: number
}
/** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst
* nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden
* etwas an, der bloß den Link hat. */
export interface PublicShareInfo {
link: ShareLink
has_password: boolean
expires_at?: string | null
download_count: number
created_at: string
file_name: string
file_size: number
mime_type: string
}
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
export interface RescanEntry {
kind: 'new' | 'orphaned' | 'changed'
storage_key: string
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
path: string
owner: string
size: number
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
reason?: string
}
export interface RescanReport {
new: number
orphaned: number
changed: number
scanned: number
samples: RescanEntry[]
aborted?: boolean
/** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */
abort_reason?: string
/** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */
overridable?: boolean
/** Nur nach dem Anwenden gefüllt. */
adopted?: number
removed?: number
corrected?: number
errors?: number
}
export interface FileVersion {
id: string
file_id: string
@@ -153,12 +189,25 @@ async function request<T>(
}
// XHR-based upload with real progress reporting.
/**
* Ein vom Nutzer abgebrochener Upload – kein Fehler, sondern eine Absicht.
*
* Eigene Klasse, damit die Warteschlange die beiden unterscheiden kann: Ein
* Fehler bleibt als Meldung stehen, weil er eine Auskunft trägt. „Ich wollte
* das nicht" trägt keine und verschwindet von selbst.
*/
export class UploadAborted extends Error {
constructor() { super('Upload aborted') }
}
export function uploadWithProgress(
file: File,
folderId: string | undefined,
onProgress: (p: UploadProgress) => void
onProgress: (p: UploadProgress) => void,
signal?: AbortSignal,
): Promise<FileItem> {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new UploadAborted()); return }
const form = new FormData()
// folder_id MUST come before the file so the server can stream the file
// straight to storage without buffering the whole body first.
@@ -197,7 +246,17 @@ export function uploadWithProgress(
})
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
xhr.addEventListener('abort', () => reject(new UploadAborted()))
/**
* Abbrechen heißt hier: die Verbindung fallen lassen.
*
* Der Server merkt das mitten im Lesen. Er schreibt in eine Temp-Datei und
* benennt sie erst am Ende um – ein abgebrochener Upload hinterlässt
* deshalb weder Bytes noch eine Zeile in der Datenbank. Es braucht also
* keinen zweiten Ruf zum Aufräumen; das Aufräumen ist die Bauweise.
*/
signal?.addEventListener('abort', () => xhr.abort(), { once: true })
xhr.send(form)
})
@@ -205,12 +264,6 @@ export function uploadWithProgress(
// ── Auth ─────────────────────────────────────────────────────────────────────
export interface ExternalApp {
name: string
url: string
icon: string
}
export interface PublicConfig {
oidc_enabled: boolean
cloud_name: string
@@ -235,10 +288,6 @@ export interface PublicConfig {
accent_color?: string
/** Ursprung der HoneyOS-Leiste (HONEYBAR_ORIGIN). Leer = keine Leiste. */
honeybar_origin?: string
/** Ausweis fuer deren App-Liste (HONEYBAR_KEY). Nur gesetzt, wenn es einen
* Ursprung gibt. */
honeybar_key?: string
external_apps: ExternalApp[]
}
export const publicApi = {
@@ -310,12 +359,15 @@ export const aclApi = {
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
}
@@ -543,8 +595,21 @@ export interface OfficeSession {
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
* entschieden wird serverseitig.
*/
/**
* Endungen, die im Office-Server aufgehen sollen.
*
* **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es
* einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur
* hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server
* braucht und direkt speichert. Mit `txt` in dieser Liste lief eine
* 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht
* auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie.
*
* `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in
* einem Textfeld.
*/
const officeExtensions = new Set([
'odt', 'docx', 'doc', 'rtf', 'txt',
'odt', 'docx', 'doc', 'rtf',
'ods', 'xlsx', 'xls', 'csv',
'odp', 'pptx', 'ppt',
'odg',
@@ -556,8 +621,11 @@ export function isOfficeFile(name: string): boolean {
}
export const officeApi = {
session: (fileId: string) =>
request<OfficeSession>('GET', `/office/session/${fileId}`),
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
* anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie
* nicht und bleibt unverändert. */
session: (fileId: string, mobile = false) =>
request<OfficeSession>('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`),
}
export const ocrApi = {
@@ -604,15 +672,25 @@ export const filesApi = {
* mit den Fassungen 1, 2, 3.
*/
/**
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
*
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
* Vorschaubild.
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
*
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
*/
officePreview: (id: string, version?: number) =>
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
setThumbnail: async (id: string, bild: Blob) => {
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
body: bild,
})
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
},
thumbnail: (id: string, version?: number | string) =>
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
@@ -855,6 +933,12 @@ export const adminApi = {
derivedStorage: () =>
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
/** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde
* fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */
rescan: (apply = false, bestaetigt = false) =>
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`),
runCleanup: () => request<{
scanned: number; orphans: number; deleted: number
freed_bytes: number; skipped_young: number
@@ -894,4 +978,141 @@ export const adminApi = {
request<AppSettings>('PATCH', '/admin/settings', data),
}
// ── Calendar ─────────────────────────────────────────────────────────────
// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit
// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese
// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen
// Session, wie jeder andere Bereich.
export interface CalendarItem {
id: string
owner_id: string
name: string
color: string
description: string
supports_todo: boolean
timezone: string
ctag: number
created_at: string
/** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */
permission: 'read' | 'write'
/** Der Kalender gehört jemand anderem. */
shared: boolean
/** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne
* sichtbaren Absender ist ein Rätsel. */
owner_name?: string
/** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */
via_group?: string
}
/** Kurz: Darf ich diesen Kalender ändern? */
export function canEditCalendar(c: CalendarItem): boolean {
return c.permission !== 'read'
}
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
* sind dann nur lesbar (siehe events_api.go). */
export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
export interface Recurrence {
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
interval: number
count?: number
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
until?: string
/** Nur WEEKLY: an welchen Wochentagen. Leer = Wochentag des Starts. */
byday?: WeekdayCode[]
/** Nur MONTHLY/YEARLY: am festen Kalendertag (am 4.) oder am n-ten
* Wochentag (am 1. Freitag). */
mode?: 'DAY' | 'WEEKDAY'
/** Nur zum Anzeigen – der Server rechnet beides aus dem Startdatum aus. */
nth?: number
weekday?: WeekdayCode
}
export interface CalendarEvent {
uid: string
summary: string
description: string
location: string
/** ISO 8601, immer UTC ("...Z"). Bei ganztägigen Terminen Mitternacht UTC
* als reine Datumsangabe – nie über new Date(...) in Ortszeit umrechnen,
* sondern über eventDay() aus components/calendar/dates.ts. */
start: string
end?: string
all_day: boolean
/** Leer = Farbe des Kalenders. */
color?: string
/** Es hängt überhaupt eine Wiederholung dran. */
recurring: boolean
/** Die Regel – nur gesetzt, wenn sie sich im Formular abbilden lässt. */
recurrence?: Recurrence
/** Bei aufgeklappten Wiederholungen: Anfang/Ende der **Reihe**, während
* start/end den einzelnen Termin tragen. Das Formular bearbeitet immer
* die Reihe. */
series_start?: string
series_end?: string
etag: string
}
export interface EventInput {
summary: string
description?: string
location?: string
start: string
end?: string
all_day: boolean
color?: string
recurrence?: Recurrence | null
}
export const calendarApi = {
list: () => request<CalendarItem[]>('GET', '/calendars'),
create: (name: string, color: string, description = '') =>
request<CalendarItem>('POST', '/calendars', { name, color, description }),
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
import: async (id: string, file: File): Promise<{ imported: number }> => {
const headers: Record<string, string> = { 'Content-Type': 'text/calendar' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
const res = await fetch(`/api/v1/calendars/${id}/import`, {
method: 'POST', headers, credentials: 'include', body: file,
})
const json = await res.json().catch(() => ({ error: res.statusText }))
if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed')
return json.data
},
/** Termine im Zeitfenster. Wiederholungen kommen vom Server bereits in
* einzelne Termine aufgeklappt zurück – deshalb braucht es die Grenzen:
* eine tägliche Reihe ohne Ende hat sonst kein natürliches Ende. */
listEvents: (id: string, from?: Date, to?: Date) => {
const qs = from && to ? `?from=${from.toISOString()}&to=${to.toISOString()}` : ''
return request<CalendarEvent[]>('GET', `/calendars/${id}/events${qs}`)
},
createEvent: (id: string, input: EventInput) =>
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
updateEvent: (id: string, uid: string, input: EventInput) =>
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
/** Hängt einen Termin in einen anderen Kalender um – UID und Inhalt bleiben,
* nur die Sammlung wechselt. Bei einer Reihe zieht die ganze Reihe mit:
* Sie ist eine einzige Datei, kein Bündel von Einzelterminen. */
moveEvent: (id: string, uid: string, targetId: string) =>
request<CalendarEvent>('POST', `/calendars/${id}/events/${encodeURIComponent(uid)}/move`,
{ calendar_id: targetId }),
/** Ohne `occurrence` verschwindet der Termin ganz – bei einer Reihe also
* die ganze Reihe. Mit `occurrence` (ISO-Anfang des einzelnen Termins)
* fällt nur dieser eine aus, die Reihe bleibt bestehen. */
deleteEvent: (id: string, uid: string, occurrence?: string) => {
const qs = occurrence ? `?occurrence=${encodeURIComponent(occurrence)}` : ''
return request<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`)
},
}
export { APIError }
+22
View File
@@ -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
}
-114
View File
@@ -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>
)
}
+414
View File
@@ -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>
)
}
+63
View File
@@ -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 -1
View File
@@ -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'
+4 -34
View File
@@ -19,17 +19,6 @@ interface Props {
onClose: () => void
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
onSaved?: () => void
/**
* Ein Office-Dokument als PDF zeigen statt im Editor.
*
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
*/
asOfficePdf?: boolean
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
onEdit?: () => void
/**
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
@@ -53,7 +42,7 @@ type Kind = 'image' | 'pdf' | 'text' | 'video' | 'audio' | 'none'
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
* sich eine vorhandene MP3 also nie abspielen.
*/
function kindOf(mime: string, name = ''): Kind {
export function kindOf(mime: string, name = ''): Kind {
const m = (mime || '').toLowerCase()
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
// SVG-Datei Skripte enthalten darf.
@@ -101,12 +90,10 @@ export function canPreview(file: FileItem): boolean {
return kindOf(file.mime_type, file.name) !== 'none'
}
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
// gewandeltes PDF ist es eins.
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
const kind = kindOf(file.mime_type, file.name)
const narrow = useNarrow()
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
const src = filesApi.inline(file.id)
const [text, setText] = useState<string | null>(null)
const [tooBig, setTooBig] = useState(false)
const [editing, setEditing] = useState(false)
@@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
{file.name}
</span>
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
die Ausnahme. */}
{asOfficePdf && onEdit && (
<button
onClick={onEdit}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
Edit
</button>
)}
{kind === 'text' && !tooBig && text !== null && (
editing ? (
<>
+1 -1
View File
@@ -5,7 +5,7 @@ import { TagRow } from './TagDialog'
import TagChip from './TagChip'
import { FileTypeIcon, FolderGlyph } from './icons'
import FileCover, { FolderCover } from './FileCover'
import { formatBytes } from '../pages/FileBrowser'
import { formatBytes } from '../format'
/**
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
+6 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
import { useBackClose } from '../hooks/useBackClose'
import { useCoarsePointer } from './FileViews'
/**
* Ein Dokument im Office-Server bearbeiten.
@@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: {
const [session, setSession] = useState<OfficeSession | null>(null)
const [error, setError] = useState('')
const formRef = useRef<HTMLFormElement>(null)
// Am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem
// Schreibtisch ist immer noch eine Maus und eine Tastatur.
const coarse = useCoarsePointer()
useEffect(() => {
let alive = true
officeApi.session(file.id)
officeApi.session(file.id, coarse)
.then(s => {
if (!alive) return
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
@@ -35,7 +39,7 @@ export default function OfficeEditor({ file, onClose }: {
setError(err instanceof Error ? err.message : 'Could not open the editor')
})
return () => { alive = false }
}, [file.id])
}, [file.id, coarse])
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
+6 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import UserMenu, { useSidebarToggle } from './UserMenu'
import { wideEnough } from '../breakpoints'
/**
* Die Kopfleiste jeder Ansicht.
@@ -23,8 +24,12 @@ export const headerHeight = 57
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
*
* Dieselbe Grenze schaltet die Seitenleiste um; sie steht deshalb in
* `breakpoints.ts` und nicht mehr hier. Re-exportiert, weil sie hier gesucht
* wird.
*/
export const wideEnough = '(min-width: 720px)'
export { wideEnough }
interface Props {
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
+169 -445
View File
@@ -3,6 +3,10 @@ import { filesApi } from '../api/client'
import * as pdfjs from 'pdfjs-dist'
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import PdfToolbar from './pdf/PdfToolbar'
import { commonWidth, padOf, pageGap, type PageSize } from './pdf/geometry'
import { countIn, wordAround, type Hit } from './pdf/text'
import { nextFine, nextStep } from './pdf/zoom'
import './pdf.css'
/**
@@ -23,6 +27,10 @@ import './pdf.css'
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
* schreiben kann, wäre ein Widerspruch dazu.
*
* Hier steht, was am DOM hängt: laden, zeichnen, rollen, zoomen, färben. Was
* sich ohne Browser ausrechnen lässt, liegt daneben in `pdf/` – Zoomstufen,
* Seitenmaße, Suche im Text – und wird dort auch geprüft.
*/
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
@@ -39,63 +47,45 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
*/
const standardFontDataUrl = '/pdfjs/standard_fonts/'
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Wie viele Seitenmaße gleichzeitig geholt werden.
*
* Vorher lief die Schleife streng nacheinander: Bei einem 200-seitigen Scan
* waren das 200 Runden, bevor überhaupt etwas zu sehen war. Alle auf einmal
* wäre das andere Extrem – PDF.js hält dann 200 Seitenobjekte zugleich.
*/
const sizeBatch = 24
/**
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
* Zahlen gerastet – 65, 70, 75, 80 …
* Wartezeit, bevor eine Eingabe im Suchfeld das ganze Dokument durchsucht.
*
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
* Raster, und das hier ist eines.
*
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
* Ohne sie las jeder einzelne Tastendruck den Text **aller** Seiten neu aus.
* Wer „Rechnung" tippt, löste damit acht vollständige Durchläufe aus, von
* denen sieben schon veraltet waren, bevor sie fertig wurden. Ein Sechstel
* einer Sekunde bemerkt beim Tippen niemand.
*/
const zoomStepPercent = 5
const minScale = 0.25
const maxScale = 4
const searchDelay = 160
/**
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
* als ein Zehntel der Darstellungsgröße.
*/
function padOf(width: number): number {
return width < 560 ? 4 : 16
}
/**
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
* Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet
* wird.
*
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
* nicht mittig. Macht 2 × 275 + 492.
* **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die
* Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht
* zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter
* Pixeldichte sind zwanzig Bitmaps zu je rund acht Megabyte – der Abbruch der
* vorigen half nur gegen die Rechenzeit, nicht gegen das Belegen und Freigeben.
*
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
* der Steuerung nichts mehr zu sehen.
* Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und
* der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also
* von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen
* weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen.
*
* Die Textebene wandert korrekt mit: PDF.js setzt ihre Stücke in **Prozent**
* des Kastens, und die Schriftgröße hängt an `--total-scale-factor`. Beides
* folgt dem Layout, nicht der Leinwand.
*/
const minGridWidth = 2 * 275 + 492
/**
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
*/
const counterWidth = 54
const hitButtonsWidth = 60
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
const hitsWidth = counterWidth + hitButtonsWidth + 6
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
* wird, wenn die sichtbare Seite bestimmt wird. */
const pageGap = 12
const zoomSettle = 120
interface Props {
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
@@ -109,23 +99,22 @@ interface Props {
searchTerm?: string
}
/**
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
*
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
*/
interface Hit { page: number; nth: number; exact: boolean }
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
const [error, setError] = useState('')
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
const [sizes, setSizes] = useState<PageSize[]>([])
const [scale, setScale] = useState<number | 'fit'>('fit')
const [fitScale, setFitScale] = useState(1)
/**
* Ist die Einpassung schon gemessen?
*
* Ohne diese Wache zeichnete jedes Öffnen **einmal umsonst**: Die Maße
* kommen an, der Zeichner läuft los – und erst danach steht fest, dass das
* Blatt nicht auf 100 %, sondern auf 65 % gehört. Die halbfertige Leinwand
* wurde weggeworfen. Bei einem A4-Blatt in doppelter Pixeldichte sind das
* acht Megabyte, die niemand je zu sehen bekam.
*/
const [fitReady, setFitReady] = useState(false)
const [current, setCurrent] = useState(1)
const [query, setQuery] = useState(searchTerm ?? '')
@@ -141,16 +130,33 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
/** Der Maßstab, in dem das Blatt **liegt** – Kastengröße, Textebene, Anzeige. */
const effScale = scale === 'fit' ? fitScale : scale
/**
* Der Maßstab, in dem zuletzt **gemalt** wurde. Er hinkt während einer Geste
* hinterher; solange streckt der Browser das vorhandene Bild. Siehe
* `zoomSettle`.
*/
const [renderScale, setRenderScale] = useState(effScale)
useEffect(() => {
if (renderScale === effScale) return
const t = setTimeout(() => setRenderScale(effScale), zoomSettle)
return () => clearTimeout(t)
}, [effScale, renderScale])
/** Läuft gerade eine Geste – ist das Blatt also nur gestreckt? */
const stretched = renderScale !== effScale
// ---------------------------------------------------------------- Laden
useEffect(() => {
let alive = true
let task: ReturnType<typeof pdfjs.getDocument> | null = null
setDoc(null); setSizes([]); setError(''); setCurrent(1)
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
// Die Verweise auf die Seitenkästen gehören zum alten Dokument. Ohne das
// blieben bei einem kürzeren nächsten Dokument Einträge stehen, die auf
// längst entfernte Elemente zeigen.
pageRefs.current = []
task = pdfjs.getDocument({
const task = pdfjs.getDocument({
url,
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
withCredentials: true,
@@ -165,10 +171,15 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
const s: { w: number; h: number }[] = []
for (let i = 1; i <= d.numPages; i++) {
const v = (await d.getPage(i)).getViewport({ scale: 1 })
s.push({ w: v.width, h: v.height })
const s: PageSize[] = []
for (let from = 1; from <= d.numPages; from += sizeBatch) {
const to = Math.min(from + sizeBatch - 1, d.numPages)
const batch = await Promise.all(
Array.from({ length: to - from + 1 }, (_, i) =>
d.getPage(from + i).then(p => p.getViewport({ scale: 1 }))),
)
if (!alive) return
for (const v of batch) s.push({ w: v.width, h: v.height })
}
if (!alive) return
setSizes(s)
@@ -185,7 +196,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
)
return () => {
alive = false
task?.destroy()
task.destroy()
}
}, [url])
@@ -210,9 +221,6 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
*
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
* schmaler als das Blatt, die Breite also unter 100 %.
*
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
* breiteste – warum, steht bei `commonWidth`.
*/
const recomputeFit = useCallback(() => {
const el = scrollRef.current
@@ -227,6 +235,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
setFitReady(true)
}, [sizes])
useEffect(() => {
@@ -248,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
* ihre Leinwand frei.
*/
/**
* Steht der Maßstab fest? Bewusst als **ein Wahrheitswert** in der
* Abhängigkeitsliste und nicht als `scale`: Der Zeichner darf sich nicht bei
* jedem Zoomschritt neu aufbauen, sonst wäre die Geste wieder so teuer wie
* vorher. Dieser Wert kippt einmal von falsch auf wahr.
*/
const scaleKnown = scale !== 'fit' || fitReady
useEffect(() => {
if (!doc || sizes.length === 0) return
if (!doc || sizes.length === 0 || !scaleKnown) return
const root = scrollRef.current
if (!root) return
@@ -263,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (!doc || !host || drawn.has(n)) return
drawn.add(n)
const page = await doc.getPage(n)
const viewport = page.getViewport({ scale: effScale })
const viewport = page.getViewport({ scale: renderScale })
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const canvas = document.createElement('canvas')
@@ -330,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
io.disconnect()
for (const t of running.values()) t.cancel()
}
}, [doc, sizes, effScale])
}, [doc, sizes, renderScale, scaleKnown])
// --------------------------------------------------- Sichtbare Seitenzahl
@@ -403,10 +420,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
*/
useEffect(() => {
const q = query.trim().toLowerCase()
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); setSearching(false); return }
let alive = true
setSearching(true)
;(async () => {
// Erst abwarten, ob noch ein Zeichen kommt – siehe `searchDelay`.
const timer = setTimeout(() => void (async () => {
const found: Hit[] = []
for (let n = 1; n <= doc.numPages; n++) {
if (!alive) return
@@ -446,9 +465,9 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (!alive) return
setHits(found)
setHitIndex(0)
setSearching(false)
})()
return () => { alive = false; setSearching(false) }
})().finally(() => { if (alive) setSearching(false) }), searchDelay)
return () => { alive = false; clearTimeout(timer) }
}, [doc, query, fileId])
/**
@@ -466,12 +485,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
*
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
* Textebene.
* Kästen, die der Browser auch beim Markieren mit der Maus malt.
*
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
* warum, steht bei `wordAround` unten.
* warum, steht bei `wordAround` in pdf/text.ts.
*
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
@@ -555,6 +572,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
}
}, [query, hits])
/**
* Während einer Zoomgeste die Färbung wegnehmen.
*
* Die Kästen sind das einzige im Seitenkasten, das in **Pixeln** sitzt –
* Leinwand und Textebene rechnen in Prozent und wandern von selbst mit. Ein
* stehengebliebener Kasten läge nach zwei Stufen sichtbar neben dem Wort.
* Nach dem Zeichnen kommt er von selbst zurück (`pdf-text-ready`).
*/
useEffect(() => {
if (!stretched) return
for (const host of pageRefs.current) {
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
}
}, [stretched])
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
const target = hits[hitIndex]
@@ -604,6 +636,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
const stepHit = useCallback((delta: number) => {
// Ohne die Wache teilte der Rest durch null und der Zeiger stünde auf
// NaN. Die Leiste ruft das zwar nur mit Treffern, aber ein Zähler, der
// sich nicht mehr erholt, ist ein teurer Preis für eine Annahme.
if (hits.length === 0) return
setHitIndex(i => (i + delta + hits.length) % hits.length)
}, [hits.length])
// ------------------------------------------------------------------ Zoom
/**
@@ -625,6 +665,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
const scaleRef = useRef(effScale)
scaleRef.current = effScale
useEffect(() => {
const anchor = keepAnchor.current
if (anchor) {
@@ -643,41 +684,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
goToPage(n)
}, [effScale, goToPage])
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
function nextStep(now: number, dir: 1 | -1): number | undefined {
return dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
}
/** Merkt den Punkt unter Zeiger oder Fingermitte, damit er dort bleibt. */
const anchorAt = useCallback((cx: number, cy: number, from: number) => {
const root = scrollRef.current
if (!root) return
const r = root.getBoundingClientRect()
keepAnchor.current = {
px: root.scrollLeft + (cx - r.left), py: root.scrollTop + (cy - r.top),
cx: cx - r.left, cy: cy - r.top, from,
}
}, [])
/**
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
* Anschlag.
*
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
* Fünfern weiter.
*/
function nextFine(now: number, dir: 1 | -1): number | undefined {
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
// genau eine Stufe, danach nichts mehr.
const p = Math.round(now * 100 * 1e6) / 1e6
const step = zoomStepPercent
const raw = dir > 0
? Math.floor(p / step + 1) * step
: Math.ceil(p / step - 1) * step
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
return Math.abs(next - now) < 0.001 ? undefined : next
}
function zoom(dir: 1 | -1) {
const next = nextStep(effScale, dir)
const zoom = useCallback((dir: 1 | -1) => {
const next = nextStep(scaleRef.current, dir)
if (next) { keepPage.current = current; setScale(next) }
}
}, [current])
/**
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
@@ -694,31 +715,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const root2 = scrollRef.current
if (!root2) return
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
const next = nextFine(effScale, dir)
const from = scaleRef.current
const next = nextFine(from, e.deltaY < 0 ? 1 : -1)
if (!next) return
const r = root2.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from: effScale,
}
anchorAt(e.clientX, e.clientY, from)
setScale(next)
}
root.addEventListener('wheel', onWheel, { passive: false })
return () => root.removeEventListener('wheel', onWheel)
}, [effScale])
}, [anchorAt])
/**
* Zwei Finger zoomen das Dokument – nicht die Seite.
*
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
* bisher nur die Knöpfe blieben.
* hinterher stand alles verschoben.
*
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
@@ -744,9 +756,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (!root) return
function distance(t: TouchList) {
const dx = t[0].clientX - t[1].clientX
const dy = t[0].clientY - t[1].clientY
return Math.hypot(dx, dy)
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
}
function onStart(e: TouchEvent) {
@@ -783,17 +793,13 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (dir < 0 && ratio > Math.min(0.98, needed)) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) return
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
const r = root2.getBoundingClientRect()
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from,
}
anchorAt(
(e.touches[0].clientX + e.touches[1].clientX) / 2,
(e.touches[0].clientY + e.touches[1].clientY) / 2,
from,
)
setScale(next)
}
@@ -812,60 +818,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
root.removeEventListener('touchend', onEnd)
root.removeEventListener('touchcancel', onEnd)
}
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
// dann über genau eine Stufe nicht hinaus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// `anchorAt` ist über useCallback stabil, der Zuhörer meldet sich also
// **einmal** an. Hinge er am Maßstab, meldete er sich nach jeder Stufe neu
// an – mitten in der Geste, und der gemerkte Fingerabstand wäre weg.
// Gemessen: Eine lange Geste kam dann über genau eine Stufe nicht hinaus.
}, [anchorAt])
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
* ersten Zeile. */
const zoomGroup = (
<>
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={() => { keepPage.current = current; setScale('fit') }}
disabled={scale === 'fit'}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: scale === 'fit' ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{Math.round(effScale * 100)}%
</button>
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
</>
)
/**
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
*/
const barRef = useRef<HTMLDivElement>(null)
const [barNarrow, setBarNarrow] = useState(false)
useEffect(() => {
const el = barRef.current
if (!el) return
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [])
const [pad, setPad] = useState(16)
useEffect(() => {
const el = scrollRef.current
@@ -892,137 +852,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
}}>
{/*
Werkzeugleiste.
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
landete gemessen 79 bzw. 142 Pixel daneben.
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
beim Umbruch, und die Suche steht in der zweiten Zeile.
*/}
<div
ref={barRef}
style={{
display: barNarrow ? 'flex' : 'grid',
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
alignItems: 'center', gap: 8, flexWrap: 'wrap',
padding: '6px 10px', flexShrink: 0,
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<ToolButton label="Previous page" disabled={current <= 1}
onClick={() => goToPage(current - 1)} glyph="up" />
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
minWidth: 62, flexShrink: 0,
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
}}>
{doc ? `${current} / ${doc.numPages}` : '…'}
</span>
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
onClick={() => goToPage(current + 1)} glyph="down" />
{barNarrow && (
<>
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
{zoomGroup}
</>
)}
</div>
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
wie ein Zusatz, der nicht recht dazugehört. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
ansieht, nicht der Kasten drumherum.
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
rechts der zweite Sprungknopf aus dem Bild fiel. */}
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
}
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
}}
placeholder="Find in document"
style={{
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
// jetzt auch hinein, ohne zu wandern.
width: 240, minWidth: 0,
padding: '5px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-background)', color: 'var(--color-text)',
outline: 'none',
}}
/>
<span style={{
fontSize: 11, color: 'var(--color-text-muted)',
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
width: counterWidth, flexShrink: 0,
overflow: 'hidden', whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}>
{searching ? 'Searching…'
: query.trim().length < 2 ? ''
: hits.length === 0 ? 'No hits'
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
</span>
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
schon. */}
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
{hits.length > 0 && (
<>
<ToolButton label="Previous hit" glyph="hitUp"
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
<ToolButton label="Next hit" glyph="hitDown"
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
</>
)}
</span>
</div>
{/* Die dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
auch etwas.
Schmal steht der Zoom **nicht** hier, sondern oben neben der
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
Dokument ab. */}
{!barNarrow && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
justifyContent: 'flex-end', minWidth: 0,
}}>
{zoomGroup}
</div>
)}
</div>
<PdfToolbar
page={current}
pageCount={doc?.numPages ?? null}
onGoPage={goToPage}
percent={Math.round(effScale * 100)}
isFit={scale === 'fit'}
onZoom={zoom}
onFit={() => { keepPage.current = current; setScale('fit') }}
query={query}
onQuery={setQuery}
searching={searching}
hitCount={hits.length}
hitIndex={hitIndex}
pageOnlyHits={!!hits[0] && !hits[0].exact}
onStepHit={stepHit}
/>
{/* Seiten */}
<div
@@ -1070,124 +915,3 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
</div>
)
}
/**
* Die häufigste Seitenbreite eines Dokuments.
*
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
* stimmt – nur ist der Preis höher als der Gewinn.
*
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
* so die Darstellung des ganzen Dokuments.
*
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
* ohnehin tun will.
*
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
*/
function commonWidth(sizes: { w: number; h: number }[]): number {
const count = new Map<number, number>()
for (const s of sizes) {
const w = Math.round(s.w)
count.set(w, (count.get(w) ?? 0) + 1)
}
let best = 0, bestN = 0
for (const [w, n] of count) {
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
}
return best || 1
}
/**
* Weitet eine Fundstelle auf das **ganze Wort** aus.
*
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
* um ein bis drei Pixel anders als die gemalten.
*
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
*
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
*
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
*/
const maxWordSnap = 40
function wordAround(text: string, at: number, to: number): [number, number] {
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
let s = at, e = to
while (s > 0 && isWord(text[s - 1])) s--
while (e < text.length && isWord(text[e])) e++
return e - s > maxWordSnap ? [at, to] : [s, e]
}
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
const out: Hit[] = []
let from = 0, nth = 0
for (;;) {
const at = haystack.indexOf(needle, from)
if (at < 0) break
out.push({ page, nth: nth++, exact })
from = at + needle.length
}
return out
}
/**
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
*
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
* wenn es Fundstellen gibt.
*/
const glyphs: Record<string, string> = {
up: '18 15 12 9 6 15',
down: '6 9 12 15 18 9',
}
function ToolButton({ label, onClick, disabled, glyph }: {
label: string; onClick: () => void; disabled?: boolean
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
style={{
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
background: 'none', color: 'var(--color-text-muted)',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
</svg>
</button>
)
}
+13 -4
View File
@@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
/** Was freigegeben wird – Ordner oder einzelne Datei. */
resource: { type: 'folder' | 'file'; id: string; name: string }
resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
onClose: () => void
}
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
setLoading(true)
try {
const [list, people] = await Promise.all([
resource.type === 'folder'
? aclApi.listForFolder(resource.id)
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
: aclApi.listForFile(resource.id),
aclApi.recipients(),
])
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
setSaving(true)
setError('')
try {
if (resource.type === 'folder') {
if (resource.type === 'calendar') {
await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission)
} else if (resource.type === 'folder') {
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
} else {
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
</button>
</div>
{resource.type === 'calendar' && (
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
The calendar shows up in their app too — web and CalDAV alike.
With “can edit” they may add and change events; renaming or
deleting the calendar stays with you.
</p>
)}
{resource.type === 'folder' && (
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Everything inside this folder is shared too, including anything added later.
+28 -3
View File
@@ -1,9 +1,10 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { filesApi } from '../api/client'
import type { FolderItem, User } from '../api/client'
import { useNavHistory, type View } from '../hooks/useNavHistory'
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
import { useBackClose } from '../hooks/useBackClose'
import { Toaster } from './Toasts'
import Sidebar from './Sidebar'
import FileBrowser from '../pages/FileBrowser'
import TrashPage from '../pages/TrashPage'
@@ -12,7 +13,9 @@ import SharedWithMePage from '../pages/SharedWithMePage'
import AdminPage from '../pages/AdminPage'
import ProfilePage from '../pages/ProfilePage'
import TagsPage from '../pages/TagsPage'
import CalendarPage from '../pages/CalendarPage'
import { UserMenuProvider } from './UserMenu'
import { wideEnough } from '../breakpoints'
export type { View }
@@ -43,6 +46,19 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
onClose: useCallback(() => { setSidebarOpen(false) }, []),
})
// Wird das Gerät aufgeklappt, während die Leiste aufgeschoben war, steht sie
// ab da ohnehin fest im Bild. Bliebe „aufgeschoben" dabei stehen, schluckte
// die Zurück-Geste einmal ins Leere – sie zielte auf etwas, das nichts mehr
// verdeckt.
useEffect(() => {
if (typeof matchMedia !== 'function') return
const mq = matchMedia(wideEnough)
const auf = () => { if (mq.matches) setSidebarOpen(false) }
auf()
mq.addEventListener('change', auf)
return () => mq.removeEventListener('change', auf)
}, [])
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
// hängt daran und holt sich daraufhin neu.
const [trashBump, setTrashBump] = useState(0)
@@ -80,9 +96,18 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
onOpenSidebar: () => setSidebarOpen(true),
}}>
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
{/* Mobile overlay */}
{/* Die Meldungen liegen über allem und sind an genau einer Stelle
eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur
diese eine Anzeige. */}
<Toaster />
{/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste.
Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht:
Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber
machte sie unanklickbar – sichtbar, aber taub. */}
{sidebarOpen && (
<div
className="aether-scrim"
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
onClick={() => setSidebarOpen(false)}
/>
@@ -105,13 +130,13 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
{view === 'files' && (
<FileBrowser
user={user}
folderPath={folderPath}
onNavigate={nav.setFolderPath}
onUserChanged={onUserChanged}
reloadSignal={trashBump}
/>
)}
{view === 'calendar' && <CalendarPage />}
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
{view === 'shared' && <SharedPage />}
+16 -1
View File
@@ -8,6 +8,7 @@ import { RenameIcon, TrashIcon } from './icons'
import type { User } from '../api/client'
import type { View } from './Shell'
import { useAppSettings } from '../hooks/useAppSettings'
import { wideEnough } from '../breakpoints'
interface Props {
user: User
@@ -36,6 +37,16 @@ const nav: { id: View; label: string; icon: React.ReactNode }[] = [
</svg>
),
},
{
id: 'calendar',
label: 'Calendar',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
),
},
{
id: 'trash',
label: 'Trash',
@@ -765,7 +776,11 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag, onTrashDro
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 767px) {
/* Schmal: Die Leiste liegt über dem Inhalt statt neben ihm.
Die Grenze ist dieselbe, die in der Kopfleiste den Knopf zum
Aufschieben zeigt – hätte sie eine eigene, gäbe es dazwischen einen
Streifen ohne Menü und ohne Knopf dorthin. */
@media not all and ${wideEnough} {
.aether-sidebar {
position: fixed;
top: var(--honeybar-h); left: 0;
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more