Das Compose in deploy/ war zu über der Hälfte Kommentar (73 von 137
Zeilen) und mischte zwei Zwecke. Jetzt getrennt:
Wurzel docker-compose.example.yml + .env.example – betreiben,
zieht das fertige Abbild, braucht keinen Quelltext
deploy/ docker-compose.yml + .env.example – entwickeln,
baut aus dem Quelltext, kennt alle Schalter
Die schlanke .env.example führt nur die Pflichtwerte und Office; die
vollständige Liste mit Erklärungen bleibt in deploy/.env.example.
Die kopierte /docker-compose.yml ist ignoriert – sie gehört in die
Umgebung, nicht ins Repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
438 lines
17 KiB
Markdown
438 lines
17 KiB
Markdown
# Aether
|
||
|
||
Self-hosted cloud storage — schnelle, schlanke Alternative zu Nextcloud.
|
||
|
||
**Stack:** Go + chi · React + TS + Vite · PostgreSQL · Docker Compose
|
||
|
||
---
|
||
|
||
## Reverse Proxy (Pflicht)
|
||
|
||
Aether lauscht intern auf reinem HTTP. TLS wird von deinem eigenen Reverse Proxy terminiert.
|
||
**nginx, Traefik, Cloudflare Tunnel** – alles funktioniert. Dieses Projekt
|
||
liefert bewusst **keinen** Proxy mit: TLS, Zertifikate und Domain gehören in
|
||
deine Hand, nicht in ein Compose-File, das du ohnehin überschreiben müsstest.
|
||
|
||
Der Proxy muss folgende Headers setzen:
|
||
|
||
```
|
||
X-Forwarded-For: <echte Client-IP>
|
||
X-Forwarded-Proto: https
|
||
```
|
||
|
||
Die IP deines Proxys muss in `TRUSTED_PROXIES` stehen – sonst ignoriert Aether diese Header
|
||
(Sicherheitsmaßnahme gegen IP-Spoofing).
|
||
|
||
Der Proxy muss folgendes durchreichen (keine eigene Verarbeitung):
|
||
- Alle HTTP-Methoden inkl. `PROPFIND`, `MKCOL`, `MOVE`, `COPY` (für WebDAV)
|
||
- Große Uploads (Timeout und Body-Size-Limit großzügig setzen)
|
||
- WebSocket-Verbindungen (für Office-Co-Editing, Phase 6)
|
||
- HSTS-Header setzen (z. B. `Strict-Transport-Security: max-age=31536000; includeSubDomains`)
|
||
|
||
## Schnellstart (lokal)
|
||
|
||
### Voraussetzungen
|
||
|
||
- Docker + Docker Compose
|
||
- Go 1.25+ und Node 20+ **nur**, wenn du aus dem Quelltext bauen willst –
|
||
zum Betreiben genügt das fertige Abbild (siehe Schritt 3)
|
||
|
||
Zwei Wege, je nachdem was du vorhast:
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Betreiben** | `docker-compose.example.yml` + `.env.example` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||
| **Entwickeln** | `deploy/docker-compose.yml` + `deploy/.env.example`. Baut aus dem Quelltext, kennt alle Schalter. |
|
||
|
||
### 1. Dateien holen
|
||
|
||
Zum Betreiben genügen zwei Dateien – kein Klonen nötig:
|
||
|
||
```bash
|
||
mkdir aether && cd aether
|
||
curl -O https://gitea.honeyy.duckdns.org/Docker/Aether/raw/branch/main/docker-compose.example.yml
|
||
curl -O https://gitea.honeyy.duckdns.org/Docker/Aether/raw/branch/main/.env.example
|
||
mv docker-compose.example.yml docker-compose.yml
|
||
```
|
||
|
||
Zum Entwickeln stattdessen das Repository:
|
||
|
||
```bash
|
||
git clone https://gitea.honeyy.duckdns.org/Docker/Aether.git aether
|
||
cd aether
|
||
```
|
||
|
||
### 2. `.env` anlegen
|
||
|
||
```bash
|
||
cp .env.example .env # beim Betreiben
|
||
cp deploy/.env.example deploy/.env # beim Entwickeln
|
||
```
|
||
|
||
Ausfüllen musst du mindestens:
|
||
|
||
```env
|
||
PUBLIC_URL=https://cloud.example.com
|
||
SESSION_SECRET= # openssl rand -hex 32
|
||
DB_PASSWORD=
|
||
DATA_PATH=/pfad/zu/aether-data
|
||
APP_UID=1000 # id -u
|
||
APP_GID=1000 # id -g
|
||
TRUSTED_PROXIES=<IP-deines-Proxys>
|
||
ADMIN_USERNAME=admin
|
||
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
|
||
```
|
||
|
||
Die schlanke `.env.example` im Wurzelverzeichnis führt nur diese Pflichtwerte
|
||
und Office. Alles Weitere – OIDC, OCR, Vorschaubilder, Kontingente – steht mit
|
||
Erklärung in `deploy/.env.example`.
|
||
|
||
### 3. Starten
|
||
|
||
```bash
|
||
docker login gitea.honeyy.duckdns.org # nur beim ersten Mal
|
||
docker compose up -d
|
||
```
|
||
|
||
Beim ersten Start:
|
||
- Postgres-Migrations laufen automatisch
|
||
- Der erste Admin-Account wird aus `ADMIN_USERNAME` / `ADMIN_PASSWORD` angelegt
|
||
- Nach dem Start: `ADMIN_PASSWORD` aus `.env` entfernen oder leer lassen
|
||
|
||
Aus dem Quelltext bauen statt ziehen:
|
||
|
||
```bash
|
||
docker compose -f deploy/docker-compose.yml build app
|
||
docker compose -f deploy/docker-compose.yml up -d
|
||
```
|
||
|
||
Aktualisieren:
|
||
|
||
```bash
|
||
docker compose pull app && docker compose up -d app
|
||
```
|
||
|
||
### 4. Öffnen
|
||
|
||
Die App ist über deinen Reverse Proxy erreichbar (z. B. `https://cloud.example.com`).
|
||
Direkt (ohne Proxy): http://localhost:8080/health
|
||
|
||
---
|
||
|
||
## Abbild bauen und veröffentlichen
|
||
|
||
```bash
|
||
# im Wurzelverzeichnis des Projekts, nicht in deploy/
|
||
IMG=gitea.honeyy.duckdns.org/docker/aether
|
||
docker build -f deploy/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
|
||
docker push $IMG:$(date +%Y.%m.%d)
|
||
docker push $IMG:latest
|
||
```
|
||
|
||
**Zwei Tags, mit Absicht.** `latest` ist der bewegliche Name zum Aufbauen, der
|
||
datierte der feste zum Zurückrollen. Wer nur `latest` fährt, hat im Fehlerfall
|
||
nichts, worauf er zurück kann – der Name zeigt dann schon auf die Fassung, die
|
||
gerade Ärger macht.
|
||
|
||
Das Abbild wiegt rund **540 MB**. Den Löwenanteil machen `ocrmypdf`,
|
||
`tesseract` samt Sprachmodellen und `poppler-utils` aus – ohne sie wären es
|
||
etwa 210 MB, aber auch keine Volltextsuche in Scans. Wer das nicht braucht,
|
||
streicht die zweite `apk`-Zeile im Dockerfile und setzt `OCR_ENABLED=false`;
|
||
der Server prüft beim Start selbst, ob die Programme da sind, und läuft sonst
|
||
ohne Volltextsuche weiter.
|
||
|
||
Gebaut wird für die Architektur des Wirts. Für ein anderes Ziel (etwa ein
|
||
ARM-Gerät) braucht es `docker buildx build --platform linux/arm64`.
|
||
|
||
---
|
||
|
||
## Lokale Entwicklung
|
||
|
||
### Backend (immer via Docker)
|
||
|
||
Das Backend wird **ausschließlich über Docker Compose** gestartet – nie mit `go run`.
|
||
Postgres-Migrations und DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
|
||
|
||
```bash
|
||
cd deploy
|
||
docker compose up -d --build # Backend + Postgres starten
|
||
docker compose logs -f app # Logs verfolgen
|
||
docker compose down # stoppen
|
||
```
|
||
|
||
Code-Änderungen am Backend: `docker compose up -d --build` neu ausführen (Image neu bauen).
|
||
|
||
**Compile-Check ohne lokales Go-Toolchain** (der Host hat kein Go installiert):
|
||
|
||
```bash
|
||
docker run --rm -v "$PWD/../backend":/src -w /src golang:1.25-alpine go build ./...
|
||
```
|
||
|
||
### Frontend
|
||
|
||
```bash
|
||
cd frontend
|
||
npm install
|
||
npm run dev # http://localhost:5173 (Vite dev server, spricht Backend auf :8080 an)
|
||
npm run build # Production-Build (wird vom Backend-Image mitgebaut)
|
||
```
|
||
|
||
### Tests
|
||
|
||
```bash
|
||
# Backend (im Go-Image, da Host kein Go hat)
|
||
docker run --rm -v "$PWD/backend":/src -w /src golang:1.25-alpine go test ./...
|
||
|
||
# Frontend
|
||
cd frontend && npm test
|
||
```
|
||
|
||
---
|
||
|
||
## Struktur
|
||
|
||
```
|
||
aether/
|
||
├── backend/
|
||
│ ├── cmd/aether/ # Einstiegspunkt
|
||
│ ├── internal/ # Domänen-Pakete (auth, storage, files, …)
|
||
│ ├── db/migrations/ # SQL-Migrations
|
||
│ └── pkg/ # Shared utils (config, middleware, response)
|
||
├── frontend/
|
||
│ └── src/
|
||
│ ├── api/ # Typed API-Client
|
||
│ ├── components/
|
||
│ ├── hooks/
|
||
│ └── pages/
|
||
├── deploy/
|
||
│ ├── docker-compose.yml # app + postgres (kein Proxy-Container)
|
||
│ ├── Dockerfile
|
||
│ └── .env.example
|
||
└── CLAUDE.md # Projektkonventionen
|
||
```
|
||
|
||
---
|
||
|
||
## Wo die Dateien liegen
|
||
|
||
Nicht in einem Docker-Volume, sondern in einem gewöhnlichen Verzeichnis auf dem
|
||
Wirt – `DATA_PATH` in der `.env`. Der Aufbau darin ist derselbe wie in der
|
||
Oberfläche:
|
||
|
||
```
|
||
<DATA_PATH>/
|
||
├── users/<benutzername>/home/… ← die eigenen Dateien, echte Namen
|
||
│ /.trash/… ← Papierkorb
|
||
│ /.versions/… ← frühere Fassungen
|
||
├── groups/<gruppen-id>/home/… ← Gruppenordner
|
||
├── thumbs/ ← Vorschaubilder
|
||
├── branding/ ← Logo und App-Symbol
|
||
└── tmp/ ← Zwischendateien, wird selbst geräumt
|
||
```
|
||
|
||
**Warum das so ist:** Ein Archiv ist nur dann eines, wenn man ohne die
|
||
Anwendung an seine Unterlagen kommt. Hier reicht dafür `scp -r` oder ein Blick
|
||
in den Dateimanager – keine Datenbank, kein Export, kein laufender Container
|
||
nötig. Bei einem benannten Volume läge alles unter
|
||
`/var/lib/docker/volumes/…` und gehörte `root`.
|
||
|
||
Damit die Dateien **dir** gehören und nicht einem Container-Nutzer, läuft der
|
||
Container unter deiner Kennung (`APP_UID`/`APP_GID`, siehe `.env`). Damit
|
||
funktioniert SFTP direkt:
|
||
|
||
```bash
|
||
sftp dein-nutzer@dein-server
|
||
get -r /pfad/zu/aether-data/users/deinname/home
|
||
```
|
||
|
||
> **Nur lesen.** Wer von Hand eine Datei hineinlegt, hat sie auf der Platte,
|
||
> aber nicht in der Datenbank: Die Cloud sieht sie nicht, und die
|
||
> Speicherbereinigung räumt sie irgendwann weg. Zum Hineinlegen ist WebDAV der
|
||
> Weg (siehe unten).
|
||
|
||
### Von einem benannten Volume umsteigen
|
||
|
||
Ältere Installationen hatten ein Volume `deploy_data`. Umzug mit Prüfung:
|
||
|
||
```bash
|
||
cd deploy
|
||
docker compose stop app
|
||
mkdir -p /pfad/zu/aether-data
|
||
docker run --rm -v deploy_data:/src:ro -v /pfad/zu/aether-data:/dst alpine:3.20 \
|
||
sh -c 'cp -a /src/. /dst/ && chown -R $(id -u):$(id -g) /dst'
|
||
|
||
# Prüfen, bevor umgeschaltet wird: beide Summen müssen gleich sein
|
||
docker run --rm -v deploy_data:/src:ro alpine:3.20 \
|
||
sh -c 'cd /src && find . -type f -exec sha256sum {} \; | LC_ALL=C sort | sha256sum'
|
||
(cd /pfad/zu/aether-data && find . -type f -exec sha256sum {} \; | LC_ALL=C sort | sha256sum)
|
||
|
||
# erst dann DATA_PATH in die .env eintragen und starten
|
||
docker compose up -d app
|
||
```
|
||
|
||
Das alte Volume **nicht sofort löschen**. Es ist der Rückfall, solange nicht
|
||
alles geprüft ist; `docker volume rm deploy_data` kann warten, bis du sicher
|
||
bist.
|
||
|
||
---
|
||
|
||
## WebDAV
|
||
|
||
Adresse: `https://<deine-domain>/dav/`
|
||
|
||
Angemeldet wird sich mit dem **Benutzernamen** und einem **App-Passwort**
|
||
(Profil → App passwords). Das Kontopasswort wird an `/dav/` **abgelehnt** – es
|
||
ist zugleich der Admin-Zugang und hat auf Geräten nichts verloren. Ein
|
||
App-Passwort gilt nur für WebDAV, nie fürs Web-Login, und lässt sich einzeln
|
||
widerrufen, ohne die anderen Geräte auszusperren.
|
||
|
||
Der Aufbau entspricht dem der Weboberfläche: eigene Ordner und Gruppenordner
|
||
liegen nebeneinander in der Wurzel, Freigaben von Personen unter
|
||
`/dav/Shared/<Person>/<Freigabe>/`. Trägt ein Gruppenordner denselben Namen wie
|
||
ein eigener, behält der eigene ihn und der Gruppenordner bekommt den
|
||
Gruppennamen angehängt (`Projekt (Buero)`).
|
||
|
||
Nach zehn Fehlanmeldungen in 15 Minuten sperrt die IP für den Rest des Fensters
|
||
(`429`). Normale Anfragen zählen nicht mit – Sync-Clients senden legitim
|
||
hunderte pro Minute.
|
||
|
||
---
|
||
|
||
## Office (Collabora / OnlyOffice)
|
||
|
||
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
|
||
sich eine Datei holen und wieder zurückschreiben – **Collabora und OnlyOffice
|
||
sprechen beide dieselbe.** Deshalb wird hier *ein* Adapter gebaut und der
|
||
Anbieter per `.env` umgeschaltet, statt zweimal dasselbe zu integrieren.
|
||
|
||
### Was dazukommt
|
||
|
||
| | |
|
||
|---|---|
|
||
| `OFFICE_PROVIDER` | `collabora`, `onlyoffice` oder leer (aus) |
|
||
| `OFFICE_URL` | wo das **Backend** den Office-Server erreicht |
|
||
| `OFFICE_PUBLIC_URL` | wo der **Browser** ihn erreicht (leer = wie oben) |
|
||
| `OFFICE_WOPI_URL` | wo der **Office-Server** Aether erreicht (leer = `PUBLIC_URL`) |
|
||
| `OFFICE_ALLOWED_HOSTS` | Ziele, die das Backend anfragen darf – gegen SSRF |
|
||
|
||
**Drei Adressen für drei Richtungen.** Das wirkt übertrieben, bis man es einmal
|
||
aufgebaut hat: Im Compose-Netz heißt der Office-Server `collabora`, im Browser
|
||
muss dort ein auflösbarer Name stehen, und der Office-Server wiederum erreicht
|
||
Aether unter `app:8080` – „localhost" ist aus seinem Container heraus er selbst.
|
||
|
||
Der Container startet nur mit dem Profil, sonst kostet er Arbeitsspeicher, ohne
|
||
gebraucht zu werden:
|
||
|
||
```bash
|
||
docker compose --profile office up -d
|
||
```
|
||
|
||
### Welche Dateien, und wie gut
|
||
|
||
Bearbeitet wird nur, was auf der **Positivliste** steht:
|
||
`odt docx doc rtf txt · ods xlsx xls csv · odp pptx ppt · odg`.
|
||
PDF steht bewusst **nicht** darauf – es hat seinen eigenen Betrachter, und ein
|
||
Office-Server würde beim Speichern ein anderes Dokument daraus machen.
|
||
|
||
Collabora Online *ist* LibreOffice, mit derselben Datei-Unterstützung. Für die
|
||
Microsoft-Formate heißt das:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `docx`, `xlsx`, `pptx` | öffnen und speichern zuverlässig, Format bleibt erhalten |
|
||
| Alte `doc`, `xls`, `ppt` | gehen, sind aber Rückwärtskompatibilität – kein Ziel der Entwicklung |
|
||
| Komplexes Layout | Seitenumbrüche und Schriftabstände können um Kleinigkeiten abweichen |
|
||
| Sehr große Tabellen, Pivot, seltene Formeln | die häufigste Stelle für echte Abweichungen |
|
||
| Makros (VBA) | laufen nicht – und das ist Absicht, nicht Mangel |
|
||
|
||
Fehlende **Schriften** sind der häufigste Grund für „sieht anders aus": Der
|
||
Container bringt Calibri und Cambria nicht mit. Wer Dokumente von außen
|
||
bearbeitet, legt die Schriften nach.
|
||
|
||
Für ein Belegarchiv ist das unkritisch – da wird gelesen und selten geändert.
|
||
Für den täglichen Umgang mit fremden Word-Dateien gilt: **Rundlauf prüfen**,
|
||
nicht darauf vertrauen.
|
||
|
||
### Wie das zusammenspielt
|
||
|
||
Der Office-Server ist **kein Client deines Browsers allein** – er holt die
|
||
Datei selbst beim Backend ab:
|
||
|
||
```
|
||
Browser ──────────► Office-Server (Bedienoberfläche im <iframe>)
|
||
Office-Server ────► Aether /wopi/... (Datei holen, Datei zurückschreiben)
|
||
```
|
||
|
||
Beide Pfeile müssen funktionieren. Das ist der Punkt, an dem solche
|
||
Installationen scheitern: Der Office-Server muss **Aether unter seiner
|
||
öffentlichen Adresse erreichen** (`PUBLIC_URL`), nicht nur unter
|
||
`localhost`. Ein Zugriffstoken mit kurzer Laufzeit hängt an jedem Aufruf; ohne
|
||
gültiges Token liefert der WOPI-Endpunkt nichts.
|
||
|
||
### Was beim Aufbau schiefgeht
|
||
|
||
Alles hier ist beim Testen tatsächlich passiert, in dieser Reihenfolge:
|
||
|
||
| Symptom | Ursache |
|
||
|---|---|
|
||
| Rahmen bleibt leer, Konsole: *„Framing … violates default-src 'self'"* | Unsere eigene Sicherheitsrichtlinie. `frame-src` fällt auf `default-src` zurück; der Office-Ursprung muss ausdrücklich erlaubt sein. Passiert automatisch, sobald `OFFICE_PROVIDER` gesetzt ist. |
|
||
| Editor lädt, Dokument bleibt weiß | `ssl.termination=true` ohne HTTPS davor. Collabora sagt dem Browser dann `wss://`, das es nicht gibt. Im HTTP-Test `OFFICE_SSL_TERMINATION=false`. |
|
||
| Log: *„CheckFileInfo returned 0 … ECONNREFUSED"* | `OFFICE_WOPI_URL` zeigt auf `127.0.0.1:8090`. Der Port ist auf loopback gebunden – aus einem Container nicht erreichbar. Im Compose den Dienstnamen nehmen. |
|
||
| Log: *„WOPISrc must be URI-encoded"* | Behoben; die Adresse wird vollständig kodiert. |
|
||
| Log: *„Failed to exec coolmount … needs CAP_SYS_ADMIN"* | Gelöst über `--o:mount_jail_tree=false` im Compose. |
|
||
| Fehlende Symbole (404 auf `.svg`) | Lücken im Collabora-Image selbst. Kosmetisch. |
|
||
|
||
### Sicherheit
|
||
|
||
- **SSRF:** Das Backend ruft den Office-Server auf. Ausgehende Ziele werden auf
|
||
`OFFICE_ALLOWED_HOSTS` beschränkt – sonst wird aus einer Adresse in der
|
||
Konfiguration ein Weg ins interne Netz.
|
||
- **Tokens sind dateibezogen und kurzlebig.** Ein Token für Datei A öffnet
|
||
Datei B nicht, auch nicht mit derselben Sitzung.
|
||
- **Schreiben folgt derselben Regel wie ein Upload:** atomar schreiben,
|
||
SHA-256 neu berechnen, Version anlegen, wenn Versionierung an ist. Ein
|
||
Office-Server ist kein Sonderweg an der Datenintegrität vorbei.
|
||
|
||
### Speicherbedarf – vor der Entscheidung lesen
|
||
|
||
Der Office-Server ist der mit Abstand hungrigste Teil dieser Anlage:
|
||
|
||
| | Bedarf | Bemerkung |
|
||
|---|---|---|
|
||
| **Collabora (CODE)** | ~1 GB im Leerlauf, +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
|
||
| **OnlyOffice Docs** | 2 GB Minimum, **4 GB empfohlen**, dazu Swap | bringt eigenes Postgres, RabbitMQ und Node mit |
|
||
|
||
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.
|
||
- **EuroOffice** ist ein junger OnlyOffice-Fork und nutzt **denselben
|
||
WOPI-Weg**. Eine Entscheidung für OnlyOffice heute verbaut ihn nicht: Es wäre
|
||
ein Wechsel des Containers und eine Zeile in der `.env`.
|
||
|
||
---
|
||
|
||
## Phasen-Roadmap
|
||
|
||
| Phase | Inhalt | Status |
|
||
|-------|--------|--------|
|
||
| 0 | Fundament (Monorepo, Docker, Migrations, Backend-Skeleton) | ✅ |
|
||
| 1 | Auth (Argon2id, Sessions), Storage (atomar + SHA-256), Datei-CRUD, Frontend | ✅ |
|
||
| 2 | Uploads mit Fortschritt, Papierkorb, Versionen, Share-Links, Thumbnails | ✅ |
|
||
| 2.5 | Sidebar-Favoriten, Ordner verschieben/hochladen, „Verschieben nach…" | ✅ |
|
||
| 3 | Admin-Panel, Gruppen(-mitgliedschaft), Theming, User-Profil | ✅ |
|
||
| 4 | WebDAV (`/dav/`), OIDC (optional) | ✅ (im Test) |
|
||
| 4.5 | Härtung (Quota, Audit-Log, App-Passwörter), interne Shares, Gruppenordner, Logo/App-Symbol | ✅ (im Test) |
|
||
| 5 | UI (Suche, Vorschau, Sortierung, Vor/Zurück) – Design-Rest offen | 🔄 |
|
||
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
|
||
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
|
||
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
|
||
|
||
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).
|