From d15a452e6f9bd8a6be870fa986eddc9024a5bd98 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 22:01:46 +0200 Subject: [PATCH] feat(policy): opt-in native file name characters per policy (#3065) Uploads/rename rejected :*?"<>| unconditionally since the strict set protects clients mounting on Windows. Add a per-policy allow_native_name toggle: when enabled, Windows-only illegal characters are accepted in file names; path separators and dot-names stay illegal regardless. Folder creation keeps the strict set since its governing policy is ambiguous. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- frontend/public/locales/en-US/dashboard.json | 2 ++ frontend/public/locales/zh-CN/dashboard.json | 2 ++ frontend/src/api/dashboard.ts | 1 + .../FormSections/StorageAndUploadSection.tsx | 21 +++++++++++++++ inventory/types/types.go | 4 +++ pkg/filemanager/fs/dbfs/manage.go | 16 ++++++----- pkg/filemanager/fs/dbfs/validator.go | 15 ++++++++--- pkg/filemanager/fs/dbfs/validator_test.go | 27 +++++++++++++++++++ 8 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 pkg/filemanager/fs/dbfs/validator_test.go diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 01442d79..377e0c48 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1022,6 +1022,8 @@ "blacklist": "Deny", "fileNameRegex": "File name regex rules", "fileNameRegexDes": "Regular expression to match file names, leave blank for no restriction.", + "allowNativeName": "Allow native file name characters", + "allowNativeNameDes": "Permit characters only illegal on Windows filesystems (: * ? \" < > |) in file names. May break clients mounting on Windows. Path separators remain forbidden.", "chunkSizeDes": "Specify the chunk size for chunked uploads. A value of 0 means no chunked uploads are used, but the maximum upload size may be limited by the web server.", "chunkSizeDesSuffix": "{{prefix}} With chunked upload, the files uploaded by users will be sliced into chunks and uploaded to the storage side one by one. After the upload is interrupted, users can choose to continue uploading from the last uploaded chunk.", "chunkSize": "Chunk size", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index e983d6cc..7facd465 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1022,6 +1022,8 @@ "blacklist": "拒绝", "fileNameRegex": "文件名正则规则", "fileNameRegexDes": "用于匹配文件名的正则表达式,留空表示无限制。", + "allowNativeName": "允许原生文件名字符", + "allowNativeNameDes": "允许文件名包含仅在 Windows 文件系统中非法的字符(: * ? \" < > |)。可能影响 Windows 客户端挂载使用。路径分隔符仍然禁止。", "chunkSizeDes": "请指定分片上传时的分片大小,填写为 0 表示不使用分片上传,但最大上传大小可能受限于 Web 服务器。", "chunkSizeDesSuffix": "{{prefix}}通过分片上传,用户上传的文件将会被切分成分片逐个上传到存储端,当上传中断后,用户可以选择从上次上传的分片后继续开始上传。", "chunkSize": "上传分片大小", diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index e90303e7..de3c73d1 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -217,6 +217,7 @@ export interface PolicySetting { is_file_type_deny_list?: boolean; file_regexp?: string; is_name_regexp_deny_list?: boolean; + allow_native_name?: boolean; od_redirect?: string; custom_proxy?: boolean; proxy_server?: string; diff --git a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx index 67068711..bcbc0e81 100644 --- a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx +++ b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx @@ -170,6 +170,16 @@ const StorageAndUploadSection = () => { [setPolicy], ); + const onNativeNameChange = useCallback( + (e: React.ChangeEvent) => { + setPolicy((p: StoragePolicy) => ({ + ...p, + settings: { ...p.settings, allow_native_name: e.target.checked ? true : undefined }, + })); + }, + [setPolicy], + ); + const onChunkSizeChange = useCallback( (size: number) => { setPolicy((p: StoragePolicy) => ({ @@ -330,6 +340,17 @@ const StorageAndUploadSection = () => { {t("policy.fileNameRegexDes")} + + + + } + label={t("policy.allowNativeName")} + /> + {t("policy.allowNativeNameDes")} + + {values.type !== PolicyType.upyun && ( diff --git a/inventory/types/types.go b/inventory/types/types.go index acc6a6bb..f95d789d 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -50,6 +50,10 @@ type ( NameRegexp string `json:"file_regexp,omitempty"` // IsNameRegexp Whether above regexp is a deny list. IsNameRegexpDenyList bool `json:"is_name_regexp_deny_list,omitempty"` + // AllowNativeName permits characters only illegal on Windows + // filesystems (:*?"<>|) in file names. Path separators and dot-names + // stay illegal (#3065). + AllowNativeName bool `json:"allow_native_name,omitempty"` // OauthRedirect Oauth 重定向地址 OauthRedirect string `json:"od_redirect,omitempty"` // CustomProxy whether to use custom-proxy to get file content diff --git a/pkg/filemanager/fs/dbfs/manage.go b/pkg/filemanager/fs/dbfs/manage.go index f47a4900..8a74cd32 100644 --- a/pkg/filemanager/fs/dbfs/manage.go +++ b/pkg/filemanager/fs/dbfs/manage.go @@ -83,8 +83,9 @@ func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType return nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("parent must be a valid folder")) } - // Validate object name - if err := validateFileName(desired[i]); err != nil { + // Validate object name — folder segments keep the strict rule set + // since their governing policy is ambiguous. + if err := validateFileName(desired[i], nil); err != nil { return nil, fs.ErrIllegalObjectName.WithError(err) } @@ -174,16 +175,17 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil return nil, nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("cannot modify root folder")) } + policy, err := f.getPreferredPolicy(ctx, target) + if err != nil { + return nil, nil, err + } + // Validate new name - if err := validateFileName(newName); err != nil { + if err := validateFileName(newName, policy); err != nil { return nil, nil, fs.ErrIllegalObjectName.WithError(err) } // If target is a file, validate file extension - policy, err := f.getPreferredPolicy(ctx, target) - if err != nil { - return nil, nil, err - } if target.Type() == types.FileTypeFile { if err := validateExtension(newName, policy); err != nil { diff --git a/pkg/filemanager/fs/dbfs/validator.go b/pkg/filemanager/fs/dbfs/validator.go index 34fde54d..f38b57bf 100644 --- a/pkg/filemanager/fs/dbfs/validator.go +++ b/pkg/filemanager/fs/dbfs/validator.go @@ -13,13 +13,20 @@ import ( const MaxFileNameLength = 256 -// validateFileName validates the file name. -func validateFileName(name string) error { +// validateFileName validates the file name. When the policy allows native +// names, characters only forbidden on Windows filesystems are accepted; +// path separators and dot-names stay illegal regardless (#3065). +func validateFileName(name string, policy *ent.StoragePolicy) error { if len(name) >= MaxFileNameLength || len(name) == 0 { return fmt.Errorf("length of name must be between 1 and 255") } - if strings.ContainsAny(name, "\\/:*?\"<>|") { + if strings.ContainsAny(name, "\\/") { + return fmt.Errorf("name contains path separators") + } + + nativeAllowed := policy != nil && policy.Settings.AllowNativeName + if !nativeAllowed && strings.ContainsAny(name, ":*?\"<>|") { return fmt.Errorf("name contains illegal characters") } @@ -74,7 +81,7 @@ func validateFileSize(size int64, policy *ent.StoragePolicy) error { // validateNewFile validates the upload request. func validateNewFile(fileName string, size int64, policy *ent.StoragePolicy) error { - if err := validateFileName(fileName); err != nil { + if err := validateFileName(fileName, policy); err != nil { return fs.ErrIllegalObjectName.WithError(err) } diff --git a/pkg/filemanager/fs/dbfs/validator_test.go b/pkg/filemanager/fs/dbfs/validator_test.go new file mode 100644 index 00000000..3b381335 --- /dev/null +++ b/pkg/filemanager/fs/dbfs/validator_test.go @@ -0,0 +1,27 @@ +package dbfs + +import ( + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/stretchr/testify/require" +) + +func TestValidateFileNameNativeChars(t *testing.T) { + native := &ent.StoragePolicy{Settings: &types.PolicySetting{AllowNativeName: true}} + + // Windows-illegal characters accepted only under allow_native_name. + for _, name := range []string{"a:b.txt", "a.txt", "a|b?.txt", `say "hi".txt`} { + require.Error(t, validateFileName(name, nil)) + require.Error(t, validateFileName(name, &ent.StoragePolicy{Settings: &types.PolicySetting{}})) + require.NoError(t, validateFileName(name, native)) + } + + // Separators and dot-names stay illegal regardless. + for _, name := range []string{"a/b", `a\b`, ".", "..", "", string(make([]byte, 300))} { + require.Error(t, validateFileName(name, native)) + } + + require.NoError(t, validateFileName("normal file.txt", nil)) +}