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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+167
View File
@@ -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)
}
}