commit 31a027c93a713366864a7a912ee0ded61230865f Author: Marc Date: Mon Aug 24 09:25:36 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c9e027 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Node — muss im Image via `npm ci` frisch gebaut werden, +# sonst überschreibt der Host-Stand die Builder-Stage (tsc: Permission denied) +frontend/node_modules/ +frontend/dist/ + +# Go +backend/aether +backend/*.test +backend/vendor/ + +# Secrets — Werte kommen zur Laufzeit über env_file, nie ins Image +deploy/.env +.env + +# Daten +data/ +backend/data/ + +# Git / IDE / OS +.git/ +.gitignore +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccf0641 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Secrets +deploy/.env +.env + +# Go +backend/aether +backend/*.test +backend/vendor/ + +# Node +frontend/node_modules/ +frontend/dist/ + +# Data (never commit real data) +data/ +backend/data/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp + +# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften) +frontend/public/pdfjs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..94d5b5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# Aether – Projektkonventionen für Claude Code + +## Projekt-Überblick + +Self-hosted Cloud-Alternative zu Nextcloud. Go-Backend + React-Frontend, modularer Monolith. +Projektplan: `cloud-projekt-plan.md` + +## Monorepo-Struktur + +``` +aether/ +├── backend/ # Go – modularer Monolith +│ ├── cmd/aether/ # main.go – Einstiegspunkt +│ ├── internal/ # Domänen-Pakete (nie von außen importieren) +│ │ ├── auth/ # Login, Sessions, CSRF, Argon2id, OIDC-User-Anlage +│ │ ├── storage/ # Storage-Interface + LocalFS (atomar, SHA-256) +│ │ ├── files/ # Datei-/Ordner-CRUD, Trash, Versionen, Favoriten +│ │ ├── sharing/ # Öffentliche Share-Links +│ │ ├── admin/ # Admin-Panel: User, Gruppen(-mitgliedschaft), Settings/Theming +│ │ ├── oidc/ # OIDC-Login (optional, via .env) +│ │ ├── webdav/ # WebDAV-Endpoint /dav/ (Basic Auth – nur App-Passwörter) +│ │ ├── acl/ # Interne Freigaben an User/Gruppen (Rechteauflösung) +│ │ ├── audit/ # Audit-Log (Schreibpfad; Endpunkte liegen in admin/) +│ │ ├── gc/ # Blob-GC: Platte gegen DB abgleichen +│ │ ├── thumbnails/ # Vorschaubild-Generierung +│ │ ├── groups/ # LEERER STUB – Gruppenordner liegen in files/groupfolder.go +│ │ ├── documents/ # Volltext: OCR-Warteschlange + Arbeiter (Phase 6, Teil 1) +│ │ ├── tags/ # Ordnungsmerkmale einer Datei: Etiketten, Belegdatum, Absender +│ │ └── office/ # WOPI-Host: Collabora/OnlyOffice, ein Adapter fuer beide +│ ├── db/migrations/ # SQL-Migrations – Spiegelkopie (siehe Migrations-Regeln!) +│ └── pkg/ # Hilfspakete: config, middleware, response, db (embed migrations) +├── frontend/ # React + TS + Vite + Tailwind +│ └── src/ +│ ├── components/ +│ ├── pages/ +│ ├── api/ # Typed API-Client +│ └── hooks/ +├── deploy/ # Docker Compose, Dockerfile, .env.example +└── CLAUDE.md +``` + +## Backend-Konventionen (Go) + +- **Module-Pfad**: `aether` +- **Go-Version**: 1.25 (siehe `go.mod`) +- **Router**: `github.com/go-chi/chi/v5` +- **DB-Treiber**: `github.com/jackc/pgx/v5` (pgxpool für Connection-Pool) +- **Migrations**: `github.com/golang-migrate/migrate/v4` +- **Logging**: `github.com/rs/zerolog` – strukturiert, immer mit Kontext (`log.Ctx(ctx)`) +- **Hashing**: `golang.org/x/crypto/argon2` (Argon2id) – App-Passwörter dagegen SHA-256 (zufälliges Token, jeder WebDAV-Request authentifiziert einzeln) +- **Sessions**: `github.com/gorilla/sessions` mit securecookie + +### Paket-Regeln + +- Alle Domänen-Pakete unter `internal/` – kein Import von außen +- Keine zirkulären Abhängigkeiten; Dependency-Richtung: `cmd → internal/* → pkg/*` +- Handler in `internal//handler.go`, Business-Logic in `internal//service.go`, DB in `internal//store.go` +- Interfaces für alle externen Abhängigkeiten (Storage, DB) → leicht mockbar + +### Code-Stil + +- `gofmt` + `goimports` – immer formatiert +- Fehler nie ignorieren (`_`), immer wrappen mit `fmt.Errorf("context: %w", err)` +- Keine Magic-Numbers – Konstanten mit sprechenden Namen +- HTTP-Handler signieren: `func (h *Handler) MethodName(w http.ResponseWriter, r *http.Request)` +- Middleware-Chain: Auth → CSRF → RateLimit → Handler + +### Tests + +- Testdatei neben der Quelldatei: `auth_test.go` neben `auth.go` +- Integrationstests mit echtem Postgres via `testcontainers-go` +- Pflicht-Tests: Path-Traversal, Upload-Abbruch, Checksum-Verifikation, Soft-Delete-Recovery +- `go test ./...` muss grün sein vor jedem Commit + +## Frontend-Konventionen (React + TS) + +- **Bundler**: Vite +- **Styling**: Tailwind CSS + CSS-Variablen für Design-Tokens +- **Testing**: Vitest + Testing Library; E2E: Playwright +- **API-Client**: typed fetch-Wrapper unter `src/api/` + +### Design-Tokens (CSS-Variablen) + +Alle Farben laufen über CSS-Custom-Properties: + +```css +:root { + --color-primary: ...; + --color-accent: ...; + --color-background: ...; + --color-surface: ...; + --color-text: ...; +} +``` + +Admin setzt globale Tokens; User-Override nur für eigene Palette (Scope: `[data-user-theme]`). + +### Komponenten-Regeln + +- Komponenten in `PascalCase.tsx` +- Kein `dangerouslySetInnerHTML` mit User-Input (XSS) +- API-Calls nur über `src/api/`-Funktionen, nie direkt `fetch` in Komponenten + +## Sicherheits-Pflichten + +- Path-Traversal: jeden Dateipfad kanonisieren und gegen Storage-Root prüfen +- CSRF: Double-Submit-Cookie oder synchronizer-Token +- Rate-Limiting: Login-Endpunkt + Admin-Login strenger +- Auth-Check: jede Route serverseitig prüfen – nie Frontend vertrauen +- SQL: ausschließlich parametrisierte Queries (pgx unterstützt das nativ) +- Uploads: außerhalb Webroot, keine ausführbaren Rechte, MIME-Prüfung +- WebDAV: **nur App-Passwörter**, nie das Kontopasswort – es ist zugleich der Admin-Zugang +- Kein Zugriff auf fremde IDs → `404`, nicht `403`: sonst verrät die Antwort die Existenz +- Listen und Suche schneiden den erreichbaren Bereich **in der Abfrage** zu, nie nachträglich im Handler +- Hochgeladene Dateien nur für eine Positivliste harmloser Typen `inline` ausliefern (sonst läuft fremdes HTML im eigenen Ursprung) + +## Datenintegrität (nicht verhandelbar) + +1. **Atomares Schreiben**: Temp-Datei → `fsync` → `rename`. Niemals in-place. +2. **SHA-256**: beim Upload berechnen + in DB; beim Download verifizieren. +3. **Soft-Delete only**: kein hartes Löschen. Bytes erst per GC entfernen. +4. **DB-Commit nach Blob-Write**: Metadaten erst committen, wenn Blob erfolgreich. + +## Migrations-Regeln + +- Dateien: `000001_init.up.sql` / `000001_init.down.sql` +- **Jede Migration in BEIDE Verzeichnisse legen:** `backend/pkg/db/migrations/` (wird ins Binary embedded und beim Start ausgeführt) UND `backend/db/migrations/` (Spiegelkopie). Beide müssen identisch sein. +- Keine destruktiven Änderungen ohne explizites Down-Migration +- Migration beim App-Start automatisch ausführen (via `migrate.Up()`) +- Aktueller Stand: bis `000018_tag_icons` (Emoji an Etiketten und Absendern) + +## Commit-Regeln + +Format: `(): ` + +Typen: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`, `security` + +Beispiele: +- `feat(auth): add argon2id password hashing` +- `test(storage): add checksum verification test` +- `security(admin): enforce server-side admin check on all routes` + +Kein Commit ohne grüne Tests. + +## Reverse Proxy & Proxy-Awareness + +- **Kein Proxy-Container** in docker-compose.yml – TLS und Domain gehören zum Wirt, + nicht ins Projekt. Nichts hier soll einen mitgelieferten Proxy voraussetzen. +- App lauscht auf reinem HTTP (`APP_LISTEN_ADDR`). TLS = externer Proxy. +- `X-Forwarded-For` / `X-Forwarded-Proto` werden **nur** von `TRUSTED_PROXIES` (IPs/CIDRs) akzeptiert. +- Echte Client-IP immer über `middleware.RealIP(r)` abrufen – niemals `r.RemoteAddr` direkt. +- `Secure`-Cookie-Flag: über `middleware.IsHTTPS(r)` setzen (berücksichtigt Forwarded-Proto). +- `PUBLIC_URL` für OIDC-Redirects, WOPI-Callbacks und Share-Links verwenden. +- HSTS gehört auf den Edge-Proxy, **nicht** in die App-Header. + +## Docker & Secrets + +- **Backend immer via Docker starten/laufen lassen (`docker compose ...` in `deploy/`), nie `go run`.** Der Host hat kein Go-Toolchain installiert; Builds/Compile-Checks laufen über das `golang:1.25-alpine`-Image. Migrations + DB-Verbindung sind auf die Compose-Umgebung ausgelegt. +- Secrets **nur** über `.env` / Compose-Env – nie im Image oder Repo +- Multi-Stage-Build: Builder-Stage (Go-Compiler) → Alpine final +- `.env` ist in `.gitignore`; `.env.example` zeigt alle Keys ohne Werte + +## Phasen-Stopp + +Nach Phase 1: **STOPP** – Zusammenfassung + Phase-2-Vorschlag, erst nach OK weitermachen. diff --git a/README.md b/README.md new file mode 100644 index 0000000..913a067 --- /dev/null +++ b/README.md @@ -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: +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 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= +PUBLIC_URL=https://cloud.example.com +TRUSTED_PROXIES= +ADMIN_USERNAME=admin +ADMIN_PASSWORD= +``` + +### 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: + +``` +/ +├── 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) + +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