From 6a3689d17da26f420da633e0b6d7d6847f96e7cc Mon Sep 17 00:00:00 2001 From: Yunling Zhang Date: Mon, 28 Sep 2026 17:38:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(webdav):=20=E5=AE=9E=E7=8E=B0=E6=AD=BB?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=88PROPPATCH?= =?UTF-8?q?/PROPFIND/COPY/MOVE/DELETE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 deadPropFS,包装 webdav.Dir 并让 File 实现 DeadPropsHolder - sidecar 存于数据目录 davprops//(账号 root 之外), 从结构上保证不污染用户可见目录与用量统计 - COPY/MOVE/DELETE/remove 跟随并清理死属性 - 新增 deadprops_test.go 覆盖读写/复制/移动/删除/隐藏 --- internal/server/deadprops.go | 254 ++++++++++++++++++++++++++++++ internal/server/deadprops_test.go | 110 +++++++++++++ internal/server/webdav.go | 11 +- 3 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 internal/server/deadprops.go create mode 100644 internal/server/deadprops_test.go diff --git a/internal/server/deadprops.go b/internal/server/deadprops.go new file mode 100644 index 0000000..b6dd2bf --- /dev/null +++ b/internal/server/deadprops.go @@ -0,0 +1,254 @@ +package server + +import ( + "context" + "encoding/json" + "encoding/xml" + "errors" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "golang.org/x/net/webdav" +) + +// deadPropFS 在 webdav.Dir 之上实现死属性(dead properties)持久化。 +// +// 存储位置的选择:sidecar 放在账号 root **之外**的 +// `<数据目录>/davprops//`,而不是 root 内的 `.davprops/`。 +// 理由: +// 1. root 内任何隐藏目录都仍处在 WebDAV/PROPFIND 和 client 目录列表可见的 +// 命名空间里,必须额外过滤每一处遍历(Readdir、client list、用量统计), +// 漏一处就会污染用户目录;放到 root 外则从结构上杜绝这种污染。 +// 2. 用量统计(usage.Dir)与 client API 直接走 os 层,无需感知 sidecar。 +// +// 资源 key 用 webdav.Handler 剥离 Prefix 后的相对路径,统一 path.Clean; +// 每个资源对应 `//__props__.json`,root 对应 +// `/__root__.json`。这样同一资源的文件/目录共享一个 key, +// 且删除/重命名可以直接搬移子树。 +type deadPropFS struct { + root string + propDir string +} + +var _ webdav.FileSystem = (*deadPropFS)(nil) + +func newDeadPropFS(root, propDir string) *deadPropFS { + return &deadPropFS{root: root, propDir: propDir} +} + +// cleanKey 与 x/net/webdav 内部的 slashClean 语义一致: +// 保证以 "/" 开头且已 clean。 +func cleanKey(name string) string { + if name == "" { + return "/" + } + if name[0] != '/' { + name = "/" + name + } + return path.Clean(name) +} + +func (d *deadPropFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error { + return webdav.Dir(d.root).Mkdir(ctx, name, perm) +} + +func (d *deadPropFS) Stat(ctx context.Context, name string) (os.FileInfo, error) { + return webdav.Dir(d.root).Stat(ctx, name) +} + +func (d *deadPropFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { + f, err := webdav.Dir(d.root).OpenFile(ctx, name, flag, perm) + if err != nil { + return nil, err + } + return &deadPropFile{File: f, fs: d, key: cleanKey(name)}, nil +} + +func (d *deadPropFS) RemoveAll(ctx context.Context, name string) error { + if err := webdav.Dir(d.root).RemoveAll(ctx, name); err != nil { + return err + } + d.removeProps(cleanKey(name)) + return nil +} + +func (d *deadPropFS) Rename(ctx context.Context, oldName, newName string) error { + if err := webdav.Dir(d.root).Rename(ctx, oldName, newName); err != nil { + return err + } + d.renameProps(cleanKey(oldName), cleanKey(newName)) + return nil +} + +// sidecar 返回某资源死属性文件的绝对路径。 +func (d *deadPropFS) sidecar(key string) string { + rel := strings.TrimPrefix(key, "/") + if rel == "" { + return filepath.Join(d.propDir, "__root__.json") + } + return filepath.Join(d.propDir, filepath.FromSlash(rel), "__props__.json") +} + +// resourceDir 返回某资源 sidecar 所在的目录(用于整体删除/搬移)。 +func (d *deadPropFS) resourceDir(key string) string { + return filepath.Join(d.propDir, filepath.FromSlash(strings.TrimPrefix(key, "/"))) +} + +func (d *deadPropFS) loadProps(key string) (map[xml.Name]webdav.Property, error) { + b, err := os.ReadFile(d.sidecar(key)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var stored []storedProp + if err := json.Unmarshal(b, &stored); err != nil { + return nil, err + } + m := make(map[xml.Name]webdav.Property, len(stored)) + for _, s := range stored { + name := xml.Name{Space: s.Space, Local: s.Local} + m[name] = webdav.Property{XMLName: name, InnerXML: s.InnerXML} + } + return m, nil +} + +func (d *deadPropFS) saveProps(key string, m map[xml.Name]webdav.Property) error { + sc := d.sidecar(key) + if len(m) == 0 { + if err := os.Remove(sc); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + pruneEmptyDirs(filepath.Dir(sc), d.propDir) + return nil + } + list := make([]storedProp, 0, len(m)) + for _, p := range m { + list = append(list, storedProp{Space: p.XMLName.Space, Local: p.XMLName.Local, InnerXML: p.InnerXML}) + } + sort.Slice(list, func(i, j int) bool { + if list[i].Space != list[j].Space { + return list[i].Space < list[j].Space + } + return list[i].Local < list[j].Local + }) + buf, err := json.Marshal(list) + if err != nil { + return err + } + buf = append(buf, '\n') + dir := filepath.Dir(sc) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".props-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(buf); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, sc) +} + +func (d *deadPropFS) removeProps(key string) { + if key == "/" { + os.RemoveAll(d.propDir) + return + } + os.RemoveAll(d.resourceDir(key)) +} + +func (d *deadPropFS) renameProps(oldKey, newKey string) { + if oldKey == "/" || newKey == "/" || oldKey == newKey { + return + } + oldDir := d.resourceDir(oldKey) + if _, err := os.Stat(oldDir); err != nil { + return + } + newDir := d.resourceDir(newKey) + if err := os.MkdirAll(filepath.Dir(newDir), 0o700); err != nil { + return + } + os.RemoveAll(newDir) + _ = os.Rename(oldDir, newDir) +} + +// pruneEmptyDirs 自底向上删除空目录,止于(含)stop。 +func pruneEmptyDirs(dir, stop string) { + stop = filepath.Clean(stop) + for { + dir = filepath.Clean(dir) + if dir == stop || !strings.HasPrefix(dir, stop+string(os.PathSeparator)) { + return + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 0 { + return + } + if err := os.Remove(dir); err != nil { + return + } + dir = filepath.Dir(dir) + } +} + +type storedProp struct { + Space string `json:"space"` + Local string `json:"local"` + InnerXML []byte `json:"innerXml,omitempty"` +} + +// deadPropFile 让 webdav.File 同时满足 webdav.DeadPropsHolder。 +type deadPropFile struct { + webdav.File + fs *deadPropFS + key string +} + +var _ webdav.DeadPropsHolder = (*deadPropFile)(nil) + +func (f *deadPropFile) DeadProps() (map[xml.Name]webdav.Property, error) { + return f.fs.loadProps(f.key) +} + +func (f *deadPropFile) Patch(patches []webdav.Proppatch) ([]webdav.Propstat, error) { + m, err := f.fs.loadProps(f.key) + if err != nil { + return nil, err + } + if m == nil { + m = map[xml.Name]webdav.Property{} + } + pstat := webdav.Propstat{Status: http.StatusOK} + for _, patch := range patches { + for _, p := range patch.Props { + pstat.Props = append(pstat.Props, webdav.Property{XMLName: p.XMLName}) + if patch.Remove { + delete(m, p.XMLName) + continue + } + m[p.XMLName] = p + } + } + if err := f.fs.saveProps(f.key, m); err != nil { + return nil, err + } + return []webdav.Propstat{pstat}, nil +} diff --git a/internal/server/deadprops_test.go b/internal/server/deadprops_test.go new file mode 100644 index 0000000..23a7289 --- /dev/null +++ b/internal/server/deadprops_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDeadPropsPersistence(t *testing.T) { + s, store, dir := newTestServer(t) + _, pass, _ := store.Create("app1", false, "") + + propDir := filepath.Join(dir, "davprops", "app1") + + // 先放一个文件。 + rec := request(s, http.MethodPut, "/app1/a.txt", "app1", pass, strings.NewReader("x"), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("PUT 应为 201,实际 %d", rec.Code) + } + + setBody := `` + + `` + + `alice` + + `` + rec = request(s, "PROPPATCH", "/app1/a.txt", "app1", pass, strings.NewReader(setBody), map[string]string{"Content-Type": "application/xml"}) + if rec.Code != 207 { + t.Fatalf("PROPPATCH 应为 207,实际 %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "HTTP/1.1 200 OK") { + t.Fatalf("PROPPATCH 应返回 200 propstat,实际 %s", rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(propDir, "a.txt", "__props__.json")); err != nil { + t.Fatalf("应写出 sidecar: %v", err) + } + // sidecar 不在账号 root 内。 + if _, err := os.Stat(filepath.Join(dir, "data", "app1", "davprops")); !os.IsNotExist(err) { + t.Fatal("账号 root 内不应出现 davprops 目录") + } + + findBody := `` + + `` + rec = request(s, "PROPFIND", "/app1/a.txt", "app1", pass, strings.NewReader(findBody), map[string]string{"Depth": "0"}) + if rec.Code != 207 || !strings.Contains(rec.Body.String(), "alice") { + t.Fatalf("PROPFIND 应读回死属性,实际 %d %s", rec.Code, rec.Body.String()) + } + + // COPY 跟随死属性。 + rec = request(s, "COPY", "/app1/a.txt", "app1", pass, nil, map[string]string{ + "Destination": "http://example.com/app1/c.txt", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("COPY 应为 201,实际 %d", rec.Code) + } + rec = request(s, "PROPFIND", "/app1/c.txt", "app1", pass, strings.NewReader(findBody), map[string]string{"Depth": "0"}) + if rec.Code != 207 || !strings.Contains(rec.Body.String(), "alice") { + t.Fatalf("COPY 后死属性应跟随,实际 %d %s", rec.Code, rec.Body.String()) + } + + // MOVE 跟随死属性。 + rec = request(s, "MOVE", "/app1/a.txt", "app1", pass, nil, map[string]string{ + "Destination": "http://example.com/app1/m.txt", + }) + if rec.Code != http.StatusCreated && rec.Code != http.StatusNoContent { + t.Fatalf("MOVE 应为 201/204,实际 %d", rec.Code) + } + rec = request(s, "PROPFIND", "/app1/m.txt", "app1", pass, strings.NewReader(findBody), map[string]string{"Depth": "0"}) + if rec.Code != 207 || !strings.Contains(rec.Body.String(), "alice") { + t.Fatalf("MOVE 后死属性应跟随,实际 %d %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(propDir, "a.txt")); !os.IsNotExist(err) { + t.Fatal("MOVE 后旧 sidecar 应消失") + } + + // 目录列表(PROPFIND Depth:1)不得出现 sidecar。 + rec = request(s, "PROPFIND", "/app1/", "app1", pass, nil, map[string]string{"Depth": "1"}) + if rec.Code != 207 { + t.Fatalf("PROPFIND Depth:1 应为 207,实际 %d", rec.Code) + } + if strings.Contains(rec.Body.String(), "davprops") || strings.Contains(rec.Body.String(), "__props__") { + t.Fatalf("目录列表不应暴露 sidecar: %s", rec.Body.String()) + } + + // PROPPATCH remove 后读不到,sidecar 清理。 + removeBody := `` + + `` + + `` + + `` + rec = request(s, "PROPPATCH", "/app1/c.txt", "app1", pass, strings.NewReader(removeBody), map[string]string{"Content-Type": "application/xml"}) + if rec.Code != 207 { + t.Fatalf("PROPPATCH remove 应为 207,实际 %d", rec.Code) + } + rec = request(s, "PROPFIND", "/app1/c.txt", "app1", pass, strings.NewReader(findBody), map[string]string{"Depth": "0"}) + if strings.Contains(rec.Body.String(), "alice") { + t.Fatalf("remove 后不应再读到死属性: %s", rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(propDir, "c.txt")); !os.IsNotExist(err) { + t.Fatal("remove 后 sidecar 应清理") + } + + // DELETE 清理死属性。 + rec = request(s, http.MethodDelete, "/app1/m.txt", "app1", pass, nil, nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE 应为 204,实际 %d", rec.Code) + } + if _, err := os.Stat(filepath.Join(propDir, "m.txt")); !os.IsNotExist(err) { + t.Fatal("DELETE 后 sidecar 应清理") + } +} diff --git a/internal/server/webdav.go b/internal/server/webdav.go index 20bb1f5..4578ac8 100644 --- a/internal/server/webdav.go +++ b/internal/server/webdav.go @@ -3,6 +3,7 @@ package server import ( "log" "net/http" + "path/filepath" "golang.org/x/net/webdav" @@ -18,6 +19,12 @@ func isWriteMethod(method string) bool { return false } +// davFSFor 为账号构造带死属性持久化的文件系统。 +// sidecar 放在账号 root 之外,保证不会出现在客户端可见目录里。 +func (s *Server) davFSFor(acct account.Account) webdav.FileSystem { + return newDeadPropFS(acct.Root, filepath.Join(s.dataDir, "davprops", acct.User)) +} + func writeAuthChallenge(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="davbox"`) writeError(w, http.StatusUnauthorized, "未认证") @@ -61,7 +68,7 @@ func (s *Server) handleDAV(w http.ResponseWriter, r *http.Request, user string) h := &webdav.Handler{ Prefix: "/" + user + "/", - FileSystem: webdav.Dir(acct.Root), + FileSystem: s.davFSFor(acct), LockSystem: s.lockSystem(user), Logger: func(req *http.Request, err error) { if err != nil { @@ -77,7 +84,7 @@ func (s *Server) handleDAV(w http.ResponseWriter, r *http.Request, user string) func (s *Server) davHandlerFor(acct account.Account) http.Handler { return &webdav.Handler{ Prefix: "/" + acct.User + "/", - FileSystem: webdav.Dir(acct.Root), + FileSystem: s.davFSFor(acct), LockSystem: s.lockSystem(acct.User), } }