Files
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

311 lines
8.8 KiB
Go
Raw Permalink 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 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
}