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