From a7192fd96efb8d6a5ed083b38e4cbe36b6d459a3 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Mon, 21 Sep 2026 17:27:54 +0800 Subject: [PATCH] refactor: route WebDAV through storage backend --- chfs-ng/internal/api/server.go | 197 +++++++++++++++++++++++++- chfs-ng/internal/api/webdav_test.go | 20 +++ chfs-ng/internal/storage/multipath.go | 19 +++ 3 files changed, 230 insertions(+), 6 deletions(-) diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 83863d0..0f43f43 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -606,18 +606,203 @@ func (s *Server) webdav(w http.ResponseWriter, r *http.Request) { r.URL.Path = "/" + vp defer func() { r.URL.Path = old }() inner := s.WebDAVFS - if inner == nil && s.MultiRoot { - if m, ok := s.Store.(storage.Multi); ok { - inner = newWebDAVMultiFS(m) - } - } if inner == nil { - inner = webdav.LocalFileSystem(s.WebDAVRoot) + inner = &webdavStorageFS{store: s.Store, removeMode: s.RemoveMode} } fs := &webdavAuthFS{inner: inner, account: account(r, s)} (&webdav.Handler{FileSystem: fs}).ServeHTTP(w, r) } +type webdavStorageFS struct { + store storage.Storage + removeMode int +} + +func davStoragePath(name string) string { + p := strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/") + p = strings.TrimPrefix(p, "webdav/") + p = strings.TrimPrefix(p, "webdav") + return strings.Trim(p, "/") +} + +func (f *webdavStorageFS) info(name string) (*webdav.FileInfo, error) { + p := davStoragePath(name) + fi, err := f.store.Stat(p) + if err != nil { + return nil, err + } + return &webdav.FileInfo{Path: "/" + p, Size: fi.Size(), ModTime: fi.ModTime(), IsDir: fi.IsDir()}, nil +} + +func (f *webdavStorageFS) Open(ctx context.Context, name string) (io.ReadCloser, error) { + p := davStoragePath(name) + fi, err := f.store.Stat(p) + if err != nil { + return nil, err + } + if fi.IsDir() { + return nil, fmt.Errorf("is a directory") + } + return f.store.Open(p) +} + +func (f *webdavStorageFS) Stat(ctx context.Context, name string) (*webdav.FileInfo, error) { + return f.info(name) +} + +func (f *webdavStorageFS) ReadDir(ctx context.Context, name string, recursive bool) ([]webdav.FileInfo, error) { + root := davStoragePath(name) + var out []webdav.FileInfo + var walk func(string) error + walk = func(dir string) error { + items, err := f.store.List(dir) + if err != nil { + return err + } + for _, item := range items { + p := path.Join(dir, item.Name()) + out = append(out, webdav.FileInfo{Path: "/" + p, Size: item.Size(), ModTime: item.ModTime(), IsDir: item.IsDir()}) + if recursive && item.IsDir() { + if err := walk(p); err != nil { + return err + } + } + } + return nil + } + if err := walk(root); err != nil { + return nil, err + } + return out, nil +} + +func (f *webdavStorageFS) Create(ctx context.Context, name string, body io.ReadCloser, opts *webdav.CreateOptions) (*webdav.FileInfo, bool, error) { + p := davStoragePath(name) + _, statErr := f.store.Stat(p) + if statErr != nil && !os.IsNotExist(statErr) { + return nil, false, statErr + } + created := os.IsNotExist(statErr) + file, err := f.store.Create(p) + if err != nil { + return nil, false, err + } + _, copyErr := io.Copy(file, body) + closeErr := file.Close() + body.Close() + if copyErr != nil { + return nil, false, copyErr + } + if closeErr != nil { + return nil, false, closeErr + } + fi, err := f.info(p) + return fi, created, err +} + +func (f *webdavStorageFS) RemoveAll(ctx context.Context, name string, opts *webdav.RemoveAllOptions) error { + p := davStoragePath(name) + if p == "" { + return fmt.Errorf("cannot remove storage root") + } + if f.removeMode == 1 { + if t, ok := f.store.(storage.Trashable); ok { + return t.Trash(p) + } + } + if f.removeMode == 2 { + if t, ok := f.store.(storage.Trashable); ok { + home, err := os.UserHomeDir() + if err != nil { + return err + } + return t.MoveToTrash(p, filepath.Join(home, ".chfs_trashbin")) + } + } + return f.store.Remove(p) +} + +func (f *webdavStorageFS) Mkdir(ctx context.Context, name string) error { + p := davStoragePath(name) + if p == "" { + return fmt.Errorf("cannot create storage root") + } + return f.store.MkdirAll(p) +} + +func (f *webdavStorageFS) Copy(ctx context.Context, name, dest string, opts *webdav.CopyOptions) (bool, error) { + src := davStoragePath(name) + dst := davStoragePath(dest) + if m, ok := f.store.(storage.Multi); ok { + srcRoot, _, srcOK := m.FindRoot(src) + dstRoot, _, dstOK := m.FindRoot(dst) + if srcOK && dstOK && srcRoot != dstRoot { + return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root copy is not supported")) + } + } + info, err := f.store.Stat(src) + if err != nil { + return false, err + } + if info.IsDir() { + if err := f.store.MkdirAll(dst); err != nil { + return false, err + } + items, err := f.store.List(src) + if err != nil { + return false, err + } + for _, item := range items { + created, err := f.Copy(ctx, path.Join(src, item.Name()), path.Join(dst, item.Name()), opts) + if err != nil { + return false, err + } + _ = created + } + return true, nil + } + in, err := f.store.Open(src) + if err != nil { + return false, err + } + out, err := f.store.Create(dst) + if err != nil { + in.Close() + return false, err + } + _, copyErr := io.Copy(out, in) + in.Close() + closeErr := out.Close() + if copyErr != nil { + return false, copyErr + } + if closeErr != nil { + return false, closeErr + } + return true, nil +} + +func (f *webdavStorageFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) { + src := davStoragePath(name) + dst := davStoragePath(dest) + if opts != nil && opts.NoOverwrite { + if _, err := f.store.Stat(dst); err == nil { + return false, fmt.Errorf("destination exists") + } + } + if m, ok := f.store.(storage.Multi); ok { + srcRoot, _, srcOK := m.FindRoot(src) + dstRoot, _, dstOK := m.FindRoot(dst) + if srcOK && dstOK && srcRoot != dstRoot { + return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root move is not supported")) + } + } + if err := f.store.Rename(src, dst); err != nil { + return false, webdav.NewHTTPError(http.StatusForbidden, err) + } + return true, nil +} + type webdavMultiFS struct { roots map[string]webdav.FileSystem names []string diff --git a/chfs-ng/internal/api/webdav_test.go b/chfs-ng/internal/api/webdav_test.go index 332234f..bf79a46 100644 --- a/chfs-ng/internal/api/webdav_test.go +++ b/chfs-ng/internal/api/webdav_test.go @@ -7,6 +7,7 @@ import ( "testing" "chfs-ng/internal/auth" + "chfs-ng/internal/storage" "github.com/emersion/go-webdav" ) @@ -35,3 +36,22 @@ func TestWebDAVAuthFSCheckTree(t *testing.T) { t.Fatalf("public file should be deletable: %v", err) } } + +func TestWebDAVStorageFSCopyAndMove(t *testing.T) { + root1 := t.TempDir() + root2 := t.TempDir() + if err := os.WriteFile(filepath.Join(root1, "a.txt"), []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + m := storage.NewMulti([]string{root1, root2}) + fs := &webdavStorageFS{store: m} + if _, err := fs.Copy(context.Background(), "/"+m.Names[0]+"/a.txt", "/"+m.Names[0]+"/b.txt", &webdav.CopyOptions{}); err != nil { + t.Fatalf("copy: %v", err) + } + if _, err := os.Stat(filepath.Join(root1, "b.txt")); err != nil { + t.Fatal(err) + } + if _, err := fs.Move(context.Background(), "/"+m.Names[0]+"/b.txt", "/"+m.Names[1]+"/b.txt", &webdav.MoveOptions{}); err == nil { + t.Fatal("expected cross-root move rejection") + } +} diff --git a/chfs-ng/internal/storage/multipath.go b/chfs-ng/internal/storage/multipath.go index f0ec2b4..b39b6d5 100644 --- a/chfs-ng/internal/storage/multipath.go +++ b/chfs-ng/internal/storage/multipath.go @@ -41,6 +41,25 @@ func NewMulti(paths []string) Multi { func (m Multi) isVirtualRoot(p string) bool { return strings.Trim(p, "/") == "" } +func (m Multi) FindRoot(p string) (string, string, bool) { + p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/") + if p == "" { + return "", "", false + } + parts := strings.SplitN(p, "/", 2) + for _, n := range m.Names { + if parts[0] != n { + continue + } + rel := "" + if len(parts) == 2 { + rel = parts[1] + } + return n, rel, true + } + return "", "", false +} + func (m Multi) find(p string) (Local, string, bool) { p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/") if p == "" {