Files
Aether/backend/internal/office/discovery.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

201 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 bool) (string, error) {
if err := c.refresh(ctx); err != nil {
return "", err
}
c.mu.Lock()
defer c.mu.Unlock()
order := []string{"edit", "view"}
if !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
}