Repository: OpenListTeam/OpenList

Issue:
[Feature] mac系统需要过滤掉apple double文件（._*）
### 请确认以下事项

- [x] 我已确认阅读并同意 [AGPL-3.0 第15条](https://www.gnu.org/licenses/agpl-3.0.txt#:~:text=15.%20Disclaimer%20of%20Warranty.) 。
本程序不提供任何明示或暗示的担保，使用风险由您自行承担。

- [x] 我已确认阅读并同意 [AGPL-3.0 第16条](https://www.gnu.org/licenses/agpl-3.0.txt#:~:text=16.%20Limitation%20of%20Liability.) 。
无论何种情况，版权持有人或其他分发者均不对使用本程序所造成的任何损失承担责任。

- [x] 我确认我的描述清晰，语法礼貌，能帮助开发者快速定位问题，并符合社区规则。

- [x] 我已确认阅读了[OpenList文档](https://doc.oplist.org)。

- [x] 我已确认没有重复的问题或讨论。

- [x] 我认为此问题必须由`OpenList`处理，而非第三方。

- [x] 我已确认此功能尚未被实现。

- [x] 我已确认此功能是合理的，且有普遍需求，并非我个人需要。


### 需求描述

mac系统做磁盘映射后，copy文件，所有上传的文件都会在网盘上多出一个._*文件名，而通过其本身的网盘app就不会，需要上传过滤功能

### 实现思路

钩子+配置文件

### 附加信息

_No response_

Discussion:
@github-actions: 感谢您联系OpenList。我们会尽快回复您。
Thanks for contacting OpenList. We will reply to you as soon as possible.

@jyxjjj: 不认为默认隐藏是合理的，可能有正常文件会用这个方式命名。Who knows.
我们提供了隐藏文件的设置 `/@manage/settings/global` 访问此前端页面进行设置。@jason-168: 首先感谢你的回复，不是隐藏文件，而是会把这些apple double文件上传到网盘上去，有专业驱动器或第三方软件会做这个过滤，
，如dotcleaner、blueharvest等，再如git软件的上传时就能自定义过滤，这个我认为是必要的，希望团队能再认真考虑一下

> 不认为默认隐藏是合理的，可能有正常文件会用这个方式命名。Who knows. 我们提供了隐藏文件的设置 `/@manage/settings/global` 访问此前端页面进行设置。

@jason-168: <img width="854" height="274" alt="Image" src="https://github.com/user-attachments/assets/fb123b5d-383f-49e6-a5cc-84c310009327" />
如上图所示，上传3个文件，多出1倍的这种文件，我不觉得是种好的用户体验@jason-168: 如上图所示，多出1倍的这种文件，我不觉得是种好的用户体验@jyxjjj: > mac系统做磁盘映射后，copy文件

还是不认为合理，因为这些文件本应该直接删除的。

但是可以给你重新打开等待其他开发者审核。

我的建议是直接删掉，我一直都有加函数自动删。

https://github.com/jyxjjj/jyxjjj/blob/UserConfigs/zshrc.d/31.functions-macos.sh#L7

```zsh
sudo find / -name '._*' -delete 2>/dev/null &
```

即使是这样也不会对macOS造成任何影响。

很久以前看到的也不知道是Apple论坛还是哪里原话是

> 不能因为Apple到处拉屎就让别的开发者解决@qianbinbin: git 据我所知默认是不忽略 ._*、.DS_Store 文件的，你可能使用了 .gitignore 或者 GUI 软件的默认设置？

个人认为可以参考 syncthing 的做法，也就是使用类似 .gitignore 的配置文件：<https://docs.syncthing.net/users/ignoring.html#ignoring-files> 这样其他平台也可以按需求忽略垃圾文件@jason-168: > git 据我所知默认是不忽略 ._*、.DS_Store 文件的，你可能使用了 .gitignore 或者 GUI 软件的默认设置？
> 
> 个人认为可以参考 syncthing 的做法，也就是使用类似 .gitignore 的配置文件：https://docs.syncthing.net/users/ignoring.html#ignoring-files 这样其他平台也可以按需求忽略垃圾文件

是的，就是提供一个像git的 .gitignore或syncthing 的,stignore功能，让用户可以自由选择@PIKACHUIM: 我觉得可以设置一个开关，启用后默认忽略
不过这个是苹果的史山，最好不由openlist解决

Solution patch:
diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go
index 58b6b568..787c73dc 100644
--- a/internal/bootstrap/data/setting.go
+++ b/internal/bootstrap/data/setting.go
@@ -177,6 +177,7 @@ func InitialSettings() []model.SettingItem {
 		{Key: conf.ShareArchivePreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC},
 		{Key: conf.ShareForceProxy, Value: "true", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE},
 		{Key: conf.ShareSummaryContent, Value: "@{{creator}} shared {{#each files}}{{#if @first}}\"{{filename this}}\"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}", Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PUBLIC},
+		{Key: conf.IgnoreSystemFiles, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE, Help: `When enabled, ignores common system files during upload (.DS_Store, desktop.ini, Thumbs.db, and files starting with ._)`},
 
 		// single settings
 		{Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},
diff --git a/internal/conf/const.go b/internal/conf/const.go
index a0b1a157..d39591dd 100644
--- a/internal/conf/const.go
+++ b/internal/conf/const.go
@@ -56,6 +56,7 @@ const (
 	ShareArchivePreview     = "share_archive_preview"
 	ShareForceProxy         = "share_force_proxy"
 	ShareSummaryContent     = "share_summary_content"
+	IgnoreSystemFiles       = "ignore_system_files"
 
 	// index
 	SearchIndex     = "search_index"
diff --git a/internal/errs/object.go b/internal/errs/object.go
index bddc78ab..ab78f314 100644
--- a/internal/errs/object.go
+++ b/internal/errs/object.go
@@ -11,6 +11,7 @@ var (
 	ObjectAlreadyExists = errors.New("object already exists")
 	NotFolder           = errors.New("not a folder")
 	NotFile             = errors.New("not a file")
+	IgnoredSystemFile   = errors.New("system file upload ignored")
 )
 
 func IsObjectNotFound(err error) bool {
diff --git a/internal/op/fs.go b/internal/op/fs.go
index 98df7039..126fa08c 100644
--- a/internal/op/fs.go
+++ b/internal/op/fs.go
@@ -173,10 +173,10 @@ func Link(ctx context.Context, storage driver.Driver, path string, args model.Li
 		mode = storage.(driver.LinkCacheModeResolver).ResolveLinkCacheMode(path)
 	}
 	typeKey := args.Type
-	if mode&driver.LinkCacheIP == 1 {
+	if mode&driver.LinkCacheIP == driver.LinkCacheIP {
 		typeKey += "/" + args.IP
 	}
-	if mode&driver.LinkCacheUA == 1 {
+	if mode&driver.LinkCacheUA == driver.LinkCacheUA {
 		typeKey += "/" + args.Header.Get("User-Agent")
 	}
 	key := Key(storage, path)
diff --git a/pkg/utils/file.go b/pkg/utils/file.go
index 1f819edf..dcfd51a2 100644
--- a/pkg/utils/file.go
+++ b/pkg/utils/file.go
@@ -185,3 +185,20 @@ const (
 	GB
 	TB
 )
+
+// IsSystemFile checks if a filename is a common system file that should be ignored
+// Returns true for files like .DS_Store, desktop.ini, Thumbs.db, and Apple Double files (._*)
+func IsSystemFile(filename string) bool {
+	// Common system files
+	switch filename {
+	case ".DS_Store", "desktop.ini", "Thumbs.db":
+		return true
+	}
+	
+	// Apple Double files (._*)
+	if strings.HasPrefix(filename, "._") {
+		return true
+	}
+	
+	return false
+}
diff --git a/server/ftp/fsup.go b/server/ftp/fsup.go
index 34fb36c6..c549a194 100644
--- a/server/ftp/fsup.go
+++ b/server/ftp/fsup.go
@@ -15,7 +15,9 @@ import (
 	"github.com/OpenListTeam/OpenList/v4/internal/fs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
 	"github.com/OpenListTeam/OpenList/v4/internal/op"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
+	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
 	"github.com/OpenListTeam/OpenList/v4/server/common"
 	ftpserver "github.com/fclairamb/ftpserverlib"
 	"github.com/pkg/errors"
@@ -49,6 +51,11 @@ func OpenUpload(ctx context.Context, path string, trunc bool) (*FileUploadProxy,
 	if err != nil {
 		return nil, err
 	}
+	// Check if system file should be ignored
+	_, name := stdpath.Split(path)
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(name) {
+		return nil, errs.IgnoredSystemFile
+	}
 	tmpFile, err := os.CreateTemp(conf.Conf.TempDir, "file-*")
 	if err != nil {
 		return nil, err
@@ -150,6 +157,11 @@ func OpenUploadWithLength(ctx context.Context, path string, trunc bool, length i
 	if err != nil {
 		return nil, err
 	}
+	// Check if system file should be ignored
+	_, name := stdpath.Split(path)
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(name) {
+		return nil, errs.IgnoredSystemFile
+	}
 	if trunc {
 		_ = fs.Remove(ctx, path)
 	}
diff --git a/server/handles/fsup.go b/server/handles/fsup.go
index 71d9dbae..2da65a16 100644
--- a/server/handles/fsup.go
+++ b/server/handles/fsup.go
@@ -8,8 +8,10 @@ import (
 	"time"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/conf"
+	"github.com/OpenListTeam/OpenList/v4/internal/errs"
 	"github.com/OpenListTeam/OpenList/v4/internal/fs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 	"github.com/OpenListTeam/OpenList/v4/internal/task"
 	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
@@ -28,6 +30,14 @@ func getLastModified(c *gin.Context) time.Time {
 	return lastModified
 }
 
+// shouldIgnoreSystemFile checks if the filename should be ignored based on settings
+func shouldIgnoreSystemFile(filename string) bool {
+	if setting.GetBool(conf.IgnoreSystemFiles) {
+		return utils.IsSystemFile(filename)
+	}
+	return false
+}
+
 func FsStream(c *gin.Context) {
 	defer func() {
 		if n, _ := io.ReadFull(c.Request.Body, []byte{0}); n == 1 {
@@ -56,6 +66,11 @@ func FsStream(c *gin.Context) {
 		}
 	}
 	dir, name := stdpath.Split(path)
+	// Check if system file should be ignored
+	if shouldIgnoreSystemFile(name) {
+		common.ErrorStrResp(c, errs.IgnoredSystemFile.Error(), 403)
+		return
+	}
 	// 如果请求头 Content-Length 和 X-File-Size 都没有，则 size=-1，表示未知大小的流式上传
 	size := c.Request.ContentLength
 	if size < 0 {
@@ -160,6 +175,11 @@ func FsForm(c *gin.Context) {
 	}
 	defer f.Close()
 	dir, name := stdpath.Split(path)
+	// Check if system file should be ignored
+	if shouldIgnoreSystemFile(name) {
+		common.ErrorStrResp(c, errs.IgnoredSystemFile.Error(), 403)
+		return
+	}
 	h := make(map[*utils.HashType]string)
 	if md5 := c.GetHeader("X-File-Md5"); md5 != "" {
 		h[utils.MD5] = md5
diff --git a/server/s3/backend.go b/server/s3/backend.go
index ac398699..c0f7aedd 100644
--- a/server/s3/backend.go
+++ b/server/s3/backend.go
@@ -19,6 +19,7 @@ import (
 	"github.com/OpenListTeam/OpenList/v4/internal/fs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
 	"github.com/OpenListTeam/OpenList/v4/internal/op"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 	"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
 	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
@@ -286,6 +287,10 @@ func (b *s3Backend) PutObject(
 		Modified: ti,
 		Ctime:    time.Now(),
 	}
+	// Check if system file should be ignored
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) {
+		return result, errs.IgnoredSystemFile
+	}
 	stream := &stream.FileStream{
 		Obj:      &obj,
 		Reader:   input,
diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go
index 4612665b..504c5fc1 100644
--- a/server/webdav/webdav.go
+++ b/server/webdav/webdav.go
@@ -20,6 +20,7 @@ import (
 
 	"github.com/OpenListTeam/OpenList/v4/internal/conf"
 	"github.com/OpenListTeam/OpenList/v4/internal/net"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/errs"
@@ -358,6 +359,10 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int,
 		Modified: h.getModTime(r),
 		Ctime:    h.getCreateTime(r),
 	}
+	// Check if system file should be ignored
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) {
+		return http.StatusForbidden, errs.IgnoredSystemFile
+	}
 	fsStream := &stream.FileStream{
 		Obj:      &obj,
 		Reader:   r.Body,



Test patch:
diff --git a/pkg/utils/file_test.go b/pkg/utils/file_test.go
new file mode 100644
index 00000000..d7a7262f
--- /dev/null
+++ b/pkg/utils/file_test.go
@@ -0,0 +1,42 @@
+package utils
+
+import (
+	"testing"
+)
+
+func TestIsSystemFile(t *testing.T) {
+	testCases := []struct {
+		filename string
+		expected bool
+	}{
+		// System files that should be filtered
+		{".DS_Store", true},
+		{"desktop.ini", true},
+		{"Thumbs.db", true},
+		{"._test.txt", true},
+		{"._", true},
+		{"._somefile", true},
+		{"._folder_name", true},
+		
+		// Regular files that should not be filtered
+		{"test.txt", false},
+		{"file.pdf", false},
+		{"document.docx", false},
+		{".gitignore", false},
+		{".env", false},
+		{"_underscore.txt", false},
+		{"normal_file.txt", false},
+		{"", false},
+		{".hidden", false},
+		{"..special", false},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.filename, func(t *testing.T) {
+			result := IsSystemFile(tc.filename)
+			if result != tc.expected {
+				t.Errorf("IsSystemFile(%q) = %v, want %v", tc.filename, result, tc.expected)
+			}
+		})
+	}
+}