marcandClaude Opus 5 944b2c63e2 feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices
Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war
falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und
OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe
Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche.
`EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus.

Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271)
samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC,
die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen
anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für
diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien
unter previews/ sammelt der GC ein.

Zwei Fehler, die dabei sichtbar wurden:

fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim
Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose
ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte,
den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste
Schritte verschluckt genau diese popstate.

fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und
lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am
Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst`
standen nie drin; das war die Asymmetrie.

Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die
Ankündigung nennt 5) und die Android-App als Idee.

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

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

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:

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:

git clone https://gitea.honeyy.duckdns.org/Docker/Aether.git aether
cd aether

2. .env anlegen

cp .env.example .env          # beim Betreiben
cp .env.example build/.env    # beim Entwickeln (dorthin schaut das Compose)

Ausfüllen musst du mindestens:

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

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:

docker compose -f build/docker-compose.yml build app
docker compose -f build/docker-compose.yml up -d

Aktualisieren:

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:

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 ausgeschaltet 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 – 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

# 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.

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):

docker run --rm -v "$PWD/../backend":/src -w /src golang:1.25-alpine go build ./...

Frontend

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

# 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:

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:

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.


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

  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"
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

  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
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.

  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.

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: die Handy-Fassung des Editors

Ein Office-Dokument öffnet überall im Editor – am Schreibtisch wie am Telefon. Am Telefon fordert Aether dabei die Handy-Fassung an, wo der Office-Server eine anbietet:

Anbieter am Telefon
Collabora seine eigene Handy-Ansicht (kennt keine getrennte Aktion)
OnlyOffice / Euro-Office die WOPI-Aktion mobileEdit

Die Ermittlungsliste nennt zu jeder Endung mehrere Aktionen. OnlyOffice und Euro-Office führen neben edit/view auch mobileEdit/mobileView, und dahinter steckt eine eigene, für kleine Schirme gebaute Oberfläche – dieselbe Adresse mit ?mobile=1. Aether hat bis zum 16.09.2026 immer die Schreibtisch-Fassung angefordert; am Telefon blieb zwischen Menüband, Formatleisten und Eigenschaftenbereich vom Dokument ein Rechteck übrig.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus und eine Tastatur. Die Oberfläche schickt dazu ?mobile=1 an /api/v1/office/session/{id}.

Entfallen am 16.09.2026: die PDF-Ansicht. Ein Office-Dokument wurde am Telefon zuvor als gewandeltes PDF gezeigt, mit einem „Edit"-Knopf hinein. Das war eine Notlösung für eine Oberfläche, die Aether selbst falsch angefordert hatte – mit mobileEdit ist der Anlass weg. Mit ihr sind internal/office/preview.go, convert.go und die Spalte files.preview_version gefallen (Migration 000026); die Dateien unter previews/ sammelt der GC ein. Wer ein PDF braucht, exportiert es im Editor. Die Umwandlung im Hintergrund (/cool/convert-to/pdf bzw. /ConvertService.ashx) hatte keinen anderen Aufrufer und ist damit ebenfalls entfallen.

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 ✅ (im Test)
8 Härtung, 2FA, Observability, Extension-Points 🔲
— Kalender: 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.

S
Description
No description provided
Readme
4.1 MiB
0 Stars 1 Watchers 0 Forks
Languages
Go 45.7%
TypeScript 45.6%
Python 6.8%
CSS 0.9%
PLpgSQL 0.6%
Other 0.3%