# 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: 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** – eine Datei, alle Einstellungen. 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`, und sie empfahl für `OFFICE_WOPI_URL` einen Wert, der mit Collabora nicht funktioniert. ### 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= ADMIN_USERNAME=admin ADMIN_PASSWORD= # nach dem ersten Start wieder leeren ``` Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, App-Umschalter – steht mit Erklärung in derselben Datei, nach Abschnitten geordnet. Der letzte Abschnitt gilt nur für den Entwicklungs-Compose; beim Betrieb mit eigenem Office-Container stehen diese Werte in dessen Compose-Datei. ### 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. 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 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: ``` / ├── users//home/… ← die eigenen Dateien, echte Namen │ /.trash/… ← Papierkorb │ /.versions/… ← frühere Fassungen ├── groups//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:///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///`. Trägt ein Gruppenordner denselben Namen wie ein eigener, behält der eigene ihn und der Gruppenordner bekommt den Gruppennamen angehängt (`Projekt (Buero)`). Nach zehn Fehlanmeldungen in 15 Minuten sperrt die IP für den Rest des Fensters (`429`). Normale Anfragen zählen nicht mit – Sync-Clients senden legitim hunderte pro Minute. --- ## Office (Collabora / OnlyOffice / Euro-Office) Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server sich eine Datei holen und wieder zurückschreiben – **alle drei sprechen dieselbe.** Deshalb gibt es *einen* Adapter und einen Schalter in der `.env`, statt derselben Sache dreimal. Im Compose-Beispiel ist deshalb **kein** Office-Server enthalten: Jeder bringt eigene Variablen mit, und man will genau einen. Such dir unten den passenden Schnipsel und häng ihn an dein `docker-compose.yml`. ### Was dazukommt | | | |---|---| | `OFFICE_PROVIDER` | `collabora`, `onlyoffice` oder leer (aus) – Euro-Office zählt als `onlyoffice` | | `OFFICE_URL` | wo das **Backend** den Office-Server erreicht | | `OFFICE_PUBLIC_URL` | wo der **Browser** ihn erreicht (leer = wie oben) | | `OFFICE_WOPI_URL` | wo der **Office-Server** Aether erreicht (leer = `PUBLIC_URL`) | | `OFFICE_ALLOWED_HOSTS` | Ziele, die das Backend anfragen darf – gegen SSRF | **Drei Adressen für drei Richtungen** – die häufigste Fehlerquelle beim Aufbau. Im Compose-Netz heißt der Server `office`, der Browser braucht dort einen Namen, den *er* auflösen kann (deine Domain oder LAN-IP), und der Office-Server erreicht Aether unter `app:8080`: „localhost" ist aus seinem Container heraus er selbst. Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980, OnlyOffice und Euro-Office auf 80.** Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten `docker compose stop office`. Wer den Container lieber ganz aus der normalen Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es `--profile office` oder `COMPOSE_PROFILES=office` in der `.env`. **Vorsicht:** Oberflächen wie Portainer, Dockge oder Komodo kennen `--profile` nicht; dort entsteht der Container dann nie, ohne jede Fehlermeldung. ### Schnipsel 1: Collabora ```yaml office: container_name: aether-office image: collabora/code:latest restart: unless-stopped environment: # Wer seine Dateien hier öffnen darf – **die Adresse, unter der dein # Browser die Cloud sieht**, nicht der interne Dienstname. Collabora # liest den Wert als regulären Ausdruck, Punkte müssen also maskiert # werden, und der Port gehört dazu (443 bei HTTPS). # # Steht hier der falsche Wert, meldet das Log beim Öffnen: # No authorized hosts found matching the target host […] in config aliasgroup1: https://cloud\.example\.com:443 # ssl.enable – spricht Collabora selbst HTTPS? # ssl.termination – endet HTTPS davor, an deinem Reverse Proxy? # Steht termination auf true ohne HTTPS davor, verlangt Collabora eine # wss://-Verbindung, die es nicht gibt: Der Editor lädt, das Dokument # bleibt weiß. mount_jail_tree=false spart CAP_SYS_ADMIN. extra_params: >- --o:ssl.enable=false --o:ssl.termination=true --o:mount_jail_tree=false dictionaries: de_DE en_US cap_add: [MKNOD] networks: [internal] ports: - "${APP_BIND:-127.0.0.1}:9980:9980" ``` ```env OFFICE_PROVIDER=collabora OFFICE_URL=http://office:9980 OFFICE_PUBLIC_URL=https://office.example.com # **Öffentliche Adresse, nicht app:8080** – Begründung direkt darunter. OFFICE_WOPI_URL=https://cloud.example.com OFFICE_ALLOWED_HOSTS=office ``` > **Warum bei Collabora die öffentliche Adresse und nicht `app:8080`?** > > Weil Collabora aus dieser Adresse seine eigene Sicherheitsrichtlinie baut. > Gemessen: Es setzt `frame-ancestors` auf *seinen* Host **plus den Host aus > `WOPISrc`* – und `WOPISrc` ist genau `OFFICE_WOPI_URL`. Steht dort der > interne Dienstname, erlaubt der Editor die Einbettung nur einer Seite, die > unter `app:8080` läuft. Dein Browser kommt aber von `cloud.example.com`, und > er lehnt ab: > > ``` > Framing 'https://office.example.com/' violates > 'frame-ancestors office.example.com:* app:8080' > ``` > > Die Folge davon muss man wollen: Collabora holt die Datei dann **über deinen > Reverse Proxy**, nicht quer durchs Docker-Netz. Der Container braucht dafür > Namensauflösung nach draußen und einen Weg zurück – bei einem Proxy auf > einem anderen Rechner ist das der normale Weg über das Internet. > > `aliasgroup1` oben muss **denselben** Host nennen. Zwei Schalter, eine > Wahrheit: Der eine entscheidet, ob der Browser den Rahmen zeigen darf, der > andere, ob Collabora die Anfrage überhaupt annimmt. ### Schnipsel 2: OnlyOffice Docs ```yaml office: container_name: aether-office image: onlyoffice/documentserver:latest restart: unless-stopped environment: WOPI_ENABLED: "true" # ohne das gibt es kein /hosting/discovery JWT_ENABLED: "true" JWT_SECRET: ${OFFICE_JWT_SECRET} networks: [internal] ports: - "${APP_BIND:-127.0.0.1}:9980:80" # OnlyOffice lauscht auf 80, nicht 9980 ``` ```env OFFICE_PROVIDER=onlyoffice OFFICE_URL=http://office OFFICE_PUBLIC_URL=https://office.example.com OFFICE_WOPI_URL=http://app:8080 OFFICE_ALLOWED_HOSTS=office OFFICE_JWT_SECRET= # mindestens 32 Zeichen ``` ### Schnipsel 3: Euro-Office Seit dem 9. Juni 2026 zu haben und in Nextcloud Hub 26 die Alternative zu Collabora. Es ist ein OnlyOffice-Fork, und man sieht es an den Variablen: Sie heißen gleich. Für uns ist es deshalb **derselbe Anbieter** – `OFFICE_PROVIDER=onlyoffice`, nur ein anderes Abbild. ```yaml office: container_name: aether-office image: ghcr.io/euro-office/documentserver:latest restart: unless-stopped environment: WOPI_ENABLED: "true" JWT_ENABLED: "true" JWT_SECRET: ${OFFICE_JWT_SECRET} EXAMPLE_ENABLED: "false" # die Beispiel-App gehört nicht ins Netz ALLOW_PRIVATE_IP_ADDRESS: "true" # es muss app:8080 erreichen networks: [internal] ports: - "${APP_BIND:-127.0.0.1}:9980:80" ``` Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das Abbild. > **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von > Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser > Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die > JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf > braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern, > weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der > WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der > Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen. > > Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es > nach Aktenlage hakt: > > 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem > „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner > Positivliste stehen. Bei Collabora heißt dasselbe `aliasgroup1`. > 2. **Der Proof-Key.** OnlyOffice signiert seine WOPI-Anfragen und legt den > öffentlichen Schlüssel als `proof-key` in die Discovery. Wir prüfen die > Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre > das der Ort. > > `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den > WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST > /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur > (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer` > über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also > gesetzt – sonst läuft die API offen. ### Am Telefon: bei OnlyOffice ansehen statt bearbeiten Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab, was aufgeht: | Anbieter | am Telefon | |---|---| | **Collabora** | der Editor – seine eigene Handy-Ansicht taugt | | **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein | Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Tastatur. Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig. Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg nicht. Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf` wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird, entscheidet der Anbieter. Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein LibreOffice-Layout desselben Dokuments sehen. | | | |---|---| | Ablage | `previews/.pdf` – **eine** Datei je Dokument, wird überschrieben | | Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt | | Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen | | Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner | | Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an | | Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` | Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der Bereinigung, zusammen mit Vorschau- und Profilbildern. ### Welche Dateien, und wie gut Bearbeitet wird nur, was auf der **Positivliste** steht: `odt docx doc rtf txt · ods xlsx xls csv · odp pptx ppt · odg`. PDF steht bewusst **nicht** darauf – es hat seinen eigenen Betrachter, und ein Office-Server würde beim Speichern ein anderes Dokument daraus machen. Collabora Online *ist* LibreOffice, mit derselben Datei-Unterstützung. Für die Microsoft-Formate heißt das: | | | |---|---| | `docx`, `xlsx`, `pptx` | öffnen und speichern zuverlässig, Format bleibt erhalten | | Alte `doc`, `xls`, `ppt` | gehen, sind aber Rückwärtskompatibilität – kein Ziel der Entwicklung | | Komplexes Layout | Seitenumbrüche und Schriftabstände können um Kleinigkeiten abweichen | | Sehr große Tabellen, Pivot, seltene Formeln | die häufigste Stelle für echte Abweichungen | | Makros (VBA) | laufen nicht – und das ist Absicht, nicht Mangel | Fehlende **Schriften** sind der häufigste Grund für „sieht anders aus": Der Container bringt Calibri und Cambria nicht mit. Wer Dokumente von außen bearbeitet, legt die Schriften nach. Für ein Belegarchiv ist das unkritisch – da wird gelesen und selten geändert. Für den täglichen Umgang mit fremden Word-Dateien gilt: **Rundlauf prüfen**, nicht darauf vertrauen. ### Wie das zusammenspielt Der Office-Server ist **kein Client deines Browsers allein** – er holt die Datei selbst beim Backend ab: ``` Browser ──────────► Office-Server (Bedienoberfläche im