Files
Aether/backend/internal/office/office.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

168 lines
5.3 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 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)
}
}