Implement LocalPath functionality for attachment management

- Added LocalPath method to the Manager and Storage interfaces to retrieve the absolute path and content type of files.
- Enhanced local and S3 storage implementations to support LocalPath, including handling gzipped files and content type detection.
- Introduced comprehensive tests for LocalPath functionality, covering various file types, non-existent files, and gzipped content.
- Updated AddFile API to utilize LocalPath for retrieving file information, improving error handling and response consistency.
This commit is contained in:
Max 2025-07-27 09:48:49 +08:00
parent 070ff59225
commit c3b374f043
9 changed files with 1119 additions and 11 deletions

View file

@ -6,6 +6,8 @@ import (
"crypto/sha256"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
@ -259,3 +261,250 @@ func (storage *Storage) makeID(filename string, ext string) string {
name := strings.TrimSuffix(filepath.Base(filename), ext)
return fmt.Sprintf("%s/%s-%s%s", date, name, hash, ext)
}
// LocalPath returns the absolute path of the file and its content type
func (storage *Storage) LocalPath(ctx context.Context, path string) (string, string, error) {
fullPath := filepath.Join(storage.Path, path)
// Check if file exists
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return "", "", fmt.Errorf("file not found: %s", path)
}
// For gzipped files, we need to detect the original content type, not the gzip wrapper
var contentType string
var err error
if strings.HasSuffix(path, ".gz") {
// For gzipped files, detect content type of the decompressed content
originalPath := strings.TrimSuffix(path, ".gz")
ext := filepath.Ext(originalPath)
// First try to detect by original file extension
contentType, err = detectContentTypeFromExtension(ext)
if err != nil || contentType == "application/octet-stream" {
// Fallback: decompress and detect from content
contentType, err = detectContentTypeFromGzippedFile(fullPath)
if err != nil {
return "", "", fmt.Errorf("failed to detect content type from gzipped file: %w", err)
}
}
} else {
// Regular file content type detection
contentType, err = detectContentType(fullPath)
if err != nil {
return "", "", fmt.Errorf("failed to detect content type: %w", err)
}
}
// Return absolute path
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", "", fmt.Errorf("failed to get absolute path: %w", err)
}
return absPath, contentType, nil
}
// detectContentType detects content type based on file extension and content
func detectContentType(filePath string) (string, error) {
// First try to detect by file extension
ext := strings.ToLower(filepath.Ext(filePath))
// Common file extensions mapping
switch ext {
case ".txt":
return "text/plain", nil
case ".html", ".htm":
return "text/html", nil
case ".css":
return "text/css", nil
case ".js":
return "application/javascript", nil
case ".json":
return "application/json", nil
case ".xml":
return "application/xml", nil
case ".jpg", ".jpeg":
return "image/jpeg", nil
case ".png":
return "image/png", nil
case ".gif":
return "image/gif", nil
case ".webp":
return "image/webp", nil
case ".svg":
return "image/svg+xml", nil
case ".pdf":
return "application/pdf", nil
case ".doc":
return "application/msword", nil
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
case ".xls":
return "application/vnd.ms-excel", nil
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
case ".ppt":
return "application/vnd.ms-powerpoint", nil
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
case ".zip":
return "application/zip", nil
case ".tar":
return "application/x-tar", nil
case ".gz":
return "application/gzip", nil
case ".mp3":
return "audio/mpeg", nil
case ".wav":
return "audio/wav", nil
case ".m4a":
return "audio/mp4", nil
case ".ogg":
return "audio/ogg", nil
case ".mp4":
return "video/mp4", nil
case ".avi":
return "video/x-msvideo", nil
case ".mov":
return "video/quicktime", nil
case ".webm":
return "video/webm", nil
case ".md", ".mdx":
return "text/markdown", nil
case ".yao":
return "application/yao", nil
case ".csv":
return "text/csv", nil
}
// Try to detect by MIME package
if contentType := mime.TypeByExtension(ext); contentType != "" {
return contentType, nil
}
// Fallback: detect by reading file content
file, err := os.Open(filePath)
if err != nil {
return "application/octet-stream", nil // Default fallback
}
defer file.Close()
// Read first 512 bytes for content detection
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return "application/octet-stream", nil
}
// Use http.DetectContentType to detect based on content
contentType := http.DetectContentType(buffer[:n])
return contentType, nil
}
// detectContentTypeFromExtension detects content type based only on file extension
func detectContentTypeFromExtension(ext string) (string, error) {
ext = strings.ToLower(ext)
// Common file extensions mapping
switch ext {
case ".txt":
return "text/plain", nil
case ".html", ".htm":
return "text/html", nil
case ".css":
return "text/css", nil
case ".js":
return "application/javascript", nil
case ".json":
return "application/json", nil
case ".xml":
return "application/xml", nil
case ".jpg", ".jpeg":
return "image/jpeg", nil
case ".png":
return "image/png", nil
case ".gif":
return "image/gif", nil
case ".webp":
return "image/webp", nil
case ".svg":
return "image/svg+xml", nil
case ".pdf":
return "application/pdf", nil
case ".doc":
return "application/msword", nil
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
case ".xls":
return "application/vnd.ms-excel", nil
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
case ".ppt":
return "application/vnd.ms-powerpoint", nil
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
case ".zip":
return "application/zip", nil
case ".tar":
return "application/x-tar", nil
case ".mp3":
return "audio/mpeg", nil
case ".wav":
return "audio/wav", nil
case ".m4a":
return "audio/mp4", nil
case ".ogg":
return "audio/ogg", nil
case ".mp4":
return "video/mp4", nil
case ".avi":
return "video/x-msvideo", nil
case ".mov":
return "video/quicktime", nil
case ".webm":
return "video/webm", nil
case ".md", ".mdx":
return "text/markdown", nil
case ".yao":
return "application/yao", nil
case ".csv":
return "text/csv", nil
}
// Try to detect by MIME package
if contentType := mime.TypeByExtension(ext); contentType != "" {
return contentType, nil
}
// Return default if not found
return "application/octet-stream", nil
}
// detectContentTypeFromGzippedFile detects content type by decompressing and reading gzipped file
func detectContentTypeFromGzippedFile(gzippedFilePath string) (string, error) {
file, err := os.Open(gzippedFilePath)
if err != nil {
return "", err
}
defer file.Close()
// Create gzip reader
gzipReader, err := gzip.NewReader(file)
if err != nil {
return "", err
}
defer gzipReader.Close()
// Read first 512 bytes of decompressed content
buffer := make([]byte, 512)
n, err := gzipReader.Read(buffer)
if err != nil && err != io.EOF {
return "", err
}
// Use http.DetectContentType to detect based on decompressed content
contentType := http.DetectContentType(buffer[:n])
return contentType, nil
}

View file

@ -223,4 +223,81 @@ func TestLocalStorage(t *testing.T) {
exists = storage.Exists(context.Background(), fileID)
assert.False(t, exists)
})
t.Run("LocalPath", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
})
assert.NoError(t, err)
// Test different file types to verify content type detection
testFiles := []struct {
name string
content []byte
contentType string
expectedCT string
}{
{"test.txt", []byte("Hello World"), "text/plain", "text/plain"},
{"test.json", []byte(`{"key": "value"}`), "application/json", "application/json"},
{"test.html", []byte("<html><body>Test</body></html>"), "text/html", "text/html"},
{"test.csv", []byte("col1,col2\nval1,val2"), "text/csv", "text/csv"},
{"test.md", []byte("# Markdown Content"), "text/markdown", "text/markdown"},
{"test.yao", []byte("yao file content"), "application/yao", "application/yao"},
}
for _, tf := range testFiles {
// Upload file
_, err = storage.Upload(context.Background(), tf.name, bytes.NewReader(tf.content), tf.contentType)
assert.NoError(t, err, "Failed to upload %s", tf.name)
// Get local path and content type
localPath, detectedCT, err := storage.LocalPath(context.Background(), tf.name)
assert.NoError(t, err, "Failed to get local path for %s", tf.name)
assert.NotEmpty(t, localPath, "Local path should not be empty for %s", tf.name)
assert.Equal(t, tf.expectedCT, detectedCT, "Content type mismatch for %s", tf.name)
// Verify the path is absolute
assert.True(t, filepath.IsAbs(localPath), "Path should be absolute for %s", tf.name)
// Verify the file exists at the returned path
_, err = os.Stat(localPath)
assert.NoError(t, err, "File should exist at local path for %s", tf.name)
// Verify file content
fileContent, err := os.ReadFile(localPath)
assert.NoError(t, err, "Failed to read file at local path for %s", tf.name)
assert.Equal(t, tf.content, fileContent, "File content mismatch for %s", tf.name)
}
})
t.Run("LocalPath_NonExistentFile", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
})
assert.NoError(t, err)
// Test with non-existent file
_, _, err = storage.LocalPath(context.Background(), "non-existent.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "file not found")
})
t.Run("LocalPath_ContentDetection", func(t *testing.T) {
storage, err := New(map[string]interface{}{
"path": testPath,
})
assert.NoError(t, err)
// Upload a file without extension but with recognizable content
htmlContent := []byte("<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hello</h1></body></html>")
_, err = storage.Upload(context.Background(), "noext", bytes.NewReader(htmlContent), "application/octet-stream")
assert.NoError(t, err)
// Get local path - should detect HTML content type
localPath, contentType, err := storage.LocalPath(context.Background(), "noext")
assert.NoError(t, err)
assert.NotEmpty(t, localPath)
// Content detection should identify this as HTML
assert.Equal(t, "text/html; charset=utf-8", contentType)
})
}

View file

@ -215,6 +215,18 @@ func New(option ManagerOption) (*Manager, error) {
return manager, nil
}
// LocalPath gets the local path of the file
func (manager Manager) LocalPath(ctx context.Context, fileID string) (string, string, error) {
// Get the real storage path from database
storagePath, err := manager.getStoragePathFromDatabase(ctx, fileID)
if err != nil {
return "", "", err
}
// Call the storage implementation
return manager.storage.LocalPath(ctx, storagePath)
}
// Upload uploads a file, Content-Sync must be true for chunked upload
func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reader io.Reader, option UploadOption) (*File, error) {

View file

@ -7,6 +7,7 @@ import (
"fmt"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -828,3 +829,282 @@ func TestList(t *testing.T) {
t.Logf("Successfully tested list functionality with %d files", len(uploadedFiles))
}
func TestManagerLocalPath(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Test with local storage
t.Run("LocalStorage", func(t *testing.T) {
// Create a local storage manager
manager, err := New(ManagerOption{
Driver: "local",
MaxSize: "10M",
AllowedTypes: []string{"text/*", "image/*", "application/*", ".txt", ".json", ".html", ".csv", ".yao"},
Options: map[string]interface{}{
"path": "/tmp/test_localpath_attachments",
},
})
if err != nil {
t.Fatalf("Failed to create local manager: %v", err)
}
manager.Name = "localpath-test"
// Test different file types
testFiles := []struct {
filename string
content string
contentType string
expectedCT string
}{
{"test.txt", "Hello LocalPath", "text/plain", "text/plain"},
{"test.json", `{"localpath": "test"}`, "application/json", "application/json"},
{"test.html", "<html><body>LocalPath Test</body></html>", "text/html", "text/html"},
{"test.csv", "col1,col2\nlocalpath,test", "text/csv", "text/csv"},
{"test.yao", "localpath yao content", "application/yao", "application/yao"},
}
for _, tf := range testFiles {
// Upload file
reader := strings.NewReader(tf.content)
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: tf.filename,
Size: int64(len(tf.content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", tf.contentType)
option := UploadOption{
Groups: []string{"localpath", "test"},
OriginalFilename: tf.filename,
}
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err != nil {
t.Fatalf("Failed to upload file %s: %v", tf.filename, err)
}
// Test LocalPath
localPath, detectedCT, err := manager.LocalPath(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to get local path for %s: %v", tf.filename, err)
}
// Verify path is absolute
if !filepath.IsAbs(localPath) {
t.Errorf("Expected absolute path for %s, got: %s", tf.filename, localPath)
}
// Verify content type
if detectedCT != tf.expectedCT {
t.Errorf("Expected content type %s for %s, got: %s", tf.expectedCT, tf.filename, detectedCT)
}
// Verify file exists
if _, err := os.Stat(localPath); os.IsNotExist(err) {
t.Errorf("File should exist at local path %s for %s", localPath, tf.filename)
}
// Verify file content
fileContent, err := os.ReadFile(localPath)
if err != nil {
t.Fatalf("Failed to read file at local path for %s: %v", tf.filename, err)
}
if string(fileContent) != tf.content {
t.Errorf("File content mismatch for %s. Expected: %s, Got: %s", tf.filename, tf.content, string(fileContent))
}
t.Logf("File %s - ID: %s, LocalPath: %s, ContentType: %s", tf.filename, file.ID, localPath, detectedCT)
}
})
// Test with gzipped files in local storage
t.Run("LocalStorage_Gzipped", func(t *testing.T) {
manager, err := New(ManagerOption{
Driver: "local",
MaxSize: "10M",
AllowedTypes: []string{"text/*"},
Options: map[string]interface{}{
"path": "/tmp/test_localpath_gzip_attachments",
},
})
if err != nil {
t.Fatalf("Failed to create local manager: %v", err)
}
manager.Name = "localpath-gzip-test"
content := "This content will be gzipped"
reader := strings.NewReader(content)
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "gzipped.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
option := UploadOption{
Groups: []string{"gzip", "test"},
OriginalFilename: "gzipped.txt",
Gzip: true, // Enable gzip compression
}
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err != nil {
t.Fatalf("Failed to upload gzipped file: %v", err)
}
// Test LocalPath - should get decompressed content
localPath, contentType, err := manager.LocalPath(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to get local path for gzipped file: %v", err)
}
// Verify content type
if contentType != "text/plain" {
t.Errorf("Expected content type text/plain, got: %s", contentType)
}
// For gzipped files in local storage, the storage path ends with .gz
// but the content should be accessible normally through Read methods
fileContent, err := manager.Read(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to read gzipped file: %v", err)
}
if string(fileContent) != content {
t.Errorf("Gzipped file content mismatch. Expected: %s, Got: %s", content, string(fileContent))
}
t.Logf("Gzipped file - ID: %s, LocalPath: %s, ContentType: %s", file.ID, localPath, contentType)
})
}
func TestManagerLocalPath_NonExistentFile(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager, err := New(ManagerOption{
Driver: "local",
AllowedTypes: []string{"text/*"},
Options: map[string]interface{}{
"path": "/tmp/test_localpath_nonexistent",
},
})
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
manager.Name = "nonexistent-test"
// Test with non-existent file ID
_, _, err = manager.LocalPath(context.Background(), "non-existent-file-id")
if err == nil {
t.Error("Expected error for non-existent file ID")
}
// Should contain "file not found" in the error chain
if !strings.Contains(err.Error(), "file not found") {
t.Errorf("Expected 'file not found' in error message, got: %s", err.Error())
}
}
func TestManagerLocalPath_ValidationFlow(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
manager, err := New(ManagerOption{
Driver: "local",
AllowedTypes: []string{"text/*"},
Options: map[string]interface{}{
"path": "/tmp/test_localpath_validation",
},
})
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
manager.Name = "validation-test"
// Upload a file
content := "Validation flow test content"
reader := strings.NewReader(content)
fileHeader := &FileHeader{
FileHeader: &multipart.FileHeader{
Filename: "validation.txt",
Size: int64(len(content)),
Header: make(map[string][]string),
},
}
fileHeader.Header.Set("Content-Type", "text/plain")
option := UploadOption{
Groups: []string{"validation"},
OriginalFilename: "original-validation.txt",
}
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
if err != nil {
t.Fatalf("Failed to upload file: %v", err)
}
// Test complete flow: Upload -> LocalPath -> Verify -> Delete
t.Run("CompleteFlow", func(t *testing.T) {
// Get local path
localPath, contentType, err := manager.LocalPath(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to get local path: %v", err)
}
// Verify all properties
if !filepath.IsAbs(localPath) {
t.Error("Path should be absolute")
}
if contentType != "text/plain" {
t.Errorf("Expected content type text/plain, got: %s", contentType)
}
// Verify file exists
stat, err := os.Stat(localPath)
if err != nil {
t.Fatalf("File should exist at local path: %v", err)
}
if stat.Size() != int64(len(content)) {
t.Errorf("File size mismatch. Expected: %d, Got: %d", len(content), stat.Size())
}
// Verify file content matches
fileContent, err := os.ReadFile(localPath)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if string(fileContent) != content {
t.Errorf("Content mismatch. Expected: %s, Got: %s", content, string(fileContent))
}
// Verify through manager's Read method as well
managerContent, err := manager.Read(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to read through manager: %v", err)
}
if string(managerContent) != content {
t.Errorf("Manager read content mismatch. Expected: %s, Got: %s", content, string(managerContent))
}
t.Logf("Validation complete - LocalPath: %s, Size: %d bytes, ContentType: %s", localPath, stat.Size(), contentType)
})
// Clean up
err = manager.Delete(context.Background(), file.ID)
if err != nil {
t.Logf("Warning: Failed to delete test file: %v", err)
}
}

View file

@ -9,6 +9,9 @@ import (
"image/jpeg"
"image/png"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -32,6 +35,7 @@ type Storage struct {
Secret string `json:"secret" yaml:"secret"`
Bucket string `json:"bucket" yaml:"bucket"`
Expiration time.Duration `json:"expiration" yaml:"expiration"`
CacheDir string `json:"cache_dir" yaml:"cache_dir"`
client *s3.Client
prefix string
compression bool
@ -69,6 +73,13 @@ func New(options map[string]interface{}) (*Storage, error) {
storage.prefix = prefix
}
if cacheDir, ok := options["cache_dir"].(string); ok {
storage.CacheDir = cacheDir
} else {
// Use system temp directory as default
storage.CacheDir = os.TempDir()
}
if exp, ok := options["expiration"].(time.Duration); ok {
storage.Expiration = exp
}
@ -103,6 +114,12 @@ func New(options map[string]interface{}) (*Storage, error) {
}
storage.client = s3.New(opts)
// Ensure cache directory exists
if err := os.MkdirAll(storage.CacheDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory %s: %w", storage.CacheDir, err)
}
return storage, nil
}
@ -445,3 +462,278 @@ func compressImage(data []byte, contentType string) ([]byte, error) {
return buf.Bytes(), nil
}
// LocalPath downloads the file to cache directory and returns absolute path with content type
func (storage *Storage) LocalPath(ctx context.Context, path string) (string, string, error) {
if storage.client == nil {
return "", "", fmt.Errorf("s3 client not initialized")
}
// Create cache file path using the same structure as storage path
cacheFilePath := filepath.Join(storage.CacheDir, "s3_cache", path)
// Create directory for cache file
dir := filepath.Dir(cacheFilePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", "", fmt.Errorf("failed to create cache directory: %w", err)
}
// Check if file already exists in cache and is not outdated
if _, err := os.Stat(cacheFilePath); err == nil {
// File exists in cache, detect content type and return
contentType, err := detectContentType(cacheFilePath)
if err != nil {
return "", "", fmt.Errorf("failed to detect content type: %w", err)
}
return cacheFilePath, contentType, nil
}
// Download file from S3 to cache
key := filepath.Join(storage.prefix, path)
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(storage.Bucket),
Key: aws.String(key),
})
if err != nil {
return "", "", fmt.Errorf("failed to download file %s: %w", path, err)
}
defer result.Body.Close()
// Create cache file
cacheFile, err := os.Create(cacheFilePath)
if err != nil {
return "", "", fmt.Errorf("failed to create cache file: %w", err)
}
defer cacheFile.Close()
// Handle gzipped files - decompress during download
var reader io.Reader = result.Body
if strings.HasSuffix(path, ".gz") {
gzipReader, err := gzip.NewReader(result.Body)
if err != nil {
return "", "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzipReader.Close()
reader = gzipReader
// Remove .gz extension from cache file path since we're decompressing
newCacheFilePath := strings.TrimSuffix(cacheFilePath, ".gz")
cacheFile.Close()
os.Remove(cacheFilePath)
cacheFile, err = os.Create(newCacheFilePath)
if err != nil {
return "", "", fmt.Errorf("failed to create decompressed cache file: %w", err)
}
defer cacheFile.Close()
cacheFilePath = newCacheFilePath
}
// Copy file content to cache
_, err = io.Copy(cacheFile, reader)
if err != nil {
return "", "", fmt.Errorf("failed to copy file to cache: %w", err)
}
// For files that were decompressed from .gz, we need to detect the original content type
var contentType string
if strings.HasSuffix(path, ".gz") {
// Original path was gzipped, detect content type of decompressed content
originalPath := strings.TrimSuffix(path, ".gz")
ext := filepath.Ext(originalPath)
// First try to detect by original file extension
contentType, err = detectContentTypeFromExtension(ext)
if err != nil || contentType == "application/octet-stream" {
// Fallback: detect from decompressed content
contentType, err = detectContentType(cacheFilePath)
if err != nil {
return "", "", fmt.Errorf("failed to detect content type: %w", err)
}
}
} else {
// Regular file content type detection
contentType, err = detectContentType(cacheFilePath)
if err != nil {
return "", "", fmt.Errorf("failed to detect content type: %w", err)
}
}
return cacheFilePath, contentType, nil
}
// detectContentType detects content type based on file extension and content
func detectContentType(filePath string) (string, error) {
// First try to detect by file extension
ext := strings.ToLower(filepath.Ext(filePath))
// Common file extensions mapping
switch ext {
case ".txt":
return "text/plain", nil
case ".html", ".htm":
return "text/html", nil
case ".css":
return "text/css", nil
case ".js":
return "application/javascript", nil
case ".json":
return "application/json", nil
case ".xml":
return "application/xml", nil
case ".jpg", ".jpeg":
return "image/jpeg", nil
case ".png":
return "image/png", nil
case ".gif":
return "image/gif", nil
case ".webp":
return "image/webp", nil
case ".svg":
return "image/svg+xml", nil
case ".pdf":
return "application/pdf", nil
case ".doc":
return "application/msword", nil
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
case ".xls":
return "application/vnd.ms-excel", nil
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
case ".ppt":
return "application/vnd.ms-powerpoint", nil
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
case ".zip":
return "application/zip", nil
case ".tar":
return "application/x-tar", nil
case ".gz":
return "application/gzip", nil
case ".mp3":
return "audio/mpeg", nil
case ".wav":
return "audio/wav", nil
case ".m4a":
return "audio/mp4", nil
case ".ogg":
return "audio/ogg", nil
case ".mp4":
return "video/mp4", nil
case ".avi":
return "video/x-msvideo", nil
case ".mov":
return "video/quicktime", nil
case ".webm":
return "video/webm", nil
case ".md", ".mdx":
return "text/markdown", nil
case ".yao":
return "application/yao", nil
case ".csv":
return "text/csv", nil
}
// Try to detect by MIME package
if contentType := mime.TypeByExtension(ext); contentType != "" {
return contentType, nil
}
// Fallback: detect by reading file content
file, err := os.Open(filePath)
if err != nil {
return "application/octet-stream", nil // Default fallback
}
defer file.Close()
// Read first 512 bytes for content detection
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return "application/octet-stream", nil
}
// Use http.DetectContentType to detect based on content
contentType := http.DetectContentType(buffer[:n])
return contentType, nil
}
// detectContentTypeFromExtension detects content type based only on file extension
func detectContentTypeFromExtension(ext string) (string, error) {
ext = strings.ToLower(ext)
// Common file extensions mapping
switch ext {
case ".txt":
return "text/plain", nil
case ".html", ".htm":
return "text/html", nil
case ".css":
return "text/css", nil
case ".js":
return "application/javascript", nil
case ".json":
return "application/json", nil
case ".xml":
return "application/xml", nil
case ".jpg", ".jpeg":
return "image/jpeg", nil
case ".png":
return "image/png", nil
case ".gif":
return "image/gif", nil
case ".webp":
return "image/webp", nil
case ".svg":
return "image/svg+xml", nil
case ".pdf":
return "application/pdf", nil
case ".doc":
return "application/msword", nil
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
case ".xls":
return "application/vnd.ms-excel", nil
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
case ".ppt":
return "application/vnd.ms-powerpoint", nil
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
case ".zip":
return "application/zip", nil
case ".tar":
return "application/x-tar", nil
case ".mp3":
return "audio/mpeg", nil
case ".wav":
return "audio/wav", nil
case ".m4a":
return "audio/mp4", nil
case ".ogg":
return "audio/ogg", nil
case ".mp4":
return "video/mp4", nil
case ".avi":
return "video/x-msvideo", nil
case ".mov":
return "video/quicktime", nil
case ".webm":
return "video/webm", nil
case ".md", ".mdx":
return "text/markdown", nil
case ".yao":
return "application/yao", nil
case ".csv":
return "text/csv", nil
}
// Try to detect by MIME package
if contentType := mime.TypeByExtension(ext); contentType != "" {
return contentType, nil
}
// Return default if not found
return "application/octet-stream", nil
}

View file

@ -5,9 +5,13 @@ import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"compress/gzip"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
@ -196,4 +200,164 @@ func TestS3Storage(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "bucket is required")
})
t.Run("LocalPath", func(t *testing.T) {
skipIfNoS3Config(t)
// Create storage with custom cache directory
tempCacheDir, err := os.MkdirTemp("", "s3_cache_test")
assert.NoError(t, err)
defer os.RemoveAll(tempCacheDir)
config := getS3Config()
config["cache_dir"] = tempCacheDir
storage, err := New(config)
assert.NoError(t, err)
// Test different file types
testFiles := []struct {
name string
content []byte
contentType string
expectedCT string
}{
{"test.txt", []byte("Hello S3 World"), "text/plain", "text/plain"},
{"test.json", []byte(`{"s3": "test"}`), "application/json", "application/json"},
{"test.html", []byte("<html><body>S3 Test</body></html>"), "text/html", "text/html"},
{"test.csv", []byte("s3,test\nval1,val2"), "text/csv", "text/csv"},
{"test.md", []byte("# S3 Markdown"), "text/markdown", "text/markdown"},
{"test.yao", []byte("s3 yao content"), "application/yao", "application/yao"},
}
for _, tf := range testFiles {
// Upload file to S3
fileID := "s3-localpath-" + uuid.New().String() + "-" + tf.name
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(tf.content), tf.contentType)
assert.NoError(t, err, "Failed to upload %s", tf.name)
// Get local path - first call should download to cache
localPath1, detectedCT1, err := storage.LocalPath(context.Background(), fileID)
assert.NoError(t, err, "Failed to get local path for %s", tf.name)
assert.NotEmpty(t, localPath1, "Local path should not be empty for %s", tf.name)
assert.Equal(t, tf.expectedCT, detectedCT1, "Content type mismatch for %s", tf.name)
// Verify the path is absolute
assert.True(t, filepath.IsAbs(localPath1), "Path should be absolute for %s", tf.name)
// Verify the file exists at the returned path
_, err = os.Stat(localPath1)
assert.NoError(t, err, "File should exist at local path for %s", tf.name)
// Verify file content
fileContent, err := os.ReadFile(localPath1)
assert.NoError(t, err, "Failed to read file at local path for %s", tf.name)
assert.Equal(t, tf.content, fileContent, "File content mismatch for %s", tf.name)
// Get local path again - should use cached version
localPath2, detectedCT2, err := storage.LocalPath(context.Background(), fileID)
assert.NoError(t, err, "Failed to get cached local path for %s", tf.name)
assert.Equal(t, localPath1, localPath2, "Cached path should be same as first call for %s", tf.name)
assert.Equal(t, detectedCT1, detectedCT2, "Cached content type should be same as first call for %s", tf.name)
// Clean up from S3
storage.Delete(context.Background(), fileID)
}
})
t.Run("LocalPath_GzippedFile", func(t *testing.T) {
skipIfNoS3Config(t)
// Create storage with custom cache directory
tempCacheDir, err := os.MkdirTemp("", "s3_cache_gzip_test")
assert.NoError(t, err)
defer os.RemoveAll(tempCacheDir)
config := getS3Config()
config["cache_dir"] = tempCacheDir
storage, err := New(config)
assert.NoError(t, err)
// Create gzipped content
originalContent := []byte("This content will be gzipped and stored in S3")
var gzipBuf bytes.Buffer
gzipWriter := gzip.NewWriter(&gzipBuf)
_, err = gzipWriter.Write(originalContent)
assert.NoError(t, err)
gzipWriter.Close()
// Upload gzipped file
fileID := "gzipped-" + uuid.New().String() + ".txt.gz"
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(gzipBuf.Bytes()), "text/plain")
assert.NoError(t, err)
// Get local path - should decompress during download
localPath, contentType, err := storage.LocalPath(context.Background(), fileID)
assert.NoError(t, err)
assert.NotEmpty(t, localPath)
// Verify the file is decompressed in cache (path should not end with .gz)
assert.False(t, strings.HasSuffix(localPath, ".gz"), "Cached file should be decompressed")
// Verify content is decompressed
cachedContent, err := os.ReadFile(localPath)
assert.NoError(t, err)
assert.Equal(t, originalContent, cachedContent, "Cached file should contain decompressed content")
// Verify content type
assert.Equal(t, "text/plain", contentType)
// Clean up
storage.Delete(context.Background(), fileID)
})
t.Run("LocalPath_NonExistentFile", func(t *testing.T) {
skipIfNoS3Config(t)
storage, err := New(getS3Config())
assert.NoError(t, err)
// Test with non-existent file
nonExistentFileID := "non-existent-" + uuid.New().String() + ".txt"
_, _, err = storage.LocalPath(context.Background(), nonExistentFileID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to download file")
})
t.Run("LocalPath_CustomCacheDir", func(t *testing.T) {
skipIfNoS3Config(t)
// Create custom cache directory
customCacheDir, err := os.MkdirTemp("", "custom_s3_cache")
assert.NoError(t, err)
defer os.RemoveAll(customCacheDir)
config := getS3Config()
config["cache_dir"] = customCacheDir
storage, err := New(config)
assert.NoError(t, err)
// Verify cache directory is set correctly
assert.Equal(t, customCacheDir, storage.CacheDir)
// Upload a test file
content := []byte("Custom cache directory test")
fileID := "custom-cache-" + uuid.New().String() + ".txt"
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(content), "text/plain")
assert.NoError(t, err)
// Get local path
localPath, contentType, err := storage.LocalPath(context.Background(), fileID)
assert.NoError(t, err)
assert.NotEmpty(t, localPath)
assert.Equal(t, "text/plain", contentType)
// Verify the file is cached in the custom directory
assert.True(t, strings.HasPrefix(localPath, customCacheDir), "File should be cached in custom directory")
// Clean up
storage.Delete(context.Background(), fileID)
})
}

View file

@ -43,6 +43,9 @@ type FileManager interface {
// Delete deletes a file
Delete(ctx context.Context, fileID string) error
// LocalPath gets the local path of the file
LocalPath(ctx context.Context, fileID string) (string, string, error)
}
// File the file
@ -103,6 +106,7 @@ type Storage interface {
URL(ctx context.Context, path string) string
Exists(ctx context.Context, path string) bool
Delete(ctx context.Context, path string) error
LocalPath(ctx context.Context, path string) (string, string, error) // Returns absolute path and content type
}
// ManagerOption the manager option

View file

@ -4,6 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/kb"
"github.com/yaoapp/yao/openapi/response"
)
@ -12,8 +13,19 @@ import (
// AddFile adds a file to a collection
func AddFile(c *gin.Context) {
var req AddFileRequest
// Check if kb.Instance is available
if kb.Instance == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Knowledge base not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Parse and bind JSON request
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
@ -34,24 +46,41 @@ func AddFile(c *gin.Context) {
return
}
// Check if kb.Instance is available
if kb.Instance == nil {
// Get file manager
m, ok := attachment.Managers[req.Uploader]
if !ok {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid uploader: " + req.Uploader + " not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Check if the file exists
exists := m.Exists(c.Request.Context(), req.FileID)
if !exists {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "File not found: " + req.FileID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Get the options of the manager
path, contentType, err := m.LocalPath(c.Request.Context(), req.FileID)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Knowledge base not initialized",
ErrorDescription: "Failed to get local path: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// TODO: Call external function to get file info
// filename, contentType, err := GetFileInfo(req.FileID)
// For now, use hardcoded values
filename := "document.pdf"
contentType := "application/pdf"
// Convert request to UpsertOptions
upsertOptions, err := req.BaseUpsertRequest.ToUpsertOptions(filename, contentType)
upsertOptions, err := req.BaseUpsertRequest.ToUpsertOptions(path, contentType)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,

View file

@ -91,7 +91,8 @@ type BaseUpsertRequest struct {
// AddFileRequest represents the request for AddFile API
type AddFileRequest struct {
BaseUpsertRequest
FileID string `json:"file_id" binding:"required"`
FileID string `json:"file_id" binding:"required"`
Uploader string `json:"uploader,omitempty"` // The name of the uploader, e.g. "s3", "local", "webdav", etc.
}
// AddTextRequest represents the request for AddText API