618 lines
94 KiB
Markdown
618 lines
94 KiB
Markdown
# 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/<Person>/<Freigabe>/`. `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/<id>` 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-<zufall>/`), 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-<zufall>.<ext>`, 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/<name>/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/<id>.pdf` trägt die Fassung **nicht** im Namen. Eine neue Fassung überschreibt, statt danebenzulegen. Ob das Abgelegte noch passt, sagt `files.preview_version`. Gemessen: Nach dem erneuten Hochladen stand die Datei auf Fassung 2, `preview_version` auf 2, und im Ordner lag weiterhin genau eine Datei.
|
||
- **Der GC kennt den Ordner – und die Referenz.** Beides musste sein: ohne Präfix wächst er ewig, ohne Referenz löscht der GC das PDF nach einem Tag weg. Genau dieser zweite Fehler ist beim Logo und beim Favicon schon einmal passiert. Beide Richtungen einzeln geprüft (herrenloses PDF von vorgestern → weg; echtes → bleibt).
|
||
- **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** OnlyOffice antwortet nicht mit dem PDF, sondern mit einer Adresse, die wir gleich abrufen – das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf `169.254.169.254` würde. Der Wähler des Transports lässt ohnehin nur `OFFICE_ALLOWED_HOSTS` zu; die ausdrückliche Prüfung steht zusätzlich da und ist mit Test abgedeckt.
|
||
- **Zählt gegen kein Kontingent** (Entscheidung von Marc): Es ist abgeleitet, niemand hat es hochgeladen. Platte kostet es trotzdem – deshalb steht der Platzbedarf jetzt im Admin-Bereich neben der Bereinigung, zusammen mit Vorschau- und Profilbildern. Ein Ordner, der wächst, ohne dass ihn irgendwo jemand sieht, ist eine stille Überraschung.
|
||
- Gemessen an dieser Anlage: erster Abruf 1,05 s, zweiter 0,009 s. Fremde Kennung `404`, ohne Anmeldung `401`, Nicht-Office-Datei `415`, zu große Datei `413` (die Oberfläche bietet daraufhin den Editor an).
|
||
|
||
- ⏸️ **Im Papierkorb ansehen, was es war – vertagt** (26.08.2026, Wunsch von Marc: „erstmal noch nicht wichtig"). Die kleinen Vorschaubilder sind seit dem 26.08. wieder da (`GetThumbnail` sah nur in nicht-gelöschten Dateien nach). Gemeint ist der Schritt danach: eine Datei im Papierkorb **öffnen** und ansehen, bevor man „Delete forever" drückt.
|
||
- **Warum es überhaupt eine Idee ist:** Der Papierkorb ist die einzige Ansicht ohne Vorschau – ausgerechnet die, in der die Entscheidung unwiderruflich ist. Wer nach zwei Wochen aufräumt, sieht einen Namen wie `IMG_2481.jpg` und rät.
|
||
- **Wo es hängt:** an derselben Stelle wie eben das Vorschaubild. `Download` schlägt die Datei mit `is_deleted = false` nach (`handler.go:411`), der Vorschau-Dialog hängt außerdem an der Dateiliste, nicht an einem einzelnen Eintrag – er blättert zum nächsten Bild und kennt Umbenennen, Teilen, Löschen. Im Papierkorb wäre davon fast nichts sinnvoll.
|
||
- **Der Zuschnitt wäre deshalb enger:** nur ansehen, kein Blättern, keine Bearbeitung – und der Download-Pfad müsste für Gelöschtes gezielt geöffnet werden, nicht pauschal. Sonst ließe sich eine gelöschte Datei über ihre Kennung genauso herunterladen wie eine lebende, und der Papierkorb wäre kein Zwischenschritt mehr, sondern ein zweiter Ordner.
|
||
|
||
**OIDC-Durchgang (23.08.2026)**
|
||
|
||
Vor dem Produktivgang durchgesehen. Zwei Befunde, beide vorher unsichtbar:
|
||
|
||
1. ⚠️ **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/<id>` statt `/tags/<id>`) – 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 `<iframe>` mit dem eingebauten Betrachter des Browsers. Der ist gut – aber er gehört uns nicht: Wir können ihm nichts sagen und nichts von ihm erfahren.
|
||
- **Der Grund, der es getragen hat: das Telefon.** Chrome auf Android zeigt ein PDF in einem `<iframe>` nicht an, sondern bietet es zum Herunterladen an. Für ein Rechnungsarchiv, in das man beim Reklamieren im Laden hineinsieht, war die Vorschau dort also schlicht nicht vorhanden. Gemessen mit Handy-Kennung und 390 Pixeln Breite: vorher ein Download-Hinweis, jetzt die Seite.
|
||
- **Der zweite Grund: der Suchtreffer.** Die Volltextsuche findet ein Wort auf Seite 3 – der fremde Betrachter beginnt trotzdem auf Seite 1, und ein Suchwort lässt sich ihm nicht vorgeben. Jetzt wandert das Wort aus der Trefferliste bis in den Betrachter, der zur Seite springt und die Stelle einfärbt.
|
||
- **Beim Scan reicht das nicht – und das ist ausgerechnet der wichtigste Fall.** In einem gescannten Beleg steht im PDF kein einziges Wort, nur ein Bild; der erkannte Text liegt daneben in der Datenbank. Die Suche im Dokument meldete dort „keine Treffer" – bei genau dem Dateityp, für den die Texterkennung überhaupt gebaut wurde. Gefunden beim Prüfen am Telefon, nicht beim Bauen.
|
||
- Behoben über `GET /api/v1/files/{id}/text`: der erkannte Text, **in Seiten zerlegt**. Möglich wurde das erst dadurch, dass `clean()` den Seitenumbruch (`\f`) jetzt stehen lässt – vorher wurde er zu einem Zeilenumbruch, und damit war die Seitengrenze für immer verloren. Für die Volltextsuche ist er bedeutungslos, ein `tsvector` kennt keine Seiten; für den Betrachter ist er das Einzige, woran sich ablesen lässt, *wo* ein Wort steht.
|
||
- **Mehr als die Seite geht nicht.** Wo auf dem Blatt das Wort steht, weiß nur die Erkennung selbst, und ihre Koordinaten bewahren wir nicht auf. Der Betrachter springt deshalb an den Seitenanfang und markiert nichts – und sagt das auch, statt so zu tun: die Zählung liest sich dann „1 / 5 · page". Ein geratenes Wort einzufärben wäre schlimmer als keines.
|
||
- `extract_version` an `file_text` sagt, nach welcher Regel ein Eintrag entstanden ist; ältere werden beim Start erneut erkannt. Ohne so eine Marke fällt der Unterschied nie auf – der Text ist ja da, er kann nur weniger. Papierkorb bleibt außen vor.
|
||
- **Zwei Fehler, die erst der Browser gezeigt hat:**
|
||
1. **Zoomen sprang zurück zur Fundstelle.** Das Einfärben der Treffer scrollte selbst – und es läuft auch dann, wenn eine Seite bloß neu gezeichnet wird. Gemessen: von Seite 4 zurück auf Seite 3 bei jedem Zoomschritt. Gescrollt wird jetzt nur noch beim gezielten Sprung.
|
||
2. **„Auf die Breite einpassen" ergab 199 %.** Die Vorschau ist fast bildschirmbreit; ein A4-Blatt füllte sie und man sah das obere Drittel. Eingepasst wird jetzt gegen *beide* Kanten – am Telefon ergibt dieselbe Rechnung von selbst wieder die Breite, weil dort die Breite die engere Kante ist. Ein Sonderfall für kleine Bildschirme wäre nur eine zweite Wahrheit.
|
||
- **Zwei Nachbesserungen auf Marcs Hinweis** (23.08.2026):
|
||
1. **Das Markieren sah „komisch und unnatürlich" aus** – und das war mein Fehler, kein Darstellungsproblem. Ich hatte die Textebenen-CSS von PDF.js von Hand gekürzt („wir zeigen nur Seiten an"). Dabei fielen `--min-font-size`, `--text-scale-factor` und `scale(var(--min-font-size-inv))` weg. PDF.js misst damit, wie klein der Browser Schrift überhaupt noch darstellt – Chrome erzwingt eine Mindestgröße – und rechnet sie mit der Skalierung wieder heraus. Ohne dieses Paar bläht der Browser jede kleine Schrift auf: Die unsichtbaren Textstücke saßen **größer** da als die gemalten Buchstaben darunter, zu sehen erst beim Markieren als grobe, versetzte Kästen. Der Block wird jetzt wörtlich übernommen, mit einem Kommentar, der das Kürzen verbietet.
|
||
2. **Zwei gleich aussehende Knopfpaare.** Links blätterte durch Seiten, rechts durch Fundstellen – dasselbe Winkelzeichen, beide immer sichtbar. Das rechte Paar erscheint jetzt erst, wenn es Fundstellen gibt, und trägt einen Pfeil *mit Schaft*. Der Platz dafür bleibt trotzdem reserviert: Sonst rutschte das Suchfeld beim ersten Treffer um dreißig Pixel zur Seite, während man darin tippt.
|
||
- **Die Fundstelle war nicht zu lesen** (24.08.2026, Hinweis von Marc). Mit 0,95 Deckkraft lag ein satter oranger Block über dem Wort – ausgerechnet das gesuchte Wort war das einzige unleserliche auf der Seite. Jetzt 0,3 bzw. 0,55, der aktuelle Treffer zusätzlich mit einer Kante statt mit mehr Farbe.
|
||
- **Dass der Kasten nicht exakt sitzt, lässt sich nicht wegrechnen.** Die unsichtbare Schrift der Textebene ist eine *Ersatzschrift* – gemessen `sans-serif` 16,9 px für eine Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (hier 1,055), damit die Zeile als Ganzes passt; die einzelnen Buchstaben darin sitzen aber leicht anders als die gemalten. Gemessen: Kasten 93 px breit, 23 px hoch bei 17 px Zeilenhöhe. Genau deshalb arbeitet auch der Betrachter von PDF.js selbst mit 0,25 Deckkraft: Ein blasser Streifen darf ungenau sitzen, ein satter Block darf es nicht. Dasselbe gilt für das Markieren mit der Maus.
|
||
- **Suchfeld breiter** (240 statt 150, 24.08.2026, Wunsch von Marc): In der Mitte einer 1440 Pixel breiten Leiste sah ein Feld in Knopfbreite aus wie ein Restposten.
|
||
- **Suchfeld mittig** (23.08.2026, Wunsch von Marc). Dafür wurde die Leiste ein **Dreispaltenraster** statt einer Reihe mit Abstandhaltern: Zwei `flex:1`-Halter teilen sich nur den Rest neben der Steuerung, das Feld landete gemessen 79 Pixel daneben – und ein erster Ausgleichsversuch machte 142 daraus. Mit `1fr auto 1fr` sind es 0. Am Telefon bleibt es beim Umbruch; ein Raster ergäbe dort eine gequetschte Suche neben gequetschten Knöpfen.
|
||
- **Der Zoom war vom Zufall abhängig** (beim Nachmessen gefunden): Dasselbe Dokument ging mal mit 96 %, mal mit 10 % auf. Der Größenbeobachter meldet sich auch, während die Vorschau noch aufgeht und der Bereich **keine Breite** hat; daraus wurde die Untergrenze von 10 %, und weil danach keine Größenänderung mehr kam, blieb das Dokument dabei stehen. Messungen unter 50 Pixeln gelten jetzt als „noch nicht sichtbar" statt als Ergebnis – dieselbe Falle steckte in der Innenabstands- und der Leistenbreiten-Messung. Dreimal hintereinander nachgemessen: stabil.
|
||
- **Nachgeladen, nicht mitgeliefert.** PDF.js wiegt 435 KB im Bündel und 1,3 MB im Arbeiter. Als Teil des Hauptbündels lud das jeder Besuch mit, auch wer nie ein PDF öffnet – gemessen 386 → 821 KB. Jetzt ein eigenes Stück, geholt beim ersten PDF. Der Service Worker legt es bewusst **nicht** vorab in den Cache: Offline wäre nichts gewonnen, weil die Dokumente selbst nicht mitgecacht werden. Ein Betrachter ohne Datei nützt nichts.
|
||
- **Kein CDN.** Arbeiter und die 14 Standardschriften kommen vom eigenen Server – die Sicherheitsrichtlinie erlaubt nur `'self'`, und eine Cloud für private Unterlagen soll beim Öffnen eines Dokuments keine fremde Adresse anfragen. Die Schriften werden beim Bauen aus `node_modules` kopiert statt eingecheckt (800 KB fremde Binärdateien gehören nicht in den Quelltext).
|
||
- **Was er absichtlich nicht kann:** Anmerkungen, Formulare, Unterschriften. Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der schreiben kann, wäre ein Widerspruch dazu.
|
||
- Randfälle gemessen: 60 Seiten → höchstens 6 gleichzeitig im Speicher (bei A4 und doppelter Pixeldichte wären 60 Leinwände rund eine halbe Gigabyte); passwortgeschützt und kaputt → je eine klare Meldung statt einer leeren Fläche.
|
||
|
||
- ✅ **OCR-Arbeiter und Volltextsuche** (`internal/documents`, Migration 000014, 23.08.2026). Damit ist der Punkt erledigt, der die Entscheidung überhaupt trug: Ein Beleg namens `scan_0042.pdf` ist jetzt über seinen *Inhalt* auffindbar.
|
||
- **Das Original wird nicht angefasst.** ocrmypdf schreibt normalerweise ein neues PDF mit eingebetteter Textebene – täten wir das, änderten sich Größe und Prüfsumme, die Versionshistorie bekäme einen Eintrag, den niemand ausgelöst hat, und WebDAV-Clients hielten die Datei für fremdverändert und lüden sie erneut. Für ein Archiv ist eine unangetastete Datei mehr wert als ein durchsuchbares PDF. Der Text liegt deshalb daneben in `file_text`, die Datei bleibt Byte für Byte, wie sie hochgeladen wurde.
|
||
- **Erst nachsehen, dann erkennen.** Hat das PDF bereits eine Textebene, liest `pdftotext` sie in Millisekunden aus – exakt so, wie sie gesetzt wurde. OCR wäre hier nicht nur langsamer, sondern *schlechter*: Es ersetzte fehlerfreien Text durch geratenen. Gemessen: Textebene 89 ms für 18 000 Zeichen, dieselbe Seite als Scan 66 s. Die Schwelle liegt bei 120 Zeichen je Seite – nicht bei „mehr als null", weil fast jeder Scan eine Kopfzeile oder ein Wasserzeichen als echten Text trägt und sonst als lesbar durchginge.
|
||
- **Warteschlange in der Datenbank, nicht im Speicher.** Vorschaubilder entstehen in einer Goroutine; geht eine verloren, fällt es sofort auf und sie ist in Millisekunden neu gemacht. Verlorener Text fällt *nie* auf – man sucht später vergeblich und weiß nicht, warum. `ocr_jobs` mit `FOR UPDATE SKIP LOCKED`, drei Versuchen und Fehlertext; ein beim Neustart unterbrochener Auftrag wird wieder aufgegriffen, statt für immer auf „running" stehen zu bleiben.
|
||
- **Zwei Fehler, die nur der echte Lauf gezeigt hat** – beide unsichtbar in Bau und Test:
|
||
1. `/dev/null` als Ausgabedatei lehnt ocrmypdf mit Exit 3 ab. Jetzt eine Wegwerfdatei, die sofort wieder verschwindet.
|
||
2. Tesseract vermisst beim allerersten Aufruf die OpenCL-Geräte und schreibt dabei einen langen Diagnoseblock, an dem ocrmypdfs Parser scheitert („Tesseract failed to report available languages"). Damit schlug **das erste Dokument nach jedem Neustart eines frischen Containers** fehl – gemessen: Versuch 1 Exit 3, Versuch 2 erfolgreich. Der Wiederholungszähler fing es auf, aber ein einbaubarer Fehlschlag gehört nicht in den Log. Die Vermessung passiert jetzt einmalig beim Bauen des Images.
|
||
- **Volltext und Namenssuche in *einem* Zweig der Abfrage**, nicht in zweien: Eine Datei, deren Name und Inhalt beide passen, käme sonst doppelt in der Trefferliste an. `matched_content` sagt, welche Hälfte den Treffer verursacht hat; die Reihenfolge ist Ordner → Namenstreffer → Inhaltstreffer.
|
||
- ✅ **Treffer springt nur zum Ordner, öffnet nicht mehr** (23.08.2026, Wunsch von Marc). Bis dahin klappte beim Klick zugleich die Vorschau auf – ein Klick in einer Trefferliste ließ unangekündigt einen Betrachter über den Bildschirm fahren, und wer nur nachsehen wollte, *wo* die Datei liegt, musste ihn erst wieder schließen. Geöffnet wird jetzt dort, wo man es erwartet: per Doppelklick auf die Datei.
|
||
- Damit der Sprung etwas taugt, wird die Datei **kurz umrandet und in den sichtbaren Bereich geholt** – in einem Ordner mit fünfzig Einträgen suchte man sonst von vorn. Die Markierung verschwindet nach 2,6 Sekunden von selbst; ein Rahmen, der bleibt, sähe irgendwann wie ein Zustand aus statt wie ein Hinweis.
|
||
- `boxShadow` statt eines dickeren Rahmens: Ein zweiter Pixel Rand ließe die Kachel wachsen und die ganze Reihe verrutschen.
|
||
- ✅ **Fundstelle über zwei Zeilen** (23.08.2026). Zuvor eine einzelne, mit „…" abgeschnittene Zeile – die endete oft, bevor klar wurde, worum es geht, und das Suchwort selbst konnte darin fehlen. Jetzt `line-clamp: 2`, das Feld von 380 auf 460 verbreitert und `MaxWords` in `ts_headline` von 18 auf 30 erhöht, damit die zweite Zeile auch voll wird. Links eine blasse Linie in der Farbe des Treffers: `--color-border` war dort **gemessen unsichtbar** (rgb(51,65,85) auf fast demselben Grund der hervorgehobenen Zeile).
|
||
- ✅ **Symbolspalte in der Trefferliste** (23.08.2026, beim Nachsehen aufgefallen). Das Ordnersymbol ist 24 Pixel breit, die Dateisymbole 18 – Ordnernamen begannen dadurch weiter rechts als Dateinamen, in einer gemischten Liste stand der Text also in zwei Spalten. Gemessen 107 gegen 95; jetzt alle bei derselben Kante.
|
||
- **Die Fundstelle wird nie als HTML eingesetzt.** `ts_headline` markiert mit `[[hl]]` statt mit `<b>`, und die Oberfläche zerlegt die Zeichenkette daran zu Textknoten. Der Text stammt aus einem hochgeladenen Dokument – als HTML eingesetzt wäre ein `<script>` darin eine Lücke.
|
||
- **Der Bestand wird beim Start nachgetragen.** Ohne das bliebe unauffindbar, was vor dem Einbau hochgeladen wurde – am Anfang also alles.
|
||
- ✅ **Gegen das Vollschreiben der Platte gesichert** (23.08.2026, auf Marcs Nachfrage – die Platte stand bei 85 %). OCR ist das einzige Stück dieser Anwendung, das ohne Zutun eines Menschen Platz verbraucht: Es arbeitet eine Warteschlange ab, solange etwas darin liegt. Drei Maßnahmen, alle gemessen:
|
||
1. **Kein Ergebnis-PDF mehr.** `--output-type none` schreibt 0 Bytes und liefert den Sidecar trotzdem vollständig. Vorher entstand bei jedem Scan ein rasterisiertes PDF, das ein Vielfaches der Vorlage wiegen kann – nur um sofort gelöscht zu werden.
|
||
2. **Keine Kopie der Eingabe.** Neue optionale Schnittstelle `storage.LocalPather`: Bei lokaler Ablage arbeiten die Werkzeuge direkt auf der Datei (nur lesend), statt sie erst vollständig zu duplizieren. Bei einem 300-MB-Scan waren das 300 MB zusätzlich.
|
||
3. **Platzbremse.** Unter 2 GiB frei pausiert der Arbeiter, statt weiterzuarbeiten – geprüft *vor* dem Zugreifen auf einen Auftrag, sonst verbrauchte er einen Versuch für etwas, das nicht an ihm lag. Die Warnung steht einmal je Zustandswechsel im Log, nicht alle fünf Sekunden.
|
||
- Dazu ein **Aufräumen beim Start**: Wird der Container hart beendet, läuft kein `defer` mehr und die angefangene Datei bleibt liegen. Entfernt wird nur, was diese Anwendung selbst angelegt hat (Präfix `ocr-`).
|
||
- Gemessen bei der Erkennung eines 4-MB-Scans: `/data/tmp` bleibt durchgehend bei **8 KB**, danach leer. Vorher wären es rund 8 MB gewesen.
|
||
- Preis: rund 330 MB im Image für ocrmypdf, tesseract (deu+eng) und poppler. `OCR_ENABLED=false` schaltet ab; fehlen die Programme, tut es der Server beim Start von selbst und läuft ohne Volltext weiter.
|
||
- Stand abfragbar über `GET /api/v1/admin/ocr` (pending/running/done/failed/indexed). **Noch ohne Oberfläche** – gehört in den anstehenden UI-Durchgang.
|
||
- ✅ **Etiketten und Belegdatum** (`internal/tags`, Migration 000015, 23.08.2026). Das dritte Ordnungsmittel neben Ordnern und Volltext – und das einzige, das beantwortet, **wofür** ein Dokument gebraucht wird. „Absetzbar" steht in keiner Rechnung; das ist eine Einschätzung des Menschen.
|
||
- **Der Fall, an dem Ordner scheitern** und der bei Marc der Normalfall ist: Der Kaufbeleg eines Notebooks ist zugleich Garantienachweis *und* absetzbar. In einen Ordner passt er einmal, Etiketten trägt dieselbe Datei beliebig viele.
|
||
- **Prinzipal-Modell wie bei Dateien:** Ein Etikett gehört einem Konto **oder** einer Gruppe. Eine Datei kann nur Etiketten ihres eigenen Prinzipals tragen – das ist die Regel, die Gruppenordner zusammenhält: Hätte dort jeder seine privaten Etiketten, sortierte jeder dieselben Dateien für sich neu ein und niemand sähe die Arbeit des anderen.
|
||
- **Namen sind je Prinzipal eindeutig, ohne Rücksicht auf Groß-/Kleinschreibung.** Sonst stehen nach drei Monaten „Stadtwerke", „stadtwerke" und „STADTWERKE" nebeneinander – genau der Zerfall, den Etiketten gegenüber freien Ordnernamen verhindern sollen.
|
||
- **`document_date` an der Datei**, nicht als Etikett: Die Garantiefrist läuft ab dem Kauf, und in die Steuererklärung 2026 gehört, was 2026 gekauft wurde – auch wenn der Beleg im März 2027 eingescannt wird. `created_at` wäre für beides das falsche Datum, und zwar *unauffällig* falsch. Die Etikettenansicht sortiert danach absteigend, ohne Datum ans Ende.
|
||
- **Nichts davon wird in die Datei geschrieben** – dieselbe Regel wie beim erkannten Text. Das Dokument bleibt Byte für Byte, wie es hochgeladen wurde.
|
||
- **Zwei Fehler beim Prüfen gegen die laufende Instanz gefunden**, beide in meinem eigenen Code:
|
||
1. Ein ungültiges Belegdatum hatte die Etiketten **trotzdem schon geändert** – Datum und Etiketten liefen als zwei Schritte. Genau das, was der Kommentar daneben zu vermeiden behauptete. Jetzt eine Transaktion, und das Datum wird geprüft, bevor irgendetwas geschrieben wird; nachgestellt und bestätigt: nach dem abgelehnten Aufruf stehen beide Zählstände unverändert.
|
||
2. Das Datumsfeld im Dialog blieb **leer**, obwohl ein Datum gesetzt war: Der Server liefert `2026-01-15T00:00:00Z`, ein `<input type="date">` versteht nur `2026-01-15` und zeigt sonst kommentarlos nichts an. Im Bildschirmfoto aufgefallen.
|
||
- Etiketten stehen in der Seitenleiste unter den Favoriten (Farbpunkt + Zählstand), gesetzt wird über das Kontextmenü einer Datei; die Ansicht `/tags/<id>` ist verlinkbar und liegt im Verlauf des Browsers.
|
||
- ✅ **Farben und Bearbeiten** (23.08.2026, Wunsch von Marc): Beim Anlegen erscheint eine Farbwahl, sobald man tippt – vorher nicht, sonst ist sie nur Fläche. Die nächste Farbe wird vorgewählt, damit zwei nacheinander angelegte Etiketten nicht zufällig gleich aussehen. **Rechtsklick** auf ein Etikett in der Seitenleiste öffnet Umbenennen, Farbe und Löschen; beim Löschen steht ausdrücklich dabei, dass die Dateien bleiben.
|
||
- ✅ **Änderungen wirken sofort überall** (`tagsChangedEvent`). Vorher lud die Seitenleiste ihre Zählstände nur beim Ansichtswechsel, und nach einem Umbenennen stand an den Kacheln noch der alte Name. Ein Ereignis statt Zustand durch drei Ebenen zu reichen: Der Dialog steckt tief im Dateibrowser, die Seitenleiste hängt am Shell.
|
||
- ✅ **Aufkleber neben der Größe statt darunter** (Vorschlag von Marc). Vorher wuchs jede etikettierte Kachel um eine Zeile – in einem Raster sieht man das sofort. Gemessen: jetzt alle 157/159 px, mit und ohne Etikett. Dazu nur *ein* Aufkleber plus „+n": Bei zweien schrumpften beide auf „gar…" und „steue…", und zwei unlesbare sagen weniger als einer lesbarer.
|
||
- ✅ **Kopfleiste auf schmalen Schirmen repariert** (23.08.2026, Marcs Bildschirmfoto vom Handy). Sie war eine Zeile ohne Umbruch und hat ihren Inhalt **gequetscht statt umgebrochen**: Bei 390 px stand die Brotkrume als einzelner Strich da, das Suchfeld schob sich über die Sortierauswahl, der Ansichts-Umschalter fehlte ganz und der Nutzerbereich war abgeschnitten. Nachgestellt und bestätigt.
|
||
- Unter 720 px bricht die Leiste jetzt auf zwei Zeilen um: Titel oben, Bedienelemente darunter über die volle Breite. **Nichts wird versteckt** – es steht nur untereinander. Gemessen: 8 Bedienelemente vollständig sichtbar, nichts abgeschnitten, kein waagerechtes Scrollen; auf dem Desktop unverändert 56 px einzeilig.
|
||
- Die feste Breite des Suchfelds (220 px) war die eigentliche Ursache – es kann jetzt schrumpfen, und seine Trefferliste passt sich der Schirmbreite an.
|
||
- ✅ **Rechtsklick öffnet erst ein Menü** (23.08.2026, Wunsch von Marc). Vorher sprang der Bearbeiten-Dialog sofort auf – das überrumpelt, und bei Dateien und Ordnern verhält sich der Rechtsklick anders. Jetzt „Edit…" und „Delete" wie überall sonst; Löschen fragt zurück und nennt die Zahl der betroffenen Dateien („The tag is removed from 1 file. The files themselves stay where they are — nothing is deleted."). Der Löschknopf ist dafür aus dem Bearbeiten-Dialog verschwunden: Ein Dialog, in dem „Save" und „Delete" nebeneinander liegen, lädt zum Fehlgriff ein.
|
||
- ✅ **PWA-Durchgang** (23.08.2026). Vier Befunde, alle nur in der *installierten* App sichtbar und deshalb bisher übersehen:
|
||
1. **Kein `viewport-fit=cover`** – der Inhalt stand neben der Kamera-Aussparung eingerahmt statt über den ganzen Schirm.
|
||
2. **Keine sicheren Bereiche.** Mit (1) reicht die Seite bis unter Statusleiste und Wischbalken; ohne Abstände säße der Speicherbalken der Seitenleiste **hinter dem Wischbalken** und wäre nicht mehr erreichbar. `env(safe-area-inset-*)` nur unter `@media (display-mode: standalone)` – im Browser übernimmt die Adressleiste das schon, und auf Geräten ohne Aussparung ist der Wert ohnehin 0.
|
||
3. **`theme-color` stand fest auf dem Indigo der Voreinstellung**, während das Manifest die eingestellte Primärfarbe nannte. In der installierten App war die Statusleiste damit in einer Farbe, die sonst nirgends vorkommt. Jetzt zieht sie mit dem Theming mit; gemessen: `#1f6b5c` in beiden.
|
||
4. **Gummiband-Scrollen** am Anschlag – ohne Adressleiste, die mitfährt, sieht das nach einem Fehler aus. `overscroll-behavior-y: none`.
|
||
- Geprüft und in Ordnung: Service Worker aktiv, `apple-touch-icon` zeigt auf das **hochgeladene** Symbol, Manifest kommt aus der Datenbank.
|
||
- **Ehrlich zum Offline-Betrieb:** Die App startet ohne Netz und zeigt die Anmeldeseite – kein Fehlerbildschirm, aber auch nicht mehr. Jede Datei und jede Liste kommt vom Server. Ein Zwischenspeicher für Dateilisten wäre bei einem Dateiverwalter eher schädlich: Man sähe einen Bestand, der nicht mehr stimmt.
|
||
- ✅ **Sammel-Download als ZIP** (`internal/files/handler_zip.go` + `store_zip.go`, 23.08.2026). Damit ist die Kette vollständig: **filtern → alles holen → weitergeben**. Ohne den letzten Schritt wären „alle Belege 2026 an den Steuerberater“ vierzig Einzeldownloads gewesen.
|
||
- Drei Wege: Mehrfachauswahl (Strg/Umschalt wie im Dateimanager), Rechtsklick auf einen Ordner (mit Unterordnerstruktur), und „Download all as ZIP“ in der Etikettenansicht.
|
||
- **Gestreamt, nicht gesammelt.** Das Archiv entsteht direkt in der Antwort und liegt nie ganz auf der Platte – bei einem 5-GB-Ordner wären das sonst 5 GB Bedarf auf einem Speicher, der bei 87 % steht, und der Nutzer sähe minutenlang nichts. Der Preis: Sobald das erste Byte draußen ist, steht der Statuscode auf 200. Deshalb wird alles, was scheitern kann – Rechte, Auswahl, Grenzen – **vor** dem ersten Byte geprüft.
|
||
- **POST über ein Formular**, nicht `fetch`: Nur eine Navigation behandelt der Browser als Download; `fetch` müsste das ganze Archiv erst in den Speicher holen. Ein Formular kann aber keine Header setzen – deshalb nimmt `ValidateCSRF` das Token jetzt auch als Formularfeld an. Am Schutz ändert das nichts, verglichen wird weiterhin gegen das Cookie.
|
||
- **Der Umfang steckt in der Abfrage**, nicht in einer Prüfung danach: Eine erratene Kennung fällt still heraus, statt im Archiv zu landen. Geprüft: `401` ohne Anmeldung, `403` ohne Token, `404` bei erfundener Kennung.
|
||
- Bereits gepackte Formate (.zip, .deb, .jpg, .pdf …) werden nur **gelagert**. Ein 116-MB-Paket durch Deflate zu schicken kostet Sekunden CPU und spart nichts.
|
||
- Namenskollisionen werden zu „name (2)“ – ein Archiv mit zwei gleichen Einträgen entpacken manche Programme kommentarlos zu einer Datei, die zweite wäre still verloren. **Beim Prüfen nachgebessert:** `path.Ext` sieht bei `paket.tar.gz` nur `.gz`, die Zahl landete als „paket.tar (2).gz“ mittendrin.
|
||
- ✅ **Escape hebt die Auswahl auf** (23.08.2026, Wunsch von Marc) – aber nur, wenn sonst nichts offen ist. Vorschau und Dialoge haben dieselbe Taste, und die soll zuerst *die* schließen; andernfalls stünde man nach dem Schließen eines Dialogs ohne Auswahl da, ohne zu wissen, warum. Ebenso nicht, während man in einem Feld tippt – dort heißt Escape „Eingabe verwerfen".
|
||
- ⚠️ **Beim Prüfen gefunden und behoben:** Der erste Entwurf ließ auch einen *einfachen* Klick auswählen, sobald schon etwas markiert war. Das kollidiert mit dem Doppelklick – dessen erster Klick löste dann eine Auswahl aus. Gemessen: aus drei Ausgewählten wurden vier, und die Vorschau blieb zu. Jetzt wählt nur noch das Kästchen bzw. Strg/Umschalt; der Doppelklick tut wieder genau eine Sache. Danach nachgemessen: Auswahl bleibt bei 2, Vorschau öffnet, erstes Escape schließt nur sie, zweites hebt die Auswahl auf.
|
||
- UTF-8-Bit im Eintragskopf gesetzt, sonst wird „Rechnung Müller.pdf“ im Windows-Explorer zu „Rechnung Müller.pdf“.
|
||
- Offen: PDF.js-Viewer, Korrespondent, Inbox-Ordner mit Auto-Verarbeitung.
|
||
|
||
> ⚠️ **Offene Entscheidung vor Beginn: überschneidet sich mit InkNote.**
|
||
> InkNote hat bereits einen PDF-Import mit Betrachter, Markierungen und einer
|
||
> Textebene – ausdrücklich als Grundlage fürs Rechnungsarchiv gebaut und
|
||
> produktiv im Einsatz.
|
||
>
|
||
> Wird Phase 6 wie geplant umgesetzt, existiert dieselbe Funktion zweimal: zwei
|
||
> Ablagen, zwei Suchen, und bei jedem Dokument die Frage, wo es liegt. Das ist
|
||
> schlechter als jede der beiden Varianten für sich.
|
||
>
|
||
> **Korrektur zweier Annahmen (23.08.2026, im InkNote-Code nachgesehen):**
|
||
>
|
||
> 1. **InkNote ist nicht Zero-Knowledge.** Notizen, Anhänge und der Textindex
|
||
> liegen AES-256-GCM-verschlüsselt auf der Platte, aber mit dem Schlüssel aus
|
||
> der `.env` **des Servers** (`ENCRYPTION_KEY`, `backend/server.js`). Der
|
||
> Server kann alles lesen; im Code steht es sinngemäß so, dass eine Sicherung
|
||
> genau so viel wert ist wie ihre `.env`. Client-seitige Verschlüsselung gibt
|
||
> es nicht. Damit ist **serverseitiges OCR kein Widerspruch** zu InkNotes
|
||
> Modell – der vermeintliche Blocker existiert nicht.
|
||
> 2. **InkNotes Suche findet keine Scans.** Der Textindex entsteht aus
|
||
> `page.getTextContent()`, also der **Textebene** des PDFs. Eine abfotografierte
|
||
> oder eingescannte Rechnung hat keine – sie bleibt unauffindbar. Genau diese
|
||
> Dokumente sind aber der Grund, warum man OCR einbaut.
|
||
>
|
||
> **Empfehlung nach dieser Prüfung: Variante B – die Dokumente gehören nach
|
||
> Aether**, InkNote behält Notizen, Zeichnen und das Annotieren eines PDFs, mit
|
||
> dem man gerade arbeitet.
|
||
>
|
||
> - **Scans.** Der ausschlaggebende Punkt: Was InkNote nicht lesen kann, findet
|
||
> Aether nach OCR. Papierbelege sind der Normalfall eines Rechnungsarchivs.
|
||
> - **Hineinkommen.** Belege sind Dateien. Aether hat WebDAV, Ordner-Upload und
|
||
> Drag & Drop; in InkNote hängt man sie an eine Notiz.
|
||
> - **Nicht verlieren.** Ein Archiv braucht Haltbarkeit: Prüfsummen, Versionen,
|
||
> Papierkorb und GC sind in Aether gebaut und geprüft.
|
||
>
|
||
> **Übergang ohne Umzug:** Aether kann heute schon als Ablage dienen – die
|
||
> Namenssuche und die PDF-Vorschau stehen. Wer ab jetzt dort einsortiert,
|
||
> bekommt OCR später als Ergänzung statt als Migration.
|
||
>
|
||
> Die Alternativen bleiben der Vollständigkeit halber: **A)** Dokumente bleiben
|
||
> in InkNote, Phase 6 schrumpft auf „PDFs ablegen und ansehen" – verzichtet auf
|
||
> Scans in der Suche. **C)** Beides bauen – nur bei bewusst getrennten Zwecken
|
||
> sinnvoll, sonst die schlechteste Variante.
|
||
>
|
||
> Siehe auch die Plattform-Überlegung: getrennte Apps mit gemeinsamem Login
|
||
> statt Verschmelzung.
|
||
|
||
**Phase 7 – Office** ✅ (24.08.2026)
|
||
|
||
- ✅ **WOPI-Host im Backend** (`internal/office`, 24.08.2026). Collabora läuft, der Kreis ist gemessen: tippen → Strg+S → v1 auf v3, und der getippte Text steht im heruntergeladenen ODT.
|
||
- **Ein Adapter für beide Anbieter.** Collabora und OnlyOffice sprechen dieselbe Schnittstelle; umgeschaltet wird über `OFFICE_PROVIDER`. Zwei Integrationen wären dieselbe Sache zweimal gepflegt – und die zweite immer die schlechter getestete.
|
||
- **Drei Adressen für drei Richtungen.** Das war der Teil, der beim Aufbau dreimal gescheitert ist: `OFFICE_URL` (Backend → Office), `OFFICE_PUBLIC_URL` (Browser → Office) und `OFFICE_WOPI_URL` (Office → Aether). Sie sind selten gleich: Im Compose-Netz heißt der Server `collabora`, der Browser braucht einen auflösbaren Namen, und aus dem Office-Container heraus ist „localhost" er selbst. Der veröffentlichte Port `127.0.0.1:8090` taugt dafür ausdrücklich **nicht** – er ist auf loopback gebunden (gemessen als `ECONNREFUSED` in Collaboras Log).
|
||
- **Die eigene Sicherheitsrichtlinie war der erste Stolperstein.** `frame-src` fällt auf `default-src 'self'` zurück; der Browser blockte den Rahmen, bevor irgendeine Anfrage hinausging. Die Ausnahme entsteht jetzt aus der Konfiguration und nennt genau einen Ursprung – kein Platzhalter, die Ausnahme soll so schmal sein wie der Anlass.
|
||
- **Das Token ist an genau eine Datei gebunden** und HMAC-signiert (abgeleiteter Schlüssel, nicht derselbe wie für die Sitzungscookies). Zwölf Stunden Laufzeit: Das ist die Zeit, die ein Dokument offen stehen darf, bevor das Speichern scheitert – kürzer hieße, dass eine Mittagspause die Änderungen kostet.
|
||
- **Die Rechte werden bei jedem WOPI-Aufruf neu aufgelöst**, nicht aus dem Token übernommen. Eine zurückgezogene Freigabe wirkt damit sofort und nicht erst, wenn das Token abläuft.
|
||
- **Ein Office-Server ist kein Sonderweg an der Datenintegrität vorbei.** Das Zurückschreiben läuft über `files.ReplaceContent` und hält dieselben vier Regeln wie jeder Upload: atomar schreiben, Prüfsumme neu berechnen, Kontingent beachten, bei eingeschalteter Versionierung die alte Fassung aufheben.
|
||
- **Der Rahmen bekommt das Token per Formular, nicht in der Adresse.** Eine URL landet im Verlauf des Browsers, im Log des Proxys und im Referrer; ein POST-Rumpf nicht.
|
||
- Weitere Fallen, alle beim Testen gefunden und im README festgehalten: `ssl.termination=true` ohne HTTPS davor lässt Collabora eine `wss://`-Verbindung verlangen, die es nicht gibt (Editor lädt, Dokument bleibt weiß); eine unkodierte `WOPISrc` weist Collabora zurück; `coolmount` braucht `CAP_SYS_ADMIN`, gelöst über `--o:mount_jail_tree=false` statt zusätzlicher Rechte.
|
||
- Der Container läuft nur mit `--profile office`. Wer kein Office will, soll nicht 1 GB Arbeitsspeicher dafür abgeben, nur weil die Zeilen im Compose stehen.
|
||
|
||
WOPI-Host im Backend + `OfficeProvider`-Interface. Collabora **und** OnlyOffice als Provider, per `.env` umschaltbar. Optional: nativer OnlyOffice-Adapter für maximale Treue.
|
||
|
||
**Phase 8 – Härtung & Extension-Points**
|
||
Security-Audit, vollständige Tests, Observability (strukturiertes Logging, Metriken), 2FA, definierte Plugin-/Hook-Schnittstellen.
|
||
|
||
---
|
||
|
||
## 6. Sicherheits-Checkliste
|
||
|
||
**Allgemein**
|
||
- Passwort-Hashing **Argon2id** (kein MD5/SHA).
|
||
- **Path-Traversal**-Schutz bei jedem Dateipfad (kanonisieren, gegen Root prüfen).
|
||
- Session-Cookies: `httpOnly`, `Secure`, `SameSite=Lax/Strict`; CSRF-Schutz.
|
||
- Rate-Limiting auf Login & API; Brute-Force-Schutz (Verzögerung/Lockout nach Fehlversuchen).
|
||
- Security-Header (CSP, X-Content-Type-Options, X-Frame-Options) setzt die App selbst;
|
||
**HSTS gehört auf den externen Proxy**, nicht in die Anwendung.
|
||
- Autorisierung pro Request (RBAC: User/Gruppe/Owner), nicht nur Authentifizierung. Jede Aktion serverseitig prüfen, nie dem Frontend vertrauen.
|
||
- Upload-Validierung: Größenlimits, MIME-Prüfung, kein Ausführen; Uploads außerhalb des Webroots, mit nicht-ausführbaren Rechten.
|
||
- Secrets nur via `.env`/Compose, nie im Image/Repo.
|
||
- Optional: Verschlüsselung at-rest.
|
||
- Abhängigkeiten scannen (`govulncheck`, `npm audit`).
|
||
|
||
**Admin-Konto besonders härten**
|
||
- Admin-Routen mit eigener AuthZ-Schicht; Admin-Status serverseitig bei JEDER Admin-Aktion prüfen (keine reine UI-Verschleierung).
|
||
- Kein Default-/Standardpasswort. Erst-Admin per Seed/CLI mit erzwungenem starken Passwort.
|
||
- Strengeres Rate-Limit + Lockout auf Admin-Login; Admin-Aktionen ins Audit-Log (wer, was, wann).
|
||
- Privilege-Escalation aktiv verhindern: ein normaler User darf sich niemals selbst zum Admin machen (Rollen nur durch bestehenden Admin änderbar, serverseitig erzwungen).
|
||
- 2FA für Admin (Phase 8) fest vorgesehen.
|
||
|
||
**Gegen gängige Angriffsmethoden prüfen (OWASP Top 10)**
|
||
- SQL-Injection → ausschließlich parametrisierte Queries / Prepared Statements.
|
||
- XSS → Output-Encoding, strikte CSP, kein `dangerouslySetInnerHTML` mit User-Input.
|
||
- CSRF → Token + SameSite-Cookies.
|
||
- IDOR/Broken Access Control → Objektzugriffe immer gegen den Eigentümer/Rechte prüfen.
|
||
- SSRF (relevant bei OIDC/WOPI-Callbacks) → ausgehende Ziele auf Allowlist beschränken.
|
||
- Insecure Deserialization, Open Redirects, Header-Injection → bewusst testen.
|
||
- Vor jedem Phasen-Abschluss: kurzer Security-Pass gegen diese Liste, plus `govulncheck`/`npm audit`.
|
||
|
||
---
|
||
|
||
## 7. Test-Strategie
|
||
|
||
- **Backend**: Unit-Tests pro Domänen-Paket; Integrationstests mit Test-Postgres (testcontainers); WebDAV-Endpoint mit echtem Client testen.
|
||
- **Frontend**: Komponenten-Tests (Vitest + Testing Library); kritische Flows als E2E (Playwright): Login, Upload, Share, Suche.
|
||
- **Sicherheit**: Path-Traversal-, AuthZ- und Rate-Limit-Tests explizit.
|
||
- CI: Tests + Lint + Build bei jedem Commit.
|
||
|
||
---
|
||
|
||
## 8. Plugin-/Extension-Strategie (bewusst spät)
|
||
|
||
Nicht von Anfang an. Wenn der Core steht:
|
||
- **Server-Seite**: Event-Bus mit Hooks (`onFileUploaded`, `onUserCreated`, …); Plugins registrieren Handler. Keine Plugin-DB-Schreibrechte ohne API.
|
||
- **Frontend-Seite**: definierte Slots (z. B. Datei-Kontextmenü, Sidebar) zum Einhängen.
|
||
- **Grenze**: Kernfeatures (Dateien, Auth, Sharing, Dokumente) bleiben im Core und schnell – Plugins nur für „extra".
|
||
|
||
---
|
||
|
||
## 9. CLAUDE-CODE MASTER-PROMPT (kopierbar)
|
||
|
||
> Lege diesen Text als ersten Auftrag in Claude Code ein. Er ist bewusst so gebaut,
|
||
> dass Claude Code **phasenweise** arbeitet und nicht versucht, alles auf einmal zu bauen.
|
||
|
||
```
|
||
Du baust mit mir zusammen eine self-hosted Cloud-Lösung (schlanke, schnelle
|
||
Alternative zu Nextcloud). Lies zuerst die Datei cloud-projekt-plan.md im Repo
|
||
vollständig – sie enthält Stack, Architektur und die Phasen-Roadmap. Halte dich daran.
|
||
|
||
STACK (verbindlich):
|
||
- Backend: Go (net/http + chi Router), modularer Monolith.
|
||
- Frontend: React + TypeScript + Vite + Tailwind, gebaut als PWA.
|
||
- DB: PostgreSQL mit Migrations (z. B. golang-migrate).
|
||
- Storage: Interface mit LocalFS-Implementierung (S3-ready, aber jetzt nur LocalFS).
|
||
- Auth: Session-Cookies (KEINE JWTs). Username + Passwort, Argon2id.
|
||
- Deployment: Docker Compose, Multi-Stage-Build. Secrets nur über .env.
|
||
|
||
GETROFFENE ENTSCHEIDUNGEN (nicht in Frage stellen):
|
||
- KEINE E-Mail-Adressen. Dies ist eine private, nicht öffentliche Cloud. Kein E-Mail-Feld,
|
||
keine E-Mail-Verifizierung, kein Passwort-Reset per Mail. User identifizieren sich per Username.
|
||
- Passwort-Reset NUR durch den Admin (Admin kann User anlegen/bearbeiten/löschen + Passwort setzen).
|
||
- DATENINTEGRITÄT IST OBERSTE PRIORITÄT (ab Phase 1, nicht verhandelbar): Dateien dürfen durch
|
||
die Cloud niemals beschädigt werden oder verloren gehen. Verbindlich:
|
||
* Atomare Schreibvorgänge: in Temp schreiben -> fsync -> atomares rename. Nie in-place überschreiben.
|
||
* SHA-256-Prüfsumme pro Datei: beim Upload speichern, beim Download verifizieren.
|
||
* Nie hart löschen / nie blind überschreiben: Soft-Delete (Papierkorb) + Versionen; echtes
|
||
Löschen der Bytes nur verzögert per Garbage-Collection.
|
||
* DB-Metadaten erst nach erfolgreichem Blob-Write committen, sonst sauber zurückrollen.
|
||
* Pflicht-Tests: Upload-Abbruch/Crash-Simulation, Checksum-Verifikation, Soft-Delete-Recovery.
|
||
- THEMING: Der Admin kann global das Branding setzen (Logo, Cloud-Name, freie Farbpaletten:
|
||
main/akzent/… frei wählbar, Styling). Ein normaler User darf NUR seine EIGENE Farbpalette für sich
|
||
selbst wählen, sonst keine Theming-Rechte. Umsetzung über CSS-Variablen / Design-Tokens
|
||
(global vom Admin, pro-User-Override nur fürs eigene Konto). Feature gehört in Phase 4.
|
||
- ADMIN-SICHERHEIT: Das Admin-Konto und alle Admin-Funktionen müssen besonders gehärtet sein.
|
||
Admin-Status serverseitig bei JEDER Admin-Aktion prüfen (nicht nur UI). Kein Default-Passwort,
|
||
strengeres Rate-Limit/Lockout am Admin-Login, Privilege-Escalation verhindern (User kann sich
|
||
nie selbst zum Admin machen), Admin-Aktionen ins Audit-Log. Details in Abschnitt 6 des Plans.
|
||
- Office (in späterer Phase): Backend wird WOPI-Host; sowohl Collabora als auch OnlyOffice
|
||
müssen als Provider funktionieren, per .env umschaltbar (OFFICE_PROVIDER). 2FA und OCR/Paperless
|
||
ebenfalls erst NACH dem Grundgerüst (spätere Phasen).
|
||
Jetzt zählt: stabiles Gerüst + Grundfunktionen zuerst, Features danach.
|
||
|
||
ARBEITSWEISE:
|
||
- Arbeite STRIKT phasenweise nach der Roadmap. Baue NICHT alles auf einmal. Erst Gerüst + Core, dann Features.
|
||
- Lege zu Beginn eine CLAUDE.md mit Projektkonventionen an (Code-Stil, Ordnerstruktur,
|
||
Commit-Regeln, wie Tests laufen).
|
||
- Schreibe zu jeder nicht-trivialen Funktion Tests (Backend: go test; Frontend: Vitest).
|
||
- Committe in kleinen, sinnvollen Schritten mit klaren Messages.
|
||
- Wenn eine Architektur-Entscheidung mehrere sinnvolle Optionen hat, FRAG MICH kurz,
|
||
statt einfach zu wählen.
|
||
- Sicherheit durchgängig: Path-Traversal, AuthZ pro Request, Argon2id, CSRF, Rate-Limiting,
|
||
und vor jedem Phasen-Abschluss ein kurzer Security-Pass gegen die OWASP-Top-10-Liste im Plan
|
||
(SQL-Injection, XSS, CSRF, IDOR/Access-Control, SSRF) plus govulncheck/npm audit.
|
||
|
||
JETZT STARTEN MIT PHASE 0 + PHASE 1, DANN STOPPEN:
|
||
1. Monorepo-Struktur anlegen: /backend, /frontend, /deploy.
|
||
2. docker-compose.yml mit Services app + postgres + caddy. .env.example anlegen.
|
||
3. Postgres-Migrations-Setup + erste Tabellen (users [ohne email], sessions, files, folders).
|
||
4. Backend-Grundgerüst: chi Router, Health-Endpoint, Config aus .env, strukturiertes Logging.
|
||
5. Lokale Auth: Login mit Username + Argon2id, Session-Cookies (httpOnly/secure/SameSite), CSRF.
|
||
(Erst-Admin per Seed/CLI anlegen, da keine öffentliche Registrierung nötig.)
|
||
6. Storage-Interface + LocalFS-Implementierung mit Path-Traversal-Schutz, ATOMAREN Schreibvorgängen
|
||
(Temp -> fsync -> rename), SHA-256-Prüfsummen und Soft-Delete (kein hartes Löschen).
|
||
7. Datei-API: Upload, Download (mit Checksum-Verifikation), Liste, Rename, Move, Delete (Soft),
|
||
Ordner anlegen. DB-Commit erst nach erfolgreichem Blob-Write.
|
||
8. Frontend: Vite+React+TS+Tailwind-Setup, Login-Seite, Datei-Browser mit Upload-Queue,
|
||
spricht die Datei-API an. Als PWA konfigurieren. Theming über CSS-Variablen vorbereiten
|
||
(Tokens für main/akzent etc.), damit Admin-/User-Theming in Phase 4 leicht andockt.
|
||
9. Tests für Auth, Storage (inkl. Traversal-Test, Upload-Abbruch-Simulation, Checksum-Verifikation,
|
||
Soft-Delete-Recovery) und Datei-API.
|
||
10. README mit Setup-Anleitung (docker compose up).
|
||
|
||
Wenn Phase 0+1 lauffähig und getestet sind: STOPP. Gib mir eine kurze Zusammenfassung,
|
||
wie ich es lokal starte und teste, und schlage Phase 2 vor. Erst nach meinem OK weitermachen.
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Getroffene Entscheidungen
|
||
|
||
- **Auth:** Session-Cookies (keine JWTs). ✅
|
||
- **Office:** WOPI-Host → Collabora **und** OnlyOffice als Provider, per `.env` umschaltbar. OnlyOffice für beste MS-Office-Treue, Collabora als unabhängige Alternative. ✅
|
||
- **E-Mail:** wird nicht verwendet (private Cloud). ✅
|
||
- **2FA:** später (Phase 8). ✅
|
||
- **OCR/Paperless:** erst nach Grundgerüst (Phase 6). ✅
|
||
- **Reihenfolge:** Gerüst + Grundfunktionen zuerst, Features danach. ✅
|
||
- **Datenintegrität:** atomare Writes + SHA-256 + Soft-Delete ab Phase 1, nicht verhandelbar. ✅
|
||
- **Theming:** Admin = volles Branding (Logo, Name, freie Farbpaletten); User = nur eigene Farbpalette. ✅
|
||
- **Admin-Sicherheit:** Admin-Konto/-Funktionen besonders gehärtet, OWASP-Top-10-Pass je Phase. ✅
|
||
|
||
- **Reverse Proxy:** extern (Bring Your Own), kein interner Proxy-Container; App proxy-aware (HTTP intern, X-Forwarded-* von TRUSTED_PROXIES, PUBLIC_URL). ✅
|
||
|
||
Noch offen:
|
||
- Suche: ab wann Meilisearch statt Postgres FTS? (Empfehlung: erst Postgres FTS, Meilisearch wenn die Doku-Menge groß wird.)
|
||
- Sidebar-Customizing (Phase 2.5): Favoriten/Pins als DB-Tabelle pro User (serverseitig gespeichert). Reihenfolge per Drag & Drop änderbar. Custom-Links: vorerst nur interne Ordner pinnen.
|