feat(webdav): 实现死属性持久化(PROPPATCH/PROPFIND/COPY/MOVE/DELETE)
- 新增 deadPropFS,包装 webdav.Dir 并让 File 实现 DeadPropsHolder - sidecar 存于数据目录 davprops/<user>/(账号 root 之外), 从结构上保证不污染用户可见目录与用量统计 - COPY/MOVE/DELETE/remove 跟随并清理死属性 - 新增 deadprops_test.go 覆盖读写/复制/移动/删除/隐藏
This commit is contained in:
1 parent
d32600dd27
commit
6a3689d17d
3 files changed
+373
-2
No files matched your search
@@ -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/<user>/`,而不是 root 内的 `.davprops/`。
|
||||
// 理由:
|
||||
// 1. root 内任何隐藏目录都仍处在 WebDAV/PROPFIND 和 client 目录列表可见的
|
||||
// 命名空间里,必须额外过滤每一处遍历(Readdir、client list、用量统计),
|
||||
// 漏一处就会污染用户目录;放到 root 外则从结构上杜绝这种污染。
|
||||
// 2. 用量统计(usage.Dir)与 client API 直接走 os 层,无需感知 sidecar。
|
||||
//
|
||||
// 资源 key 用 webdav.Handler 剥离 Prefix 后的相对路径,统一 path.Clean;
|
||||
// 每个资源对应 `<propDir>/<key 各段作为目录>/__props__.json`,root 对应
|
||||
// `<propDir>/__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
|
||||
}
|
||||
@@ -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 := `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:test:">` +
|
||||
`<D:set><D:prop><Z:author>alice</Z:author></D:prop></D:set>` +
|
||||
`</D:propertyupdate>`
|
||||
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 := `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:" xmlns:Z="urn:test:"><D:prop><Z:author/></D:prop></D:propfind>`
|
||||
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 := `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:test:">` +
|
||||
`<D:remove><D:prop><Z:author/></D:prop></D:remove>` +
|
||||
`</D:propertyupdate>`
|
||||
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 应清理")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user