Compare commits
14
Commits
8d95088271
...
471679ff64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471679ff64 | ||
|
|
a7a67da040 | ||
|
|
aa8af418f2 | ||
|
|
093ff5140e | ||
|
|
6c53651568 | ||
|
|
065372359b | ||
|
|
ded1134f6a | ||
|
|
9c5cda75d7 | ||
|
|
84ae570305 | ||
|
|
0723d853f0 | ||
|
|
574ba3e6c5 | ||
|
|
face318131 | ||
|
|
105b1a0e74 | ||
|
|
451f359734 |
No files matched your search
+113
-35
@@ -1,14 +1,21 @@
|
||||
# Aether – das Nötigste für eine neue Umgebung. cp .env.example .env
|
||||
# Aether – alle Einstellungen. cp .env.example .env
|
||||
#
|
||||
# Diese Datei gilt für beides: den Betrieb mit dem fertigen Abbild
|
||||
# (docker-compose.example.yml hier im Wurzelverzeichnis) und die Entwicklung
|
||||
# aus dem Quelltext (build/docker-compose.yml, liest build/.env).
|
||||
# Für die Entwicklung: cp .env.example build/.env
|
||||
#
|
||||
# Zwei Fallen beim Bearbeiten:
|
||||
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
||||
# Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert
|
||||
# Erklärungen: README.md, Abschnitt „Einstellungen"
|
||||
# Alle Schalter: deploy/.env.example
|
||||
# Erklärungen und Fallstricke: README.md, Abschnitt „Einstellungen"
|
||||
|
||||
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
||||
APP_ENV=production
|
||||
# Adresse, unter der der Browser die Cloud sieht. Ohne Schrägstrich am Ende.
|
||||
# ══ Pflicht ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
APP_ENV=production # development | production
|
||||
# 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.
|
||||
PUBLIC_URL=https://cloud.example.com
|
||||
# openssl rand -hex 32
|
||||
SESSION_SECRET=
|
||||
@@ -28,57 +35,128 @@ DATA_PATH=/pfad/zu/aether-data
|
||||
APP_UID=1000
|
||||
APP_GID=1000
|
||||
|
||||
# ── Reverse Proxy ─────────────────────────────────────────────────────────────
|
||||
# Nur diesen Adressen wird X-Forwarded-For/-Proto geglaubt.
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe,
|
||||
# davor gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
||||
APP_BIND=127.0.0.1
|
||||
|
||||
# Wie viele frühere Fassungen einer Datei aufgehoben werden. Gilt nur für
|
||||
# Dateien, an denen „Keep versions" eingeschaltet ist – Standard ist aus.
|
||||
MAX_VERSIONS_KEPT=5
|
||||
|
||||
# ── Erster Start ──────────────────────────────────────────────────────────────
|
||||
# Legt den Admin an. Danach ADMIN_PASSWORD leeren.
|
||||
# ══ Erster Start ══════════════════════════════════════════════════════════════
|
||||
# Legt den Admin an. Danach ADMIN_PASSWORD leeren – das Konto bleibt.
|
||||
# Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD).
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# ── Anmeldung über einen Identitätsanbieter (optional) ───────────────────────
|
||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige Adresse des Anbieters,
|
||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht
|
||||
# der Start ab.
|
||||
# ══ Reverse Proxy ═════════════════════════════════════════════════════════════
|
||||
# IPs/CIDRs der Proxys, denen X-Forwarded-For/-Proto geglaubt wird. Steht hier
|
||||
# das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit und
|
||||
# Audit-Log werden damit wertlos, ohne dass etwas nach Fehler aussieht.
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe, davor
|
||||
# gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
||||
APP_BIND=127.0.0.1
|
||||
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
|
||||
|
||||
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
|
||||
# Wie lange eine Anmeldung gilt – **ab der letzten Benutzung**, nicht ab dem
|
||||
# Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie so
|
||||
# lange nicht anfasst, meldet sich neu an. 2592000 = 30 Tage.
|
||||
#
|
||||
# Kommentar NIE hinter einen leeren Wert setzen: `OIDC_ISSUER= # text` macht
|
||||
# den Text zum Wert. Hinter einem gefüllten Wert ist er dagegen harmlos.
|
||||
# Kürzer ist sinnvoll, wenn mehrere Leute denselben Rechner benutzen. Alle
|
||||
# Sitzungen sofort beenden: SESSION_SECRET wechseln und neu starten.
|
||||
SESSION_MAX_AGE=2592000 # Sekunden
|
||||
|
||||
# ══ Speicher ══════════════════════════════════════════════════════════════════
|
||||
STORAGE_PATH=/data # im Container
|
||||
TMP_PATH=/data/tmp
|
||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||
MAX_VERSIONS_KEPT=5
|
||||
|
||||
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
|
||||
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
|
||||
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
|
||||
# Start ab.
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=aether
|
||||
OIDC_CLIENT_SECRET=
|
||||
# Muss exakt <PUBLIC_URL>/api/v1/auth/oidc/callback sein.
|
||||
# Muss EXAKT <PUBLIC_URL>/api/v1/auth/oidc/callback sein.
|
||||
OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
|
||||
# Bestehendes Konto verbinden statt ein zweites anzulegen.
|
||||
# VOR der ersten Anmeldung setzen – danach ist es zu spät.
|
||||
# Bestehendes Konto gleichen Namens mit der Identität verbinden, statt ein
|
||||
# zweites anzulegen. VOR der ersten Anmeldung setzen – danach ist es zu spät.
|
||||
# Nur bei einem Anbieter, dem die Namen gehören: Bei freier Registrierung wäre
|
||||
# es die Übernahme fremder Konten.
|
||||
OIDC_LINK_BY_USERNAME=false
|
||||
# Beschriftung des Knopfes auf der Anmeldeseite.
|
||||
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
|
||||
|
||||
# ── Office (optional) ─────────────────────────────────────────────────────────
|
||||
# ══ Office (optional) ═════════════════════════════════════════════════════════
|
||||
# Leer = aus, auch wenn ein Office-Container läuft.
|
||||
# Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
|
||||
# Compose-Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
|
||||
# collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_PROVIDER=
|
||||
|
||||
# Drei Adressen für drei Richtungen – die häufigste Fehlerquelle.
|
||||
#
|
||||
# Backend → Office. Port je nach Anbieter:
|
||||
# Collabora http://office:9980
|
||||
# OnlyOffice/Euro-Office http://office
|
||||
OFFICE_URL=http://office:9980
|
||||
# Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name.
|
||||
OFFICE_PUBLIC_URL=https://office.example.com
|
||||
# Office → Aether. Weder localhost noch der veröffentlichte Port.
|
||||
OFFICE_WOPI_URL=http://app:8080
|
||||
# Gegen SSRF: was das Backend anfragen darf.
|
||||
# Office → Aether.
|
||||
#
|
||||
# **Bei Collabora die öffentliche Adresse der Cloud**, nicht app:8080: Aus
|
||||
# diesem Wert baut Collabora sein frame-ancestors, und der Browser darf den
|
||||
# Rahmen sonst nicht zeigen ("Framing … violates 'frame-ancestors …'").
|
||||
# Collabora holt die Datei dann über deinen Reverse Proxy.
|
||||
OFFICE_WOPI_URL=https://cloud.example.com
|
||||
# Gegen SSRF: was das Backend anfragen darf. Der Host aus OFFICE_URL zählt
|
||||
# immer dazu.
|
||||
OFFICE_ALLOWED_HOSTS=office
|
||||
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen: openssl rand -hex 32
|
||||
# Muss mit JWT_SECRET des Office-Containers übereinstimmen. Ohne ihn schlägt
|
||||
# das Wandeln nach PDF fehl (der Server antwortet `{"error":-8}`). Für den
|
||||
# WOPI-Weg wird er nicht gebraucht, der hat eigene Token.
|
||||
OFFICE_JWT_SECRET=
|
||||
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
||||
|
||||
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
|
||||
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
|
||||
# OCR_ENABLED=false
|
||||
# OCR_LANGUAGES=deu+eng # weitere brauchen ein apk-Paket im Dockerfile
|
||||
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
|
||||
# OCR_TIMEOUT=10m # Notbremse je Datei
|
||||
|
||||
# ══ App-Umschalter (optional) ═════════════════════════════════════════════════
|
||||
# Andere selbst gehostete Apps im Menü der Seitenleiste.
|
||||
# EXTERNAL_APPS=InkNote|https://notes.example.com|📝
|
||||
|
||||
# ══ HoneyOS-Leiste (optional) ═════════════════════════════════════════════════
|
||||
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
||||
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
|
||||
# connect-src), und die Oberfläche lädt das Skript von dort nach.
|
||||
# Wirkt beim **Neustart** – kein Neubau nötig.
|
||||
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
||||
HONEYBAR_ORIGIN=
|
||||
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
|
||||
# drüben passen. Nur nötig, wenn HoneyOS auf einer anderen Site liegt als
|
||||
# Aether – dann schickt der Browser die HoneyOS-Sitzung nicht mit.
|
||||
HONEYBAR_KEY=
|
||||
|
||||
# ══ Nur für build/docker-compose.yml ══════════════════════════════════════════
|
||||
# Diese vier steuern den Office-Container aus dem Entwicklungs-Compose. Beim
|
||||
# Betrieb mit eigenem Office-Container stehen sie in dessen Compose-Datei.
|
||||
#
|
||||
# Wer seine Dateien in Collabora öffnen darf – derselbe Host wie in
|
||||
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
|
||||
# anhängen. Fehlt er, lehnt Collabora ab:
|
||||
# "No authorized hosts found matching the target host […] in config"
|
||||
OFFICE_ALIASGROUP=https://cloud\.example\.com:443
|
||||
OFFICE_SSL=false
|
||||
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
|
||||
OFFICE_PORT=9980
|
||||
|
||||
# ══ Später ════════════════════════════════════════════════════════════════════
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_KEY=
|
||||
|
||||
# ══ WebDAV ════════════════════════════════════════════════════════════════════
|
||||
# Läuft unter /dav/, kein Schalter nötig. Der Proxy muss
|
||||
# PROPFIND/MKCOL/MOVE/COPY durchreichen. Anmeldung ausschließlich mit
|
||||
# App-Passwörtern, nie mit dem Kontopasswort.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Secrets
|
||||
deploy/.env
|
||||
build/.env
|
||||
.env
|
||||
|
||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
||||
|
||||
@@ -36,7 +36,7 @@ aether/
|
||||
│ ├── pages/
|
||||
│ ├── api/ # Typed API-Client
|
||||
│ └── hooks/
|
||||
├── deploy/ # Docker Compose, Dockerfile, .env.example
|
||||
├── build/ # Dockerfile + Compose zum Bauen aus dem Quelltext
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
@@ -156,7 +156,7 @@ Kein Commit ohne grüne Tests.
|
||||
|
||||
## 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.
|
||||
- **Backend immer via Docker starten/laufen lassen (`docker compose -f build/docker-compose.yml ...`), 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
|
||||
|
||||
@@ -41,8 +41,15 @@ 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. |
|
||||
| **Betreiben** | `docker-compose.example.yml` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
|
||||
| **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
|
||||
|
||||
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – eine Datei, alle
|
||||
Einstellungen. Sie lag bis zum 02.09.2026 doppelt vor (einmal schlank hier,
|
||||
einmal vollständig im Bau-Ordner), und die beiden liefen auseinander: Der
|
||||
produktiven fehlten am Ende zwölf Schlüssel, darunter `SESSION_MAX_AGE`, und
|
||||
sie empfahl für `OFFICE_WOPI_URL` einen Wert, der mit Collabora nicht
|
||||
funktioniert.
|
||||
|
||||
### 1. Dateien holen
|
||||
|
||||
@@ -66,7 +73,7 @@ cd aether
|
||||
|
||||
```bash
|
||||
cp .env.example .env # beim Betreiben
|
||||
cp deploy/.env.example deploy/.env # beim Entwickeln
|
||||
cp .env.example build/.env # beim Entwickeln (dorthin schaut das Compose)
|
||||
```
|
||||
|
||||
Ausfüllen musst du mindestens:
|
||||
@@ -83,9 +90,10 @@ 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`.
|
||||
Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, App-Umschalter – steht mit
|
||||
Erklärung in derselben Datei, nach Abschnitten geordnet. Der letzte Abschnitt
|
||||
gilt nur für den Entwicklungs-Compose; beim Betrieb mit eigenem
|
||||
Office-Container stehen diese Werte in dessen Compose-Datei.
|
||||
|
||||
### 3. Starten
|
||||
|
||||
@@ -102,8 +110,8 @@ Beim ersten Start:
|
||||
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
|
||||
docker compose -f build/docker-compose.yml build app
|
||||
docker compose -f build/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Aktualisieren:
|
||||
@@ -121,14 +129,8 @@ Direkt (ohne Proxy): http://localhost:8080/health
|
||||
|
||||
## Einstellungen
|
||||
|
||||
Zwei Beispieldateien, je nach Zweck:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `.env.example` (Wurzel) | das Nötigste zum Betreiben |
|
||||
| `deploy/.env.example` | alle Schalter, auch die optionalen |
|
||||
|
||||
Beide sind knapp gehalten – was dahintersteckt, steht hier.
|
||||
**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
|
||||
|
||||
@@ -181,6 +183,18 @@ auf, wenn ein Freigabe-Link oder ein OIDC-Rücksprung ins Leere zeigt.
|
||||
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.
|
||||
|
||||
@@ -299,9 +313,9 @@ nur `GET`/`POST`, und dann scheitert das Einbinden mit einem `405`.
|
||||
## Abbild bauen und veröffentlichen
|
||||
|
||||
```bash
|
||||
# im Wurzelverzeichnis des Projekts, nicht in deploy/
|
||||
# im Wurzelverzeichnis des Projekts, nicht in build/
|
||||
IMG=gitea.honeyy.duckdns.org/docker/aether
|
||||
docker build -f deploy/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
|
||||
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
|
||||
```
|
||||
@@ -381,7 +395,7 @@ aether/
|
||||
│ ├── components/
|
||||
│ ├── hooks/
|
||||
│ └── pages/
|
||||
├── deploy/
|
||||
├── build/
|
||||
│ ├── docker-compose.yml # app + postgres (kein Proxy-Container)
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
@@ -520,9 +534,14 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
|
||||
image: collabora/code:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Wer seine Dateien hier öffnen darf. Punkte maskieren – Collabora liest
|
||||
# den Wert als regulären Ausdruck.
|
||||
aliasgroup1: http://app:8080
|
||||
# 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
|
||||
@@ -543,10 +562,34 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
|
||||
OFFICE_PROVIDER=collabora
|
||||
OFFICE_URL=http://office:9980
|
||||
OFFICE_PUBLIC_URL=https://office.example.com
|
||||
OFFICE_WOPI_URL=http://app:8080
|
||||
# **Öffentliche Adresse, nicht app:8080** – Begründung direkt darunter.
|
||||
OFFICE_WOPI_URL=https://cloud.example.com
|
||||
OFFICE_ALLOWED_HOSTS=office
|
||||
```
|
||||
|
||||
> **Warum bei Collabora die öffentliche Adresse und nicht `app:8080`?**
|
||||
>
|
||||
> Weil Collabora aus dieser Adresse seine eigene Sicherheitsrichtlinie baut.
|
||||
> Gemessen: Es setzt `frame-ancestors` auf *seinen* Host **plus den Host aus
|
||||
> `WOPISrc`* – und `WOPISrc` ist genau `OFFICE_WOPI_URL`. Steht dort der
|
||||
> interne Dienstname, erlaubt der Editor die Einbettung nur einer Seite, die
|
||||
> unter `app:8080` läuft. Dein Browser kommt aber von `cloud.example.com`, und
|
||||
> er lehnt ab:
|
||||
>
|
||||
> ```
|
||||
> Framing 'https://office.example.com/' violates
|
||||
> 'frame-ancestors office.example.com:* app:8080'
|
||||
> ```
|
||||
>
|
||||
> Die Folge davon muss man wollen: Collabora holt die Datei dann **über deinen
|
||||
> Reverse Proxy**, nicht quer durchs Docker-Netz. Der Container braucht dafür
|
||||
> Namensauflösung nach draußen und einen Weg zurück – bei einem Proxy auf
|
||||
> einem anderen Rechner ist das der normale Weg über das Internet.
|
||||
>
|
||||
> `aliasgroup1` oben muss **denselben** Host nennen. Zwei Schalter, eine
|
||||
> Wahrheit: Der eine entscheidet, ob der Browser den Rahmen zeigen darf, der
|
||||
> andere, ob Collabora die Anfrage überhaupt annimmt.
|
||||
|
||||
### Schnipsel 2: OnlyOffice Docs
|
||||
|
||||
```yaml
|
||||
@@ -598,10 +641,14 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
|
||||
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das
|
||||
Abbild.
|
||||
|
||||
> **Ungeprüft.** Collabora ist hier gemessen worden, den Kreis von Tippen bis
|
||||
> Speichern eingeschlossen. Die beiden anderen Schnipsel stehen nach ihrer
|
||||
> Dokumentation und dem gemeinsamen WOPI-Weg, sind auf dieser Anlage aber nie
|
||||
> gelaufen – für OnlyOffice fehlt schlicht der Arbeitsspeicher.
|
||||
> **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:
|
||||
@@ -614,9 +661,53 @@ Abbild.
|
||||
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
|
||||
> das der Ort.
|
||||
>
|
||||
> `JWT_SECRET` betrifft nach der Dokumentation die eigene API des
|
||||
> Dokumentenservers, nicht den WOPI-Weg – unser Adapter signiert damit nichts.
|
||||
> Der Schlüssel gehört trotzdem gesetzt, sonst läuft der Server offen.
|
||||
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
|
||||
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
|
||||
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
|
||||
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
|
||||
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
|
||||
> gesetzt – sonst läuft die API offen.
|
||||
|
||||
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
|
||||
|
||||
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
|
||||
was aufgeht:
|
||||
|
||||
| Anbieter | am Telefon |
|
||||
|---|---|
|
||||
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
|
||||
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
|
||||
|
||||
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
|
||||
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
|
||||
eine Tastatur.
|
||||
|
||||
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
|
||||
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
|
||||
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
|
||||
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
|
||||
nicht.
|
||||
|
||||
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
|
||||
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
|
||||
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
|
||||
entscheidet der Anbieter.
|
||||
|
||||
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
|
||||
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
|
||||
LibreOffice-Layout desselben Dokuments sehen.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
|
||||
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
|
||||
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
|
||||
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
|
||||
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
|
||||
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
|
||||
|
||||
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
|
||||
Bereinigung, zusammen mit Vorschau- und Profilbildern.
|
||||
|
||||
### Welche Dateien, und wie gut
|
||||
|
||||
@@ -671,6 +762,8 @@ Alles hier ist beim Testen tatsächlich passiert, in dieser Reihenfolge:
|
||||
| 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. |
|
||||
@@ -692,8 +785,14 @@ 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 |
|
||||
| **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 rund 5 GB | derselbe Unterbau |
|
||||
|
||||
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
|
||||
|
||||
+3
-2
@@ -721,12 +721,13 @@ Hinter einem **gefüllten** Wert ist ein Kommentar harmlos. Hinter einem
|
||||
den OIDC-Aufbau mit dem Kommentartext als Adresse – und weil ein Fehlschlag
|
||||
dort den Start abbricht, kam die Cloud nicht hoch.
|
||||
|
||||
In `deploy/.env.example` steckten vier weitere solche Zeilen, darunter
|
||||
In der (inzwischen zusammengeführten) `.env.example` steckten vier weitere
|
||||
solche Zeilen, darunter
|
||||
`OFFICE_PROVIDER= # collabora | onlyoffice`: Office wäre mit Unsinn als
|
||||
Anbieter eingeschaltet worden. Alle vier umgestellt, der Kommentar steht jetzt
|
||||
darüber.
|
||||
|
||||
- [ ] `grep -nE '^[A-Z_]+=[[:space:]]*#' .env.example deploy/.env.example`
|
||||
- [ ] `grep -nE '^[A-Z_]+=[[:space:]]*#' .env.example`
|
||||
liefert nichts 🔎
|
||||
- [ ] Frische Instanz allein aus den Beispieldateien startet 🔎
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ func main() {
|
||||
var officeHandler *office.Handler
|
||||
if cfg.OfficeEnabled() {
|
||||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
|
||||
if err != nil {
|
||||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||||
@@ -396,6 +396,16 @@ func main() {
|
||||
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
||||
// erst anbietet, wenn es keinen Office-Server gibt.
|
||||
"office_enabled": officeHandler.Enabled(),
|
||||
// Welcher Anbieter – die Oberflaeche entscheidet daran, ob ein
|
||||
// Dokument am Telefon im Editor oder als PDF aufgeht. Collabora
|
||||
// hat eine brauchbare Handy-Ansicht, OnlyOffice baut dort die
|
||||
// PC-Oberflaeche auf. Ohne Office-Server ein leerer Wert.
|
||||
"office_provider": func() string {
|
||||
if officeHandler.Enabled() {
|
||||
return cfg.OfficeProvider
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
// Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server
|
||||
// anwendet. Vorher stand sie zweimal im Quelltext, mit einem
|
||||
// Kommentar „muss zusammenpassen" – das ist keine Zusicherung.
|
||||
@@ -449,6 +459,9 @@ func main() {
|
||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||
// angemeldeten Zusammenhang.
|
||||
r.Get("/office/session/{id}", officeHandler.Session)
|
||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||
// Telefon der Normalfall.
|
||||
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||
@@ -529,6 +542,9 @@ func main() {
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||
// Platz, der in keinem Kontingent auftaucht.
|
||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
|
||||
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
|
||||
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
|
||||
--
|
||||
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
|
||||
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
|
||||
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
|
||||
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
|
||||
--
|
||||
-- 0 heißt "keine". Dateifassungen zählen ab 1.
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -97,3 +97,24 @@ func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
|
||||
"skipped_young": res.SkippedYoung,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/storage/derived – Platzbedarf der abgeleiteten Daten.
|
||||
//
|
||||
// Getrennt vom Aufräumen, weil es eine Auskunft ist und keine Handlung: Man
|
||||
// soll nachsehen können, ohne etwas anzustoßen.
|
||||
func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.gc == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "not available")
|
||||
return
|
||||
}
|
||||
usage, err := h.gc.Derived(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not read storage")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, usage)
|
||||
}
|
||||
@@ -26,6 +26,12 @@ func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
// Die Frist läuft ab der letzten Benutzung, nicht ab dem
|
||||
// Anmelden. **Vor** dem Handler, weil danach der Rumpf der
|
||||
// Antwort schon geschrieben sein kann – ein Set-Cookie käme dann
|
||||
// zu spät und fiele stillschweigend unter den Tisch.
|
||||
ss.Touch(w, r)
|
||||
|
||||
ctx := context.WithValue(r.Context(), keyUser, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
@@ -15,10 +16,22 @@ import (
|
||||
const (
|
||||
sessionName = "aether_session"
|
||||
sessionKeyUser = "user_id"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
// sessionKeyTouched hält fest, wann die Sitzung zuletzt verlängert wurde.
|
||||
// Ohne diesen Wert müsste jede einzelne Anfrage ein neues Cookie setzen.
|
||||
sessionKeyTouched = "touched_at"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
// refreshInterval: Wie oft eine benutzte Sitzung höchstens erneuert wird.
|
||||
//
|
||||
// Die Frist gleitet – aber sie muss nicht bei jedem Klick nachgezogen werden.
|
||||
// Ohne diese Bremse trüge **jede** Antwort ein `Set-Cookie`, und beim Laden
|
||||
// einer Dateiliste sind das schnell dreißig Stück. Einmal pro Stunde reicht:
|
||||
// Der Unterschied zur Frist ist eine Stunde, und die fällt bei Tagen nicht
|
||||
// ins Gewicht.
|
||||
var refreshInterval = time.Hour
|
||||
|
||||
type SessionStore struct {
|
||||
store *sessions.CookieStore
|
||||
maxAge int
|
||||
@@ -32,6 +45,15 @@ func NewSessionStore(secret string, maxAge int) *SessionStore {
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
// **Zwei Fristen, nicht eine.** `Options.MaxAge` steht nur im Cookie – der
|
||||
// Browser wirft es dann weg. Wie lange der *Inhalt* gilt, entscheidet die
|
||||
// Signatur, und die hängt an den Codecs.
|
||||
//
|
||||
// Ohne diesen Aufruf blieb dort gorillas Voreinstellung von 30 Tagen
|
||||
// stehen: Ein Cookie, das jemand aus einem Browser kopiert hat, wäre einen
|
||||
// Monat lang gültig geblieben, obwohl SESSION_MAX_AGE auf einen Tag stand.
|
||||
// Wer die Frist kürzt, um sie zu kürzen, hätte nichts gekürzt.
|
||||
store.MaxAge(maxAge)
|
||||
return &SessionStore{store: store, maxAge: maxAge}
|
||||
}
|
||||
|
||||
@@ -41,10 +63,45 @@ func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID u
|
||||
sess, _ = ss.store.New(r, sessionName)
|
||||
}
|
||||
sess.Values[sessionKeyUser] = userID.String()
|
||||
sess.Values[sessionKeyTouched] = time.Now().Unix()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
// Touch verlängert eine benutzte Sitzung.
|
||||
//
|
||||
// **Warum überhaupt:** Bisher lief die Frist ab dem *Anmelden* – wer die Cloud
|
||||
// täglich benutzte, flog nach 24 Stunden trotzdem heraus, mitten in der
|
||||
// Arbeit. Das ist die Sorte Ablauf, die keine Sicherheit bringt: Sie trifft
|
||||
// den, der da ist, und nicht den, der weg ist.
|
||||
//
|
||||
// Jetzt läuft sie ab der *letzten Benutzung*. Wer die Cloud regelmäßig
|
||||
// öffnet, bleibt angemeldet; wer sie einen Monat nicht anfasst, muss sich neu
|
||||
// anmelden – und genau dieser zweite Fall ist der, um den es bei einem
|
||||
// abgelaufenen Cookie geht.
|
||||
//
|
||||
// **Was das kostet, ehrlich gesagt:** Ein entwendetes Cookie, das weiter
|
||||
// benutzt wird, verlängert sich mit. Ein Sitzungscookie hat keine Liste auf
|
||||
// dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne
|
||||
// Sitzungstabelle auszukommen. Wer eine Sitzung sofort beenden will, ändert
|
||||
// SESSION_SECRET; dann sind alle ungültig.
|
||||
func (ss *SessionStore) Touch(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil || sess.IsNew {
|
||||
return
|
||||
}
|
||||
if last, ok := sess.Values[sessionKeyTouched].(int64); ok {
|
||||
if time.Since(time.Unix(last, 0)) < refreshInterval {
|
||||
return
|
||||
}
|
||||
}
|
||||
sess.Values[sessionKeyTouched] = time.Now().Unix()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
// Ein Fehlschlag ist keiner, der den Aufruf scheitern lassen darf: Die
|
||||
// Sitzung gilt weiter, sie wurde nur nicht verlängert.
|
||||
_ = ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const testSecret = "test-geheimnis-mit-mehr-als-32-zeichen!!"
|
||||
|
||||
// Anmelden und die gesetzten Cookies zurückgeben.
|
||||
func signIn(t *testing.T, ss *SessionStore) []*http.Cookie {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if err := ss.SetUser(w, r, uuid.New()); err != nil {
|
||||
t.Fatalf("SetUser: %v", err)
|
||||
}
|
||||
return w.Result().Cookies()
|
||||
}
|
||||
|
||||
// Eine **frische** Anfrage mit diesen Cookies.
|
||||
//
|
||||
// Jedes Mal eine neue, und das ist keine Umständlichkeit: gorilla legt die
|
||||
// entschlüsselte Sitzung im Zusammenhang der Anfrage ab und gibt sie beim
|
||||
// zweiten Zugriff unverändert zurück. Zweimal dieselbe Anfrage zu befragen
|
||||
// prüfte also den Zwischenspeicher und nicht die Frist – der erste Entwurf
|
||||
// dieses Tests ist genau darauf hereingefallen.
|
||||
func withCookies(cookies []*http.Cookie) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func signedIn(t *testing.T, ss *SessionStore) *http.Request {
|
||||
t.Helper()
|
||||
return withCookies(signIn(t, ss))
|
||||
}
|
||||
|
||||
// Die Frist muss auch für den **Inhalt** gelten, nicht nur für das Cookie.
|
||||
//
|
||||
// Der Browser wirft ein abgelaufenes Cookie von selbst weg – wer es vorher
|
||||
// herauskopiert hat, aber nicht. Ohne store.MaxAge() blieb die Signatur 30
|
||||
// Tage gültig, egal was in SESSION_MAX_AGE stand.
|
||||
func TestSessionExpiresAfterMaxAge(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 1) // eine Sekunde
|
||||
cookies := signIn(t, ss)
|
||||
|
||||
if _, ok := ss.GetUserID(withCookies(cookies)); !ok {
|
||||
t.Fatal("frische Sitzung wurde nicht erkannt")
|
||||
}
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
if _, ok := ss.GetUserID(withCookies(cookies)); ok {
|
||||
t.Error("abgelaufene Sitzung gilt weiter – die Frist wirkt nur im Browser")
|
||||
}
|
||||
}
|
||||
|
||||
// Nicht bei jedem Aufruf ein neues Cookie: Sonst trüge jede Antwort ein
|
||||
// Set-Cookie, und beim Laden einer Dateiliste sind das dreißig Stück.
|
||||
func TestTouchIsThrottled(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
r := signedIn(t, ss)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ss.Touch(w, r)
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Error("Sitzung wurde sofort erneuert, obwohl sie eben erst entstand")
|
||||
}
|
||||
}
|
||||
|
||||
// Nach Ablauf der Bremse verlängert sie sich – das ist der eigentliche Zweck.
|
||||
func TestTouchExtendsSession(t *testing.T) {
|
||||
old := refreshInterval
|
||||
refreshInterval = 0
|
||||
defer func() { refreshInterval = old }()
|
||||
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
r := signedIn(t, ss)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ss.Touch(w, r)
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("Sitzung wurde nicht verlängert")
|
||||
}
|
||||
// Das erneuerte Cookie muss dieselbe Anmeldung tragen.
|
||||
next := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range cookies {
|
||||
next.AddCookie(c)
|
||||
}
|
||||
if _, ok := ss.GetUserID(next); !ok {
|
||||
t.Error("nach dem Verlängern ist niemand mehr angemeldet")
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Anfrage ohne Cookie darf keine Sitzung erfinden.
|
||||
func TestTouchIgnoresAnonymous(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
w := httptest.NewRecorder()
|
||||
ss.Touch(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Error("für einen Unangemeldeten wurde ein Cookie gesetzt")
|
||||
}
|
||||
}
|
||||
|
||||
// Abmelden muss stärker sein als Verlängern.
|
||||
func TestClearEndsSession(t *testing.T) {
|
||||
ss := NewSessionStore(testSecret, 3600)
|
||||
r := signedIn(t, ss)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
if err := ss.Clear(w, r); err != nil {
|
||||
t.Fatalf("Clear: %v", err)
|
||||
}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionName && c.MaxAge >= 0 {
|
||||
t.Errorf("Abmelden löscht das Cookie nicht (MaxAge=%d)", c.MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,6 +715,13 @@ func (h *Handler) GetThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Auch aus dem Papierkorb ausliefern. Das Vorschaubild überlebt das
|
||||
// weiche Löschen (entfernt wird es erst beim endgültigen), und ohne
|
||||
// diesen zweiten Griff stünde dort nur ein graues Quadrat – man
|
||||
// entschiede über „endgültig löschen", ohne zu sehen, was es war.
|
||||
f, err = h.store.GetTrashedFile(r.Context(), id, owner.OwnerID)
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
|
||||
@@ -104,6 +104,10 @@ func (h *Handler) EmptyTrash(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, id := range fileIDs {
|
||||
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
|
||||
// Das als PDF gewandelte Office-Dokument. Es trägt denselben Inhalt
|
||||
// wie die Datei – bleibt es liegen, überlebt der Inhalt das endgültige
|
||||
// Löschen.
|
||||
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
|
||||
}
|
||||
|
||||
log.Ctx(r.Context()).Info().
|
||||
@@ -139,6 +143,7 @@ func (h *Handler) HardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_ = h.storage.Delete(r.Context(), key)
|
||||
}
|
||||
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
|
||||
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
|
||||
h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash")
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -533,3 +533,50 @@ func isUniqueViolation(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||
// entstanden ist – 0 heißt: es gibt keins.
|
||||
//
|
||||
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
|
||||
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
|
||||
// Liste und jede Suche mit.
|
||||
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("preview version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
|
||||
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set preview version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||||
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
|
||||
func (s *Store) FilesWithPreview(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT id::text FROM files WHERE preview_version > 0`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("files with preview: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars"} {
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
res.Scanned++
|
||||
if referenced[key] {
|
||||
@@ -153,6 +153,27 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
return nil, fmt.Errorf("iterate file keys: %w", err)
|
||||
}
|
||||
|
||||
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
|
||||
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
|
||||
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
|
||||
prevRows, err := c.db.Query(ctx,
|
||||
`SELECT id::text FROM files WHERE preview_version > 0`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query preview keys: %w", err)
|
||||
}
|
||||
for prevRows.Next() {
|
||||
var id string
|
||||
if err := prevRows.Scan(&id); err != nil {
|
||||
prevRows.Close()
|
||||
return nil, fmt.Errorf("scan preview key: %w", err)
|
||||
}
|
||||
keys["previews/"+id+".pdf"] = true
|
||||
}
|
||||
prevRows.Close()
|
||||
if err := prevRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate preview keys: %w", err)
|
||||
}
|
||||
|
||||
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
||||
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
||||
@@ -263,3 +284,38 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
||||
//
|
||||
// Vorschaubilder, Profilbilder und die als PDF gewandelten Office-Dokumente
|
||||
// zählen nicht gegen das Kontingent eines Kontos – niemand hat sie
|
||||
// hochgeladen, und sie entstehen jederzeit neu. Platte kosten sie trotzdem,
|
||||
// und in der Anzeige „X von Y GB belegt" tauchen sie nicht auf.
|
||||
//
|
||||
// Genau deshalb steht die Zahl hier: Ein Ordner, der wächst, ohne dass ihn
|
||||
// irgendwo jemand sieht, ist eine stille Überraschung. Sichtbar ist sie eine
|
||||
// Größe, über die man entscheiden kann.
|
||||
type DerivedUsage struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Derived zählt die abgeleiteten Ordner durch. Reines Lesen – dieser Aufruf
|
||||
// löscht nichts.
|
||||
func (c *Collector) Derived(ctx context.Context) ([]DerivedUsage, error) {
|
||||
out := make([]DerivedUsage, 0, 3)
|
||||
for _, prefix := range []string{"thumbs", "previews", "avatars"} {
|
||||
u := DerivedUsage{Prefix: prefix}
|
||||
err := c.storage.Walk(ctx, prefix, func(_ string, info storage.FileInfo) error {
|
||||
u.Files++
|
||||
u.Bytes += info.Size
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ein Dokument nach PDF wandeln – dafür, es *anzusehen*, statt es zu
|
||||
// bearbeiten.
|
||||
//
|
||||
// **Warum überhaupt:** Am Telefon ist der Office-Editor unbrauchbar. Er ist
|
||||
// eine vollständige Textverarbeitung; Menüband, Formatleisten und
|
||||
// Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt. Auf 400 Pixeln
|
||||
// bleibt für das Dokument übrig, was die Leisten nicht brauchen. Das gilt für
|
||||
// Collabora wie für OnlyOffice – Marc hat beide ausprobiert, mit demselben
|
||||
// Ergebnis. Ein PDF im eigenen Betrachter ist für „kurz nachsehen" das
|
||||
// bessere Werkzeug, und für „kurz nachsehen" ist man unterwegs da.
|
||||
//
|
||||
// **Warum derselbe Server, der auch bearbeitet:** Wer OnlyOffice wegen der
|
||||
// Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe,
|
||||
// sähe unterwegs ein LibreOffice-Layout desselben Dokuments. Zwei Geräte,
|
||||
// zwei Umbrüche – ausgerechnet bei Unterlagen, bei denen es auf die Seite
|
||||
// ankommt. Die Umwandlung gehört deshalb zu dem Anbieter, der auch bearbeitet.
|
||||
//
|
||||
// Die beiden Anbieter gehen dabei verschiedene Wege, und der Unterschied ist
|
||||
// grundsätzlich:
|
||||
//
|
||||
// - **Collabora** nimmt die Datei entgegen und antwortet mit dem PDF. Ein
|
||||
// Aufruf, keine Anmeldung.
|
||||
// - **OnlyOffice** nimmt eine *Adresse* entgegen, holt die Datei selbst ab
|
||||
// und antwortet mit einer Adresse, unter der das Ergebnis liegt. Also
|
||||
// zwei Aufrufe, eine Signatur, und ein zweiter Ort, dem man nicht trauen
|
||||
// darf (siehe unten).
|
||||
|
||||
// convertTimeout: So lange darf eine Umwandlung dauern.
|
||||
//
|
||||
// Gemessen an dieser Anlage braucht ein gewöhnliches Textdokument etwa eine
|
||||
// Sekunde. Eine Minute ist keine Erwartung, sondern eine Notbremse: Hängt der
|
||||
// Office-Server, soll die Anfrage enden und nicht der Server mit ihr.
|
||||
const convertTimeout = 60 * time.Second
|
||||
|
||||
// jwtLifetime: Wie lange die Signatur an OnlyOffice gilt. Sie wird für genau
|
||||
// einen Aufruf ausgestellt.
|
||||
const jwtLifetime = 2 * time.Minute
|
||||
|
||||
// ConvertToPDF wandelt ein Dokument um.
|
||||
//
|
||||
// `src` ist die Adresse, unter der der Office-Server die Datei abholen kann –
|
||||
// nur OnlyOffice braucht sie. `body` sind die Bytes selbst – nur Collabora
|
||||
// braucht die. Der Aufrufer stellt beides bereit; welcher Weg genommen wird,
|
||||
// entscheidet der Anbieter, nicht der Aufrufer.
|
||||
func (c *Client) ConvertToPDF(ctx context.Context, ext, key, name, src string, body io.Reader) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch c.provider {
|
||||
case "collabora":
|
||||
return c.convertCollabora(ctx, name, body)
|
||||
case "onlyoffice":
|
||||
return c.convertOnlyOffice(ctx, ext, key, name, src)
|
||||
}
|
||||
return nil, fmt.Errorf("office: provider %q cannot convert", c.provider)
|
||||
}
|
||||
|
||||
// CanConvert sagt, ob dieser Anbieter das Wandeln überhaupt anbietet. Für die
|
||||
// Oberfläche: Ohne das gäbe es einen Knopf, der nur Fehler erzeugt.
|
||||
func (c *Client) CanConvert() bool {
|
||||
return c != nil && (c.provider == "collabora" || c.provider == "onlyoffice")
|
||||
}
|
||||
|
||||
// ── Collabora ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *Client) convertCollabora(ctx context.Context, name string, body io.Reader) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
// Der Feldname ist vorgeschrieben („data"), der Dateiname bestimmt, wie
|
||||
// Collabora den Typ erkennt – ohne Endung rät es und liegt daneben.
|
||||
part, err := mw.CreateFormFile("data", name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, body); err != nil {
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/cool/convert-to/pdf", &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("convert: office server answered %d", resp.StatusCode)
|
||||
}
|
||||
// Collabora antwortet mit dem PDF selbst, nicht mit einer Auskunft.
|
||||
return readPDF(resp.Body)
|
||||
}
|
||||
|
||||
// ── OnlyOffice / Euro-Office ────────────────────────────────────────────────
|
||||
|
||||
type convertResponse struct {
|
||||
FileURL string `json:"fileUrl"`
|
||||
FileType string `json:"fileType"`
|
||||
Percent int `json:"percent"`
|
||||
EndConvert bool `json:"endConvert"`
|
||||
// Error ist eine Zahl, keine Meldung: -8 heißt „Signatur fehlt oder ist
|
||||
// falsch", -4 „Quelle nicht erreichbar". Die Zahl steht deshalb im
|
||||
// Fehlertext, sonst sucht man im Log des anderen Servers.
|
||||
Error int `json:"error"`
|
||||
}
|
||||
|
||||
func (c *Client) convertOnlyOffice(ctx context.Context, ext, key, name, src string) ([]byte, error) {
|
||||
if src == "" {
|
||||
return nil, fmt.Errorf("convert: no source address for the document server")
|
||||
}
|
||||
payload := map[string]any{
|
||||
// Synchron: Die Umwandlung dauert Sekunden, und ein zweiter Aufruf zum
|
||||
// Nachfragen wäre ein Zustand mehr, den jemand verwalten muss.
|
||||
"async": false,
|
||||
"filetype": strings.TrimPrefix(ext, "."),
|
||||
"key": key,
|
||||
"outputtype": "pdf",
|
||||
"title": name,
|
||||
"url": src,
|
||||
}
|
||||
if c.jwtSecret != "" {
|
||||
// Die Signatur trägt genau die Nutzlast, die auch gesendet wird –
|
||||
// gemessen: ohne sie antwortet der Server `{"error":-8}`.
|
||||
payload["token"] = signJWT(c.jwtSecret, payload)
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/ConvertService.ashx", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Ohne diesen Kopf antwortet der Server in XML.
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("convert: document server answered %d", resp.StatusCode)
|
||||
}
|
||||
var out convertResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("convert: unreadable answer: %w", err)
|
||||
}
|
||||
if out.Error != 0 {
|
||||
return nil, fmt.Errorf("convert: document server error %d", out.Error)
|
||||
}
|
||||
if !out.EndConvert || out.FileURL == "" {
|
||||
return nil, fmt.Errorf("convert: document server did not finish (%d%%)", out.Percent)
|
||||
}
|
||||
|
||||
// **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** Sie kommt aus
|
||||
// der Antwort eines anderen Servers, und wir würden sie gleich abrufen –
|
||||
// das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf
|
||||
// 169.254.169.254 würde. Der Wähler des Transports lässt ohnehin nur
|
||||
// Adressen aus OFFICE_ALLOWED_HOSTS zu; hier steht die Prüfung trotzdem
|
||||
// noch einmal ausdrücklich, weil eine klare Meldung mehr wert ist als ein
|
||||
// abgewiesener Verbindungsversuch tief im Netzstapel.
|
||||
if err := c.sameHostAsServer(out.FileURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
get, err := http.NewRequestWithContext(ctx, http.MethodGet, out.FileURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res2, err := c.http.Do(get)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch result: %w", err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch result: answered %d", res2.StatusCode)
|
||||
}
|
||||
return readPDF(res2.Body)
|
||||
}
|
||||
|
||||
// sameHostAsServer prüft, dass eine Adresse auf denselben Rechner zeigt wie
|
||||
// der Office-Server selbst.
|
||||
func (c *Client) sameHostAsServer(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: unreadable result address")
|
||||
}
|
||||
base, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: OFFICE_URL is not a valid address")
|
||||
}
|
||||
if !strings.EqualFold(u.Hostname(), base.Hostname()) {
|
||||
return fmt.Errorf("convert: result address points elsewhere (%q), refused", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── gemeinsam ────────────────────────────────────────────────────────────────
|
||||
|
||||
// maxPDFBytes begrenzt, was als Ergebnis angenommen wird. Ohne Grenze
|
||||
// bestimmte der andere Server, wie viel Speicher wir belegen.
|
||||
const maxPDFBytes = 256 << 20
|
||||
|
||||
func readPDF(r io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxPDFBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read result: %w", err)
|
||||
}
|
||||
if len(data) > maxPDFBytes {
|
||||
return nil, fmt.Errorf("read result: converted file is too large")
|
||||
}
|
||||
// Kein Vertrauen auf den Content-Type: Collabora liefert
|
||||
// `application/octet-stream`. Die ersten Bytes sind die Auskunft, die
|
||||
// zählt – und sie verhindert, dass eine Fehlerseite als PDF abgelegt wird.
|
||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
return nil, fmt.Errorf("read result: answer is not a PDF")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// signJWT baut ein HS256-Token über die Nutzlast.
|
||||
//
|
||||
// Von Hand statt mit einer Bibliothek: Es sind zwölf Zeilen, und die einzige
|
||||
// Alternative wäre eine weitere Abhängigkeit für einen einzigen Aufruf.
|
||||
func signJWT(secret string, payload map[string]any) string {
|
||||
claims := make(map[string]any, len(payload)+1)
|
||||
for k, v := range payload {
|
||||
claims[k] = v
|
||||
}
|
||||
claims["exp"] = time.Now().Add(jwtLifetime).Unix()
|
||||
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
head := enc(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||
body := enc(claims)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(head + "." + body))
|
||||
return head + "." + body + "." +
|
||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
|
||||
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
|
||||
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
|
||||
|
||||
func TestSignJWTIsVerifiable(t *testing.T) {
|
||||
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
|
||||
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(parts[0] + "." + parts[1]))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if parts[2] != want {
|
||||
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
|
||||
}
|
||||
|
||||
head, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Kopf nicht dekodierbar: %v", err)
|
||||
}
|
||||
var h map[string]string
|
||||
if err := json.Unmarshal(head, &h); err != nil {
|
||||
t.Fatalf("Kopf ist kein JSON: %v", err)
|
||||
}
|
||||
if h["alg"] != "HS256" {
|
||||
t.Errorf("alg = %q, erwartet HS256", h["alg"])
|
||||
}
|
||||
|
||||
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(body, &claims); err != nil {
|
||||
t.Fatalf("Nutzlast ist kein JSON: %v", err)
|
||||
}
|
||||
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
|
||||
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
|
||||
}
|
||||
// Ohne Ablauf gälte ein abgefangenes Token für immer.
|
||||
if _, ok := claims["exp"]; !ok {
|
||||
t.Error("exp fehlt – das Token liefe nie ab")
|
||||
}
|
||||
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
|
||||
if _, ok := claims["token"]; ok {
|
||||
t.Error("die Signatur signiert sich selbst mit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPDFRejectsNonPDF(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
|
||||
"leer": "",
|
||||
"JSON-Fehler": `{"error":-4}`,
|
||||
"fast ein PDF": "%PD",
|
||||
}
|
||||
for name, body := range cases {
|
||||
if _, err := readPDF(strings.NewReader(body)); err == nil {
|
||||
t.Errorf("%s wurde als PDF angenommen", name)
|
||||
}
|
||||
}
|
||||
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
|
||||
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
|
||||
t.Errorf("echtes PDF abgelehnt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
|
||||
c := &Client{baseURL: "http://office:80"}
|
||||
|
||||
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
|
||||
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
|
||||
foreign := []string{
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost:8080/api/v1/admin/users",
|
||||
"http://office.evil.example.com/x.pdf",
|
||||
"file:///etc/passwd",
|
||||
"://kaputt",
|
||||
}
|
||||
for _, raw := range foreign {
|
||||
if err := c.sameHostAsServer(raw); err == nil {
|
||||
t.Errorf("fremde Adresse angenommen: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ok := range []string{
|
||||
"http://office/cache/files/x.pdf",
|
||||
"http://office:80/cache/files/x.pdf",
|
||||
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
|
||||
} {
|
||||
if err := c.sameHostAsServer(ok); err != nil {
|
||||
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`Rechnung "2026".docx`: "Rechnung 2026",
|
||||
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
|
||||
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
|
||||
".odt": ".odt",
|
||||
"": "document",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFilename(in); got != want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@ type Client struct {
|
||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||
// keinem Browser.
|
||||
publicBase string
|
||||
http *http.Client
|
||||
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
|
||||
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
|
||||
jwtSecret string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
actions map[string]string // ".docx|edit" -> urlsrc
|
||||
@@ -62,7 +65,7 @@ type Client struct {
|
||||
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
||||
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
||||
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
||||
@@ -96,6 +99,7 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
|
||||
provider: provider,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
publicBase: strings.TrimRight(publicURL, "/"),
|
||||
jwtSecret: jwtSecret,
|
||||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
|
||||
//
|
||||
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
|
||||
// bleibt einen Knopfdruck entfernt.
|
||||
//
|
||||
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
|
||||
//
|
||||
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
|
||||
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
|
||||
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
|
||||
// Frage des Aufräumens, sondern des Schlüssels.
|
||||
//
|
||||
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
|
||||
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
|
||||
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
|
||||
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
|
||||
//
|
||||
// ── Was das für die Platte heißt ────────────────────────────────────────────
|
||||
//
|
||||
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
|
||||
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
|
||||
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
|
||||
// den Ordner – beides ist nötig, und beides einzeln geprüft.
|
||||
|
||||
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
|
||||
// ebenfalls kennen müssen.
|
||||
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
|
||||
|
||||
// PreviewPrefix ist der Ordner, den der GC durchgeht.
|
||||
const PreviewPrefix = "previews"
|
||||
|
||||
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
|
||||
//
|
||||
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
|
||||
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
|
||||
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
|
||||
const maxPreviewSource = 100 << 20
|
||||
|
||||
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
|
||||
//
|
||||
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
|
||||
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
|
||||
// Umwandlung, nicht die Richtigkeit.
|
||||
var previewLocks sync.Map // fileID -> *sync.Mutex
|
||||
|
||||
func lockPreview(id string) func() {
|
||||
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := m.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// GET /api/v1/files/{id}/preview.pdf
|
||||
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.Enabled() || !h.client.CanConvert() {
|
||||
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
|
||||
return
|
||||
}
|
||||
f, userID, ok := h.resolveSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !Editable(f.Name) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
|
||||
return
|
||||
}
|
||||
if f.Size > maxPreviewSource {
|
||||
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
|
||||
// Editor anbieten soll oder einen Fehler zeigt.
|
||||
response.Error(w, http.StatusRequestEntityTooLarge,
|
||||
"this document is too large to be shown as a PDF")
|
||||
return
|
||||
}
|
||||
|
||||
unlock := lockPreview(f.ID.String())
|
||||
defer unlock()
|
||||
|
||||
key := PreviewKey(f.ID.String())
|
||||
have, err := h.files.PreviewVersion(r.Context(), f.ID)
|
||||
if err != nil && !errors.Is(err, files.ErrNotFound) {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
if have == f.Version {
|
||||
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
|
||||
defer rc.Close()
|
||||
servePDF(w, f.Name, f.Version)
|
||||
_, _ = io.Copy(w, rc)
|
||||
return
|
||||
}
|
||||
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
|
||||
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
|
||||
// sie neu zu erzeugen.
|
||||
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
|
||||
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
|
||||
}
|
||||
|
||||
pdf, err := h.convert(r.Context(), f, userID)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("office preview: Umwandlung fehlgeschlagen")
|
||||
response.Error(w, http.StatusBadGateway, "the document could not be converted")
|
||||
return
|
||||
}
|
||||
|
||||
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
|
||||
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
|
||||
// ohne es zu merken.
|
||||
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
|
||||
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
|
||||
}
|
||||
|
||||
servePDF(w, f.Name, f.Version)
|
||||
_, _ = w.Write(pdf)
|
||||
}
|
||||
|
||||
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
|
||||
// versteht.
|
||||
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
|
||||
ext := Extension(f.Name)
|
||||
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
|
||||
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
|
||||
// alte Dokument aus.
|
||||
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
|
||||
|
||||
switch h.client.Provider() {
|
||||
case "collabora":
|
||||
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
|
||||
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
|
||||
// Erreichbarkeit – und ein Token mehr in der Welt.
|
||||
rc, err := h.blobs.Get(ctx, f.StorageKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
|
||||
|
||||
default:
|
||||
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
|
||||
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
|
||||
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
|
||||
token, _ := h.signer.Issue(f.ID, userID, false)
|
||||
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
|
||||
h.wopiURL, f.ID, token)
|
||||
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func servePDF(w http.ResponseWriter, name string, version int) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
|
||||
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
|
||||
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
|
||||
// der Browser holt es nach einer Änderung also von selbst neu.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
|
||||
}
|
||||
|
||||
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
|
||||
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
|
||||
if i := strings.LastIndexByte(name, '.'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
|
||||
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
|
||||
//
|
||||
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
|
||||
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
|
||||
// Token für genau eine Datei.
|
||||
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
|
||||
// Datei gibt.
|
||||
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
return f, user.ID, true
|
||||
}
|
||||
@@ -113,6 +113,11 @@ type Config struct {
|
||||
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
|
||||
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
|
||||
OfficeInsecureTLS bool
|
||||
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
|
||||
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
|
||||
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
|
||||
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
|
||||
OfficeJWTSecret string
|
||||
}
|
||||
|
||||
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
|
||||
@@ -199,6 +204,7 @@ func Load() (*Config, error) {
|
||||
|
||||
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
|
||||
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
|
||||
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
|
||||
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
|
||||
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
|
||||
HoneybarKey: getEnv("HONEYBAR_KEY", ""),
|
||||
@@ -216,10 +222,21 @@ func Load() (*Config, error) {
|
||||
OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true",
|
||||
}
|
||||
|
||||
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "86400"))
|
||||
// 30 Tage, und die Frist **gleitet**: Sie zählt ab der letzten Benutzung,
|
||||
// nicht ab dem Anmelden (siehe auth.SessionStore.Touch). Vorher stand hier
|
||||
// ein Tag ab Anmeldung – wer täglich arbeitete, flog trotzdem täglich
|
||||
// heraus. Ein Ablauf, der den Anwesenden trifft und nicht den Abwesenden,
|
||||
// erkauft keine Sicherheit, sondern nur Ärger.
|
||||
//
|
||||
// Kürzer ist jederzeit einstellbar; bei einem gemeinsam genutzten Rechner
|
||||
// wäre das sinnvoll.
|
||||
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "2592000"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err)
|
||||
}
|
||||
if maxAge <= 0 {
|
||||
return nil, fmt.Errorf("SESSION_MAX_AGE must be greater than 0 (seconds)")
|
||||
}
|
||||
cfg.SessionMaxAge = maxAge
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
|
||||
--
|
||||
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
|
||||
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
|
||||
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
|
||||
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
|
||||
--
|
||||
-- 0 heißt "keine". Dateifassungen zählen ab 1.
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
File renamed without changes.
@@ -1,13 +1,22 @@
|
||||
# Aether – Entwicklung: aus dem Quelltext bauen.
|
||||
#
|
||||
# docker compose -f deploy/docker-compose.yml build app
|
||||
# docker compose -f deploy/docker-compose.yml up -d
|
||||
# docker compose -f build/docker-compose.yml build app
|
||||
# docker compose -f build/docker-compose.yml up -d
|
||||
#
|
||||
# Zum **Betreiben** ist docker-compose.example.yml im Wurzelverzeichnis
|
||||
# gedacht: schlanker und ohne Quelltext, es zieht das fertige Abbild.
|
||||
#
|
||||
# Die Einstellungen kommen aus build/.env – als Vorlage dient die eine
|
||||
# .env.example im Wurzelverzeichnis: cp .env.example build/.env
|
||||
#
|
||||
# Erklärungen zu allem hier: README.md
|
||||
|
||||
# Der Projektname steht hier fest und wird **nicht** aus dem Ordnernamen
|
||||
# abgeleitet. Sonst hinge er daran, wie der Ordner gerade heißt: Beim
|
||||
# Umbenennen von deploy/ nach build/ hätte Compose ein neues, leeres Projekt
|
||||
# angelegt – mit einer neuen, leeren Datenbank.
|
||||
name: aether
|
||||
|
||||
services:
|
||||
app:
|
||||
container_name: aether-app
|
||||
@@ -15,7 +24,7 @@ services:
|
||||
# Kontext ist das Wurzelverzeichnis – das Abbild braucht backend/ und
|
||||
# frontend/, nicht nur diesen Ordner.
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
dockerfile: build/Dockerfile
|
||||
image: gitea.honeyy.duckdns.org/docker/aether:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
@@ -85,6 +94,10 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
|
||||
# Ordner „deploy" hieß – umbenennen hieße hier: neues Volume, leere
|
||||
# Datenbank. Der unschöne Name ist der Preis dafür, die Daten zu behalten.
|
||||
name: deploy_postgres_data
|
||||
# "data" ist bewusst weg – die Dateien liegen unter DATA_PATH auf dem Wirt.
|
||||
# Die Datenbank bleibt ein benanntes Volume: An ihre Dateien geht man nicht
|
||||
# von Hand, und Postgres mag fremde Besitzer nicht.
|
||||
@@ -273,6 +273,27 @@ Geplante Ideen (noch nicht umgesetzt):
|
||||
- Glasmorphismus-Theme (transluzente Sidebar/Cards mit Blur-Effekt)
|
||||
- Logo und App-Symbol bereits in Phase 4.5/D umgesetzt
|
||||
|
||||
- ✅ **Gleitende Sitzung** (`internal/auth/session.go`, 01.09.2026, Wunsch von Marc: „der Login speichert sich nicht"). Die Frist zählt ab der **letzten Benutzung** statt ab dem Anmelden; Vorgabe 30 Tage. Verlängert wird höchstens einmal pro Stunde – ohne diese Bremse trüge jede Antwort ein `Set-Cookie`, und beim Laden einer Dateiliste sind das dreißig.
|
||||
- **Warum das keine Bequemlichkeit auf Kosten der Sicherheit ist:** Der alte Ablauf traf den *Anwesenden* und nicht den *Abwesenden* – wer täglich arbeitete, flog täglich heraus, wer das Konto vergaß, blieb genauso lange angemeldet. Ein Ablauf ab letzter Benutzung trifft genau den Fall, um den es geht.
|
||||
- **Der Preis steht in der Doku, nicht nur im Kopf:** Ein aus einem fremden Browser kopiertes Cookie 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 beenden = `SESSION_SECRET` wechseln.
|
||||
- ⚠️ **Dabei gefunden, war schon vorher drin:** `NewSessionStore` ersetzte `store.Options`, ohne `store.MaxAge()` zu rufen. Es gibt aber **zwei** Fristen – die im Cookie (der Browser wirft es weg) und die in der Signatur (der Server lehnt es ab). Nur die erste war gesetzt, die zweite stand auf gorillas Voreinstellung von 30 Tagen. Ein herauskopiertes Cookie blieb damit einen Monat gültig, obwohl `SESSION_MAX_AGE` auf einen Tag stand: Wer die Frist kürzte, um sie zu kürzen, hatte nichts gekürzt. Behoben und mit Test festgehalten – der Test ist gegengeprüft, ohne den Aufruf schlägt er fehl.
|
||||
- **Bestehende `.env`-Werte bleiben unangetastet.** Wer die 30 Tage will, trägt sie selbst ein; die neue Vorgabe gilt nur für frische Installationen.
|
||||
|
||||
- ✅ **Office-Dokumente am Telefon als PDF ansehen** (`internal/office/convert.go` + `preview.go`, Migration 000020, 31.08.2026, Wunsch von Marc). Am Finger öffnet ein `.docx` **bei OnlyOffice** nicht mehr den Editor, sondern das gewandelte PDF im eigenen Betrachter; „Edit" daneben führt trotzdem hinein. Am Schreibtisch bleibt alles wie es war.
|
||||
- **Bei Collabora bleibt der Editor.** Zuerst galt die Regel für beide Anbieter – falsch, und Marc hat es sofort gesehen: Seine Kritik betraf nur OnlyOffice. Collabora bringt für kleine Schirme eine eigene Ansicht mit, die taugt; dort wäre der Umweg über ein PDF ein Rückschritt. Der Anbieter steht deshalb in der öffentlichen Konfiguration (`office_provider`), und die Oberfläche entscheidet daran. Der Endpunkt selbst kann beides und ist mit beiden gemessen (Collabora 0,35 s, OnlyOffice 1,05 s).
|
||||
- **Warum überhaupt:** Der Web-Editor ist eine vollständige Textverarbeitung. Menüband, Formatleisten und Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt – auf 400 Pixeln bleibt für das Dokument ein Rechteck. Marc hat Collabora **und** OnlyOffice ausprobiert, mit demselben Ergebnis; das ist der Grund, aus dem hier kein besserer Bedienmodus gesucht wurde, sondern ein anderes Werkzeug. Nextcloud löst es in seiner App genauso: ansehen statt bearbeiten.
|
||||
- **Gewandelt wird von dem Server, der auch bearbeitet.** Wer OnlyOffice wegen der Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe, sähe unterwegs ein LibreOffice-Layout desselben Dokuments – zwei Geräte, zwei Umbrüche, ausgerechnet bei Unterlagen. Beide Wege sind deshalb gebaut: Collabora nimmt die Bytes (`/cool/convert-to/pdf`), OnlyOffice eine Adresse, holt die Datei selbst und antwortet mit einer Adresse (`/ConvertService.ashx`, HS256-signiert).
|
||||
- **Je Datei gibt es genau ein PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels:** `previews/<id>.pdf` trägt die Fassung **nicht** im Namen. Eine neue Fassung überschreibt, statt danebenzulegen. Ob das Abgelegte noch passt, sagt `files.preview_version`. Gemessen: Nach dem erneuten Hochladen stand die Datei auf Fassung 2, `preview_version` auf 2, und im Ordner lag weiterhin genau eine Datei.
|
||||
- **Der GC kennt den Ordner – und die Referenz.** Beides musste sein: ohne Präfix wächst er ewig, ohne Referenz löscht der GC das PDF nach einem Tag weg. Genau dieser zweite Fehler ist beim Logo und beim Favicon schon einmal passiert. Beide Richtungen einzeln geprüft (herrenloses PDF von vorgestern → weg; echtes → bleibt).
|
||||
- **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** OnlyOffice antwortet nicht mit dem PDF, sondern mit einer Adresse, die wir gleich abrufen – das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf `169.254.169.254` würde. Der Wähler des Transports lässt ohnehin nur `OFFICE_ALLOWED_HOSTS` zu; die ausdrückliche Prüfung steht zusätzlich da und ist mit Test abgedeckt.
|
||||
- **Zählt gegen kein Kontingent** (Entscheidung von Marc): Es ist abgeleitet, niemand hat es hochgeladen. Platte kostet es trotzdem – deshalb steht der Platzbedarf jetzt im Admin-Bereich neben der Bereinigung, zusammen mit Vorschau- und Profilbildern. Ein Ordner, der wächst, ohne dass ihn irgendwo jemand sieht, ist eine stille Überraschung.
|
||||
- Gemessen an dieser Anlage: erster Abruf 1,05 s, zweiter 0,009 s. Fremde Kennung `404`, ohne Anmeldung `401`, Nicht-Office-Datei `415`, zu große Datei `413` (die Oberfläche bietet daraufhin den Editor an).
|
||||
|
||||
- ⏸️ **Im Papierkorb ansehen, was es war – vertagt** (26.08.2026, Wunsch von Marc: „erstmal noch nicht wichtig"). Die kleinen Vorschaubilder sind seit dem 26.08. wieder da (`GetThumbnail` sah nur in nicht-gelöschten Dateien nach). Gemeint ist der Schritt danach: eine Datei im Papierkorb **öffnen** und ansehen, bevor man „Delete forever" drückt.
|
||||
- **Warum es überhaupt eine Idee ist:** Der Papierkorb ist die einzige Ansicht ohne Vorschau – ausgerechnet die, in der die Entscheidung unwiderruflich ist. Wer nach zwei Wochen aufräumt, sieht einen Namen wie `IMG_2481.jpg` und rät.
|
||||
- **Wo es hängt:** an derselben Stelle wie eben das Vorschaubild. `Download` schlägt die Datei mit `is_deleted = false` nach (`handler.go:411`), der Vorschau-Dialog hängt außerdem an der Dateiliste, nicht an einem einzelnen Eintrag – er blättert zum nächsten Bild und kennt Umbenennen, Teilen, Löschen. Im Papierkorb wäre davon fast nichts sinnvoll.
|
||||
- **Der Zuschnitt wäre deshalb enger:** nur ansehen, kein Blättern, keine Bearbeitung – und der Download-Pfad müsste für Gelöschtes gezielt geöffnet werden, nicht pauschal. Sonst ließe sich eine gelöschte Datei über ihre Kennung genauso herunterladen wie eine lebende, und der Papierkorb wäre kein Zwischenschritt mehr, sondern ein zweiter Ordner.
|
||||
|
||||
**OIDC-Durchgang (23.08.2026)**
|
||||
|
||||
Vor dem Produktivgang durchgesehen. Zwei Befunde, beide vorher unsichtbar:
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# Aether – alle Schalter. cp deploy/.env.example deploy/.env
|
||||
#
|
||||
# Zwei Fallen beim Bearbeiten:
|
||||
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
|
||||
# Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert
|
||||
# Erklärungen und Fallstricke: README.md, Abschnitt „Einstellungen"
|
||||
#
|
||||
# Für den reinen Betrieb genügt die kürzere .env.example im
|
||||
# Wurzelverzeichnis – hier steht zusätzlich alles Optionale.
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
APP_ENV=production # development | production
|
||||
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
|
||||
PUBLIC_URL=https://cloud.example.com
|
||||
# IPs/CIDRs der Proxys, denen X-Forwarded-For/-Proto geglaubt wird.
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
|
||||
# ── Datenbank ─────────────────────────────────────────────────────────────────
|
||||
# DB_HOST ist der Dienstname aus dem Compose, nicht localhost.
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=aether
|
||||
DB_USER=aether
|
||||
# Jedes Zeichen erlaubt – nur ein $ muss $$ heißen oder der Wert in
|
||||
# 'einfache Anführungszeichen'. Sonst schneidet Compose lautlos ab. (README)
|
||||
DB_PASSWORD=changeme_strong_password
|
||||
|
||||
# ── Sitzung ───────────────────────────────────────────────────────────────────
|
||||
SESSION_SECRET=changeme_generate_with_openssl_rand_hex_32 # openssl rand -hex 32
|
||||
SESSION_MAX_AGE=86400 # Sekunden
|
||||
|
||||
# ── Speicher ──────────────────────────────────────────────────────────────────
|
||||
STORAGE_PATH=/data # im Container
|
||||
TMP_PATH=/data/tmp
|
||||
# Verzeichnis auf dem Wirt. Nur lesend anfassen – zum Hineinlegen: WebDAV.
|
||||
DATA_PATH=/pfad/zu/aether-data
|
||||
# `id -u` / `id -g` – sonst gehören die Dateien einem Container-Nutzer.
|
||||
APP_UID=1000
|
||||
APP_GID=1000
|
||||
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
|
||||
MAX_VERSIONS_KEPT=5
|
||||
# Port auf dem Wirt. Im Container bleibt es 8080.
|
||||
APP_PORT=8080
|
||||
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe).
|
||||
# 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
|
||||
APP_BIND=127.0.0.1
|
||||
|
||||
# ── Erster Start ──────────────────────────────────────────────────────────────
|
||||
# Legt den Admin an. Danach ADMIN_PASSWORD leeren.
|
||||
# Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD).
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme_strong_admin_password
|
||||
|
||||
# ── OIDC (optional) ───────────────────────────────────────────────────────────
|
||||
# Leerer OIDC_ISSUER = aus. OIDC_REDIRECT_URL muss exakt
|
||||
# <PUBLIC_URL>/api/v1/auth/oidc/callback sein und beim Anbieter eingetragen.
|
||||
# OIDC_ISSUER=https://your-idp.example.com # Adresse, nicht der Name
|
||||
# OIDC_CLIENT_ID=aether
|
||||
# OIDC_CLIENT_SECRET=
|
||||
# OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
|
||||
# Bestehendes Konto verbinden statt ein zweites anzulegen.
|
||||
# VOR der ersten Anmeldung setzen – danach ist es zu spät.
|
||||
# OIDC_LINK_BY_USERNAME=true
|
||||
# Beschriftung des Knopfes auf der Anmeldeseite.
|
||||
# OIDC_BUTTON_LABEL=Anmelden mit PocketID
|
||||
|
||||
# ── HoneyOS-Leiste ────────────────────────────────────────────────────────────
|
||||
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
|
||||
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
|
||||
# connect-src), und die Oberflaeche laedt das Skript von dort nach.
|
||||
#
|
||||
# Wirkt beim **Neustart** — kein Neubau noetig.
|
||||
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
|
||||
HONEYBAR_ORIGIN=https://os.example.com
|
||||
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
|
||||
# drueben passen. Nur noetig, wenn HoneyOS auf einer anderen Site liegt als
|
||||
# Aether — dann schickt der Browser die HoneyOS-Sitzung nicht mit.
|
||||
#
|
||||
# Gleiche Site (os.beispiel.duckdns.org + cloud.beispiel.duckdns.org): kann leer
|
||||
# bleiben, die Sitzung reicht.
|
||||
HONEYBAR_KEY=
|
||||
|
||||
# ── Volltextsuche (OCR) ───────────────────────────────────────────────────────
|
||||
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
|
||||
# OCR_ENABLED=false
|
||||
# OCR_LANGUAGES=deu+eng # weitere brauchen ein apk-Paket im Dockerfile
|
||||
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
|
||||
# OCR_TIMEOUT=10m # Notbremse je Datei
|
||||
|
||||
# ── App-Umschalter ────────────────────────────────────────────────────────────
|
||||
# Andere selbst gehostete Apps im Menü der Seitenleiste.
|
||||
# Format: Name|URL|Emoji – mehrere durch Komma. Leer = kein Menü.
|
||||
EXTERNAL_APPS=
|
||||
|
||||
# ── Office (optional) ─────────────────────────────────────────────────────────
|
||||
# Leer = aus, auch wenn ein Office-Container läuft. Schnipsel: README.md
|
||||
# collabora | onlyoffice (auch Euro-Office)
|
||||
OFFICE_PROVIDER=
|
||||
# Backend → Office. Collabora lauscht auf 9980, OnlyOffice/Euro-Office auf 80.
|
||||
OFFICE_URL=
|
||||
# Browser → Office; leer = wie OFFICE_URL
|
||||
OFFICE_PUBLIC_URL=
|
||||
# Office → Aether; leer = PUBLIC_URL.
|
||||
# Weder localhost noch der veröffentlichte Port – beides scheitert.
|
||||
OFFICE_WOPI_URL=
|
||||
# Gegen SSRF; der Host aus OFFICE_URL zählt immer dazu.
|
||||
OFFICE_ALLOWED_HOSTS=
|
||||
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen.
|
||||
OFFICE_JWT_SECRET=
|
||||
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
|
||||
|
||||
# Nur für den Collabora-Container aus deploy/docker-compose.yml:
|
||||
OFFICE_ALIASGROUP=http://app:8080
|
||||
OFFICE_SSL=false
|
||||
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
|
||||
OFFICE_PORT=9980
|
||||
|
||||
# ── Später ────────────────────────────────────────────────────────────────────
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_KEY=
|
||||
|
||||
# ── WebDAV ────────────────────────────────────────────────────────────────────
|
||||
# Läuft unter /dav/, kein Schalter nötig. Der Proxy muss
|
||||
# PROPFIND/MKCOL/MOVE/COPY durchreichen.
|
||||
@@ -16,7 +16,7 @@
|
||||
# `docker compose stop office`.
|
||||
#
|
||||
# Hintergrund und Fallstricke: README.md
|
||||
# Aus dem Quelltext bauen statt ziehen: deploy/docker-compose.yml
|
||||
# Aus dem Quelltext bauen statt ziehen: build/docker-compose.yml
|
||||
|
||||
services:
|
||||
app:
|
||||
|
||||
@@ -225,6 +225,8 @@ export interface PublicConfig {
|
||||
/** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar
|
||||
* nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */
|
||||
office_enabled?: boolean
|
||||
/** 'collabora' | 'onlyoffice' | '' – entscheidet über die Handy-Ansicht. */
|
||||
office_provider?: string
|
||||
/** Wie viele frühere Fassungen der Server aufhebt (MAX_VERSIONS_KEPT). */
|
||||
max_versions_kept?: number
|
||||
/** Beschriftung des SSO-Knopfes (OIDC_BUTTON_LABEL). Leer, wenn OIDC aus ist. */
|
||||
@@ -601,6 +603,17 @@ export const filesApi = {
|
||||
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
|
||||
* mit den Fassungen 1, 2, 3.
|
||||
*/
|
||||
/**
|
||||
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
|
||||
*
|
||||
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
|
||||
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
|
||||
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
|
||||
* Vorschaubild.
|
||||
*/
|
||||
officePreview: (id: string, version?: number) =>
|
||||
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
|
||||
|
||||
thumbnail: (id: string, version?: number | string) =>
|
||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||
|
||||
@@ -838,6 +851,10 @@ export const adminApi = {
|
||||
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
|
||||
},
|
||||
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
|
||||
/** Platzbedarf der abgeleiteten Daten – zählt gegen kein Kontingent. */
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { filesApi, APIError, type FileItem } from '../api/client'
|
||||
import { fileKind } from './filetypes'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
|
||||
/**
|
||||
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
|
||||
@@ -18,6 +19,17 @@ interface Props {
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* Ein Office-Dokument als PDF zeigen statt im Editor.
|
||||
*
|
||||
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
|
||||
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
|
||||
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
|
||||
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
|
||||
*/
|
||||
asOfficePdf?: boolean
|
||||
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
|
||||
onEdit?: () => void
|
||||
/**
|
||||
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
||||
@@ -89,10 +101,12 @@ export function canPreview(file: FileItem): boolean {
|
||||
return kindOf(file.mime_type, file.name) !== 'none'
|
||||
}
|
||||
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||
const kind = kindOf(file.mime_type, file.name)
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
|
||||
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
|
||||
// gewandeltes PDF ist es eins.
|
||||
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
|
||||
const narrow = useNarrow()
|
||||
const src = filesApi.inline(file.id)
|
||||
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [tooBig, setTooBig] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -163,11 +177,17 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (dirty && !confirm('Discard your changes?')) return
|
||||
function tryClose(): boolean {
|
||||
if (dirty && !confirm('Discard your changes?')) return false
|
||||
onClose()
|
||||
return true
|
||||
}
|
||||
|
||||
// Zurück-Geste und Zurück-Taste schließen die Vorschau, statt die Anwendung
|
||||
// zu verlassen. Wichtig in der installierten App: Dort gibt es keinen
|
||||
// anderen Weg zurück.
|
||||
useBackClose({ active: true, onClose: tryClose })
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={tryClose}
|
||||
@@ -198,6 +218,23 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
|
||||
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
|
||||
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
|
||||
die Ausnahme. */}
|
||||
{asOfficePdf && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{kind === 'text' && !tooBig && text !== null && (
|
||||
editing ? (
|
||||
<>
|
||||
|
||||
@@ -34,7 +34,7 @@ function FolderIcon({ size = 24, group = false }: { size?: number; group?: boole
|
||||
* nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine
|
||||
* Mausgeste; am Finger ist er nie angekommen.
|
||||
*/
|
||||
function useCoarsePointer(): boolean {
|
||||
export function useCoarsePointer(): boolean {
|
||||
const [coarse, setCoarse] = useState(
|
||||
() => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
|
||||
/**
|
||||
* Ein Dokument im Office-Server bearbeiten.
|
||||
@@ -41,6 +42,12 @@ export default function OfficeEditor({ file, onClose }: {
|
||||
// ein Schritt, der nichts entscheidet.
|
||||
useEffect(() => { if (session) formRef.current?.submit() }, [session])
|
||||
|
||||
// Die Zurück-Geste schließt das Dokument. Auf dem Telefon ist sie die
|
||||
// gewohnte Bewegung, und Collabora blendet seinen eigenen Schließen-Knopf
|
||||
// auf kleinen Schirmen aus – ohne das käme man aus dem Editor nur über die
|
||||
// Kopfzeile heraus.
|
||||
useBackClose({ active: true, onClose })
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
// Escape schließt nur, solange der Rahmen nicht den Tastendruck hat –
|
||||
|
||||
@@ -39,9 +39,26 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
|
||||
*/
|
||||
const standardFontDataUrl = '/pdfjs/standard_fonts/'
|
||||
|
||||
/** Zoomstufen des Knopfes. „Fit" liegt außerhalb und wird gerechnet. */
|
||||
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
|
||||
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
|
||||
|
||||
/**
|
||||
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
|
||||
* Zahlen gerastet – 65, 70, 75, 80 …
|
||||
*
|
||||
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
|
||||
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
|
||||
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
|
||||
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
|
||||
* Raster, und das hier ist eines.
|
||||
*
|
||||
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
|
||||
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
|
||||
*/
|
||||
const zoomStepPercent = 5
|
||||
const minScale = 0.25
|
||||
const maxScale = 4
|
||||
|
||||
/**
|
||||
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
|
||||
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
|
||||
@@ -603,6 +620,11 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
* anspringt.
|
||||
*/
|
||||
const keepAnchor = useRef<{ px: number; py: number; cx: number; cy: number; from: number } | null>(null)
|
||||
/** Fingerabstand, gegen den die Zoomgeste misst. Null heißt: keine Geste. */
|
||||
const pinchBase = useRef(0)
|
||||
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
|
||||
const scaleRef = useRef(effScale)
|
||||
scaleRef.current = effScale
|
||||
useEffect(() => {
|
||||
const anchor = keepAnchor.current
|
||||
if (anchor) {
|
||||
@@ -628,6 +650,30 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
: [...zoomSteps].reverse().find(s => s < now - 0.001)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
|
||||
* Anschlag.
|
||||
*
|
||||
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
|
||||
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
|
||||
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
|
||||
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
|
||||
* Fünfern weiter.
|
||||
*/
|
||||
function nextFine(now: number, dir: 1 | -1): number | undefined {
|
||||
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
|
||||
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
|
||||
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
|
||||
// genau eine Stufe, danach nichts mehr.
|
||||
const p = Math.round(now * 100 * 1e6) / 1e6
|
||||
const step = zoomStepPercent
|
||||
const raw = dir > 0
|
||||
? Math.floor(p / step + 1) * step
|
||||
: Math.ceil(p / step - 1) * step
|
||||
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
|
||||
return Math.abs(next - now) < 0.001 ? undefined : next
|
||||
}
|
||||
|
||||
function zoom(dir: 1 | -1) {
|
||||
const next = nextStep(effScale, dir)
|
||||
if (next) { keepPage.current = current; setScale(next) }
|
||||
@@ -651,7 +697,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const root2 = scrollRef.current
|
||||
if (!root2) return
|
||||
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
|
||||
const next = nextStep(effScale, dir)
|
||||
const next = nextFine(effScale, dir)
|
||||
if (!next) return
|
||||
const r = root2.getBoundingClientRect()
|
||||
const cx = e.clientX - r.left
|
||||
@@ -666,6 +712,113 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
return () => root.removeEventListener('wheel', onWheel)
|
||||
}, [effScale])
|
||||
|
||||
/**
|
||||
* Zwei Finger zoomen das Dokument – nicht die Seite.
|
||||
*
|
||||
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
|
||||
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
|
||||
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
|
||||
* bisher nur die Knöpfe blieben.
|
||||
*
|
||||
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
|
||||
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
|
||||
* einem Finger bleibt erlaubt), und dieser Zuhörer setzt an ihre Stelle
|
||||
* unsere Stufen.
|
||||
*
|
||||
* **In Zwanzigsteln, nicht stufenlos.** Der Zoom rastet auf fünf Prozent
|
||||
* des aktuellen Werts, sobald der Fingerabstand um ebenso viel gewachsen
|
||||
* oder geschrumpft ist. Danach zählt der neue Abstand als Anfang, eine
|
||||
* lange Geste geht also über viele Stufen weiter.
|
||||
*
|
||||
* Dass daraus keine Ruckelei wird, liegt nicht an dieser Stelle: Die
|
||||
* Zeichenschleife bricht eine laufende Darstellung ab, sobald sich der
|
||||
* Maßstab ändert. Von zwanzig Stufen einer Geste wird also nur die letzte
|
||||
* fertig gemalt.
|
||||
*
|
||||
* Die Leiter der Knöpfe (0,5 / 0,75 / 1 / …) wäre hier zu grob: Ein
|
||||
* Viertelsprung schießt über die Größe hinaus, die man mit den Fingern
|
||||
* gerade sucht.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
|
||||
function distance(t: TouchList) {
|
||||
const dx = t[0].clientX - t[1].clientX
|
||||
const dy = t[0].clientY - t[1].clientY
|
||||
return Math.hypot(dx, dy)
|
||||
}
|
||||
|
||||
function onStart(e: TouchEvent) {
|
||||
pinchBase.current = e.touches.length === 2 ? distance(e.touches) : 0
|
||||
}
|
||||
|
||||
function onMove(e: TouchEvent) {
|
||||
const base = pinchBase.current
|
||||
if (e.touches.length !== 2 || !base) return
|
||||
// Verhindert, dass die Geste nebenbei die Liste weiterschiebt.
|
||||
e.preventDefault()
|
||||
const now = distance(e.touches)
|
||||
const ratio = now / base
|
||||
const dir: 1 | -1 | 0 = ratio > 1 ? 1 : ratio < 1 ? -1 : 0
|
||||
if (!dir) return
|
||||
const from = scaleRef.current
|
||||
const next = nextFine(from, dir)
|
||||
if (!next) return
|
||||
|
||||
/**
|
||||
* **Die Finger geben den Maßstab vor, nicht eine feste Schwelle.**
|
||||
*
|
||||
* Fünf Prozentpunkte sind bei 25 % ein Fünftel und bei 400 % ein
|
||||
* Hundertstel. Eine feste Schwelle wäre deshalb an einem Ende zu träge
|
||||
* und am anderen zu nervös: Von 100 auf 400 zu ziehen dauerte ewig,
|
||||
* unten spränge es bei der kleinsten Bewegung.
|
||||
*
|
||||
* Verlangt wird stattdessen genau die Bewegung, die dem nächsten
|
||||
* Schritt entspricht – zwei Prozent Fingerweg als Untergrenze, damit
|
||||
* ein Zittern in der Hand nicht schon zoomt.
|
||||
*/
|
||||
const needed = next / from
|
||||
if (dir > 0 && ratio < Math.max(1.02, needed)) return
|
||||
if (dir < 0 && ratio > Math.min(0.98, needed)) return
|
||||
|
||||
pinchBase.current = now
|
||||
const root2 = scrollRef.current
|
||||
if (!root2) return
|
||||
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
|
||||
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
|
||||
const r = root2.getBoundingClientRect()
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
|
||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
|
||||
keepAnchor.current = {
|
||||
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
|
||||
cx, cy, from,
|
||||
}
|
||||
setScale(next)
|
||||
}
|
||||
|
||||
function onEnd(e: TouchEvent) {
|
||||
if (e.touches.length < 2) pinchBase.current = 0
|
||||
}
|
||||
|
||||
// Nicht passiv: Ein passiver Zuhörer dürfte `preventDefault` nicht rufen.
|
||||
root.addEventListener('touchstart', onStart, { passive: true })
|
||||
root.addEventListener('touchmove', onMove, { passive: false })
|
||||
root.addEventListener('touchend', onEnd, { passive: true })
|
||||
root.addEventListener('touchcancel', onEnd, { passive: true })
|
||||
return () => {
|
||||
root.removeEventListener('touchstart', onStart)
|
||||
root.removeEventListener('touchmove', onMove)
|
||||
root.removeEventListener('touchend', onEnd)
|
||||
root.removeEventListener('touchcancel', onEnd)
|
||||
}
|
||||
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
|
||||
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
|
||||
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
|
||||
// dann über genau eine Stufe nicht hinaus.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
||||
|
||||
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
|
||||
@@ -880,6 +1033,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// Auf dem Handy zieht man das Dokument mit dem Finger; die
|
||||
// Vorschau darunter darf dabei nicht mitwandern.
|
||||
overscrollBehavior: 'contain',
|
||||
// Ziehen ja, Zoomen nein: Die Zusammenziehgeste gehört uns (siehe
|
||||
// den Zuhörer oben), sonst vergrößerte der Browser die ganze Seite
|
||||
// samt Leiste und Knöpfen.
|
||||
touchAction: 'pan-x pan-y',
|
||||
}}
|
||||
>
|
||||
{!doc && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { filesApi } from '../api/client'
|
||||
import type { FolderItem, User } from '../api/client'
|
||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import Sidebar from './Sidebar'
|
||||
import FileBrowser from '../pages/FileBrowser'
|
||||
import TrashPage from '../pages/TrashPage'
|
||||
@@ -34,6 +35,14 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
onOpen: useCallback(() => setSidebarOpen(true), []),
|
||||
onClose: useCallback(() => setSidebarOpen(false), []),
|
||||
})
|
||||
// Ist die Leiste am Telefon aufgeschoben, holt die Zurück-Geste sie
|
||||
// zurück – sie ist dann das Oberste auf dem Schirm, und genau darauf zielt
|
||||
// die Bewegung.
|
||||
useBackClose({
|
||||
active: sidebarOpen,
|
||||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
||||
})
|
||||
|
||||
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||||
// hängt daran und holt sich daraufhin neu.
|
||||
const [trashBump, setTrashBump] = useState(0)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface UploadItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -13,12 +15,68 @@ interface Props {
|
||||
|
||||
export type { UploadItem }
|
||||
|
||||
/**
|
||||
* Wie lange eine fertige Meldung stehen bleibt.
|
||||
*
|
||||
* Lang genug, um sie im Vorbeigehen zu lesen, kurz genug, dass beim Hochladen
|
||||
* vieler Dateien nicht der halbe Bildschirm zuwächst.
|
||||
*/
|
||||
const dismissAfter = 4000
|
||||
|
||||
/**
|
||||
* So viele Meldungen zeigen wir gleichzeitig, der Rest wird gezählt.
|
||||
*
|
||||
* Bei fünfzig Dateien auf einmal wäre die Liste sonst länger als das Fenster –
|
||||
* und das gilt schon während des Hochladens, nicht erst danach.
|
||||
*/
|
||||
const maxVisible = 4
|
||||
|
||||
export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
|
||||
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
|
||||
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
|
||||
const dismiss = useRef(onDismiss)
|
||||
dismiss.current = onDismiss
|
||||
|
||||
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
|
||||
useEffect(() => {
|
||||
for (const item of items) {
|
||||
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
|
||||
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
|
||||
// von selbst.
|
||||
if (item.status !== 'done') continue
|
||||
if (timers.current.has(item.id)) continue
|
||||
timers.current.set(item.id, setTimeout(() => {
|
||||
timers.current.delete(item.id)
|
||||
dismiss.current(item.id)
|
||||
}, dismissAfter))
|
||||
}
|
||||
|
||||
// Aufräumen, wenn eine Meldung von Hand weggeklickt wurde: Ihre Frist läuft
|
||||
// sonst weiter und rührte später an eine Kennung, die es nicht mehr gibt.
|
||||
const live = new Set(items.map(i => i.id))
|
||||
for (const [id, timer] of timers.current) {
|
||||
if (!live.has(id)) { clearTimeout(timer); timers.current.delete(id) }
|
||||
}
|
||||
}, [items])
|
||||
|
||||
// Beim Verlassen der Seite alle Fristen abräumen.
|
||||
const all = timers.current
|
||||
useEffect(() => () => { for (const t of all.values()) clearTimeout(t) }, [all])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
// Fehler zuerst: Wenn mehr Meldungen anstehen als Platz ist, darf gerade die
|
||||
// eine gescheiterte Datei nicht diejenige sein, die unter den Tisch fällt.
|
||||
const failed = items.filter(i => i.status === 'error')
|
||||
const rest = items.filter(i => i.status !== 'error')
|
||||
const visible = [...failed, ...rest].slice(0, maxVisible)
|
||||
const hidden = items.length - visible.length
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 w-72 space-y-2 z-40">
|
||||
{items.map(item => (
|
||||
{visible.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl p-3 text-sm shadow-lg border"
|
||||
@@ -52,6 +110,17 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hidden > 0 && (
|
||||
<div
|
||||
className="rounded-xl px-3 py-2 text-xs shadow-lg border text-center"
|
||||
style={{
|
||||
background: 'var(--color-surface)', borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
+{hidden} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,14 @@ interface AppSettingsValue {
|
||||
globalColors: { primary?: string; accent?: string }
|
||||
/** Gibt es einen Office-Server? Steuert nur, ob der Menuepunkt erscheint. */
|
||||
officeEnabled: boolean
|
||||
/**
|
||||
* Welcher Anbieter – 'collabora', 'onlyoffice' oder leer.
|
||||
*
|
||||
* Die Oberfläche braucht das, weil sich die beiden am Telefon verschieden
|
||||
* verhalten: Collabora hat eine eigene Handy-Ansicht, die taugt; OnlyOffice
|
||||
* baut dort dieselbe Oberfläche auf wie am PC.
|
||||
*/
|
||||
officeProvider: string
|
||||
/** Wie viele frühere Fassungen der Server aufhebt. Kommt vom Server, damit
|
||||
* der Dialog nicht eine andere Zahl nennt als die, die angewendet wird. */
|
||||
maxVersionsKept: number
|
||||
@@ -34,6 +42,7 @@ interface AppSettingsValue {
|
||||
const Ctx = createContext<AppSettingsValue>({
|
||||
cloudName: 'Aether',
|
||||
officeEnabled: false,
|
||||
officeProvider: '',
|
||||
maxVersionsKept: 5,
|
||||
logoUrl: '',
|
||||
faviconUrl: '',
|
||||
@@ -89,6 +98,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [appIconUrl, setAppIconUrl] = useState('')
|
||||
const [globalColors, setGlobalColors] = useState<{ primary?: string; accent?: string }>({})
|
||||
const [officeEnabled, setOfficeEnabled] = useState(false)
|
||||
const [officeProvider, setOfficeProvider] = useState('')
|
||||
const [maxVersionsKept, setMaxVersionsKept] = useState(5)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -101,6 +111,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
applyFavicon(cfg.favicon_url ?? '', cfg.apple_touch_icon_url ?? '')
|
||||
setGlobalColors({ primary: cfg.primary_color, accent: cfg.accent_color })
|
||||
setOfficeEnabled(!!cfg.office_enabled)
|
||||
setOfficeProvider(cfg.office_provider ?? '')
|
||||
if (typeof cfg.max_versions_kept === 'number' && cfg.max_versions_kept > 0) {
|
||||
setMaxVersionsKept(cfg.max_versions_kept)
|
||||
}
|
||||
@@ -116,7 +127,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ cloudName, logoUrl, faviconUrl, appIconUrl, globalColors, officeEnabled, maxVersionsKept, refresh }}>{children}</Ctx.Provider>
|
||||
<Ctx.Provider value={{ cloudName, logoUrl, faviconUrl, appIconUrl, globalColors, officeEnabled, officeProvider, maxVersionsKept, refresh }}>{children}</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Die Zurück-Taste schließt die oberste bildschirmfüllende Ansicht, statt die
|
||||
* Anwendung zu verlassen.
|
||||
*
|
||||
* Auf einem Telefon ist „zurück" die Geste vom Rand, und in der installierten
|
||||
* App ist sie der einzige Weg nach draußen – ohne Adresszeile, ohne Reiter.
|
||||
* Wer ein Bild ansieht und zurückwischt, meint das Bild. Bisher fiel er aus
|
||||
* der Anwendung, weil Vorschau und Editor im Verlauf des Browsers gar nicht
|
||||
* vorkamen: Der einzige Eintrag war die Dateiliste, und der davor war der
|
||||
* Startbildschirm.
|
||||
*
|
||||
* Der Kniff ist ein **leerer Eintrag** im Verlauf, der beim Öffnen entsteht.
|
||||
* Die Adresse bleibt dabei gleich – die Vorschau ist kein Ort, auf den ein
|
||||
* Lesezeichen zeigen soll. Zurück verbraucht diesen Eintrag, wir schließen,
|
||||
* und der Ort darunter ist unverändert derselbe.
|
||||
*
|
||||
* **Der Stapel ist nötig, nicht schön:** Liegt der Office-Editor über der
|
||||
* Vorschau, hören beide auf dieselbe Zurück-Taste. Ohne Rangfolge schlössen
|
||||
* sich beide auf einmal. Deshalb weiß jeder Eintrag, der wievielte er ist,
|
||||
* und nur der oberste reagiert.
|
||||
*/
|
||||
|
||||
/** Kennungen der offenen Ansichten, unterste zuerst. */
|
||||
let stack: number[] = []
|
||||
let seq = 0
|
||||
|
||||
/** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim
|
||||
* Zurückkommen wiederzuerkennen. */
|
||||
const stateKey = 'aether:overlay'
|
||||
|
||||
interface Options {
|
||||
/** Ist die Ansicht offen? Der Eintrag entsteht beim Wechsel auf `true`. */
|
||||
active: boolean
|
||||
/**
|
||||
* Schließen. Gibt die Funktion `false` zurück, bleibt die Ansicht offen –
|
||||
* dann legen wir den Eintrag wieder an, sonst führte die nächste
|
||||
* Zurück-Geste aus der Anwendung heraus. Das ist der Fall „ungespeicherte
|
||||
* Änderungen verwerfen?" mit „Abbrechen".
|
||||
*/
|
||||
onClose: () => boolean | void
|
||||
}
|
||||
|
||||
export function useBackClose({ active, onClose }: Options) {
|
||||
// Über eine Referenz gelesen: Der Aufrufer gibt bei jedem Rendern eine neue
|
||||
// Funktion herein, und der Eintrag darf davon nicht neu entstehen.
|
||||
const close = useRef(onClose)
|
||||
close.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
|
||||
const id = ++seq
|
||||
stack.push(id)
|
||||
// Den bisherigen State mitnehmen: Darin steckt die Ansicht der Anwendung
|
||||
// (siehe useNavHistory). Ginge er verloren, stünde man nach dem
|
||||
// Schließen in der Wurzel statt im Ordner.
|
||||
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
|
||||
|
||||
function push() {
|
||||
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
|
||||
}
|
||||
|
||||
function onPop() {
|
||||
// Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen
|
||||
// eigenen Zurück-Schritt noch vor sich.
|
||||
if (stack.at(-1) !== id) return
|
||||
stack = stack.filter(x => x !== id)
|
||||
if (close.current() === false) {
|
||||
stack.push(id)
|
||||
push()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', onPop)
|
||||
// Geschlossen wurde über einen Knopf oder die Escape-Taste: Unser
|
||||
// Eintrag steht noch im Verlauf und muss weg, sonst bräuchte die
|
||||
// Zurück-Geste danach einen Schritt ins Leere.
|
||||
if (stack.at(-1) === id) {
|
||||
stack = stack.filter(x => x !== id)
|
||||
window.history.back()
|
||||
} else {
|
||||
stack = stack.filter(x => x !== id)
|
||||
}
|
||||
}
|
||||
}, [active])
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import Breadcrumb from '../components/Breadcrumb'
|
||||
import SearchBox from '../components/SearchBox'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import GridView, { ListView } from '../components/FileViews'
|
||||
import GridView, { ListView, useCoarsePointer } from '../components/FileViews'
|
||||
import FilePreview, { canPreview } from '../components/FilePreview'
|
||||
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
|
||||
import UploadQueue, { type UploadItem } from '../components/UploadQueue'
|
||||
@@ -149,9 +149,14 @@ export default function FileBrowser({
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
|
||||
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
|
||||
const [preview, setPreview] = useState<FileItem | null>(null)
|
||||
/** Office-Dokument als PDF – am Telefon der übliche Weg hinein. */
|
||||
const [officePreview, setOfficePreview] = useState<FileItem | null>(null)
|
||||
/** Datei, die gerade im Office-Server bearbeitet wird. */
|
||||
const [editing, setEditing] = useState<FileItem | null>(null)
|
||||
const { officeEnabled } = useAppSettings()
|
||||
const { officeEnabled, officeProvider } = useAppSettings()
|
||||
/** Finger oder Maus? Entscheidet, ob ein Office-Dokument als PDF oder im
|
||||
* Editor aufgeht. */
|
||||
const touchDevice = useCoarsePointer()
|
||||
|
||||
/** Datei, zu der gerade aus der Suche gesprungen wurde – siehe openHitFile.
|
||||
* Die Markierung verschwindet von selbst; ein Rahmen, der bleibt, sähe
|
||||
@@ -310,8 +315,24 @@ export default function FileBrowser({
|
||||
* ansieht; heruntergeladen wird, was der Browser nicht zeigen kann.
|
||||
*/
|
||||
function openFile(file: FileItem) {
|
||||
if (officeEnabled && isOfficeFile(file.name)) setEditing(file)
|
||||
else if (canPreview(file)) setPreview(file)
|
||||
if (officeEnabled && isOfficeFile(file.name)) {
|
||||
// **Nur bei OnlyOffice** öffnet ein Dokument am Telefon als PDF.
|
||||
//
|
||||
// Der Unterschied ist gemessen, nicht vermutet: Collabora bringt eine
|
||||
// eigene Handy-Ansicht mit, die taugt – dort ist der Editor der
|
||||
// richtige Weg. OnlyOffice baut auf dem Telefon dieselbe Oberfläche
|
||||
// auf wie am PC; zwischen Menüband, Formatleisten und
|
||||
// Eigenschaftenbereich bleibt vom Dokument ein Rechteck übrig, und
|
||||
// genau das war der Anlass für die PDF-Ansicht.
|
||||
//
|
||||
// Die Entscheidung fällt am **Zeigegerät**, nicht an der Fensterbreite:
|
||||
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus
|
||||
// und eine Tastatur, und dort will man bearbeiten.
|
||||
if (touchDevice && officeProvider === 'onlyoffice') setOfficePreview(file)
|
||||
else setEditing(file)
|
||||
return
|
||||
}
|
||||
if (canPreview(file)) setPreview(file)
|
||||
else downloadFile(file)
|
||||
}
|
||||
|
||||
@@ -984,6 +1005,15 @@ export default function FileBrowser({
|
||||
/>
|
||||
)}
|
||||
|
||||
{officePreview && (
|
||||
<FilePreview
|
||||
file={officePreview}
|
||||
asOfficePdf
|
||||
onEdit={() => { const f = officePreview; setOfficePreview(null); setEditing(f) }}
|
||||
onClose={() => setOfficePreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<FilePreview
|
||||
file={preview}
|
||||
|
||||
@@ -147,6 +147,14 @@ function IndexingStatus() {
|
||||
function StorageCleanup() {
|
||||
const [running, setRunning] = useState(false)
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
/** Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen.
|
||||
* Steht hier, weil es sonst nirgends steht: Diese Dateien zählen gegen kein
|
||||
* Kontingent, ihr Ordner könnte also unbemerkt wachsen. */
|
||||
const [derived, setDerived] = useState<{ prefix: string; files: number; bytes: number }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.derivedStorage().then(setDerived).catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function run() {
|
||||
setRunning(true)
|
||||
@@ -166,6 +174,8 @@ function StorageCleanup() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DerivedStorage items={derived} />
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
|
||||
padding: '12px 14px', borderRadius: 10,
|
||||
@@ -192,6 +202,44 @@ function StorageCleanup() {
|
||||
{running ? 'Running…' : 'Run now'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Namen, die etwas sagen – „thumbs" steht nirgends auf der Oberfläche. */
|
||||
const derivedLabels: Record<string, string> = {
|
||||
thumbs: 'Thumbnails',
|
||||
previews: 'Office documents as PDF',
|
||||
avatars: 'Profile pictures',
|
||||
}
|
||||
|
||||
function DerivedStorage({ items }: { items: { prefix: string; files: number; bytes: number }[] }) {
|
||||
const total = items.reduce((n, i) => n + i.bytes, 0)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '12px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Generated files — {formatBytes(total)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '2px 0 8px' }}>
|
||||
Created by the server, not uploaded by anyone. They count against no quota
|
||||
and are rebuilt when needed — deleting them is safe.
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 20px' }}>
|
||||
{items.map(i => (
|
||||
<span key={i.prefix} style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{derivedLabels[i.prefix] ?? i.prefix}:{' '}
|
||||
<span style={{ color: 'var(--color-text)' }}>
|
||||
{formatBytes(i.bytes)}
|
||||
</span>{' '}
|
||||
({i.files})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user