From 0944de2c0bdf066ff5cafbe36d7839195fb6bd74 Mon Sep 17 00:00:00 2001 From: Back Yu Date: Wed, 25 Mar 2026 16:35:34 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(feishu):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=E7=B4=A2=E5=BC=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现文件下载索引系统,记录下载文件的元数据 - 新增文件名冲突智能处理逻辑 - 添加文件哈希计算和校验功能 - 实现按年/月自动分类存储下载文件 - 增加索引文件原子写入和并发控制 ✅ test(feishu): 添加文件下载相关测试用例 - 测试文件名冲突处理逻辑 - 测试文件哈希计算功能 - 测试文件路径生成逻辑 - 测试索引文件加载和保存 - 测试索引更新和查询功能 🌐 i18n(feishu): 添加下载文件只读设置国际化支持 - 添加下载文件只读设置字段 - 更新中英文翻译文本 --- pkg/channels/feishu/feishu_64.go | 327 ++++++++++++- pkg/channels/feishu/feishu_64_test.go | 440 ++++++++++++++++++ pkg/config/config.go | 3 +- pkg/utils/media.go | 19 +- .../channels/channel-forms/feishu-form.tsx | 6 + web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + 7 files changed, 774 insertions(+), 25 deletions(-) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index cbbcf8e4f..5fff3632b 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -4,6 +4,8 @@ package feishu import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -14,6 +16,7 @@ import ( "strings" "sync" "sync/atomic" + "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -35,6 +38,26 @@ import ( // on this error, so we do it ourselves. const errCodeTenantTokenInvalid = 99991663 +const indexFileName = ".feishu_index.json" +const maxFilenameConflictRetries = 10000 // Maximum iterations to find unique filename +const readOnlyFileMode = 0o444 // Read-only file permission (user=r, group=r, other=r) + +// DirectoryIndex represents a per-directory index file. +type DirectoryIndex struct { + Version int `json:"version"` + Files []FileMeta `json:"files"` +} + +// FileMeta represents a single downloaded file in the directory index. +type FileMeta struct { + Filename string `json:"filename"` // Local filename + Hash string `json:"hash"` // SHA-256 hash + Size int64 `json:"size"` // File size in bytes + FileKey string `json:"file_key"` // Feishu file key (for re-downloading) + MessageID string `json:"message_id"` // Message ID + DownloadedAt time.Time `json:"downloaded_at"` // Download time +} + type FeishuChannel struct { *channels.BaseChannel config config.FeishuConfig @@ -45,8 +68,9 @@ type FeishuChannel struct { botOpenID atomic.Value // stores string; populated lazily for @mention detection - mu sync.Mutex - cancel context.CancelFunc + mu sync.Mutex + cancel context.CancelFunc + indexMu sync.Map // map[string]*sync.Mutex - one lock per directory for index updates } func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus, globalCfg *config.Config) (*FeishuChannel, error) { @@ -722,6 +746,180 @@ func (c *FeishuChannel) validateAndCreateDir(dir string) error { return fmt.Errorf("failed to access directory: %w", err) } +// loadDirectoryIndex loads the directory index file. +// Returns an empty index if the file doesn't exist or cannot be read. +func (c *FeishuChannel) loadDirectoryIndex(dirPath string) (*DirectoryIndex, error) { + indexPath := filepath.Join(dirPath, indexFileName) + + data, err := os.ReadFile(indexPath) + if err != nil { + if os.IsNotExist(err) { + // Index doesn't exist, return empty index + return &DirectoryIndex{Version: 1, Files: []FileMeta{}}, nil + } + return nil, err + } + + var index DirectoryIndex + if err := json.Unmarshal(data, &index); err != nil { + return nil, err + } + + return &index, nil +} + +// saveDirectoryIndex saves the directory index file. +func (c *FeishuChannel) saveDirectoryIndex(dirPath string, index *DirectoryIndex) error { + indexPath := filepath.Join(dirPath, indexFileName) + + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + + // Atomic write: write to temp file first, then rename + tmpPath := indexPath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + + return os.Rename(tmpPath, indexPath) +} + +// updateIndexWithFile updates the index, adding or updating file metadata. +// getIndexLock returns a mutex for the given directory path. +func (c *FeishuChannel) getIndexLock(dirPath string) *sync.Mutex { + lock, _ := c.indexMu.LoadOrStore(dirPath, &sync.Mutex{}) + return lock.(*sync.Mutex) +} + +func (c *FeishuChannel) updateIndexWithFile(dirPath string, meta FileMeta) error { + // Acquire lock for this directory to prevent concurrent index corruption + lock := c.getIndexLock(dirPath) + lock.Lock() + defer lock.Unlock() + + index, err := c.loadDirectoryIndex(dirPath) + if err != nil { + return err + } + + // Find if this file already exists in the index + found := false + for i, file := range index.Files { + if file.Filename == meta.Filename { + index.Files[i] = meta // Update + found = true + break + } + } + + if !found { + index.Files = append(index.Files, meta) // Add + } + + return c.saveDirectoryIndex(dirPath, index) +} + +// findFileByKeyInIndex searches for a file by file_key in the index. +// Returns the FileMeta if found, nil otherwise. +func (c *FeishuChannel) findFileByKeyInIndex(dirPath, fileKey string) (*FileMeta, error) { + index, err := c.loadDirectoryIndex(dirPath) + if err != nil { + return nil, err + } + + for _, file := range index.Files { + if file.FileKey == fileKey { + return &file, nil + } + } + + return nil, nil // Not found +} + +// generateFilePath generates a file path: {year}/{month}/{filename}. +func (c *FeishuChannel) generateFilePath(downloadDir, filename string) string { + now := time.Now() + yearMonth := now.Format("2006/01") // 2026/03 + monthPath := filepath.Join(downloadDir, yearMonth) + return filepath.Join(monthPath, utils.SanitizeFilename(filename)) +} + +// generateUniqueFilename intelligently handles filename conflicts. +// Returns: unique file path, whether existing file is reused, error. +func (c *FeishuChannel) generateUniqueFilename(basePath, tmpPath string) (string, bool, error) { + // Check if file exists + if _, err := os.Stat(basePath); os.IsNotExist(err) { + return basePath, false, nil // File doesn't exist, use it directly + } + + // File exists, check if it's the same file (hash + size) + existingHash, err := c.calculateFileHash(basePath) + if err == nil { + newHash, _ := c.calculateFileHash(tmpPath) + existingSize, _ := getFileSize(basePath) + newSize, _ := getFileSize(tmpPath) + + // hash and size match, reuse existing file + if existingHash == newHash && existingSize == newSize { + return basePath, true, nil + } + } + + // Not the same file, add serial number suffix + ext := filepath.Ext(basePath) + nameWithoutExt := strings.TrimSuffix(basePath, ext) + + for i := 1; i <= maxFilenameConflictRetries; i++ { + // Use -1, -2 format + newPath := fmt.Sprintf("%s-%d%s", nameWithoutExt, i, ext) + if _, err := os.Stat(newPath); os.IsNotExist(err) { + return newPath, false, nil + } + + // Check if new path has same content as temp file + if existingHash, err := c.calculateFileHash(newPath); err == nil { + newHash, _ := c.calculateFileHash(tmpPath) + if existingHash == newHash { + existingSize, _ := getFileSize(newPath) + newSize, _ := getFileSize(tmpPath) + if existingSize == newSize { + return newPath, true, nil + } + } + } + } + + // Should never happen, but return error to be safe + return "", false, fmt.Errorf("too many filename conflicts (>%d), could not generate unique filename", maxFilenameConflictRetries) +} + +// getFileSize gets the size of a file. +func getFileSize(filePath string) (int64, error) { + info, err := os.Stat(filePath) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// calculateFileHash calculates SHA-256 hash of a file and returns it as hex string. +func (c *FeishuChannel) calculateFileHash(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + // downloadResource downloads a message resource (image/file) from Feishu, // writes it to the configured media directory, and stores the reference in MediaStore. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. @@ -772,38 +970,124 @@ func (c *FeishuChannel) downloadResource( filename += fallbackExt } - // Determine download directory - mediaDir := c.resolveDownloadDir() - if mediaDir == "" { + // 1. Determine download directory + downloadDir := c.resolveDownloadDir() + if downloadDir == "" { // Fallback to TempDir - mediaDir = media.TempDir() + downloadDir = media.TempDir() } - logger.DebugCF("feishu", "Downloading resource to directory", map[string]any{ - "directory": mediaDir, - }) - ext := filepath.Ext(filename) - localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext)) - - out, err := os.Create(localPath) + // 2. Download to temporary file + tmpFile, err := os.CreateTemp("", "feishu_download_*") if err != nil { - logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{ + logger.ErrorCF("feishu", "Failed to create temporary file", map[string]any{ + "error": err.Error(), + }) + return "" + } + tmpPath := tmpFile.Name() + + // Write downloaded content to temp file + size, err := io.Copy(tmpFile, resp.File) + tmpFile.Close() + if err != nil { + os.Remove(tmpPath) + logger.ErrorCF("feishu", "Failed to write resource to temp file", map[string]any{ "error": err.Error(), }) return "" } - if _, copyErr := io.Copy(out, resp.File); copyErr != nil { - out.Close() - os.Remove(localPath) - logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ - "error": copyErr.Error(), + // 3. Generate target path: {year}/{month}/{filename} + basePath := c.generateFilePath(downloadDir, filename) + + // 4. Intelligently handle filename conflicts + targetPath, reused, err := c.generateUniqueFilename(basePath, tmpPath) + if err != nil { + os.Remove(tmpPath) + logger.ErrorCF("feishu", "Failed to generate unique filename", map[string]any{ + "error": err.Error(), }) return "" } - out.Close() - ref, err := store.Store(localPath, media.MediaMeta{ + if reused { + // Reuse existing file, delete temp file + os.Remove(tmpPath) + logger.InfoCF("feishu", "Reusing existing file (deduplication)", map[string]any{ + "existing_path": targetPath, + }) + + // Store the reference in MediaStore + ref, err := store.Store(targetPath, media.MediaMeta{ + Filename: filename, + Source: "feishu", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, + }, scope) + if err != nil { + logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{ + "file_key": fileKey, + "error": err.Error(), + }) + return "" + } + return ref + } + + // 5. Create directory and move file + monthDir := filepath.Dir(targetPath) + if err := os.MkdirAll(monthDir, 0o755); err != nil { + os.Remove(tmpPath) + logger.ErrorCF("feishu", "Failed to create month directory", map[string]any{ + "error": err.Error(), + }) + return "" + } + + if err := os.Rename(tmpPath, targetPath); err != nil { + os.Remove(tmpPath) + logger.ErrorCF("feishu", "Failed to move temp file to final location", map[string]any{ + "error": err.Error(), + }) + return "" + } + + // 6. Set file permissions (according to config) + if !c.config.DownloadReadonlyDisable { + // Set to read-only (prevent accidental deletion or modification) + if err := os.Chmod(targetPath, readOnlyFileMode); err != nil { + logger.WarnCF("feishu", "Failed to set file read-only", map[string]any{ + "path": targetPath, + "error": err.Error(), + }) + } + } + + // 7. Update index file + hash, _ := c.calculateFileHash(targetPath) + meta := FileMeta{ + Filename: filepath.Base(targetPath), + Hash: hash, + Size: size, + FileKey: fileKey, + MessageID: messageID, + DownloadedAt: time.Now(), + } + + if err := c.updateIndexWithFile(monthDir, meta); err != nil { + // Index update failed, log warning but don't affect download + logger.WarnCF("feishu", "Failed to update index file", map[string]any{ + "error": err.Error(), + }) + } + + logger.InfoCF("feishu", "Saved new file to download directory", map[string]any{ + "filename": filename, + "local_path": targetPath, + "hash": hash, + }) + + ref, err := store.Store(targetPath, media.MediaMeta{ Filename: filename, Source: "feishu", CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, @@ -813,7 +1097,6 @@ func (c *FeishuChannel) downloadResource( "file_key": fileKey, "error": err.Error(), }) - os.Remove(localPath) return "" } diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 8b66e95e1..b785fbdac 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,8 +3,13 @@ package feishu import ( + "encoding/json" + "fmt" "os" + "path/filepath" + "strings" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" @@ -495,3 +500,438 @@ func TestResolveDownloadDir(t *testing.T) { }) } } + +func TestGenerateUniqueFilename(t *testing.T) { + tests := []struct { + name string + baseName string + tmpContent string + setupFiles func(string) // 创建已存在的文件 + wantSuffix string // 期望的文件名后缀(空表示原名) + wantReused bool // 期望是否重用 + }{ + { + name: "returns base path when file doesn't exist", + baseName: "test.jpg", + tmpContent: "new content", + setupFiles: func(string) {}, + wantSuffix: "", + wantReused: false, + }, + { + name: "reuses file when hash and size match", + baseName: "test.jpg", + tmpContent: "same content", + setupFiles: func(dir string) { + _ = os.WriteFile(filepath.Join(dir, "test.jpg"), []byte("same content"), 0o644) + }, + wantSuffix: "", + wantReused: true, + }, + { + name: "adds suffix when file exists with different content", + baseName: "test.jpg", + tmpContent: "different content", + setupFiles: func(dir string) { + _ = os.WriteFile(filepath.Join(dir, "test.jpg"), []byte("old content"), 0o644) + }, + wantSuffix: "-1.jpg", + wantReused: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + basePath := filepath.Join(dir, tt.baseName) + tt.setupFiles(dir) + + // Create temporary file with test content + tmpFile, err := os.CreateTemp("", "test_*") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + if _, err := tmpFile.WriteString(tt.tmpContent); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + tmpFile.Close() + + channel := &FeishuChannel{} + gotPath, gotReused, err := channel.generateUniqueFilename(basePath, tmpFile.Name()) + + if err != nil { + t.Errorf("generateUniqueFilename() error = %v", err) + return + } + + // Check suffix + gotBase := filepath.Base(gotPath) + wantBase := tt.baseName + if tt.wantSuffix != "" { + ext := filepath.Ext(tt.baseName) + nameWithoutExt := strings.TrimSuffix(tt.baseName, ext) + wantBase = nameWithoutExt + tt.wantSuffix + } + + if gotBase != wantBase { + t.Errorf("generateUniqueFilename() filename = %q, want %q", gotBase, wantBase) + } + + if gotReused != tt.wantReused { + t.Errorf("generateUniqueFilename() reused = %v, want %v", gotReused, tt.wantReused) + } + + // Clean up + os.Remove(tmpFile.Name()) + }) + } +} + +func TestCalculateFileHash(t *testing.T) { + tests := []struct { + name string + content string + wantHash string + wantError bool + }{ + { + name: "calculates SHA-256 hash correctly", + content: "hello world", + wantHash: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", // SHA-256 of "hello world" + wantError: false, + }, + { + name: "handles empty content", + content: "", + wantHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // SHA-256 of empty string + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testFile := filepath.Join(t.TempDir(), "test.txt") + + if err := os.WriteFile(testFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + channel := &FeishuChannel{} + got, err := channel.calculateFileHash(testFile) + + if (err != nil) != tt.wantError { + t.Errorf("calculateFileHash() error = %v, wantError %v", err, tt.wantError) + return + } + + if got != tt.wantHash { + t.Errorf("calculateFileHash() = %q, want %q", got, tt.wantHash) + } + }) + } +} + +func TestGenerateFilePath(t *testing.T) { + tests := []struct { + name string + downloadDir string + filename string + wantPrefix string // 期望路径前缀(年/月部分会变化) + }{ + { + name: "generates correct path structure", + downloadDir: "/downloads", + filename: "report.pdf", + wantPrefix: "/downloads/", + }, + { + name: "sanitizes filename", + downloadDir: "/downloads", + filename: "report:name.pdf", + wantPrefix: "/downloads/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := &FeishuChannel{} + got := channel.generateFilePath(tt.downloadDir, tt.filename) + + if !filepath.IsAbs(got) { + t.Errorf("generateFilePath() returned relative path, want absolute") + } + + // Check path format: {download_dir}/{year}/{month}/{sanitized_filename} + if !strings.HasPrefix(got, tt.wantPrefix) { + t.Errorf("generateFilePath() = %q, want prefix %q", got, tt.wantPrefix) + } + + // Check that filename is sanitized + base := filepath.Base(got) + if strings.Contains(base, ":") || strings.Contains(base, "*") { + t.Errorf("generateFilePath() filename not sanitized: %q", base) + } + }) + } +} + +func TestLoadDirectoryIndex(t *testing.T) { + tests := []struct { + name string + setupIndex func(string) error + wantFileCount int + wantVersion int + }{ + { + name: "returns empty index when file doesn't exist", + setupIndex: func(string) error { return nil }, + wantFileCount: 0, + wantVersion: 1, + }, + { + name: "loads valid index file", + setupIndex: func(dir string) error { + index := DirectoryIndex{ + Version: 1, + Files: []FileMeta{ + { + Filename: "test.pdf", + Hash: "abc123", + Size: 1024, + FileKey: "key123", + MessageID: "msg123", + DownloadedAt: time.Now(), + }, + }, + } + data, err := json.Marshal(index) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, indexFileName), data, 0o644) + }, + wantFileCount: 1, + wantVersion: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + _ = tt.setupIndex(dir) + + channel := &FeishuChannel{} + index, err := channel.loadDirectoryIndex(dir) + + if err != nil { + t.Errorf("loadDirectoryIndex() error = %v", err) + return + } + + if len(index.Files) != tt.wantFileCount { + t.Errorf("loadDirectoryIndex() file count = %d, want %d", len(index.Files), tt.wantFileCount) + } + + if index.Version != tt.wantVersion { + t.Errorf("loadDirectoryIndex() version = %d, want %d", index.Version, tt.wantVersion) + } + }) + } +} + +func TestSaveDirectoryIndex(t *testing.T) { + dir := t.TempDir() + + index := &DirectoryIndex{ + Version: 1, + Files: []FileMeta{ + { + Filename: "test.pdf", + Hash: "abc123", + Size: 1024, + FileKey: "key123", + MessageID: "msg123", + DownloadedAt: time.Now(), + }, + }, + } + + channel := &FeishuChannel{} + err := channel.saveDirectoryIndex(dir, index) + + if err != nil { + t.Errorf("saveDirectoryIndex() error = %v", err) + return + } + + // Verify file was created + indexPath := filepath.Join(dir, indexFileName) + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + t.Errorf("saveDirectoryIndex() file not created") + return + } + + // Verify content + data, err := os.ReadFile(indexPath) + if err != nil { + t.Errorf("failed to read index file: %v", err) + return + } + + var loaded DirectoryIndex + if err := json.Unmarshal(data, &loaded); err != nil { + t.Errorf("failed to unmarshal index: %v", err) + return + } + + if len(loaded.Files) != 1 { + t.Errorf("saveDirectoryIndex() saved wrong number of files: got %d, want 1", len(loaded.Files)) + } +} + +func TestUpdateIndexWithFile(t *testing.T) { + tests := []struct { + name string + setup func(string) (*DirectoryIndex, error) + meta FileMeta + verify func(*DirectoryIndex) error + }{ + { + name: "adds new file to empty index", + setup: func(dir string) (*DirectoryIndex, error) { + return &DirectoryIndex{Version: 1, Files: []FileMeta{}}, nil + }, + meta: FileMeta{ + Filename: "new.pdf", + Hash: "abc123", + Size: 1024, + FileKey: "key123", + MessageID: "msg123", + DownloadedAt: time.Now(), + }, + verify: func(index *DirectoryIndex) error { + if len(index.Files) != 1 { + return fmt.Errorf("expected 1 file, got %d", len(index.Files)) + } + if index.Files[0].Filename != "new.pdf" { + return fmt.Errorf("expected filename new.pdf, got %s", index.Files[0].Filename) + } + return nil + }, + }, + { + name: "updates existing file in index", + setup: func(dir string) (*DirectoryIndex, error) { + index := &DirectoryIndex{ + Version: 1, + Files: []FileMeta{ + { + Filename: "existing.pdf", + Hash: "old123", + Size: 512, + FileKey: "key123", + MessageID: "msg123", + DownloadedAt: time.Now(), + }, + }, + } + return index, nil + }, + meta: FileMeta{ + Filename: "existing.pdf", + Hash: "new456", + Size: 2048, + FileKey: "key456", + MessageID: "msg456", + DownloadedAt: time.Now(), + }, + verify: func(index *DirectoryIndex) error { + if len(index.Files) != 1 { + return fmt.Errorf("expected 1 file, got %d", len(index.Files)) + } + if index.Files[0].Hash != "new456" { + return fmt.Errorf("expected hash new456, got %s", index.Files[0].Hash) + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + _, _ = tt.setup(dir) + + channel := &FeishuChannel{} + err := channel.updateIndexWithFile(dir, tt.meta) + + if err != nil { + t.Errorf("updateIndexWithFile() error = %v", err) + return + } + + // Verify + index, err := channel.loadDirectoryIndex(dir) + if err != nil { + t.Errorf("failed to load index for verification: %v", err) + return + } + + if err := tt.verify(index); err != nil { + t.Errorf("verification failed: %v", err) + } + }) + } +} + +func TestFindFileByKeyInIndex(t *testing.T) { + dir := t.TempDir() + + // Setup: Create index with test data + index := &DirectoryIndex{ + Version: 1, + Files: []FileMeta{ + { + Filename: "file1.pdf", + Hash: "abc123", + Size: 1024, + FileKey: "key1", + MessageID: "msg1", + DownloadedAt: time.Now(), + }, + { + Filename: "file2.pdf", + Hash: "def456", + Size: 2048, + FileKey: "key2", + MessageID: "msg2", + DownloadedAt: time.Now(), + }, + }, + } + + channel := &FeishuChannel{} + _ = channel.saveDirectoryIndex(dir, index) + + // Test: Find existing file + meta, err := channel.findFileByKeyInIndex(dir, "key1") + if err != nil { + t.Errorf("findFileByKeyInIndex() error = %v", err) + return + } + + if meta == nil { + t.Errorf("findFileByKeyInIndex() = nil, want file metadata") + return + } + + if meta.Filename != "file1.pdf" { + t.Errorf("findFileByKeyInIndex() filename = %q, want file1.pdf", meta.Filename) + } + + // Test: Find non-existing file + meta, _ = channel.findFileByKeyInIndex(dir, "key999") + if meta != nil { + t.Errorf("findFileByKeyInIndex() = %v, want nil", meta) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a831c58ce..74bf25a49 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -452,7 +452,8 @@ type FeishuConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` - DownloadDir string `json:"download_dir,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_DIR"` + DownloadDir string `json:"download_dir,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_DIR"` + DownloadReadonlyDisable bool `json:"download_readonly_disable,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_READONLY_DISABLE"` secDirty bool } diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 823ca155e..23c6da09c 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -56,8 +56,23 @@ func SanitizeFilename(filename string) string { // Remove any directory traversal attempts base = strings.ReplaceAll(base, "..", "") - base = strings.ReplaceAll(base, "/", "_") - base = strings.ReplaceAll(base, "\\", "_") + + // Replace filesystem invalid characters with underscore + // These characters are not allowed in filenames on various OS: + // Windows: < > : " / \ | ? * + // Unix/macOS: / (and : can cause issues in Finder) + invalidChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} + for _, char := range invalidChars { + base = strings.ReplaceAll(base, char, "_") + } + + // Trim spaces and dots from start/end + base = strings.Trim(base, " .") + + // Ensure filename is not empty after sanitization + if base == "" { + base = "unnamed_file" + } return base } diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx index 761ec4cd0..e97138537 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -136,6 +136,12 @@ export function FeishuForm({ placeholder={t("channels.field.downloadDirPlaceholder")} /> + onChange("download_readonly_disable", checked)} + /> ) } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index a157bc339..ed90505ba 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -302,6 +302,7 @@ "allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173", "downloadDir": "Download Directory", "downloadDirPlaceholder": "downloads or /absolute/path", + "downloadReadonlyDisable": "Disable Download Readonly", "secretPlaceholder": "Enter secret", "secretHintSet": "A value is already set. Leave blank to keep it unchanged." }, @@ -334,6 +335,7 @@ "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).", "allowFrom": "Allowed user or group IDs, separated by commas.", "downloadDir": "Custom directory for downloaded files. Relative paths are resolved from the workspace directory. Leave empty to use system temp directory.", + "downloadReadonlyDisable": "Disable read-only protection for downloaded files (default: read-only enabled). When enabled, files can be edited directly.", "allowOrigins": "Allowed origin domains, separated by commas.", "wsUrl": "WebSocket service URL.", "reconnectInterval": "Reconnect interval after disconnection (seconds).", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 6504236f0..ef4525ce8 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -302,6 +302,7 @@ "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", "downloadDir": "下载目录", "downloadDirPlaceholder": "downloads 或 /absolute/path", + "downloadReadonlyDisable": "禁用下载文件只读", "secretPlaceholder": "输入密钥", "secretHintSet": "已设置密钥,留空表示不修改。" }, @@ -334,6 +335,7 @@ "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)。", "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。", "downloadDir": "自定义下载文件目录。相对路径将基于工作目录解析,留空则使用系统临时目录。", + "downloadReadonlyDisable": "禁用下载文件的只读保护(默认:启用只读)。开启后,下载的文件可以直接编辑。", "allowOrigins": "允许访问的来源域名,多个值用逗号分隔。", "wsUrl": "WebSocket 服务地址。", "reconnectInterval": "断线后的重连间隔(秒)。",