✨ feat(feishu): 添加文件下载索引功能
- 实现文件下载索引系统,记录下载文件的元数据 - 新增文件名冲突智能处理逻辑 - 添加文件哈希计算和校验功能 - 实现按年/月自动分类存储下载文件 - 增加索引文件原子写入和并发控制 ✅ test(feishu): 添加文件下载相关测试用例 - 测试文件名冲突处理逻辑 - 测试文件哈希计算功能 - 测试文件路径生成逻辑 - 测试索引文件加载和保存 - 测试索引更新和查询功能 🌐 i18n(feishu): 添加下载文件只读设置国际化支持 - 添加下载文件只读设置字段 - 更新中英文翻译文本
This commit is contained in:
parent
17ab506b81
commit
0944de2c0b
7 changed files with 774 additions and 25 deletions
|
|
@ -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 ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@ export function FeishuForm({
|
|||
placeholder={t("channels.field.downloadDirPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.downloadReadonlyDisable")}
|
||||
hint={t("channels.form.desc.downloadReadonlyDisable")}
|
||||
checked={asBool(config.download_readonly_disable)}
|
||||
onCheckedChange={(checked) => onChange("download_readonly_disable", checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
|
|
|
|||
|
|
@ -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": "断线后的重连间隔(秒)。",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue