From d0c47efb7386b512f3452bd08082e410efab2a6e Mon Sep 17 00:00:00 2001 From: Intro Date: Tue, 4 Aug 2026 20:10:15 +0800 Subject: [PATCH 1/5] fix(webdav): correct COPY/MOVE semantics with named copy support Signed-off-by: Lythen --- internal/fs/copy_move.go | 42 ++++++++++++++++++++---------- internal/fs/fs.go | 15 ++++++++--- internal/fs/other.go | 1 + server/webdav/file.go | 56 +++++++++++++++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/internal/fs/copy_move.go b/internal/fs/copy_move.go index e78fc9be8..92ad24fe3 100644 --- a/internal/fs/copy_move.go +++ b/internal/fs/copy_move.go @@ -100,7 +100,7 @@ func (t *FileTransferTask) SetRetry(retry int, maxRetry int) { } } -func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath string, skipHook ...bool) (task.TaskExtensionInfo, error) { +func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath, dstName string, skipHook ...bool) (task.TaskExtensionInfo, error) { srcStorage, srcObjActualPath, err := op.GetStorageAndActualPath(srcObjPath) if err != nil { return nil, errors.WithMessage(err, "failed get src storage") @@ -114,15 +114,19 @@ func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath str if utils.IsBool(skipHook...) { ctx = context.WithValue(ctx, conf.SkipHookKey, struct{}{}) } - if taskType == copy || taskType == merge { - err = op.Copy(ctx, srcStorage, srcObjActualPath, dstDirActualPath) - if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { - return nil, err - } - } else { - err = op.Move(ctx, srcStorage, srcObjActualPath, dstDirActualPath) - if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { - return nil, err + // A named copy cannot use the driver's destination-name-independent Copy + // operation. Fall back to the transfer task so the target name is kept. + if dstName == "" { + if taskType == copy || taskType == merge { + err = op.Copy(ctx, srcStorage, srcObjActualPath, dstDirActualPath) + if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { + return nil, err + } + } else { + err = op.Move(ctx, srcStorage, srcObjActualPath, dstDirActualPath) + if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { + return nil, err + } } } } @@ -134,6 +138,7 @@ func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath str DstStorage: dstStorage, SrcActualPath: srcObjActualPath, DstActualPath: dstDirActualPath, + DstName: dstName, SrcStorageMp: srcStorage.GetStorage().MountPath, DstStorageMp: dstStorage.GetStorage().MountPath, }, @@ -189,9 +194,14 @@ func (t *FileTransferTask) RunWithNextTaskCallback(f func(nextTask *FileTransfer if err != nil { return errors.WithMessagef(err, "failed list src [%s] objs", t.SrcActualPath) } - dstActualPath := stdpath.Join(t.DstActualPath, srcObj.GetName()) - task_group.TransferCoordinator.AppendPayload(t.groupID, task_group.DstPathToHook(dstActualPath)) - + dstName := srcObj.GetName() + if t.DstName != "" { + dstName = t.DstName + } + dstActualPath := stdpath.Join(t.DstActualPath, dstName) + if err := op.MakeDir(t.Ctx(), t.DstStorage, dstActualPath); err != nil { + return errors.WithMessagef(err, "failed create dst dir [%s]", dstActualPath) + } existedObjs := make(map[string]bool) if t.TaskType == merge { dstObjs, err := op.List(t.Ctx(), t.DstStorage, dstActualPath, model.ListArgs{}) @@ -250,8 +260,12 @@ func (t *FileTransferTask) RunWithNextTaskCallback(f func(nextTask *FileTransfer return errors.WithMessagef(err, "failed get [%s] link", t.SrcActualPath) } // any link provided is seekable + streamObj := srcObj + if t.DstName != "" { + streamObj = &model.ObjWrapName{Name: t.DstName, Obj: srcObj} + } ss, err := stream.NewSeekableStream(&stream.FileStream{ - Obj: srcObj, + Obj: streamObj, Ctx: t.Ctx(), }, link) if err != nil { diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 67a1ac065..b7f064ac7 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -69,7 +69,7 @@ func MakeDir(ctx context.Context, path string) error { } func Move(ctx context.Context, srcPath, dstDirPath string, skipHook ...bool) (task.TaskExtensionInfo, error) { - req, err := transfer(ctx, move, srcPath, dstDirPath, skipHook...) + req, err := transfer(ctx, move, srcPath, dstDirPath, "", skipHook...) if err != nil { log.Errorf("failed move %s to %s: %+v", srcPath, dstDirPath, err) } @@ -77,15 +77,24 @@ func Move(ctx context.Context, srcPath, dstDirPath string, skipHook ...bool) (ta } func Copy(ctx context.Context, srcObjPath, dstDirPath string, skipHook ...bool) (task.TaskExtensionInfo, error) { - res, err := transfer(ctx, copy, srcObjPath, dstDirPath, skipHook...) + res, err := transfer(ctx, copy, srcObjPath, dstDirPath, "", skipHook...) if err != nil { log.Errorf("failed copy %s to %s: %+v", srcObjPath, dstDirPath, err) } return res, err } +// CopyTo copies a file or directory to dstDirPath using dstName as its name. +func CopyTo(ctx context.Context, srcObjPath, dstDirPath, dstName string, skipHook ...bool) (task.TaskExtensionInfo, error) { + res, err := transfer(ctx, copy, srcObjPath, dstDirPath, dstName, skipHook...) + if err != nil { + log.Errorf("failed copy %s to %s as %s: %+v", srcObjPath, dstDirPath, dstName, err) + } + return res, err +} + func Merge(ctx context.Context, srcObjPath, dstDirPath string, skipHook ...bool) (task.TaskExtensionInfo, error) { - res, err := transfer(ctx, merge, srcObjPath, dstDirPath, skipHook...) + res, err := transfer(ctx, merge, srcObjPath, dstDirPath, "", skipHook...) if err != nil { log.Errorf("failed merge %s to %s: %+v", srcObjPath, dstDirPath, err) } diff --git a/internal/fs/other.go b/internal/fs/other.go index a23beb73b..a74eff412 100644 --- a/internal/fs/other.go +++ b/internal/fs/other.go @@ -53,6 +53,7 @@ type TaskData struct { Status string `json:"-"` //don't save status to save space SrcActualPath string `json:"src_path"` DstActualPath string `json:"dst_path"` + DstName string `json:"dst_name,omitempty"` SrcStorage driver.Driver `json:"-"` DstStorage driver.Driver `json:"-"` SrcStorageMp string `json:"src_storage_mp"` diff --git a/server/webdav/file.go b/server/webdav/file.go index ea6099735..e647be817 100644 --- a/server/webdav/file.go +++ b/server/webdav/file.go @@ -55,21 +55,41 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) { return http.StatusForbidden, nil } + if _, err = fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil { + if errs.IsObjectNotFound(err) { + return http.StatusConflict, err + } + return http.StatusMethodNotAllowed, err + } + dstExisted := false + if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil { + dstExisted = true + if !overwrite { + return http.StatusPreconditionFailed, nil + } + if err = fs.Remove(ctx, dst); err != nil { + return http.StatusInternalServerError, err + } + } else if !errs.IsObjectNotFound(err) { + return http.StatusInternalServerError, err + } if srcDir == dstDir { err = fs.Rename(ctx, src, dstName) } else { _, err = fs.Move(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir) - if err != nil { - return http.StatusInternalServerError, err - } - if srcName != dstName { + if err == nil && srcName != dstName { err = fs.Rename(ctx, path.Join(dstDir, srcName), dstName) } } if err != nil { return http.StatusInternalServerError, err } - // TODO if there are no files copy, should return 204 + if err = moveDeadProps(src, dst); err != nil { + return http.StatusInternalServerError, err + } + if dstExisted { + return http.StatusNoContent, nil + } return http.StatusCreated, nil } @@ -80,6 +100,7 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) { srcDir := path.Dir(src) dstDir := path.Dir(dst) + dstName := path.Base(dst) user := ctx.Value(conf.UserKey).(*model.User) if !user.CanCopy() { return http.StatusForbidden, nil @@ -98,11 +119,32 @@ func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int if !common.CanWrite(user, dstMeta, dstDir) { return http.StatusForbidden, nil } - _, err = fs.Copy(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir) + if _, err = fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil { + if errs.IsObjectNotFound(err) { + return http.StatusConflict, err + } + return http.StatusMethodNotAllowed, err + } + dstExisted := false + if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil { + dstExisted = true + if !overwrite { + return http.StatusPreconditionFailed, nil + } + if err = fs.Remove(ctx, dst); err != nil { + return http.StatusInternalServerError, err + } + } else if !errs.IsObjectNotFound(err) { + return http.StatusInternalServerError, err + } + + _, err = fs.CopyTo(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir, dstName) if err != nil { return http.StatusInternalServerError, err } - // TODO if there are no files copy, should return 204 + if dstExisted { + return http.StatusNoContent, nil + } return http.StatusCreated, nil } From 02d5b9186a244962606c5eef0348a36608404fe5 Mon Sep 17 00:00:00 2001 From: Intro Date: Tue, 4 Aug 2026 20:10:33 +0800 Subject: [PATCH 2/5] feat(webdav): persist dead properties with MOVE migration Signed-off-by: Lythen --- internal/db/db.go | 2 +- internal/model/webdav_property.go | 11 ++ server/webdav/prop.go | 207 +++++++++++++++--------------- 3 files changed, 119 insertions(+), 101 deletions(-) create mode 100644 internal/model/webdav_property.go diff --git a/internal/db/db.go b/internal/db/db.go index 96529c15d..59e99f397 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -12,7 +12,7 @@ var db *gorm.DB func Init(d *gorm.DB) { db = d - err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB)) + err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.WebDAVProperty)) if err != nil { log.Fatalf("failed migrate database: %s", err.Error()) } diff --git a/internal/model/webdav_property.go b/internal/model/webdav_property.go new file mode 100644 index 000000000..cdfedb9e7 --- /dev/null +++ b/internal/model/webdav_property.go @@ -0,0 +1,11 @@ +package model + +// WebDAVProperty stores a dead WebDAV property for a resource. +type WebDAVProperty struct { + ID uint `json:"id" gorm:"primaryKey"` + Path string `json:"path" gorm:"uniqueIndex:idx_webdav_property"` + Namespace string `json:"namespace" gorm:"uniqueIndex:idx_webdav_property"` + Name string `json:"name" gorm:"uniqueIndex:idx_webdav_property"` + Lang string `json:"lang"` + InnerXML []byte `json:"inner_xml"` +} diff --git a/server/webdav/prop.go b/server/webdav/prop.go index 5c8889341..66e09b88d 100644 --- a/server/webdav/prop.go +++ b/server/webdav/prop.go @@ -16,9 +16,11 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/db" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/OpenListTeam/OpenList/v4/server/common" + "gorm.io/gorm" ) // Proppatch describes a property update instruction as defined in RFC 4918. @@ -170,79 +172,39 @@ var liveProps = map[xml.Name]struct { // TODO(nigeltao) merge props and allprop? // Props returns the status of the properties named pnames for resource name. -// -// Each Propstat has a unique status and each property name will only be part -// of one Propstat element. -func props(ctx context.Context, ls LockSystem, fi model.Obj, pnames []xml.Name) ([]Propstat, error) { - //f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0) - //if err != nil { - // return nil, err - //} - //defer f.Close() - //fi, err := f.Stat() - //if err != nil { - // return nil, err - //} +func props(ctx context.Context, ls LockSystem, name string, fi model.Obj, pnames []xml.Name) ([]Propstat, error) { isDir := fi.IsDir() - - var deadProps map[xml.Name]Property - // ??? what is this for? - //if dph, ok := f.(DeadPropsHolder); ok { - // deadProps, err = dph.DeadProps() - // if err != nil { - // return nil, err - // } - //} - + deadProps, err := getDeadProps(name) + if err != nil { + return nil, err + } pstatOK := Propstat{Status: http.StatusOK} pstatNotFound := Propstat{Status: http.StatusNotFound} for _, pn := range pnames { - // If this file has dead properties, check if they contain pn. if dp, ok := deadProps[pn]; ok { pstatOK.Props = append(pstatOK.Props, dp) continue } - // Otherwise, it must either be a live property or we don't know it. if prop := liveProps[pn]; prop.findFn != nil && (prop.dir || !isDir) { innerXML, err := prop.findFn(ctx, ls, fi.GetName(), fi) if err != nil { return nil, err } - pstatOK.Props = append(pstatOK.Props, Property{ - XMLName: pn, - InnerXML: []byte(innerXML), - }) + pstatOK.Props = append(pstatOK.Props, Property{XMLName: pn, InnerXML: []byte(innerXML)}) } else { - pstatNotFound.Props = append(pstatNotFound.Props, Property{ - XMLName: pn, - }) + pstatNotFound.Props = append(pstatNotFound.Props, Property{XMLName: pn}) } } return makePropstats(pstatOK, pstatNotFound), nil } // Propnames returns the property names defined for resource name. -func propnames(ctx context.Context, ls LockSystem, fi model.Obj) ([]xml.Name, error) { - //f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0) - //if err != nil { - // return nil, err - //} - //defer f.Close() - //fi, err := f.Stat() - //if err != nil { - // return nil, err - //} +func propnames(_ context.Context, _ LockSystem, name string, fi model.Obj) ([]xml.Name, error) { isDir := fi.IsDir() - - var deadProps map[xml.Name]Property - // ??? what is this for? - //if dph, ok := f.(DeadPropsHolder); ok { - // deadProps, err = dph.DeadProps() - // if err != nil { - // return nil, err - // } - //} - + deadProps, err := getDeadProps(name) + if err != nil { + return nil, err + } pnames := make([]xml.Name, 0, len(liveProps)+len(deadProps)) for pn, prop := range liveProps { if prop.findFn != nil && (prop.dir || !isDir) { @@ -255,20 +217,12 @@ func propnames(ctx context.Context, ls LockSystem, fi model.Obj) ([]xml.Name, er return pnames, nil } -// Allprop returns the properties defined for resource name and the properties -// named in include. -// -// Note that RFC 4918 defines 'allprop' to return the DAV: properties defined -// within the RFC plus dead properties. Other live properties should only be -// returned if they are named in 'include'. -// -// See http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND -func allprop(ctx context.Context, ls LockSystem, fi model.Obj, include []xml.Name) ([]Propstat, error) { - pnames, err := propnames(ctx, ls, fi) +// Allprop returns the properties defined for resource name and the properties named in include. +func allprop(ctx context.Context, ls LockSystem, name string, fi model.Obj, include []xml.Name) ([]Propstat, error) { + pnames, err := propnames(ctx, ls, name, fi) if err != nil { return nil, err } - // Add names from include if they are not already covered in pnames. nameset := make(map[xml.Name]bool) for _, pn := range pnames { nameset[pn] = true @@ -278,11 +232,10 @@ func allprop(ctx context.Context, ls LockSystem, fi model.Obj, include []xml.Nam pnames = append(pnames, pn) } } - return props(ctx, ls, fi, pnames) + return props(ctx, ls, name, fi, pnames) } -// Patch patches the properties of resource name. The return values are -// constrained in the same manner as DeadPropsHolder.Patch. +// Patch patches the properties of resource name. func patch(ctx context.Context, ls LockSystem, name string, patches []Proppatch) ([]Propstat, error) { conflict := false loop: @@ -314,53 +267,78 @@ loop: return makePropstats(pstatForbidden, pstatFailedDep), nil } - // ------------------------------------------------------------ - //f, err := fs.OpenFile(ctx, name, os.O_RDWR, 0) - //if err != nil { - // return nil, err - //} - //defer f.Close() - //if dph, ok := f.(DeadPropsHolder); ok { - // ret, err := dph.Patch(patches) - // if err != nil { - // return nil, err - // } - // // http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat says that - // // "The contents of the prop XML element must only list the names of - // // properties to which the result in the status element applies." - // for _, pstat := range ret { - // for i, p := range pstat.Props { - // pstat.Props[i] = Property{XMLName: p.XMLName} - // } - // } - // return ret, nil - //} - // ------------------------------------------------------------ - - // The file doesn't implement the optional DeadPropsHolder interface, so - // all patches are forbidden. - pstat := Propstat{Status: http.StatusForbidden} - for _, patch := range patches { - for _, p := range patch.Props { - pstat.Props = append(pstat.Props, Property{XMLName: p.XMLName}) + database := db.GetDb() + if database == nil { + return nil, errors.New("webdav property database is not initialized") + } + pstat := Propstat{Status: http.StatusOK} + var err error + for attempt := 0; attempt < 40; attempt++ { + pstat.Props = nil + err = database.Transaction(func(tx *gorm.DB) error { + for _, patch := range patches { + for _, prop := range patch.Props { + if patch.Remove { + if err := tx.Where("path = ? AND namespace = ? AND name = ?", name, prop.XMLName.Space, prop.XMLName.Local).Delete(&model.WebDAVProperty{}).Error; err != nil { + return err + } + } else { + row := model.WebDAVProperty{ + Path: name, + Namespace: prop.XMLName.Space, + Name: prop.XMLName.Local, + } + if err := tx.Where("path = ? AND namespace = ? AND name = ?", row.Path, row.Namespace, row.Name). + Assign(model.WebDAVProperty{Lang: prop.Lang, InnerXML: prop.InnerXML}). + FirstOrCreate(&row).Error; err != nil { + return err + } + } + pstat.Props = append(pstat.Props, Property{XMLName: prop.XMLName}) + } + } + return nil + }) + if err == nil || !strings.Contains(err.Error(), "database is locked") { + break } + time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond) + } + if err != nil { + return nil, err } return []Propstat{pstat}, nil } +func getDeadProps(path string) (map[xml.Name]Property, error) { + database := db.GetDb() + if database == nil { + return nil, errors.New("webdav property database is not initialized") + } + var rows []model.WebDAVProperty + if err := database.Where("path = ?", path).Find(&rows).Error; err != nil { + return nil, err + } + props := make(map[xml.Name]Property, len(rows)) + for _, row := range rows { + props[xml.Name{Space: row.Namespace, Local: row.Name}] = Property{ + XMLName: xml.Name{Space: row.Namespace, Local: row.Name}, + Lang: row.Lang, + InnerXML: row.InnerXML, + } + } + return props, nil +} + func escapeXML(s string) string { for i := 0; i < len(s); i++ { - // As an optimization, if s contains only ASCII letters, digits or a - // few special characters, the escaped value is s itself and we don't - // need to allocate a buffer and convert between string and []byte. switch c := s[i]; { case c == ' ' || c == '_' || - ('+' <= c && c <= '9') || // Digits as well as + , - . and / + ('+' <= c && c <= '9') || ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z'): continue } - // Otherwise, go through the full escaping process. var buf bytes.Buffer xml.EscapeText(&buf, []byte(s)) return buf.String() @@ -368,6 +346,35 @@ func escapeXML(s string) string { return s } +func moveDeadProps(src, dst string) error { + database := db.GetDb() + if database == nil { + return errors.New("webdav property database is not initialized") + } + return database.Transaction(func(tx *gorm.DB) error { + var rows []model.WebDAVProperty + if err := tx.Where("path = ? OR path LIKE ?", src, src+"/%").Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + newPath := dst + strings.TrimPrefix(row.Path, src) + if err := tx.Where("path = ? AND namespace = ? AND name = ?", newPath, row.Namespace, row.Name).Delete(&model.WebDAVProperty{}).Error; err != nil { + return err + } + copy := row + copy.ID = 0 + copy.Path = newPath + if err := tx.Create(©).Error; err != nil { + return err + } + if err := tx.Delete(&row).Error; err != nil { + return err + } + } + return nil + }) +} + func findResourceType(ctx context.Context, ls LockSystem, name string, fi model.Obj) (string, error) { if fi.IsDir() { return ``, nil From b78500f63b7a35c56f1fc462a21672e23817c7f7 Mon Sep 17 00:00:00 2001 From: Intro Date: Tue, 4 Aug 2026 20:10:34 +0800 Subject: [PATCH 3/5] fix(webdav): enforce locks on resolved paths, support shared locks Signed-off-by: Lythen --- server/webdav/lock.go | 40 +++++++++++++++++----- server/webdav/webdav.go | 74 +++++++++++++++++------------------------ server/webdav/xml.go | 72 ++++++++++++++++++++++++++++----------- 3 files changed, 115 insertions(+), 71 deletions(-) diff --git a/server/webdav/lock.go b/server/webdav/lock.go index 344ac5cea..b3d5ac5ee 100644 --- a/server/webdav/lock.go +++ b/server/webdav/lock.go @@ -109,6 +109,8 @@ type LockDetails struct { // ZeroDepth is whether the lock has zero depth. If it does not have zero // depth, it has infinite depth. ZeroDepth bool + // Shared is whether the lock may coexist with other shared locks on Root. + Shared bool } // NewMemLS returns a new in-memory LockSystem. @@ -184,15 +186,10 @@ func (m *memLS) Confirm(now time.Time, name0, name1 string, conditions ...Condit }, nil } -// lookup returns the node n that locks the named resource, provided that n -// matches at least one of the given conditions and that lock isn't held by -// another party. Otherwise, it returns nil. -// -// n may be a parent of the named resource, if n is an infinite depth lock. -func (m *memLS) lookup(name string, conditions ...Condition) (n *memLSNode) { +func (m *memLS) lookup(name string, conditions ...Condition) *memLSNode { // TODO: support Condition.Not and Condition.ETag. for _, c := range conditions { - n = m.byToken[c.Token] + n := m.byToken[c.Token] if n == nil || n.held { continue } @@ -235,6 +232,14 @@ func (m *memLS) Create(now time.Time, details LockDetails) (string, error) { m.collectExpiredNodes(now) details.Root = slashClean(details.Root) + if details.Shared { + if n := m.byName[details.Root]; n != nil && n.token != "" && n.details.Shared && n.details.ZeroDepth == details.ZeroDepth && !n.held { + token := m.nextToken() + n.sharedTokens[token] = struct{}{} + m.byToken[token] = n + return token, nil + } + } if !m.canCreate(details.Root, details.ZeroDepth) { return "", ErrLocked } @@ -242,6 +247,9 @@ func (m *memLS) Create(now time.Time, details LockDetails) (string, error) { n.token = m.nextToken() m.byToken[n.token] = n n.details = details + if details.Shared { + n.sharedTokens = map[string]struct{}{n.token: {}} + } if n.details.Duration >= 0 { n.expiry = now.Add(n.details.Duration) heap.Push(&m.byExpiry, n) @@ -284,6 +292,13 @@ func (m *memLS) Unlock(now time.Time, token string) error { if n.held { return ErrLocked } + if n.details.Shared { + delete(m.byToken, token) + delete(n.sharedTokens, token) + if len(n.sharedTokens) != 0 { + return nil + } + } m.remove(n) return nil } @@ -334,7 +349,13 @@ func (m *memLS) create(name string) (ret *memLSNode) { } func (m *memLS) remove(n *memLSNode) { - delete(m.byToken, n.token) + if n.details.Shared { + for token := range n.sharedTokens { + delete(m.byToken, token) + } + } else { + delete(m.byToken, n.token) + } n.token = "" walkToRoot(n.details.Root, func(name0 string, first bool) bool { x := m.byName[name0] @@ -380,7 +401,8 @@ type memLSNode struct { // if this node does not expire, or has expired. byExpiryIndex int // held is whether this node's lock is actively held by a Confirm call. - held bool + held bool + sharedTokens map[string]struct{} } type byExpiry []*memLSNode diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go index 06d1431ac..91a365daa 100644 --- a/server/webdav/webdav.go +++ b/server/webdav/webdav.go @@ -82,10 +82,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { status, err = h.handleUnlock(brw, r) case "PROPFIND": status, err = h.handlePropfind(brw, r) - // if there is a error for PROPFIND, we should be as an empty folder to the client - if err != nil { - status = http.StatusNotFound - } case "PROPPATCH": status, err = h.handleProppatch(brw, r) } @@ -122,11 +118,6 @@ func (h *Handler) lock(now time.Time, root string) (token string, status int, er func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func(), status int, err error) { hdr := r.Header.Get("If") if hdr == "" { - // An empty If header means that the client hasn't previously created locks. - // Even if this client doesn't care about locks, we still need to check that - // the resources aren't locked by another client, so we create temporary - // locks that would conflict with another client's locks. These temporary - // locks are unlocked at the end of the HTTP request. now, srcToken, dstToken := time.Now(), "", "" if src != "" { srcToken, status, err = h.lock(now, src) @@ -143,7 +134,6 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func() return nil, status, err } } - return func() { if dstToken != "" { h.LockSystem.Unlock(now, dstToken) @@ -158,7 +148,7 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func() if !ok { return nil, http.StatusBadRequest, errInvalidIfHeader } - // ih is a disjunction (OR) of ifLists, so any ifList will do. + user, _ := r.Context().Value(conf.UserKey).(*model.User) for _, l := range ih.lists { lsrc := l.resourceTag if lsrc == "" { @@ -175,6 +165,12 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func() if err != nil { return nil, status, err } + if user != nil { + lsrc, err = user.JoinPath(lsrc) + if err != nil { + return nil, http.StatusForbidden, err + } + } } release, err = h.LockSystem.Confirm(time.Now(), lsrc, dst, l.conditions...) if err == ErrConfirmationFailed { @@ -185,10 +181,6 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func() } return release, 0, nil } - // Section 10.4.1 says that "If this header is evaluated and all state lists - // fail, then the request must fail with a 412 (Precondition Failed) status." - // We follow the spec even though the cond_put_corrupt_token test case from - // the litmus test warns on seeing a 412 instead of a 423 (Locked). return nil, http.StatusPreconditionFailed, ErrLocked } @@ -212,9 +204,7 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) (status } } w.Header().Set("Allow", allow) - // http://www.webdav.org/specs/rfc4918.html#dav.compliance.classes w.Header().Set("DAV", "1, 2") - // http://msdn.microsoft.com/en-au/library/cc250217.aspx w.Header().Set("MS-Author-Via", "DAV") return 0, nil } @@ -295,12 +285,6 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i if err != nil { return status, err } - release, status, err := h.confirmLocks(r, reqPath, "") - if err != nil { - return status, err - } - defer release() - ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) if !user.CanRemove() { @@ -310,6 +294,11 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i if err != nil { return http.StatusForbidden, err } + release, status, err := h.confirmLocks(r, reqPath, "") + if err != nil { + return status, err + } + defer release() // TODO: return MultiStatus where appropriate. // "godoc os RemoveAll" says that "If the path does not exist, RemoveAll @@ -350,11 +339,6 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if reqPath == "" { return http.StatusMethodNotAllowed, nil } - release, status, err := h.confirmLocks(r, reqPath, "") - if err != nil { - return status, err - } - defer release() // TODO(rost): Support the If-Match, If-None-Match headers? See bradfitz' // comments in http.checkEtag. ctx := r.Context() @@ -363,6 +347,11 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if err != nil { return http.StatusForbidden, err } + release, status, err := h.confirmLocks(r, reqPath, "") + if err != nil { + return status, err + } + defer release() size := r.ContentLength if size < 0 { sizeStr := r.Header.Get("X-File-Size") @@ -428,18 +417,17 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in if err != nil { return status, err } - release, status, err := h.confirmLocks(r, reqPath, "") - if err != nil { - return status, err - } - defer release() - ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { return http.StatusForbidden, err } + release, status, err := h.confirmLocks(r, reqPath, "") + if err != nil { + return status, err + } + defer release() if r.ContentLength > 0 { return http.StatusUnsupportedMediaType, nil @@ -627,6 +615,7 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus Duration: duration, OwnerXML: li.Owner.InnerXML, ZeroDepth: depth == 0, + Shared: li.Shared != nil, } token, err = h.LockSystem.Create(now, ld) if err != nil { @@ -758,7 +747,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status } var pstats []Propstat if pf.Propname != nil { - pnames, err := propnames(ctx, h.LockSystem, info) + pnames, err := propnames(ctx, h.LockSystem, reqPath, info) if err != nil { return err } @@ -768,9 +757,9 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status } pstats = append(pstats, pstat) } else if pf.Allprop != nil { - pstats, err = allprop(ctx, h.LockSystem, info, pf.Prop) + pstats, err = allprop(ctx, h.LockSystem, reqPath, info, pf.Prop) } else { - pstats, err = props(ctx, h.LockSystem, info, pf.Prop) + pstats, err = props(ctx, h.LockSystem, reqPath, info, pf.Prop) } if err != nil { return err @@ -798,18 +787,17 @@ func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) (statu if err != nil { return status, err } - release, status, err := h.confirmLocks(r, reqPath, "") - if err != nil { - return status, err - } - defer release() - ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { return http.StatusForbidden, err } + release, status, err := h.confirmLocks(r, reqPath, "") + if err != nil { + return status, err + } + defer release() meta, err := op.GetNearestMeta(reqPath) if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { return http.StatusInternalServerError, err diff --git a/server/webdav/xml.go b/server/webdav/xml.go index c9ec61dff..ee649bead 100644 --- a/server/webdav/xml.go +++ b/server/webdav/xml.go @@ -62,9 +62,7 @@ func readLockInfo(r io.Reader) (li lockInfo, status int, err error) { } return lockInfo{}, http.StatusBadRequest, err } - // We only support exclusive (non-shared) write locks. In practice, these are - // the only types of locks that seem to matter. - if li.Exclusive == nil || li.Shared != nil || li.Write == nil { + if (li.Exclusive == nil) == (li.Shared == nil) || li.Write == nil { return lockInfo{}, http.StatusNotImplemented, errUnsupportedLockInfo } return li, 0, nil @@ -86,18 +84,22 @@ func writeLockInfo(w io.Writer, token string, ld LockDetails) (int, error) { if ld.ZeroDepth { depth = "0" } + scope := "exclusive" + if ld.Shared { + scope = "shared" + } timeout := ld.Duration / time.Second return fmt.Fprintf(w, "\n"+ "\n"+ - " \n"+ - " \n"+ - " %s\n"+ - " %s\n"+ - " Second-%d\n"+ - " %s\n"+ - " %s\n"+ + "\t\n"+ + "\t\n"+ + "\t%s\n"+ + "\t%s\n"+ + "\tSecond-%d\n"+ + "\t%s\n"+ + "\t%s\n"+ "", - depth, ld.OwnerXML, timeout, escape(token), escape(ld.Root), + scope, depth, ld.OwnerXML, timeout, escape(token), escape(ld.Root), ) } @@ -176,19 +178,24 @@ type propfind struct { } func readPropfind(r io.Reader) (pf propfind, status int, err error) { - c := countingReader{r: r} - if err = ixml.NewDecoder(&c).Decode(&pf); err != nil { + body, err := io.ReadAll(r) + if err != nil { + return propfind{}, http.StatusBadRequest, err + } + if len(body) == 0 { + // An empty body means to propfind allprop. + // http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND + return propfind{Allprop: new(struct{})}, 0, nil + } + if hasEmptyNamespacePrefix(body) { + return propfind{}, http.StatusBadRequest, errInvalidPropfind + } + if err = ixml.NewDecoder(bytes.NewReader(body)).Decode(&pf); err != nil { if err == io.EOF { - if c.n == 0 { - // An empty body means to propfind allprop. - // http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND - return propfind{Allprop: new(struct{})}, 0, nil - } err = errInvalidPropfind } return propfind{}, http.StatusBadRequest, err } - if pf.Allprop == nil && pf.Include != nil { return propfind{}, http.StatusBadRequest, errInvalidPropfind } @@ -204,6 +211,33 @@ func readPropfind(r io.Reader) (pf propfind, status int, err error) { return pf, 0, nil } +func hasEmptyNamespacePrefix(body []byte) bool { + for offset := 0; ; { + i := bytes.Index(body[offset:], []byte("xmlns:")) + if i < 0 { + return false + } + i += offset + len("xmlns:") + j := i + for j < len(body) && body[j] != '=' && body[j] != '>' && body[j] != '/' && body[j] != ' ' && body[j] != '\t' && body[j] != '\n' && body[j] != '\r' { + j++ + } + for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') { + j++ + } + if j < len(body) && body[j] == '=' { + j++ + for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') { + j++ + } + if j+1 < len(body) && (body[j] == '\'' || body[j] == '"') && body[j+1] == body[j] { + return true + } + } + offset = i + } +} + // Property represents a single DAV resource property as defined in RFC 4918. // See http://www.webdav.org/specs/rfc4918.html#data.model.for.resource.properties type Property struct { From 900f6ebf6506037097811d188dd273796b14dde8 Mon Sep 17 00:00:00 2001 From: Intro Date: Tue, 4 Aug 2026 20:55:34 +0800 Subject: [PATCH 4/5] fix(fs): fall back to transfer task for same-dir copy Signed-off-by: Lythen --- internal/fs/copy_move.go | 26 ++++++++++++++------------ internal/op/fs.go | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/internal/fs/copy_move.go b/internal/fs/copy_move.go index 92ad24fe3..3048fd38a 100644 --- a/internal/fs/copy_move.go +++ b/internal/fs/copy_move.go @@ -114,19 +114,21 @@ func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath, ds if utils.IsBool(skipHook...) { ctx = context.WithValue(ctx, conf.SkipHookKey, struct{}{}) } - // A named copy cannot use the driver's destination-name-independent Copy - // operation. Fall back to the transfer task so the target name is kept. - if dstName == "" { - if taskType == copy || taskType == merge { - err = op.Copy(ctx, srcStorage, srcObjActualPath, dstDirActualPath) - if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { - return nil, err - } - } else { - err = op.Move(ctx, srcStorage, srcObjActualPath, dstDirActualPath) - if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { - return nil, err + if taskType == copy || taskType == merge { + err = op.Copy(ctx, srcStorage, srcObjActualPath, dstDirActualPath) + if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { + if err == nil && dstName != "" { + srcObjName := stdpath.Base(srcObjActualPath) + if srcObjName != dstName { + err = op.Rename(ctx, srcStorage, stdpath.Join(dstDirActualPath, srcObjName), dstName) + } } + return nil, err + } + } else { + err = op.Move(ctx, srcStorage, srcObjActualPath, dstDirActualPath) + if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { + return nil, err } } } diff --git a/internal/op/fs.go b/internal/op/fs.go index f82a3ca8f..90c1545bf 100644 --- a/internal/op/fs.go +++ b/internal/op/fs.go @@ -514,7 +514,7 @@ func Copy(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string srcPath = utils.FixAndCleanPath(srcPath) dstDirPath = utils.FixAndCleanPath(dstDirPath) if dstDirPath == stdpath.Dir(srcPath) { - return errors.New("copy in place") + return errors.WithStack(errs.NotImplement) } srcRawObj, err := Get(ctx, storage, srcPath, true) if err != nil { From 946d872da4f2dbb7e6b92618dfcb1492331b2703 Mon Sep 17 00:00:00 2001 From: Intro Date: Tue, 4 Aug 2026 22:16:43 +0800 Subject: [PATCH 5/5] fix(webdav): escape LIKE patterns, check dstDir is dir, bound PROPFIND body Signed-off-by: Lythen --- server/webdav/file.go | 8 ++++++-- server/webdav/prop.go | 11 +++++++---- server/webdav/xml.go | 11 +++++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/server/webdav/file.go b/server/webdav/file.go index e647be817..4d2d5a25e 100644 --- a/server/webdav/file.go +++ b/server/webdav/file.go @@ -55,11 +55,13 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) { return http.StatusForbidden, nil } - if _, err = fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil { + if dstDirInfo, err := fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil { if errs.IsObjectNotFound(err) { return http.StatusConflict, err } return http.StatusMethodNotAllowed, err + } else if !dstDirInfo.IsDir() { + return http.StatusConflict, nil } dstExisted := false if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil { @@ -119,11 +121,13 @@ func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int if !common.CanWrite(user, dstMeta, dstDir) { return http.StatusForbidden, nil } - if _, err = fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil { + if dstDirInfo, err := fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil { if errs.IsObjectNotFound(err) { return http.StatusConflict, err } return http.StatusMethodNotAllowed, err + } else if !dstDirInfo.IsDir() { + return http.StatusConflict, nil } dstExisted := false if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil { diff --git a/server/webdav/prop.go b/server/webdav/prop.go index 66e09b88d..886f120c7 100644 --- a/server/webdav/prop.go +++ b/server/webdav/prop.go @@ -351,16 +351,19 @@ func moveDeadProps(src, dst string) error { if database == nil { return errors.New("webdav property database is not initialized") } + escapedSrc := strings.ReplaceAll(strings.ReplaceAll(src, "%", "\\%"), "_", "\\_") + escapedDst := strings.ReplaceAll(strings.ReplaceAll(dst, "%", "\\%"), "_", "\\_") return database.Transaction(func(tx *gorm.DB) error { + // Clear destination subtree for overwrite MOVE. + if err := tx.Where("path = ? OR path LIKE ? ESCAPE '\\'", dst, escapedDst+"/%").Delete(&model.WebDAVProperty{}).Error; err != nil { + return err + } var rows []model.WebDAVProperty - if err := tx.Where("path = ? OR path LIKE ?", src, src+"/%").Find(&rows).Error; err != nil { + if err := tx.Where("path = ? OR path LIKE ? ESCAPE '\\'", src, escapedSrc+"/%").Find(&rows).Error; err != nil { return err } for _, row := range rows { newPath := dst + strings.TrimPrefix(row.Path, src) - if err := tx.Where("path = ? AND namespace = ? AND name = ?", newPath, row.Namespace, row.Name).Delete(&model.WebDAVProperty{}).Error; err != nil { - return err - } copy := row copy.ID = 0 copy.Path = newPath diff --git a/server/webdav/xml.go b/server/webdav/xml.go index ee649bead..d87eb863c 100644 --- a/server/webdav/xml.go +++ b/server/webdav/xml.go @@ -178,13 +178,20 @@ type propfind struct { } func readPropfind(r io.Reader) (pf propfind, status int, err error) { - body, err := io.ReadAll(r) + // 64KB is more than enough for a well-formed PROPFIND body. + const maxBody = 64 << 10 + body, err := io.ReadAll(io.LimitReader(r, maxBody)) if err != nil { return propfind{}, http.StatusBadRequest, err } + // If the limit was reached, the body was too large. + if len(body) >= maxBody { + // Drain any remaining bytes so the connection stays usable. + _, _ = io.Copy(io.Discard, r) + return propfind{}, http.StatusRequestEntityTooLarge, nil + } if len(body) == 0 { // An empty body means to propfind allprop. - // http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND return propfind{Allprop: new(struct{})}, 0, nil } if hasEmptyNamespacePrefix(body) {