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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user