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>
964 lines
42 KiB
Markdown
964 lines
42 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` 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).
|