# Eigene Cloud – Projektplan & Claude-Code-Prompt > Self-hosted Cloud-Lösung als schlanke, schnelle Alternative zu Nextcloud. > Ziel: schnell, modular, gut wartbar, **nicht** plugin-abhängig, **nicht** überladen. --- ## 1. Tech-Stack (Entscheidung & Begründung) | Schicht | Wahl | Warum | |---|---|---| | Backend | **Go** (`net/http` + `chi` Router) | Einzelnes Binary, top File-Throughput, fertige Libs für WebDAV & OIDC, löst das „langsam wie Nextcloud (PHP)"-Problem | | Frontend | **React + TypeScript + Vite**, als **PWA** | Modern, riesiges Ökosystem, Claude Code ist darin sehr stark; PWA ersetzt Flutter für Mobile | | DB (Metadaten) | **PostgreSQL** | Robust, Volltextsuche eingebaut (`tsvector`), gute Migrations | | Blob-Storage | **Lokales FS** über Storage-Abstraktion | Später ohne Umbau auf S3 umstellbar | | Suche | Start: **Postgres FTS** → optional **Meilisearch** | Erst keine Extra-Dependency; bei Bedarf upgraden | | WebDAV | `golang.org/x/net/webdav` | Standard-Bibliothek, an Storage + Auth angebunden | | OIDC | `coreos/go-oidc` + `golang.org/x/oauth2` | Lokaler Login **und** OIDC parallel | | Office | **WOPI-Host** im Backend → Collabora **oder** OnlyOffice als Provider (per Config) | NICHT selbst bauen. Beide sprechen WOPI → ein Adapter, Provider per `.env` umschaltbar | | PDF-Anzeige | **PDF.js** (Frontend) | Standard | | OCR | **ocrmypdf** / Tesseract (Worker-Job) | Für gescannte PDFs durchsuchbar machen | | Reverse Proxy / TLS | **Eigener Reverse Proxy** (Traefik/nginx/Cloudflare …); App lauscht intern auf HTTP | TLS terminiert extern. Das Projekt liefert **keinen** Proxy mit (23.08.2026, Entscheidung von Marc – er baut ihn selbst) | | Jobs | In-Process Worker-Queue (z. B. `asynq` mit Redis, oder simpler Channel-Worker) | OCR, Thumbnails, Indexierung asynchron | | Deployment | **Docker Compose**, Multi-Stage-Build | Wie geplant | **Alternative (nur falls EINE Sprache gewünscht):** Full-TypeScript-Monorepo (Backend: Fastify oder Hono; Frontend: React). Vorteil: geteilte Typen. Nachteil: schwereres Runtime. → Empfehlung bleibt Go-Backend + React/TS-Frontend. ### Office-Entscheidung (Stand Juni 2026) **Beide unterstützen** – die Cloud ist ein **WOPI-Host**, dahinter wahlweise Collabora oder OnlyOffice (per `.env`). - **OnlyOffice** = beste Round-Trip-Treue für MS-Office-Dateien (nativ OOXML). Ideal, wenn man .docx/.xlsx aktiv zwischen Desktop-MS-Office und Web bearbeitet. Gegenargument (russische Code-Herkunft) wiegt bei einer privaten, nicht öffentlichen Instanz gering. - **Collabora** = LibreOffice-Engine (nativ ODF), europäisch, unabhängig. Solide, aber minimal höheres Risiko für Layout-Drift bei sehr komplexen OOXML-Dokumenten. - **Euro-Office** = junger OnlyOffice-Fork (Nextcloud/IONOS), laut Analyse Juni 2026 noch ~98 % OnlyOffice-/russischer Code. Beobachten, später via gleicher WOPI-Integration nutzbar. **Architektur:** internes `OfficeProvider`-Interface; primäre Implementierung ist der WOPI-Host (deckt Collabora + OnlyOffice ab). `.env`: `OFFICE_PROVIDER=collabora|onlyoffice` + Server-URL. Empfehlung: **einen** Server aktiv laufen lassen (beide sind ressourcenhungrig), Wechsel = Neustart. Optionaler nativer OnlyOffice-Adapter nur, falls WOPI-Treue mal nicht reicht. --- ## 2. Architektur-Prinzipien - **Modularer Monolith**, keine Microservices. Klare Domänen-Pakete: `auth`, `storage`, `files`, `sharing`, `documents` (PDF/Paperless), `admin`, `groups`, `webdav`. - **Storage-Abstraktion**: ein `Storage`-Interface (`Put/Get/Delete/Stat/List`), Implementierung `LocalFS` jetzt, `S3` später. - **API**: REST + JSON für die SPA. WebDAV als eigener, abgegrenzter Endpunkt. - **Auth**: lokale Accounts (Username + Argon2id-Hashing) **und** OIDC parallel; **Session-Cookies** (httpOnly, Secure, SameSite) — Entscheidung getroffen, keine JWTs. - **Keine E-Mail-Adressen.** Persönliche, nicht öffentliche Cloud → kein E-Mail-Feld, keine E-Mail-Verifizierung, kein Passwort-Reset per Mail. Passwort-Reset läuft ausschließlich über den Admin. - **Trennung Metadaten vs. Blobs**: Postgres kennt Datei-Metadaten/Rechte; Bytes liegen im Storage. - **Datenintegrität zuerst**: die App darf eine Datei niemals beschädigen oder unwiederbringlich verlieren. Garantien siehe unten. - **Plugin-Strategie**: ERST sauberer Core. Extension-Points (Server-Hooks via Event-Bus + Frontend-Slots) erst definieren, wenn der Core stabil ist. Kernfeatures bleiben erstklassig, nicht als Plugin. ### Datenintegritäts-Garantien (ab Phase 1, nicht verhandelbar) Wichtige Dateien (Hausarbeiten, Tabellen) dürfen durch die Cloud selbst nicht kaputtgehen. Verbindlich: - **Atomare Schreibvorgänge.** Nie in-place überschreiben. Immer in Temp-Datei schreiben → `fsync` (Daten auf Platte erzwingen) → atomares `rename` an den Zielpfad. Ein abgebrochener Upload kann nie eine halbe/korrupte Datei hinterlassen. - **Prüfsummen (SHA-256).** Beim Upload berechnen und in der DB speichern; beim Download verifizieren. Erkennt stille Korruption (Bit-Rot) sofort statt erst beim Öffnen. - **Nie hart löschen / nie blind überschreiben.** „Löschen" = Soft-Delete (Papierkorb). „Speichern" über einen bestehenden Inhalt legt eine neue Version an, die alte bleibt erhalten. Echtes Entfernen der Bytes nur verzögert und kontrolliert per Garbage-Collection. - **Konsistenz DB ↔ Storage.** Metadaten erst nach erfolgreichem Blob-Write committen; bei Fehler sauber zurückrollen, keine „verwaisten" oder „halben" Zustände. - **Verifizierende Tests.** Crash-/Abbruch-Simulation beim Upload, Checksum-Verifikation, Soft-Delete-Recovery sind Pflicht-Tests. --- ## 3. Datenablage & Deployment ``` /data /users /{username} /home # eigentliche Dateien (spiegelt App-Ordnerstruktur) /.trash # Soft-deleted Dateien ({shortID}_{name}) /.versions# Versionierungsblobs ({uuid}) /groups # Gruppenordner (ab Phase 4.5) /{groupID} /home # geteilte Dateien der Gruppe /.trash /.versions /thumbs # generierte Vorschauen ({fileID}.jpg) /tmp # Upload-Staging (temp → fsync → rename) /postgres # DB-Volume (eigener Container) /config .env # Secrets & Konfiguration ``` **Hinweis (ab Phase 4.5):** Ein `folder`/`file` gehört entweder einem User (`owner_id`) **oder** einer Gruppe (`group_id`) – nie beidem. Storage-Keys folgen entsprechend `users/{username}/home/…` bzw. `groups/{groupID}/home/…`. Persönliche Ordner können zusätzlich per ACL an andere User/Gruppen freigegeben werden (interne Shares), ohne dass die Bytes umziehen – der Empfänger sieht sie virtuell unter „Geteilt mit mir". `docker-compose.yml` Services: 1. `app` (Go-Binary, serviert auch das gebaute Frontend; lauscht intern auf HTTP `APP_LISTEN_ADDR`) 2. `postgres` 3. *(optional)* `meilisearch` 4. *(optional)* `onlyoffice` / `collabora` 5. *(optional)* `redis` (für Job-Queue) Ein Proxy-Service ist **nicht** dabei und war zuletzt nur auskommentiert vorhanden; am 23.08.2026 auf Marcs Wunsch samt `Caddyfile` entfernt. **Reverse Proxy = extern (Bring Your Own).** TLS wird vom eigenen Proxy (Traefik/nginx/Cloudflare) terminiert; die App läuft dahinter auf reinem HTTP. Die App muss proxy-aware sein: - `APP_LISTEN_ADDR` – konfigurierbarer Listen-Port - `TRUSTED_PROXIES` – nur von diesen IPs/CIDRs werden `X-Forwarded-*`-Header akzeptiert - Echte Client-IP für Rate-Limiting über `middleware.RealIP(r)` (nicht die Proxy-IP) - `Secure`-Cookie über `middleware.IsHTTPS(r)` (erkennt Forwarded-Proto) - `PUBLIC_URL` für OIDC-Redirects, WOPI- und Share-Links - HSTS beim Edge-Proxy, CSP in der App `.env` enthält: DB-Credentials, `SESSION_SECRET`, `APP_LISTEN_ADDR`, `PUBLIC_URL`, `TRUSTED_PROXIES`, OIDC-Client-ID/Secret/Issuer, Storage-Pfade, Feature-Flags. **Nie** Secrets ins Image bauen – immer über `.env` / Compose-Env. --- ## 4. Feature → Lösung (deine Checkliste) | Anforderung | Umsetzung | |---|---| | OIDC-Anbindung | `go-oidc`, parallel zu lokalem Login, Mapping OIDC-Claims → User/Gruppen | | WebDAV (erstellen + verbinden) | `x/net/webdav` mit Auth-Middleware + Storage-Adapter; testbar mit `cadaver`/Finder/Windows | | Modernes Frontend zum Weiterbauen | React + TS + Vite, klare Komponentenstruktur, Tailwind | | Office-Support | Backend als **WOPI-Host**; Collabora **oder** OnlyOffice als Provider, per `.env` wählbar | | PDF-Support + Paperless-artig | Eigenes `documents`-Modul: PDF.js-Viewer, OCR-Worker, Volltextsuche, Tags | | Sicher + getestet | siehe Abschnitt 6 & 7 | | Datenintegrität | Atomare Writes, SHA-256-Prüfsummen, Soft-Delete/Versionierung – Datei geht durch die Cloud nicht kaputt (Abschnitt 2) | | Admin-Oberfläche | User anlegen/bearbeiten/löschen **inkl. Passwort zurücksetzen**, Gruppen-Verwaltung, Quotas, Settings | | Theming (Admin) | Globales Branding: Logo, Cloud-Name, freie Farbpaletten (main, akzent, …), Styling | | Theming (User) | User darf **nur** seine **eigene** Farbpalette für sich wählen – sonst keine Theming-Rechte | | Gruppenordner | Geteilte Ordner mit gruppenbasierten ACLs | | Plugin-Gedanke | Modularer Monolith + spätere Extension-Points (Abschnitt 2) | --- ## 5. Phasen-Roadmap (MVP → vollständig) **Phase 0 – Fundament** ✅ Monorepo (`/backend`, `/frontend`, `/deploy`), Docker Compose, Postgres + Migrations, CI-Skelett, `CLAUDE.md` mit Konventionen, Health-Endpoint. **Phase 1 – Core (MVP)** ✅ Lokale Auth (Argon2id, Sessions), Storage-Interface + LocalFS **mit atomaren Schreibvorgängen + SHA-256-Prüfsummen + Soft-Delete**, Datei-CRUD (Upload/Download/Move/Rename/Delete), Ordner, React-Datei-Browser mit Upload-Queue. Datenintegrität ist ab hier Pflicht (siehe Garantien in Abschnitt 2). **Hier stoppen & reviewen.** **Phase 2 – Dateien fortgeschritten** ✅ Upload mit Fortschritt + paralleler Queue, Papierkorb (Soft-Delete, voller Pfad), Versionierung (automatisch beim Re-Upload, max. 10 Versionen, Restore), Share-Links (passwortgeschützt, ablaufend, öffentliche Download-Seite), Thumbnails/Vorschauen, Drag & Drop (Dateien in Ordner), Quota-Anzeige in Sidebar, Shared-Links-Übersicht, Ordner umbenennen/löschen (Blob-Kaskade), lesbare Dateipfade auf Disk (`users/{name}/home/…`), `.trash/`-Ordner auf Disk (Blobs wandern beim Löschen/Wiederherstellen mit), Versions-Blobs werden bei Hard-Delete bereinigt. **Phase 2.5 – Sidebar-Favoriten & Ordner-Features** ✅ - **Favoriten:** User kann beliebige Ordner als Favorit markieren; erscheinen als eigener Abschnitt „Favoriten" in der Sidebar. Reihenfolge per Drag & Drop änderbar. Auf-/Zugeklappt-Zustand pro Favorit ebenfalls serverseitig in DB gespeichert (`favorites`-Tabelle, ON DELETE CASCADE). Klick navigiert direkt in den Ordner. - **Ordner verschieben:** Drag & Drop eines Ordners in einen anderen Ordner (Move-Folder-Endpoint mit Blob-Kaskade analog zu Rename-Folder). - **Dateien/Ordner nach oben verschieben:** Kontextmenü „Verschieben nach…" mit Ordner-Picker (Baumansicht) – ermöglicht auch Verschieben zurück in Root oder beliebige andere Ebene, nicht nur Drag & Drop eine Ebene tiefer. - **Ordner hochladen:** Upload eines ganzen Verzeichnisses inkl. Unterstruktur (Browser `webkitdirectory`-API); Ordner werden serverseitig rekursiv angelegt, Pfade beibehalten. **Phase 3 – Admin + Gruppen + Theming** ✅ Admin-Panel: User anlegen/bearbeiten/löschen inkl. Passwort-Reset; Gruppen-Verwaltung; Quotas; Settings. **Theming Admin:** Cloud-Name, freie Farbpaletten (main/akzent/…), globales Styling. **Theming User:** nur eigene Farbpalette für sich selbst. Profil-Seite für reguläre User (Anzeigename, Passwort ändern, persönliche Farben). **Phase 4 – WebDAV + OIDC** ✅ (im Test) WebDAV-Endpoint (`/dav/`) mit Basic-Auth (seit Phase 4.5/A **nur App-Passwörter**, nicht das Kontopasswort); OIDC-Login parallel zum lokalen (go-oidc + oauth2, Mapping OIDC-Claims → User, Auto-User-Anlage). Manuelle Test-Checkliste: `TESTING.md`. **Phase 4.5 – Härtung, interne Shares & Gruppenordner** ✅ (im Test) Zwischenphase: schließt zugesagte, aber noch offene Funktionen aus Phase 3 + die in Phase 4 neu entstandenen Lücken (WebDAV). Aufgeteilt in vier Blöcke: **A) Sicherheits-Härtung (laut Plan „nicht verhandelbar", aber noch offen)** - ✅ **Quota-Enforcement:** Vor jedem Upload (normale API **und** WebDAV) `used_bytes + neue Größe ≤ quota_bytes` serverseitig geprüft – `413` über die API, `507` über WebDAV. - ✅ **Abgebrochene Uploads:** WebDAV committet einen unvollständigen Upload nicht mehr. Vorher legte ein Abbruch (Kontingent voll oder Verbindung weg) eine abgeschnittene Datei mit passender Prüfsumme an bzw. ein verwaistes Blob ohne DB-Zeile. - ✅ **WebDAV-Rate-Limiting:** eigener Fehlversuchszähler pro IP (10 / 15 min → `429` mit `Retry-After`). Bewusst kein Request-Limit: Sync-Clients senden legitim hunderte Requests pro Minute, nur Fehlversuche zählen, ein Erfolg räumt den Zähler weg. - ✅ **App-Passwörter:** pro User generierbare, widerrufbare Tokens (`app_passwords`, SHA-256 statt Argon2 – das Token ist serverseitig zufällig, und WebDAV authentifiziert jeden einzelnen Request). Gelten nur für `/dav/`, nie fürs Web-Login – und sind seit dem 22.08.2026 der **einzige** WebDAV-Zugang: Das Kontopasswort wird dort abgelehnt. Begründung: Es ist zugleich der Admin-Zugang, wer es über WebDAV errät, bekommt weit mehr als Dateizugriff; ein Widerruf trifft nur ein Gerät statt alle; und es liegt nicht mehr in fremden Passwortspeichern. Nebeneffekt: 0,5 ms statt 22,7 ms pro Anfrage, weil ein indizierter Lookup den Argon2-Durchlauf ersetzt. Der Realm-Text im `WWW-Authenticate`-Header nennt den Grund, sonst tippt man im Client vergeblich sein Kontopasswort ein. - ✅ **Audit-Log:** `audit_log` (Migration 000010) + `internal/audit/`. Protokolliert An-/Abmeldung inkl. Fehlversuchen, Passwortwechsel, App-Passwörter, alle Admin-Aktionen (User, Gruppen, Einstellungen), Freigaben und Löschungen. Admin-UI mit Filter und Blätterfunktion. `actor_id` ist `ON DELETE SET NULL` und der Name denormalisiert – sonst räumte das Löschen eines Kontos genau die Spuren weg, die man danach untersuchen will. Bewusst **nicht** protokolliert: normale Uploads/Downloads (ersäufen sonst das Wesentliche) und das Passwort bei einem Reset. - ✅ **Blob-GC:** `internal/gc/` – gleicht den Bestand auf der Platte gegen `files.storage_key` + `file_versions.storage_key` + Thumbnails ab und entfernt, was keine Datenbankzeile mehr kennt. Läuft alle 12 h und von Hand über „Run now" im Admin-Panel; jeder Lauf mit Wirkung landet im Audit-Log. Zwei Schutzmechanismen: 24 h Schonfrist (zwischen `Put` und `INSERT` sieht ein gesunder Upload wie eine Waise aus) und eine Notbremse, die den Lauf abbricht, wenn die Datenbank *keinen einzigen* Blob kennt, obwohl der Speicher voll ist – der Fingerabdruck einer kaputten Abfrage oder falschen Datenbankverbindung. - **Versionierung pro Ordner (Idee, später):** Ergänzend zum Datei-Schalter ein `versioning_default` auf `folders` – neue Dateien in diesem Ordner starten mit aktivierter Versionierung. Praktisch für einen Dokumente-Ordner, damit nicht jedes Dokument einzeln angeschaltet werden muss. Bestehende Dateien blieben unberührt (keine rückwirkende Änderung). Bewusst zurückgestellt, um das Datenmodell zunächst einfach zu halten. **B) Interne Shares (Ordner/Dateien an User oder Gruppe teilen)** ✅ (REST + WebDAV) - ✅ `acl_entries` (Migration 000011). **Abweichung von der Skizze oben:** statt polymorpher Spalten vier nullbare Fremdschlüssel mit CHECK-Bedingung. Polymorph kann die Datenbank nichts erzwingen – gelöschte Nutzer oder Ordner hinterließen Einträge, die ins Leere zeigen, und daraus später eine Freigabe für einen neu vergebenen Namen zu machen wäre ein Sicherheitsloch. Mit echten Fremdschlüsseln räumt `ON DELETE CASCADE` das von selbst auf (geprüft: nach dem Löschen zweier Testkonten 0 verwaiste Einträge). - ✅ AuthZ pro Request in `internal/acl/`: Zugriff hat der Eigentümer oder wer einen Eintrag besitzt – direkt, über eine Gruppe, oder geerbt von einem Ordner darüber (rekursiv nach oben, nicht nach unten: der Pfad zur Wurzel ist kurz, der Unterbaum kann riesig sein). Mehrere Wege → das stärkere Recht gilt. - ✅ Durchgesetzt in allen REST-Handlern für Dateien und Ordner. Die Datenbankschicht bleibt eigentümerbezogen; vorgeschaltet klärt `resolveFile`/`resolveFolder`, **in wessen Namen** die Operation läuft. Kein Zugriff → `404` statt `403`, sonst verriete die Antwort die Existenz der ID. - ✅ Frontend: „Share with people…" im Kontextmenü (Person/Gruppe + Recht), Ansicht „Shared with me" mit Herkunftshinweis bei Gruppenfreigaben. - ✅ **WebDAV nachgezogen** (`internal/webdav/scope.go`). Der Aufbau bildet die Weboberfläche nach, damit man nicht umdenken muss: **Gruppenordner liegen in der Wurzel** neben den eigenen, **Personen-Freigaben** gebündelt unter `/dav/Shared///`. `davScope` klärt für jeden Pfad, welcher Prinzipal gilt, ab welchem Ordner die restlichen Pfadteile zählen und ob geschrieben werden darf; `FolderByPathFrom` löst ab diesem Einstiegspunkt auf, sodass alles oberhalb der Freigabe unerreichbar bleibt. - **Namensgleichheit in der Wurzel:** Der eigene Ordner gewinnt, der Gruppenordner bekommt den Gruppennamen angehängt (`Projekt (Buero)`). Nötig, weil WebDAV über Pfade adressiert – zwei gleichnamige Einträge in einem Verzeichnis kann ein Client nicht auseinanderhalten. In der Weboberfläche ist das harmlos, dort führen IDs zum Ziel. - **Personen-Freigaben bleiben nach Person verschachtelt.** Ein flacher Aufbau müsste bei gleichnamigen Freigaben Namen vergeben, die sich ändern, sobald eine weitere dazukommt – und ein Sync-Client, dem sich Pfade unter den Füßen verschieben, lädt alles neu hoch oder löscht es. - `statusWriter` bildet Ablehnungen aus Rechtegründen auf `403` ab. x/net/webdav vergibt sonst pauschal `404` oder `405`, und man sucht den Fehler beim Pfad statt bei seinen Rechten. Nicht Vorhandenes bleibt bewusst `404`. - `/Shared` existiert nur, solange etwas geteilt ist – sonst bliebe bei Clients ein leerer Ordner stehen. **Festlegungen aus der Umsetzung:** - Wer in einen geliehenen Ordner hochlädt, verbraucht das **Kontingent des Eigentümers** – die Bytes liegen in dessen Bereich. Alles andere wäre ein Weg, fremde Kontingente zu umgehen. - Ein Unterordner, den ein Empfänger anlegt, gehört dem **Eigentümer des Elternordners**. Sonst hätte der Eigentümer nach Entzug der Freigabe fremde Ordner in seinem Baum. - **Weitergeben ist nicht möglich:** nur der Eigentümer erteilt Freigaben. Sonst verlöre er die Kontrolle darüber, wer seine Daten sieht. - **Verschieben über Eigentumsgrenzen ist gesperrt** – es würde Bytes zwischen Kontingenten umbuchen und Freigaben unter der Hand umhängen. **C) Gruppenordner** ✅ (REST + WebDAV) - ✅ `folders`/`files` haben `group_id` (XOR mit `owner_id`, per CHECK erzwungen), Migration 000012. Ablage unter `groups/{groupID}/home/…` – auch Papierkorb und Versionen, damit man auf der Platte ansieht, dass die Daten keinem Konto gehören. - ✅ **Prinzipal statt Eigentümer:** Die Store-Abfragen prüfen `owner_id = $1 OR group_id = $1`. UUIDs sind global eindeutig, eine Gruppen-ID kann also nie auf ein Konto passen. Dadurch blieben die ~40 Store-Methoden unverändert – nur die WHERE-Klauseln wurden erweitert. - ✅ Admin legt den Ordner an (Admin → Groups). Jedes Mitglied sieht ihn in seiner Wurzel; Zugriff = Mitgliedschaft, ein Austritt entzieht ihn im selben Moment. - ✅ **Quota: eigene Gruppen-Quota** (`groups.quota_bytes`, Vorgabe 10 GB). Die Alternative „zählt beim Ersteller" wäre für die Mitglieder nicht nachvollziehbar – der Ordner wäre plötzlich voll, weil jemand anders privat etwas hochgeladen hat. - ✅ **Der Wurzelordner ist geschützt:** kein Löschen, Umbenennen oder Verschieben durch Mitglieder – serverseitig `403`, im Kontextmenü fehlen die Einträge ganz statt als Knöpfe, die nur eine Fehlermeldung liefern. Entfernt wird er allein im Admin-Bereich. Alles *innerhalb* bleibt normal bedienbar. - ✅ **Eine Gruppe hat genau einen Ordner.** Hält das Modell verständlich („der Ordner der Gruppe") und erlaubt es, beim Löschen `groups/` am Stück zu entfernen – sonst blieben leere Verzeichnisse zurück. - ✅ Papierkorb: Gelöschte Gruppendateien erscheinen im Papierkorb *jedes* Mitglieds und sind wiederherstellbar. Ohne das wären sie spurlos verschwunden – sie gehören keinem Konto, tauchten also in keinem persönlichen Papierkorb auf. **D) Theming-Rest** ✅ - ✅ **App-Symbol-Upload** neben dem Logo (22.08.2026). **Ein** Bild für Tab-Symbol *und* installierte App: Der Server rechnet aus der Vorlage 64, 192, 512 und eine maskable-Fassung (`internal/admin/appicon.go`). Ein zuerst gebauter reiner Favicon-Upload wurde wieder ersetzt – zwei Bilder zu pflegen, die dasselbe zeigen sollen, laufen mit der Zeit auseinander. - **Nie hochskalieren.** Aus einer 256er-Vorlage entsteht kein 512er; das Manifest nennt dann nur die Größen, die es gibt. Ein hochgerechnetes Symbol sähe schlechter aus als gar keins. - **Rahmen statt beschneiden** bei nicht quadratischen Vorlagen. Ein Zuschnitt würde ein Logo anschneiden, und das fällt erst auf, wenn das Symbol überall klebt. - **maskable auf der Primärfarbe**, nicht durchsichtig: Android legt sonst eine weiße Kachel unter das Symbol, die im dunklen App-Menü leuchtet. Motiv auf 80 % – das ist die Schutzzone, die kein Gerät wegschneidet. - Skaliert wird mit `draw.CatmullRom` statt des `BiLinear` aus `internal/thumbnails`: Vorschaubilder dürfen weich sein, ein Symbol soll scharfe Kanten behalten. Ausgabe PNG wegen der Transparenz. - **ICO fällt weg** (der Favicon-Upload nahm es kurzzeitig an): Go bringt keinen Dekodierer mit, und ohne Dekodieren keine Größen. Dafür jetzt WebP – der Dekodierer `x/image/webp` fehlte im Projekt ganz, weshalb auch `internal/thumbnails` für WebP stillschweigend nie ein Vorschaubild erzeugte. Mit behoben. - ✅ **Manifest aus der Datenbank** (`internal/admin/manifest_handler.go`, Route `/manifest.webmanifest`). Vorher stand es fest in `vite.config.ts` und zeigte auf drei mitgelieferte Dateien – ein hochgeladenes Symbol konnte die installierte App also nie erreichen. Jetzt folgen Name, Farbe und Symbole dem Admin-Bereich; ohne eigenes Symbol die mitgelieferten als Rückfall. `Cache-Control: no-cache` für die Liste, die Bilder darin cachen weiter eine Woche. - **Grenze, die bleibt:** Chrome baut bei der Installation ein eigenes Paket mit einer Kopie des Symbols und fragt das Manifest nur in Abständen von Tagen nach. Ein Wechsel ist auf dem Startbildschirm frühestens in ein, zwei Tagen zu sehen. - ✅ **Symbol-Satz als Verzeichnis** (`branding/icon-/`), Schlüssel in `settings.app_icon_storage_key`. Der GC behandelt einen Wert mit `/` am Ende als Präfix und zählt alles darunter als referenziert – sonst hätte er den ganzen Satz nach 24 Stunden gelöscht. Beim Austausch räumt `DeletePrefix` das alte Verzeichnis **samt Ordner** weg: Einzelne Dateien zu löschen ließ bei jedem Wechsel einen leeren Ordner zurück, den der GC nie sieht, weil er nur Dateien betrachtet. - ✅ Logo-Upload durch Admin (`internal/admin/branding_handler.go`). Blob unter `branding/logo-.`, Schlüssel in `settings.logo_storage_key` – keine Migration nötig, der Eintrag war seit 000004 vorgesehen. - **SVG ist bewusst nicht erlaubt.** Eine SVG-Datei darf Skripte enthalten, und das Logo wird auf der Anmeldeseite ausgeliefert – also dort, wo noch niemand angemeldet ist. Zugelassen sind PNG, JPEG, WebP und GIF, erkannt am Inhalt (`http.DetectContentType`), nicht an Endung oder mitgeschicktem Content-Type. Dazu `X-Content-Type-Options: nosniff` beim Ausliefern. - **Zufälliger Namensanteil statt festem Dateinamen:** So ändert sich die Adresse bei jedem Austausch und Browser zeigen sofort das neue Logo, statt tagelang das alte aus dem Zwischenspeicher. Deshalb darf mit `max-age=604800` lange zwischengespeichert werden. - Reihenfolge beim Austausch: neues Blob schreiben → Einstellung setzen → erst dann das alte löschen. Andersherum stünde man ohne Logo da, wenn der mittlere Schritt scheitert. - Der GC durchsucht `branding/` mit und kennt den Logo-Schlüssel als Referenz – sonst hielte er das Logo für verwaist und löschte es beim nächsten Lauf. **Phase 5 – UI-Überarbeitung & Design** Vollständiger Design-Pass: konsistente Abstände, Icons, Hover-Effekte, Dark/Light-Mode-Toggle. - ✅ **Vor/Zurück im Verlauf** (`frontend/src/hooks/useNavHistory.ts`, 22.08.2026). Ansicht und Ordnerkette liegen in `history.pushState`, nicht im Komponenten-State. Damit greifen die Zurück-Taste des Browsers, die Seitentasten der Maus und die Wischgeste – die Anwendung hat kein eigenes Verlaufsmodell daneben, das damit auseinanderlaufen könnte. Die Ordnerkette steckt im State des Eintrags (der Browser bringt ihn beim Neuladen mit, man landet also wieder im selben Ordner); die Adresse trägt nur die Ansicht (`/trash`, `/admin`), weil es keine Abfrage gibt, die zu einer Ordner-ID die Kette darüber liefert – ein Link auf einen tiefen Ordner hätte also keine Brotkrumen. Zweimal derselbe Ort erzeugt bewusst keinen zweiten Eintrag, sonst bräuchte ein Klick auf den Ordner, in dem man schon steht, ein Zurück. **Bedient wird ausschließlich über den Browser** – eigene Vor/Zurück-Knöpfe standen kurzzeitig in der Werkzeugleiste und sind am 23.08.2026 wieder raus: zwei Bedienelemente für dieselbe Sache, von denen eines nur die halbe Wahrheit kennt (ob es vorwärts weitergeht, verrät der Browser einer Seite nicht). - ✅ **Namenssuche** (`internal/files/search.go`, Feld in der Werkzeugleiste, 23.08.2026). Trigramm-Index (`pg_trgm`, Migration 000013) statt Volltextsuche: Gesucht wird nach Namens*teilen*, und eine Volltextsuche zerlegt in Wörter – „rechn" fände „Rechnung_2026.pdf" also gerade nicht. Volltext über den *Inhalt* bleibt Phase 6. - **Der Umfang steckt in der Abfrage, nicht im Handler.** Eine Suche, die Treffer nachträglich filtert, ist die Variante, bei der man einen Fall vergisst – und ein vergessener Fall zeigt Dateinamen aus fremden Ordnern. Die Abfrage schneidet deshalb selbst zu: eigenes, Gruppenordner, Freigaben und alles *unterhalb* einer freigegebenen Ordnerfreigabe (rekursive CTE nach unten – die Einzelprüfung in `internal/acl` läuft andersherum nach oben, weil sie nur einen Pfad zur Wurzel braucht). - Mit zwei Wegwerf-Konten geprüft: vor der Freigabe nichts, nach der Freigabe auch die Dateien in Unterordnern, nach dem Entzug sofort wieder nichts; Gruppenordner nur für Mitglieder, nach Austritt weg; Gelöschtes bleibt im Papierkorb und außerhalb der Suche. - **LIKE-Platzhalter werden entschärft.** Ohne das fände die Suche nach `%e` alles mit einem „e" darin. - Zum Index: Bei 20 000 Zeilen wählt Postgres noch den vollständigen Durchlauf (10 ms); erzwungen liegt der Trigramm-Index bei 4 ms. Er zahlt sich also erst bei wachsendem Bestand aus – angelegt ist er trotzdem, weil eine Migration später am vollen Bestand teurer ist. - ✅ **Dateivorschau** (`frontend/src/components/FilePreview.tsx`). Doppelklick zeigt Bild, PDF, Text, Video und Ton, statt herunterzuladen; alles andere lädt weiter herunter. Pfeiltasten blättern durch die anzeigbaren Dateien des Ordners. - **`?inline=1` ist die gefährliche Richtung** und deshalb an eine Positivliste gebunden: Eine hochgeladene HTML- oder SVG-Datei liefe sonst im Ursprung der Anwendung und käme an die Sitzung. Was nicht auf der Liste steht, wird weiterhin als Anhang ausgeliefert – still, ohne Fehlermeldung. Dazu `nosniff` und ein `Content-Security-Policy: sandbox` auf der Antwort. - Der PDF-Betrachter ist der eingebaute des Browsers. Ein eigener (PDF.js) käme mit Phase 6. - ✅ **Range-Unterstützung nachgerüstet** (23.08.2026). Ohne `Accept-Ranges` hält der Browser die Antwort für unteilbar und gibt in der Abspielleiste kein Springen frei – Video und Ton ließen sich nur von vorn bis hinten abspielen. Jetzt beantwortet `http.ServeContent` einen `Range`-Kopf mit `206` und dem Ausschnitt. Die Prüfsumme bleibt dabei außen vor: Sie deckt die ganze Datei ab, ein Ausschnitt lässt sich nicht dagegen halten. Beim vollständigen Abruf – also bei jedem echten Download – wird weiter geprüft. - ✅ **Typtabelle nachgerüstet** (`extraMIMETypes`, 23.08.2026). Go liest die MIME-Zuordnung aus `/etc/mime.types` – die Datei gibt es im Alpine-Image nicht, und die eingebaute Ersatztabelle kennt nur Web-Typen. Eine hochgeladene MP3 lag deshalb als `application/octet-stream` in der Datenbank: nicht abspielbar (die Vorschau entscheidet am Typ) und mit dem Symbol für „unbekannt". 94 Bestandsdateien waren betroffen. Statt sie nachträglich umzuschreiben, bestimmt der Download-Handler den Typ aus dem Namen neu, wenn der gespeicherte nichts aussagt – das wirkt sofort und rührt die Daten nicht an. Die Oberfläche zieht den Dateinamen aus demselben Grund mit heran. - ✅ **Cover je Dateiart** (`components/filetypes.ts` + `FileCover.tsx`). Art, Farbe und Symbol werden an *einer* Stelle bestimmt – vorher leitete das jede Komponente selbst aus dem MIME-Typ ab, wobei Symbol und Farbe auseinanderlaufen. Ton, Video, Archiv, Tabelle, Code, Dokument und PDF haben je eigene Farbe, eigenes Symbol und die Endung als Aufkleber; `.tar.gz` wird über den Namen erkannt, weil die zweiteilige Endung sonst als `.gz` durchgeht. - ✅ **Eine Kopfleiste für alle Ansichten** (`components/PageHeader.tsx`, 23.08.2026). Vorher zwei Muster nebeneinander: Dateien, Papierkorb und Freigabe-Links hatten eine feste Leiste, „Geteilt mit mir", Admin und Profil dagegen eine große Überschrift im Scrollbereich – beim Umschalten sprang der Inhalt. Und schon die drei Leisten waren unterschiedlich hoch, weil sich die Höhe aus dem jeweils höchsten Bedienelement ergab (die Dateiansicht hat mit Suchfeld und Auswahlfeld die größten). Jetzt eine **feste Höhe** statt einer gewachsenen: 57 px, Symbol 18, Titel 15/600, Bedienelemente rechts. - ✅ **Office-Formate ergänzt** (23.08.2026): Word, Excel und **PowerPoint** – letzteres fehlte ganz und landete bei „unbekannt". Jede Familie mit eigener Farbe (blau / grün / orangerot) und eigenem Symbol; die OpenDocument-Entsprechungen laufen über dieselben Regeln. Backend-seitig dazu `.ppt`, `.pptx`, `.odp` und `.rtf` in `extraMIMETypes`. `.md` und `.txt` zählen bewusst als Text, nicht als Office-Dokument: Sie sind lesbarer Text und lassen sich hier sogar bearbeiten. - ✅ **PDF-Vorschau tatsächlich zum Laufen gebracht** (23.08.2026). Sie war seit dem Einbau **blockiert**, ohne dass es jemandem auffiel: `pkg/middleware/security.go` setzt `X-Frame-Options: DENY` für alle Antworten – damit darf auch die eigene Anwendung die Datei nicht in einen Rahmen legen. Der Browser meldete „Refused to display … X-Frame-Options to 'deny'" und zeigte einen leeren Rahmen. Auf der Antwort einer Inline-Auslieferung steht jetzt `SAMEORIGIN` plus `frame-ancestors 'self'`; fremde Seiten können die Datei weiterhin nicht einbetten. Dazu `sandbox allow-scripts` **nur für PDF** – der eingebaute Betrachter von Chrome ist selbst ein Skript. Ohne `allow-same-origin` bleibt der Ursprung fremd, die Sitzung also unerreichbar. - **Gefunden, weil ich es angesehen habe.** Der Server lieferte 200 mit korrektem Typ, die Bytes stimmten, Range funktionierte – nach jeder Prüfung per `curl` war alles grün. Erst ein Chromium im Container mit Bildschirmfoto zeigte den leeren Rahmen. - ✅ **Speicherbalken nach der Anmeldung** (23.08.2026): Die Login-Antwort lieferte `used_bytes: 0`, nur `/me` rechnete den Wert aus. Direkt nach dem Anmelden stand der Balken deshalb auf 0 B und sprang erst beim nächsten Abgleich um. Ebenfalls im Bildschirmfoto aufgefallen. - ✅ **`--color-warning` war nie definiert.** Vier Stellen benutzten den Namen – „Verschieben nach…", „Geteilt mit mir", der Admin-Bereich und der Papierkorb – und zeichneten ihre Ordner deshalb **schwarz**. Token nachgetragen und die betroffenen Symbole auf `FolderGlyph` umgestellt. - ✅ **PDF-Symbol unterscheidbar gemacht.** Es teilte sich die Form mit dem Word-Dokument und unterschied sich nur in der Farbe – bei nebeneinanderliegenden Kacheln zu wenig. Ein gefüllter Block statt dünner Zeilen war der erste Versuch – auf Marcs Wunsch wieder zurück auf dieselbe Blattform in Rot: Farbe und Aufkleber trennen die beiden ausreichend. **Beim Zeichnen tatsächlich angesehen** statt geraten: Die Varianten wurden gerendert und verglichen. - ✅ **Brotkrume auf die Schrift der Seitentitel gebracht.** Sie lief über Tailwind-Klassen (`text-sm`, `font-medium` = 14 px / 500), die Titel der anderen Ansichten über 15 px / 600 – nebeneinander sieht man das sofort. Ebenso das Symbol: 18 statt 15 Pixel und gedämpft statt in Textfarbe, wie in den anderen Leisten. - ✅ **„My Files" heißt „Home"** (23.08.2026, Entscheidung von Marc). Die alte Bezeichnung stimmte nicht mehr: Seit dem WebDAV-Abgleich liegen **Gruppenordner in derselben Wurzel** – die gehören nicht dem Anmeldenden. „Home" benennt einen Ort statt Besitz und deckt sich mit dem Speicherpfad (`users//home`) und der WebDAV-Wurzel. Dazu ein Haus-Symbol in der Brotkrume, wie es die Einträge der Seitenleiste haben. - ✅ **Ordner- und Bildkacheln nachgezogen** (`FolderCover`): Der Ordner ist eine Definition (`FolderGlyph` in `icons.tsx`) für Kachel, Liste und Suchtreffer – vorher lag die Form dreimal im Code. Ganzzahlige Koordinaten auf dem 48er-Raster und eine Anzeigegröße, die ein Vielfaches davon ist: So landet jede Kante auf einem ganzen Pixel statt zwischen zweien, wo die Kantenglättung sie weichzeichnet. Die vordere Lage deckt voll, nur die hintere ist durchscheinend – beide durchscheinend sah auf dem dunklen Grund blass aus. Zweilagig mit Verlauf statt eines flachen Symbols, Gruppenordner tragen die Primärfarbe und einen `GROUP`-Aufkleber. Bilder behalten ihr Vorschaubild und bekommen den Endungs-Aufkleber auf dunklem Grund darüber – einem Vorschaubild sieht man sonst nicht an, ob PNG, JPG oder GIF dahintersteckt. - ✅ **MIME-Typen der Bestandsdateien nachgezogen** (87 Zeilen, 23.08.2026, auf Marcs Wunsch). Ohne Endung bleiben `Dockerfile`, `Caddyfile` und `.gitignore` bewusst `application/octet-stream`. - ✅ **Textdateien bearbeiten** direkt in der Vorschau. Gespeichert wird über denselben Weg wie ein Upload (gleicher Name, gleicher Ordner): Der Server ersetzt die Datei und legt bei eingeschalteter Versionierung die alte Fassung in die Historie. Ein eigener Schreib-Endpunkt bräuchte dieselben Prüfungen – Quota, Freigaberecht, Prüfsumme – ein zweites Mal. - ✅ **Sortierung** nach Name, Größe und Datum, je in beide Richtungen. Ordner stehen immer vor Dateien und folgen nur der Namensrichtung – Größe und Datum sagen über einen Ordner in dieser Liste nichts aus. Sortierung und Ansicht liegen im `localStorage`: eine Einstellung, die man bei jedem Besuch neu treffen muss, ist keine. - ✅ **Große Dateien aufgeteilt** (23.08.2026). Rein mechanisch, keine Signatur geändert – `go test`, `go vet` und `tsc` vorher wie nachher grün, danach alle Ansichten im Browser durchgeklickt. - Backend, gleiches Paket, also keine Änderung an den Aufrufern: `internal/files/handler.go` 1400 → 744 Zeilen, daneben `handler_folders.go`, `handler_trash.go`, `handler_versions.go`, `handler_favorites.go`; `store.go` 937 → 535, daneben `store_folders.go`, `store_favorites.go`, `store_webdav.go`. - Frontend: `AdminPage.tsx` 1300 → 69 Zeilen (nur noch Reiterleiste), die vier Reiter unter `pages/admin/` mit `ui.tsx` für die geteilten Bausteine; `FileBrowser.tsx` 1123 → 823, Kachel- und Listenansicht in `components/FileViews.tsx`. - ✅ **Kopfleiste bis auf den Pixel abgeglichen** (23.08.2026). Der Unterschied war **messbar, nicht nur gefühlt**: Das Symbol saß auf „Home" bei x=246, in allen anderen Ansichten bei x=240 – die 6 px kamen vom Innenrand des Wurzel-Knopfs; und der Titel begann bei 265 statt 268, weil die Brotkrume 7 px Abstand zum Symbol hielt statt 10. Beides mit Chromium ausgemessen statt geschätzt, danach identisch. - ✅ **„Admin" aus der Seitenleiste entfernt** (23.08.2026, Wunsch von Marc). Die Leiste zeigt jetzt ausschließlich *Orte, an denen Dateien liegen* – Home, Papierkorb, Geteiltes, Favoriten. Die Verwaltung ist keiner davon; sie steckt im Nutzermenü, wo auch die Einstellungen liegen. Nebenbei fällt damit die einzige Stelle weg, an der ein Eintrag der Leiste je nach Rolle erschien oder nicht. - ✅ **Passwortänderung bei OIDC-Konten ausgeblendet** (23.08.2026, Hinweis von Marc). Konten aus dem Identitätsanbieter haben hier kein Passwort (`password = ''`); das Formular konnte nur fehlschlagen – und zwar mit „current password incorrect", was nach einem Tippfehler aussieht statt nach dem wahren Grund. Neues Feld `is_oidc` am Nutzer (aus `oidc_sub`), die Oberfläche zeigt stattdessen einen Hinweis. - **Kein Sicherheitsloch geschlossen, sondern eine Sackgasse beseitigt:** Anmeldung und Passwortänderung scheiterten für solche Konten ohnehin schon daran, dass `''` kein gültiger Argon2-Hash ist. Der Server weist den Aufruf jetzt zusätzlich mit `400` und klarer Begründung ab, statt mit einem irreführenden `401`. - ✅ **Reiter „Audit log" heißt „Activity"** (23.08.2026, Wunsch von Marc). Er zeigt nicht mehr nur das Protokoll, sondern davor den Stand der Texterkennung und die Speicherbereinigung – also alles, was im Hintergrund läuft. Die interne Kennung bleibt `audit`: Sie steht in keiner Adresse, ein Umbenennen wäre reine Bewegung. - ✅ **Stand der Texterkennung sichtbar** (`pages/admin/AuditTab.tsx`, 23.08.2026). Vorher lieferte `/api/v1/admin/ocr` Zahlen, die niemand sah. Ohne die Anzeige ist nicht zu unterscheiden, ob die Volltextsuche nichts findet oder den Bestand noch gar nicht gelesen hat – und nach dem ersten Start dauert das je nach Menge Stunden. - Steht bei der Speicherbereinigung, weil beides dasselbe Problem hat: ein Vorgang, der unbeobachtbar im Hintergrund läuft, ist schwer zu vertrauen. - **Fragt nur nach, solange sich etwas bewegt.** Steht die Warteschlange still, ändert sich die Zahl erst beim nächsten Upload; jede weitere Anfrage wäre verschenkt. - Ein farbiger Punkt sagt den Zustand ohne Lesen: grün fertig, gelb läuft, gedämpft abgeschaltet. - ✅ **Eigener Erkennungsstand für jeden Nutzer** (`GET /api/v1/ocr`, 23.08.2026, auf Marcs Nachfrage). Der Stand war zunächst nur im Admin-Bereich zu sehen – **die falsche Person**: Wissen muss es, wer gerade eine Rechnung hochgeladen hat und sie nicht findet. „Wird noch gelesen" und „ist nicht drin" sehen in einer leeren Trefferliste identisch aus. - Getrennte Abfrage statt derselben mit Filter: Die Gesamtzahl der Dokumente auf dem Server verrät etwas über fremde Bestände und bleibt dem Administrator vorbehalten. Gezählt wird über `owner_id`; Gruppenordner bleiben außen vor, weil ihr Stand keine Auskunft über den eigenen Bestand wäre. - Steht im Reiter „Storage" der Einstellungen – dort, wo ohnehin steht, was einem gehört. Damit trägt dieser Reiter auch genug, um einer zu sein. - ✅ **Reiterleiste als eine Definition** (`components/Tabs.tsx`, 23.08.2026). Einstellungen und Admin-Bereich hatten sie sonst zweimal im Code – zwei Kopien derselben Leiste laufen mit der Zeit auseinander, und beim Umschalten merkt man den Unterschied, ohne ihn benennen zu können. Genau das war bei den Kopfleisten schon einmal passiert. Nachgemessen: beide bei Höhe 39, Schrift 14 px, gleiche Position. - ✅ **Einstellungen sortiert** (`pages/ProfilePage.tsx`, 23.08.2026, Wunsch von Marc). Vorher gewachsen statt geordnet: Der Speicher stand ganz oben und war das Erste, was man sah – obwohl er eine reine Anzeige ist und derselbe Balken ohnehin in der Seitenleiste steht. Jetzt in **Reiter** unterteilt wie der Admin-Bereich, geordnet nach dem, was man tatsächlich aufsucht: **Account** (Profil, Passwort) → **Access** (App-Passwörter) → **Appearance** (Farben) → **Storage** (ans Ende). - Die Seite heißt jetzt **Settings** statt „Profile" – so steht es auch im Nutzermenü, und „Profil" beschrieb nur die erste von vier Gruppen. - Bei den App-Passwörtern steht jetzt dabei, *warum* das Kontopasswort an `/dav/` abgelehnt wird. Der Hinweis stand bisher nur im README – also dort, wo ihn niemand liest, der gerade ein Gerät anbindet. - ✅ **Nutzerbereich oben rechts** (`components/UserMenu.tsx`, 23.08.2026, Wunsch von Marc). Vorher unten links in der Seitenleiste, mit dem Abmelden-Symbol **unmittelbar neben** dem Knopf zu den Einstellungen – die einzige nicht rücknehmbare Handlung der Anwendung war die am leichtesten erreichbare. Jetzt liegt sie eine bewusste Handlung tief, im aufklappenden Menü, zuletzt und abgesetzt. - Weitergereicht über einen Context, nicht über Eigenschaften: Sonst müsste jede der sechs Seiten Nutzer, Abmelden und Umschalten durchreichen, nur damit die Kopfleiste sie hat. - **Nur der Anzeigename** in der Leiste. Vorher standen zwei Zeilen untereinander – Anzeigename und darunter die Rolle –, was bei einem Konto namens „Admin" als „Admin / Admin" ankam und wie eine Doppelung aussah. Die Kennung steht jetzt im Menü, und auch dort nur, wenn sie sich vom Anzeigenamen unterscheidet. - **Der Speicherbalken bleibt unten links.** Er gehört zum *Ort*, nicht zur Person. - ✅ **Dateien liegen auf dem Wirt statt in einem Docker-Volume** (23.08.2026, Wunsch von Marc). `DATA_PATH` als Bind-Mount, dazu `APP_UID`/`APP_GID`, damit der Container unter Marcs Kennung läuft und die Dateien ihm gehören – per SFTP, rsync oder Dateimanager direkt erreichbar, ohne `root`. Vorher lag alles unter `/var/lib/docker/volumes/` und gehörte root. - **Das ist die Eigenschaft, die ein Archiv erst zu einem macht:** ohne die Anwendung an die Unterlagen zu kommen. `scp -r` genügt. - Umzug der 121 Bestandsdateien (654 MB) **mit Prüfsummenvergleich vor dem Umschalten**; das alte Volume `deploy_data` bleibt als Rückfall stehen. Danach Upload, OCR und Vorschau erneut geprüft – alles grün unter der neuen Kennung. - `HOME=/tmp` im Compose, weil ghostscript und ocrmypdf Arbeitsdateien im Heimverzeichnis ablegen und das aus dem Image bei fremder Kennung nicht mehr beschreibbar ist. - **Hell-Modus verworfen** (23.08.2026, Entscheidung von Marc): Die Cloud ist persönlich und bleibt dunkel. Damit entfällt auch die Hauptbegründung, die ~40 fest eingetragenen Farbwerte auf Tokens umzustellen – das passiert künftig nebenbei, wenn eine Datei ohnehin angefasst wird. Geplante Ideen (noch nicht umgesetzt): - Custom Background (User kann eigenes Hintergrundbild setzen) - Glasmorphismus-Theme (transluzente Sidebar/Cards mit Blur-Effekt) - Logo und App-Symbol bereits in Phase 4.5/D umgesetzt - ✅ **Gleitende Sitzung** (`internal/auth/session.go`, 01.09.2026, Wunsch von Marc: „der Login speichert sich nicht"). Die Frist zählt ab der **letzten Benutzung** statt ab dem Anmelden; Vorgabe 30 Tage. Verlängert wird höchstens einmal pro Stunde – ohne diese Bremse trüge jede Antwort ein `Set-Cookie`, und beim Laden einer Dateiliste sind das dreißig. - **Warum das keine Bequemlichkeit auf Kosten der Sicherheit ist:** Der alte Ablauf traf den *Anwesenden* und nicht den *Abwesenden* – wer täglich arbeitete, flog täglich heraus, wer das Konto vergaß, blieb genauso lange angemeldet. Ein Ablauf ab letzter Benutzung trifft genau den Fall, um den es geht. - **Der Preis steht in der Doku, nicht nur im Kopf:** Ein aus einem fremden Browser kopiertes Cookie verlängert sich beim Benutzen mit. Es gibt keine Sitzungsliste auf dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne Sitzungstabelle auszukommen. Alle Sitzungen beenden = `SESSION_SECRET` wechseln. - ⚠️ **Dabei gefunden, war schon vorher drin:** `NewSessionStore` ersetzte `store.Options`, ohne `store.MaxAge()` zu rufen. Es gibt aber **zwei** Fristen – die im Cookie (der Browser wirft es weg) und die in der Signatur (der Server lehnt es ab). Nur die erste war gesetzt, die zweite stand auf gorillas Voreinstellung von 30 Tagen. Ein herauskopiertes Cookie blieb damit einen Monat gültig, obwohl `SESSION_MAX_AGE` auf einen Tag stand: Wer die Frist kürzte, um sie zu kürzen, hatte nichts gekürzt. Behoben und mit Test festgehalten – der Test ist gegengeprüft, ohne den Aufruf schlägt er fehl. - **Bestehende `.env`-Werte bleiben unangetastet.** Wer die 30 Tage will, trägt sie selbst ein; die neue Vorgabe gilt nur für frische Installationen. - ✅ **Office-Dokumente am Telefon als PDF ansehen** (`internal/office/convert.go` + `preview.go`, Migration 000020, 31.08.2026, Wunsch von Marc). Am Finger öffnet ein `.docx` **bei OnlyOffice** nicht mehr den Editor, sondern das gewandelte PDF im eigenen Betrachter; „Edit" daneben führt trotzdem hinein. Am Schreibtisch bleibt alles wie es war. - **Bei Collabora bleibt der Editor.** Zuerst galt die Regel für beide Anbieter – falsch, und Marc hat es sofort gesehen: Seine Kritik betraf nur OnlyOffice. Collabora bringt für kleine Schirme eine eigene Ansicht mit, die taugt; dort wäre der Umweg über ein PDF ein Rückschritt. Der Anbieter steht deshalb in der öffentlichen Konfiguration (`office_provider`), und die Oberfläche entscheidet daran. Der Endpunkt selbst kann beides und ist mit beiden gemessen (Collabora 0,35 s, OnlyOffice 1,05 s). - **Warum überhaupt:** Der Web-Editor ist eine vollständige Textverarbeitung. Menüband, Formatleisten und Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt – auf 400 Pixeln bleibt für das Dokument ein Rechteck. Marc hat Collabora **und** OnlyOffice ausprobiert, mit demselben Ergebnis; das ist der Grund, aus dem hier kein besserer Bedienmodus gesucht wurde, sondern ein anderes Werkzeug. Nextcloud löst es in seiner App genauso: ansehen statt bearbeiten. - **Gewandelt wird von dem Server, der auch bearbeitet.** Wer OnlyOffice wegen der Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe, sähe unterwegs ein LibreOffice-Layout desselben Dokuments – zwei Geräte, zwei Umbrüche, ausgerechnet bei Unterlagen. Beide Wege sind deshalb gebaut: Collabora nimmt die Bytes (`/cool/convert-to/pdf`), OnlyOffice eine Adresse, holt die Datei selbst und antwortet mit einer Adresse (`/ConvertService.ashx`, HS256-signiert). - **Je Datei gibt es genau ein PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels:** `previews/.pdf` trägt die Fassung **nicht** im Namen. Eine neue Fassung überschreibt, statt danebenzulegen. Ob das Abgelegte noch passt, sagt `files.preview_version`. Gemessen: Nach dem erneuten Hochladen stand die Datei auf Fassung 2, `preview_version` auf 2, und im Ordner lag weiterhin genau eine Datei. - **Der GC kennt den Ordner – und die Referenz.** Beides musste sein: ohne Präfix wächst er ewig, ohne Referenz löscht der GC das PDF nach einem Tag weg. Genau dieser zweite Fehler ist beim Logo und beim Favicon schon einmal passiert. Beide Richtungen einzeln geprüft (herrenloses PDF von vorgestern → weg; echtes → bleibt). - **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** OnlyOffice antwortet nicht mit dem PDF, sondern mit einer Adresse, die wir gleich abrufen – das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf `169.254.169.254` würde. Der Wähler des Transports lässt ohnehin nur `OFFICE_ALLOWED_HOSTS` zu; die ausdrückliche Prüfung steht zusätzlich da und ist mit Test abgedeckt. - **Zählt gegen kein Kontingent** (Entscheidung von Marc): Es ist abgeleitet, niemand hat es hochgeladen. Platte kostet es trotzdem – deshalb steht der Platzbedarf jetzt im Admin-Bereich neben der Bereinigung, zusammen mit Vorschau- und Profilbildern. Ein Ordner, der wächst, ohne dass ihn irgendwo jemand sieht, ist eine stille Überraschung. - Gemessen an dieser Anlage: erster Abruf 1,05 s, zweiter 0,009 s. Fremde Kennung `404`, ohne Anmeldung `401`, Nicht-Office-Datei `415`, zu große Datei `413` (die Oberfläche bietet daraufhin den Editor an). - ⏸️ **Im Papierkorb ansehen, was es war – vertagt** (26.08.2026, Wunsch von Marc: „erstmal noch nicht wichtig"). Die kleinen Vorschaubilder sind seit dem 26.08. wieder da (`GetThumbnail` sah nur in nicht-gelöschten Dateien nach). Gemeint ist der Schritt danach: eine Datei im Papierkorb **öffnen** und ansehen, bevor man „Delete forever" drückt. - **Warum es überhaupt eine Idee ist:** Der Papierkorb ist die einzige Ansicht ohne Vorschau – ausgerechnet die, in der die Entscheidung unwiderruflich ist. Wer nach zwei Wochen aufräumt, sieht einen Namen wie `IMG_2481.jpg` und rät. - **Wo es hängt:** an derselben Stelle wie eben das Vorschaubild. `Download` schlägt die Datei mit `is_deleted = false` nach (`handler.go:411`), der Vorschau-Dialog hängt außerdem an der Dateiliste, nicht an einem einzelnen Eintrag – er blättert zum nächsten Bild und kennt Umbenennen, Teilen, Löschen. Im Papierkorb wäre davon fast nichts sinnvoll. - **Der Zuschnitt wäre deshalb enger:** nur ansehen, kein Blättern, keine Bearbeitung – und der Download-Pfad müsste für Gelöschtes gezielt geöffnet werden, nicht pauschal. Sonst ließe sich eine gelöschte Datei über ihre Kennung genauso herunterladen wie eine lebende, und der Papierkorb wäre kein Zwischenschritt mehr, sondern ein zweiter Ordner. **OIDC-Durchgang (23.08.2026)** Vor dem Produktivgang durchgesehen. Zwei Befunde, beide vorher unsichtbar: 1. ⚠️ **Der erste OIDC-Login hätte ein zweites, leeres Konto erzeugt.** Zugeordnet wurde ausschließlich über `oidc_sub`; das bestehende `admin`-Konto hat keinen. Der Einfügeversuch scheitert am eindeutigen Benutzernamen (in der Datenbank nachgespielt: `duplicate key … users_username_key`), und der Code weicht auf `admin2` aus – **ohne Adminrechte** (`is_admin` steht per Vorgabe auf `false`), ohne Dateien, mit leerem Speicher. Wer seine Cloud bisher mit Passwort benutzt hat und dann OIDC einschaltet, landet in einer fremden, leeren Cloud. - ✅ Behoben mit `OIDC_LINK_BY_USERNAME`: Ein bestehendes Konto gleichen Namens **ohne** Bindung wird mit der Identität verbunden, statt daneben ein zweites anzulegen. Dateien, Adminrecht und Freigaben bleiben. - **Voreingestellt aus.** Verbinden heißt, dem Anbieter zu glauben, dass „marc" bei ihm derselbe „marc" ist wie hier. Bei einem eigenen Anbieter stimmt das; bei einem mit freier Registrierung wäre es die Übernahme fremder Konten. Deshalb eine Entscheidung in der `.env` statt einer stillen Annahme. - Ein Konto, das **schon** zu einer Identität gehört, wird nie überschrieben – als Test mitgeschrieben (`oidclink_test.go`, vier Fälle gegen eine echte Datenbank, u. a. „fremde Identität übernimmt ein gebundenes Konto nicht"). 2. ✅ **Das `oidc_state`-Cookie hatte kein `Secure`-Flag** – anders als Sitzungs- und CSRF-Cookie. Jetzt über `middleware.IsHTTPS(r)`, das `X-Forwarded-Proto` auswertet, aber nur von vertrauenswürdigen Proxys. Unauffällig geblieben: Zustandswert gegen CSRF vorhanden und geprüft, ID-Token wird gegen die Signatur des Anbieters verifiziert, Benutzername aus `preferred_username` wird auf harmlose Zeichen beschränkt, Anmeldung mit Passwort ist für OIDC-Konten unmöglich (`''` ist kein gültiger Argon2-Hash). **Phase 6 – Dokumente-Modul (Paperless-lite)** PDF.js-Viewer ✅, OCR-Worker (ocrmypdf) ✅, Volltextsuche (Postgres FTS) ✅, Tags ✅ / Korrespondenten ✅. - ⏸️ **„Inbox"-Ordner mit Auto-Verarbeitung – vertagt** (23.08.2026, Entscheidung von Marc: „grade sehe noch keinen Nutzen drin für mich persönlich"). Festgehalten als Idee, nicht verworfen. Der Kern wäre nicht der Ordner – den kann man sich heute anlegen –, sondern drei Dinge daran: ein **Zähler**, der anzeigt, was noch nicht eingeordnet ist (ein Ordner sagt das nie und wird nie leer); ein **Sortier-Modus**, der Beleg und Eingabefelder nebeneinander stellt und mit „Speichern & nächster" durchgeht statt zwanzigmal Rechtsklick–Dialog–Schließen; und **Vorschläge** für Absender und Belegdatum aus dem erkannten Text. Der dritte Punkt wurde erst durch den Korrespondenten möglich – gegen ein geschlossenes Feld lässt sich abgleichen, gegen freie Etiketten wäre es Raterei. Sinnvoll wird das alles erst, wenn ein Bestand da ist, gegen den verglichen werden kann. - ✅ **Absender („Korrespondent")** (`internal/tags/correspondents.go`, Migration 000017, 23.08.2026, Wunsch von Marc). Das zweite Ordnungsmerkmal neben den Etiketten – und der Unterschied ist nicht die Technik, sondern die **Anzahl**: Ein Etikett sagt, *wofür* ein Dokument gebraucht wird, davon trägt eine Datei beliebig viele. Der Absender sagt, *von wem* es kommt – davon hat sie genau einen. - **Warum nicht einfach ein Etikett „Stadtwerke".** Technisch ginge es, und ich hatte es Marc zunächst auch so empfohlen. Was dagegen spricht, zeigt sich erst mit der Zeit: Beide stünden in **derselben** Liste, „Stadtwerke" neben „absetzbar" – Absender und Verwendungszweck durcheinander. Bei zwanzig Einträgen fällt das nicht auf, bei achtzig ist die Seitenleiste unbenutzbar. Und „alle Rechnungen von X" ließe sich nicht von „alles Absetzbare" trennen, obwohl genau diese Verknüpfung der Zweck ist. - **Die Eins-Regel steht im Schema**, nicht in einer Zusicherung des Codes: eine Spalte an `files`, keine Verknüpfungstabelle. Namen sind je Prinzipal eindeutig, ohne Rücksicht auf Groß-/Kleinschreibung – hier wiegt das schwerer als bei Etiketten: „Amazon" und „amazon" nebeneinander hieße, dass „alle Rechnungen von Amazon" nur die Hälfte findet, und zwar unauffällig. - **`ON DELETE SET NULL`, nicht `CASCADE`.** Wer einen Absender löscht, will ihn aus der Liste haben – nicht seine Rechnungen. Der Bestätigungsdialog schreibt das auch hin; ohne den Satz klingt „Delete" nach dem Gegenteil, und niemand traut sich, aufzuräumen. - **Ein Textfeld mit Vorschlägen, kein Auswahlfeld.** Ein Auswahlfeld zwänge dazu, den Absender vorher anzulegen – beim ersten Beleg von „Stadtwerke" gibt es ihn aber noch gar nicht. Der Server sucht den getippten Namen und legt ihn nur an, wenn es ihn nicht gibt, **in derselben Transaktion** wie Etiketten und Belegdatum. Ein zweiter Aufruf der Oberfläche hinterließe bei einem Fehler dazwischen einen Absender, der an keiner Datei hängt. - **Im Paket `tags`, nicht in einem eigenen.** Alles drei wird in *einem* Dialog gesetzt und muss deshalb in *einer* Transaktion geschrieben werden. Ein eigenes Paket hieße zwei Transaktionen – und einen Zustand, in dem die Etiketten sitzen und der Absender nicht. Das Paket verwaltet also nicht „Etiketten", sondern die Ordnungsmerkmale einer Datei; der Paketkommentar sagt das jetzt auch. - **Kostet keine Zeile an der Kachel.** Marc hatte zu Recht angemerkt, dass Etiketten die Kacheln höher machen. Der Absender steht deshalb *in* der Zeile mit der Größe, links davon und schrumpfbar – dort war ohnehin Platz. In der Listenansicht eine eigene Spalte: In einer Tabelle ändert eine leere Zelle die Zeilenhöhe nicht. - Die Ansicht teilt sich die Seite mit den Etiketten (`/from/` statt `/tags/`) – es ist dieselbe Frage in anderer Richtung, und eine zweite, fast gleiche Seite liefe mit der Zeit auseinander. Genau das war bei den Kopfleisten schon einmal passiert. - Der Menüpunkt heißt jetzt **„Organise…"** statt „Tags…": Der Dialog setzt drei Dinge, und ein Menüpunkt, der nur eines nennt, lässt die anderen beiden unauffindbar. - ✅ **Eigener PDF-Betrachter** (`components/PdfViewer.tsx`, Migration 000016, 23.08.2026). Vorher ein `