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
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxSaveBytes begrenzt, was der Office-Server zurückschreiben darf.
|
||||
//
|
||||
// Ein Textdokument, das größer ist, hat kein Mensch geschrieben. Ohne Grenze
|
||||
// wäre der Endpunkt eine Möglichkeit, die Platte zu füllen – er ist der
|
||||
// einzige, der ohne Sitzung schreibt.
|
||||
const maxSaveBytes = 512 << 20
|
||||
|
||||
type Handler struct {
|
||||
client *Client
|
||||
signer *Signer
|
||||
files *files.Store
|
||||
blobs storage.Storage
|
||||
acl *acl.Store
|
||||
// baseURL ist die Adresse dieser Anwendung im **Browser**. Sie steht als
|
||||
// PostMessageOrigin in der Auskunft – der Office-Server prüft damit, an
|
||||
// wen er Nachrichten aus dem Rahmen schicken darf.
|
||||
baseURL string
|
||||
// wopiURL ist die Adresse, unter der der **Office-Server** hierher findet.
|
||||
// Aus seinem Container heraus ist „localhost" er selbst.
|
||||
wopiURL string
|
||||
}
|
||||
|
||||
func NewHandler(c *Client, s *Signer, fileStore *files.Store, blobs storage.Storage, aclStore *acl.Store, publicURL, wopiURL string) *Handler {
|
||||
if wopiURL == "" {
|
||||
wopiURL = publicURL
|
||||
}
|
||||
return &Handler{
|
||||
client: c, signer: s, files: fileStore, blobs: blobs, acl: aclStore,
|
||||
baseURL: strings.TrimRight(publicURL, "/"),
|
||||
wopiURL: strings.TrimRight(wopiURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled sagt der Oberfläche, ob es die Bearbeitung überhaupt gibt. Ein
|
||||
// nil-Handler heißt „abgeschaltet" – dann ist die Antwort eine Auskunft, kein
|
||||
// Fehler.
|
||||
func (h *Handler) Enabled() bool { return h != nil && h.client != nil }
|
||||
|
||||
// ── Vom Browser aufgerufen ───────────────────────────────────────────────────
|
||||
|
||||
// Session beantwortet GET /api/v1/office/session/{id}.
|
||||
//
|
||||
// Gibt zurück, wohin der Browser den Rahmen zeigen soll und mit welchem Token.
|
||||
// Das Token entsteht **hier**, im angemeldeten Zusammenhang – der WOPI-Weg
|
||||
// selbst kennt die Sitzung nicht.
|
||||
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.Enabled() {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
|
||||
// Zugriff auflösen: eigene Datei, geliehene oder Gruppenordner. Wer nur
|
||||
// lesen darf, bekommt ein Token ohne Schreibrecht – nicht etwa gar keins.
|
||||
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if !Editable(f.Name) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "this file type cannot be opened in Office")
|
||||
return
|
||||
}
|
||||
|
||||
write := access.Permission == acl.PermWrite
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||
return
|
||||
}
|
||||
|
||||
token, exp := h.signer.Issue(f.ID, user.ID, write)
|
||||
wopiSrc := fmt.Sprintf("%s/wopi/files/%s", h.wopiURL, f.ID)
|
||||
|
||||
// Die Adresse aus der Ermittlungsliste trägt Platzhalter in <>-Klammern
|
||||
// („<ui=UI_LLCC&>"). Alles, was wir nicht setzen, muss weg – sonst
|
||||
// schickt der Browser die Klammern mit.
|
||||
src = stripPlaceholders(src)
|
||||
sep := "?"
|
||||
if strings.Contains(src, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
// **Vollständig kodiert**, nicht nur die auffälligen Zeichen: Collabora
|
||||
// weist eine unkodierte WOPISrc zurück („WOPISrc must be URI-encoded.
|
||||
// This is highly problematic with proxies, load balancers, and when
|
||||
// tunneling") – Doppelpunkt und Schrägstrich eingeschlossen.
|
||||
src = fmt.Sprintf("%s%sWOPISrc=%s", src, sep, url.QueryEscape(wopiSrc))
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"provider": h.client.Provider(),
|
||||
"url": src,
|
||||
"token": token,
|
||||
"token_ttl": exp.UnixMilli(),
|
||||
"can_write": write,
|
||||
"file_name": f.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Vom Office-Server aufgerufen (WOPI) ──────────────────────────────────────
|
||||
|
||||
// authorize prüft das Token und holt Datei und Zugriffskontext.
|
||||
//
|
||||
// Absichtlich ohne Sitzung und ohne CSRF: Diese Aufrufe kommen vom
|
||||
// Office-Server, nicht aus einem Browser. Das Token ist die einzige Berechtigung
|
||||
// – und es gilt für **genau eine Datei**, damit ein entwendetes Token nicht
|
||||
// zum Generalschlüssel wird.
|
||||
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request, needWrite bool) (*files.File, acl.Access, bool) {
|
||||
raw := r.URL.Query().Get("access_token")
|
||||
if raw == "" {
|
||||
raw = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
access, err := h.signer.Verify(raw)
|
||||
if err != nil {
|
||||
// 401, nicht 403: Der Office-Server versucht es damit gar nicht erst
|
||||
// erneut, statt in eine Schleife zu laufen.
|
||||
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil || id != access.FileID {
|
||||
// Das Token gehört zu einer anderen Datei. 404 statt 403 – sonst
|
||||
// verriete die Antwort, dass es die Datei gibt.
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
if needWrite && !access.Write {
|
||||
http.Error(w, "read only", http.StatusForbidden)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
|
||||
// Die Rechte werden **bei jedem Aufruf neu** aufgelöst, nicht aus dem
|
||||
// Token übernommen: Eine zurückgezogene Freigabe soll sofort wirken und
|
||||
// nicht erst, wenn das Token abläuft.
|
||||
ctxAccess, err := h.acl.ResolveFile(r.Context(), access.UserID, id)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
if needWrite && ctxAccess.Permission != acl.PermWrite {
|
||||
http.Error(w, "read only", http.StatusForbidden)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, ctxAccess.OwnerID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
return f, ctxAccess, true
|
||||
}
|
||||
|
||||
// CheckFileInfo beantwortet GET /wopi/files/{id} – die Auskunft über die Datei.
|
||||
func (h *Handler) CheckFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
f, access, ok := h.authorize(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
canWrite := access.Permission == acl.PermWrite
|
||||
|
||||
// **Ohne die Hülle `{"data": …}` dieser Anwendung.** WOPI ist ein fremdes
|
||||
// Protokoll mit festem Antwortformat; ein Feld drumherum verstünde der
|
||||
// Office-Server nicht.
|
||||
writeRawJSON(w, map[string]any{
|
||||
"BaseFileName": f.Name,
|
||||
"Size": f.Size,
|
||||
"Version": fmt.Sprintf("%d", f.Version),
|
||||
// Wer speichert, steht später in der Versionshistorie – dort soll ein
|
||||
// Name stehen und nicht eine Kennung.
|
||||
"OwnerId": f.OwnerID.String(),
|
||||
"UserId": access.OwnerID.String(),
|
||||
"UserFriendlyName": access.OwnerUsername,
|
||||
"UserCanWrite": canWrite,
|
||||
"UserCanNotWriteRelative": true,
|
||||
// Kein „Speichern unter", kein Drucken-nach-Hause, keine Verweise auf
|
||||
// fremde Dienste: Was die Datei verlassen könnte, bleibt aus.
|
||||
"DisablePrint": false,
|
||||
"DisableExport": false,
|
||||
"DisableCopy": false,
|
||||
"HideSaveOption": !canWrite,
|
||||
"HideExportOption": false,
|
||||
"PostMessageOrigin": h.baseURL,
|
||||
"LastModifiedTime": f.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetFile beantwortet GET /wopi/files/{id}/contents.
|
||||
func (h *Handler) GetFile(w http.ResponseWriter, r *http.Request) {
|
||||
f, _, ok := h.authorize(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rc, err := h.blobs.Get(r.Context(), f.StorageKey)
|
||||
if err != nil {
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", f.Version))
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: sending file failed")
|
||||
}
|
||||
}
|
||||
|
||||
// PutFile beantwortet POST /wopi/files/{id}/contents – das Speichern.
|
||||
func (h *Handler) PutFile(w http.ResponseWriter, r *http.Request) {
|
||||
f, access, ok := h.authorize(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, maxSaveBytes)
|
||||
defer body.Close()
|
||||
|
||||
updated, err := files.ReplaceContent(r.Context(), h.files, h.blobs, f, access, body)
|
||||
if err != nil {
|
||||
if errors.Is(err, files.ErrQuotaExceeded) {
|
||||
// 413 sagt dem Office-Server, dass es an der Größe lag – er zeigt
|
||||
// das dem Menschen an, statt still nicht zu speichern.
|
||||
http.Error(w, "storage quota exceeded", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
log.Ctx(r.Context()).Error().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("office: saving failed")
|
||||
http.Error(w, "could not save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", updated.Version))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// stripPlaceholders entfernt die <…>-Platzhalter aus einer urlsrc.
|
||||
//
|
||||
// Die Ermittlungsliste liefert z. B.
|
||||
//
|
||||
// https://office/browser/dist/cool.html?<ui=UI_LLCC&><rs=DC_LLCC&>
|
||||
//
|
||||
// Jeder Platzhalter, den der Host nicht ersetzt, muss weg – sonst landen die
|
||||
// spitzen Klammern in der Adresse und der Server lehnt sie ab.
|
||||
func stripPlaceholders(src string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range src {
|
||||
switch r {
|
||||
case '<':
|
||||
depth++
|
||||
case '>':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
default:
|
||||
if depth == 0 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
// Ein zurückbleibendes „?" oder „&" am Ende stört keinen Server, ist aber
|
||||
// unsauber in der Adresszeile.
|
||||
return strings.TrimRight(out, "?&")
|
||||
}
|
||||
|
||||
// writeRawJSON schreibt ein Objekt ohne die Hülle dieser Anwendung.
|
||||
func writeRawJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package office bindet einen Office-Server an – Collabora oder OnlyOffice.
|
||||
//
|
||||
// **Ein Adapter für beide.** Beide sprechen WOPI („Web Application Open
|
||||
// Platform Interface"), die Schnittstelle, über die ein Office-Server sich
|
||||
// eine Datei holt und wieder zurückschreibt. Zwei getrennte Integrationen zu
|
||||
// bauen hieße, dieselbe Sache zweimal zu pflegen; umgeschaltet wird über
|
||||
// OFFICE_PROVIDER in der .env.
|
||||
//
|
||||
// **Die Richtung der Aufrufe ist das Entscheidende.** Der Office-Server ist
|
||||
// kein Anhängsel des Browsers – er holt die Datei selbst hier ab:
|
||||
//
|
||||
// Browser ──► Office-Server (die Bedienoberfläche im <iframe>)
|
||||
// Office-Server ──► Aether /wopi/... (Datei holen, Datei zurückschreiben)
|
||||
//
|
||||
// Daraus folgt alles Weitere: Der Office-Server muss Aether unter seiner
|
||||
// **öffentlichen** Adresse erreichen (PUBLIC_URL), nicht unter localhost. Und
|
||||
// weil dieser Weg an der Sitzung vorbeiführt, trägt jeder Aufruf ein eigenes,
|
||||
// kurzlebiges Zugriffstoken.
|
||||
package office
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadToken = errors.New("invalid access token")
|
||||
ErrExpired = errors.New("access token expired")
|
||||
ErrNoSupport = errors.New("this file type cannot be opened in Office")
|
||||
)
|
||||
|
||||
// tokenTTL ist die Lebensdauer eines Zugriffstokens.
|
||||
//
|
||||
// Zwölf Stunden klingt lang für ein Token – es ist aber die Zeit, die ein
|
||||
// geöffnetes Dokument im Browser stehen darf, bevor das Speichern scheitert.
|
||||
// Kürzer hieße: Wer ein Dokument über die Mittagspause offen lässt, verliert
|
||||
// beim Zurückkommen seine Änderungen. Der Schaden bei einem entwendeten Token
|
||||
// ist begrenzt, weil es **an genau eine Datei** gebunden ist.
|
||||
const tokenTTL = 12 * time.Hour
|
||||
|
||||
// Access beschreibt, was ein Token erlaubt.
|
||||
type Access struct {
|
||||
FileID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Write bool
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
// Signer stellt Zugriffstoken aus und prüft sie.
|
||||
//
|
||||
// HMAC über den Inhalt, nichts in der Datenbank: Ein Token ist damit
|
||||
// überprüfbar, ohne bei jedem Zugriff nachzuschlagen – und es verfällt von
|
||||
// selbst, statt eine Aufräumaufgabe zu brauchen.
|
||||
type Signer struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewSigner(secret string) *Signer {
|
||||
// Abgeleitet statt direkt verwendet: Dasselbe Geheimnis signiert die
|
||||
// Sitzungscookies. Zwei Zwecke sollen nie denselben Schlüssel teilen –
|
||||
// sonst wäre ein Fehler im einen Verfahren einer im anderen.
|
||||
sum := sha256.Sum256([]byte("aether-wopi\x00" + secret))
|
||||
return &Signer{key: sum[:]}
|
||||
}
|
||||
|
||||
// Issue stellt ein Token für genau diese Datei und diesen Nutzer aus.
|
||||
func (s *Signer) Issue(fileID, userID uuid.UUID, write bool) (string, time.Time) {
|
||||
exp := time.Now().Add(tokenTTL)
|
||||
payload := make([]byte, 0, 41)
|
||||
payload = append(payload, fileID[:]...)
|
||||
payload = append(payload, userID[:]...)
|
||||
var w byte
|
||||
if write {
|
||||
w = 1
|
||||
}
|
||||
payload = append(payload, w)
|
||||
payload = binary.BigEndian.AppendUint64(payload, uint64(exp.Unix()))
|
||||
|
||||
mac := hmac.New(sha256.New, s.key)
|
||||
mac.Write(payload)
|
||||
full := append(payload, mac.Sum(nil)...)
|
||||
return base64.RawURLEncoding.EncodeToString(full), exp
|
||||
}
|
||||
|
||||
// Verify prüft ein Token und gibt zurück, was es erlaubt.
|
||||
func (s *Signer) Verify(token string) (Access, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != 41+sha256.Size {
|
||||
return Access{}, ErrBadToken
|
||||
}
|
||||
payload, sig := raw[:41], raw[41:]
|
||||
|
||||
mac := hmac.New(sha256.New, s.key)
|
||||
mac.Write(payload)
|
||||
// Zeitkonstanter Vergleich: Ein normaler Vergleich verrät über die
|
||||
// Laufzeit, wie viele Bytes stimmen – daraus lässt sich eine Signatur
|
||||
// Byte für Byte erraten.
|
||||
if !hmac.Equal(sig, mac.Sum(nil)) {
|
||||
return Access{}, ErrBadToken
|
||||
}
|
||||
|
||||
var a Access
|
||||
copy(a.FileID[:], payload[0:16])
|
||||
copy(a.UserID[:], payload[16:32])
|
||||
a.Write = payload[32] == 1
|
||||
a.Expiry = time.Unix(int64(binary.BigEndian.Uint64(payload[33:41])), 0)
|
||||
if time.Now().After(a.Expiry) {
|
||||
return Access{}, ErrExpired
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// editableExtensions sind die Endungen, die ein Office-Server bearbeiten kann.
|
||||
//
|
||||
// Eine **Positivliste**, keine Ausschlussliste: Was hier nicht steht, wird
|
||||
// nicht an den Office-Server gereicht. Ein PDF gehört bewusst nicht dazu – es
|
||||
// hat seinen eigenen Betrachter, und ein Office-Server würde daraus beim
|
||||
// Speichern ein anderes Dokument machen.
|
||||
var editableExtensions = map[string]bool{
|
||||
// Textdokumente
|
||||
"odt": true, "docx": true, "doc": true, "rtf": true, "txt": true,
|
||||
// Tabellen
|
||||
"ods": true, "xlsx": true, "xls": true, "csv": true,
|
||||
// Präsentationen
|
||||
"odp": true, "pptx": true, "ppt": true,
|
||||
// Zeichnungen
|
||||
"odg": true,
|
||||
}
|
||||
|
||||
// Editable sagt, ob eine Datei im Office-Server geöffnet werden kann.
|
||||
func Editable(name string) bool {
|
||||
i := strings.LastIndexByte(name, '.')
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
return editableExtensions[strings.ToLower(name[i+1:])]
|
||||
}
|
||||
|
||||
// Extension liefert die Endung in Kleinbuchstaben, mit Punkt – so, wie die
|
||||
// Ermittlungsliste des Office-Servers sie führt.
|
||||
func Extension(name string) string {
|
||||
i := strings.LastIndexByte(name, '.')
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(name[i:])
|
||||
}
|
||||
|
||||
// ProviderName ist der Anzeigename für die Oberfläche.
|
||||
func ProviderName(provider string) string {
|
||||
switch provider {
|
||||
case "collabora":
|
||||
return "Collabora Online"
|
||||
case "onlyoffice":
|
||||
return "OnlyOffice"
|
||||
default:
|
||||
return fmt.Sprintf("Office (%s)", provider)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user