Ein $ im Wert wird von Docker Compose als Variable gelesen und schneidet ab: Sicher$Passwort2026 kommt als Sicher an (gemessen). Angelegt wird ein Konto mit einem Passwort, das niemand kennt; die Anmeldung scheitert mit 'invalid credentials', obwohl alles richtig eingetippt ist. Die Datenbank laeuft dabei weiter, weil Postgres denselben abgeschnittenen Wert bekommt. Der Start nennt jetzt die Laenge des Passworts (nicht den Wert) und warnt unter 12 Zeichen mit dem Hinweis auf $$. Ohne diese Zahl war der Fehler von aussen nicht zu sehen: Compose meldet zwar 'variable is not set', aber auf stderr beim up, nicht im Container-Log. Escape-Tabelle in README und beiden .env.example. Ein Backslash hilft nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
649 lines
25 KiB
Markdown
649 lines
25 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**. 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_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.
|
||
|
||
### 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.** Das wirkt übertrieben, bis man es einmal
|
||
aufgebaut hat: Im Compose-Netz heißt der Office-Server `collabora`, im Browser
|
||
muss dort ein auflösbarer Name stehen, und der Office-Server wiederum erreicht
|
||
Aether unter `app:8080` – „localhost" ist aus seinem Container heraus er selbst.
|
||
|
||
**Drei Adressen für drei Richtungen.** Das wirkt übertrieben, bis man es einmal
|
||
aufgebaut hat – die häufigste Fehlerquelle beim Aufbau.
|
||
|
||
Alle Schnipsel unten legen den Dienst ins Profil `office`. Ohne das Profil
|
||
startet er nicht, und wer kein Office will, gibt kein Gigabyte dafür ab:
|
||
|
||
```bash
|
||
docker compose --profile office up -d
|
||
```
|
||
|
||
### Schnipsel 1: Collabora
|
||
|
||
```yaml
|
||
office:
|
||
container_name: aether-office
|
||
image: collabora/code:latest
|
||
profiles: ["office"]
|
||
restart: unless-stopped
|
||
environment:
|
||
# Wer seine Dateien hier öffnen darf. Punkte maskieren – Collabora liest
|
||
# den Wert als regulären Ausdruck.
|
||
aliasgroup1: http://app:8080
|
||
# 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
|
||
OFFICE_WOPI_URL=http://app:8080
|
||
OFFICE_ALLOWED_HOSTS=office
|
||
```
|
||
|
||
### Schnipsel 2: OnlyOffice Docs
|
||
|
||
```yaml
|
||
office:
|
||
container_name: aether-office
|
||
image: onlyoffice/documentserver:latest
|
||
profiles: ["office"]
|
||
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
|
||
profiles: ["office"]
|
||
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.
|
||
|
||
> **Ungeprüft.** Collabora ist hier gemessen worden, den Kreis von Tippen bis
|
||
> Speichern eingeschlossen. Die beiden anderen Schnipsel stehen nach ihrer
|
||
> Dokumentation und dem gemeinsamen WOPI-Weg, sind auf dieser Anlage aber nie
|
||
> gelaufen – für OnlyOffice fehlt schlicht der Arbeitsspeicher.
|
||
|
||
### 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 |
|
||
|---|---|
|
||
| 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. |
|
||
| 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).
|