Compare commits

..
36 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
143 changed files with 15000 additions and 2468 deletions

No files matched your search

+26 -2
View File
@@ -80,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
@@ -103,11 +117,21 @@ OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
# sonst verweigert der Browser den Rahmen.
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
# Browser → Office
# Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
# ist fast immer falsch.
OFFICE_PUBLIC_URL=
OFFICE_WOPI_URL=http://app:8080 # Office → Aether
OFFICE_ALLOWED_HOSTS=office # gegen SSRF
# nur OnlyOffice, openssl rand -hex 32
# Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet
# die Zertifikatsprüfung **ab** – im Internet nichts zu suchen.
OFFICE_INSECURE_TLS=false
# Liest **der Office-Container**, nicht Aether: Es schützt seine eigene API.
# Der WOPI-Weg braucht es nicht, der hat eigene Token. Bei OnlyOffice und
# Euro-Office mit JWT_ENABLED=true gehört es gesetzt, sonst steht deren API
# offen. Collabora kennt es nicht. openssl rand -hex 32
OFFICE_JWT_SECRET=
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
+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.
+122 -60
View File
@@ -507,6 +507,88 @@ hunderte pro Minute.
---
## Dateien von außen: Abgleich mit der Datenbank
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
und löscht sie.
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
| Fall | Was der Abgleich tut |
|---|---|
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
Papierkorb – es ist endgültig.
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
eigenen Datei.
### Für Sicherungen heißt das
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
auf Wunsch weg.
### Der große Fall: alles zurück, ohne Postgres
Einzelne Dateien zurückzuholen ist das eine. Der Ernstfall ist der andere:
Der Server ist fort, aus der Sicherung kommen nur die **Dateien** zurück, die
Datenbank ist leer.
Das geht – aber nur in dieser Reihenfolge, und der zweite Schritt ist der,
den man vergisst:
1. **Dateien zurücklegen** nach `DATA_PATH`, mit der Struktur aus der
Sicherung (`users/<konto>/home/…`). Aether starten.
2. **Konten neu anlegen – mit exakt denselben Benutzernamen.** Der Ordnername
auf der Platte *ist* die Zuordnung; ein Konto `mueller` findet nichts, was
unter `users/Mueller/` liegt. Passwörter dürfen neu sein, Namen nicht.
3. **Admin → Audit → Rescan storage**, erst *Check*, dann *Apply*.
Ohne Schritt 2 bleibt der Abgleich stehen und sagt es je Datei:
`Konto "altnutzer" gibt es nicht`. Er rät nicht und legt nichts an – ein
falsch zugeordnetes Dokument wäre schlimmer als ein unsichtbares.
**Einmal geprobt, nicht behauptet** (10.09.2026): Auf einer eigens
aufgesetzten Instanz Dateien gesichert, Container *und* Datenbank
weggeworfen, nur die Dateien zurückgeholt. Nach den drei Schritten standen
Ordnerkette, Unterordner, Umlaute im Dateinamen, Größen, Prüfsummen und
Dateitypen wieder da, und der Download lieferte den ursprünglichen Inhalt.
`.trash/` und `.versions/` blieben zu Recht außen vor. Ein dritter Abgleich
meldete nichts Offenes mehr.
**Was dabei nicht zurückkommt**, weil es nur in der Datenbank steht:
Etiketten, Absender, Belegdatum, Freigabelinks, frühere Fassungen, der
erkannte Text der Volltextsuche (er wird neu erzeugt), Kalender und Termine.
Wem das wehtut, der sichert Postgres mit – die Dateien allein sind die
Rückfallebene, nicht der volle Umfang.
### Aktualisieren einer laufenden Instanz
Neues Abbild ziehen und neu starten, mehr nicht: Die Migrationen laufen beim
Start selbst. Geprobt am 10.09.2026 von Schema 19 auf 23 mit Daten in der
Datenbank – Konten, Ordner, Dateien und Etikettenzuordnungen standen danach
unverändert da, kein `dirty`-Zustand. **Ein Neuaufbau ist dafür nicht nötig
und bringt nichts**, was das Update nicht auch bringt.
---
## Kalender (CalDAV)
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
@@ -583,6 +665,13 @@ Container heraus er selbst.
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
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
@@ -702,20 +791,31 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
- "${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
@@ -725,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
@@ -857,7 +918,7 @@ unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
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
@@ -872,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
+887 -2
View File
@@ -135,6 +135,183 @@ diesem Bereich sucht:
Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet
Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen.
## Abgleich Platte ↔ Datenbank: Selbsttest per Skript
Der Abgleich (Admin → Audit → **Rescan storage**) übernimmt Dateien, die von
außen dazugekommen sind – aus einer Sicherung zurückgeholt oder per SFTP
abgelegt – und **entfernt Einträge**, deren Datei fort ist. Das zweite ist der
Grund für diesen Test: Ein Fehler darin löscht Daten, die niemand vermisst,
bis er sie braucht.
python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD]
Der Test schreibt selbst Dateien ins Datenverzeichnis – er muss also auf dem
Rechner laufen, auf dem die Daten liegen (Vorgabe `~/aether-data`).
20 Prüfungen in fünf Gruppen: **Prüfen ändert nichts** (zweimal geprüft
liefert dasselbe, nichts wird sichtbar), **Übernehmen** (Ordnerkette angelegt,
Prüfsumme und Größe stimmen, Dateityp erkannt, Herunterladen liefert die
Datei), **von außen ersetzt** (Größe und Prüfsumme werden berichtigt),
**Datei ist fort** (verwaister Eintrag wird gefunden, bleibt beim Prüfen
stehen, verschwindet erst beim Anwenden), **nicht anfassen** (Papierkorb und
frühere Fassungen liegen im selben Baum und dürfen nicht übernommen werden –
sonst tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu
einer eigenen Datei), und **nur für Verwalter**.
### Warum es das überhaupt gibt
Der GC räumt Dateien weg, zu denen es keinen Eintrag gibt. Für Reste
gelöschter Dateien ist das richtig – für eine per SFTP abgelegte Datei ist es
falsch: Sie ist in Aether unsichtbar und nach 24 Stunden weg. Der Abgleich ist
die Gegenrichtung und macht aus solchen Dateien richtige Einträge.
Zwei Fallen, die beim Bauen auffielen und die ein Test hier abdecken muss:
- **Der Dateityp muss aus Endung oder Inhalt kommen.** Beim Hochladen schickt
der Browser ihn mit; eine per SFTP abgelegte Datei bringt niemanden mit, der
ihn kennt. Dazu kommt: Das Alpine-Abbild hat keine `/etc/mime.types`, und
Gos eingebaute Liste kennt nicht einmal `.txt`.
- **Prüfsumme nicht überspringen.** Sie kostet einen vollständigen Lesevorgang
je Datei, aber ein Eintrag ohne sie ist ein Eintrag, dessen Beschädigung
beim Herunterladen niemand bemerkt.
## Abgebrochener Upload
Am Kreuz der Fortschrittsmeldung lässt sich ein laufender Upload fallen
lassen. Die Zusage dahinter ist die eigentliche Frage: **Auf dem Server bleibt
dabei nichts zurück.**
python3 scripts/upload-abbruch-selbsttest.py
Der Test kappt eine echte Verbindung mitten im Leib – auf einem rohen Socket,
nicht über eine Bibliothek. Ein sauber gesendeter, aber zu kurzer Leib wäre
etwas anderes: Den wiese der Server als Formatfehler ab, und über den Abbruch
bewiese das nichts.
13 Prüfungen: **neue Datei** (kein Eintrag, nichts im Papierkorb),
**Überschreiben mit Versionierung** – der gefährlichste Fall, weil der Server
die alte Fassung dabei kurzzeitig zur Seite schiebt: Sie muss danach in Größe,
Prüfsumme und Inhalt unversehrt sein –, **Verbrauch** und ein **Rescan**, der
nachsieht, ob herrenlose Bytes liegengeblieben sind.
### Eine Falle für jeden, der die Schnittstelle skriptet
`GET /api/v1/auth/me` **erneuert das CSRF-Token** und gibt das neue zurück.
Wer den Wert von der Anmeldung festhält, bekommt beim nächsten schreibenden
Ruf ein `403` und sucht die Ursache bei den Rechten. Die Weboberfläche zieht
den neuen Wert an allen drei Stellen nach (`hooks/useAuth.ts`); ein Skript
muss es auch.
## Kalender teilen
python3 scripts/kalender-teilen-selbsttest.py
Eine Rechtefunktion ist so viel wert wie ihre engste Stelle. Aether hat **zwei**
Zugänge zu denselben Kalendern – die Weboberfläche und CalDAV –, und beide
prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist keines;
deshalb geht hier jede Prüfung über beide Wege.
41 Prüfungen in sieben Gruppen, und die Reihenfolge ist der Punkt: **vorher
sieht der Empfänger nichts** (ohne diesen Anfang beweist der Rest nichts – ein
Kalender, den ohnehin jeder sieht, ist auch nach dem Freigeben sichtbar), dann
nur lesend (sehen ja, ändern nein – und beim Eigentümer kommt nachweislich
nichts an), schreibend, die **Grenzen** des Schreibrechts (fremden Kalender
umbenennen, löschen oder weiterverschenken geht auch dann nicht), Freigabe über
eine **Gruppe**, `sync-collection` auf einem fremden Kalender, und der
Widerruf.
Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am Ende
alles weg.
### Eine Falle, die der Test selbst hatte
Die Terminliste hat einen Standardzeitraum (ein Monat zurück bis ein Jahr
voraus). Mit einem festen Testdatum lieferte sie irgendwann eine leere Liste –
und die Prüfung „beim Eigentümer ist nichts angekommen" **bestand dann leer**.
Eine Prüfung, die ohne Daten besteht, prüft nichts. Jetzt liegen die Testtermine
relativ zum heutigen Tag, der Zeitraum wird ausdrücklich mitgegeben, und eine
Zeile davor stellt sicher, dass überhaupt etwas in der Liste steht.
## Go-Tests mit Datenbank
Einige Go-Tests brauchen ein echtes Postgres – die Warteschlange der
Texterkennung etwa lässt sich ohne nicht prüfen. Sie überspringen sich selbst,
wenn `TEST_DSN` fehlt, damit `go test ./...` überall grün bleibt.
**Der Preis dieser Freundlichkeit: Sie laufen dann nie.** Und ein Test, der nie
läuft, ist Dekoration – genau wie `vitest` es war, bevor es auffiel. Deshalb:
./scripts/db-tests.sh # alle
./scripts/db-tests.sh ./internal/documents/
Das Skript baut eine Wegwerf-Datenbank, spielt alle Migrationen ein, lässt die
Tests laufen und räumt sie wieder weg. Die Entwicklungsdatenbank bleibt
unberührt.
### Texterkennung: die drei stillen Zustände
`internal/documents` ist das Paket, dessen Versagen am teuersten ist, weil es
**still** ist: Die Volltextsuche findet dann einfach nichts, und das sieht
genauso aus wie „steht nicht drin". Geprüft werden deshalb nicht die Abfragen,
sondern die drei Zustände, in denen die Schlange hängenbleiben kann, ohne dass
es auffällt:
1. **Ein Auftrag, der nie aufgibt.** Ein kaputtes oder passwortgeschütztes PDF
besetzte sonst für immer den Arbeiter, und die übrigen Dateien kämen nie
dran – während nach außen „die Erkennung läuft".
2. **Ein Auftrag, der nach einem harten Abbruch auf `running` stehen bleibt.**
Nähme `Claim` nur `pending`, wäre diese Datei für immer verloren, und die
Zahl der offenen Aufträge sähe dabei gesund aus.
3. **Ein Text nach alter Regel, der nie erneuert wird.** Auffindbar, aber im
Betrachter nicht auf der richtigen Seite – und nichts zeigt, dass diese
Dateien anders sind.
Dazu: gelöschte Dateien werden nicht mehr genommen, ein erneuter Upload gibt
eine neue Chance, und `Complete` schreibt Text und Auftragsende zusammen.
### Etiketten und Absender: bleibt Fremdes fremd?
./scripts/db-tests.sh ./internal/tags/
Die Rechteprüfung steckt hier nicht in einer `if`-Zeile, sondern in
WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage
läuft, sie liefert nur zu viel.
Fünf Tests auf dieselbe Frage – Liste, Umbenennen, Löschen, Anhängen an die
eigene Datei, und vor allem das **Auflisten der Dateien** unter einem Etikett.
Der letzte Weg gibt Dateinamen, Ordner und Größen heraus.
Dazu die Gruppen: Ein Mitglied sieht das Gruppen-Etikett, ein Nichtmitglied
nicht, und wer die Gruppe verlässt, verliert den Zugriff im selben Moment.
**Eine Absicherung, die wie eine Förmlichkeit aussieht:** Ein Gruppen-Etikett
darf nicht an eine persönliche Datei. Der Grund steht nicht im Code, wo die
Prüfung sitzt, sondern zwei Dateien weiter: `FilesWithTag` prüft, ob der
Anfragende das *Etikett* sehen darf – **nicht**, ob er die *Datei* sehen darf.
Harmlos ist das nur, weil ein Etikett stets denselben Prinzipal hat wie die
Dateien darunter. Fiele die Prüfung weg, hinge an einem Gruppen-Etikett eine
private Datei, und jedes Gruppenmitglied bekäme ihren Namen zu sehen.
### Was der Mutationsversuch gezeigt hat
Die Tests wurden gegen **absichtlich kaputten Code** gegengeprüft – sonst weiß
man nicht, ob sie überhaupt etwas fangen.
* `'running'` aus `Claim` entfernt → zwei Tests wurden rot. Gut.
* `maxAttempts` von 3 auf 9999 gesetzt → **alle blieben grün.** Sie schleifen
über die Konstante, prüfen die Zahl also nicht, sondern setzen sie voraus.
Auffällig war nur die Laufzeit: 27 statt 0,2 Sekunden.
Genau so verschwindet eine Entscheidung unbemerkt aus einer Anwendung. Dagegen
steht jetzt ein Test, der die Zahl selbst festhält und sagt, wo die Begründung
hingehört, falls sie sich ändert.
Bei den Etiketten dasselbe Vorgehen, mit klarerem Ausgang: Eigentümerprüfung
aus `FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien:
Gehaltsabrechnung.pdf"*. Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests
rot. Beide Absicherungen sind also wirklich abgedeckt und nicht bloß
mitgelaufen.
## Notfall: Migration hängt fest ("Dirty database version")
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
@@ -1179,8 +1356,9 @@ Die Daten liegen seit dem 23.08.2026 in einem gewöhnlichen Verzeichnis
## Office – Collabora / OnlyOffice / Euro-Office (24.08.2026)
Aether ist WOPI-Host; der Office-Server holt die Datei selbst ab und schreibt
sie zurück. **Gemessen wurde mit Collabora**; OnlyOffice und Euro-Office
sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher).
sie zurück. Gemessen wurde mit **Collabora** und – seit dem 16.09.2026 – mit
**Euro-Office**, beide auf dieser Anlage. OnlyOffice lief hier nur bis zur
Ermittlung; Euro-Office ist sein Fork und spricht dieselbe Schnittstelle.
### Öffnen
- [ ] Rechtsklick auf .odt/.docx/.ods → „Edit in Office…" erscheint 🔎
@@ -1218,6 +1396,713 @@ sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher).
---
## Office am Telefon: `mobileEdit` statt PDF-Umweg (16.09.2026)
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, mit einem
„Edit"-Knopf hinein. Begründet war das mit OnlyOffices Oberfläche, die auf
400 Pixeln nichts vom Dokument übrig lässt.
**Die Begründung war falsch – wir hatten die falsche Oberfläche bestellt.**
Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und für
`.docx` stehen dort acht. Nachgesehen im laufenden Euro-Office:
edit …/hosting/wopi/word/edit
mobileEdit …/hosting/wopi/word/edit?mobile=1
`EditURL` kannte nur `edit` und `view`. Die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.
- [x] Am Telefon öffnet ein `.docx` in der Handy-Oberfläche 🔎
- [x] Am Schreibtisch unverändert die Schreibtisch-Fassung 🔎
- [x] Schmales Fenster am Schreibtisch → weiterhin Schreibtisch-Fassung
(entschieden wird am Zeigegerät, nicht an der Breite)
- [x] Collabora kennt die Aktionen nicht → fällt auf `edit` zurück
- [ ] Nur-Lese-Freigabe am Telefon → `mobileView`
**Vorher geprüft, ob es überhaupt etwas ändert.** Beide Aktionen zeigen auf
denselben Pfad; erst der Vergleich der Adressen zeigte das `?mobile=1`. Ohne
diesen Blick wäre der Umbau eine Umbenennung gewesen.
### Die PDF-Ansicht ist entfallen
Mit dem Anlass fiel die Notlösung: `internal/office/preview.go` (229 Zeilen),
`convert.go` (271) samt Test, `files.preview_version` (Migration 000026), die
Sonderabfrage im GC, die Route und dreizehn Fundstellen im Frontend.
`convert.go` hatte **keinen anderen Aufrufer** – die gesamte Umwandlung nach
PDF existierte allein für diese Ansicht. Wer ein PDF braucht, exportiert es
im Editor.
- [x] Alle 234 Prüfungen der sieben Selbsttest-Suiten weiterhin grün
- [ ] `previews/` ist nach einem GC-Lauf leer (24 h Schonfrist beachten)
- [ ] Admin → abgeleitete Daten zeigt danach 0 für `previews`
### Der Zurück-Fehler dahinter
Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu
öffnen. Ursache war kein Office, sondern der Verlauf: Beim Wechsel räumt React
erst die alte Ansicht ab, dann baut es die neue auf. Der Aufräumer von
`useBackClose` ruft `history.back()` – das wirkt aber **verzögert** und
verbrauchte deshalb den Eintrag, den der Editor gerade angelegt hatte.
Behoben mit einem Zähler für selbst ausgelöste Schritte: So viele `popstate`
werden verschluckt. Die Geste des Nutzers erkennt man daran, dass keiner
offen ist.
- [x] Aus der Vorschau in den Editor wechseln → der Editor bleibt offen 🔎
- [ ] Zurück-Geste im Editor → schließt den Editor, nicht die App
- [ ] Zwei Ebenen (Vorschau unter Editor) → jede Geste schließt genau eine
- [ ] Ungespeicherte Änderungen: „Abbrechen" lässt die Ansicht offen
### `.txt` ging gar nicht auf
`txt` stand in `officeExtensions`. Eine 12-Byte-Notiz lief damit in den
Office-Zweig statt in den eingebauten Text-Editor – und am Telefon in die
PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin;
das war die Asymmetrie, über die man stolperte.
- [x] `.txt` antippen → eingebauter Text-Editor, Bearbeiten und Speichern 🔎
- [x] `.md` unverändert
- [ ] `.csv` weiterhin im Tabellenprogramm (bleibt bewusst Office)
- [ ] „Edit in Office…" erscheint für `.txt` nicht mehr im Kontextmenü
---
## Meldungen mit Rückgängig (18.09.2026)
Vorher bestätigte **nichts** irgendetwas, und Fehler verschwanden still: Die
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
klappte – nichts.
- [ ] Datei löschen → Meldung mit „Undo", Datei ist zurück 🔎
- [ ] Mehrere löschen → eine Meldung, „Undo" holt alle zurück
- [ ] Umbenennen → „Undo" stellt den alten Namen her
- [ ] Verschieben → „Undo" bringt die Datei an den alten Ort
- [ ] Ordner löschen → **kein** „Undo", stattdessen der Hinweis auf den
Papierkorb (der Ordner selbst ist wirklich weg)
- [ ] Ohne Schreibrecht löschen → rote Meldung mit dem Satz des Servers
- [ ] Netz trennen, dann löschen → „No connection to the server."
- [ ] Meldung verschwindet nach 4 s, mit Knopf erst nach 10
- [ ] Fünfte Meldung verdrängt die erste (höchstens vier stehen)
- [ ] Betriebssystem auf „Bewegung reduzieren" → keine Einblendung
### Wo es weiterhin still bleibt – und warum
Nicht jeder verschluckte Fehler ist einer. Diese bleiben absichtlich stumm,
damit niemand sie später "nachrüstet":
| Stelle | Grund |
|---|---|
| `SearchBox` | Bricht bei jedem Tastendruck die vorige Suche ab. Eine Meldung je Anschlag wäre nur im Weg. |
| `FileInfoDialog` (Prüfsumme kopieren) | Ohne HTTPS gibt es keine Zwischenablage – der Wert steht vollständig da und lässt sich markieren. |
| `useAppSettings` | Erreichbarkeit des Servers ist nicht Sache des Themings; die Vorgaben bleiben stehen. |
| `TagDialog` (Absender-Vorschläge) | Ohne Vorschläge kann man trotzdem tippen. |
| `ProfilePage` (OCR-Stand) | Hintergrundabfrage im Sekundentakt. |
| `useAuth` | Kein Konto = abgemeldet, das ist die Antwort und kein Fehler. |
Wo ein Dialog seinen Fehler **bei sich** anzeigt (ShareWithDialog, ThemingTab
beim Hochladen, TagDialog, CalendarPage, OfficeEditor, FilePreview), bleibt es
dabei: Dort gehört die Meldung hin, nicht an den unteren Bildschirmrand.
### Zwei Fehler, die schlimmer waren als Schweigen
**ProfilePage meldete "gespeichert", wenn das Speichern fehlgeschlagen war.**
`updateProfile(...).catch(() => {})`, danach ungeprüft das Häkchen. Das ist die
übelste Sorte: Sie behauptet das Gegenteil dessen, was geschehen ist.
**TagEditDialog riet die Ursache.** Jeder Fehler meldete "That name is already
taken" – auch ein Netzausfall, auch ein 500er. Jetzt steht dort der Satz des
Servers. Im TagDialog war genau das schon einmal aufgefallen und behoben
worden; hier stand es noch.
- [ ] Netz trennen, Profilnamen speichern -> rote Meldung, **kein** Häkchen
- [ ] Netz trennen, Etikett umbenennen -> "No connection to the server.",
nicht "That name is already taken"
- [ ] Papierkorb: Wiederherstellen und Leeren melden sich
- [ ] Freigabe-Link kopieren ohne HTTPS -> Hinweis auf HTTPS statt gar nichts
- [ ] Freigabe-Link löschen -> Meldung, **kein** Rückgängig (ein neuer Link
bekäme ein neues Token; der alte bliebe tot)
## Der Zurück-Fehler und was der Test **nicht** kann (16.–18.09.2026)
Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu
öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; deren Aufräumer rief
`history.back()`, das verzögert eintraf und den Eintrag verbrauchte, den der
Editor gerade angelegt hatte.
**Die erste Behebung war selbst fehlerhaft** und ist erst durch die neuen
jsdom-Tests aufgefallen: Sie zählte selbst ausgelöste Sprünge mit. Das setzt
voraus, dass jedes `back()` auch ein `popstate` nach sich zieht – und das gilt
nicht. Folgt im selben Durchlauf ein `pushState`, verwirft der Browser den
Sprung. Der Zähler blieb dann für immer stehen, und die **nächste** echte
Zurück-Geste wurde verschluckt: Statt die Ansicht zu schließen, hätte man die
Anwendung verlassen.
Jetzt wird erst im nächsten Durchlauf entschieden **und** geprüft, ob der
eigene Eintrag überhaupt noch obenauf liegt. Nur dann kommt es zum Sprung – und
dann folgt das `popstate` garantiert.
- [x] Vier jsdom-Tests in `useBackClose.test.tsx`, unter Mutation nachgewiesen:
Der Schalter bleibt stehen → der Übergangstest wird rot.
> **Was diese Tests nicht leisten.** Den ursprünglichen Fehler bilden sie
> **nicht** ab. jsdom verwirft einen noch nicht ausgeführten Sprung, sobald
> `pushState` folgt – das verhängnisvolle `popstate` entsteht dort also nie.
> Nachgeprüft, indem die alte, kaputte Fassung wieder eingesetzt wurde: **alle
> Tests blieben grün.** Chrome auf dem Telefon verhält sich anders, und dort
> war der Fehler zu sehen.
>
> Wer diesen Fall absichern will, braucht einen echten Browser (Playwright).
> Bis dahin ist der Beleg ein Mensch mit einem Telefon.
Seit dem 18.09.2026 ist **jsdom** in den devDependencies. Die Vitest-Umgebung
bleibt `node` (schnell); wer ein DOM braucht, schreibt
`// @vitest-environment jsdom` an den Anfang seiner Datei.
---
## Zeichnen und Mindmaps (27.09.2026)
Auf Excalidraw (MIT). **Eine Zeichnung ist eine Datei, kein neues Ding** – das
ist die ganze Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie
alles, was Aether fuer Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
Versionen, Freigabelinks, interne Freigaben, Suche. Keine neue Tabelle, kein
zweites Rechtemodell.
Gespeichert wird ueber denselben Upload wie alles andere – gleicher Name,
gleicher Ordner, wie beim Text-Editor in der Vorschau.
### Drei Sorgen, alle nachgemessen
**Das Hauptbuendel darf nicht wachsen.** Wer nie zeichnet, soll den Brocken nie
holen. Nachgeladen wie der PDF-Betrachter:
| | vorher | nachher |
|---|---:|---:|
| Hauptbuendel `index-*.js` | 484,11 kB | **485,22 kB** |
| Zeichenbrett (eigenes Stueck) | – | 584 kB + 142 kB CSS |
**Schriften.** Die Sicherheitsrichtlinie ist `default-src 'self'` ohne eigenes
`font-src` – Excalidraw haette seine handgezeichneten Schriften von aussen
geladen und still eine Ersatzschrift gezeigt. Sie werden jetzt beim Bauen ins
`public/`-Verzeichnis kopiert (`vite.config.ts`) und ueber
`window.EXCALIDRAW_ASSET_PATH` gefunden.
> Der Pfad wird **im Modul** gesetzt, nicht per Inline-Skript in `index.html`:
> Das waere von derselben Richtlinie blockiert (`script-src 'self'`).
> **Xiaolai bleibt draussen.** Die chinesische Schrift ist 13 der 14 MB; alles
> andere zusammen 512 KB. Wer Chinesisch in eine Zeichnung tippt, bekommt eine
> Ersatzschrift – der Preis dafuer, das Abbild nicht um 13 MB aufzublaehen.
**Reihenfolge der Dateiarten.** `.excalidraw` wird **vor** `code` und `text`
geprueft: `json` steht in `codeExt`, und stuende die Pruefung spaeter, oeffnete
eine Zeichnung im Texteditor als Zahlenkolonne.
### Kein gleichzeitiges Arbeiten – und was das kostet
Die Komponente koennte es (`isCollaborating`), aber den Transport bringt sie
nicht mit: Excalidraw betreibt dafuer einen eigenen WebSocket-Dienst, Nextcloud
hat fuer seine Whiteboard-App ein eigenes Node-Backend gebaut. Das waere ein
zweiter Container neben Aether, so wie Euro-Office einer ist – eine eigene
Entscheidung, kein Nebenprodukt.
Solange es den nicht gibt, gilt: **Zwei Leute speichern nacheinander, der
Zweite ueberschreibt den Ersten.** Zwei Dinge fangen das ab:
1. Vor dem Speichern wird geprueft, ob sich die Fassung geaendert hat. Wenn ja,
wird **gefragt** statt ueberschrieben.
2. Zeichnungen behalten ihre Fassungen **von der ersten Minute an**.
Punkt 2 ist erst durch den Selbsttest aufgefallen: Versionierung ist sonst
bewusst abgeschaltet (Migration 000006), damit der Speicher nicht volllaeuft.
Fuer Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder
bearbeitet und sind ein paar Kilobyte gross. **Ohne sie waere die Warnung im
Editor ein Versprechen ohne Deckung** gewesen.
### Selbsttest
`scripts/zeichnung-selbsttest.py`, 12 Pruefungen. Er prueft genau die These
nach, auf der die Umsetzung beruht: Wenn eine Zeichnung wirklich eine Datei
ist, muss sie koennen, was Dateien koennen.
- [x] Anlegen, Pruefsumme, Inhalt kommt unveraendert zurueck
- [x] Aendern erzeugt eine Fassung in der Historie
- [x] Jede Speicherung hebt die Fassungsnummer – darauf beruht der Abgleich
- [x] Freigabelink darauf, oeffentlich abrufbar
- [x] Zeichnung im Gruppenordner
- [x] Excalifont wird vom eigenen Server ausgeliefert
### Vorschaubild und Politur (29.09.2026)
**Vorschaubilder entstehen sonst serverseitig** aus den hochgeladenen Bytes –
bei einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer
sie gerade bearbeitet, hat aber den Browser, der sie ohnehin zeichnet.
Neu: `POST /api/v1/files/{id}/thumbnail`. Nach dem Speichern schickt der Editor
ein PNG hinterher.
> **Den Bytes wird nicht geglaubt.** Sie werden nicht abgelegt, sondern durch
> denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
> verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
> uebliche Groesse; nichts anderes kann im `thumbs`-Bereich landen. Dazu
> Schreibrecht auf die Datei: Wer sie nicht aendern darf, bestimmt auch nicht,
> wie sie in der Liste aussieht.
Das Hochladen laeuft **nach** dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schoenheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb auch stumm.
### Was am Zeichenbrett hueebscher wurde
* **Dunkles Thema.** Ein weisses Zeichenbrett in einer dunklen Oberflaeche war
der auffaelligste Bruch. Umschalten geht weiterhin im Menue – fuer einen
Ausdruck ist Hell die bessere Wahl.
* **„Unsaved" als Marke** statt eines Punktes hinter dem Namen. Das Uebersehene
war hier die ungespeicherte Arbeit.
* **Strg+S / Cmd+S** speichert.
* Dateisymbol und Name ohne die Endung in der Kopfzeile.
* **„Open" und „Save to…" sind aus.** Beide gehen am Browser vorbei: „Open"
ersetzte den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…"
schriebe eine zweite Fassung in den Download-Ordner, die niemand
wiederfindet. In Aether liegt die Datei da, wo sie liegt.
- [x] Vorschaubild setzen (204), Liste weiss davon, laesst sich abrufen
- [x] Was kein Bild ist -> 400. Fremder Typ -> 415.
- [ ] Zeichnung speichern -> nach kurzer Zeit zeigt die Kachel das Bild 🔎
- [ ] Zeichnung aendern und neu speichern -> das Bild zieht nach
- [ ] Strg+S speichert, „Unsaved" verschwindet
### Warum Excalidraw und nicht tldraw
tldraw sieht besser aus und ist angenehmer zu bedienen – aber es ist **nicht
quelloffen**: proprietaere Lizenz, Wasserzeichen, und Produktivbetrieb nur mit
bezahlter Lizenz. Fuer eine selbst betriebene Cloud, die laufen soll, ohne dass
jemand jaehrlich etwas verlaengert, faellt es damit aus.
Excalidraw ist MIT. draw.io waere die Alternative (Apache-2.0), zielt aber auf
Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich
schwerere Oberflaeche mit.
### Feinschliff: zwei stille Wege in den Datenverlust (30.09.2026)
Beim Durchlesen mit fremdem Blick gefunden, beide nachgestellt.
**Ein Fehler beim Laden ergab ein leeres Zeichenbrett.** `fetch` wirft bei 404
oder 403 **nicht**. Der Rumpf `{"error":"file not found"}` ist gueltiges JSON,
`JSON.parse` gelang, `elements` war `undefined` – und die Zeichnung ging leer
auf. Sie *sah aus*, als waere sie verloren. Wer dann speichert, macht es wahr.
Nachgestellt gegen die laufende Instanz:
GET /api/v1/files/<erfunden>/download?inline=1
Status: 404
Rumpf : {"error":"file not found"}
JSON.parse gelingt -> ['error'] d.elements -> None
**Weiterzeichnen waehrend des Speicherns gingen verloren.** Der Upload lief mit
dem Stand von Sekunde 0; die Fassungsnummer wurde hinterher aus
`aktuell.current` gelesen – und das war inzwischen ein anderer Stand. Die neuen
Striche galten als gespeichert, ohne je hochgegangen zu sein, und verschwanden
beim Schliessen ohne Rueckfrage.
Jetzt wird der Stand **einmal festgehalten**: Er geht hoch, seine Nummer wird
vermerkt, und das Vorschaubild zeigt denselben. Ist inzwischen weitergezeichnet
worden, steht „Unsaved" sofort wieder da – richtig so.
Das Laden steht dafuer jetzt in `ladeZeichnung()` statt im Effekt: Der Weg hat
drei Fallen, und zwei enden in verlorener Arbeit – das gehoert pruefbar.
- [x] Vier Tests fuer das Laden, unter Mutation nachgewiesen: Ohne die
Statuspruefung werden zwei davon rot.
### Was bewusst so bleibt
**Leserecht faellt erst beim Speichern auf.** `FileItem` traegt kein
Rechtefeld; die Oberflaeche kann vorher nicht unterscheiden. Wer eine geliehene
Zeichnung nur lesen darf, kann darin zeichnen und erfaehrt es beim Speichern –
mit dem Satz des Servers. Das Zeichenbrett bleibt dabei offen, die Arbeit ist
also nicht weg. Genauso verhaelt sich der Text-Editor seit jeher; es einzeln zu
loesen hiesse, einen Rechte-Endpunkt nur fuer diesen Fall zu bauen.
### Drei Nachbesserungen aus dem Testen (29.09.2026)
**„Close without saving?" kam, obwohl gerade gespeichert war.** Der Schalter
„geaendert" wurde in `onChange` gesetzt – und das feuert bei jeder Kleinigkeit,
auch bei Mauszeiger, Auswahl und Bildlauf. Sekunden nach dem Speichern stand er
wieder auf „offen".
Jetzt zaehlt `getSceneVersion` den **Inhalt** statt der Bedienung. Stimmt die
Zahl mit der zuletzt gespeicherten ueberein, gibt es nichts zu sichern.
**Das Vorschaubild kam nicht in der Liste an.** Der Editor laedt es *nach* dem
Speichern hoch – die Liste kannte aber nur die Antwort des Uploads, und die
entstand, bevor es das Bild gab. Ohne Neuladen blieb die Kachel leer. Sie wird
jetzt ein zweites Mal benachrichtigt.
**Rueckfragen als eigener Dialog.** Vorher `window.confirm` – ein
Browser-Fenster mitten in einer Oberflaeche, die sonst ihre eigene Sprache
spricht. Jetzt derselbe `ConfirmDialog` wie ueberall.
> Beim Schliessen ueber die Zurueck-Geste gibt der Griff `false` zurueck,
> solange der Dialog steht. Sonst waere der Verlaufseintrag schon verbraucht,
> und ein „Cancel" liesse die naechste Zurueck-Geste aus der Anwendung fuehren.
**Kein Escape zum Schliessen** – die Beschriftung versprach es, es gab ihn nie,
und er waere falsch: Das Zeichenbrett braucht die Taste selbst (Abwaehlen,
Werkzeug verlassen). Wer sie abfaengt, schliesst die Zeichnung, waehrend jemand
nur ein Rechteck abwaehlen wollte. Das Versprechen ist raus.
- [ ] Speichern, dann schliessen -> **keine** Rueckfrage 🔎
- [ ] Etwas zeichnen, dann schliessen -> Rueckfrage im Aether-Dialog
- [ ] Im Dialog „Cancel" -> Zeichenbrett bleibt, Zurueck-Geste greift weiter
- [ ] Speichern -> Kachel zeigt das Bild **ohne** Neuladen 🔎
- [ ] Nur etwas auswaehlen oder scrollen -> „Unsaved" erscheint **nicht**
### Beim zweiten Oeffnen blieb es leer (29.09.2026)
Eine Zeichnung liess sich anlegen und bearbeiten – **beim zweiten Oeffnen**
blieb das Zeichenbrett leer.
Ursache: Die erste Fassung schrieb den `appState` roh mit `JSON.stringify`
heraus. Darin steckt `collaborators`, und das ist eine `Map`. JSON kennt keine
Maps; es wurde `{}` daraus. Beim Laden bekam Excalidraw ein leeres Objekt, wo
es eine Map erwartet, und brach ab. Beim **ersten** Oeffnen fiel es nicht auf –
da war noch nichts gespeichert.
Behoben mit den Werkzeugen des Pakets: `serializeAsJSON` schreibt nur, was eine
Zeichnung ausmacht, `restore` setzt beim Laden zusammen, was fehlt. Selbst zu
pruefen hiesse, Excalidraws Feldliste nachzubauen und bei jeder Fassung
nachzuziehen.
**Eine Sache erledigt `restore` allerdings nicht** – nachgemessen, nicht
angenommen: Ein bereits verungluecktes `collaborators: {}` reicht es
**unveraendert durch**; die Map entsteht nur, wenn das Feld ganz fehlt. Neue
Dateien haben es nicht mehr, aber die aus jener Stunde waeren dauerhaft kaputt
gewesen. Deshalb wird es beim Laden zusaetzlich entfernt.
- [x] Fuenf Tests in `drawing.test.ts` (jsdom), darunter einer, der genau
festhaelt, dass `restore` allein nicht reicht.
> **Zwei Stolpersteine beim Testen**, beide in `vite.config.ts` vermerkt:
> Excalidraw importiert `roughjs/bin/rough` ohne Endung – Nodes Aufloesung
> verlangt sie, der Buendler nicht. Deshalb laeuft es unter
> `server.deps.inline`. Und es fasst beim Laden ein Canvas an, das jsdom nicht
> hat; ein Platzhalter im Test genuegt.
### Von Hand zu pruefen
- [ ] `+ Drawing` legt eine Zeichnung an und oeffnet sie 🔎
- [ ] Zeichnung schliessen und **erneut oeffnen** -> der Inhalt ist da 🔎
- [ ] Zeichnen, Rechteck, Text darin, Pfeil andocken – Block verschieben, der
Pfeil folgt (das ist der Mindmap-Fall)
- [ ] Die Schrift sieht handgezeichnet aus, nicht nach Arial
- [ ] Speichern -> Punkt neben dem Namen verschwindet, Meldung erscheint
- [ ] Schliessen mit ungespeicherten Aenderungen -> Rueckfrage
- [ ] Zurueck-Geste im Zeichenbrett -> schliesst es, nicht die App
- [ ] Am Telefon: Geht es mit dem Finger?
- [ ] In zwei Browsern oeffnen, in beiden etwas zeichnen, nacheinander
speichern -> der Zweite bekommt die Rueckfrage
## Der Abgleich bekommt eine Notbremse (26.09.2026)
`internal/rescan` ist das einzige Stueck Aether, das **hart** loescht:
`DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang ueber
die Platte nicht gesehen wurde.
Der Ernstfall ist nicht ausgedacht ([localfs.go:199](../backend/internal/storage/localfs.go)):
if _, err := os.Stat(base); os.IsNotExist(err) {
return nil // kein Fehler, keine Eintraege
}
Existiert das Datenverzeichnis nicht, meldet `Walk` **keinen Fehler** – es
meldet gar nichts. Genau der Fingerabdruck eines Bind-Mounts, der nach einem
Neustart nicht hochkam, oder eines Tippfehlers in `DATA_PATH`. Dann gilt jede
Datei als verwaist, und **ein** Aufruf mit `?anwenden=true` loescht den
gesamten Bestand aus der Datenbank. Die Bytes laegen noch da, aber niemand
wuesste mehr, dass es sie gibt – und der GC raeumte sie 24 Stunden spaeter ab.
Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank
ab, nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall:
Eine Seite antwortet faelschlich mit "leer". Der GC loescht dann Blobs – die
Datenbank weiss hinterher noch, was fehlt. Hier faellt die Zeile selbst. **Die
gefaehrlichere Seite war die ungesicherte.**
### Zwei Bremsen, eine davon nicht uebersteuerbar
| Bremse | wann | bestaetigbar |
|---|---|---|
| **Notbremse** | nichts auf der Platte gefunden, Datenbank kennt Dateien | **nein** |
| **Mengenbremse** | mehr als die Haelfte wuerde fallen | ja, mit `&bestaetigt=true` |
Die leere Installation (nichts hier, nichts dort) ist ausgenommen – da gibt es
nichts zu verwechseln.
Dazu umgebaut: erst sammeln, dann entscheiden, dann loeschen. Vorher wurde
mitten in der Schleife geloescht; die Gesamtzahl kennt man dort erst, wenn die
Haelfte schon weg ist, und eine Bremse kaeme zu spaet.
- [x] Fuenf Tests in `bremse_test.go`. Unter Mutation nachgewiesen: Wird die
Notbremse entfernt, werden `TestNotbremseBeiLeeremSpeicher` **und** der
Ernstfalltest rot.
- [x] Ernstfall am echten Geraet: fuenf Zeilen in der Datenbank, ein
Wurzelverzeichnis ohne `users/`. Nach `Apply` stehen alle fuenf noch –
auch mit ausdruecklicher Bestaetigung.
- [ ] Im Admin-Bereich: "Rescan storage" -> Check -> Apply bei heilem Bestand
arbeitet wie vorher 🔎
- [ ] Ein abgebrochener Lauf steht rot in der Karte, mit Grund, und die Karte
bleibt im Zustand "geprueft" (der Anwenden-Knopf verschwindet nicht)
- [ ] Ein abgebrochener Lauf steht im Audit-Log – gerade der: Er ist der
Hinweis darauf, dass mit dem Datenordner etwas nicht stimmt
### Nebenbefund: der Teilen-Selbsttest leckte App-Passwoerter
`kalender-teilen-selbsttest.py` legte je Lauf zwei App-Passwoerter an und
raeumte nur das des Empfaengers weg (das faellt mit dessen Konto). Das des
Besitzers blieb liegen. Nach **achtzehn** Laeufen war
`auth.maxAppPasswords = 20` erreicht, und caldav wie kalender-teilen
scheiterten mit `409` – ohne dass am Code etwas kaputt gewesen waere.
Aufgeraeumt und behoben; zweimal hintereinander laufen lassen, danach stehen
wieder nur die beiden echten (`CalDAV`, `DAV`).
## Freigabelinks: Prüfsumme, Gruppenordner, Grenzen (25.09.2026)
Beim Lesen von `internal/sharing` fiel eine ungleiche Zusage auf: Der
**angemeldete** Download prüft seit dem 12.09. die SHA-256-Summe beim
Ausliefern und lässt die Verbindung fallen, wenn sie nicht stimmt. Der
**öffentliche Freigabelink** lief durch ein blankes `io.Copy` – ungeprüft.
Ausgerechnet der Weg nach draussen. Wer die Datei angemeldet holt, ist der
Eigentümer und merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es
nicht. In CLAUDE.md steht das unter "nicht verhandelbar": beim Upload
berechnen, **beim Download verifizieren**. Die Regel galt an einer von zwei
Stellen.
Die Prüfmechanik liegt jetzt in `files/verified.go` (`ServeVerified`) und wird
von beiden gerufen, statt ein zweites Mal geschrieben zu werden.
- [x] Bit auf der Platte kippen, Datei ueber den Freigabelink holen -> kommt
**nicht** vollstaendig an. Mit 200-KB- und 900-Byte-Datei geprueft, der
Selbsttest macht das jetzt fuer beide Wege.
### Gruppenordner lassen sich verlinken
`Create` uebergab `user.ID` statt des aufgeloesten Prinzipals. Eine Datei in
einem Gruppenordner liess sich damit nicht verlinken – auch nicht von einem
Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete "file not
found".
Marcs Entscheidung: Das soll gehen, der Empfaenger sieht ohnehin nur die
Datei, nie den Ordner darum.
**Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst, die
Datei oeffentlich zu machen. Wer sie nur geliehen lesen darf, soll sie nicht
weiterreichen koennen -> 403.
- [x] Datei im Gruppenordner verlinken -> 201, Link funktioniert oeffentlich
- [x] Nur-Lese-Freigabe -> eigener Freigabelink wird mit 403 abgelehnt
### Zwei Kleinigkeiten
**`expires_in_hours` hatte keine Obergrenze.** `time.Duration` rechnet in
Nanosekunden; ab etwa 2,6 Millionen Stunden laeuft `int64` ueber, die Dauer
wird **negativ**, und der Link ist in dem Moment abgelaufen, in dem er
entsteht – ohne Fehlermeldung. Jetzt zehn Jahre Obergrenze mit klarer
Ablehnung darueber.
**`X-Content-Type-Options: nosniff`** fehlte. `attachment` traegt die
Hauptlast, aber der angemeldete Weg setzt beides.
- [x] `expires_in_hours: 9000000000` -> 400 mit Begruendung
- [x] `expires_in_hours: 24` -> 201
- [x] Oeffentlicher Download setzt nosniff
### Go-Tests fuer `internal/sharing`
Sechs Testfunktionen, unter Mutation nachgewiesen:
| Mutation | wird rot |
|---|---|
| Eigentuemerpruefung beim Loeschen entfernt | `TestFremderLinkLaesstSichNichtLoeschen` |
| Ablaufpruefung entfernt | `TestAbgelaufenerLinkGiltNichtMehr` |
| Token auf 6 Byte verkuerzt | `TestTokenIstNichtZuErraten` |
Dazu: Die Liste zeigt nur eigene Links, der Passwort-Hash verlaesst den
Speicher nur beim Tokenabruf (dort wird er gebraucht), der Zaehler zaehlt.
> **Falle beim Erweitern des Selbsttests:** `GET /api/v1/auth/me` dreht den
> CSRF-Token weiter. Wer ihn zwischendurch ruft und den alten weiterbenutzt,
> bekommt 403 – genau das ist mir passiert, obwohl es seit dem 12.09. hier
> steht.
## Ein Wächter für die Sprache (24.09.2026)
Sechs Tage nach der Übersetzung fielen beim Nachsehen **fünf Reste** auf:
`label="Ganztägig"`, `aria-label="Kalendermenü"`, `label="Bearbeiten"` und
zweimal `"Löschen"`. Alle in **doppelt** gequoteten JSX-Attributen – die
Ersetzungsliste hatte nur die einfach gequoteten, und das damalige Prüfskript
hielt Einzelwörter ohne Leerzeichen für Bezeichner.
Deshalb steht die Prüfung jetzt als Test (`src/sprache.test.ts`) statt als
Skript, das man einmal laufen lässt. Sie läuft bei jedem `npm test` mit.
**Der erste Wächter taugte nichts, und das kam nur durch die Mutation heraus:**
Er prüfte allein auf Umlaute und liess `"Papierkorb ist leer"` anstandslos
durch. Wer einen Wächter baut, muss ihn einmal angreifen.
Jetzt: Umlaute **plus** eine Liste häufiger deutscher Wörter mit Wortgrenzen
(sonst schlägt "ist" in "list" an). Kommentare werden vorher entfernt – und
zwar richtig, mit einem kleinen Durchlauf statt zeilenweise: Blockkommentare
haben Fortsetzungszeilen ohne führenden Stern, die beim ersten Versuch fünf
Fehlalarme erzeugt haben.
Nachgewiesen an vier Mutationen, alle werden rot:
| eingeschleuster Text | erkannt an |
|---|---|
| `Papierkorb ist leer` | "ist", "leer" |
| `Loeschen fehlgeschlagen` | "Loeschen", "fehlgeschlagen" |
| `Ganztägig` | Umlaut |
| `Bitte warten` | "bitte" |
> **Ein Netz, kein Beweis.** Ein deutscher Satz ohne Umlaut und ohne eines der
> gelisteten Wörter käme durch – "Datum bereits vergeben" etwa. Der Test
> fängt, was erfahrungsgemäß passiert: ein vergessenes Attribut beim
> Übersetzen. Wer die Liste ergänzt, weil einmal etwas durchgerutscht ist, tut
> genau das Richtige.
- [x] Gebautes Bündel enthält keine deutschen Oberflächentexte mehr –
gegengeprüft, dass die englischen drin sind (sonst prüfte der Grep nur
sich selbst)
## Wochentage und Monate aus `Intl` (20.09.2026)
Sechs handgeschriebene Arrays in drei Dateien, und `Intl` kam im ganzen Projekt
nicht vor – obwohl jeder Browser es seit Jahren mitbringt.
Die Doppelpflege war nicht nur lästig, sie war eine Falle: `DayDialog` begann
seine Woche am **Sonntag**, die anderen am **Montag**, jeweils passend zu ihrer
Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays in eine
neue Ansicht kopiert hätte, hätte sich einen Off-by-one gebaut, der nur an
sieben Tagen im Jahr auffällt.
Jetzt eine Ordnung (Montag zuerst, wie `weekdayIndex`) plus eine ausdrücklich
benannte zweite Liste `weekdayLongNamesSun` für alles, was direkt mit
`getDay()` indiziert.
**Verhaltensgleich belegt:** Die erzeugten Namen stimmen zeichengenau mit den
gelöschten Arrays überein – vor dem Austausch verglichen, nicht danach gehofft.
**Die Sprache ist fest auf `en` gesetzt, nicht die des Browsers.**
`Intl.DateTimeFormat(undefined, …)` nähme die Einstellung des Rechners und
setzte auf einem deutschen System „Montag" neben ein englisches „Repeat" –
genau der Mischmasch, der am 18.09. ausgeräumt wurde. Kommt eine Sprachwahl,
ist `uiLocale` in `dates.ts` die eine Stelle, an der sie andockt.
- [x] Fünf Tests in `dates.test.ts`, unter Mutation nachgewiesen: Nimmt man
dem Sonntag seinen Platz vorn, wird der Test rot.
- [ ] Monatsansicht: Kopfzeile Mon–Sun, Termine am richtigen Tag 🔎
- [ ] Wochenansicht: Datumszeile stimmt mit dem Wochentag überein
- [ ] Tagesdialog: Überschrift nennt den richtigen Wochentag (der Fall mit
der Sonntags-Liste) 🔎
- [ ] Serientermin „An diesen Tagen": Beschriftung passt zum gesetzten Tag –
anlegen, in DAVx5 prüfen, ob derselbe Wochentag ankommt
- [ ] Listenansicht: Datum als „September 18, 2026"
## Platzhalter und Leerzustände (18.09.2026)
Beim Laden stand ein Kreisel in der Mitte, danach klappte das Layout um. Jetzt
steht dort ein Platzhalter **in der Form des Kommenden** – Kacheln in der
Kachelansicht, Zeilen in der Liste –, und nichts springt mehr, wenn die Daten
ankommen. Die Maße sind aus `FileViews.tsx` abgeschrieben, nicht geschätzt;
weichen sie ab, springt es wieder.
- [ ] Ordner öffnen → Platzhalter in der Form der gewählten Ansicht 🔎
- [ ] Beim Umschalten zwischen Kachel und Liste wechselt auch der Platzhalter
- [ ] Datenankunft ohne Sprung: Die Kacheln stehen dort, wo die Platzhalter
standen
- [ ] Papierkorb, „Für mich freigegeben", Etiketten, Kalender: ebenfalls
Platzhalter statt „Loading…"
- [ ] Betriebssystem auf „Bewegung reduzieren" → kein Pulsieren, nur gedämpfte
Flächen
Leerzustände sagen jetzt, **warum** nichts da ist, und bieten den Knopf an,
der an dieser Stelle weiterhilft:
- [ ] Leerer Ordner → „This folder is empty" mit Knopf „Upload files"
- [ ] Wurzel ohne Inhalt → „Nothing here yet"
- [ ] Leerer Papierkorb → **kein** Knopf (ein leerer Papierkorb ist ein guter
Zustand, kein Mangel)
- [ ] Nichts freigegeben → „Nothing shared with you yet"
- [ ] Etikett ohne Dateien → Hinweis auf „Organise…"
### Keine „Geister" beim Wechseln
Der Platzhalter kommt erst nach **220 ms** (`useSkeleton`). Ist die Liste
vorher da, wird er nie sichtbar; beim Ordnerwechsel bleibt solange die
vorherige Liste stehen, weil `load()` sie nicht löscht.
Die Frist gilt **auch beim ersten Laden einer Seite**. Zuerst war das anders –
gedacht war: beim ersten Mal gibt es ja nichts, was stehenbleiben könnte. Beim
Wechsel zwischen den Menüpunkten entsteht die Seite aber jedes Mal neu, und
damit war *jeder* Tab-Wechsel ein „erstes Laden". Genau dort blitzte es.
- [ ] Schnell durch Ordner klicken -> **kein** Aufblitzen grauer Kästen 🔎
- [ ] Zwischen Dateien, Papierkorb, Freigaben, Etiketten, Kalender wechseln ->
ebenfalls keines 🔎
- [ ] Langsame Verbindung (Drosselung im Browser) -> nach kurzer Leere kommt
der Platzhalter
- [ ] Im Wartefenster blitzt **kein Leerzustand** auf. Das ist die
unangenehmere Sorte Geist: Der Platzhalter sieht nur nach Daten aus,
ein Satz wie „Nothing shared with you yet" behauptet etwas.
### Der Kreisel stand sechsmal im Quelltext
`@keyframes spin` lag als Inline-`<style>` in sechs Komponenten – und zwei
Nutzer (`AuditTab`, `SearchBox`) hatten **gar keine** eigene Kopie. Dass es
trotzdem lief, war Zufall: `@keyframes` gilt fürs ganze Dokument, es genügte
also, dass irgendeine der sechs gerade im Bild war. Kaputt wäre es an dem Tag
gewesen, an dem jemand die letzte Kopie entfernt. Steht jetzt einmal in
`index.css`.
- [ ] Kreisel dreht sich noch: Suchfeld tippen, Admin → Audit laden,
Versionen-Dialog öffnen 🔎
### Der Sprachmix ist weg (18.09.2026)
Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch.
Jetzt spricht alles Englisch – 80 Stellen in sechs Dateien. Shell, TagChip und
UserMenu standen nur wegen ihrer **Kommentare** auf der Verdachtsliste; deren
Oberfläche war längst englisch.
Kommentare bleiben deutsch. Das Ersetzen hat Kommentarzeilen deshalb
übersprungen.
- [ ] Monatsansicht: Wochentage Mon–Sun, Monatsnamen englisch 🔎
- [ ] Umschalter Month / Week / List, Knopf „Today"
- [ ] Termin anlegen: Title, Calendar, All day, Start/End, Repeat, Color,
Location, Description
- [ ] Wiederholung: Does not repeat / Daily / Weekly / Monthly / Yearly,
Ends: Never / After / On a date
- [ ] Serientermin löschen: „Only this event" / „Entire series" / „Cancel"
- [ ] Kalendermenü: Edit / Share… / Export (.ics) / Import (.ics) / Delete
- [ ] Leerer Zeitraum → „No events"
### Der Standardkalender heißt „Personal"
Automatisch angelegt wurde er als „Persönlich". Migration 000027 zieht die
bestehenden nach – **bewusst eng gefasst**: nur Zeilen mit Name *und* Farbe des
Automaten (`#4285f4`). Wer sich selbst einen Kalender „Persönlich" angelegt
hat, behält seinen Namen; das ist seine Beschriftung, nicht unsere.
- [x] Nach der Migration meldet die API `'Personal'` – geprüft, nicht nur
„Migration gelaufen".
---
## Absender / Korrespondent (23.08.2026)
Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer**
+48 -11
View File
@@ -32,11 +32,13 @@ import (
"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"
@@ -125,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()
@@ -175,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")
@@ -204,7 +223,7 @@ func main() {
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
}
}()
})
}
}
@@ -217,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
@@ -245,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
@@ -276,7 +298,7 @@ func main() {
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
// CalDAV
calStore := calendar.NewStore(pool)
calStore := calendar.NewStore(pool, aclStore)
calHandler := calendar.NewHandler(authStore, calStore)
calAPIHandler := calendar.NewAPIHandler(calStore)
@@ -482,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
@@ -489,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)
@@ -546,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)
@@ -585,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)
@@ -624,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...")
@@ -689,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 {
@@ -706,7 +743,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
return
}
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
}()
})
}
}
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS cal_tombstones;
DROP INDEX IF EXISTS cal_objects_sync;
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
@@ -0,0 +1,38 @@
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
--
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
--
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
-- bei dem es zuletzt angefasst wurde.
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
UPDATE cal_objects o SET sync_seq = c.ctag
FROM cal_calendars c WHERE c.id = o.calendar_id;
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
-- Art, weil er unsichtbar ist.
CREATE TABLE IF NOT EXISTS cal_tombstones (
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
sync_seq BIGINT NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_acl_calendar;
DROP INDEX IF EXISTS idx_acl_group_calendar;
DROP INDEX IF EXISTS idx_acl_user_calendar;
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
@@ -0,0 +1,31 @@
-- Kalender an andere Konten freigeben.
--
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
-- auf, wenn jemand sieht, was er nicht sehen soll.
--
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
ALTER TABLE acl_entries
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
-- mehrdeutig: welches Recht gilt dann?
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
ON acl_entries (subject_user_id, resource_calendar_id)
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
ON acl_entries (subject_group_id, resource_calendar_id)
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
CREATE INDEX IF NOT EXISTS idx_acl_calendar
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
@@ -0,0 +1,4 @@
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
-- „noch nichts gewandelt".
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
@@ -0,0 +1,11 @@
-- Die Vorschau als PDF ist entfallen (16.09.2026).
--
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
-- welcher Fassung das abgelegte PDF gehörte.
--
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,5 @@
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
UPDATE cal_calendars
SET name = 'Persönlich'
WHERE name = 'Personal'
AND color = '#4285f4';
@@ -0,0 +1,11 @@
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
-- zieht die bereits angelegten nach.
--
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
UPDATE cal_calendars
SET name = 'Personal'
WHERE name = 'Persönlich'
AND color = '#4285f4';
+4 -4
View File
@@ -15,7 +15,7 @@ require (
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
)
@@ -31,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
)
+8 -8
View File
@@ -111,21 +111,21 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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
}
+23 -3
View File
@@ -31,7 +31,19 @@ func NewAPIHandler(store *Store) *APIHandler {
// GET /api/v1/calendars
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
cals, err := h.store.ListCalendars(r.Context(), user.ID)
// Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst
// steht ein neues Konto vor einer leeren Ansicht und muss erst erraten,
// dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich
// vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht
// zeigt.
if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
response.Error(w, http.StatusInternalServerError, "list calendars failed")
return
}
// Eigene **und** freigegebene. Jeder Eintrag trägt sein Recht mit, damit
// die Oberfläche einen nur lesbaren Kalender gar nicht erst zum
// Bearbeiten anbietet.
cals, err := h.store.ListAccessible(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "list calendars failed")
return
@@ -116,7 +128,8 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
cal, err := h.store.GetCalendar(r.Context(), user.ID, id)
// Ausgeben darf, wer lesen darf – auch bei einem freigegebenen Kalender.
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, false)
if err == ErrNotFound {
response.Error(w, http.StatusNotFound, "calendar not found")
return
@@ -125,6 +138,7 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) {
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")
@@ -178,16 +192,22 @@ func (h *APIHandler) Import(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
cal, err := h.store.GetCalendar(r.Context(), user.ID, id)
// Hineinschreiben darf nur, wer schreiben darf.
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, true)
if err == ErrNotFound {
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 {
+34 -9
View File
@@ -120,7 +120,10 @@ func parseCalendarPath(p string) (ref string, rest string, err error) {
return parts[1], rest, nil
}
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Calendar, error) {
// resolveCalendar ist der **eine** Weg von einer Adresse zu einem Kalender –
// und damit die Stelle, an der die Rechteprüfung sitzt. Jede CalDAV-Operation
// läuft hier durch; eine zweite Auflösung daneben wäre eine Tür ohne Schloss.
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Access, error) {
u, err := userFromContext(ctx)
if err != nil {
return nil, nil, err
@@ -129,7 +132,7 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C
if err != nil {
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
cal, err := b.store.GetCalendarByRef(ctx, u.ID, ref)
cal, err := b.store.ResolveRef(ctx, u.ID, ref)
if errors.Is(err, ErrNotFound) {
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
@@ -139,14 +142,36 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C
return u, cal, nil
}
func toCalDAVCalendar(username string, c *Calendar) caldav.Calendar {
// resolveWritable: dasselbe, aber der Aufrufer will schreiben. Ein nur lesend
// freigegebener Kalender darf sichtbar bleiben und trotzdem ablehnen – 403
// verrät hier nichts, was der Anfragende nicht ohnehin sieht.
func (b *Backend) resolveWritable(ctx context.Context, p string) (*auth.User, *Access, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, nil, err
}
if !cal.CanWrite() {
return nil, nil, webdav.NewHTTPError(http.StatusForbidden,
errors.New("calendar is shared read-only"))
}
return u, cal, nil
}
func toCalDAVCalendar(username string, c *Access) caldav.Calendar {
comps := []string{"VEVENT"}
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: c.Name,
Name: name,
Description: c.Description,
MaxResourceSize: 5 * 1024 * 1024,
SupportedComponentSet: comps,
@@ -194,13 +219,13 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
return nil, fmt.Errorf("ensure default calendar: %w", err)
}
all, err := b.store.ListCalendars(ctx, u.ID)
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, c := range all {
out[i] = toCalDAVCalendar(u.Username, &c)
for i := range all {
out[i] = toCalDAVCalendar(u.Username, &all[i])
}
return out, nil
}
@@ -323,7 +348,7 @@ func etagFor(data string) string {
}
func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
u, calRow, err := b.resolveCalendar(ctx, p)
u, calRow, err := b.resolveWritable(ctx, p)
if err != nil {
return nil, err
}
@@ -366,7 +391,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Cal
}
func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error {
_, calRow, err := b.resolveCalendar(ctx, p)
_, calRow, err := b.resolveWritable(ctx, p)
if err != nil {
return err
}
+9 -4
View File
@@ -393,7 +393,8 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
// Lesen genügt: Ein freigegebener Kalender soll sich ansehen lassen.
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, false); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
@@ -552,13 +553,17 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
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.GetCalendar(r.Context(), user.ID, calID); err != nil {
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, true); err != nil {
return uuid.UUID{}, false
}
return calID, true
@@ -658,10 +663,10 @@ func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusBadRequest, "calendar_id is required")
return
}
// Der Zielkalender muss demselben Konto gehören. Ohne diese Prüfung
// In den Zielkalender muss geschrieben werden dürfen. Ohne diese Prüfung
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
if _, err := h.store.GetCalendar(r.Context(), user.ID, toCal); err != nil {
if _, err := h.store.RequireCalendar(r.Context(), user.ID, toCal, true); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
+6
View File
@@ -102,6 +102,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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.
+8 -3
View File
@@ -27,9 +27,14 @@ type Calendar struct {
// 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"`
CreatedAt time.Time `json:"created_at"`
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
+39 -6
View File
@@ -42,9 +42,17 @@ const (
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
const maxPropfindBody = 1 << 20
type wantedExtras struct{ ctag, color bool }
// Vier Eigenschaften, die go-webdav nicht kennt. Die ersten beiden sind
// herstellereigen, die letzten beiden stehen im RFC – die Bibliothek liefert
// sie trotzdem nicht.
type wantedExtras struct{ ctag, color, syncToken, reports bool }
func (w wantedExtras) any() bool { return w.ctag || w.color }
func (w wantedExtras) any() bool { return w.ctag || w.color || w.syncToken || w.reports }
// alleExtras: Antwort auf <allprop> und auf einen leeren Rumpf.
func alleExtras() wantedExtras {
return wantedExtras{ctag: true, color: true, syncToken: true, reports: true}
}
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
// und baut den Rumpf ohne sie neu auf.
@@ -55,7 +63,7 @@ func (w wantedExtras) any() bool { return w.ctag || w.color }
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
if len(bytes.TrimSpace(body)) == 0 {
return wantedExtras{ctag: true, color: true}, body
return alleExtras(), body
}
dec := xml.NewDecoder(bytes.NewReader(body))
@@ -72,7 +80,7 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
return wantedExtras{ctag: true, color: true}, body
return alleExtras(), body
}
if !inProp {
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
@@ -89,6 +97,10 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
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)
}
@@ -159,6 +171,15 @@ func appleColor(hex string) string {
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 {
@@ -169,6 +190,14 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string {
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 ""
}
@@ -182,15 +211,19 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string {
// 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 {
cals, err := h.store.ListCalendars(ctx, user.ID)
// **Accessible, nicht nur eigene**: Ein freigegebener Kalender steht mit
// in der Liste und braucht dieselben Eigenschaften – ohne ctag und
// sync-token hielte ihn der Client für unveränderlich.
cals, err := h.store.ListAccessible(ctx, user.ID)
if err != nil {
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]
byRef[cals[i].Ref()] = &cals[i].Calendar
}
const closeTag = "</response>"
+226 -29
View File
@@ -10,6 +10,8 @@ import (
"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")
@@ -21,15 +23,19 @@ 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) *Store {
return &Store{db: db}
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, created_at
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
ownerID,
)
@@ -41,7 +47,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
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.CreatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan calendar: %w", err)
}
out = append(out, c)
@@ -52,10 +58,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
ownerID, id,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -75,13 +81,13 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars
WHERE owner_id = $1 AND (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.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -102,9 +108,9 @@ func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment
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, created_at`,
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, name, color, description, segment,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if isUniqueViolation(err) {
return nil, ErrConflict
}
@@ -120,7 +126,7 @@ func isUniqueViolation(err error) bool {
}
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort
// Bedarf "Personal" an. So findet ein frisch eingerichteter Client sofort
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
// CalDAV-Client kann das.
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
@@ -131,7 +137,7 @@ func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*
if len(cals) > 0 {
return &cals[0], nil
}
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "")
return s.CreateCalendar(ctx, ownerID, "Personal", "#4285f4", "")
}
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
@@ -142,9 +148,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col
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, created_at`,
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.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert calendar: %w", err)
}
@@ -164,9 +170,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name,
description = COALESCE($5, description),
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, created_at`,
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, id, name, color, description, timezone,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -248,22 +254,35 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon
}
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, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
`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, updated_at = now()
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
sync_seq = EXCLUDED.sync_seq, updated_at = now()
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
calendarID, uid, component, data, etag, dtstart, dtend,
calendarID, uid, component, data, etag, dtstart, dtend, seq,
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert object: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return nil, fmt.Errorf("bump ctag: %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 {
@@ -303,11 +322,22 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
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, updated_at = now()
WHERE calendar_id = $2 AND uid = $3
`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, fromCal, uid,
toCal, toSeq, fromCal, uid,
)
o, err := scanObject(row)
if errors.Is(err, pgx.ErrNoRows) {
@@ -317,10 +347,14 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
return nil, err
}
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,
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal,
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
); err != nil {
return nil, fmt.Errorf("bump ctag: %w", err)
return nil, fmt.Errorf("clear tombstone: %w", err)
}
if err := tx.Commit(ctx); err != nil {
@@ -343,8 +377,171 @@ func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid stri
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return fmt.Errorf("bump ctag: %w", err)
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
}
+76 -7
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
@@ -13,10 +12,12 @@ import (
"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"
)
@@ -29,6 +30,10 @@ 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 {
@@ -36,12 +41,16 @@ type Handler struct {
fileStore *files.Store
storage storage.Storage
auditor *audit.Logger
limiter *middleware.FailureLimiter
// acl löst auf, in wessen Namen die Datei liegt. Ohne ihn ging nur, was
// dem Anfragenden persönlich gehört – siehe Create.
acl *acl.Store
limiter *middleware.FailureLimiter
}
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler {
return &Handler{
store: store, fileStore: fileStore, storage: st, auditor: auditor,
acl: aclStore,
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
}
}
@@ -54,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
}
@@ -76,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
}
@@ -229,13 +262,49 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
// 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)
go func() {
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)
}
}
-6
View File
@@ -100,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.
@@ -150,7 +145,6 @@ func Load() (*Config, error) {
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", ""), "/"),
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS cal_tombstones;
DROP INDEX IF EXISTS cal_objects_sync;
ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
@@ -0,0 +1,38 @@
-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
--
-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
--
-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
-- bei dem es zuletzt angefasst wurde.
ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
UPDATE cal_objects o SET sync_seq = c.ctag
FROM cal_calendars c WHERE c.id = o.calendar_id;
CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
-- Art, weil er unsichtbar ist.
CREATE TABLE IF NOT EXISTS cal_tombstones (
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
sync_seq BIGINT NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
-- aelteren Token bekommt eine klare Absage (<valid-sync-token/>) und macht
-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_acl_calendar;
DROP INDEX IF EXISTS idx_acl_group_calendar;
DROP INDEX IF EXISTS idx_acl_user_calendar;
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1);
@@ -0,0 +1,31 @@
-- Kalender an andere Konten freigeben.
--
-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und
-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine
-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit
-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei
-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst
-- auf, wenn jemand sieht, was er nicht sehen soll.
--
-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe**
-- kommt damit ohne eine einzige Zeile zusaetzlichen Code.
ALTER TABLE acl_entries
ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE;
-- Genau eine Ressource je Eintrag -- jetzt zu dritt.
ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource;
ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource
CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1);
-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren
-- mehrdeutig: welches Recht gilt dann?
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar
ON acl_entries (subject_user_id, resource_calendar_id)
WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar
ON acl_entries (subject_group_id, resource_calendar_id)
WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL;
-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang.
CREATE INDEX IF NOT EXISTS idx_acl_calendar
ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL;
@@ -0,0 +1,4 @@
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
-- „noch nichts gewandelt".
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
@@ -0,0 +1,11 @@
-- Die Vorschau als PDF ist entfallen (16.09.2026).
--
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
-- welcher Fassung das abgelegte PDF gehörte.
--
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,5 @@
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
UPDATE cal_calendars
SET name = 'Persönlich'
WHERE name = 'Personal'
AND color = '#4285f4';
@@ -0,0 +1,11 @@
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
-- zieht die bereits angelegten nach.
--
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
UPDATE cal_calendars
SET name = 'Personal'
WHERE name = 'Persönlich'
AND color = '#4285f4';
+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
+31
View File
@@ -102,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
+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>
)
}
+114 -12
View File
@@ -103,6 +103,36 @@ export interface PublicShareInfo {
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
@@ -159,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.
@@ -203,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)
})
@@ -306,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}`),
}
@@ -539,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',
@@ -552,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 = {
@@ -600,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)}` : ''}`,
@@ -851,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
@@ -906,6 +994,20 @@ export interface CalendarItem {
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/
+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
}
+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'
+3 -33
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
@@ -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.
+26 -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'
@@ -14,6 +15,7 @@ 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 }
@@ -44,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)
@@ -81,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)}
/>
@@ -106,7 +130,6 @@ 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}
+6 -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
@@ -775,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;
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react'
/**
* Platzhalter, solange geladen wird.
*
* Vorher stand dort ein Kreisel in der Mitte. Der sagt „warte", aber er sagt
* nicht, **worauf** – und wenn die Daten da sind, klappt das Layout um: Der
* Kreisel verschwindet, Kacheln erscheinen, die Seite springt. Ein Platzhalter
* in der Form des Kommenden tut beides besser. Er zeigt, was gleich da ist,
* und er hält den Platz, sodass nichts springt.
*
* **Die Maße sind abgeschrieben, nicht geschätzt** – aus FileViews.tsx: Kachel
* mit 12er Radius und 100 Pixel Deckfläche, Zeile mit 8/12er Innenabstand und
* 8er Radius. Weichen sie ab, springt es beim Wechsel wieder, und der ganze
* Zweck wäre dahin.
*/
/**
* Zeigen wir den Platzhalter **wirklich** – oder ist die Liste schneller da?
*
* Ohne Frist blitzt bei jedem Ordnerwechsel und bei jedem Tab-Wechsel kurz ein
* Feld grauer Kästen auf. Sie sehen aus wie Dateien, die sofort wieder
* verschwinden; Marc nannte sie „Geister", und das trifft es: Der Platzhalter
* soll Warten überbrücken, nicht Warten erfinden.
*
* **Die Frist gilt auch beim allerersten Laden.** Das war zuerst anders – ich
* hatte gedacht, beim ersten Mal gebe es ja nichts, was stehenbleiben könnte,
* also könne er sofort kommen. Beim Wechsel zwischen den Menüpunkten entsteht
* die Seite aber jedes Mal neu, und damit war *jeder* Tab-Wechsel ein „erstes
* Laden". Genau dort blitzte es dann. 220 ms Leere ist das ruhigere Bild: Man
* kommt ohnehin von einer anderen Seite, der Wechsel liest sich als Wechsel.
*
* 220 ms ist die Grenze, ab der ein Wechsel als „hakt" empfunden wird –
* darunter merkt niemand, dass überhaupt geladen wurde.
*/
export function useSkeleton(loading: boolean, frist = 220): boolean {
const [zeigen, setZeigen] = useState(false)
useEffect(() => {
if (!loading) { setZeigen(false); return }
const t = setTimeout(() => setZeigen(true), frist)
return () => clearTimeout(t)
}, [loading, frist])
return zeigen
}
/** Ein graues Feld, das leicht pulsiert. */
function Feld({ w, h, r = 6, mb }: { w: number | string; h: number; r?: number; mb?: number }) {
return (
<div
className="aether-skeleton"
style={{ width: w, height: h, borderRadius: r, marginBottom: mb }}
/>
)
}
/**
* Wie viele Platzhalter.
*
* Genug, um die sichtbare Fläche zu füllen, und nicht mehr: Ein Schirm voll
* grauer Kästen, der sich noch scrollen lässt, verspricht mehr Inhalt als da
* ist – und enttäuscht zweimal, wenn der Ordner am Ende drei Dateien hat.
*/
const kacheln = 8
const zeilen = 7
export function SkeletonGrid() {
return (
<div className="aether-grid" aria-hidden="true">
{Array.from({ length: kacheln }, (_, i) => (
<div
key={i}
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12,
overflow: 'hidden',
}}
>
<div style={{ height: 100 }}>
<Feld w="100%" h={100} r={0} />
</div>
<div style={{ padding: '8px 10px 10px' }}>
{/* Unterschiedlich lang, sonst sieht es aus wie ein Formular und
nicht wie eine Liste von Namen. */}
<Feld w={`${55 + ((i * 17) % 35)}%`} h={9} mb={6} />
<Feld w="38%" h={8} />
</div>
</div>
))}
</div>
)
}
export function SkeletonRows() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }} aria-hidden="true">
{Array.from({ length: zeilen }, (_, i) => (
<div
key={i}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 8,
}}
>
<Feld w={28} h={28} r={6} />
<div style={{ flex: 1 }}>
<Feld w={`${35 + ((i * 23) % 40)}%`} h={10} />
</div>
<Feld w={64} h={8} />
</div>
))}
</div>
)
}
+13 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client'
import { tagPalette, tagColor } from './TagChip'
import { fehlertext } from './Toasts'
/**
* Ein Etikett umbenennen, einfärben oder löschen.
@@ -28,8 +29,12 @@ export default function TagEditDialog({ tag, onClose, onChanged }: {
notifyTagsChanged()
onChanged(updated)
onClose()
} catch {
setError('That name is already taken')
} catch (err) {
// **Nicht mehr raten.** Die feste Meldung „That name is already taken"
// behauptete eine Ursache und lag bei jedem anderen Fehler daneben –
// Netz weg, Server kaputt, Recht fehlt. Der Server schreibt seine
// Meldungen selbst; dieselbe Entscheidung wie im TagDialog.
setError(fehlertext(err, 'Could not save the change'))
setBusy(false)
}
}
@@ -242,8 +247,12 @@ export function CorrespondentRenameDialog({ correspondent, onClose, onChanged }:
notifyTagsChanged()
onChanged(updated)
onClose()
} catch {
setError('That name is already taken')
} catch (err) {
// **Nicht mehr raten.** Die feste Meldung „That name is already taken"
// behauptete eine Ursache und lag bei jedem anderen Fehler daneben –
// Netz weg, Server kaputt, Recht fehlt. Der Server schreibt seine
// Meldungen selbst; dieselbe Entscheidung wie im TagDialog.
setError(fehlertext(err, 'Could not save the change'))
setBusy(false)
}
}
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { APIError } from '../api/client'
/**
* Kurze Rückmeldungen am unteren Rand – und, wo es geht, ein „Rückgängig".
*
* Vorher bestätigte **nichts** irgendetwas: Verschieben, Umbenennen, Teilen
* geschah, und die Oberfläche schwieg. Schlimmer noch das Scheitern – die
* Handler fingen ihre Fehler mit `catch { /* bleibt stehen *\/ }` ab. Wer eine
* Datei löschen wollte und keine Berechtigung hatte, sah exakt dasselbe wie
* jemand, bei dem es geklappt hat: nichts.
*
* **Kein Context, sondern Modulzustand.** Eine Meldung soll von überall
* abgesetzt werden können – auch aus `api/client.ts`, das keine Komponente
* ist und in keinem Anbieter steckt. Derselbe Weg wie bei `useBackClose`:
* ein Zustand im Modul, und die Anzeige hört zu.
*/
export type ToastTone = 'info' | 'error'
export interface ToastAction {
label: string
/** Läuft genau einmal; danach schließt die Meldung. */
run: () => void | Promise<void>
}
interface Toast {
id: number
text: string
tone: ToastTone
action?: ToastAction
}
let toasts: Toast[] = []
let seq = 0
const hoerer = new Set<(t: Toast[]) => void>()
/** Höchstens so viele gleichzeitig. Darüber stapelt sich Lärm, kein Inhalt. */
const maxSichtbar = 4
/**
* Wie lange eine Meldung steht.
*
* Mit Knopf deutlich länger: Man muss sie lesen, verstehen, *entscheiden* und
* dann treffen. Vier Sekunden reichen zum Lesen, nicht zum Entscheiden – ein
* „Rückgängig", das weg ist, bevor die Hand da war, ist schlimmer als keines,
* weil es die Erwartung geweckt hat.
*/
const dauerSchlicht = 4000
const dauerMitKnopf = 10000
function melden() {
for (const h of hoerer) h(toasts)
}
function schliessen(id: number) {
toasts = toasts.filter(t => t.id !== id)
melden()
}
function zeigen(text: string, tone: ToastTone, action?: ToastAction): number {
const id = ++seq
toasts = [...toasts, { id, text, tone, action }].slice(-maxSichtbar)
melden()
// Bewusst `setTimeout` statt `window.setTimeout`: Dieselbe Funktion, aber
// ohne `window` läuft der Zustandsteil dieser Datei auch im Test, der ohne
// DOM startet.
setTimeout(() => schliessen(id), action ? dauerMitKnopf : dauerSchlicht)
return id
}
export const toast = {
/** Es hat geklappt. */
info: (text: string) => zeigen(text, 'info'),
/** Es hat nicht geklappt – bleibt genauso lange stehen, das ist Absicht:
* Eine Fehlermeldung, die man festhalten will, steht in der Konsole. */
error: (text: string) => zeigen(text, 'error'),
/**
* Es hat geklappt, und man kann es zurücknehmen.
*
* Der eigentliche Grund, warum es diese Datei gibt. Löschen ist bei uns ein
* Papierkorb, Wiederherstellen ein einziger Aufruf – das Rückgängig kostet
* also fast nichts und nimmt dem Löschen den Schrecken.
*/
undo: (text: string, run: ToastAction['run'], label = 'Undo') =>
zeigen(text, 'info', { label, run }),
schliessen,
}
/** Einmal im Gerüst einhängen. */
export function Toaster() {
const [liste, setListe] = useState<Toast[]>(toasts)
const [laufend, setLaufend] = useState<number | null>(null)
useEffect(() => {
hoerer.add(setListe)
return () => { hoerer.delete(setListe) }
}, [])
if (liste.length === 0) return null
return (
<div
// `polite`, nicht `assertive`: Eine Bestätigung darf warten, bis der
// Vorleser den laufenden Satz beendet hat.
aria-live="polite"
style={{
position: 'fixed', zIndex: 300,
left: 'calc(12px + var(--safe-left))', right: 12,
bottom: 'calc(12px + var(--safe-bottom))',
display: 'flex', flexDirection: 'column', gap: 8,
alignItems: 'center',
// Der Bereich selbst fängt keine Klicks – darunter liegt die
// Dateiliste, und die soll bedienbar bleiben. Nur die Meldungen
// selbst nehmen wieder an.
pointerEvents: 'none',
}}
>
{liste.map(t => (
<div
key={t.id}
className="aether-toast"
style={{
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', gap: 14,
maxWidth: 480, width: 'fit-content',
padding: '11px 14px',
borderRadius: 10,
background: 'var(--color-surface)',
border: `1px solid ${t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-border)'}`,
boxShadow: '0 10px 30px rgba(0,0,0,0.35)',
fontSize: 13,
color: t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-text)',
}}
>
<span style={{ flex: 1, minWidth: 0 }}>{t.text}</span>
{t.action && (
<button
disabled={laufend === t.id}
onClick={async () => {
setLaufend(t.id)
try {
await t.action!.run()
} finally {
// Auch nach einem Fehler schließen: Der Aufrufer meldet ihn
// selbst, und eine stehengebliebene Meldung mit totem Knopf
// wäre die schlechtere Antwort.
setLaufend(null)
schliessen(t.id)
}
}}
style={{
flexShrink: 0,
border: 'none', background: 'none',
color: 'var(--color-primary)',
font: 'inherit', fontWeight: 600,
cursor: laufend === t.id ? 'default' : 'pointer',
padding: '2px 4px',
opacity: laufend === t.id ? 0.5 : 1,
}}
>
{t.action.label}
</button>
)}
<button
onClick={() => schliessen(t.id)}
aria-label="Dismiss"
style={{
flexShrink: 0,
border: 'none', background: 'none', cursor: 'pointer',
color: 'var(--color-text-muted)', display: 'flex', padding: 2,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
)
}
/**
* Aus einem Fehler einen Satz machen, den man lesen kann.
*
* Der Server schreibt seine Meldungen selbst und in ganzen Sätzen („You only
* have read access to this file") – die ist besser als alles, was wir hier
* erfinden könnten, und wird deshalb bevorzugt. Der Rückfalltext greift nur,
* wenn gar nichts da ist.
*
* Zwei Fälle bekommen einen eigenen Satz, weil die Rohfassung nichts sagt:
* `403` ohne Text, und der Abbruch ohne Antwort (Status 0) – „Unknown error"
* hilft niemandem, „keine Verbindung" schon.
*/
export function fehlertext(err: unknown, rückfall: string): string {
if (err instanceof APIError) {
if (err.status === 0) return 'No connection to the server.'
if (err.message && err.message !== 'Unknown error') return err.message
if (err.status === 403) return 'You do not have permission for that.'
return rückfall
}
return err instanceof Error && err.message ? err.message : rückfall
}
+88 -17
View File
@@ -1,16 +1,25 @@
import { useEffect, useRef } from 'react'
import { formatBytes, formatDuration } from '../format'
interface UploadItem {
id: string
name: string
progress: number // 0-100
status: 'uploading' | 'done' | 'error'
status: 'uploading' | 'done' | 'error' | 'canceled'
error?: string
/** Schon draußen, in Bytes. */
loaded?: number
/** Größe der Datei, in Bytes. */
total?: number
/** Bytes je Sekunde, geglättet. 0 heißt „noch nicht messbar". */
speed?: number
}
interface Props {
items: UploadItem[]
onDismiss: (id: string) => void
/** Bricht einen laufenden oder noch wartenden Upload ab. */
onCancel: (id: string) => void
}
export type { UploadItem }
@@ -31,7 +40,7 @@ const dismissAfter = 4000
*/
const maxVisible = 4
export default function UploadQueue({ items, onDismiss }: Props) {
export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
@@ -45,7 +54,9 @@ export default function UploadQueue({ items, onDismiss }: Props) {
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
// von selbst.
if (item.status !== 'done') continue
// Abgebrochenes verschwindet wie Fertiges: Die Entscheidung ist
// getroffen, die Zeile hat nichts mehr zu sagen.
if (item.status !== 'done' && item.status !== 'canceled') continue
if (timers.current.has(item.id)) continue
timers.current.set(item.id, setTimeout(() => {
timers.current.delete(item.id)
@@ -85,29 +96,52 @@ export default function UploadQueue({ items, onDismiss }: Props) {
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
{item.name}
</span>
{item.status !== 'uploading' && (
<button onClick={() => onDismiss(item.id)}
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
style={{ color: 'var(--color-text-muted)' }}>✕</button>
)}
{/* Dasselbe Kreuz, zwei Bedeutungen – und beide sind dieselbe
Geste: „weg damit". Läuft der Upload noch, lässt es die
Verbindung fallen; ist er vorbei, räumt es nur die Meldung ab.
Auf dem Server bleibt in keinem Fall etwas liegen. */}
<button
onClick={() => item.status === 'uploading' ? onCancel(item.id) : onDismiss(item.id)}
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
aria-label={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
style={{ color: 'var(--color-text-muted)' }}
// Beim Darüberfahren rot – **nur solange der Upload läuft**.
// Dasselbe Zeichen tut hier zwei verschiedene Dinge: eine
// Meldung wegräumen ist folgenlos, einen laufenden Vorgang
// fallen zu lassen nicht. Die Farbe ist der einzige Hinweis
// darauf, bevor man klickt.
onMouseEnter={e => {
if (item.status === 'uploading') e.currentTarget.style.color = 'var(--color-danger)'
}}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
>✕</button>
</div>
{item.status === 'uploading' && (
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
<div
className="h-full rounded-full transition-all"
style={{
width: `${item.progress}%`,
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
}} />
</div>
<>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
<div
className="h-full rounded-full transition-all"
style={{
width: `${item.progress}%`,
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
}} />
</div>
<Details item={item} />
</>
)}
{item.status === 'done' && (
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
<p style={{ color: 'var(--color-success)' }} className="text-xs">
✓ Uploaded{item.total ? ` · ${formatBytes(item.total)}` : ''}
</p>
)}
{item.status === 'error' && (
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
)}
{item.status === 'canceled' && (
<p style={{ color: 'var(--color-text-muted)' }} className="text-xs">Cancelled — nothing was saved</p>
)}
</div>
))}
@@ -124,3 +158,40 @@ export default function UploadQueue({ items, onDismiss }: Props) {
</div>
)
}
/**
* Die Zeile unter dem Balken: wie viel, wie schnell, wie lange noch.
*
* Ein Prozentwert allein sagt nichts über das Warten. Bei 40 % weiß man nicht,
* ob noch zehn Sekunden oder zehn Minuten kommen – und genau das ist die
* Frage, wegen der man auf den Balken sieht.
*
* Jeder Teil erscheint erst, wenn er stimmt: Die Größe kennt der Browser
* sofort, das Tempo erst nach dem zweiten Messpunkt, die Restzeit erst mit
* beidem. Eine Schätzung aus dem Nichts wäre schlimmer als keine – man richtet
* sich danach.
*/
function Details({ item }: { item: UploadItem }) {
const teile: string[] = []
if (item.total) {
teile.push(`${formatBytes(item.loaded ?? 0)} / ${formatBytes(item.total)}`)
}
if (item.speed) {
teile.push(`${formatBytes(Math.round(item.speed))}/s`)
if (item.total) {
const rest = formatDuration((item.total - (item.loaded ?? 0)) / item.speed)
if (rest) teile.push(`${rest} left`)
}
}
if (teile.length === 0) return null
return (
<p
className="text-xs mt-1 truncate"
style={{ color: 'var(--color-text-muted)', fontVariantNumeric: 'tabular-nums' }}
>
{teile.join(' · ')}
</p>
)
}
+1 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
import { formatBytes } from '../pages/FileBrowser'
import { formatBytes } from '../format'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
import { useAppSettings } from '../hooks/useAppSettings'
@@ -134,7 +134,6 @@ export default function VersionsDialog({ file, onClose, onRestored }: Props) {
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
</div>
)}
@@ -34,7 +34,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
borderRadius: 14, padding: 24, width: 340, maxWidth: '100%',
}}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{calendar ? 'Kalender bearbeiten' : 'Neuer Kalender'}
{calendar ? 'Edit calendar' : 'New calendar'}
</h2>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label>
@@ -48,7 +48,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
}}
/>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Color</label>
<div style={{ marginBottom: 20 }}>
<ColorSwatches value={color} onChange={setColor} />
</div>
@@ -1,12 +1,8 @@
import { useEffect } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent } from '../../api/client'
import { weekdayLongNamesSun, monthLongNames } from './dates'
const weekdayLong = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
export interface DayEvent extends CalendarEvent {
calendarId: string
@@ -42,7 +38,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
}, [onClose])
const timeLabel = (ev: DayEvent) => {
if (ev.all_day) return 'ganztägig'
if (ev.all_day) return 'all day'
const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
if (!ev.endsAt) return from
const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -60,9 +56,9 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ padding: '18px 22px 12px', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLong[day.getDay()]}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLongNamesSun[day.getDay()]}</div>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--color-text)' }}>
{day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
{day.getDate()}. {monthLongNames[day.getMonth()]} {day.getFullYear()}
</div>
</div>
@@ -86,7 +82,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
<span style={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.summary || '(ohne Titel)'}
{ev.summary || '(untitled)'}
{ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>}
</span>
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}>
@@ -42,17 +42,17 @@ export default function DeleteEventDialog({ summary, recurring, onSingle, onSeri
borderRadius: 14, padding: 24, width: 380, maxWidth: '100%',
}}>
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{recurring ? 'Serientermin löschen' : 'Termin löschen'}
{recurring ? 'Delete recurring event' : 'Delete event'}
</h2>
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{recurring
? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
: <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?</>}
? <>„{summary || 'Untitled'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
: <>„{summary || 'Untitled'}" unwiderruflich löschen?</>}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={onSingle} style={danger}>
{recurring ? 'Nur diesen Termin' : 'Löschen'}
{recurring ? 'Only this event' : 'Delete'}
</button>
{recurring && (
<button onClick={onSeries} style={danger}>
@@ -4,6 +4,7 @@ import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode }
import {
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
weekdayShortNames, weekdayLongNames, monthLongNames,
} from './dates'
import { CalendarSelect, ColorSwatches, Toggle } from './controls'
@@ -23,27 +24,21 @@ type FreqChoice = 'NONE' | Recurrence['freq']
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
const freqLabels: Record<FreqChoice, string> = {
NONE: 'Einmalig',
DAILY: 'Täglich',
WEEKLY: 'Wöchentlich',
MONTHLY: 'Monatlich',
YEARLY: 'Jährlich',
NONE: 'Does not repeat',
DAILY: 'Daily',
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
YEARLY: 'Yearly',
}
const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const monthNames = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
/** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie
* man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
const n = nthOfMonth(start)
const nth = n === -1 ? 'letzten' : `${n}.`
const wd = weekdayLong[(start.getDay() + 6) % 7]
const month = monthNames[start.getMonth()]
const wd = weekdayLongNames[(start.getDay() + 6) % 7]
const month = monthLongNames[start.getMonth()]
return yearly
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
: { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` }
@@ -185,7 +180,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
}}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}
{event ? (isSeries ? 'Edit series' : 'Edit event') : 'New event'}
</h2>
{readOnly && (
@@ -197,20 +192,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
)}
{isSeries && !readOnly && (
<div style={noteStyle}>
Terminreihe: Änderungen gelten für <strong>alle</strong> Termine der
Terminreihe: Änderungen gelten für <strong>every</strong> Termine der
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Titel</label>
<label style={labelStyle}>Title</label>
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
</div>
{calendars.length > 1 && (
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Kalender</label>
<label style={labelStyle}>Calendar</label>
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
Speichern die Sammlung, behält aber seine Kennung – für
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
@@ -219,14 +214,14 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{!!event && calId !== calendarId && (
<p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben
{isSeries ? ' – mit der ganzen Reihe.' : '.'}
{isSeries ? ' – together with the whole series.' : '.'}
</p>
)}
</div>
)}
<div style={{ marginBottom: 14 }}>
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="All day" />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
@@ -237,7 +232,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
</div>
{!allDay && (
<div style={{ width: 116 }}>
<label style={labelStyle}>Uhrzeit</label>
<label style={labelStyle}>Time</label>
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
</div>
)}
@@ -245,13 +240,13 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Ende</label>
<label style={labelStyle}>End</label>
<input type="date" value={endDate} min={startDate} disabled={readOnly}
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<div style={{ width: 116 }}>
<label style={labelStyle}>Uhrzeit</label>
<label style={labelStyle}>Time</label>
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
</div>
)}
@@ -259,7 +254,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{/* ── Wiederholung ───────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Wiederholung</label>
<label style={labelStyle}>Repeat</label>
<div style={{ display: 'flex', gap: 8 }}>
<select value={freq} disabled={readOnly}
onChange={e => setFreq(e.target.value as FreqChoice)}
@@ -270,7 +265,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
</select>
{freq !== 'NONE' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>alle</span>
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>every</span>
<input type="number" min={1} max={99} value={interval} disabled={readOnly}
onChange={e => setInterval(e.target.value)}
style={{ ...inputStyle, width: 60 }} />
@@ -283,7 +278,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
{freq === 'WEEKLY' && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>An diesen Tagen</label>
<label style={labelStyle}>On these days</label>
<div style={{ display: 'flex', gap: 5 }}>
{weekdayCodes.map((code, i) => {
const on = activeDays.includes(code)
@@ -292,7 +287,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
<button
key={code} type="button" disabled={readOnly || fixed}
onClick={() => toggleDay(code)}
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
title={fixed ? `${weekdayLongNames[i]} – the day the event starts` : weekdayLongNames[i]}
style={{
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
border: '1px solid var(--color-border)',
@@ -303,7 +298,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
transition: 'background 0.12s, color 0.12s',
}}
>
{weekdayShort[i]}
{weekdayShortNames[i]}
</button>
)
})}
@@ -314,7 +309,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Wiederholt sich</label>
<label style={labelStyle}>Repeats</label>
<select value={monthMode} disabled={readOnly}
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
style={inputStyle}>
@@ -326,20 +321,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{freq !== 'NONE' && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Endet</label>
<label style={labelStyle}>Ends</label>
<div style={{ display: 'flex', gap: 8 }}>
<select value={endChoice} disabled={readOnly}
onChange={e => setEndChoice(e.target.value as EndChoice)}
style={{ ...inputStyle, flex: 1 }}>
<option value="NEVER">Nie</option>
<option value="COUNT">Nach Anzahl</option>
<option value="UNTIL">An einem Datum</option>
<option value="NEVER">Never</option>
<option value="COUNT">After</option>
<option value="UNTIL">On a date</option>
</select>
{endChoice === 'COUNT' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<input type="number" min={1} max={999} value={count} disabled={readOnly}
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Mal</span>
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>times</span>
</div>
)}
{endChoice === 'UNTIL' && (
@@ -352,17 +347,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{/* ── Farbe ──────────────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Farbe</label>
<label style={labelStyle}>Color</label>
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Ort</label>
<label style={labelStyle}>Location</label>
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
</div>
<div style={{ marginBottom: 20 }}>
<label style={labelStyle}>Beschreibung</label>
<label style={labelStyle}>Description</label>
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
</div>
@@ -384,7 +379,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}>
{readOnly ? 'Schließen' : 'Abbrechen'}
{readOnly ? 'Close' : 'Cancel'}
</button>
{!readOnly && (
<button type="submit" style={{
@@ -85,7 +85,7 @@ export function ColorSwatches({ value, onChange, inherit, disabled }: {
return (
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}>
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Farbe des Kalenders')}
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Calendar color')}
{inherit !== undefined && (
<span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} />
)}
@@ -151,7 +151,7 @@ export function CalendarSelect({ calendars, value, onChange, disabled }: {
aria-haspopup="listbox" aria-expanded={open} style={field}>
{current && dot(current.color, true)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{current?.name ?? 'Kalender wählen'}
{current?.name ?? 'Choose calendar'}
</span>
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
</button>
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import {
weekdayIndex, weekdayCodes,
weekdayShortNames, weekdayLongNames, weekdayLongNamesSun, monthLongNames,
} from './dates'
/**
* Die Namen kommen seit dem 20.09.2026 aus `Intl` statt aus sechs
* handgeschriebenen Arrays. Geprüft wird hier nicht, ob der Browser Englisch
* kann – sondern die **Reihenfolge**, denn daran hing die eigentliche Falle:
* Eine Liste begann am Montag, eine am Sonntag, jede passend zu ihrer
* Indizierung. Wer die falsche erwischt, verschiebt jeden Wochentag um eins.
*/
describe('Namen von Wochentagen und Monaten', () => {
it('beginnt die Woche am Montag – wie weekdayIndex', () => {
expect(weekdayLongNames[0]).toBe('Monday')
expect(weekdayLongNames[6]).toBe('Sunday')
expect(weekdayShortNames[0]).toBe('Mon')
expect(weekdayShortNames).toHaveLength(7)
// Der Beweis, dass Namen und Index zusammenpassen: Für jeden Tag einer
// echten Woche muss der Name am errechneten Index stehen.
const montag = new Date(2024, 0, 1)
for (let i = 0; i < 7; i++) {
const tag = new Date(2024, 0, 1 + i)
expect(weekdayIndex(tag)).toBe(i)
expect(weekdayLongNames[weekdayIndex(tag)]).toBe(
new Intl.DateTimeFormat('en', { weekday: 'long' }).format(tag))
}
expect(weekdayIndex(montag)).toBe(0)
})
it('hat eine zweite Liste, die am Sonntag beginnt – für getDay()', () => {
expect(weekdayLongNamesSun[0]).toBe('Sunday')
expect(weekdayLongNamesSun[1]).toBe('Monday')
// Genau dafür ist sie da: direkt mit getDay() indizierbar.
for (let i = 0; i < 7; i++) {
const tag = new Date(2024, 0, 1 + i)
expect(weekdayLongNamesSun[tag.getDay()]).toBe(
new Intl.DateTimeFormat('en', { weekday: 'long' }).format(tag))
}
})
it('passt zu den Wiederholungs-Kürzeln der Terminreihe', () => {
// weekdayCodes ist MO..SU. Stünden die Namen anders herum, bekäme die
// Auswahl „An diesen Tagen" die falsche Beschriftung – und ein
// Serientermin landete auf dem falschen Wochentag.
expect(weekdayCodes).toHaveLength(weekdayLongNames.length)
expect(weekdayCodes[0]).toBe('MO')
expect(weekdayLongNames[0].startsWith('Mo')).toBe(true)
expect(weekdayCodes[6]).toBe('SU')
expect(weekdayLongNames[6].startsWith('Su')).toBe(true)
})
it('liefert zwölf Monate, Januar zuerst', () => {
expect(monthLongNames).toHaveLength(12)
expect(monthLongNames[0]).toBe('January')
expect(monthLongNames[11]).toBe('December')
// Mitte des Monats als Bezugspunkt: Am Monatsersten könnte eine Zeitzone
// westlich von UTC in den Vormonat kippen.
for (let i = 0; i < 12; i++) {
expect(monthLongNames[new Date(2024, i, 15).getMonth()]).toBe(monthLongNames[i])
}
})
it('spricht Englisch, unabhängig von der Sprache des Browsers', () => {
// Die Oberfläche ist englisch; die Daten müssen es auch sein. Sonst stünde
// auf einem deutschen Rechner „Montag" neben „Repeat".
expect(monthLongNames[2]).toBe('March')
expect(weekdayLongNames[2]).toBe('Wednesday')
})
})
+54
View File
@@ -132,3 +132,57 @@ export function spannedDays(start: Date, end: Date | null, allDay: boolean): Dat
for (let d = first, i = 0; +d <= +last && i < 400; d = addDays(d, 1), i++) out.push(d)
return out
}
// ── Namen von Wochentagen und Monaten ────────────────────────────────────────
//
// Bis zum 20.09.2026 standen die als **sechs** handgeschriebene Arrays in drei
// Dateien – und `Intl` kam im ganzen Projekt nicht vor, obwohl jeder Browser
// es seit Jahren mitbringt.
//
// Die Doppelpflege war nicht nur lästig, sie war eine Falle: `DayDialog`
// begann seine Woche am Sonntag, die anderen am Montag, jeweils passend zu
// ihrer Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays
// in eine neue Ansicht kopiert hätte, hätte sich mit hoher Wahrscheinlichkeit
// einen Off-by-one gebaut, der nur an sieben Tagen im Jahr auffällt.
//
// Hier gibt es deshalb nur noch **eine** Ordnung: Montag zuerst, wie
// `weekdayIndex` oben. Wer den Sonntag vorn braucht, nimmt `weekdayNamesSun`.
/**
* **Feste Sprache, nicht die des Browsers.**
*
* `Intl.DateTimeFormat(undefined, …)` nähme die Spracheinstellung des
* Browsers – und läge auf einem deutschen Rechner „Montag" neben ein
* englisches „Repeat". Genau diesen Mischmasch haben wir am 18.09.2026
* ausgeräumt; ihn über die Hintertür wieder hereinzulassen wäre absurd.
*
* Die Oberfläche spricht Englisch, also sprechen die Daten Englisch. Kommt
* eines Tages eine Sprachwahl (siehe IDEAS.md), ist **diese Zeile** die
* Stelle, an der sie andockt – und dann stimmt es überall auf einmal.
*/
const uiLocale = 'en'
/** Einmal rechnen, nicht bei jedem Rendern. */
function bauen(anzahl: number, tag: (i: number) => Date, opts: Intl.DateTimeFormatOptions) {
const fmt = new Intl.DateTimeFormat(uiLocale, opts)
return Array.from({ length: anzahl }, (_, i) => fmt.format(tag(i)))
}
// Der 1. Januar 2024 war ein Montag – der Bezugspunkt für die Wochentage.
const ersterMontag = (i: number) => new Date(2024, 0, 1 + i)
// Mitte des Monats, damit keine Zeitzone den Monat kippen kann.
const monatsMitte = (i: number) => new Date(2024, i, 15)
/** Mo–So, kurz („Mon"). */
export const weekdayShortNames = bauen(7, ersterMontag, { weekday: 'short' })
/** Montag–Sonntag, lang. */
export const weekdayLongNames = bauen(7, ersterMontag, { weekday: 'long' })
/** Januar–Dezember, lang. */
export const monthLongNames = bauen(12, monatsMitte, { month: 'long' })
/**
* Dieselben Namen, aber **Sonntag zuerst** – für alles, was direkt mit
* `Date.getDay()` indiziert. Bewusst eine eigene Liste statt eines Umrechnens
* am Aufrufort: Genau dort entsteht sonst der Off-by-one.
*/
export const weekdayLongNamesSun = [weekdayLongNames[6], ...weekdayLongNames.slice(0, 6)]
+139
View File
@@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
// jsdom hat kein echtes Canvas. Excalidraw fasst es beim Laden an, obwohl die
// beiden geprueften Funktionen es nicht brauchen – ein Platzhalter genuegt.
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 0 }), fillText: () => {}, clearRect: () => {},
save: () => {}, restore: () => {}, translate: () => {}, scale: () => {},
beginPath: () => {}, closePath: () => {}, fill: () => {}, stroke: () => {},
setTransform: () => {}, drawImage: () => {}, getImageData: () => ({ data: [] }),
})) as never
const { restore, serializeAsJSON } = await import('@excalidraw/excalidraw')
const { ladeZeichnung } = await import('./DrawingEditor')
/**
* Der Fehler, den dieser Test festhält, ist echt passiert (29.09.2026):
* Eine Zeichnung liess sich anlegen und bearbeiten – aber **beim zweiten
* Öffnen** blieb das Zeichenbrett leer.
*
* Ursache: Die erste Fassung schrieb den `appState` roh mit `JSON.stringify`
* heraus. Darin steckt `collaborators`, und das ist eine `Map`. JSON kennt
* keine Maps; es wurde `{}` daraus. Beim Laden bekam Excalidraw ein leeres
* Objekt, wo es eine Map erwartet, rief eine Map-Methode darauf und brach ab.
*
* Beim **ersten** Öffnen fiel das nicht auf, weil da noch nichts gespeichert
* war – genau die Sorte Fehler, die man nur findet, wenn man zweimal hinsieht.
*
* Behoben mit den Werkzeugen, die das Paket dafür mitbringt:
* `serializeAsJSON` schreibt nur, was eine Zeichnung ausmacht, und `restore`
* setzt beim Laden wieder zusammen, was fehlt. Selbst zu prüfen hiesse, die
* Feldliste von Excalidraw nachzubauen und bei jeder Fassung nachzuziehen.
*/
describe('Zeichnungen: Speichern und wieder Öffnen', () => {
const beispiel = [{
id: 'a1', type: 'rectangle', x: 10, y: 20, width: 100, height: 50,
angle: 0, strokeColor: '#000', backgroundColor: 'transparent',
fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 1,
opacity: 100, seed: 1, version: 1, versionNonce: 1, isDeleted: false,
groupIds: [], frameId: null, roundness: null, boundElements: null,
updatedAt: 1, link: null, locked: false, index: 'a0',
}]
it('schreibt den Laufzeitzustand gar nicht erst mit', () => {
// `collaborators` ist eine Map und hat in einer Datei nichts zu suchen.
const json = serializeAsJSON(
beispiel as never,
{ collaborators: new Map(), viewBackgroundColor: '#ffffff' } as never,
{}, 'local')
const d = JSON.parse(json)
expect(d.type).toBe('excalidraw')
expect(d.elements).toHaveLength(1)
expect('collaborators' in d.appState).toBe(false)
// Was zur Zeichnung gehört, bleibt erhalten.
expect(d.appState.viewBackgroundColor).toBe('#ffffff')
})
it('überlebt den Weg durch die Datei und zurück', () => {
const json = serializeAsJSON(beispiel as never, { viewBackgroundColor: '#fafafa' } as never, {}, 'local')
const zurueck = restore(JSON.parse(json), null, null)
expect(zurueck.elements).toHaveLength(1)
expect(zurueck.elements[0].id).toBe('a1')
expect(zurueck.elements[0].width).toBe(100)
expect(zurueck.appState.viewBackgroundColor).toBe('#fafafa')
})
it('restore allein repariert ein verunglücktes collaborators NICHT', () => {
// Nachgemessen, nicht angenommen: `restore` erzeugt die Map nur, wenn das
// Feld **fehlt**. Ein bereits vorhandenes `{}` reicht es durch.
const kaputt = {
type: 'excalidraw', version: 2, source: 'aether',
elements: beispiel, appState: { collaborators: {} }, files: {},
}
const zurueck = restore(kaputt as never, null, null)
expect(zurueck.appState.collaborators instanceof Map).toBe(false)
})
it('deshalb wird das Feld vorher entfernt – so wie im Editor', () => {
// Genau das, was die alte Fassung in die Datei geschrieben hat. Die paar
// Dateien aus jener Stunde sollen sich wieder öffnen lassen.
const kaputt: { appState: Record<string, unknown>; elements: unknown[]; files: unknown } = {
elements: beispiel, appState: { collaborators: {} }, files: {},
}
delete kaputt.appState.collaborators
const zurueck = restore(kaputt as never, null, null)
expect(zurueck.appState.collaborators instanceof Map).toBe(true)
expect(zurueck.elements).toHaveLength(1)
})
it('kommt mit einer leeren Datei zurecht', () => {
// Eine frisch angelegte Zeichnung hat noch keinen Inhalt.
const leer = restore(JSON.parse(serializeAsJSON([], {}, {}, 'local')), null, null)
expect(leer.elements).toHaveLength(0)
expect(leer.appState.collaborators instanceof Map).toBe(true)
})
describe('Laden', () => {
function antwort(status: number, rumpf: string) {
vi.stubGlobal('fetch', async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => rumpf,
}))
}
it('macht aus einem Fehlerrumpf KEINE leere Zeichnung', async () => {
// Das war der Fehler: `fetch` wirft bei 404 nicht, der Rumpf
// `{"error":"file not found"}` ist gueltiges JSON, `elements` waere
// undefined – und das Zeichenbrett ginge leer auf. Wer dann speichert,
// ueberschreibt die echte Zeichnung mit nichts.
antwort(404, '{"error":"file not found"}')
await expect(ladeZeichnung('/egal')).rejects.toBeTruthy()
})
it('auch 403 fuehrt nicht zu einem leeren Brett', async () => {
antwort(403, '{"error":"you only have read access to this file"}')
await expect(ladeZeichnung('/egal')).rejects.toBeTruthy()
})
it('eine leere Datei ist dagegen in Ordnung', async () => {
// Eine frisch angelegte Zeichnung hat noch keinen Inhalt.
antwort(200, '')
const szene = await ladeZeichnung('/egal')
expect(szene.elements).toHaveLength(0)
})
it('und eine echte Zeichnung kommt vollstaendig an', async () => {
antwort(200, serializeAsJSON(beispiel as never, { viewBackgroundColor: '#123456' } as never, {}, 'local'))
const szene = await ladeZeichnung('/egal')
expect(szene.elements).toHaveLength(1)
expect(szene.appState.viewBackgroundColor).toBe('#123456')
expect(szene.appState.collaborators instanceof Map).toBe(true)
})
})
})
@@ -0,0 +1,189 @@
import type { Correspondent, FileItem, FolderItem, Tag } from '../../api/client'
import InputDialog from '../Dialog'
import ConfirmDialog from '../ConfirmDialog'
import ShareDialog from '../ShareDialog'
import ShareWithDialog from '../ShareWithDialog'
import VersionsDialog from '../VersionsDialog'
import FileInfoDialog from '../FileInfoDialog'
import TagDialog from '../TagDialog'
import MoveTo from '../MoveTo'
/**
* Alle Dialoge des Dateibrowsers an einer Stelle.
*
* Es ist **immer höchstens einer** offen – das ist der Grund, warum sie einen
* gemeinsamen Zustand haben und hier eine gemeinsame Verzweigung. Am Ende der
* Seite standen dafür 150 Zeilen aus dreizehn fast gleich aussehenden Blöcken,
* hinter denen die eigentliche Anzeige verschwand.
*
* Die Verzweigung selbst bleibt, wie sie war: eine ausgezeichnete Vereinigung
* (`type`), keine dreizehn Wahrheitswerte. Ein Zustand, in dem zwei Dialoge
* zugleich „offen" sind, lässt sich so gar nicht erst hinschreiben.
*/
export type Dialog =
| { type: 'rename'; file: FileItem }
| { type: 'rename-folder'; folder: FolderItem }
| { type: 'confirm-delete-folder'; folder: FolderItem }
| { type: 'new-folder' }
| { type: 'share'; file: FileItem }
| { type: 'share-with'; resource: { type: 'folder' | 'file'; id: string; name: string } }
| { type: 'versions'; file: FileItem }
| { type: 'confirm-delete'; file: FileItem }
| { type: 'confirm-delete-many'; ids: string[] }
| { type: 'move-file'; file: FileItem }
| { type: 'move-folder'; folder: FolderItem }
| { type: 'tags'; file: FileItem }
| { type: 'info'; file: FileItem }
interface Props {
dialog: Dialog | null
/** Abbrechen – bei jedem Dialog dieselbe Geste. */
onClose: () => void
fileTags: Record<string, Tag[]>
fileCorrespondents: Record<string, Correspondent>
renameFile(file: FileItem, name: string): void
renameFolder(folder: FolderItem, name: string): void
createFolder(name: string): void
deleteFile(file: FileItem): void
deleteFolder(folder: FolderItem): void
deleteMany(ids: string[]): void
moveFile(file: FileItem, targetId: string | null): void
moveFolder(folder: FolderItem, targetId: string | null): void
versionRestored(file: FileItem): void
tagsSaved(fileId: string, tags: Tag[]): void
}
export default function FileDialogs({
dialog, onClose, fileTags, fileCorrespondents,
renameFile, renameFolder, createFolder,
deleteFile, deleteFolder, deleteMany,
moveFile, moveFolder, versionRestored, tagsSaved,
}: Props) {
if (!dialog) return null
switch (dialog.type) {
case 'rename':
return (
<InputDialog
title="Rename"
defaultValue={dialog.file.name}
confirmLabel="Rename"
onConfirm={name => renameFile(dialog.file, name)}
onCancel={onClose}
/>
)
case 'rename-folder':
return (
<InputDialog
title="Rename folder"
defaultValue={dialog.folder.name}
confirmLabel="Rename"
onConfirm={name => renameFolder(dialog.folder, name)}
onCancel={onClose}
/>
)
case 'new-folder':
return (
<InputDialog
title="New folder"
placeholder="Folder name"
confirmLabel="Create"
onConfirm={createFolder}
onCancel={onClose}
/>
)
case 'confirm-delete-folder':
return (
<ConfirmDialog
title="Delete folder"
message={`Move "${dialog.folder.name}" and all its contents to the trash?`}
confirmLabel="Delete"
danger
onConfirm={() => deleteFolder(dialog.folder)}
onCancel={onClose}
/>
)
case 'confirm-delete':
return (
<ConfirmDialog
title="Move to trash"
message={`Move "${dialog.file.name}" to the trash?`}
confirmLabel="Move to trash"
danger
onConfirm={() => deleteFile(dialog.file)}
onCancel={onClose}
/>
)
case 'confirm-delete-many':
return (
<ConfirmDialog
title="Move to trash"
message={`Move ${dialog.ids.length} ${dialog.ids.length === 1 ? 'file' : 'files'} to the trash? You can restore them from there.`}
confirmLabel={`Move ${dialog.ids.length} to trash`}
danger
onConfirm={() => deleteMany(dialog.ids)}
onCancel={onClose}
/>
)
case 'share':
return <ShareDialog file={dialog.file} onClose={onClose} />
case 'share-with':
return <ShareWithDialog resource={dialog.resource} onClose={onClose} />
case 'versions':
return (
<VersionsDialog
file={dialog.file}
onClose={onClose}
onRestored={versionRestored}
/>
)
case 'info':
return (
<FileInfoDialog
file={dialog.file}
tags={fileTags[dialog.file.id] ?? []}
correspondent={fileCorrespondents[dialog.file.id]}
onClose={onClose}
/>
)
case 'tags':
return (
<TagDialog
file={dialog.file}
current={fileTags[dialog.file.id] ?? []}
currentCorrespondent={fileCorrespondents[dialog.file.id]}
onClose={onClose}
onSaved={tags => tagsSaved(dialog.file.id, tags)}
/>
)
case 'move-file':
return (
<MoveTo
title={`Move "${dialog.file.name}"`}
onConfirm={targetId => moveFile(dialog.file, targetId)}
onCancel={onClose}
/>
)
case 'move-folder':
return (
<MoveTo
title={`Move "${dialog.folder.name}"`}
excludeId={dialog.folder.id}
onConfirm={targetId => moveFolder(dialog.folder, targetId)}
onCancel={onClose}
/>
)
}
}
@@ -0,0 +1,158 @@
import SearchBox from '../SearchBox'
import { DownloadIcon, TrashIcon } from '../icons'
import type { SearchHit } from '../../api/client'
/**
* Die Bedienelemente rechts in der Kopfleiste des Dateibrowsers.
*
* Zwei Zustände, nicht zwei Leisten: Solange etwas ausgewählt ist, **ersetzt**
* die Auswahlleiste Suche und Sortierung. Sie danebenzustellen wäre auf einem
* schmalen Schirm eine dritte Zeile – und solange man auswählt, will man
* ohnehin nichts anderes.
*/
export type ViewMode = 'grid' | 'list'
export type Sort = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc'
export const sortLabels: Record<Sort, string> = {
'name-asc': 'Name A–Z',
'name-desc': 'Name Z–A',
'size-asc': 'Smallest first',
'size-desc': 'Largest first',
'date-asc': 'Oldest first',
'date-desc': 'Newest first',
}
interface Props {
selectedCount: number
onDownloadSelection: () => void
onTrashSelection: () => void
onClearSelection: () => void
onOpenFolder: (folderId: string | null, folderName: string) => void
onOpenFile: (hit: SearchHit, term: string) => void
sort: Sort
onSort: (s: Sort) => void
viewMode: ViewMode
onViewMode: (v: ViewMode) => void
onNewFolder: () => void
onNewDrawing: () => void
onUpload: () => void
}
export default function FileToolbar({
selectedCount, onDownloadSelection, onTrashSelection, onClearSelection,
onOpenFolder, onOpenFile, sort, onSort, viewMode, onViewMode,
onNewFolder, onNewDrawing, onUpload,
}: Props) {
return (
<>
{selectedCount > 0 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
{selectedCount} selected
</span>
<button onClick={onDownloadSelection} style={primaryButton}>
<DownloadIcon /> Download as ZIP
</button>
<button onClick={onTrashSelection} style={dangerButton}>
<TrashIcon /> Move to trash
</button>
<button onClick={onClearSelection} style={quietButton}>Clear</button>
</div>
) : (
<SearchBox onOpenFolder={onOpenFolder} onOpenFile={onOpenFile} />
)}
<select
value={sort}
onChange={e => onSort(e.target.value as Sort)}
title="Sort by"
style={{
padding: '6px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
{(Object.keys(sortLabels) as Sort[]).map(key => (
<option key={key} value={key}>{sortLabels[key]}</option>
))}
</select>
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<button onClick={() => onViewMode('grid')} title="Grid view" style={toggleStyle(viewMode === 'grid')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
<button onClick={() => onViewMode('list')} title="List view" style={toggleStyle(viewMode === 'list')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="currentColor" /><circle cx="3" cy="12" r="1" fill="currentColor" /><circle cx="3" cy="18" r="1" fill="currentColor" />
</svg>
</button>
</div>
<button
onClick={onNewFolder}
title="New folder"
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}
>
+ Folder
</button>
<button
onClick={onNewDrawing}
title="New drawing or mind map"
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}
>
+ Drawing
</button>
<button
onClick={onUpload}
title="Upload files (drag & drop for folders)"
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
Upload
</button>
</>
)
}
const primaryButton: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
padding: '6px 14px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, color: '#fff', background: 'var(--color-primary)',
flexShrink: 0,
}
const dangerButton: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
fontSize: 12, fontWeight: 600, flexShrink: 0,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)',
}
const quietButton: React.CSSProperties = {
padding: '6px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 12,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', flexShrink: 0,
}
function toggleStyle(active: boolean): React.CSSProperties {
return {
padding: '6px 10px', border: 'none', cursor: 'pointer',
background: active ? 'var(--color-surface-2)' : 'transparent',
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { MenuItem } from '../ContextMenu'
import type { FileItem, FolderItem } from '../../api/client'
import { isOfficeFile } from '../../api/client'
import {
DownloadIcon, ShareIcon, HistoryIcon, RenameIcon, MoveIcon, TrashIcon,
FolderOpenIcon, StarIcon, CheckIcon, PeopleIcon, TagIcon, OfficeIcon, InfoIcon,
} from '../icons'
/**
* Die beiden Kontextmenüs des Dateibrowsers.
*
* Ausgelagert, weil sie **Beschreibung** sind und keine Ablaufsteuerung: eine
* Liste aus Beschriftung, Symbol und einem Ruf nach außen. Zwischen dem
* Hochladen und den Dialogen der Seite standen 120 Zeilen davon; hier lassen
* sie sich am Stück lesen – und die Reihenfolge der Einträge ist genau das,
* worüber man beim Lesen nachdenkt.
*
* Die Gruppierung ist Absicht und bleibt, wie sie war: erst, was man am
* häufigsten will, dann Teilen, dann Verwalten, ganz unten abgesetzt das
* Löschen.
*/
export interface FileActions {
/** Steht ein Office-Server bereit? Ohne ihn fehlt „Edit in Office". */
officeEnabled: boolean
download(file: FileItem): void
properties(file: FileItem): void
shareWithPeople(file: FileItem): void
shareLink(file: FileItem): void
organise(file: FileItem): void
editInOffice(file: FileItem): void
versionHistory(file: FileItem): void
setVersioning(file: FileItem, enabled: boolean): void
rename(file: FileItem): void
move(file: FileItem): void
remove(file: FileItem): void
}
export function fileMenuItems(file: FileItem, a: FileActions): MenuItem[] {
return [
{ label: 'Download', icon: <DownloadIcon />, onClick: () => a.download(file) },
// Nachschlagen statt anfassen – deshalb direkt beim Herunterladen und
// nicht unten bei Umbenennen/Verschieben. Beantwortet Fragen, die die
// Kachel nicht beantwortet: Bildmaße, genaue Bytezahl, Prüfsumme.
{ label: 'Properties…', icon: <InfoIcon />, onClick: () => a.properties(file) },
{ separator: true },
{ label: 'Share with people…', icon: <PeopleIcon />, onClick: () => a.shareWithPeople(file) },
{ label: 'Share link…', icon: <ShareIcon />, onClick: () => a.shareLink(file) },
{ separator: true },
// Zwischen Teilen und Verwalten: Etikettieren ist beides nicht ganz –
// es ordnet ein, ohne die Datei anzufassen.
// Hiess bis zum 23.08.2026 „Tags…". Der Dialog setzt inzwischen drei
// Dinge – Etiketten, Absender und Belegdatum –, und ein Menuepunkt,
// der nur eines davon nennt, laesst die anderen beiden unauffindbar.
{ label: 'Organise…', icon: <TagIcon />, onClick: () => a.organise(file) },
// Nur bei passenden Dateien und nur, wenn es einen Office-Server gibt.
// Ein Punkt, der immer scheitert, ist schlimmer als keiner.
...(a.officeEnabled && isOfficeFile(file.name)
? [{ label: 'Edit in Office…', icon: <OfficeIcon />, onClick: () => a.editInOffice(file) }]
: []),
{ separator: true },
{ label: 'Version history…', icon: <HistoryIcon />, onClick: () => a.versionHistory(file) },
{
label: 'Keep versions',
icon: <CheckIcon />,
checked: file.versioning_enabled,
onClick: () => a.setVersioning(file, !file.versioning_enabled),
},
{ separator: true },
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => a.rename(file) },
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => a.move(file) },
{ separator: true },
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => a.remove(file) },
]
}
export interface FolderActions {
isFavourite(folderId: string): boolean
open(folder: FolderItem): void
toggleFavourite(folder: FolderItem, isFav: boolean): void
shareWithPeople(folder: FolderItem): void
downloadZip(folder: FolderItem): void
rename(folder: FolderItem): void
move(folder: FolderItem): void
remove(folder: FolderItem): void
}
export function folderMenuItems(folder: FolderItem, a: FolderActions): MenuItem[] {
const isFav = a.isFavourite(folder.id)
// Ein Gruppenordner gehört der Gruppe, nicht dem, der ihn gerade offen hat.
// Umbenennen, Verschieben und Löschen fehlen deshalb ganz statt als
// Einträge, die nur eine Fehlermeldung liefern – der Server lehnt sie
// ohnehin ab. Alles *innerhalb* des Ordners bleibt normal bedienbar.
const isGroupFolder = folder.group_id !== null
return [
{ label: 'Open', icon: <FolderOpenIcon />, onClick: () => a.open(folder) },
{
label: 'Favourite',
icon: <StarIcon filled={isFav} />,
checked: isFav,
onClick: () => a.toggleFavourite(folder, isFav),
},
...(isGroupFolder ? [] : [
{ separator: true as const },
{ label: 'Share with people…', icon: <PeopleIcon />, onClick: () => a.shareWithPeople(folder) },
{ separator: true as const },
// Ganzer Ordnerinhalt als Archiv – mit Unterordnern, ihre Struktur
// bleibt im Archiv erhalten.
{ label: 'Download as ZIP', icon: <DownloadIcon />, onClick: () => a.downloadZip(folder) },
{ separator: true as const },
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => a.rename(folder) },
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => a.move(folder) },
{ separator: true as const },
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => a.remove(folder) },
]),
]
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { sortFiles, sortFolders } from './sorting'
import type { FileItem, FolderItem } from '../../api/client'
function file(name: string, size: number, updated: string): FileItem {
return { id: name, name, size, updated_at: updated } as FileItem
}
function folder(name: string): FolderItem {
return { id: name, name } as FolderItem
}
const files = [
file('beta.txt', 300, '2026-01-03T00:00:00Z'),
file('alpha.txt', 100, '2026-01-02T00:00:00Z'),
file('gamma.txt', 200, '2026-01-01T00:00:00Z'),
]
const names = (list: { name: string }[]) => list.map(x => x.name)
describe('sortFiles', () => {
it('sortiert nach Namen in beide Richtungen', () => {
expect(names(sortFiles(files, 'name-asc'))).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt'])
expect(names(sortFiles(files, 'name-desc'))).toEqual(['gamma.txt', 'beta.txt', 'alpha.txt'])
})
it('sortiert nach Größe', () => {
expect(names(sortFiles(files, 'size-asc'))).toEqual(['alpha.txt', 'gamma.txt', 'beta.txt'])
expect(names(sortFiles(files, 'size-desc'))).toEqual(['beta.txt', 'gamma.txt', 'alpha.txt'])
})
it('sortiert nach Datum', () => {
expect(names(sortFiles(files, 'date-asc'))).toEqual(['gamma.txt', 'alpha.txt', 'beta.txt'])
expect(names(sortFiles(files, 'date-desc'))).toEqual(['beta.txt', 'alpha.txt', 'gamma.txt'])
})
it('lässt die übergebene Liste unangetastet', () => {
const before = names(files)
sortFiles(files, 'size-desc')
expect(names(files)).toEqual(before)
})
})
describe('sortFolders', () => {
const folders = [folder('Zeta'), folder('Alpha'), folder('Mitte')]
it('sortiert nach Namen', () => {
expect(names(sortFolders(folders, 'name-asc'))).toEqual(['Alpha', 'Mitte', 'Zeta'])
expect(names(sortFolders(folders, 'name-desc'))).toEqual(['Zeta', 'Mitte', 'Alpha'])
})
it('bleibt bei Größe und Datum aufsteigend beim Namen', () => {
// Ordner haben keine Größe und kein aussagekräftiges Datum – nach ihnen
// zu sortieren ergäbe eine zufällige Reihe, die nach Fehler aussieht.
for (const sort of ['size-asc', 'size-desc', 'date-asc', 'date-desc'] as const) {
expect(names(sortFolders(folders, sort))).toEqual(['Alpha', 'Mitte', 'Zeta'])
}
})
})
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more