feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,417 @@
|
||||
# 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)
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
```bash
|
||||
git clone <repo-url> aether
|
||||
cd aether
|
||||
```
|
||||
|
||||
### 2. `.env` anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/.env.example deploy/.env
|
||||
```
|
||||
|
||||
Dann in `deploy/.env` ausfüllen:
|
||||
|
||||
```env
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
DB_PASSWORD=<sicheres_passwort>
|
||||
PUBLIC_URL=https://cloud.example.com
|
||||
TRUSTED_PROXIES=<IP-deines-Proxys>
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=<sicheres_admin_passwort>
|
||||
```
|
||||
|
||||
### 3. Starten
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker login gitea.honeyy.duckdns.org # nur beim ersten Mal
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Das Compose zieht ein **fertiges Abbild** aus der eigenen Registry
|
||||
(`gitea.honeyy.duckdns.org/honey/aether`). Für eine neue Umgebung reichen damit
|
||||
drei Dateien – `docker-compose.yml`, `.env` und das Datenverzeichnis. Backend
|
||||
und Frontend müssen dort nicht liegen und nicht gebaut werden.
|
||||
|
||||
Wer aus dem Quelltext bauen will, kommentiert in `docker-compose.yml` `image:`
|
||||
aus und den `build:`-Block ein, dann:
|
||||
|
||||
```bash
|
||||
docker compose build app && docker compose up -d
|
||||
```
|
||||
|
||||
Aktualisieren:
|
||||
|
||||
```bash
|
||||
docker compose pull app && docker compose up -d app
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
### 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/honey/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).
|
||||
Reference in new issue
Block a user