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

+310
View File
@@ -0,0 +1,310 @@
package webdav
import (
"context"
"os"
"sort"
"strconv"
"github.com/google/uuid"
"aether/internal/acl"
"aether/internal/files"
)
// Geteilte Ordner und Gruppenordner über WebDAV.
//
// WebDAV kennt keine IDs, es navigiert über Pfade – und ein Pfad lief bisher
// immer durch den Baum genau eines Prinzipals: des angemeldeten Nutzers. Damit
// waren Gruppenordner und Freigaben dort unsichtbar; sie gehören einer Gruppe
// oder einem anderen Konto.
//
// Der Aufbau bildet die Weboberfläche nach, damit man nicht umdenken muss:
//
// /dav/<Gruppenordner>/… – wie in der Wurzel der Weboberfläche
// /dav/Shared/<Person>/<Freigabe>/… – wie die Ansicht „Shared with me"
//
// Beim Betreten wechselt der Prinzipal. Personen-Freigaben bleiben verschachtelt:
// Ein flacher Aufbau müsste bei gleichnamigen Freigaben Namen vergeben, die sich
// ändern, sobald eine weitere dazukommt – und ein Sync-Client, dem sich Pfade
// unter den Füßen verschieben, lädt alles neu hoch oder löscht es.
const sharedRoot = "Shared"
// davScope beschreibt, in wessen Namen ein Pfad aufgelöst wird.
type davScope struct {
principal uuid.UUID
storageRoot string
quota int64
writable bool
// base ist der Ordner, ab dem parts zählen. nil = Wurzel des Prinzipals.
// Bei einer Freigabe zeigt er auf den freigegebenen Ordner, damit alles
// darüber unerreichbar bleibt.
base *uuid.UUID
parts []string
// sharedFile ist gesetzt, wenn der Pfad genau auf eine einzeln freigegebene
// Datei zeigt. Ohne dieses Feld liefe die Auflösung in die Wurzel des
// Eigentümers – und die gilt als Ordner, wodurch die Datei als Verzeichnis
// gemeldet würde.
sharedFile *files.File
// virtual markiert die beiden Ebenen ohne echten Ordner dahinter:
// /Shared und /Shared/<Eigentümer>.
virtual bool
// virtualEntries sind die Einträge, die eine virtuelle Ebene auflistet.
virtualEntries []sharedEntry
}
// sharedEntry ist eine Freigabe oder ein Gruppenordner, wie er unter /Shared
// erscheint.
type sharedEntry struct {
owner string // Verzeichnisname der Ebene 2
name string // Name der Freigabe selbst
principal uuid.UUID
root string // Speicherwurzel des Prinzipals
quota int64
writable bool
folderID *uuid.UUID // gesetzt bei Ordnern
file *files.File
}
// forbid meldet dem Handler ein fehlendes Recht und liefert den passenden
// Fehler. Nur für Ablehnungen aus Rechtegründen – nicht für „gibt es nicht",
// sonst würde eine schlicht fehlende Datei als „verboten" gemeldet und
// verriete damit, dass an dem Pfad etwas ist.
func (fs *aetherFS) forbid() error {
if fs.denied != nil {
*fs.denied = true
}
return os.ErrPermission
}
func (fs *aetherFS) ownScope(parts []string) *davScope {
return &davScope{
principal: fs.user.ID,
storageRoot: "users/" + fs.user.Username,
quota: fs.user.QuotaBytes,
writable: true,
parts: parts,
}
}
// scopeFor klärt für einen WebDAV-Pfad, welcher Prinzipal gilt und welcher
// Rest des Pfades in dessen Baum aufzulösen ist.
func (fs *aetherFS) scopeFor(ctx context.Context, parts []string) (*davScope, error) {
if len(parts) == 0 {
return fs.ownScope(parts), nil
}
if parts[0] != sharedRoot {
// Gruppenordner liegen in der Wurzel, gleichrangig neben den eigenen –
// so wie in der Weboberfläche. Die Namen sind bereits eindeutig
// gemacht, ein eigener Ordner gewinnt also bei Gleichheit.
groups, err := fs.rootGroupFolders(ctx)
if err != nil {
return nil, err
}
for _, e := range groups {
if e.name == parts[0] {
return &davScope{
principal: e.principal, storageRoot: e.root, quota: e.quota,
writable: e.writable, base: e.folderID, parts: parts[1:],
}, nil
}
}
return fs.ownScope(parts), nil
}
if fs.acl == nil {
return nil, os.ErrNotExist
}
entries, err := fs.aclShares(ctx)
if err != nil {
return nil, err
}
// Gibt es nichts Geliehenes, gibt es auch kein /Shared. Sonst bliebe bei
// Clients ein leerer Ordner stehen, den niemand loswird.
if len(entries) == 0 {
return nil, os.ErrNotExist
}
// /Shared – Liste der Eigentümer.
if len(parts) == 1 {
return &davScope{virtual: true, virtualEntries: entries}, nil
}
// /Shared/<Eigentümer> – Liste von dessen Freigaben.
owned := []sharedEntry{}
for _, e := range entries {
if e.owner == parts[1] {
owned = append(owned, e)
}
}
if len(owned) == 0 {
return nil, os.ErrNotExist
}
if len(parts) == 2 {
return &davScope{virtual: true, virtualEntries: owned}, nil
}
// /Shared/<Eigentümer>/<Freigabe>/… – ab hier gilt ein echter Prinzipal.
for _, e := range owned {
if e.name != parts[2] {
continue
}
if e.file != nil {
// Eine einzeln freigegebene Datei hat nichts unter sich.
if len(parts) > 3 {
return nil, os.ErrNotExist
}
return &davScope{
principal: e.principal, storageRoot: e.root, quota: e.quota,
writable: e.writable, sharedFile: e.file,
}, nil
}
return &davScope{
principal: e.principal, storageRoot: e.root, quota: e.quota,
writable: e.writable, base: e.folderID, parts: parts[3:],
}, nil
}
return nil, os.ErrNotExist
}
// rootGroupFolders liefert die Gruppenordner des Nutzers mit dem Namen, unter
// dem sie in der Wurzel erscheinen.
//
// Bei Namensgleichheit gewinnt der eigene Ordner und der Gruppenordner bekommt
// den Gruppennamen angehängt. Grund: WebDAV adressiert über Pfade – zwei
// gleichnamige Einträge in einem Verzeichnis kann ein Client nicht auseinander-
// halten. In der Weboberfläche ist das harmlos, dort führen IDs zum Ziel.
func (fs *aetherFS) rootGroupFolders(ctx context.Context) ([]sharedEntry, error) {
groupFolders, err := fs.fileStore.ListGroupFoldersForUser(ctx, fs.user.ID)
if err != nil {
return nil, err
}
if len(groupFolders) == 0 {
return nil, nil
}
ownFolders, err := fs.fileStore.ListFolders(ctx, fs.user.ID, nil)
if err != nil {
return nil, err
}
taken := map[string]bool{}
for _, f := range ownFolders {
taken[f.Name] = true
}
out := make([]sharedEntry, 0, len(groupFolders))
for _, gf := range groupFolders {
if gf.GroupID == nil || fs.acl == nil {
continue
}
info, err := fs.acl.ResolveFolder(ctx, fs.user.ID, gf.ID)
if err != nil {
continue
}
group, _ := fs.fileStore.GroupName(ctx, *gf.GroupID)
name := gf.Name
if taken[name] && group != "" {
name = gf.Name + " (" + group + ")"
}
for n := 2; taken[name]; n++ {
name = gf.Name + " (" + strconv.Itoa(n) + ")"
}
taken[name] = true
id := gf.ID
out = append(out, sharedEntry{
owner: group,
name: name,
principal: info.OwnerID,
root: info.StorageRoot(),
quota: info.OwnerQuota,
writable: info.CanWrite(),
folderID: &id,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out, nil
}
// aclShares sammelt, was andere Personen freigegeben haben – der Inhalt von
// /Shared. Gruppenordner gehören nicht dazu, die liegen in der Wurzel.
func (fs *aetherFS) aclShares(ctx context.Context) ([]sharedEntry, error) {
var out []sharedEntry
shared, err := fs.acl.ListSharedWith(ctx, fs.user.ID)
if err != nil {
return nil, err
}
for _, r := range shared {
e := sharedEntry{
owner: r.OwnerName,
name: r.Name,
writable: r.Permission == acl.PermWrite,
}
if r.Type == "folder" {
info, err := fs.acl.ResolveFolder(ctx, fs.user.ID, r.ID)
if err != nil {
continue
}
id := r.ID
e.principal, e.root, e.quota = info.OwnerID, info.StorageRoot(), info.OwnerQuota
e.folderID = &id
} else {
info, err := fs.acl.ResolveFile(ctx, fs.user.ID, r.ID)
if err != nil {
continue
}
f, err := fs.fileStore.GetFile(ctx, r.ID, info.OwnerID)
if err != nil {
continue
}
e.principal, e.root, e.quota = info.OwnerID, info.StorageRoot(), info.OwnerQuota
e.file = f
}
out = append(out, e)
}
return dedupe(out), nil
}
// dedupe stellt sicher, dass in einem Verzeichnis kein Name zweimal vorkommt –
// zwei gleichnamige Einträge machen eine PROPFIND-Antwort für Clients
// unbrauchbar. Sortiert wird vorher, damit die Vergabe bei gleichem Bestand
// immer gleich ausfällt und Pfade sich nicht von Lauf zu Lauf ändern.
func dedupe(entries []sharedEntry) []sharedEntry {
sort.Slice(entries, func(i, j int) bool {
if entries[i].owner != entries[j].owner {
return entries[i].owner < entries[j].owner
}
return entries[i].name < entries[j].name
})
seen := map[string]int{}
for i := range entries {
key := entries[i].owner + "/" + entries[i].name
seen[key]++
if n := seen[key]; n > 1 {
entries[i].name += " (" + strconv.Itoa(n) + ")"
}
}
return entries
}
// owners fasst die Einträge zur zweiten Ebene zusammen.
func owners(entries []sharedEntry) []string {
seen := map[string]bool{}
var out []string
for _, e := range entries {
if !seen[e.owner] {
seen[e.owner] = true
out = append(out, e.owner)
}
}
return out
}