Der Knopf hiess fest "Sign in with SSO / OIDC". Der Anbieter heisst bei jedem anders; "Anmelden mit PocketID" sagt dem, der davorsteht, mehr. Vorgabe unveraendert. Der Wert geht nur ueber /public/config, wenn OIDC ueberhaupt an ist. Beim Testen dabei selbst einen Fehler gebaut und eingefangen: Ein Kommentar hinter einem LEEREN Wert wird in einer .env zum Wert. OIDC_ISSUER= # z. B. https://... startete den OIDC-Aufbau mit dem Kommentartext als Adresse, und der Start brach ab. In deploy/.env.example steckten vier weitere solche Zeilen, darunter OFFICE_PROVIDER= # collabora | onlyoffice - Office waere mit Unsinn als Anbieter eingeschaltet worden. Alle umgestellt, Warnung an den Dateikopf. Dazu im README belegt, was beim Ausschalten der Versionierung passiert: Fassungen bleiben, auch im Papierkorb; erst das Leeren entfernt sie samt Blobs, und zwar sofort statt erst beim GC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
716 lines
28 KiB
Markdown
716 lines
28 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.
|
||
|
||
**`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. 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
|
||
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.
|
||
|
||
> **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.
|
||
>
|
||
> 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 nach der Dokumentation die eigene API des
|
||
> Dokumentenservers, nicht den WOPI-Weg – unser Adapter signiert damit nichts.
|
||
> Der Schlüssel gehört trotzdem gesetzt, sonst läuft der Server offen.
|
||
|
||
### 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. |
|
||
| 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).
|