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