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//… – wie in der Wurzel der Weboberfläche // /dav/Shared///… – 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/. 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/ – 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///… – 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 }