diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 5d8aa001..2bfc7485 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1264,6 +1264,8 @@ "allowWabDAVDes": "If disabled, users cannot connect to the storage via the WebDAV protocol", "allowWabDAVProxy": "WebDAV Proxy", "allowWabDAVProxyDes": "If enabled, users can configure the WebDAV to be proxied by Cloudreve when downloading files.", + "wabDAVReadOnly": "WebDAV read-only", + "wabDAVReadOnlyDes": "If enabled, WebDAV mounts for users in this group become read-only — uploads, deletes, moves and other write methods are rejected.", "compressTask": "Compression/Decompression tasks", "compressTaskDes": "If enabled, users can do compression/decompression for files online.", "compressSize": "Maximum file size to be compressed", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index c0d0f2ba..960e4612 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1264,6 +1264,8 @@ "allowWabDAVDes": "关闭后,用户无法通过 WebDAV 协议连接至网盘。", "allowWabDAVProxy": "WebDAV 代理", "allowWabDAVProxyDes": "启用后,用户可以配置 WebDAV 下载经由 Cloudreve 中转。", + "wabDAVReadOnly": "WebDAV 只读", + "wabDAVReadOnlyDes": "启用后,该用户组的 WebDAV 挂载变为只读——上传、删除、移动及其他写操作将被拒绝。", "compressTask": "压缩/解压缩任务", "compressTaskDes": "开启后,用户可以在线压缩/解压缩文件。", "compressSize": "待压缩文件最大大小", diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index ab337f45..5237f364 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -93,6 +93,7 @@ export const GroupPermission = { redirected_source: 11, advance_delete: 12, unique_direct_link: 17, + webdav_read_only: 18, }; export interface UserSettings { diff --git a/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx b/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx index 4e3f8914..5c6664bc 100644 --- a/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx +++ b/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx @@ -63,6 +63,16 @@ const FileManagementSection = () => { [setGroup], ); + const onWabDAVReadOnlyChange = useCallback( + (e: React.ChangeEvent) => { + setGroup((p: GroupEnt) => ({ + ...p, + permissions: new Boolset(p.permissions).set(GroupPermission.webdav_read_only, e.target.checked).toString(), + })); + }, + [setGroup], + ); + const onAllowCompressTaskChange = useCallback( (e: React.ChangeEvent) => { setGroup((p: GroupEnt) => ({ @@ -186,6 +196,20 @@ const FileManagementSection = () => { {t("group.allowWabDAVProxyDes")} + + + + } + label={t("group.wabDAVReadOnly")} + /> + {t("group.wabDAVReadOnlyDes")} + + diff --git a/inventory/types/types.go b/inventory/types/types.go index d6162ceb..febd32a6 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -269,6 +269,10 @@ const ( GroupPermissionSetExplicitUser_placeholder GroupPermissionIgnoreFileOwnership // not used GroupPermissionUniqueRedirectDirectLink + // GroupPermissionWebDAVReadOnly restricts the group's WebDAV access to + // read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK, + // PROPPATCH) are rejected even if the group's WebDAV access is enabled. + GroupPermissionWebDAVReadOnly ) const ( diff --git a/middleware/auth.go b/middleware/auth.go index 0653faeb..35c87e94 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -161,9 +161,10 @@ func WebDAVAuth() gin.HandlerFunc { } // 检查是否只读 - if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) { + if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) || + group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) { switch c.Request.Method { - case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK": + case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "PROPPATCH": c.Status(http.StatusForbidden) c.Abort() return diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go index 78ec8027..dc9233c0 100644 --- a/pkg/webdav/webdav.go +++ b/pkg/webdav/webdav.go @@ -190,7 +190,29 @@ func confirmLock(c *gin.Context, fm manager.FileManager, user *ent.User, srcAnc, return nil, nil, http.StatusPreconditionFailed, ErrLocked } +// davWriteForbidden reports whether the user is confined to read-only WebDAV +// access, either by a group-level restriction or a read-only dav account. +// Read-only access allows GET/HEAD/OPTIONS/PROPFIND and rejects every +// state-changing method. +func davWriteForbidden(user *ent.User) bool { + if user == nil { + return false + } + if len(user.Edges.DavAccounts) > 0 && + user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) { + return true + } + if user.Edges.Group != nil && + user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) { + return true + } + return false +} + func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -227,6 +249,9 @@ func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status } func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -439,6 +464,12 @@ func handleRangedPut(ctx context.Context, c *gin.Context, user *ent.User, m mana return http.StatusInternalServerError, err } if !allReceived { + // If the session record vanished mid-upload, coverage can never + // complete — fail so the client retries rather than leaving a stuck + // placeholder. + if _, ok := kv.Get(manager.UploadSessionCachePrefix + sessionKey); !ok { + return http.StatusConflict, errors.New("upload session expired") + } return http.StatusCreated, nil } @@ -466,7 +497,7 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat if target, _, err := fm.SharedAddressTranslation(c, reqPath); err == nil { allow = allow[:1] read, update, del, create := true, true, true, true - if target.OwnerID() != user.ID { + if target.OwnerID() != user.ID || davWriteForbidden(user) { update = false del = false create = false @@ -475,7 +506,10 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat allow = append(allow, "DELETE", "MOVE") } if read { - allow = append(allow, "COPY", "PROPFIND") + if !davWriteForbidden(user) { + allow = append(allow, "COPY") + } + allow = append(allow, "PROPFIND") if target.Type() == types.FileTypeFile { allow = append(allow, "GET", "HEAD", "POST") } @@ -559,6 +593,9 @@ func handleUnlock(c *gin.Context, user *ent.User, fm manager.FileManager) (retSt } func handleLock(c *gin.Context, user *ent.User, fm manager.FileManager) (retStatus int, retErr error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } duration, err := parseTimeout(c.Request.Header.Get("Timeout")) if err != nil { return http.StatusBadRequest, err @@ -732,6 +769,9 @@ func handlePropfind(c *gin.Context, user *ent.User, fm manager.FileManager) (sta } func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -759,6 +799,9 @@ func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (statu } func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } hdr := c.Request.Header.Get("Destination") if hdr == "" { return http.StatusBadRequest, errInvalidDestination @@ -902,6 +945,9 @@ func performCopyMove( } func handleProppatch(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err diff --git a/pkg/webdav/webdav_test.go b/pkg/webdav/webdav_test.go index 8c3682c6..521573f0 100644 --- a/pkg/webdav/webdav_test.go +++ b/pkg/webdav/webdav_test.go @@ -6,6 +6,9 @@ import ( "net/http" "testing" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" ) @@ -156,3 +159,34 @@ func TestParseContentRange(t *testing.T) { } } } + +func TestDavWriteForbidden(t *testing.T) { + readOnlyAccount := &boolset.BooleanSet{} + boolset.Set(types.DavAccountReadOnly, true, readOnlyAccount) + readOnlyGroup := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionWebDAVReadOnly, true, readOnlyGroup) + + mkUser := func(group *boolset.BooleanSet, account *boolset.BooleanSet) *ent.User { + u := &ent.User{} + if group != nil { + u.SetGroup(&ent.Group{Permissions: group}) + } + if account != nil { + u.Edges.DavAccounts = []*ent.DavAccount{{Options: account}} + } + return u + } + + if !davWriteForbidden(mkUser(nil, readOnlyAccount)) { + t.Fatal("read-only dav account not blocked") + } + if !davWriteForbidden(mkUser(readOnlyGroup, &boolset.BooleanSet{})) { + t.Fatal("read-only group not blocked") + } + if davWriteForbidden(mkUser(&boolset.BooleanSet{}, &boolset.BooleanSet{})) { + t.Fatal("normal user blocked") + } + if davWriteForbidden(nil) { + t.Fatal("nil user blocked") + } +}