Files
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

2600 lines
124 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Aether – Test-Checkliste
Manuelle Test-Checkliste für die fertigen Phasen. Abhaken beim Durchtesten.
- ⚠️ = bekannte Lücke, muss *nicht* getestet werden
- 🔎 = von Claude per `curl`/API gegen ein Wegwerf-Konto geprüft und grün.
Bewusst **nicht** abgehakt: eine API-Prüfung ersetzt nicht den Test in der
Oberfläche bzw. mit einem echten Client. Beim Durchgehen selbst abhaken.
---
## Kalender: Selbsttest per Skript
Die Kalender-Schnittstelle hat einen automatischen Selbsttest – schneller und
gründlicher als Klicken, und er prüft genau die Stellen, an denen schon
einmal etwas schiefging:
python3 scripts/kalender-selbsttest.py
36 Prüfungen: Ganztags-Datum (der 3. muss der 3. bleiben – der Fehler, bei dem
ein Termin östlich von UTC einen Tag zurücksprang), mehrtägige Termine,
Terminfarbe in beiden Schreibweisen, alle Wiederholungsmuster (wöchentlich an
bestimmten Tagen, monatlich/jährlich am festen Tag bzw. am n-ten Wochentag),
Bearbeiten einer Reihe, einen einzelnen Termin aus einer Reihe streichen
(EXDATE), Umhängen in einen anderen Kalender (samt ctag auf **beiden** Seiten,
sonst gleicht ein CalDAV-Client nur eine davon ab), und dass das Zeitfenster
wirklich begrenzt.
**Legt Termine an und löscht am Ende alle Termine im ersten Kalender des
Kontos**; für den Umzugstest entsteht kurzzeitig ein zweiter Kalender
„T zweitkalender", der danach wieder verschwindet. Also nur gegen eine
Entwicklungsinstanz laufen lassen.
## CalDAV: Selbsttest per Skript
Der Test oben prüft die JSON-Schnittstelle – also den Weg der
Web-Oberfläche. Dieser hier geht den Weg eines echten Clients (DAVx5,
Thunderbird, Apple Kalender). Beide führen auf dieselben Daten, aber über
völlig verschiedenen Code: Ein grüner Lauf des einen sagt nichts über den
anderen.
python3 scripts/caldav-selbsttest.py
62 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden
abgewiesen – für CalDAV gelten nur App-Passwörter), Auffinden über
`.well-known/caldav` samt der Umleitung, der Clients mit GET folgen,
Kalenderliste mit Anzeigename, Komponenten, `getctag` und
`calendar-color`, PUT/GET/DELETE eines Termins, `If-Match` (falsches ETag
muss 412 geben), `calendar-query` mit Zeitfenster und `calendar-multiget`,
beide Richtungen zwischen Web und CalDAV (Ganztagstermin als reines Datum
mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE, EXDATE), das
Umhängen aus Client-Sicht (Datei verschwindet in der Quelle, taucht im Ziel
auf, UID bleibt), Kalender vom Client aus verwalten (MKCALENDAR und der
erweiterte MKCOL, PROPPATCH zum Umbenennen und Umfärben, DELETE der
ganzen Sammlung) und dass ein fremder Benutzerpfad 404 gibt statt eines
leeren 207.
Legt sich ein eigenes App-Passwort und zwei eigene Kalender an und räumt
beides am Ende weg; vorhandene Kalender werden nur gelesen.
### Kalender verwalten: Adresse wählt der Client
Bei MKCALENDAR sucht **der Client** die Adresse aus (`…/calendars/max/arbeit/`).
Deshalb hat `cal_calendars` seit Migration 23 eine Spalte `path_segment`:
gesetzt = die gewählte Adresse, leer = es gilt weiterhin die UUID. Alle vor
der Migration angelegten Kalender bleiben damit unter genau der Adresse
erreichbar, unter der eingerichtete Geräte sie schon kennen – das ist der
Punkt, den ein Test hier zuerst prüfen sollte.
`go-webdav` kann davon nur MKCOL. MKCALENDAR kennt es nicht (405), PROPPATCH
beantwortet es mit 501, und ein DELETE auf einen Kalender reicht es an das
Löschen eines *Termins* weiter, was im 404 endet. Alle drei fängt Aether
deshalb vorher ab (`backend/internal/calendar/collections.go`).
### Zwei Eigenschaften kommen nicht aus der Bibliothek
`go-webdav` v0.7.0 beantwortet in PROPFIND einen festen Satz Eigenschaften
und bietet keinen Haken, eigene dazuzulegen. `getctag` (Calendarserver) und
`calendar-color` (Apple) ergänzt Aether deshalb selbst
(`backend/internal/calendar/propfind.go`): Beide werden aus der Anfrage
genommen, bevor go-webdav sie zu sehen bekommt, und der Antwort als eigener
`propstat`-Block wieder angehängt. Ohne das Herausnehmen stünde dieselbe
Eigenschaft zweimal in der Antwort – einmal mit 404, einmal mit 200.
Weil das eigener Code ist und nicht Bibliothek, prüft das Skript ihn
besonders: dass beide mit 200 kommen, die Farbe in Apple-Schreibweise
(`#RRGGBBAA`), dass das ctag sich bei jeder Änderung **ändert** (ein
festgefrorenes ctag ist schlimmer als gar keins – der Client fragt einmal
und hält den Kalender danach für unverändert), dass eine Anfrage nach
*nur* diesen Eigenschaften funktioniert (sonst bliebe für go-webdav ein
leeres `<prop>` übrig, das es abweist), und dass ohne Nachfrage auch nichts
davon in der Antwort steht.
Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
aus go-webdav; Clients prüfen auf 2xx.
## Freigabelinks: Selbsttest per Skript
Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung
erreicht. Ein Fehler darin ist kein Anzeigefehler, sondern eine offene Tür.
python3 scripts/sharing-selbsttest.py
24 Prüfungen: anlegen nur angemeldet und nur für **eigene** Dateien (auch der
Admin kommt nicht an fremde), Tokenlänge, öffentlicher Abruf ohne Anmeldung,
Herunterladen byte-genau, Passwortschutz (ohne, falsch, richtig),
Download-Zähler, Widerrufen (danach ist der Link tot), ein Link auf eine
gelöschte Datei liefert nichts, und die Sperre nach wiederholten
Fehlversuchen.
Der Test legt einen Zweitnutzer an – ohne ihn lässt sich „fremde Dateien
bleiben fremd" gar nicht prüfen – und räumt ihn samt Dateien wieder weg.
**Nach einem Lauf ist die eigene IP 15 Minuten gesperrt**, für
passwortgeschützte Freigabelinks (nicht fürs Anmelden). Das ist der Preis der
letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb
der Frist bricht früh ab und sagt es. Beim Entwickeln hilft ein
`docker restart aether-app` – die Sperre liegt im Arbeitsspeicher.
### Was dieser Test beim ersten Lauf gefunden hat
Drei Dinge, alle behoben – hier notiert, weil sie zeigen, wonach man in
diesem Bereich sucht:
- **Der Download-Zähler blieb auf 0.** Die Zählung lief in einer Goroutine
mit dem Kontext der Anfrage – der wird abgebrochen, sobald die Antwort
draußen ist, und die Zählung verlor das Rennen fast immer.
- **`owner_id` stand in der öffentlichen Antwort.** Das ganze
Datenbankobjekt durchzureichen war bequem, gab aber die Kennung des
Besitzers an jeden heraus, der den Link hat: Zwei Links desselben
Besitzers ließen sich einander zuordnen, ohne dass irgendwer angemeldet
ist.
- **Passwörter ließen sich unbegrenzt durchprobieren.** Der öffentliche
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
bestätigt (siehe kalender-plan.md, Abschnitt „Migrationen"): Scheitert eine
Migration, bleibt `schema_migrations.dirty` auf `true` stehen, und **jeder**
weitere Start von Aether bricht ab – die ganze Cloud, nicht nur der neue Teil.
Kein Datenverlust dabei: Die Migration lief in einer Transaktion und wurde
zurückgerollt, bevor der Fehler kam. `dirty=true` ist nur eine Sperre, keine
Aussage über beschädigte Daten.
Beheben:
docker exec aether-db psql -U aether -d aether \
-c "UPDATE schema_migrations SET dirty=false;"
Danach Aether neu starten. Lief die Migrationsdatei nur teilweise (z. B. zwei
`CREATE TABLE` in einer Datei, das erste gelang), vorher prüfen, was bereits
existiert, bevor man einen erneuten Lauf zulässt – sonst scheitert der zweite
Versuch am selben Fehler wie der erste.
---
## Phase 3 – Admin, Gruppen, Theming, Profil
### Admin-Panel: Zugriff & Sicherheit
- [X] Admin-Link erscheint **nur** für Admin-User in der Sidebar
- [X] Normaler User sieht den Admin-Link **nicht**
- [X] Normaler User, der `/api/v1/admin/users` direkt aufruft, bekommt `403` (serverseitiger Check)
- [X] Nach Logout ist kein Admin-Endpunkt erreichbar (`401`)
### Admin: Benutzerverwaltung
- [x] Benutzerliste zeigt alle User mit verwendetem Speicher + Quota + Rolle
- [x] Neuen User anlegen (Username, Anzeigename, Passwort, Quota, Admin-Flag)
- [x] Anlegen mit existierendem Username → Fehler „bereits vergeben" (`409`)
- [x] User bearbeiten (Anzeigename, Quota, Admin-Flag ändern)
- [x] **Selbst-Schutz:** eigenen Admin-Status kann man **nicht** entfernen (Fehlermeldung)
- [x] *Selbst-Schutz:** eigenes Konto kann man **nicht** löschen
- [x] Passwort-Reset generiert neues Passwort und zeigt es **einmalig** an
- [x] Neuer User kann sich mit dem zurückgesetzten Passwort einloggen
- [ ] User löschen entfernt den User (und seine Dateien) 🔎
### Admin: Gruppen
- [x] Gruppen-Tab lädt ohne schwarzen Bildschirm (war der Bug – jetzt gefixt)
- [x] Gruppe anlegen (Name + Beschreibung)
- [x] Gruppe aufklappen zeigt Mitglieder
- [x] Mitglied über Dropdown hinzufügen → Zähler steigt
- [x] Mitglied entfernen → Zähler sinkt
- [x] Gruppe mit doppeltem Namen → Fehler (`409`)
- [x] Gruppe löschen (auch mit Mitgliedern – Cascade)
- [ ] ⚠️ *Gruppen haben noch keine Funktion (keine Gruppenordner) – Phase 4.5/C, offen*
### Admin: Theming
- [x] Cloud-Name ändern → erscheint nach Reload überall (Login-Seite, Sidebar)
- [x] Primärfarbe ändern → Buttons/Akzente übernehmen die Farbe sofort
- [x] Akzentfarbe ändern → Vorschau aktualisiert live
- [x] Nach Reload sind die Farben persistent
- [ ] ⚠️ *Logo-Upload noch nicht vorhanden – Phase 4.5/D, offen*
### Profil-Seite (jeder User)
- [x] Avatar/Name unten in der Sidebar öffnet die Profil-Seite
- [x] Speicheranzeige (Balken) stimmt mit tatsächlicher Nutzung
- [ ] Anzeigename ändern → wird gespeichert (✓-Bestätigung) 🔎
- [x] Passwort ändern: falsches aktuelles Passwort → Fehler
- [x] Passwort ändern: neue Passwörter stimmen nicht überein → Fehler
- [x] Passwort ändern: < 8 Zeichen → Fehler
- [x] Passwort erfolgreich ändern → Logout → Login mit neuem Passwort klappt
- [x] Persönliche Farben ändern → werden angewendet
- [x] Logout-Button im Profil-Bereich funktioniert (ohne Profil zu öffnen)
---
## Phase 4 – WebDAV & OIDC
### WebDAV – Verbindung
Verbinden mit `https://<deine-domain>/dav/` oder lokal direkt mit
`http://127.0.0.1:8090/dav/`. **Achtung Port:** die App lauscht *im Container* auf
8080, Compose bildet das auf dem Host aber auf **8090** ab (`127.0.0.1:8090->8080`).
Vom Host aus gilt immer 8090 – und nur vom Host, die Bindung ist auf localhost.
Anmeldung mit dem Benutzernamen und einem **App-Passwort** (Profil → App passwords).
Das Kontopasswort wird hier **abgelehnt** – es ist zugleich der Admin-Zugang und
hat auf Geräten nichts verloren.
Clients: KDE/Dolphin, macOS Finder, Windows Explorer, `cadaver`, `rclone`, Cyberduck.
> **Hintergrund zu Dolphin/KIO:** KIO schickt Zugangsdaten nie vorab, sondern
> immer erst auf den `401` hin – und beginnt dabei sofort mit dem Rumpf, ohne
> `Expect: 100-continue` abzuwarten. Große Uploads scheiterten deshalb mit
> „unerwarteter Fehler 0", während kleine Dateien durchliefen. Behoben am
> 22.08.2026: Der Server liest den Rumpf einer unauthentifizierten Anfrage weg,
> bevor er die Anmeldeaufforderung schickt, damit die Verbindung stehen bleibt
> und KIO die `401` überhaupt zu sehen bekommt.
> Alle Punkte hier wurden am 19.08.2026 per `curl` gegen ein Wegwerf-Konto
> geprüft und waren grün (🔎). Was das **nicht** abdeckt: ein echter Client
> (Finder, Explorer, Cyberduck, rclone) verlangt andere PROPFIND-Properties und
> nutzt Locking – deshalb stehen die Haken offen.
- [x] Login mit Username + **App-Passwort** → Verbindung steht 🔎
- [x] Login mit falschem Passwort → abgelehnt (`401`) 🔎
- [x] Login mit dem **Kontopasswort** → abgelehnt (`401`), auch wenn es stimmt 🔎
- [x] Ordnerstruktur entspricht der in der Web-App (gleiche Ordner sichtbar) 🔎
### WebDAV – Lesen
- [x] Ordner auflisten (PROPFIND) zeigt Dateien + Unterordner 🔎
- [x] Datei herunterladen → Inhalt korrekt 🔎
- [x] Datei, die in der Web-App hochgeladen wurde, ist via WebDAV sichtbar 🔎
### WebDAV – Schreiben
- [x] Datei hochladen (PUT) → erscheint danach in der Web-App 🔎
- [x] Neuen Ordner anlegen (MKCOL) → erscheint in der Web-App 🔎
- [x] Datei umbenennen (MOVE im selben Ordner) 🔎
- [x] Datei in anderen Ordner verschieben (MOVE) 🔎
- [x] Ordner umbenennen 🔎
- [x] Datei löschen (DELETE) → landet im Papierkorb der Web-App (nicht hart gelöscht!) 🔎
- [x] Bestehende Datei überschreiben → bei **aktivierter** Versionierung landet die
alte Fassung in der Historie; bei deaktivierter wird ersetzt (so gewollt,
Versionierung ist pro Datei opt-in) 🔎
- [ ] Checksumme/Integrität: hochgeladene Datei wieder runterladen → identisch 🔎
- [x] **Große Datei (100 MB+) per Dolphin hochladen** – war der Fehler
„unerwarteter Fehler 0" 🔎
- [ ] Quota wird bei WebDAV-Upload geprüft → `507 Insufficient Storage` 🔎
- [ ] Abgebrochener Upload (Kontingent voll oder Verbindung weg) hinterlässt
**nichts** – keine abgeschnittene Datei, kein verwaistes Blob 🔎
- [ ] Rate-Limit/Lockout am WebDAV-Login (10 Fehlversuche / 15 min → `429`) 🔎
- [ ] App-Passwort ist der **einzige** WebDAV-Zugang, gilt nicht fürs Web-Login,
und ist nach dem Widerruf sofort tot 🔎
### App-Passwörter (Profil → App passwords)
- [ ] App-Passwort anlegen → Klartext erscheint **einmalig**, „Copy" kopiert es
- [ ] Damit einen WebDAV-Client verbinden (Username = normaler Benutzername)
- [ ] Liste zeigt „Last used" nach der ersten Nutzung
- [ ] Widerrufen → Client kommt sofort nicht mehr rein
- [ ] Kontopasswort im WebDAV-Client → abgelehnt; der Anmeldedialog nennt den
Grund im Realm-Text 🔎
- [ ] Kontopasswort funktioniert weiterhin fürs **Web-Login** 🔎
- [ ] App-Passwort funktioniert **nicht** fürs Web-Login 🔎
### Audit-Log (Admin → Activity)
- [ ] Tab zeigt Einträge, neueste zuerst 🔎
- [ ] Fehlgeschlagene Anmeldung erscheint rot als „Sign-in failed" 🔎
- [ ] Anmeldung, Passwortwechsel, User-/Gruppen-Änderungen, Freigaben und
Löschungen tauchen auf 🔎
- [ ] Filter-Dropdown schränkt auf eine Aktion ein
- [ ] Blättern funktioniert (ab 50 Einträgen)
- [ ] Gelöschtes Konto: Eintrag bleibt lesbar, Name mit „(deleted account)"
- [ ] Passwort-Reset steht im Protokoll – **ohne** das Passwort selbst
- [ ] Normaler User bekommt auf `/api/v1/admin/audit` ein `403`
### Speicher-Bereinigung (Admin → Activity → „Run now")
- [ ] „Run now" meldet „Nothing to clean up", solange alles in Ordnung ist 🔎
- [ ] Verwaiste Datei älter als 24 h wird entfernt, Ergebnis wird angezeigt 🔎
- [ ] Frisch angelegte verwaiste Datei bleibt liegen (Schonfrist) 🔎
- [ ] Echte Dateien bleiben nach dem Lauf unangetastet 🔎
- [ ] Der Lauf erscheint anschließend selbst im Audit-Log
---
## Phase 4.5/B – Interne Freigaben
### Freigeben (Rechtsklick → „Share with people…")
- [ ] Ordner an eine **Person** freigeben, Recht „Can view"
- [ ] Empfänger sieht ihn unter **Shared with me**, mit „View only"
- [ ] Empfänger kann hineinsehen und herunterladen
- [ ] Empfänger kann **nicht** umbenennen, löschen, hochladen (Meldung statt Fehler)
- [ ] Recht auf „Can edit" ändern → wird ersetzt, **kein** zweiter Eintrag
- [ ] Empfänger kann jetzt umbenennen und hochladen
- [ ] Einzelne **Datei** freigeben → Empfänger sieht nur sie, nicht den Ordner drumherum
- [ ] An eine **Gruppe** freigeben → alle Mitglieder sehen es, mit Hinweis „via <Gruppe>"
- [ ] Mitglied aus der Gruppe entfernen → Zugriff sofort weg
- [ ] „Remove" im Dialog → Zugriff sofort weg
### Grenzen (das Wichtige)
- [ ] Empfänger kann die Freigabe **nicht weitergeben** (nur der Eigentümer teilt)
- [ ] Dateien bleiben beim Eigentümer: Speicherbalken des **Eigentümers** wächst,
wenn der Empfänger etwas hochlädt – nicht der des Empfängers
- [ ] Unterordner, den ein Empfänger anlegt, gehört dem **Eigentümer**
- [ ] Empfänger kann eine geteilte Datei **nicht** in seinen eigenen Ordner verschieben
- [ ] Ohne Freigabe liefert ein direkter Aufruf mit bekannter ID `404` (nicht `403` –
sonst verriete die Antwort, dass die ID existiert)
- [ ] Freigaben tauchen im Audit-Log als `share.grant` / `share.revoke` auf
> Über WebDAV erscheinen Freigaben unter `/dav/Shared/<Eigentümer>/<Freigabe>/`
> – siehe eigener Abschnitt weiter unten.
---
---
## Phase 4.5/C – Gruppenordner
### Anlegen (Admin → Groups)
- [ ] Gruppe wählen, Namen eingeben, „Create" → Ordner erscheint in der Liste
- [ ] Zweiter Ordner für dieselbe Gruppe → Fehler „already has a folder" (`409`) 🔎
- [ ] Übersicht zeigt Gruppe, Mitgliederzahl und belegten Platz
### Beim Nutzer
- [ ] **Kein** Mitglied: Ordner ist nicht sichtbar 🔎
- [ ] Mitglied hinzufügen → Ordner erscheint in dessen Wurzel, beschriftet
als „Group folder" 🔎
- [ ] Zwei Mitglieder arbeiten darin: hochladen, herunterladen, umbenennen,
Unterordner anlegen 🔎
- [ ] Mitglied entfernen → Ordner verschwindet sofort 🔎
### Der Ordner selbst ist geschützt
- [ ] Rechtsklick auf den Gruppenordner: **kein** Löschen, Umbenennen,
Verschieben, Teilen – nur Öffnen und Favorit
- [ ] Er lässt sich nicht per Drag & Drop verschieben
- [ ] Direkter API-Aufruf (`DELETE /folders/{id}`) → `403` 🔎
- [ ] **Innerhalb** des Ordners ist alles normal löschbar 🔎
### Speicher und Papierkorb
- [ ] Dateien liegen unter `groups/<id>/home/…`, nicht bei einem Nutzer 🔎
- [ ] Kontingent zählt gegen die **Gruppe**, bei keinem Mitglied 🔎
- [ ] Gruppenkontingent überschritten → `413` 🔎
- [ ] Gelöschte Gruppendatei erscheint im Papierkorb **jedes Mitglieds** und
lässt sich wiederherstellen 🔎
- [ ] Admin löscht den Gruppenordner → Daten und Verzeichnis restlos weg 🔎
### Geteiltes und Gruppenordner über WebDAV
Der Aufbau bildet die Weboberfläche nach: Gruppenordner liegen **in der Wurzel**
neben den eigenen, Personen-Freigaben gebündelt unter `Shared/`.
- [ ] Gruppenordner erscheint in der WebDAV-Wurzel, gleiche Liste wie in der
Weboberfläche 🔎
- [ ] Hineinnavigieren, hochladen, Unterordner anlegen 🔎
- [ ] **Namensgleichheit:** eigener Ordner `Projekt` + Gruppenordner `Projekt`
→ eigener behält den Namen, Gruppenordner heißt `Projekt (<Gruppe>)`,
beide erreichbar, Schreibvorgänge landen jeweils richtig 🔎
- [ ] Nach Austritt aus der Gruppe verschwindet er aus der Wurzel 🔎
- [ ] `Shared` erscheint nur, wenn eine **Person** etwas geteilt hat 🔎
- [ ] `/Shared/` listet die Personen, `/Shared/<Person>/` deren Freigaben 🔎
- [ ] Datei im geteilten Ordner lesen und schreiben (bei „Can edit") 🔎
- [ ] Einzeln geteilte Datei erscheint als **Datei**, nicht als Ordner 🔎
- [ ] Nur-Lesen: Schreiben und Löschen → `403` (nicht `404`/`405`) 🔎
- [ ] In `/Shared/` selbst lässt sich nichts anlegen oder löschen (`403`) 🔎
- [ ] Verschieben über Eigentumsgrenzen → `403` 🔎
- [ ] Nicht Vorhandenes bleibt `404` – verrät nichts 🔎
- [ ] Entzug der Freigabe / Austritt aus der Gruppe → sofort `404` 🔎
- [ ] Bens Upload landet im Bereich des **Eigentümers** und zählt gegen dessen
Kontingent 🔎
- [ ] Prüfsumme über den geliehenen Pfad: Upload → WebDAV-GET → Web-Download,
alles identisch 🔎
- [ ] Mit einem echten Client (Dolphin) durchgehen
### OIDC (nur falls konfiguriert)
OIDC ist optional. Test nur, wenn `OIDC_ISSUER` etc. in `.env` gesetzt sind.
**Wichtig:** `OIDC_REDIRECT_URL` muss auf `<PUBLIC_URL>/api/v1/auth/oidc/callback` zeigen
und exakt so beim IdP registriert sein.
- [ ] Ohne OIDC-Config: **kein** SSO-Button auf der Login-Seite (Standardfall)
- [ ] Mit OIDC-Config: SSO-Button erscheint
- [ ] Klick auf SSO → Weiterleitung zum IdP
- [ ] Nach erfolgreichem IdP-Login → zurück in Aether, eingeloggt
- [ ] Beim ersten OIDC-Login wird automatisch ein User angelegt (aus `preferred_username`)
- [ ] Zweiter OIDC-Login desselben Accounts → gleicher User (kein Duplikat)
- [ ] Username-Kollision → automatischer Suffix (`name2`, `name3`, …)
- [ ] Manipulierter/abgebrochener Callback → Redirect mit Fehler, kein Login
- [ ] CSRF/State: Login mit falschem `state`-Cookie → abgelehnt
---
## Phase 4.5/D – Logo & App-Symbol
### Admin → Theming: Logo
- [x] Logo hochladen → erscheint sofort in der Vorschau, Seitenleiste und
auf der Anmeldeseite (ohne Speichern-Klick) 🔎
- [x] Logo austauschen → neues Bild sofort sichtbar, altes ist von der
Platte verschwunden 🔎
- [ ] „Remove" → Standard-Wolke ist zurück 🔎
- [ ] SVG, Textdatei, getarnte EXE → abgelehnt (`415`) 🔎
- [ ] Datei über 2 MB → `413` mit klarer Meldung 🔎
- [ ] Normaler Nutzer kann weder hochladen noch löschen (`403`) 🔎
- [ ] Logo ist **ohne** Anmeldung abrufbar (die Anmeldeseite braucht es) 🔎
### Admin → Theming: App-Symbol
Ein Upload für beides – Tab-Symbol und installierte App. Der Server rechnet
64, 192, 512 und die maskable-Fassung daraus.
- [ ] Quadratisches PNG (am besten 512×512) hochladen → Symbol im Tab wechselt
ohne Neuladen 🔎
- [ ] Anderer, schon offener Tab zieht beim nächsten Laden nach
- [ ] Austauschen → neues Symbol sofort da, altes Verzeichnis von der Platte
verschwunden 🔎
- [ ] „Remove" → Standard-Symbol ist zurück, Verzeichnis gelöscht 🔎
- [ ] Vorlage kleiner als 192×192 → `422` mit klarer Meldung 🔎
- [ ] Vorlage mit 256×256 → es entsteht **kein** 512er, das Manifest nennt ihn
auch nicht (nie hochskalieren) 🔎
- [ ] Nicht quadratisches Bild → wird gerahmt, **nicht** beschnitten 🔎
- [ ] SVG und ICO → abgelehnt (`415`); Datei über 2 MB → `413` 🔎
- [ ] Ohne Anmeldung abrufbar (der Browser fragt das Tab-Symbol vor dem
Anmelden ab) 🔎
- [ ] Speicher-Bereinigung („Run now") löscht den Symbol-Satz **nicht** 🔎
### Manifest (`/manifest.webmanifest`)
- [ ] Direkt aufrufen → nennt Cloud-Namen und Primärfarbe aus dem
Admin-Bereich, nicht die fest eingebauten Werte 🔎
- [ ] Ohne eigenes Symbol fällt es auf die mitgelieferten Bilder zurück 🔎
- [ ] Auf dem Handy „Zum Startbildschirm hinzufügen" → Name und Symbol stimmen
- [ ] **Bereits installierte App:** Symbol wechselt erst nach ein, zwei Tagen –
Chrome fragt das Manifest von selbst nach, aber nicht sofort. Sicher
sofort nur nach Deinstallieren und neu Installieren.
---
## Vor und Zurück im Verlauf
- [ ] In mehrere Ordner nacheinander wechseln → **Zurück** (Browser-Taste,
Seitentaste der Maus oder Wischgeste) geht die Reihenfolge rückwärts,
**Vor** wieder nach vorn
- [ ] In der Werkzeugleiste stehen **keine** eigenen Vor/Zurück-Pfeile mehr –
das macht allein der Browser
- [ ] Über die Seitenleiste die Ansicht wechseln (Papierkorb, Freigaben,
Admin, Profil) → Zurück führt zur vorigen Ansicht
- [ ] Aus einer anderen Ansicht zurück zu „Files" → der zuletzt geöffnete
Ordner ist wieder da, nicht die Wurzel
- [ ] Über die Brotkrumen nach oben springen → zählt als eigener Schritt,
Zurück führt wieder nach unten
- [ ] Auf einen Favoriten in der Seitenleiste klicken → eigener Schritt im
Verlauf
- [ ] Zweimal denselben Ordner öffnen, in dem man schon steht → erzeugt
**keinen** zweiten Schritt (Zurück springt nicht ins Leere)
- [ ] Seite neu laden (`F5`) → man landet wieder im selben Ordner
- [ ] Adresse zeigt die Ansicht (`/admin`, `/trash`); direkt eingegeben lädt
sie auch. Der Ordner steht **nicht** in der Adresse – ein Link auf einen
tiefen Ordner ist also nicht teilbar (bewusst, siehe Projektplan)
---
## Suche
Feld in der Werkzeugleiste, rechts neben den Brotkrumen. Sucht in **Namen** von
Dateien und Ordnern – und seit dem 23.08.2026 zusätzlich im **Inhalt** von PDFs
und Bildern (siehe „Volltext über den Inhalt" weiter unten).
- [ ] Ab zwei Zeichen erscheinen Treffer, ein Zeichen tut nichts
- [ ] Unter jedem Treffer steht der Ordner, in dem er liegt
- [ ] Ordner-Treffer anklicken → Ordner wird geöffnet
- [ ] Datei-Treffer anklicken → springt **nur** in den Ordner. Es öffnet sich
**keine** Vorschau 🔎
- [ ] Die gesuchte Datei ist dort kurz umrandet und wird in den sichtbaren
Bereich geholt; die Markierung verschwindet nach knapp drei Sekunden von
selbst 🔎
- [ ] Geöffnet wird weiterhin per **Doppelklick** auf die Datei
- [ ] Das gilt in Kachel- **und** Listenansicht
- [ ] Pfeiltasten wählen aus, Enter öffnet, Esc schließt
- [ ] Schnell tippen → keine veralteten Treffer (jede Anfrage bricht die
vorige ab)
- [ ] Papierkorb bleibt außen vor: gelöschte Datei erscheint nicht 🔎
### Volltext über den Inhalt (OCR)
Der Punkt, für den ein Rechnungsarchiv überhaupt gebaut wird: Ein Beleg namens
`scan_0042.pdf` muss über das zu finden sein, was **drin** steht.
Wichtig zum Verständnis beim Prüfen: Die Erkennung läuft **im Hintergrund**.
Direkt nach dem Hochladen findet die Suche den Inhalt noch nicht – bei einem
PDF mit Textebene nach etwa einer Sekunde, bei einem Scan nach rund einer
Minute je Seite. Der Stand steht in `GET /api/v1/admin/ocr` (noch ohne
Oberfläche) und im Log (`docker compose logs app | grep component=ocr`).
- [ ] PDF **mit** Textebene hochladen → nach ein paar Sekunden findet ein Wort
aus dem Text die Datei; im Log steht `source=layer` 🔎
- [ ] Eingescannte oder abfotografierte Rechnung hochladen → nach etwa einer
Minute je Seite findet ein Wort daraus die Datei; im Log `source=ocr` 🔎
- [ ] Unter dem Treffer steht die **Fundstelle** mit hervorgehobenem Suchwort,
und der Name allein hätte nicht gepasst 🔎
- [ ] Die Fundstelle läuft über **zwei Zeilen** und bricht am Ende der zweiten
mit „…" ab – nicht mitten in der ersten 🔎
- [ ] Links daneben eine dünne Linie in derselben Farbe wie das hervorgehobene
Wort; sie trennt das Zitat vom Ordnernamen darüber 🔎
- [ ] Ein Dokument mit `<script>` im Text zeigt das als **sichtbaren Text**,
führt es nicht aus (die Marken kommen als `[[hl]]`, nicht als HTML)
- [ ] Ordner- und Dateinamen beginnen in der Trefferliste auf **derselben**
linken Kante (gemessen: alle bei x=616) 🔎
- [ ] Datei, deren **Name** passt, steht **vor** den Inhaltstreffern 🔎
- [ ] Eine Datei, bei der Name *und* Inhalt passen, erscheint **einmal**, nicht
zweimal 🔎
- [ ] **Das Original ist unverändert:** Größe und Prüfsumme nach der Erkennung
identisch, keine neue Version in der Historie, WebDAV lädt die Datei
nicht erneut 🔎
- [ ] Bestandsdateien von vor dem Einbau sind ebenfalls durchsuchbar (werden
beim ersten Start nachgetragen) 🔎
- [ ] Gelöschte Datei erscheint auch bei Inhaltstreffern nicht
- [ ] Ein anderer Nutzer findet **nichts** über den Inhalt fremder Dokumente –
der Umfang ist derselbe wie bei der Namenssuche
- [ ] Word-/Excel-Datei liefert keinen Inhaltstreffer (bewusst nicht gebaut),
wird aber weiterhin über den Namen gefunden
- [ ] Erstes Dokument nach einem frischen Container-Start gelingt beim
**ersten** Versuch (kein „Tesseract failed to report available
languages" im Log) 🔎
- [ ] `OCR_ENABLED=false` in der `.env` → Cloud läuft normal, nur ohne
Inhaltstreffer; `/api/v1/admin/ocr` meldet `enabled:false`
**Platte** (OCR ist das Einzige, was von allein Platz verbraucht):
- [ ] Während der Erkennung eines großen Scans bleibt `/data/tmp` fast leer –
`docker compose exec app du -sh /data/tmp` sollte im Kilobyte-Bereich
liegen, nicht im Megabyte-Bereich 🔎
- [ ] Nach der Erkennung liegen dort keine `ocr-*`-Dateien mehr 🔎
- [ ] `docker kill` mitten in einer Erkennung → beim nächsten Start meldet das
Log „Zwischendateien eines früheren Laufs entfernt"
- [ ] Bei weniger als 2 GiB freiem Platz pausiert die Erkennung mit einer
Warnung im Log, statt weiterzuarbeiten; die Cloud bleibt bedienbar
### Suche und Berechtigungen (das Wichtige)
Am 23.08.2026 mit zwei Wegwerf-Konten durchgespielt – die Punkte sind grün,
aber ein zweites Augenpaar auf der Oberfläche schadet nicht.
- [ ] Fremde Dateien tauchen **nie** auf, auch nicht mit passendem Namen 🔎
- [ ] Nach einer Freigabe erscheinen die Dateien des anderen – auch die in
**Unterordnern** der Freigabe (Vererbung nach unten) 🔎
- [ ] Nach Entzug der Freigabe sind sie sofort wieder weg 🔎
- [ ] Gruppenordner erscheinen bei Mitgliedern, bei anderen nicht 🔎
- [ ] Nach Austritt aus der Gruppe sofort weg 🔎
- [ ] Suchwort mit `%` oder `_` sucht diese Zeichen wörtlich, nicht als
Platzhalter (`%e` findet **nicht** alles mit „e") 🔎
## Vorschau
Doppelklick auf eine Datei zeigt sie, statt sie herunterzuladen. Der
Download-Knopf und das Kontextmenü laden weiter herunter.
- [ ] Bild, PDF, Textdatei, Video, Tondatei → werden angezeigt
- [ ] **PDF** öffnet den eingebauten Betrachter mit Seitenleiste, Zoom und
Drucken 🔎
- [ ] Speicherbalken zeigt direkt nach der Anmeldung den echten Wert, nicht
0 B 🔎
- [ ] ZIP, EXE und anderes → Doppelklick lädt weiter herunter
- [ ] Pfeile links/rechts blättern durch die anzeigbaren Dateien des Ordners,
Pfeiltasten ebenso
- [ ] Esc schließt, Klick auf den dunklen Rand ebenso
- [ ] „Download" in der Vorschau lädt die Datei
- [ ] Textdatei über 2 MB → Hinweis statt Anzeige
- [ ] **Video/Ton:** in der Abspielleiste vor- und zurückspringen, auch an eine
Stelle, die noch nicht geladen war 🔎
- [ ] Bild behält sein Seitenverhältnis, egal wie das Fenster steht
- [ ] Textansicht ist ein Blatt in der Mitte, nicht die ganze Fläche
### Textdatei bearbeiten
- [ ] „Edit" → Textfeld, „Save" schreibt zurück, Liste zeigt neue Größe
- [ ] Bei eingeschalteter Versionierung landet die alte Fassung in der Historie
- [ ] „Cancel" verwirft, Schließen mit ungesicherten Änderungen fragt nach
- [ ] Pfeiltasten schreiben im Textfeld, statt zu blättern
- [ ] Nur-Lese-Freigabe → „Save" meldet fehlenden Schreibzugriff
- [ ] **Sicherheit:** eine hochgeladene HTML-Datei wird auch mit `?inline=1`
als Download ausgeliefert, nicht angezeigt 🔎
- [ ] Fremde Datei über `?inline=1` → `404` wie beim normalen Download 🔎
## Kachel-Cover nach Dateityp
- [x] Ton, Video, Archiv, Tabelle, **Präsentation**, Code, PDF und Dokument
haben je eigenes Symbol und eigene Farbe
- [x] Word (blau), Excel (grün), PowerPoint (orangerot) sind auf einen Blick
auseinanderzuhalten – auch die OpenDocument-Entsprechungen
(`.odt`, `.ods`, `.odp`) 🔎
- [x] PDF und Word-Dokument haben **verschiedene** Symbole, nicht nur
verschiedene Farben
- [x] `.md` und `.txt` zählen als Text (grau), nicht als Office-Dokument
- [x] Unten links steht die Endung als Aufkleber (`MP3`, `TAR.GZ`, `CSV`)
- [ ] Ton und Video tragen zusätzlich ein Abspielzeichen
- [ ] `.tar.gz` wird als Archiv erkannt, obwohl die Endung zweiteilig ist 🔎
- [x] Bilder zeigen weiterhin ihr Vorschaubild – mit Aufkleber darüber, damit
PNG, JPG, GIF und SVG unterscheidbar bleiben
- [ ] Ordner: zweilagige Form mit Reiter und heller Oberkante, nicht mehr
flachgedrückt; Gruppenordner in der Primärfarbe mit `GROUP`-Aufkleber
- [ ] Auch in „Verschieben nach…", „Shared with me" und im Admin-Bereich
dieselbe Form – dort waren die Ordner vorher **schwarz** 🔎
- [ ] Ziel beim Ziehen hebt sich weiterhin ab
- [ ] Listenansicht und Suchtreffer zeigen denselben Ordner wie die Kacheln
## Dateitypen (MIME)
- [ ] Neu hochgeladene `.mp3` erscheint als `audio/mpeg`, nicht als
`application/octet-stream` 🔎
- [ ] **Bestandsdatei** mit falschem Typ lässt sich trotzdem abspielen – der
Typ wird beim Ausliefern aus dem Namen neu bestimmt 🔎
## Kopfleiste in allen Ansichten
Alle sechs Ansichten teilen sich dieselbe Leiste (`components/PageHeader.tsx`),
feste Höhe 57 px.
- [ ] Zwischen Home, Papierkorb, „Shared with me", Freigabe-Links, Admin und
Profil umschalten → die Leiste **springt nicht**, der Inhalt darunter
beginnt überall auf derselben Kante
- [ ] Symbol (18 px) und Titel (15 px, Stärke 600) sitzen überall gleich –
auch „Home" in den Brotkrumen. Beim Umschalten springt nichts 🔎
(nachgemessen: Symbol x=240, Titel x=268 in allen sechs Ansichten)
- [ ] Alle Kopfsymbole haben runde Strichenden
- [ ] Bedienelemente stehen überall rechts
- [ ] Schmales Fenster: die Leiste bleibt einzeilig, nichts bricht um
## Einstellungen und Admin-Zugang (23.08.2026)
- [ ] „Admin" steht **nicht mehr** in der Seitenleiste – der Weg dorthin führt
über das Nutzermenü oben rechts 🔎
- [ ] Die Seitenleiste zeigt nur noch Orte: Home, Trash, Shared with me,
Shared links, Favoriten 🔎
- [ ] Die Einstellungen heißen „Settings" (nicht mehr „Profile") und sind in
**Reiter** unterteilt wie der Admin-Bereich:
**Account** → **Access** → **Appearance** → **Storage** 🔎
- [ ] Die Reiterleiste sieht in Einstellungen und Admin-Bereich **gleich** aus –
beide benutzen `components/Tabs.tsx`. Gemessen: Höhe 39, Schrift 14 px,
aktiver Reiter fett und unterstrichen 🔎
- [ ] Der Speicherbalken steht am **Ende**, nicht mehr ganz oben 🔎
- [ ] Bei „App passwords" steht der Hinweis dabei, dass das Kontopasswort an
`/dav/` bewusst abgelehnt wird
- [ ] Ein Nutzer **ohne** Adminrechte sieht „Admin panel" im Menü nicht und
kommt über `/admin` auch nicht hinein
## Stand der Texterkennung im Admin-Bereich (23.08.2026)
Admin panel → Activity, oberhalb von „Storage cleanup".
- [ ] „Full-text indexing" zeigt die Zahl der gelesenen Dokumente 🔎
- [ ] Während die Warteschlange abgearbeitet wird: gelber Punkt, Drehrädchen
und „… to go"; die Zahl aktualisiert sich von selbst alle fünf Sekunden
- [ ] Steht die Warteschlange still, hört das Nachfragen auf (im Netzwerk-Reiter
der Entwicklerwerkzeuge zu sehen) – sonst liefe alle fünf Sekunden eine
Anfrage ins Leere
- [ ] Grüner Punkt, wenn alles gelesen ist
- [ ] Bei `OCR_ENABLED=false`: gedämpfter Punkt und ein Hinweis, was fehlt
- [ ] Nicht lesbare Dateien werden mitgezählt („n could not be read")
### Eigener Stand für jeden Nutzer
Settings → Storage → „Document search". Der Admin-Bereich zeigt den ganzen
Server; hier sieht **jeder** Nutzer seine eigenen Dateien.
- [ ] Als normaler Nutzer (ohne Adminrechte) ist die Karte sichtbar und zeigt
eine Zahl 🔎
- [ ] Die Zahl umfasst **nur die eigenen** Dateien – ein zweites Konto mit
eigenen PDFs sieht seine eigene Zahl, nicht die des Servers 🔎
- [ ] `GET /api/v1/ocr` ohne Anmeldung → `401` 🔎
- [ ] Während der Erkennung: gelber Punkt und „… still to go", danach grün
- [ ] Kein Adminrecht nötig, um die eigene Zahl zu sehen
## Etiketten und Belegdatum (23.08.2026)
Rechtsklick auf eine Datei → „Tags…".
- [ ] Neues Etikett im Dialog anlegen; es erscheint sofort gewählt 🔎
- [ ] Derselbe Name in anderer Schreibweise wird abgelehnt („STEUER-2026"
neben „steuer-2026") 🔎
- [ ] Leerer Name wird abgelehnt 🔎
- [ ] Etiketten erscheinen als Aufkleber an der Kachel **und** in der
Listenansicht 🔎
- [ ] Seitenleiste zeigt sie unter den Favoriten mit Farbpunkt und Zählstand 🔎
- [ ] Klick darauf zeigt alles mit diesem Etikett – **über Ordnergrenzen
hinweg** 🔎
- [ ] Die Adresse `/tags/<id>` ist verlinkbar und übersteht ein Neuladen 🔎
- [ ] Zurück-Taste des Browsers führt vom Etikett zurück
- [ ] Gelöschte Dateien zählen nicht mit und erscheinen nicht
**Belegdatum**
- [ ] Datum setzen, Dialog erneut öffnen → das Datum steht drin (nicht leer) 🔎
- [ ] Die Etikettenansicht sortiert nach Belegdatum, neueste zuerst; Dateien
ohne Datum stehen am Ende 🔎
- [ ] Datum wieder leeren funktioniert
**Was nicht schiefgehen darf**
- [ ] Ungültiges Datum → Fehlermeldung, und die Etiketten bleiben **unverändert**
(nicht halb gespeichert) 🔎
- [ ] Eine Datei in einem **Gruppenordner** kann nur Gruppen-Etiketten tragen;
der Dialog zeigt dort auch nur diese 🔎
- [ ] Ein zweites Konto sieht die eigenen Etiketten **nicht**
- [ ] Wer eine Datei nur lesen darf, kann sie nicht etikettieren
- [ ] Etikett löschen entfernt nur die Ordnung, **keine Dateien** 🔎
## Sammel-Download als ZIP (23.08.2026)
Drei Wege dorthin - Auswahl, Ordner, Etikett.
**Auswahl**
- [ ] `Strg`-Klick (oder Klick aufs Kaestchen) waehlt eine Datei aus; das
Kaestchen erscheint beim Ueberfahren 🔎
- [ ] Ist etwas ausgewaehlt, genuegt ein einfacher Klick fuer die naechste
- [ ] `Umschalt`-Klick waehlt den **Bereich** in der angezeigten Reihenfolge
- [ ] Die Kopfleiste zeigt "n selected" mit "Download as ZIP" und "Clear" 🔎
- [ ] Ordnerwechsel verwirft die Auswahl (sonst laedt man ungesehen mit) 🔎
- [ ] **Escape** hebt die Auswahl auf 🔎
- [ ] Ist die Vorschau offen, schliesst das erste Escape **nur die Vorschau** –
die Auswahl bleibt; erst das zweite hebt sie auf 🔎
- [ ] Waehrend man in ein Feld tippt, verwirft Escape die Eingabe und nicht
die Auswahl
- [ ] Ein einfacher Klick waehlt **nichts** aus, auch nicht wenn schon etwas
markiert ist – dafuer gibt es Kaestchen, Strg und Umschalt 🔎
- [ ] Ein Doppelklick oeffnet die Vorschau und **aendert die Auswahl nicht** 🔎
- [ ] Funktioniert in Kachel- **und** Listenansicht
**Ordner und Etikett**
- [ ] Rechtsklick auf einen Ordner -> "Download as ZIP" laedt den ganzen Inhalt 🔎
- [ ] **Unterordner behalten ihre Struktur** im Archiv 🔎
- [ ] Etikettenansicht -> "Download all as ZIP" laedt alles mit diesem Etikett
- [ ] Das Archiv heisst nach Ordner bzw. Etikett 🔎
**Was nicht schiefgehen darf**
- [ ] Ohne Anmeldung -> `401`, ohne CSRF-Token -> `403` 🔎
- [ ] Eine erfundene Datei-Kennung landet **nicht** im Archiv 🔎
- [ ] Fremde Dateien sind nicht enthalten, auch nicht bei geratener Kennung
- [ ] Zwei gleichnamige Dateien werden zu "name" und "name (2)" - nicht
stillschweigend zu einer 🔎
- [ ] `paket.tar.gz` doppelt -> "paket (2).tar.gz", nicht "paket.tar (2).gz" 🔎
- [ ] Umlaute im Dateinamen kommen im entpackten Archiv richtig an
- [ ] Ein grosser Ordner startet **sofort** (gestreamt) statt erst zu sammeln 🔎
- [ ] Ueber 2000 Dateien -> klare Meldung statt endlosem Warten
- [ ] Bereits gepackte Formate (.zip, .deb, .jpg, .pdf) werden nur gelagert,
nicht erneut komprimiert 🔎
## Bedienung am Finger (24.08.2026)
**Auf dem Handy öffnete gar nichts.** Ordner und Dateien hingen an
`onDoubleClick` – und den doppelten Klick gibt es auf einem Touchscreen
praktisch nicht: Man trifft nicht zweimal denselben Pixel, der Browser meldet
zwei einzelne Berührungen. Der Doppelklick ist eine Mausgeste.
Unterschieden wird jetzt nach `pointer: coarse`, also nach dem **Zeigegerät**,
nicht nach der Fensterbreite: Ein schmales Fenster am Schreibtisch bleibt eine
Maus, ein Tablet im Querformat bleibt ein Finger.
- [ ] Handy: **ein Tipp** öffnet Ordner 🔎 und Dateien 🔎 (Kachel und Liste)
- [ ] Läuft eine Auswahl, schaltet der Tipp die Auswahl um statt zu öffnen –
ohne Strg/Umschalt wäre Mehrfachauswahl am Telefon sonst unbedienbar
- [ ] Langes Drücken öffnet das Kontextmenü (**bitte am Gerät prüfen** – die
Kacheln sind auch ziehbar, und der Browser könnte stattdessen eine
Ziehbewegung beginnen)
- [ ] Desktop **unverändert**: Doppelklick öffnet, einfacher Klick bleibt frei,
Strg/Umschalt wählt aus 🔎
> **Falle, die zweimal zuschlug:** In JSX gewinnt die zuletzt notierte
> Eigenschaft. Ein per Spread eingesetztes `onClick` wurde vom danach
> notierten `onClick` stillschweigend überschrieben – der Tipp auf ein PDF tat
> nichts, während der auf einen Ordner ging. Dateien schreiben ihren Handler
> deshalb aus, statt ihn zusammenzusetzen. Dieselbe Sorte Fehler wie
> Inline-Stil gegen Klasse: Es wird gesetzt und sofort überschrieben.
---
## Zoom „ganze Breite" bei gemischten Formaten (24.08.2026)
Gerechnet wird gegen die **häufigste** Seitenbreite, nicht mehr gegen die
breiteste.
- [ ] Zehnseitiges Dokument mit **einer** Querformat-Seite: die hochkanten
Seiten füllen die Breite 🔎 (gemessen 384 von 412 px; vorher 46 % statt
65 %, weil eine einzige Seite das ganze Dokument bestimmte)
- [ ] Die Querformat-Seite ragt über den Rand und lässt sich seitlich
schieben – das ist bei ihr ohnehin gewollt
- [ ] Reines Hochformat: unverändert
---
## „invalid credentials" beim ersten Admin-Login (24.08.2026)
Symptom: frischer Start, `admin account ready` im Log, Volume mehrfach
gelöscht – und die Anmeldung mit den richtigen Daten gibt `401`.
Ursache ist die `.env`, nicht der Code: **Ein `$` wird von Docker Compose als
Variable gelesen und schneidet den Wert ab.** Gemessen mit
`ADMIN_PASSWORD=Sicher$Passwort2026`:
```
angekommen ist: Sicher (6 statt 19 Zeichen)
Compose meldet dazu: The "Passwort2026" variable is not set.
```
Die Meldung erscheint auf stderr beim `up`, nicht im Container-Log – wer die
Logs im Nachhinein ansieht, sieht sie nie. Die Datenbank funktioniert dabei,
weil Postgres und Anwendung **denselben** abgeschnittenen Wert bekommen.
Escapen:
| in der `.env` | kommt an |
|---|---|
| `abc$def` | `abc` |
| `abc$$def` | `abc$def` ✓ |
| `'abc$def'` | `abc$def` ✓ |
| `abc\$def` | `abc\` |
- [ ] `ADMIN_PASSWORD` mit `$$` → `password_length` stimmt, Anmeldung 200 🔎
(gemessen: 19 Zeichen, Login 200)
- [ ] Ohne Escape → Warnung im Log „ADMIN_PASSWORD ist auffaellig kurz" 🔎
- [ ] Nachsehen, was wirklich ankam: `docker compose exec app printenv
ADMIN_PASSWORD`
> Der Start nennt seit dem 24.08.2026 die **Länge** des Passworts (nicht den
> Wert) und warnt unter 12 Zeichen. Ohne diese Zahl war der Fehler von außen
> nicht zu sehen.
---
## Office-Container entsteht nicht (24.08.2026)
Kein Fehler, keine Meldung – der Container existiert einfach nicht. Ursache
war `profiles: ["office"]` in den README-Schnipseln: Ein Dienst in einem
Profil startet nur mit `--profile office`.
**In Portainer, Dockge oder Komodo gibt es diesen Schalter nicht.** Dort war
der Dienst über die Bedienung gar nicht erreichbar, und nichts wies darauf
hin. Eine Voreinstellung, bei der das Einfügen des Schnipsels nichts bewirkt,
ist die falsche – die Schnipsel haben deshalb **kein** Profil mehr.
- [ ] Schnipsel aus dem README anhängen, `docker compose up -d` → der
Office-Container entsteht 🔎 (geprüft für alle drei: `app office
postgres`)
- [ ] Abschalten mit `docker compose stop office`
- [ ] Wer ein Profil will, hängt `profiles: ["office"]` selbst an – dann
entweder `--profile office` oder `COMPOSE_PROFILES=office` in der
`.env`; Letzteres wirkt auch ohne Kommandozeile 🔎
---
## Kommentar hinter leerem Wert in der .env (24.08.2026)
Beim Testen selbst gebaut und sofort wieder eingefangen. In einer `.env` gilt:
```
A=production # kommentar → production ✓
C=production#kommentar → production#kommentar
F= # kommentar → "# kommentar" ✗
```
Hinter einem **gefüllten** Wert ist ein Kommentar harmlos. Hinter einem
**leeren** wird er zum Wert. `OIDC_ISSUER= # z. B. https://…` startete damit
den OIDC-Aufbau mit dem Kommentartext als Adresse – und weil ein Fehlschlag
dort den Start abbricht, kam die Cloud nicht hoch.
In der (inzwischen zusammengeführten) `.env.example` steckten vier weitere
solche Zeilen, darunter
`OFFICE_PROVIDER= # collabora | onlyoffice`: Office wäre mit Unsinn als
Anbieter eingeschaltet worden. Alle vier umgestellt, der Kommentar steht jetzt
darüber.
- [ ] `grep -nE '^[A-Z_]+=[[:space:]]*#' .env.example`
liefert nichts 🔎
- [ ] Frische Instanz allein aus den Beispieldateien startet 🔎
---
## Beschriftung des SSO-Knopfes (24.08.2026)
`OIDC_BUTTON_LABEL`, Vorgabe „Sign in with SSO / OIDC".
- [ ] Gesetzt → der Knopf auf der Anmeldeseite trägt den Text
- [ ] Nicht gesetzt → die Vorgabe
- [ ] OIDC aus → `/public/config` liefert einen **leeren** Wert 🔎 (die
Einstellung geht niemanden etwas an, solange es keinen Knopf gibt)
---
## Versionen: was beim Ausschalten passiert (24.08.2026)
Der Schalter „Keep versions" setzt nur ein Flag. Vorhandene Fassungen bleiben
liegen – mitsamt ihrem Platz im Kontingent. Gemessen an einer Datei mit drei
Fassungen:
| Schritt | Fassungen | Blobs |
|---|---|---|
| dreimal überschrieben | 3 | 4 |
| Versionierung ausgeschaltet | 3 | 4 |
| in den Papierkorb | 3 | 4 |
| Papierkorb geleert | – | 0 |
- [ ] Ausschalten löscht nichts 🔎
- [ ] Der Papierkorb genügt nicht – dort bleibt alles 🔎
- [ ] Leeren des Papierkorbs entfernt Fassungen **und** Blobs sofort 🔎
(nicht erst beim nächsten GC)
---
## Anzahl aufgehobener Fassungen (24.08.2026)
`MAX_VERSIONS_KEPT`, Vorgabe 5. Vorher eine Konstante – und zwar **zwei**:
eine im Backend, eine im Verlaufs-Dialog, verbunden nur durch den Kommentar
„muss zusammenpassen". Ein Satz ist keine Zusicherung. Der Dialog holt die
Zahl jetzt aus `/api/v1/public/config`.
- [ ] `MAX_VERSIONS_KEPT=3` → `/public/config` meldet 3 🔎
- [ ] Datei mit „Keep versions" siebenmal überschreiben → genau 3 Fassungen
im Verlauf, und zwar die neuesten 🔎 (gemessen: v6, v5, v4 bei
aktueller v7)
- [ ] Der Dialog nennt dieselbe Zahl wie der Server
- [ ] `MAX_VERSIONS_KEPT=0` wird verworfen, die Vorgabe greift – `OFFSET 0`
löschte sonst **jede** Fassung
- [ ] Unbrauchbarer Wert (`abc`) → Vorgabe, kein Startabbruch
- [ ] Ohne „Keep versions" an der Datei entsteht weiterhin kein Verlauf
---
## Erreichbarkeit von außen (24.08.2026)
`ERR_CONNECTION_REFUSED` beim Zugriff über die IP ist meist kein Fehler,
sondern die Vorgabe: Der Port ist auf `127.0.0.1` gebunden, davor gehört der
Reverse Proxy. Der Kernel lehnt ab, bevor irgendetwas die Anwendung erreicht –
daher „refused" und kein Zeitüberlauf.
Prüfen:
```bash
docker ps --format '{{.Names}}\t{{.Ports}}'
# 127.0.0.1:8090->8080/tcp = nur dieser Rechner
# 0.0.0.0:8090->8080/tcp = das ganze Netz
```
- [ ] Ohne `APP_BIND` bindet der Port auf `127.0.0.1` 🔎
- [ ] `APP_BIND=0.0.0.0` bindet auf alle Adressen 🔎
- [ ] Mit `0.0.0.0` von einem zweiten Gerät im Netz erreichbar
- [ ] Hinter dem Reverse Proxy wieder auf `127.0.0.1` zurückstellen – sonst
steht die Anmeldung unverschlüsselt im Netz
---
## Frische Umgebung nur aus den Beispieldateien (24.08.2026)
Der einzige Test, der zählt: `docker-compose.example.yml` + `.env.example`
kopieren, Passwörter eintragen, starten. Nichts sonst.
Beim ersten Durchlauf brachte er sofort einen Fehler ans Licht, den keine
Codeprüfung findet:
```
migrations up to date
FTL blob storage init failed error="mkdir /app/data: permission denied"
```
`STORAGE_PATH` hat die Voreinstellung `./data` – richtig für ein Binary, das
man von Hand startet, falsch im Container, wo daraus `/app/data` wird statt
des Mounts unter `/data`. Die schlanke `.env.example` führte den Schlüssel
nicht, weil er „eine brauchbare Vorgabe" hat. Hatte er nicht.
Jetzt setzt das **Abbild** `STORAGE_PATH` und `TMP_PATH` selbst. Es kennt seine
Verzeichnisstruktur besser als jede Beispieldatei; eine `.env` darf weiterhin
überschreiben.
- [ ] Frische Umgebung nur aus den beiden Beispieldateien → `migrations up to
date`, `admin account ready`, `listening` 🔎
- [ ] `/health` 200, Anmeldung 200, `data/tmp` liegt auf dem Wirt 🔎
- [ ] Dabei ein Passwort mit `%` und `@` verwendet 🔎
---
## Datenbank-Passwort mit Sonderzeichen (24.08.2026)
Die Verbindungszeichenkette wurde mit `fmt.Sprintf` zusammengeklebt. Beim
ersten Aufbau einer frischen Umgebung fiel das sofort auf:
```
FTL migrations failed
error="parse \"postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/aether\":
invalid URL escape \"%Co\""
```
`@` und `:` beenden den Anmeldeteil einer URL, `%` leitet eine Maskierung ein.
Die `key=value`-Form hätte dasselbe Problem mit einem **Leerzeichen** gehabt,
nur stiller: Der Rest dahinter wäre als eigener Schlüssel gelesen worden.
Jetzt baut `net/url` die Adresse, und die Umrechnung von Hand in `pkg/db` ist
ersatzlos weg.
- [ ] `DB_PASSWORD` mit `%`, `@`, `:`, `/` und Leerzeichen → Server startet,
Migrationen laufen, Anmeldung geht 🔎 (gemessen mit `8UoNj%CoWJ7ze@qy`
in einer eigenen Umgebung: `migrations up to date`, `/health` 200,
Login 200)
- [ ] `go test ./pkg/config/...` deckt die Fälle ab, ohne dass eine Datenbank
laufen muss 🔎
- [ ] IPv6 als `DB_HOST` (`::1`) wird geklammert, der Port bleibt erhalten 🔎
> **Keine Regel „welche Zeichen erlaubt sind".** Wer die erst nachschlagen
> muss, wählt am Ende ein schwächeres Passwort.
### Dazu: `DB_HOST` fehlte im schlanken Beispiel
Die `.env.example` im Wurzelverzeichnis führte `DB_NAME`, `DB_USER` und
`DB_PASSWORD`, aber nicht `DB_HOST`. Die Voreinstellung ist `localhost` – und
aus dem Container heraus ist das er selbst. Im Log stand deshalb
`@localhost:5432`, obwohl Postgres als `postgres` im Compose-Netz lief.
- [ ] Frische Umgebung nur aus `docker-compose.example.yml` + `.env.example`
startet ohne Nacharbeit
- [ ] `APP_ENV`, `DB_HOST` und `DB_PORT` stehen darin
---
## Handy: eine Kopfzeile statt zwei (24.08.2026)
Die eigene Leiste über der Kopfzeile („☰ Aether") ist weg. Sie enthielt nur
das Menü-Symbol und den Namen der Cloud – und der stand ohnehin in der
Seitenleiste, die sie öffnete. Gemessen kostete sie 57 der 915 Pixel.
- [ ] Handy: **eine** Kopfzeile – ☰, Ort („Home"/Ordner), rechts das Konto 🔎
(gemessen 120 px statt 205 px)
- [ ] Das Konto steht in der **Titelzeile**, nicht in der Reihe mit
„+ Folder" und „Upload" 🔎
- [ ] ☰ öffnet die Seitenleiste 🔎
- [ ] Desktop **unverändert**: Kopfzeile 56 px, kein ☰, Konto ganz rechts
hinter der Trennlinie 🔎
### Sichere Bereiche – der Abstand wirkte nirgends
`.aether-safe-top` und Geschwister waren Klassen; alle drei Stellen setzen
ihren Abstand aber als **Inline-Stil**, und der schlägt jede Klasse. Der
Abstand wurde berechnet und sofort überschrieben. Jetzt Variablen
(`--safe-top`, `--safe-bottom`, `--safe-left`).
Nur in der **installierten** App zu sehen (im Browser ist der Wert 0):
- [ ] Kopfzeile klebt nicht an der Statusleiste / Uhr
- [ ] Speicherbalken der Seitenleiste liegt nicht hinter dem Wischbalken
- [ ] Querformat mit Aussparung: Seitenleiste beginnt neben der Kamera
---
## Handy und PWA (23.08.2026)
Die Kopfleiste war auf schmalen Schirmen **kaputt**: eine Zeile ohne Umbruch,
die ihren Inhalt gequetscht statt umgebrochen hat. Gemessen bei 390 px:
Brotkrume auf einen Strich zusammengeschoben, Suchfeld über der
Sortierauswahl, Ansichts-Umschalter verschwunden, Nutzerbereich abgeschnitten.
- [ ] Auf dem Handy sind **alle** Bedienelemente sichtbar und erreichbar:
Brotkrume, Suche, Sortierung, Umschalter, „+ Folder", Upload, Nutzer 🔎
- [ ] Nichts läuft seitlich aus dem Bild (kein waagerechtes Scrollen) 🔎
- [ ] Auf dem Desktop bleibt die Leiste **einzeilig, 56 px** wie vorher 🔎
- [ ] Die Trefferliste der Suche passt auf den Schirm, ohne über den Rand zu
gehen
- [ ] Etikettendialog und „Edit tag" sind auf dem Handy vollständig bedienbar
- [ ] Als PWA installiert dasselbe prüfen – dort fehlt die Adressleiste, die
Leiste sitzt also weiter oben
- [ ] Drehen ins Querformat und zurück
**Installierte App**
- [ ] Der Inhalt reicht bis an die Ränder, steht aber **nicht** hinter Uhr
oder Wischbalken 🔎
- [ ] Der Speicherbalken unten links ist erreichbar, nicht hinter dem
Wischbalken 🔎
- [ ] Die Statusleiste hat die eingestellte Primärfarbe (nicht mehr das
voreingestellte Indigo) 🔎
- [ ] Das Symbol auf dem Startbildschirm ist das hochgeladene 🔎
- [ ] Die Seite lässt sich am Anschlag **nicht** gummiartig überziehen
- [ ] **Offline:** Die App startet und zeigt die Anmeldeseite – mehr geht
nicht, weil jede Datei vom Server kommt. Kein Fehlerbildschirm 🔎
## Etiketten anpassen (23.08.2026)
- [ ] Beim Anlegen erscheint eine Farbwahl, sobald man tippt 🔎
- [ ] Zwei nacheinander angelegte Etiketten bekommen **verschiedene** Farben
- [ ] **Rechtsklick** auf ein Etikett in der Seitenleiste öffnet ein kleines
Menü mit „Edit…" und „Delete" – **nicht** sofort einen Dialog 🔎
- [ ] „Edit…" öffnet Umbenennen und Farbwahl (ohne Löschknopf daneben) 🔎
- [ ] „Delete" fragt zurück und nennt die Zahl der betroffenen Dateien 🔎
- [ ] Umbenennen wirkt sofort – auch an den Aufklebern der Kacheln, ohne
Neuladen 🔎
- [ ] Farbe ändern wirkt sofort an Punkt und Aufklebern
- [ ] Die Rückfrage sagt ausdrücklich, dass die **Dateien bleiben** 🔎
- [ ] Nach dem Löschen verschwinden die Aufkleber an den Kacheln sofort 🔎
- [ ] Der Zählstand in der Seitenleiste geht direkt hoch, wenn man etikettiert 🔎
## OIDC scharf schalten (23.08.2026)
Der Durchgang vor dem Produktivgang. **Reihenfolge zählt hier**: Zuerst
`OIDC_LINK_BY_USERNAME=true` setzen, *dann* zum ersten Mal über PocketID
anmelden. Andersherum entsteht ein zweites Konto, und die Dateien liegen danach
auf zwei Konten verteilt.
- [ ] `.env`: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URL` gesetzt; Redirect-URL zeigt auf
`https://<domain>/api/v1/auth/oidc/callback` und ist bei PocketID
**identisch** hinterlegt
- [ ] `OIDC_LINK_BY_USERNAME=true` **vor** dem ersten Login
- [ ] Der Benutzername bei PocketID (`preferred_username`) ist derselbe wie in
Aether – sonst greift die Verknüpfung nicht
- [ ] Anmeldeseite zeigt den SSO-Knopf 🔎
- [ ] Erster OIDC-Login landet auf dem **bestehenden** Konto: gleiche Dateien,
Adminrechte noch da 🔎
- [ ] Danach steht in Settings → Account der Hinweis statt des
Passwortformulars 🔎
- [ ] Zweiter Login findet dasselbe Konto (kein `name2` in der Nutzerliste) 🔎
- [ ] **WebDAV funktioniert weiter** – App-Passwörter sind von OIDC unberührt
- [ ] Das Kontopasswort funktioniert für den Web-Login **nicht** mehr
(Konto hat keines mehr)
## Passwort bei OIDC-Konten (23.08.2026)
Wer sich über PocketID anmeldet, hat in Aether **kein** Passwort (`password`
steht auf `''`). Das Formular konnte also nur fehlschlagen – und zwar mit
„current password incorrect", was nach einem Tippfehler aussieht statt nach dem
wahren Grund.
- [ ] Als OIDC-Konto zeigt Settings → Account statt des Formulars den Hinweis
„You sign in through your identity provider…" 🔎
- [ ] Als Konto **mit** Passwort ist das Formular unverändert da 🔎
- [ ] `POST /auth/change-password` als OIDC-Konto → `400` mit klarer Meldung,
nicht `401` „current password incorrect" 🔎
- [ ] Ein OIDC-Konto kann sich **nicht** über das Passwortfeld anmelden
(`''` ist kein gültiger Argon2-Hash – galt schon vorher, bitte
trotzdem einmal probieren)
## Nutzerbereich oben rechts (23.08.2026)
Der Block saß bis dahin unten links in der Seitenleiste – mit dem Abmelden-
Symbol direkt neben dem Knopf, der zu den Einstellungen führt. Abmelden ist die
einzige Handlung, die man nicht zurücknehmen kann, und war die am leichtesten
erreichbare.
- [ ] Oben rechts steht Avatar + **Anzeigename**, in **jeder** Ansicht an
derselben Stelle 🔎
- [ ] Nur der Anzeigename, nicht zusätzlich die Kennung darunter 🔎
- [ ] Klick öffnet das Menü: Name, Kennung (nur wenn sie sich unterscheidet),
„Administrator", dann Settings / Admin panel / Sign out
- [ ] „Sign out" steht zuletzt, rot und durch eine Trennlinie abgesetzt
- [ ] Klick daneben schließt, Esc schließt
- [ ] Unten links steht **nur noch** der Speicherbalken – kein Nutzer, kein
Abmelden 🔎
- [ ] „Admin panel" erscheint nur bei Administratoren
- [ ] Die Kopfleiste bleibt in allen Ansichten gleich hoch (der Nutzerbereich
wächst sie nicht auf)
## Dateien auf dem Wirt / SFTP (23.08.2026)
Die Daten liegen seit dem 23.08.2026 in einem gewöhnlichen Verzeichnis
(`DATA_PATH`), nicht mehr in einem Docker-Volume.
- [ ] `ls -la $DATA_PATH/users/<name>/home/` zeigt echte Ordner und Namen, und
die Dateien gehören **dir** – kein `sudo` nötig 🔎
- [ ] Eine hochgeladene Datei taucht dort sofort mit ihrem Namen auf 🔎
- [ ] Gelöschte Datei liegt unter `.trash/`, wiederhergestellte wieder in
`home/`
- [ ] Per SFTP von einem anderen Rechner: `get -r …/home` holt alles
- [ ] Upload, Vorschau, WebDAV und OCR funktionieren wie vorher (der Container
läuft jetzt unter deiner Kennung) 🔎
- [ ] Beim Umzug: Prüfsummen von Volume und Zielverzeichnis stimmen überein,
**bevor** umgeschaltet wird 🔎
## 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** 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 🔎
- [ ] Doppelklick öffnet den Editor statt der Vorschau 🔎
- [ ] Der Inhalt ist da – nicht nur die Werkzeugleiste 🔎
- [ ] Ohne Office-Server: Der Menüpunkt erscheint gar nicht erst 🔎
- [ ] Bei einem PDF erscheint er ebenfalls nicht (eigener Betrachter)
### Speichern
- [ ] Text tippen, Strg+S → Fassung und Größe der Datei ändern sich 🔎
(gemessen: v1 → v3, 81 → 94 Zeichen im Dokument)
- [ ] Der getippte Text steht wirklich in der heruntergeladenen Datei 🔎
- [ ] Bei eingeschalteter Versionierung entsteht ein Eintrag im Verlauf
- [ ] Kontingent voll → Office meldet einen Fehler, die alte Fassung bleibt
- [ ] Schließen und neu öffnen zeigt den gespeicherten Stand
### Rechte
- [ ] Datei mit Leserecht geliehen → „read only" in der Kopfzeile, kein Speichern
- [ ] Freigabe während der Bearbeitung zurückziehen → das nächste Speichern
scheitert (die Rechte werden bei **jedem** WOPI-Aufruf neu aufgelöst)
- [ ] Token einer Datei an einer anderen Datei-ID → `404`
- [ ] Abgelaufenes Token → `401`
- [ ] `/wopi/files/<id>` ohne Token → `401` 🔎
### Aufbau
- [ ] Schnipsel aus dem README anhängen, `docker compose up -d` → der
Container entsteht (die Schnipsel haben **kein** Profil, siehe unten) 🔎
- [ ] `OFFICE_PROVIDER` leer → kein Menüpunkt, auch wenn der Container läuft
- [ ] Falscher Port in `OFFICE_URL` (9980 statt 80 bei Euro-Office) → die
Ermittlung findet nichts, der Menüpunkt bleibt weg
- [ ] `OFFICE_ALLOWED_HOSTS` greift: eine fremde Adresse in `OFFICE_URL` wird
abgelehnt statt angefragt
- [ ] Sicherheitsrichtlinie nennt den Office-Ursprung unter `frame-src` 🔎
- [ ] Ohne Office-Server bleibt die Richtlinie so eng wie zuvor
---
## 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**
je Dokument. Gesetzt im selben Dialog (Rechtsklick → „Organise…").
### Setzen
- [ ] Namen tippen und speichern → Absender erscheint an der Kachel, in der
Seitenleiste unter FROM, Zähler auf 1 🔎
- [ ] Zweites Dokument, Vorschlagsliste zeigt den vorhandenen Namen 🔎
- [ ] Gleicher Name in anderer Schreibweise („stadtwerke münchen") landet beim
vorhandenen Absender und legt **keinen** Zwilling an 🔎
- [ ] Feld leeren und speichern → Absender weg, Etiketten und Belegdatum
bleiben stehen 🔎
- [ ] Nur Leseberechtigung an einer geliehenen Datei → kein Speichern möglich
### Liste und Ansicht
- [ ] Klick auf einen Absender in der Seitenleiste → „alles von X",
absteigend nach Belegdatum sortiert 🔎
- [ ] Adresse `/from/<id>` überlebt das Neuladen (Lesezeichen) 🔎
- [ ] „Download all as ZIP" in dieser Ansicht
- [ ] Zurück-Taste des Browsers führt aus der Absenderansicht heraus
### Umbenennen und Löschen
- [ ] Rechtsklick → „Rename…" → alle bisherigen Dokumente behalten ihn 🔎
- [ ] Umbenennen auf einen schon vergebenen Namen → klare Meldung 🔎
- [ ] Rechtsklick → „Delete" fragt nach und sagt dabei, dass die **Dokumente
bleiben** 🔎
- [ ] Nach dem Löschen: Dokumente da, Belegdatum da, nur die Zuordnung weg 🔎
### Fremdzugriff
- [ ] Erratene Absender-ID an einer eigenen Datei → `400` 🔎
- [ ] Fremden Absender umbenennen oder löschen → `404` (nicht `403`) 🔎
### Gruppenordner
- [ ] Ein Absender in einem Gruppenordner gehört der Gruppe, nicht dem
Anlegenden – alle Mitglieder sehen ihn
- [ ] Die Vorschlagsliste zeigt in einem Gruppenordner nur Gruppen-Absender
---
## PDF-Betrachter (23.08.2026)
Eigener Betrachter statt des eingebauten im `<iframe>`. Gründe: Chrome auf
Android zeigte im `<iframe>` gar kein PDF an, und einem fremden Betrachter
lässt sich kein Suchwort vorgeben.
### Werkzeugleiste
- [ ] Suchfeld sitzt **mittig** in der Leiste (gemessen: 0 Pixel Abweichung) 🔎
- [ ] Beim ersten Treffer rutscht das Feld **nicht** zur Seite – der Platz für
die Sprungknöpfe steht immer bereit 🔎
- [ ] Am Telefon zwei Zeilen: Steuerung oben, Suche darunter, beide
Sprungpfeile sichtbar 🔎
- [ ] Zoomprozent stimmt beim Öffnen (nicht 10 %) – dreimal hintereinander
geprüft, war vorher vom Zufall abhängig 🔎
### Grundlegend
- [ ] PDF per Doppelklick öffnen → Seiten erscheinen, Seitenzahl stimmt 🔎
- [ ] Blättern über die Pfeile der Werkzeugleiste 🔎
- [ ] Zoom −/+ und der Umschalter „ganze Seite ↔ 100 %" 🔎
- [ ] **Nach dem Zoomen bleibt man auf derselben Seite** – nicht am Anfang
eines fremden Blattes 🔎 (war ein Fehler: sprang zurück zur Fundstelle)
- [ ] Text im PDF lässt sich markieren und kopieren 🔎
- [ ] Drucken über den Browser (Strg+P) – der Betrachter bringt keinen
eigenen Druckknopf mit
### Suche im Dokument
- [ ] Aus der Volltextsuche geöffnet → Suchwort steht im Feld, der Betrachter
steht auf der Seite mit dem Treffer, das Wort ist orange 🔎
- [ ] Weitere Treffer blass markiert, der aktuelle kräftiger und mit Kante 🔎
- [ ] Das markierte Wort bleibt **lesbar** – der Streifen liegt darüber, nicht
darauf 🔎
- [ ] Der Streifen sitzt **auf der Zeile**: nicht schief, oben nicht
angeschnitten 🔎 (kein Treffer ragt aus der Seite heraus)
- [ ] Markiert wird das **ganze Wort**, nicht der Schnipsel: „la" färbt
`Display`, `last`, `labex` – nicht zwei Buchstaben mittendrin 🔎
(gemessen: unser Kasten und die Auswahl des Browsers liegen auf
demselben Pixel, 357,08 / 10,38 – der Versatz der Ersatzschrift ist
nicht wegzurechnen, wohl aber zu umgehen)
- [ ] Sehr lange „Wörter" (Adresse, Prüfsumme) werden **nicht** ganz
eingefärbt – dort bleibt es beim Fundstück
- [ ] **Suchfeld leeren → alle Markierungen verschwinden** 🔎 (war ein Fehler:
die Färbung blieb zu einem Wort stehen, das im Feld längst nicht mehr
stand)
- [ ] Während Treffer markiert sind, lässt sich Text weiter mit der Maus
auswählen – die Markierungsebene fängt keine Klicks ab
- [ ] „Nächster Treffer" / „Voriger Treffer" springt der Reihe nach 🔎
- [ ] **Beim Tippen ruckt nichts.** Der Rand über der ersten Seite bleibt
stehen, das Blatt klebt nicht am Fensterrand 🔎 (gemessen: `scrollTop`
bleibt 0, Rand bleibt 16 px, **kein einziges** Roll-Ereignis)
- [ ] Ist der Treffer schon zu sehen, wird gar nicht gerollt; erst ein
Treffer außerhalb holt die Anzeige nach 🔎 (gemessen: 6 × Enter →
Treffer 7 von 54, genau ein Roll-Ereignis)
- [ ] Enter im Suchfeld = nächster Treffer, Umschalt+Enter = voriger
- [ ] **Scan** (nur Bild, per OCR erkannt): Suche findet die *Seite*, springt
hin, markiert aber nichts – Anzeige „1 / 5 · page" 🔎
- [ ] Ein Treffer nur im Dateinamen (nicht im Inhalt) füllt das Suchfeld
**nicht** vor – sonst meldete der Betrachter „No hits" ohne Grund
- [ ] Fremde Datei: `GET /api/v1/files/<id>/text` → `404`
### Test-PDF für die Suche
`~/Dokumente/aether-suchtest.pdf` – zehn Seiten, jede eine eigene Falle.
Suchwort ist überall **aether**; ein Durchlauf deckt alle Fälle ab.
| Seite | Fall | Erwartung |
|---|---|---|
| 1 | Schriftgrößen 48 bis 4 pt | Kasten sitzt bei **jeder** Größe auf dem Wort |
| 2 | Times / Helvetica / Courier, fett, kursiv | dito – hier fällt der Versatz der Ersatzschrift am ehesten auf |
| 3 | Wortgrenzen: Bindestrich, Klammern, Umlaute, Ziffern, Unterstriche | ganzes Wort gefärbt; die 68-stellige Prüfsumme **nicht** |
| 4 | Zwei Spalten, 8 pt, enge Zeilen | kein Überlappen zwischen den Zeilen |
| 5 | Wort am Zeilenende getrennt (`Ae-` / `ther`) | wird **nicht** gefunden – bekannte Grenze |
| 6 | gedreht: 90°, 45°, 180° | Kasten dreht mit |
| 7 | Tabelle | Treffer in Zellen |
| 8 | 66 Treffer auf einer Seite | Zähler stimmt, Weiterspringen läuft durch |
| 9 | Querformat | Zoom stellt sich um |
| 10 | **Scan** (nur Bild, leicht schief) | erst nach OCR, dann seitengenau: „· page", ohne Färbung |
Gemessen beim Bauen: 390 Treffer über 10 Seiten, keine Fehler in der Konsole.
### Randfälle
- [ ] 60-seitiges PDF: höchstens 6 Seiten gleichzeitig gezeichnet, Suche über
alle 60 findet die richtige Seite 🔎
- [ ] Passwortgeschütztes PDF → „This PDF is password protected." 🔎
- [ ] Kaputte Datei mit Endung .pdf → „Could not open this PDF." 🔎
- [ ] Andere Vorschauen unverändert (Bild, Text, Ton, Video) 🔎
### Am Telefon
- [ ] PDF wird **angezeigt** (mit dem `<iframe>` kam dort nur ein
Download-Hinweis) 🔎
- [ ] Kein waagerechter Überlauf, Seite füllt die Breite 🔎
- [ ] Die Pfeile zum Blättern zwischen Dateien sind bei einem PDF ausgeblendet
– sie lägen mitten auf dem Text 🔎
- [ ] Zwei Finger zum Zoomen (das macht der Browser selbst)
### Tastatur
- [ ] Escape im Suchfeld des Betrachters leert das Feld, schließt **nicht**
die Vorschau
- [ ] Pfeiltasten blättern nicht zur nächsten Datei, während man im Suchfeld
schreibt 🔎
### Erkannter Text mit Seitenmarken
- [ ] Nach dem Aktualisieren stehen Bestandsdateien auf `extract_version = 1`
und tragen Seitenmarken 🔎
- [ ] Dateien im Papierkorb werden **nicht** neu erkannt 🔎
---
## Eigenschaften einer Datei (24.08.2026)
Rechtsklick → **Properties…**. Das, was Dateimanager unter Windows und Linux
anbieten. Anlass: Bei einem Bild stand nirgends, wie groß es in **Pixeln** ist.
- [ ] Bild → Zeile „Dimensions" mit Breite × Höhe und Megapixeln 🔎
(gemessen: 2000 × 1851 px, 3.7 MP)
- [ ] Nicht-Bild (Archiv, PDF, Office) → **keine** Zeile „Dimensions" 🔎
- [ ] Größe steht zweimal: gerundet und auf das Byte genau 🔎
- [ ] Belegdatum, Absender und Etiketten erscheinen nur, wenn gesetzt 🔎
- [ ] „Version 2 (history kept)" bzw. „(history off)" passt zum Schalter
„Keep versions" im selben Menü 🔎
- [ ] Klick auf die Prüfsumme kopiert sie („copied" erscheint kurz)
- [ ] Ohne HTTPS (kein sicherer Kontext) bleibt der Wert stehen statt zu
kopieren – keine Fehlermeldung
- [ ] Die Maße kommen aus dem Browser, nicht vom Server: Ein Bildformat, das
der Browser nicht kennt, lässt die Zeile bei „measuring…" – kein Fehler
---
## Kachelansicht: Absender statt Größe (24.08.2026)
- [ ] Kachel **mit** Absender: links Symbol + Name, rechts das Etikett,
**keine** Dateigröße 🔎
- [ ] Kachel **ohne** Absender: links die Größe wie bisher 🔎
- [ ] Alle Kacheln einer Reihe sind gleich hoch, auch mit langem
Absendernamen (er wird abgeschnitten, nicht umgebrochen) 🔎
- [ ] Die Größe steht weiterhin in der Listenansicht und unter „Properties…"
---
## Wurzelort heißt „Home"
- [ ] Brotkrume ganz links zeigt ein Haus-Symbol und „Home", nicht „My Files"
- [ ] Seitenleiste ebenso
- [ ] Suchtreffer in der Wurzel stehen als „in Home"
## Sortierung
- [ ] Auswahlfeld in der Werkzeugleiste: Name, Größe, Datum – je auf- und
absteigend
- [ ] Ordner stehen immer vor Dateien und folgen nur der Namensrichtung
- [ ] Sortierung und Ansicht (Kacheln/Liste) überleben das Neuladen
---
## Downloads & Adressen (PWA-Fallen)
- [ ] Rechtsklick → „Download" lädt die Datei – **ohne** dass ein leerer Tab aufgeht
- [ ] Download aus dem Versionsverlauf funktioniert
- [ ] Download aus „Shared with me" funktioniert
- [ ] `/admin` oder `/profile` direkt in der Adresszeile eingeben → Anwendung
lädt, kein 404 🔎
- [ ] Freigabelink `/s/<token>` in einem **frischen** Browser öffnen (privates
Fenster, kein Service Worker) → Seite lädt 🔎
- [ ] Nach dem Update einmal hart neu laden (`Strg+Shift+R`), damit der alte
Service Worker ersetzt wird
---
## Profilbild vom Identitätsanbieter (24.08.2026)
Gemessen an der laufenden Instanz auf `127.0.0.1:8090`:
| Fall | Erwartet | Gemessen |
|---|---|---|
| Bild hinterlegt, `/me` | `avatar_ver` > 0 | 1787571663 |
| `GET /users/<id>/avatar?v=…` | `200`, `image/png`, 256×256 | ✅, `private, max-age=604800, immutable` |
| ohne Anmeldung | `401` | ✅ |
| Konto ohne Bild | `404` | ✅ |
| erfundene Konto-ID | `404`, nicht `403` | ✅ |
| Blob fehlt, Zeile steht | Buchstabenkreis, kein Loch | ✅ (Screenshot) |
| GC-Lauf | Bild bleibt, 0 Waisen | ✅ (122 gescannt) |
| verwaiste Datei unter `avatars/` | wird gefunden, Schonfrist greift | ✅ (123 gescannt, 1 jung) |
Geprüft mit Go-Tests, nicht am laufenden Anbieter:
- Zuschnitt: 800×200, 200×800, 512×512 und 16×16 ergeben alle 256×256; bei
einem breiten Bild bleibt die Mitte stehen (linke Kante rot, rechte blau).
- JPEG wird angenommen, Unsinn (`""`, `<svg/>`, Text) abgelehnt.
- **SSRF:** `169.254.169.254`, `localhost:8080`, `postgres:5432`,
`id.example.com.angreifer.net`, `id.example.com:9200`, `file://`, `gopher://`
werden alle abgewiesen; eine Weiterleitung auf einen fremden Rechner wird
nicht befolgt.
- Ein Anbieter, der erst `401` sagt, bekommt genau einen zweiten Versuch mit
Token – nicht mehr.
- Eine 8-MB-Antwort wird bei 4 MB abgeschnitten.
**Noch offen (braucht deinen echten PocketID):**
- [ ] Meldet PocketID `picture` im ID-Token oder erst über UserInfo?
- [ ] Kommt das Bild ohne Token heraus, oder greift der zweite Versuch?
- [ ] Bild in PocketID austauschen → steht nach dem nächsten Anmelden hier
- [ ] Bild in PocketID entfernen → das alte bleibt hier stehen (so gebaut:
gelöscht wird nur beim Austausch, nicht auf Verdacht)
## Kopfleiste auf dem Handy (24.08.2026)
Gemessen bei 412×915, angemeldet:
| | vorher | nachher |
|---|---|---|
| `padding-top` | **0px** | 14px |
| `padding-bottom` | **0px** | 10px |
| Oberkante Titel | 8px | 22px |
Ursache war nicht der gewählte Wert, sondern dass die Regel nie griff: Der
Inline-Stil (`padding: '0 14px'`, `paddingTop: 'var(--safe-top)'`) schlug die
Klassenregel der schmalen Ansicht. Alle Abstände stehen jetzt im CSS-Block.
- [ ] Auf dem Handy gegenprüfen, installiert **und** im Browser-Tab – nur
installiert ist `--safe-top` ungleich 0.
## Mitgeliefertes App-Symbol (24.08.2026)
Die drei PNG lagen als **Graustufen** vor: Der indigo Verlauf war zu Schwarz
eingeebnet, die Unterkante der Wolke lief schief aus. Neu gezeichnet (drei
Kreise plus Rechteck dazwischen, keine Freihandkurve) und in RGBA gerendert.
- [ ] Nach `docker compose pull` erscheint das neue Symbol im Tab
- [ ] Auf dem Startbildschirm frühestens nach Neu-Installieren (Chrome hält
eine eigene Kopie)
## Anleitung „Connect as a drive" (24.08.2026)
Neuer Info-Knopf unter Einstellungen → Access, neben den App-Passwörtern.
Die Adressen entstehen aus `window.location`, nicht aus `PUBLIC_URL` – was im
Browser trägt, trägt auch im Dateimanager.
Geprüft auf 1280×900 und 412×915:
- Alle drei Reiter (Windows, Linux · Konsole, Linux · Dateimanager) öffnen.
- Über HTTP erscheint der rote Hinweis und der Dateimanager-Reiter bietet
`dav://` an; über HTTPS `davs://` und kein Hinweis.
- Die Kopierknöpfe stehen an WebDAV-Adresse, Benutzername, Cloud-Adresse und
an jedem Befehlsblock.
- [ ] Auf der produktiven Instanz gegenprüfen, dass dort `https://…/dav/`
steht und der HTTPS-Hinweis **nicht** erscheint
- [ ] Einen der Befehle wirklich ausführen (davfs2-Einhängen)
- [ ] Windows-Weg auf einem echten Windows durchspielen – die 50-MB-Grenze und
der Registry-Pfad stammen aus der Dokumentation, nicht aus einem Versuch
## Kachelraster auf schmalen Geräten (24.08.2026)
Ein Galaxy Z Flip 7 (360px aufgeklappt) zeigte **eine** Spalte statt zwei:
Bei 160px Mindestbreite und 20px Innenrand brauchen zwei Spalten 332px, da
waren aber nur 320px. Schmal jetzt 132px Mindestbreite, 10px Abstand und
12px Innenrand.
| Breite | Spalten | Kachel | Innenrand | Querlauf |
|---|---|---|---|---|
| 316 (Deckelschirm) | 2 | 141px | 12px | nein |
| 360 (Flip 7) | 2 | 163px | 12px | nein |
| 412 (übliches Handy) | 2 | 189px | 12px | nein |
| 768 | 3 | 161px | 20px | nein |
| 1280 | 6 | 160px | 20px | nein |
Raster und Innenrand stehen jetzt als Klassen in `index.css`; inline gesetzt
hätte keine Media-Query sie erreicht.
- [ ] Auf dem Flip 7 selbst gegenprüfen, auf beiden Schirmen
## Bild ziehen lud es erneut hoch (24.08.2026)
Zieht man das angezeigte Bild in der Vorschau ein Stück zur Seite, legt Chrome
es als **echte Datei** in den Ziehvorgang. `dataTransfer.types` enthält dann
`Files` – nicht zu unterscheiden von einer Datei aus dem Dateimanager. Die
Ablagefläche lud das Bild daraufhin ein zweites Mal hoch.
Nachgestellt und gemessen:
| Fall | vorher | nachher |
|---|---|---|
| Vorschaubild ziehen und ablegen | `draggable=true`, **1 Upload** | `draggable=false`, **0 Uploads** |
| Datei von außen ablegen | 1 Upload, Overlay sichtbar | unverändert 1 Upload, Overlay sichtbar |
Zwei Riegel, weil einer allein zu leicht wieder aufgeht:
1. `draggable={false}` an allen Bildern, die kein eigenes Ziehen brauchen –
Kachel-Vorschauen, Vorschaufenster, Suchtreffer, Papierkorb, Etiketten,
Logo, Profilbild. Die **Kachel** bleibt ziehbar und verschiebt die Datei.
2. `hooks/useInternalDrag.ts`: Was in dieser Seite losgezogen ist, wird nie
als Upload behandelt. `dragstart` feuert nur bei eigener Quelle; von außen
gibt es keins.
- [ ] Auf dem Handy gegenprüfen: Ein Bild lässt sich mit dem Finger nicht mehr
aus der Vorschau ziehen (dort war das Wischen ohnehin gemeint)
## Sammel-Löschen und Ziehen auf den Papierkorb (24.08.2026)
Zwei Wege zum selben Ziel: der Knopf in der Auswahlleiste (mit Rückfrage) und
das Ziehen auf den Papierkorb in der Seitenleiste (ohne).
Gemessen an einem Testordner mit fünf Dateien:
| Handlung | Erwartet | Gemessen |
|---|---|---|
| drei markieren, eine davon auf den Papierkorb ziehen | alle drei | `aether-file-ids` trägt 3 IDs, 3 DELETE, Kacheln 5 → 2 |
| über dem Papierkorb schweben | rot umrandet | `rgb(239,68,68)` + Rahmen; danach wieder grau |
| zwei markieren, „Move to trash" | Rückfrage, dann weg | Dialog „Move 2 files…", 2 DELETE, Kacheln 0, Leiste weg |
Der Sammel-Aufruf läuft mit vier gleichzeitigen Anfragen. Fehlgeschlagene
Dateien bleiben markiert, statt die Auswahl zu verlieren.
- [ ] Am Finger gegenprüfen: Ziehen auf den Papierkorb setzt eine Maus voraus,
auf dem Handy bleibt der Knopf der Weg
- [ ] Mit vielen Dateien (50+) auf der produktiven Instanz gegenprüfen
## PDF: Strg + Mausrad, Zoom nach rechts (24.08.2026)
| Prüfung | Gemessen |
|---|---|
| Zoomgruppe breit | rechts außen, 1138–1262 px bei 1280 px Fenster |
| Suchfeld bleibt mittig | Mitte 640, Fenstermitte 640 |
| Strg + Rad hoch | 100 % → 125 % |
| Strg + Rad runter (2×) | → 75 % |
| Rad **ohne** Strg | unverändert 75 %, blättert nur |
| `preventDefault` gerufen | ja – sonst zoomte der Browser die ganze Seite |
| Punkt unter dem Zeiger | bleibt: Seite 2 vor und nach zwei Zoomstufen, Bildlauf 1400 → 2250 |
| Schmal (412 px) | Zoom in der **ersten** Zeile neben der Seitenzahl, zweizeilig wie vorher |
Beim Rad wird der Punkt unter dem Zeiger festgehalten, nicht die Seite: Man
zeigt auf eine Stelle und will, dass sie dort bleibt. Die Knöpfe halten
weiterhin die Seite – dort gibt es keinen Zeiger, auf den man sich beziehen
könnte.
- [ ] Am Trackpad gegenprüfen (Zwei-Finger-Zoom meldet sich als Strg + Rad)
## Hochkant-Fotos lagen im Vorschaubild quer (24.08.2026)
Kameras speichern die Bildpunkte fast immer quer und legen die Drehung als
EXIF-Notiz daneben. Browser befolgen sie von selbst – Go nicht. Gemessen an
einem Testbild mit 400×200 Bildpunkten und Drehung 6:
| | vorher | nachher |
|---|---|---|
| große Ansicht (Browser) | 200×400 hochkant ✅ | unverändert |
| Vorschaubild (Server) | **256×128 quer** ❌ | 128×256 hochkant ✅ |
Alle acht Fälle, beide Byte-Reihenfolgen (II und MM):
- Drehung 1 und 3 bleiben quer, 6 und 8 werden hochkant – Seitenverhältnis
allein genügt aber nicht als Beweis, deshalb ein gelber Balken als Marke:
bei 6 landet er **oben**, bei 8 **unten**. Beide Bilder angesehen.
- Leere Datei, Text statt JPEG, abgeschnittener EXIF-Block, Drehung 0 und 99
ergeben alle 1 („nicht drehen") statt eines Fehlers.
- Bei Drehung 1 entsteht keine Kopie des Bildes.
Bestehende Vorschaubilder ändern sich davon **nicht** – sie liegen fertig im
Speicher und werden nicht neu erzeugt. Ein Foto, das heute quer liegt, liegt
auch morgen quer, bis es erneut hochgeladen wird.
Ein Knopf zum Neuerzeugen war kurz da und wurde wieder entfernt (130 Zeilen
für einen einmaligen Bedarf).
- [ ] Auf der produktiven Instanz mit echten Handy-Fotos gegenprüfen –
**neu hochgeladene**, nicht die bestehenden
## Vorschaubilder: Zwischenspeicher, Sofortanzeige, Flackern (25.08.2026)
**Gleicher Dateiname = gleiche Datei.** Gemessen: dreimal dieselbe Datei
hochgeladen ergibt dreimal **dieselbe ID** mit den Fassungen 1, 2, 3. Die
Adresse des Vorschaubildes war damit unverändert, und es liegt einen Tag im
Browser (`max-age=86400`) – die Kachel zeigte bis zu 24 Stunden lang das alte
Bild. Die Adresse trägt jetzt `?v=<Fassung>`.
**Vorschaubild ohne Neuladen.** Der Server erzeugt es im Hintergrund, die
Antwort auf den Upload ist längst da. Die Kachel fragt jetzt bis zu fünfmal
nach (0,4 s bis 5 s Abstand), aber nur bei Bildern, die jünger als zwei
Minuten sind – sonst fragte jede dauerhaft fehlgeschlagene Datei bei jedem
Öffnen des Ordners erneut.
Gemessen: Bild über das Datei-Feld hochgeladen, Vorschaubild **608 ms** später
sichtbar, `performance` zählt genau **eine** Navigation – also kein Neuladen.
**Flackern am Kachelrand.** Beim Überfahren wurde die Kachel um einen Pixel
angehoben. Steht der Zeiger genau auf der Unterkante, rutscht sie unter ihm
weg → `mouseleave` → sie fällt zurück → `mouseenter`, im Kreis. Das Anheben
ist raus, der Rahmen in der Primärfarbe zeigt dasselbe an, ohne die Kachel zu
bewegen. Nachher gemessen: Zeiger 1,5 s auf der Unterkante gehalten, **0**
Ereignisse, `transform: none`.
- [ ] Das Flackern selbst konnte ich **nicht** nachstellen: Ein
programmgesteuerter Zeiger prüft die Überfahrung nicht bei jedem Bild neu,
eine echte Maus schon. Bitte mit der Maus gegenprüfen.
## Wischen holt die Seitenleiste (25.08.2026)
Vom linken Rand nach rechts öffnet, nach links schließt – nur an Geräten mit
grobem Zeiger (`pointer: coarse`), mit der Maus passiert nichts.
Gemessen bei 412×900 mit Touch:
| Geste | Erwartet | Gemessen |
|---|---|---|
| vom Rand (x=10) 150 px nach rechts | öffnet | ✅ |
| offen, 130 px nach links | schließt | ✅ |
| aus der Mitte (x=200) nach rechts | nichts | ✅ |
| vom Rand, nur 30 px | nichts | ✅ |
| vom Rand, 60 px rechts bei 100 px runter | nichts | ✅ |
| vom Rand, 150 px in 1,2 s | nichts | ✅ |
| in der Vollbild-Vorschau vom Rand | nichts | ✅ |
Die Grenzen: 44 px Randstreifen, mindestens 60 px Weg, doppelt so weit
waagerecht wie senkrecht, höchstens 800 ms.
- [ ] **Auf Android gegenprüfen.** Die Zurück-Geste des Systems belegt die
äußersten ~20 px; was dort beginnt, erreicht die Seite nie. Deshalb 44 px
statt 20 – ob das in der Praxis reicht, kann nur ein echtes Gerät sagen.
- [ ] Im Querformat gegenprüfen (Aussparung links)
## Automatisiert geprüft (22.08.2026)
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
brauchen keinen manuellen Durchgang mehr, stehen hier aber als Beleg:
- **Prüfsummen** über 8 Größen (0 B, 1 B, 511/512/513 B, 64 KB, 1 MB+1, 20 MB):
WebDAV rein → WebDAV raus → REST raus, jedes Mal identisch. Gespeicherte
Prüfsumme = tatsächliche Prüfsumme auf der Platte, 0 Abweichungen.
- **Pfad-Ausbruch:** 5 WebDAV-Varianten (`../`, URL-kodiert, absolut) und
5 REST-Dateinamen – nichts landet außerhalb des Nutzerbereichs.
- **Kontingent:** `413` über REST, `507` über WebDAV, keine Datei-Torsos, kein
Rest im Staging-Verzeichnis.
- **Soft-Delete:** gelöschte Datei liegt im Papierkorb, Download `404`,
nach Wiederherstellung Inhalt unverändert.
- **Sperre:** 10 Fehlversuche → `429` mit `Retry-After: 900`; ein Erfolg
setzt den Zähler zurück.
- **Passwort-Trennung:** Kontopasswort → WebDAV `401`, App-Passwort → Web-Login
`401`, App-Passwort eines fremden Kontos → `401`.
- **Isolation:** fremdes Konto bekommt auf 9 Endpunkte plus WebDAV durchgängig
`404`, `shared-with-me` leer.
- **Audit-Log:** jede sicherheitsrelevante Aktion erzeugt einen Eintrag;
zurückgesetztes Passwort, App-Passwort, Freigabe-Token und Share-Passwort
stehen **nicht** darin; Einträge überleben das Löschen des Kontos
(`actor_id` wird `NULL`, Name bleibt); Filter, Blättern und die Obergrenze
von 500 greifen; Nicht-Admin `403`, ohne Anmeldung `401`.
- **Blob-Bereinigung:** DB-Referenzen und Platte stimmen exakt überein
(123 zu 123); ein künstlich gealtertes Waisen-Blob wird entfernt, das
eingetragene Logo nicht.
## Regressionstest (sollte weiter funktionieren)
- [ ] Normaler Datei-Upload (Web) mit Fortschrittsanzeige
- [ ] Papierkorb: löschen → wiederherstellen
- [ ] Share-Link erstellen (mit Passwort + Ablauf) und öffentlich öffnen
- [ ] Favoriten in Sidebar: hinzufügen, umsortieren, navigieren
- [ ] Ordner per Drag&Drop in anderen Ordner verschieben
- [ ] „Verschieben nach…"-Dialog
- [ ] Ordner-Upload per Drag & Drop (ganzer Ordner inkl. Unterordner wird angelegt)