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 }