Files
marcandClaude Opus 5 7d45941c03 refactor(office): toten JWT-Schlüssel entfernen, Doku richtigstellen
Beim Löschen von convert.go ist Aethers einzige Verwendung des JWT-Schlüssels
mitgegangen: signJWT stand dort. Zurück blieb ein Feld auf dem Client, das
niemand liest, plus Config-Eintrag und Aufrufparameter – Go meldet ungenutzte
Strukturfelder nicht, deshalb lief der Build durch. Eine Einstellung, die
nichts mehr tut, ist schlimmer als keine: Wer sie setzt, glaubt, sie wirke.

OFFICE_JWT_SECRET bleibt in der .env, wird aber vom **Office-Container**
gelesen, nicht von Aether. Geprüft mit Euro-Office: JWT_ENABLED=true und
Aether ohne jedes Signieren – der WOPI-Weg läuft.

Doku richtiggestellt, nachdem Marc den Compose-Schnipsel angezweifelt hat:

- „Euro-Office ist hier nie gelaufen" stimmte nicht mehr. Jetzt steht da, was
  tatsächlich geprüft ist (Abbild, Start, Discovery, CheckFileInfo, Ausliefern,
  mobileEdit) und was nicht: PutFile, also das Zurückschreiben.
- Der Absatz zu /ConvertService.ashx beschrieb gelöschten Code.
- OFFICE_PUBLIC_URL: die Falle benannt, die einen Tag gekostet hat. Stand dort
  localhost, lief alles am Docker-Rechner und blieb am Telefon schwarz – dort
  ist localhost das Telefon. Ohne Fehlermeldung.
- Zur Portbindung: Die Office-Schnipsel binden auf 0.0.0.0, anders als Aether
  selbst. Das ist nötig, weil der Browser direkt hin muss – steht jetzt dabei,
  samt dem sauberen Weg über einen Reverse Proxy.

Geprüft: go build/vet sauber, alle 234 Prüfungen der sieben Selbsttest-Suiten
grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:10:17 +02:00

964 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Aether
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` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – sie beschreibt,
was die Anwendung liest, und das ist in beiden Fällen dasselbe. Sie lag bis zum
02.09.2026 doppelt vor (einmal schlank hier, einmal vollständig im Bau-Ordner),
und die beiden liefen auseinander: Der produktiven fehlten am Ende zwölf
Schlüssel, darunter `SESSION_MAX_AGE`.
Die Schalter des **mitgelieferten Collabora-Containers** (`aliasgroup1`, SSL,
Port) stehen nicht dort, sondern kommentiert in `build/docker-compose.yml` –
sie konfigurieren einen fremden Container, nicht Aether. Wer Office selbst
betreibt, richtet sie in dessen eigener Compose-Datei ein.
### 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 .env.example build/.env # beim Entwickeln (dorthin schaut das Compose)
```
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
```
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, HoneyOS-Leiste – steht mit
Erklärung in derselben Datei, nach Abschnitten geordnet. Absichtlich
ausführlich: Sie ist auch die Übersicht darüber, was überhaupt geht.
### 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 build/docker-compose.yml build app
docker compose -f build/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
**Eine** Beispieldatei: `.env.example` im Wurzelverzeichnis, für Betrieb wie
Entwicklung. Sie ist 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.
**`SESSION_MAX_AGE`** ist die Frist **ab der letzten Benutzung**, nicht ab dem
Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie 30 Tage
nicht anfasst, meldet sich neu an. Verlängert wird höchstens einmal pro
Stunde – sonst trüge jede Antwort ein `Set-Cookie`.
Was das kostet, damit es niemanden überrascht: Ein Cookie, das jemand aus
einem fremden Browser kopiert, 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 sofort
beenden heißt: `SESSION_SECRET` ändern und neu starten. Bei einem gemeinsam
genutzten Rechner ist eine kürzere Frist die bessere Wahl.
**`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. Der richtige Wert
hängt davon ab, **wo dein Reverse Proxy steht:**
| Proxy läuft … | Wert |
|---|---|
| auf einem anderen Rechner (VPS, Router) | `0.0.0.0` – die Vorgabe |
| hinter einem Tunnel (WireGuard, Tailscale) | die Tunnel-Adresse, z. B. `10.0.0.5` |
| auf demselben Rechner | `127.0.0.1` |
| als Container im selben Docker-Netz | dann braucht es `ports:` gar nicht – der Proxy erreicht `app:8080` intern |
`127.0.0.1` heißt **nur dieser Rechner**: Von einem anderen Gerät kommt kein
Zeitüberlauf, sondern sofort `ERR_CONNECTION_REFUSED` – der Kernel lehnt ab,
bevor irgendetwas die Anwendung erreicht. Wer den Proxy daneben stehen hat,
sollte genau das nehmen.
Die Vorgabe ist trotzdem `0.0.0.0`, weil der häufigere Fall ein Proxy auf einem
anderen Rechner ist, den loopback gar nicht erst herankommen ließe. Was das
kostet, damit es niemanden überrascht: Der Port ist dann auch aus dem eigenen
Netz offen, und **TLS endet erst am Proxy** – auf dem Weg dorthin liegt die
Anmeldung unverschlüsselt im Netz. Wer das nicht will, engt den Wert ein oder
lässt nur den Proxy durch die Firewall.
**`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 build/
IMG=gitea.honeyy.duckdns.org/docker/aether
docker build -f build/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/
├── build/
│ ├── 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.
---
## Dateien von außen: Abgleich mit der Datenbank
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
und löscht sie.
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
| Fall | Was der Abgleich tut |
|---|---|
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
Papierkorb – es ist endgültig.
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
eigenen Datei.
### Für Sicherungen heißt das
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
auf Wunsch weg.
### Der große Fall: alles zurück, ohne Postgres
Einzelne Dateien zurückzuholen ist das eine. Der Ernstfall ist der andere:
Der Server ist fort, aus der Sicherung kommen nur die **Dateien** zurück, die
Datenbank ist leer.
Das geht – aber nur in dieser Reihenfolge, und der zweite Schritt ist der,
den man vergisst:
1. **Dateien zurücklegen** nach `DATA_PATH`, mit der Struktur aus der
Sicherung (`users/<konto>/home/…`). Aether starten.
2. **Konten neu anlegen – mit exakt denselben Benutzernamen.** Der Ordnername
auf der Platte *ist* die Zuordnung; ein Konto `mueller` findet nichts, was
unter `users/Mueller/` liegt. Passwörter dürfen neu sein, Namen nicht.
3. **Admin → Audit → Rescan storage**, erst *Check*, dann *Apply*.
Ohne Schritt 2 bleibt der Abgleich stehen und sagt es je Datei:
`Konto "altnutzer" gibt es nicht`. Er rät nicht und legt nichts an – ein
falsch zugeordnetes Dokument wäre schlimmer als ein unsichtbares.
**Einmal geprobt, nicht behauptet** (10.09.2026): Auf einer eigens
aufgesetzten Instanz Dateien gesichert, Container *und* Datenbank
weggeworfen, nur die Dateien zurückgeholt. Nach den drei Schritten standen
Ordnerkette, Unterordner, Umlaute im Dateinamen, Größen, Prüfsummen und
Dateitypen wieder da, und der Download lieferte den ursprünglichen Inhalt.
`.trash/` und `.versions/` blieben zu Recht außen vor. Ein dritter Abgleich
meldete nichts Offenes mehr.
**Was dabei nicht zurückkommt**, weil es nur in der Datenbank steht:
Etiketten, Absender, Belegdatum, Freigabelinks, frühere Fassungen, der
erkannte Text der Volltextsuche (er wird neu erzeugt), Kalender und Termine.
Wem das wehtut, der sichert Postgres mit – die Dateien allein sind die
Rückfallebene, nicht der volle Umfang.
### Aktualisieren einer laufenden Instanz
Neues Abbild ziehen und neu starten, mehr nicht: Die Migrationen laufen beim
Start selbst. Geprobt am 10.09.2026 von Schema 19 auf 23 mit Daten in der
Datenbank – Konten, Ordner, Dateien und Etikettenzuordnungen standen danach
unverändert da, kein `dirty`-Zustand. **Ein Neuaufbau ist dafür nicht nötig
und bringt nichts**, was das Update nicht auch bringt.
---
## Kalender (CalDAV)
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
automatische Einrichtung über `/.well-known/caldav` laufen lassen, die jeder
verbreitete Client von selbst versucht.
Angemeldet wird sich wie bei WebDAV mit **Benutzername und App-Passwort**; das
Kontopasswort wird auch hier abgewiesen. Dieselbe Sperre nach zehn
Fehlanmeldungen gilt ebenfalls.
Was geht:
- **Termine** lesen und schreiben, mit Wiederholungen, Ausnahmen einzelner
Termine (`EXDATE`), Ganztagsterminen und Zeitzonen.
- **Mehrere Kalender**, jeder mit Name und Farbe. Anlegen, umbenennen,
umfärben und löschen geht in der Weboberfläche **und** vom Client aus
(`MKCALENDAR`, `MKCOL`, `PROPPATCH`, `DELETE`).
- **Termin in einen anderen Kalender umhängen**, ohne dass er dabei seine
Kennung verliert – für Clients derselbe Termin an neuer Adresse, kein neuer.
- **Farben** in beide Richtungen: die des Kalenders (`calendar-color`) und die
einzelner Termine (`COLOR` nach RFC 7986, als CSS3-Name, weil DAVx5 sie so
liest).
- **Im- und Export** ganzer Kalender als `.ics`.
Zwei Dinge, die man beim Einrichten wissen sollte:
**Die Kalenderfarbe holt DAVx5 nur beim Erfassen der Sammlungen**, nicht bei
jedem Abgleich. Ändert sich die Farbe hier, bleibt sie im Telefon so lange
alt, bis man dort einmal *Kalenderliste aktualisieren* (⟳) antippt. Das ist
kein Fehler, sondern DAVx5' Sparsamkeit – kostet aber eine halbe Stunde
Suche, wenn man es nicht weiß.
**Kalender verwalten kann die Kalender-App des Telefons nicht.** Android
überlässt das der Sync-App: Anlegen und Löschen geschieht in DAVx5 selbst,
nicht in der Samsung- oder Google-Kalender-App.
Noch nicht da: `sync-collection` (RFC 6578). Jeder Abgleich holt deshalb die
volle Terminliste statt nur der Änderungen – richtig, aber sparsamer ginge es.
Ebenso offen: Aufgaben (`VTODO`) in der Weboberfläche und das Teilen von
Kalendern mit anderen Nutzern.
Zwei Selbsttests decken den Bereich ab, einer je Zugangsweg – siehe
[`TESTING.md`](TESTING.md).
---
## 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.**
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
> den Proxy die öffentliche Adresse bedienen.
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
`docker compose stop office`. Wer den Container lieber ganz aus der normalen
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
`--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:-0.0.0.0}: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:-0.0.0.0}: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:-0.0.0.0}:9980:80"
```
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das
Abbild.
> **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
> Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
> auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
> `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
> zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
> stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
> ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
> über die Ermittlung hinaus.
>
> Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
> betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
> Collabora daneben weiterlief.
>
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
> Fehler meldet sich nicht, er zeigt nur nichts.
>
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
> an denen es nach Aktenlage hakt:
>
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
> 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` liest Aether nicht.** Es schützt die *eigene* API des
> Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
> 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
> PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
> Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
> **Der Office-Container liest es**, und ohne steht seine API offen, wenn
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
> Aether ohne jedes Signieren – der WOPI-Weg läuft.
### Welche Dateien, und wie gut
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)** | **51 MB im Leerlauf** (gemessen), +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
| **OnlyOffice Docs** | **396 MB im Leerlauf** (gemessen), 2 GB Minimum unter Last, **4 GB empfohlen** | bringt eigenes Postgres, RabbitMQ und Node mit |
Die Leerlaufwerte sind auf dieser Anlage nachgemessen und liegen deutlich
unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
| **Euro-Office** | wie OnlyOffice, Abbild **6,99 GB** (hier nachgemessen) | derselbe Unterbau |
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
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 **6,99 GB** – nachgemessen, nicht
geschätzt; die Ankündigung nennt weniger.
**Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die
Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu
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 | 🔲 |
| — | [Kalender](#kalender-caldav): CalDAV, mehrere Kalender, Weboberfläche (Monat/Woche/Liste) – `sync-collection`, Aufgaben und Teilen offen | ✅ (im Test) |
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).