Files
Aether/backend/internal/office/discovery.go
T
marcandClaude Opus 5 9c5cda75d7 feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten
Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen
Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und
OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus,
sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch
bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines
Dokuments, für das man OnlyOffice gewählt hat.

Je Dokument 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. files.preview_version
sagt, ob das Abgelegte noch passt.

Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns
abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu,
zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für
die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung
401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine
Fehlerseite im Zwischenspeicher.

Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz:
  erster Abruf 1,05 s -> zweiter 0,009 s
  erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei
  GC: herrenloses PDF von vorgestern weg, echtes bleibt
  endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt
  ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415

Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten
(Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent –
ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille
Überraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:30:49 +02:00

205 lines
6.0 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
// 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
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, 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)
}
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, "/"),
jwtSecret: jwtSecret,
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
}