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>
201 lines
5.8 KiB
Go
201 lines
5.8 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 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
|
||
}
|