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>
219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
package office
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Die Ermittlungsliste („discovery") sagt, welche Adresse ein Office-Server
|
||
// für welchen Dateityp anbietet.
|
||
//
|
||
// Fest verdrahten ließe sich das nicht: Collabora und OnlyOffice bauen ihre
|
||
// Adressen verschieden auf, und beide ändern sie zwischen Fassungen. Gefragt
|
||
// wird deshalb der Server selbst – einmal, dann für eine Weile gemerkt.
|
||
|
||
type discoveryXML struct {
|
||
NetZone struct {
|
||
Apps []struct {
|
||
Name string `xml:"name,attr"`
|
||
Actions []struct {
|
||
Name string `xml:"name,attr"`
|
||
Ext string `xml:"ext,attr"`
|
||
URLSrc string `xml:"urlsrc,attr"`
|
||
} `xml:"action"`
|
||
} `xml:"app"`
|
||
} `xml:"net-zone"`
|
||
}
|
||
|
||
// discoveryTTL: Wie lange die Liste gilt. Kurz genug, dass ein Neustart des
|
||
// Office-Servers nach einer Aktualisierung durchschlägt; lang genug, dass
|
||
// nicht jedes Öffnen eine zusätzliche Anfrage auslöst.
|
||
const discoveryTTL = 30 * time.Minute
|
||
|
||
// Client spricht mit dem Office-Server.
|
||
type Client struct {
|
||
provider string
|
||
baseURL string
|
||
// publicBase ist die Adresse, unter der der **Browser** den Office-Server
|
||
// erreicht. Die Ermittlungsliste nennt die, unter der der Server sich
|
||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||
// keinem Browser.
|
||
publicBase string
|
||
http *http.Client
|
||
|
||
mu sync.Mutex
|
||
actions map[string]string // ".docx|edit" -> urlsrc
|
||
fetched time.Time
|
||
}
|
||
|
||
// NewClient baut den Zugang zum Office-Server.
|
||
//
|
||
// allowedHosts begrenzt, wohin überhaupt verbunden werden darf. **Das ist
|
||
// keine Formalie:** Das Backend ruft hier eine Adresse aus der Konfiguration
|
||
// 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) {
|
||
u, err := url.Parse(baseURL)
|
||
if err != nil || u.Host == "" {
|
||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
||
}
|
||
|
||
allowed := map[string]bool{strings.ToLower(u.Hostname()): true}
|
||
for _, h := range allowedHosts {
|
||
allowed[strings.ToLower(strings.TrimSpace(h))] = true
|
||
}
|
||
|
||
transport := &http.Transport{
|
||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||
host, _, err := net.SplitHostPort(addr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !allowed[strings.ToLower(host)] {
|
||
return nil, fmt.Errorf("office: host %q is not in OFFICE_ALLOWED_HOSTS", host)
|
||
}
|
||
return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, addr)
|
||
},
|
||
// #nosec G402 – nur wenn ausdrücklich eingeschaltet, für den
|
||
// Testaufbau mit selbstsigniertem Zertifikat auf demselben Rechner.
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureTLS},
|
||
}
|
||
|
||
if publicURL == "" {
|
||
publicURL = baseURL
|
||
}
|
||
return &Client{
|
||
provider: provider,
|
||
baseURL: strings.TrimRight(baseURL, "/"),
|
||
publicBase: strings.TrimRight(publicURL, "/"),
|
||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||
}, nil
|
||
}
|
||
|
||
// toPublic schreibt Schema und Host einer Adresse aus der Ermittlungsliste auf
|
||
// die Adresse um, unter der der Browser den Server erreicht.
|
||
//
|
||
// Der Pfad bleibt unangetastet – er enthält die Fassungsnummer des Servers
|
||
// (`/browser/1ec9f2b9/cool.html`) und ändert sich mit jeder Aktualisierung.
|
||
func (c *Client) toPublic(raw string) string {
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return raw
|
||
}
|
||
pub, err := url.Parse(c.publicBase)
|
||
if err != nil || pub.Host == "" {
|
||
return raw
|
||
}
|
||
u.Scheme, u.Host = pub.Scheme, pub.Host
|
||
return u.String()
|
||
}
|
||
|
||
func (c *Client) Provider() string { return c.provider }
|
||
|
||
// EditURL liefert die Adresse, unter der ein Dateityp bearbeitet wird.
|
||
//
|
||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
|
||
if err := c.refresh(ctx); err != nil {
|
||
return "", err
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
// Am Finger zuerst die Handy-Fassung.
|
||
//
|
||
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
|
||
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
|
||
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
|
||
// Schirme gebaute Oberfläche. Aether hat bisher immer die
|
||
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
|
||
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
|
||
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
|
||
// wir selbst falsch bestellt hatten.
|
||
//
|
||
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
|
||
// Eintrag, und es bleibt beim bisherigen Verhalten.
|
||
order := []string{"edit", "view"}
|
||
switch {
|
||
case mobile && write:
|
||
order = []string{"mobileEdit", "edit", "mobileView", "view"}
|
||
case mobile:
|
||
order = []string{"mobileView", "view", "mobileEdit", "edit"}
|
||
case !write:
|
||
order = []string{"view", "edit"}
|
||
}
|
||
for _, action := range order {
|
||
if u, ok := c.actions[ext+"|"+action]; ok {
|
||
return c.toPublic(u), nil
|
||
}
|
||
}
|
||
return "", ErrNoSupport
|
||
}
|
||
|
||
// refresh holt die Liste, wenn sie fehlt oder zu alt ist.
|
||
func (c *Client) refresh(ctx context.Context) error {
|
||
c.mu.Lock()
|
||
fresh := c.actions != nil && time.Since(c.fetched) < discoveryTTL
|
||
c.mu.Unlock()
|
||
if fresh {
|
||
return nil
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/hosting/discovery", nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := c.http.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("office server unreachable: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("office discovery returned %d", resp.StatusCode)
|
||
}
|
||
// Grenze gegen eine Antwort, die kein Ende nimmt: Die Liste ist wenige
|
||
// hundert Kilobyte groß.
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var doc discoveryXML
|
||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||
return fmt.Errorf("office discovery is not valid XML: %w", err)
|
||
}
|
||
|
||
actions := map[string]string{}
|
||
for _, app := range doc.NetZone.Apps {
|
||
for _, a := range app.Actions {
|
||
if a.Ext == "" || a.URLSrc == "" {
|
||
continue
|
||
}
|
||
ext := a.Ext
|
||
if !strings.HasPrefix(ext, ".") {
|
||
ext = "." + ext
|
||
}
|
||
actions[strings.ToLower(ext)+"|"+a.Name] = a.URLSrc
|
||
}
|
||
}
|
||
if len(actions) == 0 {
|
||
return fmt.Errorf("office discovery listed no file types")
|
||
}
|
||
|
||
c.mu.Lock()
|
||
c.actions, c.fetched = actions, time.Now()
|
||
c.mu.Unlock()
|
||
return nil
|
||
}
|