refactor(office): toten JWT-Schlüssel entfernen, Doku richtigstellen

Beim Löschen von convert.go ist Aethers einzige Verwendung des JWT-Schlüssels
mitgegangen: signJWT stand dort. Zurück blieb ein Feld auf dem Client, das
niemand liest, plus Config-Eintrag und Aufrufparameter – Go meldet ungenutzte
Strukturfelder nicht, deshalb lief der Build durch. Eine Einstellung, die
nichts mehr tut, ist schlimmer als keine: Wer sie setzt, glaubt, sie wirke.

OFFICE_JWT_SECRET bleibt in der .env, wird aber vom **Office-Container**
gelesen, nicht von Aether. Geprüft mit Euro-Office: JWT_ENABLED=true und
Aether ohne jedes Signieren – der WOPI-Weg läuft.

Doku richtiggestellt, nachdem Marc den Compose-Schnipsel angezweifelt hat:

- „Euro-Office ist hier nie gelaufen" stimmte nicht mehr. Jetzt steht da, was
  tatsächlich geprüft ist (Abbild, Start, Discovery, CheckFileInfo, Ausliefern,
  mobileEdit) und was nicht: PutFile, also das Zurückschreiben.
- Der Absatz zu /ConvertService.ashx beschrieb gelöschten Code.
- OFFICE_PUBLIC_URL: die Falle benannt, die einen Tag gekostet hat. Stand dort
  localhost, lief alles am Docker-Rechner und blieb am Telefon schwarz – dort
  ist localhost das Telefon. Ohne Fehlermeldung.
- Zur Portbindung: Die Office-Schnipsel binden auf 0.0.0.0, anders als Aether
  selbst. Das ist nötig, weil der Browser direkt hin muss – steht jetzt dabei,
  samt dem sauberen Weg über einen Reverse Proxy.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-16 23:10:17 +02:00
1 parent 944b2c63e2
commit 7d45941c03
5 files changed
+48 -64

No files matched your search

+9 -2
View File
@@ -117,14 +117,21 @@ OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
# sonst verweigert der Browser den Rahmen. # sonst verweigert der Browser den Rahmen.
OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office) OFFICE_PROVIDER=onlyoffice # collabora | onlyoffice (auch Euro-Office)
OFFICE_URL=http://office # Backend → Office (Collabora: :9980) OFFICE_URL=http://office # Backend → Office (Collabora: :9980)
# Browser → Office # Browser → Office. **Muss eine Adresse sein, die der Browser auflösen kann**
# – deine Domain oder die LAN-IP, niemals "localhost" und nie der interne
# Dienstname. Sonst lädt der Editor am Rechner mit Docker und bleibt auf jedem
# anderen Gerät schwarz, ohne Fehlermeldung. Leer = wie OFFICE_URL, und das
# ist fast immer falsch.
OFFICE_PUBLIC_URL= OFFICE_PUBLIC_URL=
OFFICE_WOPI_URL=http://app:8080 # Office → Aether OFFICE_WOPI_URL=http://app:8080 # Office → Aether
OFFICE_ALLOWED_HOSTS=office # gegen SSRF OFFICE_ALLOWED_HOSTS=office # gegen SSRF
# Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet # Nur für ein Office mit selbstsigniertem Zertifikat im eigenen Netz. Schaltet
# die Zertifikatsprüfung **ab** – im Internet nichts zu suchen. # die Zertifikatsprüfung **ab** – im Internet nichts zu suchen.
OFFICE_INSECURE_TLS=false OFFICE_INSECURE_TLS=false
# nur OnlyOffice, openssl rand -hex 32 # Liest **der Office-Container**, nicht Aether: Es schützt seine eigene API.
# Der WOPI-Weg braucht es nicht, der hat eigene Token. Bei OnlyOffice und
# Euro-Office mit JWT_ENABLED=true gehört es gesetzt, sonst steht deren API
# offen. Collabora kennt es nicht. openssl rand -hex 32
OFFICE_JWT_SECRET= OFFICE_JWT_SECRET=
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════ # ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
+36 -49
View File
@@ -665,6 +665,13 @@ Container heraus er selbst.
Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980, Der Port unterscheidet sich je nach Anbieter: **Collabora lauscht auf 9980,
OnlyOffice und Euro-Office auf 80.** OnlyOffice und Euro-Office auf 80.**
> **Zur Portbindung in den Schnipseln:** Dort steht `${APP_BIND:-0.0.0.0}` –
> also standardmäßig **offen im Netz**, anders als bei Aether selbst
> (`127.0.0.1`). Das ist Absicht: Der Browser muss den Office-Server direkt
> erreichen, und über Loopback kann das kein zweites Gerät. Wer einen Reverse
> Proxy davor setzt – der saubere Weg –, trägt hier `127.0.0.1` ein und lässt
> den Proxy die öffentliche Adresse bedienen.
Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten Die Schnipsel unten starten sofort mit `docker compose up -d`. Zum Abschalten
`docker compose stop office`. Wer den Container lieber ganz aus der normalen `docker compose stop office`. Wer den Container lieber ganz aus der normalen
Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es Startfolge nimmt, hängt `profiles: ["office"]` an den Dienst – dann braucht es
@@ -787,17 +794,28 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das
Abbild. Abbild.
> **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von > **Stand der Prüfung (16.09.2026).** Collabora ist hier gemessen worden, den
> Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser > Kreis von Tippen bis Speichern eingeschlossen. **Euro-Office lief inzwischen
> Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die > auf dieser Anlage** – geprüft sind: Abbild ziehen (6,99 GB), Start,
> JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf > `/hosting/discovery` (antwortet mit 196 KB und einer breiten Formatliste bis
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern, > zu iWork und EPUB), `CheckFileInfo`, das Ausliefern der Datei (der Inhalt
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der > stand im Editor) und die Handy-Fassung über `mobileEdit`. **Nicht** geprüft
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der > ist dort das Zurückschreiben, also `PutFile`. OnlyOffice selbst kam hier nie
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen. > über die Ermittlung hinaus.
> >
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es > Genau dieser Schnipsel lief – mit zwei Abweichungen, die nur diese Anlage
> nach Aktenlage hakt: > betreffen: Dienstname `euro` statt `office` und Port 9981 statt 9980, weil
> Collabora daneben weiterlief.
>
> **Die Falle, die uns einen Tag gekostet hat**, steht nicht in dieser
> Aufzählung, sondern eine Ebene höher: `OFFICE_PUBLIC_URL`. Stand dort
> `http://localhost:9980`, lief alles am Rechner mit Docker – und am Telefon
> blieb der Rahmen **schwarz**, denn dort ist „localhost" das Telefon. Diese
> Adresse muss der Browser auflösen können: deine Domain oder die LAN-IP. Der
> Fehler meldet sich nicht, er zeigt nur nichts.
>
> Bleibt der Editor trotz richtiger Adresse leer, sind das die zwei Stellen,
> an denen es nach Aktenlage hakt:
> >
> 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem > 1. **Der IP-Filter.** OnlyOffice nimmt WOPI-Anfragen nur von einem
> „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner > „vertrauenswürdigen Integrator" an; dessen Adresse muss in seiner
@@ -807,45 +825,14 @@ Abbild.
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre > Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
> das der Ort. > das der Ort.
> >
> `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den > **`JWT_SECRET` liest Aether nicht.** Es schützt die *eigene* API des
> WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST > Dokumentenservers, nicht den WOPI-Weg – der hat eigene Token. Bis zum
> /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur > 16.09.2026 signierte Aether damit seine Umwandlungsaufrufe; mit der
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer` > PDF-Ansicht ist auch das entfallen, und `OFFICE_JWT_SECRET` wird von der
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also > Anwendung nicht mehr gelesen. In der `.env` gehört es trotzdem gesetzt:
> gesetzt – sonst läuft die API offen. > **Der Office-Container liest es**, und ohne steht seine API offen, wenn
> `JWT_ENABLED` an ist. Geprüft mit Euro-Office: `JWT_ENABLED=true` und
### Am Telefon: die Handy-Fassung des Editors > Aether ohne jedes Signieren – der WOPI-Weg läuft.
Ein Office-Dokument öffnet **überall im Editor** – am Schreibtisch wie am
Telefon. Am Telefon fordert Aether dabei die Handy-Fassung an, wo der
Office-Server eine anbietet:
| Anbieter | am Telefon |
|---|---|
| **Collabora** | seine eigene Handy-Ansicht (kennt keine getrennte Aktion) |
| **OnlyOffice / Euro-Office** | die WOPI-Aktion `mobileEdit` |
Die Ermittlungsliste nennt zu jeder Endung mehrere Aktionen. OnlyOffice und
Euro-Office führen neben `edit`/`view` auch `mobileEdit`/`mobileView`, und
dahinter steckt eine eigene, für kleine Schirme gebaute Oberfläche – dieselbe
Adresse mit `?mobile=1`. Aether hat bis zum 16.09.2026 immer die
Schreibtisch-Fassung angefordert; am Telefon blieb zwischen Menüband,
Formatleisten und Eigenschaftenbereich vom Dokument ein Rechteck übrig.
Entschieden wird am **Zeigegerät**, nicht an der Fensterbreite: Ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus und eine Tastatur. Die
Oberfläche schickt dazu `?mobile=1` an `/api/v1/office/session/{id}`.
> **Entfallen am 16.09.2026: die PDF-Ansicht.** Ein Office-Dokument wurde am
> Telefon zuvor als gewandeltes PDF gezeigt, mit einem „Edit"-Knopf hinein.
> Das war eine Notlösung für eine Oberfläche, die Aether selbst falsch
> angefordert hatte – mit `mobileEdit` ist der Anlass weg. Mit ihr sind
> `internal/office/preview.go`, `convert.go` und die Spalte
> `files.preview_version` gefallen (Migration 000026); die Dateien unter
> `previews/` sammelt der GC ein. Wer ein PDF braucht, exportiert es im
> Editor. Die Umwandlung im Hintergrund (`/cool/convert-to/pdf` bzw.
> `/ConvertService.ashx`) hatte keinen anderen Aufrufer und ist damit
> ebenfalls entfallen.
### Welche Dateien, und wie gut ### Welche Dateien, und wie gut
+1 -1
View File
@@ -267,7 +267,7 @@ func main() {
var officeHandler *office.Handler var officeHandler *office.Handler
if cfg.OfficeEnabled() { if cfg.OfficeEnabled() {
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL, client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret) cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
if err != nil { if err != nil {
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am // Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten // Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
+2 -6
View File
@@ -48,10 +48,7 @@ type Client struct {
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man // selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
// keinem Browser. // keinem Browser.
publicBase string publicBase string
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die http *http.Client
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
jwtSecret string
http *http.Client
mu sync.Mutex mu sync.Mutex
actions map[string]string // ".docx|edit" -> urlsrc actions map[string]string // ".docx|edit" -> urlsrc
@@ -65,7 +62,7 @@ type Client struct {
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die // auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt // Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
// immer dazu, sonst wäre die Voreinstellung unbrauchbar. // immer dazu, sonst wäre die Voreinstellung unbrauchbar.
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) { func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
u, err := url.Parse(baseURL) u, err := url.Parse(baseURL)
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL) return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
@@ -99,7 +96,6 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
provider: provider, provider: provider,
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
publicBase: strings.TrimRight(publicURL, "/"), publicBase: strings.TrimRight(publicURL, "/"),
jwtSecret: jwtSecret,
http: &http.Client{Timeout: 20 * time.Second, Transport: transport}, http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
}, nil }, nil
} }
-6
View File
@@ -100,11 +100,6 @@ type Config struct {
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am // OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
// Office-Server. Nur für den Testaufbau auf demselben Rechner. // Office-Server. Nur für den Testaufbau auf demselben Rechner.
OfficeInsecureTLS bool 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. // splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
@@ -150,7 +145,6 @@ func Load() (*Config, error) {
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))), OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"), OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"), OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"), HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"), OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),