Die Regel galt für beide Anbieter – das war falsch. Marcs Kritik betraf nur OnlyOffice: Es baut auf dem Telefon dieselbe Oberfläche auf wie am PC. 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 jetzt in der öffentlichen Konfiguration (office_provider), und die Oberfläche entscheidet daran. Der Endpunkt selbst kann weiterhin beides. Nachgeholt: Der ganze Weg ist jetzt auch mit Collabora gemessen, nicht nur mit OnlyOffice – gegen die Dev-Instanz, die damit läuft. erster Abruf 0,347 s, zweiter 0,009 s, eine Datei auf der Platte (OnlyOffice zum Vergleich: 1,05 s – es muss die Datei erst abholen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
817 lines
34 KiB
Markdown
817 lines
34 KiB
Markdown
# Aether
|
||
|
||
Self-hosted cloud storage — schnelle, schlanke Alternative zu Nextcloud.
|
||
|
||
**Stack:** Go + chi · React + TS + Vite · PostgreSQL · Docker Compose
|
||
|
||
---
|
||
|
||
## Reverse Proxy (Pflicht)
|
||
|
||
Aether lauscht intern auf reinem HTTP. TLS wird von deinem eigenen Reverse Proxy terminiert.
|
||
**nginx, Traefik, Cloudflare Tunnel** – alles funktioniert. Dieses Projekt
|
||
liefert bewusst **keinen** Proxy mit: TLS, Zertifikate und Domain gehören in
|
||
deine Hand, nicht in ein Compose-File, das du ohnehin überschreiben müsstest.
|
||
|
||
Der Proxy muss folgende Headers setzen:
|
||
|
||
```
|
||
X-Forwarded-For: <echte Client-IP>
|
||
X-Forwarded-Proto: https
|
||
```
|
||
|
||
Die IP deines Proxys muss in `TRUSTED_PROXIES` stehen – sonst ignoriert Aether diese Header
|
||
(Sicherheitsmaßnahme gegen IP-Spoofing).
|
||
|
||
Der Proxy muss folgendes durchreichen (keine eigene Verarbeitung):
|
||
- Alle HTTP-Methoden inkl. `PROPFIND`, `MKCOL`, `MOVE`, `COPY` (für WebDAV)
|
||
- Große Uploads (Timeout und Body-Size-Limit großzügig setzen)
|
||
- WebSocket-Verbindungen (für Office-Co-Editing, Phase 6)
|
||
- HSTS-Header setzen (z. B. `Strict-Transport-Security: max-age=31536000; includeSubDomains`)
|
||
|
||
## Schnellstart (lokal)
|
||
|
||
### Voraussetzungen
|
||
|
||
- Docker + Docker Compose
|
||
- Go 1.25+ und Node 20+ **nur**, wenn du aus dem Quelltext bauen willst –
|
||
zum Betreiben genügt das fertige Abbild (siehe Schritt 3)
|
||
|
||
Zwei Wege, je nachdem was du vorhast:
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Betreiben** | `docker-compose.example.yml` + `.env.example` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||
| **Entwickeln** | `deploy/docker-compose.yml` + `deploy/.env.example`. Baut aus dem Quelltext, kennt alle Schalter. |
|
||
|
||
### 1. Dateien holen
|
||
|
||
Zum Betreiben genügen zwei Dateien – kein Klonen nötig:
|
||
|
||
```bash
|
||
mkdir aether && cd aether
|
||
curl -O https://gitea.honeyy.duckdns.org/Docker/Aether/raw/branch/main/docker-compose.example.yml
|
||
curl -O https://gitea.honeyy.duckdns.org/Docker/Aether/raw/branch/main/.env.example
|
||
mv docker-compose.example.yml docker-compose.yml
|
||
```
|
||
|
||
Zum Entwickeln stattdessen das Repository:
|
||
|
||
```bash
|
||
git clone https://gitea.honeyy.duckdns.org/Docker/Aether.git aether
|
||
cd aether
|
||
```
|
||
|
||
### 2. `.env` anlegen
|
||
|
||
```bash
|
||
cp .env.example .env # beim Betreiben
|
||
cp deploy/.env.example deploy/.env # beim Entwickeln
|
||
```
|
||
|
||
Ausfüllen musst du mindestens:
|
||
|
||
```env
|
||
PUBLIC_URL=https://cloud.example.com
|
||
SESSION_SECRET= # openssl rand -hex 32
|
||
DB_PASSWORD=
|
||
DATA_PATH=/pfad/zu/aether-data
|
||
APP_UID=1000 # id -u
|
||
APP_GID=1000 # id -g
|
||
TRUSTED_PROXIES=<IP-deines-Proxys>
|
||
ADMIN_USERNAME=admin
|
||
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
|
||
```
|
||
|
||
Die schlanke `.env.example` im Wurzelverzeichnis führt nur diese Pflichtwerte
|
||
und Office. Alles Weitere – OIDC, OCR, Vorschaubilder, Kontingente – steht mit
|
||
Erklärung in `deploy/.env.example`.
|
||
|
||
### 3. Starten
|
||
|
||
```bash
|
||
docker login gitea.honeyy.duckdns.org # nur beim ersten Mal
|
||
docker compose up -d
|
||
```
|
||
|
||
Beim ersten Start:
|
||
- Postgres-Migrations laufen automatisch
|
||
- Der erste Admin-Account wird aus `ADMIN_USERNAME` / `ADMIN_PASSWORD` angelegt
|
||
- Nach dem Start: `ADMIN_PASSWORD` aus `.env` entfernen oder leer lassen
|
||
|
||
Aus dem Quelltext bauen statt ziehen:
|
||
|
||
```bash
|
||
docker compose -f deploy/docker-compose.yml build app
|
||
docker compose -f deploy/docker-compose.yml up -d
|
||
```
|
||
|
||
Aktualisieren:
|
||
|
||
```bash
|
||
docker compose pull app && docker compose up -d app
|
||
```
|
||
|
||
### 4. Öffnen
|
||
|
||
Die App ist über deinen Reverse Proxy erreichbar (z. B. `https://cloud.example.com`).
|
||
Direkt (ohne Proxy): http://localhost:8080/health
|
||
|
||
---
|
||
|
||
## Einstellungen
|
||
|
||
Zwei Beispieldateien, je nach Zweck:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `.env.example` (Wurzel) | das Nötigste zum Betreiben |
|
||
| `deploy/.env.example` | alle Schalter, auch die optionalen |
|
||
|
||
Beide sind knapp gehalten – was dahintersteckt, steht hier.
|
||
|
||
### Was schiefgeht, wenn man es falsch setzt
|
||
|
||
Diese sieben fallen **nicht** beim Start auf, sondern später und an
|
||
unerwarteter Stelle. Der Rest hat brauchbare Vorgaben.
|
||
|
||
**`DB_HOST=postgres`** – nicht `localhost`. Aus dem Container heraus ist
|
||
`localhost` er selbst; die Voreinstellung führt zu `migrations failed` beim
|
||
Start.
|
||
|
||
**`DB_PASSWORD`** darf jedes Zeichen enthalten, auch `% @ : /` und
|
||
Leerzeichen. Es gibt keine Liste erlaubter Zeichen – wer die erst nachschlagen
|
||
muss, wählt am Ende ein schwächeres Passwort.
|
||
|
||
**Ein `$` ist die Ausnahme, und zwar in *jedem* Wert.** Docker Compose liest
|
||
es als Variable und schneidet lautlos ab. Gemessen:
|
||
|
||
| in der `.env` | kommt an |
|
||
|---|---|
|
||
| `abc$def` | `abc` |
|
||
| `abc$$def` | `abc$def` ✓ |
|
||
| `'abc$def'` | `abc$def` ✓ |
|
||
| `abc\$def` | `abc\` |
|
||
|
||
Ein Backslash hilft also **nicht**.
|
||
|
||
`$$` sind dabei keine zwei Zeichen, sondern eine Fluchtsequenz: Das erste
|
||
schützt das zweite und wird dabei verbraucht. Im Container steht danach **ein**
|
||
`$` – das ist das gewünschte Ergebnis, und angemeldet wird sich mit dem
|
||
einfachen `$`. Wem das zu leicht zu verwechseln ist, nimmt die einfachen
|
||
Anführungszeichen: Dort steht das Passwort so da, wie es ist.
|
||
|
||
Das trifft besonders `ADMIN_PASSWORD`:
|
||
Angelegt wird dann ein Konto mit einem Passwort, das niemand kennt, und die
|
||
Anmeldung scheitert mit „invalid credentials", obwohl alles richtig eingetippt
|
||
ist. Nachsehen, was wirklich angekommen ist:
|
||
|
||
```bash
|
||
docker compose exec app printenv ADMIN_PASSWORD
|
||
```
|
||
|
||
Seit dem 24.08.2026 nennt der Start zusätzlich die **Länge** des Passworts und
|
||
warnt, wenn sie auffällig klein ist.
|
||
|
||
**`PUBLIC_URL`** ist die Adresse, unter der der **Browser** die Cloud sieht,
|
||
ohne Schrägstrich am Ende. Ein falscher Wert startet klaglos und fällt erst
|
||
auf, wenn ein Freigabe-Link oder ein OIDC-Rücksprung ins Leere zeigt.
|
||
|
||
**`TRUSTED_PROXIES`** entscheidet, wem `X-Forwarded-For` geglaubt wird. Steht
|
||
hier das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit
|
||
und Audit-Log werden damit wertlos, ohne dass irgendetwas nach Fehler aussieht.
|
||
|
||
**`ADMIN_PASSWORD`** legt beim allerersten Start den Admin an. Danach leeren:
|
||
Das Konto bleibt, das Passwort stünde sonst dauerhaft in einer Datei.
|
||
|
||
**`OIDC_BUTTON_LABEL`** beschriftet den Knopf auf der Anmeldeseite
|
||
(Vorgabe „Sign in with SSO / OIDC"). „Anmelden mit PocketID" sagt dem, der
|
||
davorsteht, mehr als eine Abkürzung, die man kennen muss.
|
||
|
||
### Profilbild vom Anbieter
|
||
|
||
Schickt der Identitätsanbieter im Token ein `picture` (PocketID tut das, wenn
|
||
im Konto ein Bild hinterlegt ist), holt Aether es bei der Anmeldung ab,
|
||
schneidet es mittig quadratisch auf 256 px zu und zeigt es oben rechts. Ohne
|
||
Bild bleibt es beim farbigen Kreis mit dem Anfangsbuchstaben.
|
||
|
||
Es gibt dafür **keinen Schalter** – nichts einzustellen, nichts, was
|
||
fehlkonfiguriert sein kann:
|
||
|
||
* Geholt wird nur vom Anbieter selbst. Die Bildadresse kommt aus einem Token,
|
||
also von außen; erlaubt ist ausschließlich der Ursprung aus `OIDC_ISSUER`,
|
||
Weiterleitungen eingeschlossen. Sonst wäre es eine Aufforderung an den
|
||
Server, für einen Fremden beliebige Adressen im internen Netz anzufragen.
|
||
* Höchstens 4 MB, höchstens fünf Sekunden, höchstens einmal am Tag je Konto –
|
||
und nur, wenn sich die Adresse geändert hat oder noch kein Bild da ist.
|
||
* Scheitert etwas davon, geht die Anmeldung trotzdem durch. Ein nicht
|
||
erreichbares Bild ist kein Grund, jemanden auszusperren.
|
||
|
||
Ändert sich das Bild beim Anbieter, steht es hier spätestens am nächsten Tag –
|
||
oder sofort nach dem nächsten Abmelden und Anmelden.
|
||
|
||
**`OIDC_LINK_BY_USERNAME`** verbindet ein bestehendes Konto mit derselben
|
||
Kennung, statt ein zweites anzulegen. **Vor der ersten Anmeldung setzen** –
|
||
danach existiert das zweite Konto (`marc2`, leer, ohne Adminrecht) schon, und
|
||
das Verbinden kommt zu spät. Nur bei einem Anbieter sinnvoll, dessen Konten du
|
||
selbst anlegst (PocketID, Authentik); bei freier Registrierung wäre es die
|
||
Übernahme fremder Konten. Verbunden wird ohnehin nur, was noch keine Bindung
|
||
hat.
|
||
|
||
**`APP_BIND`** entscheidet, wer den Port überhaupt erreicht. Die Vorgabe
|
||
`127.0.0.1` heißt: **nur dieser Rechner.** Von einem anderen Gerät kommt dann
|
||
kein Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt
|
||
ab, bevor irgendetwas die Anwendung erreicht. Das ist Absicht: Davor gehört
|
||
dein Reverse Proxy mit TLS. Zum Ausprobieren im eigenen Netz `APP_BIND=0.0.0.0`
|
||
setzen – dann geht die Anmeldung allerdings unverschlüsselt über das Netz.
|
||
|
||
**`DATA_PATH`** ist ein gewöhnliches Verzeichnis auf dem Wirt, kein
|
||
Docker-Volume – deshalb kommst du per SFTP, rsync oder im Dateimanager daran.
|
||
Nur lesend anfassen: Wer von Hand hineinschreibt, hat eine Datei, die die
|
||
Datenbank nicht kennt; die Cloud sieht sie nicht, und der GC räumt sie
|
||
irgendwann weg. Zum Hineinlegen ist WebDAV der Weg. Dazu `APP_UID`/`APP_GID`
|
||
aus `id -u` und `id -g`, sonst gehören die Dateien einem Container-Nutzer.
|
||
|
||
### Versionen
|
||
|
||
Eine Datei kann frühere Fassungen aufheben. **Standard ist aus**, eingeschaltet
|
||
wird es je Datei über Rechtsklick → „Keep versions".
|
||
|
||
Warum nicht für alle: Eine 50-MB-Datei, die täglich per WebDAV synchronisiert
|
||
wird, läge sonst nach einer Woche sechsmal auf der Platte. Der Schalter macht
|
||
sichtbar, wo das gewollt ist.
|
||
|
||
`MAX_VERSIONS_KEPT=5` bestimmt, wie viele Vorgänger bleiben – zusammen mit der
|
||
aktuellen Fassung also sechs Stände. Was darüber hinausgeht, wird beim
|
||
Speichern samt Blob gelöscht. Die Fassungen zählen auf dein Kontingent.
|
||
|
||
Kleiner als 1 wird verworfen: Die Zahl geht als `OFFSET` in die Aufräumabfrage,
|
||
und `OFFSET 0` löschte jede Fassung – aus „keine Grenze" würde „kein Verlauf".
|
||
Wer keinen Verlauf will, lässt den Schalter an der Datei aus.
|
||
|
||
**Ausschalten löscht nichts.** Der Schalter setzt nur ein Flag: Ab dann
|
||
entstehen keine neuen Fassungen mehr, die vorhandenen bleiben liegen – mitsamt
|
||
ihrem Platz im Kontingent. Das ist Absicht, ein Umschalter soll keine Daten
|
||
vernichten. Es heißt aber auch, dass belegter Platz übrig bleibt, den man nur
|
||
im Verlaufs-Dialog sieht.
|
||
|
||
Endgültig weg sind sie erst, wenn die **Datei selbst** verschwindet, und zwar
|
||
richtig: Der Papierkorb genügt nicht – dort liegen Datei und Fassungen weiter.
|
||
Beim **Leeren des Papierkorbs** wird die Zeile gelöscht, `ON DELETE CASCADE`
|
||
nimmt die Fassungen mit, und die Blobs verschwinden sofort von der Platte.
|
||
|
||
Gemessen an einer Datei mit drei Fassungen:
|
||
|
||
| Schritt | Fassungen | Blobs |
|
||
|---|---|---|
|
||
| Versionierung an, dreimal überschrieben | 3 | 4 |
|
||
| Versionierung **aus**geschaltet | 3 | 4 |
|
||
| in den Papierkorb gelegt | 3 | 4 |
|
||
| Papierkorb geleert | – | 0 |
|
||
|
||
Zu beachten: **Jedes Strg+S in Office legt eine Fassung an.** Beim Testen
|
||
sprang eine Datei dadurch von v1 auf v3, nicht auf v2.
|
||
|
||
### Volltextsuche (OCR)
|
||
|
||
Standardmäßig an. Fehlen `ocrmypdf`/`tesseract` im Abbild, schaltet sich der
|
||
Arbeiter beim Start selbst ab und die Cloud läuft ohne Volltext weiter.
|
||
|
||
`OCR_MAX_PAGES=50` ist eine Bremse, keine Schikane: Erkennung kostet rund eine
|
||
Minute je gescannter Seite, und ohne Grenze blockiert ein dickes Handbuch die
|
||
Warteschlange für alles andere. `OCR_LANGUAGES` kennt `deu` und `eng`; weitere
|
||
brauchen ein zusätzliches `apk`-Paket im Dockerfile.
|
||
|
||
### Office
|
||
|
||
Die drei Adressen und ihre Fallstricke stehen weiter unten bei
|
||
[Office](#office-collabora--onlyoffice--euro-office) – dort, wo auch die
|
||
Container-Schnipsel liegen.
|
||
|
||
### WebDAV
|
||
|
||
Läuft unter `/dav/`, kein Schalter nötig. Dein Reverse Proxy muss
|
||
`PROPFIND`, `MKCOL`, `MOVE` und `COPY` durchreichen – viele Vorlagen erlauben
|
||
nur `GET`/`POST`, und dann scheitert das Einbinden mit einem `405`.
|
||
|
||
---
|
||
|
||
## Abbild bauen und veröffentlichen
|
||
|
||
```bash
|
||
# im Wurzelverzeichnis des Projekts, nicht in deploy/
|
||
IMG=gitea.honeyy.duckdns.org/docker/aether
|
||
docker build -f deploy/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
|
||
docker push $IMG:$(date +%Y.%m.%d)
|
||
docker push $IMG:latest
|
||
```
|
||
|
||
**Zwei Tags, mit Absicht.** `latest` ist der bewegliche Name zum Aufbauen, der
|
||
datierte der feste zum Zurückrollen. Wer nur `latest` fährt, hat im Fehlerfall
|
||
nichts, worauf er zurück kann – der Name zeigt dann schon auf die Fassung, die
|
||
gerade Ärger macht.
|
||
|
||
Das Abbild wiegt rund **540 MB**. Den Löwenanteil machen `ocrmypdf`,
|
||
`tesseract` samt Sprachmodellen und `poppler-utils` aus – ohne sie wären es
|
||
etwa 210 MB, aber auch keine Volltextsuche in Scans. Wer das nicht braucht,
|
||
streicht die zweite `apk`-Zeile im Dockerfile und setzt `OCR_ENABLED=false`;
|
||
der Server prüft beim Start selbst, ob die Programme da sind, und läuft sonst
|
||
ohne Volltextsuche weiter.
|
||
|
||
Gebaut wird für die Architektur des Wirts. Für ein anderes Ziel (etwa ein
|
||
ARM-Gerät) braucht es `docker buildx build --platform linux/arm64`.
|
||
|
||
---
|
||
|
||
## Lokale Entwicklung
|
||
|
||
### Backend (immer via Docker)
|
||
|
||
Das Backend wird **ausschließlich über Docker Compose** gestartet – nie mit `go run`.
|
||
Postgres-Migrations und DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
|
||
|
||
```bash
|
||
cd deploy
|
||
docker compose up -d --build # Backend + Postgres starten
|
||
docker compose logs -f app # Logs verfolgen
|
||
docker compose down # stoppen
|
||
```
|
||
|
||
Code-Änderungen am Backend: `docker compose up -d --build` neu ausführen (Image neu bauen).
|
||
|
||
**Compile-Check ohne lokales Go-Toolchain** (der Host hat kein Go installiert):
|
||
|
||
```bash
|
||
docker run --rm -v "$PWD/../backend":/src -w /src golang:1.25-alpine go build ./...
|
||
```
|
||
|
||
### Frontend
|
||
|
||
```bash
|
||
cd frontend
|
||
npm install
|
||
npm run dev # http://localhost:5173 (Vite dev server, spricht Backend auf :8080 an)
|
||
npm run build # Production-Build (wird vom Backend-Image mitgebaut)
|
||
```
|
||
|
||
### Tests
|
||
|
||
```bash
|
||
# Backend (im Go-Image, da Host kein Go hat)
|
||
docker run --rm -v "$PWD/backend":/src -w /src golang:1.25-alpine go test ./...
|
||
|
||
# Frontend
|
||
cd frontend && npm test
|
||
```
|
||
|
||
---
|
||
|
||
## Struktur
|
||
|
||
```
|
||
aether/
|
||
├── backend/
|
||
│ ├── cmd/aether/ # Einstiegspunkt
|
||
│ ├── internal/ # Domänen-Pakete (auth, storage, files, …)
|
||
│ ├── db/migrations/ # SQL-Migrations
|
||
│ └── pkg/ # Shared utils (config, middleware, response)
|
||
├── frontend/
|
||
│ └── src/
|
||
│ ├── api/ # Typed API-Client
|
||
│ ├── components/
|
||
│ ├── hooks/
|
||
│ └── pages/
|
||
├── deploy/
|
||
│ ├── docker-compose.yml # app + postgres (kein Proxy-Container)
|
||
│ ├── Dockerfile
|
||
│ └── .env.example
|
||
└── CLAUDE.md # Projektkonventionen
|
||
```
|
||
|
||
---
|
||
|
||
## Wo die Dateien liegen
|
||
|
||
Nicht in einem Docker-Volume, sondern in einem gewöhnlichen Verzeichnis auf dem
|
||
Wirt – `DATA_PATH` in der `.env`. Der Aufbau darin ist derselbe wie in der
|
||
Oberfläche:
|
||
|
||
```
|
||
<DATA_PATH>/
|
||
├── users/<benutzername>/home/… ← die eigenen Dateien, echte Namen
|
||
│ /.trash/… ← Papierkorb
|
||
│ /.versions/… ← frühere Fassungen
|
||
├── groups/<gruppen-id>/home/… ← Gruppenordner
|
||
├── thumbs/ ← Vorschaubilder
|
||
├── branding/ ← Logo und App-Symbol
|
||
└── tmp/ ← Zwischendateien, wird selbst geräumt
|
||
```
|
||
|
||
**Warum das so ist:** Ein Archiv ist nur dann eines, wenn man ohne die
|
||
Anwendung an seine Unterlagen kommt. Hier reicht dafür `scp -r` oder ein Blick
|
||
in den Dateimanager – keine Datenbank, kein Export, kein laufender Container
|
||
nötig. Bei einem benannten Volume läge alles unter
|
||
`/var/lib/docker/volumes/…` und gehörte `root`.
|
||
|
||
Damit die Dateien **dir** gehören und nicht einem Container-Nutzer, läuft der
|
||
Container unter deiner Kennung (`APP_UID`/`APP_GID`, siehe `.env`). Damit
|
||
funktioniert SFTP direkt:
|
||
|
||
```bash
|
||
sftp dein-nutzer@dein-server
|
||
get -r /pfad/zu/aether-data/users/deinname/home
|
||
```
|
||
|
||
> **Nur lesen.** Wer von Hand eine Datei hineinlegt, hat sie auf der Platte,
|
||
> aber nicht in der Datenbank: Die Cloud sieht sie nicht, und die
|
||
> Speicherbereinigung räumt sie irgendwann weg. Zum Hineinlegen ist WebDAV der
|
||
> Weg (siehe unten).
|
||
|
||
### Von einem benannten Volume umsteigen
|
||
|
||
Ältere Installationen hatten ein Volume `deploy_data`. Umzug mit Prüfung:
|
||
|
||
```bash
|
||
cd deploy
|
||
docker compose stop app
|
||
mkdir -p /pfad/zu/aether-data
|
||
docker run --rm -v deploy_data:/src:ro -v /pfad/zu/aether-data:/dst alpine:3.20 \
|
||
sh -c 'cp -a /src/. /dst/ && chown -R $(id -u):$(id -g) /dst'
|
||
|
||
# Prüfen, bevor umgeschaltet wird: beide Summen müssen gleich sein
|
||
docker run --rm -v deploy_data:/src:ro alpine:3.20 \
|
||
sh -c 'cd /src && find . -type f -exec sha256sum {} \; | LC_ALL=C sort | sha256sum'
|
||
(cd /pfad/zu/aether-data && find . -type f -exec sha256sum {} \; | LC_ALL=C sort | sha256sum)
|
||
|
||
# erst dann DATA_PATH in die .env eintragen und starten
|
||
docker compose up -d app
|
||
```
|
||
|
||
Das alte Volume **nicht sofort löschen**. Es ist der Rückfall, solange nicht
|
||
alles geprüft ist; `docker volume rm deploy_data` kann warten, bis du sicher
|
||
bist.
|
||
|
||
---
|
||
|
||
## WebDAV
|
||
|
||
Adresse: `https://<deine-domain>/dav/`
|
||
|
||
Angemeldet wird sich mit dem **Benutzernamen** und einem **App-Passwort**
|
||
(Profil → App passwords). Das Kontopasswort wird an `/dav/` **abgelehnt** – es
|
||
ist zugleich der Admin-Zugang und hat auf Geräten nichts verloren. Ein
|
||
App-Passwort gilt nur für WebDAV, nie fürs Web-Login, und lässt sich einzeln
|
||
widerrufen, ohne die anderen Geräte auszusperren.
|
||
|
||
Der Aufbau entspricht dem der Weboberfläche: eigene Ordner und Gruppenordner
|
||
liegen nebeneinander in der Wurzel, Freigaben von Personen unter
|
||
`/dav/Shared/<Person>/<Freigabe>/`. Trägt ein Gruppenordner denselben Namen wie
|
||
ein eigener, behält der eigene ihn und der Gruppenordner bekommt den
|
||
Gruppennamen angehängt (`Projekt (Buero)`).
|
||
|
||
Nach zehn Fehlanmeldungen in 15 Minuten sperrt die IP für den Rest des Fensters
|
||
(`429`). Normale Anfragen zählen nicht mit – Sync-Clients senden legitim
|
||
hunderte pro Minute.
|
||
|
||
---
|
||
|
||
## Office (Collabora / OnlyOffice / Euro-Office)
|
||
|
||
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
|
||
sich eine Datei holen und wieder zurückschreiben – **alle drei sprechen
|
||
dieselbe.** Deshalb gibt es *einen* Adapter und einen Schalter in der `.env`,
|
||
statt derselben Sache dreimal.
|
||
|
||
Im Compose-Beispiel ist deshalb **kein** Office-Server enthalten: Jeder bringt
|
||
eigene Variablen mit, und man will genau einen. Such dir unten den passenden
|
||
Schnipsel und häng ihn an dein `docker-compose.yml`.
|
||
|
||
### Was dazukommt
|
||
|
||
| | |
|
||
|---|---|
|
||
| `OFFICE_PROVIDER` | `collabora`, `onlyoffice` oder leer (aus) – Euro-Office zählt als `onlyoffice` |
|
||
| `OFFICE_URL` | wo das **Backend** den Office-Server erreicht |
|
||
| `OFFICE_PUBLIC_URL` | wo der **Browser** ihn erreicht (leer = wie oben) |
|
||
| `OFFICE_WOPI_URL` | wo der **Office-Server** Aether erreicht (leer = `PUBLIC_URL`) |
|
||
| `OFFICE_ALLOWED_HOSTS` | Ziele, die das Backend anfragen darf – gegen SSRF |
|
||
|
||
**Drei Adressen für drei Richtungen** – die häufigste Fehlerquelle beim
|
||
Aufbau. Im Compose-Netz heißt der Server `office`, der Browser braucht dort
|
||
einen Namen, den *er* auflösen kann (deine Domain oder LAN-IP), und der
|
||
Office-Server erreicht Aether unter `app:8080`: „localhost" ist aus seinem
|
||
Container heraus er selbst.
|
||
|
||
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
|
||
OnlyOffice und Euro-Office auf 80.**
|
||
|
||
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
|
||
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
|
||
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
|
||
`--profile office` oder `COMPOSE_PROFILES=office` in der `.env`. **Vorsicht:**
|
||
Oberflächen wie Portainer, Dockge oder Komodo kennen `--profile` nicht; dort
|
||
entsteht der Container dann nie, ohne jede Fehlermeldung.
|
||
|
||
### Schnipsel 1: Collabora
|
||
|
||
```yaml
|
||
office:
|
||
container_name: aether-office
|
||
image: collabora/code:latest
|
||
restart: unless-stopped
|
||
environment:
|
||
# Wer seine Dateien hier öffnen darf – **die Adresse, unter der dein
|
||
# Browser die Cloud sieht**, nicht der interne Dienstname. Collabora
|
||
# liest den Wert als regulären Ausdruck, Punkte müssen also maskiert
|
||
# werden, und der Port gehört dazu (443 bei HTTPS).
|
||
#
|
||
# Steht hier der falsche Wert, meldet das Log beim Öffnen:
|
||
# No authorized hosts found matching the target host […] in config
|
||
aliasgroup1: https://cloud\.example\.com:443
|
||
# ssl.enable – spricht Collabora selbst HTTPS?
|
||
# ssl.termination – endet HTTPS davor, an deinem Reverse Proxy?
|
||
# Steht termination auf true ohne HTTPS davor, verlangt Collabora eine
|
||
# wss://-Verbindung, die es nicht gibt: Der Editor lädt, das Dokument
|
||
# bleibt weiß. mount_jail_tree=false spart CAP_SYS_ADMIN.
|
||
extra_params: >-
|
||
--o:ssl.enable=false
|
||
--o:ssl.termination=true
|
||
--o:mount_jail_tree=false
|
||
dictionaries: de_DE en_US
|
||
cap_add: [MKNOD]
|
||
networks: [internal]
|
||
ports:
|
||
- "${APP_BIND:-127.0.0.1}:9980:9980"
|
||
```
|
||
|
||
```env
|
||
OFFICE_PROVIDER=collabora
|
||
OFFICE_URL=http://office:9980
|
||
OFFICE_PUBLIC_URL=https://office.example.com
|
||
# **Öffentliche Adresse, nicht app:8080** – Begründung direkt darunter.
|
||
OFFICE_WOPI_URL=https://cloud.example.com
|
||
OFFICE_ALLOWED_HOSTS=office
|
||
```
|
||
|
||
> **Warum bei Collabora die öffentliche Adresse und nicht `app:8080`?**
|
||
>
|
||
> Weil Collabora aus dieser Adresse seine eigene Sicherheitsrichtlinie baut.
|
||
> Gemessen: Es setzt `frame-ancestors` auf *seinen* Host **plus den Host aus
|
||
> `WOPISrc`* – und `WOPISrc` ist genau `OFFICE_WOPI_URL`. Steht dort der
|
||
> interne Dienstname, erlaubt der Editor die Einbettung nur einer Seite, die
|
||
> unter `app:8080` läuft. Dein Browser kommt aber von `cloud.example.com`, und
|
||
> er lehnt ab:
|
||
>
|
||
> ```
|
||
> Framing 'https://office.example.com/' violates
|
||
> 'frame-ancestors office.example.com:* app:8080'
|
||
> ```
|
||
>
|
||
> Die Folge davon muss man wollen: Collabora holt die Datei dann **über deinen
|
||
> Reverse Proxy**, nicht quer durchs Docker-Netz. Der Container braucht dafür
|
||
> Namensauflösung nach draußen und einen Weg zurück – bei einem Proxy auf
|
||
> einem anderen Rechner ist das der normale Weg über das Internet.
|
||
>
|
||
> `aliasgroup1` oben muss **denselben** Host nennen. Zwei Schalter, eine
|
||
> Wahrheit: Der eine entscheidet, ob der Browser den Rahmen zeigen darf, der
|
||
> andere, ob Collabora die Anfrage überhaupt annimmt.
|
||
|
||
### Schnipsel 2: OnlyOffice Docs
|
||
|
||
```yaml
|
||
office:
|
||
container_name: aether-office
|
||
image: onlyoffice/documentserver:latest
|
||
restart: unless-stopped
|
||
environment:
|
||
WOPI_ENABLED: "true" # ohne das gibt es kein /hosting/discovery
|
||
JWT_ENABLED: "true"
|
||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||
networks: [internal]
|
||
ports:
|
||
- "${APP_BIND:-127.0.0.1}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980
|
||
```
|
||
|
||
```env
|
||
OFFICE_PROVIDER=onlyoffice
|
||
OFFICE_URL=http://office
|
||
OFFICE_PUBLIC_URL=https://office.example.com
|
||
OFFICE_WOPI_URL=http://app:8080
|
||
OFFICE_ALLOWED_HOSTS=office
|
||
OFFICE_JWT_SECRET= # mindestens 32 Zeichen
|
||
```
|
||
|
||
### Schnipsel 3: Euro-Office
|
||
|
||
Seit dem 9. Juni 2026 zu haben und in Nextcloud Hub 26 die Alternative zu
|
||
Collabora. Es ist ein OnlyOffice-Fork, und man sieht es an den Variablen: Sie
|
||
heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
|
||
`OFFICE_PROVIDER=onlyoffice`, nur ein anderes Abbild.
|
||
|
||
```yaml
|
||
office:
|
||
container_name: aether-office
|
||
image: ghcr.io/euro-office/documentserver:latest
|
||
restart: unless-stopped
|
||
environment:
|
||
WOPI_ENABLED: "true"
|
||
JWT_ENABLED: "true"
|
||
JWT_SECRET: ${OFFICE_JWT_SECRET}
|
||
EXAMPLE_ENABLED: "false" # die Beispiel-App gehört nicht ins Netz
|
||
ALLOW_PRIVATE_IP_ADDRESS: "true" # es muss app:8080 erreichen
|
||
networks: [internal]
|
||
ports:
|
||
- "${APP_BIND:-127.0.0.1}:9980:80"
|
||
```
|
||
|
||
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das
|
||
Abbild.
|
||
|
||
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
|
||
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
|
||
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
|
||
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
|
||
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
|
||
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
|
||
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
|
||
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
|
||
>
|
||
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
|
||
> nach Aktenlage hakt:
|
||
>
|
||
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
|
||
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
|
||
> Positivliste stehen. Bei Collabora heißt dasselbe `aliasgroup1`.
|
||
> 2. **Der Proof-Key.** OnlyOffice signiert seine WOPI-Anfragen und legt den
|
||
> öffentlichen Schlüssel als `proof-key` in die Discovery. Wir prüfen die
|
||
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||
> das der Ort.
|
||
>
|
||
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
|
||
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
|
||
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
|
||
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
|
||
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
|
||
> gesetzt – sonst läuft die API offen.
|
||
|
||
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
|
||
|
||
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
|
||
was aufgeht:
|
||
|
||
| Anbieter | am Telefon |
|
||
|---|---|
|
||
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
|
||
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
|
||
|
||
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
|
||
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
|
||
eine Tastatur.
|
||
|
||
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
|
||
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
|
||
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
|
||
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
|
||
nicht.
|
||
|
||
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
|
||
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
|
||
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
|
||
entscheidet der Anbieter.
|
||
|
||
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
|
||
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
|
||
LibreOffice-Layout desselben Dokuments sehen.
|
||
|
||
| | |
|
||
|---|---|
|
||
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
|
||
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
|
||
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
|
||
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
|
||
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
|
||
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
|
||
|
||
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
|
||
Bereinigung, zusammen mit Vorschau- und Profilbildern.
|
||
|
||
### Welche Dateien, und wie gut
|
||
|
||
Bearbeitet wird nur, was auf der **Positivliste** steht:
|
||
`odt docx doc rtf txt · ods xlsx xls csv · odp pptx ppt · odg`.
|
||
PDF steht bewusst **nicht** darauf – es hat seinen eigenen Betrachter, und ein
|
||
Office-Server würde beim Speichern ein anderes Dokument daraus machen.
|
||
|
||
Collabora Online *ist* LibreOffice, mit derselben Datei-Unterstützung. Für die
|
||
Microsoft-Formate heißt das:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `docx`, `xlsx`, `pptx` | öffnen und speichern zuverlässig, Format bleibt erhalten |
|
||
| Alte `doc`, `xls`, `ppt` | gehen, sind aber Rückwärtskompatibilität – kein Ziel der Entwicklung |
|
||
| Komplexes Layout | Seitenumbrüche und Schriftabstände können um Kleinigkeiten abweichen |
|
||
| Sehr große Tabellen, Pivot, seltene Formeln | die häufigste Stelle für echte Abweichungen |
|
||
| Makros (VBA) | laufen nicht – und das ist Absicht, nicht Mangel |
|
||
|
||
Fehlende **Schriften** sind der häufigste Grund für „sieht anders aus": Der
|
||
Container bringt Calibri und Cambria nicht mit. Wer Dokumente von außen
|
||
bearbeitet, legt die Schriften nach.
|
||
|
||
Für ein Belegarchiv ist das unkritisch – da wird gelesen und selten geändert.
|
||
Für den täglichen Umgang mit fremden Word-Dateien gilt: **Rundlauf prüfen**,
|
||
nicht darauf vertrauen.
|
||
|
||
### Wie das zusammenspielt
|
||
|
||
Der Office-Server ist **kein Client deines Browsers allein** – er holt die
|
||
Datei selbst beim Backend ab:
|
||
|
||
```
|
||
Browser ──────────► Office-Server (Bedienoberfläche im <iframe>)
|
||
Office-Server ────► Aether /wopi/... (Datei holen, Datei zurückschreiben)
|
||
```
|
||
|
||
Beide Pfeile müssen funktionieren. Das ist der Punkt, an dem solche
|
||
Installationen scheitern: Der Office-Server muss **Aether unter seiner
|
||
öffentlichen Adresse erreichen** (`PUBLIC_URL`), nicht nur unter
|
||
`localhost`. Ein Zugriffstoken mit kurzer Laufzeit hängt an jedem Aufruf; ohne
|
||
gültiges Token liefert der WOPI-Endpunkt nichts.
|
||
|
||
### Was beim Aufbau schiefgeht
|
||
|
||
Alles hier ist beim Testen tatsächlich passiert, in dieser Reihenfolge:
|
||
|
||
| Symptom | Ursache |
|
||
|---|---|
|
||
| Der Container entsteht gar nicht, ohne Fehlermeldung | Ein `profiles:` am Dienst. Startet nur mit `--profile office`, und Oberflächen wie Portainer kennen den Schalter nicht. Die Schnipsel oben haben deshalb keins. |
|
||
| Container läuft, aber kein „Edit in Office…" im Menü | `OFFICE_PROVIDER` ist leer – oder `OFFICE_URL` nennt den falschen Port (Collabora 9980, OnlyOffice/Euro-Office 80). Ohne Antwort auf `/hosting/discovery` bleibt der Punkt weg. |
|
||
| Rahmen bleibt leer, Konsole: *„Framing … violates default-src 'self'"* | Unsere eigene Sicherheitsrichtlinie. `frame-src` fällt auf `default-src` zurück; der Office-Ursprung muss ausdrücklich erlaubt sein. Passiert automatisch, sobald `OFFICE_PROVIDER` gesetzt ist. |
|
||
| Editor lädt, Dokument bleibt weiß | `ssl.termination=true` ohne HTTPS davor. Collabora sagt dem Browser dann `wss://`, das es nicht gibt. Im HTTP-Test `OFFICE_SSL_TERMINATION=false`. |
|
||
| Log: *„CheckFileInfo returned 0 … ECONNREFUSED"* | `OFFICE_WOPI_URL` zeigt auf `127.0.0.1:8090`. Der Port ist auf loopback gebunden – aus einem Container nicht erreichbar. Im Compose den Dienstnamen nehmen. |
|
||
| Konsole: *„Framing … violates 'frame-ancestors … app:8080'"* | `OFFICE_WOPI_URL` steht auf dem internen Dienstnamen. Collabora baut daraus seine Richtlinie und erlaubt dann nur eine Einbettung von dort. Die öffentliche Adresse der Cloud eintragen. |
|
||
| Log: *„No authorized hosts found matching the target host …"* | `aliasgroup1` kennt diesen Host nicht. Öffentliche Adresse eintragen, Punkte maskieren, Port anhängen – und den **Office**-Container neu erzeugen, nicht nur die App. Prüfen mit `docker compose logs office \| grep "trusted WOPI host"`. |
|
||
| Log: *„WOPISrc must be URI-encoded"* | Behoben; die Adresse wird vollständig kodiert. |
|
||
| Log: *„Failed to exec coolmount … needs CAP_SYS_ADMIN"* | Gelöst über `--o:mount_jail_tree=false` im Compose. |
|
||
| Fehlende Symbole (404 auf `.svg`) | Lücken im Collabora-Image selbst. Kosmetisch. |
|
||
|
||
### Sicherheit
|
||
|
||
- **SSRF:** Das Backend ruft den Office-Server auf. Ausgehende Ziele werden auf
|
||
`OFFICE_ALLOWED_HOSTS` beschränkt – sonst wird aus einer Adresse in der
|
||
Konfiguration ein Weg ins interne Netz.
|
||
- **Tokens sind dateibezogen und kurzlebig.** Ein Token für Datei A öffnet
|
||
Datei B nicht, auch nicht mit derselben Sitzung.
|
||
- **Schreiben folgt derselben Regel wie ein Upload:** atomar schreiben,
|
||
SHA-256 neu berechnen, Version anlegen, wenn Versionierung an ist. Ein
|
||
Office-Server ist kein Sonderweg an der Datenintegrität vorbei.
|
||
|
||
### Speicherbedarf – vor der Entscheidung lesen
|
||
|
||
Der Office-Server ist der mit Abstand hungrigste Teil dieser Anlage:
|
||
|
||
| | Bedarf | Bemerkung |
|
||
|---|---|---|
|
||
| **Collabora (CODE)** | ~1 GB im Leerlauf, +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
|
||
| **OnlyOffice Docs** | 2 GB Minimum, **4 GB empfohlen**, dazu Swap | bringt eigenes Postgres, RabbitMQ und Node mit |
|
||
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
|
||
|
||
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
|
||
kurzzeitig mehrere hundert Megabyte.
|
||
|
||
### Welchen nehmen?
|
||
|
||
- **Collabora**, wenn der Speicher knapp ist oder Herkunft und ODF zählen.
|
||
LibreOffice-Kern, europäisch, deutlich genügsamer.
|
||
- **OnlyOffice**, wenn `.docx`/`.xlsx` zwischen Desktop-Office und Web hin und
|
||
her wandern. Beste Treue bei OOXML, weil es nativ damit arbeitet.
|
||
- **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft.
|
||
Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe
|
||
WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach
|
||
Arbeitsspeicher, und das Abbild wiegt rund 5 GB.
|
||
|
||
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
|
||
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
|
||
wenig. Collabora ist dort die einzige, die trägt – und ein Wechsel später
|
||
kostet einen Container und eine Zeile `.env`, nicht mehr.
|
||
|
||
---
|
||
|
||
## Phasen-Roadmap
|
||
|
||
| Phase | Inhalt | Status |
|
||
|-------|--------|--------|
|
||
| 0 | Fundament (Monorepo, Docker, Migrations, Backend-Skeleton) | ✅ |
|
||
| 1 | Auth (Argon2id, Sessions), Storage (atomar + SHA-256), Datei-CRUD, Frontend | ✅ |
|
||
| 2 | Uploads mit Fortschritt, Papierkorb, Versionen, Share-Links, Thumbnails | ✅ |
|
||
| 2.5 | Sidebar-Favoriten, Ordner verschieben/hochladen, „Verschieben nach…" | ✅ |
|
||
| 3 | Admin-Panel, Gruppen(-mitgliedschaft), Theming, User-Profil | ✅ |
|
||
| 4 | WebDAV (`/dav/`), OIDC (optional) | ✅ (im Test) |
|
||
| 4.5 | Härtung (Quota, Audit-Log, App-Passwörter), interne Shares, Gruppenordner, Logo/App-Symbol | ✅ (im Test) |
|
||
| 5 | UI (Suche, Vorschau, Sortierung, Vor/Zurück) – Design-Rest offen | 🔄 |
|
||
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
|
||
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
|
||
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
|
||
|
||
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).
|