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,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