All checks were successful
Build Backend / Build Docker Image (push) Successful in 5m3s
371 lines
10 KiB
Go
371 lines
10 KiB
Go
package storage
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/rs/zerolog/log"
|
||
)
|
||
|
||
type LocalStorage struct {
|
||
rootDir string
|
||
metaDir string
|
||
}
|
||
|
||
func NewLocalStorage(rootDir string) (*LocalStorage, error) {
|
||
// Ensure the root directory exists
|
||
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||
return nil, fmt.Errorf("failed to create root directory: %w", err)
|
||
}
|
||
|
||
// Create metadata directory
|
||
metaDir := filepath.Join(rootDir, ".meta")
|
||
if err := os.MkdirAll(metaDir, 0755); err != nil {
|
||
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
||
}
|
||
|
||
return &LocalStorage{
|
||
rootDir: rootDir,
|
||
metaDir: metaDir,
|
||
}, nil
|
||
}
|
||
|
||
func (s *LocalStorage) generateID() (string, error) {
|
||
bytes := make([]byte, 16)
|
||
if _, err := rand.Read(bytes); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(bytes), nil
|
||
}
|
||
|
||
func (s *LocalStorage) generateFilePath(id string, ext string, createTime time.Time) string {
|
||
// Create year/month directory structure
|
||
year := createTime.Format("2006")
|
||
month := createTime.Format("01")
|
||
|
||
// If id already has an extension, don't add ext
|
||
if filepath.Ext(id) != "" {
|
||
return filepath.Join(year, month, id)
|
||
}
|
||
|
||
// Otherwise, add the extension if provided
|
||
filename := id
|
||
if ext != "" {
|
||
filename = id + ext
|
||
}
|
||
return filepath.Join(year, month, filename)
|
||
}
|
||
|
||
func (s *LocalStorage) saveMetadata(id string, info *FileInfo) error {
|
||
metaPath := filepath.Join(s.metaDir, id+".meta")
|
||
file, err := os.Create(metaPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create metadata file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
data := fmt.Sprintf("%s\n%s", info.Name, info.ContentType)
|
||
if _, err := file.WriteString(data); err != nil {
|
||
return fmt.Errorf("failed to write metadata: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *LocalStorage) loadMetadata(id string) (string, string, error) {
|
||
metaPath := filepath.Join(s.metaDir, id+".meta")
|
||
data, err := os.ReadFile(metaPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return id, "", nil // Return ID as name if metadata doesn't exist
|
||
}
|
||
return "", "", fmt.Errorf("failed to read metadata: %w", err)
|
||
}
|
||
|
||
parts := bytes.Split(data, []byte("\n"))
|
||
name := string(parts[0])
|
||
contentType := ""
|
||
if len(parts) > 1 {
|
||
contentType = string(parts[1])
|
||
}
|
||
return name, contentType, nil
|
||
}
|
||
|
||
func (s *LocalStorage) Save(ctx context.Context, name string, contentType string, reader io.Reader) (*FileInfo, error) {
|
||
if reader == nil {
|
||
return nil, fmt.Errorf("reader cannot be nil")
|
||
}
|
||
|
||
// Generate a unique ID for the file
|
||
id, err := s.generateID()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate file ID: %w", err)
|
||
}
|
||
|
||
// Get file extension from original name or content type
|
||
ext := filepath.Ext(name)
|
||
if ext == "" {
|
||
// If no extension in name, try to get it from content type
|
||
switch contentType {
|
||
case "image/jpeg":
|
||
ext = ".jpg"
|
||
case "image/png":
|
||
ext = ".png"
|
||
case "image/gif":
|
||
ext = ".gif"
|
||
case "image/webp":
|
||
ext = ".webp"
|
||
case "image/svg+xml":
|
||
ext = ".svg"
|
||
case "video/mp4":
|
||
ext = ".mp4"
|
||
case "video/webm":
|
||
ext = ".webm"
|
||
case "audio/mpeg":
|
||
ext = ".mp3"
|
||
case "audio/ogg":
|
||
ext = ".ogg"
|
||
case "audio/wav":
|
||
ext = ".wav"
|
||
case "application/pdf":
|
||
ext = ".pdf"
|
||
case "application/msword":
|
||
ext = ".doc"
|
||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||
ext = ".docx"
|
||
case "application/vnd.ms-excel":
|
||
ext = ".xls"
|
||
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||
ext = ".xlsx"
|
||
case "application/zip":
|
||
ext = ".zip"
|
||
case "application/x-rar-compressed":
|
||
ext = ".rar"
|
||
case "text/plain":
|
||
ext = ".txt"
|
||
case "text/csv":
|
||
ext = ".csv"
|
||
}
|
||
}
|
||
|
||
// Create the file path with year/month structure
|
||
now := time.Now()
|
||
relPath := s.generateFilePath(id, ext, now)
|
||
fullPath := filepath.Join(s.rootDir, relPath)
|
||
|
||
// Create directory if it doesn't exist
|
||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||
}
|
||
|
||
// Create the file
|
||
file, err := os.Create(fullPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
// Copy the content
|
||
size, err := io.Copy(file, reader)
|
||
if err != nil {
|
||
os.Remove(fullPath) // Clean up on error
|
||
return nil, fmt.Errorf("failed to write file content: %w", err)
|
||
}
|
||
|
||
// Save metadata
|
||
info := &FileInfo{
|
||
ID: id,
|
||
Name: name,
|
||
ContentType: contentType,
|
||
Size: size,
|
||
CreatedAt: now,
|
||
URL: fmt.Sprintf("/media/%s/%s/%s", now.Format("2006"), now.Format("01"), filepath.Base(relPath)),
|
||
}
|
||
if err := s.saveMetadata(id, info); err != nil {
|
||
os.Remove(fullPath) // Clean up on error
|
||
return nil, fmt.Errorf("failed to save metadata: %w", err)
|
||
}
|
||
|
||
return info, nil
|
||
}
|
||
|
||
func (s *LocalStorage) Get(ctx context.Context, id string) (io.ReadCloser, *FileInfo, error) {
|
||
// 从 id 中提取文件扩展名和基础 ID
|
||
ext := filepath.Ext(id)
|
||
baseID := strings.TrimSuffix(id, ext)
|
||
|
||
// 获取文件的创建时间(从元数据或当前时间)
|
||
metaPath := filepath.Join(s.metaDir, baseID+".meta")
|
||
var createTime time.Time
|
||
if stat, err := os.Stat(metaPath); err == nil {
|
||
createTime = stat.ModTime()
|
||
} else {
|
||
createTime = time.Now() // 如果找不到元数据,使用当前时间
|
||
}
|
||
|
||
// 生成完整的文件路径
|
||
year := createTime.Format("2006")
|
||
month := createTime.Format("01")
|
||
filePath := filepath.Join(s.rootDir, year, month, id) // 直接使用完整的 id(包含扩展名)
|
||
|
||
// 调试日志
|
||
log.Debug().
|
||
Str("id", id).
|
||
Str("baseID", baseID).
|
||
Str("ext", ext).
|
||
Str("filePath", filePath).
|
||
Time("createTime", createTime).
|
||
Msg("Attempting to get file")
|
||
|
||
// 打开文件
|
||
file, err := os.Open(filePath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil, fmt.Errorf("file not found: %s (path: %s)", id, filePath)
|
||
}
|
||
return nil, nil, fmt.Errorf("failed to open file: %w", err)
|
||
}
|
||
|
||
// 获取文件信息
|
||
stat, err := file.Stat()
|
||
if err != nil {
|
||
file.Close()
|
||
return nil, nil, fmt.Errorf("failed to get file info: %w", err)
|
||
}
|
||
|
||
// 加载元数据
|
||
name, contentType, err := s.loadMetadata(baseID)
|
||
if err != nil {
|
||
file.Close()
|
||
return nil, nil, err
|
||
}
|
||
|
||
info := &FileInfo{
|
||
ID: id,
|
||
Name: name,
|
||
Size: stat.Size(),
|
||
ContentType: contentType,
|
||
CreatedAt: createTime,
|
||
UpdatedAt: stat.ModTime(),
|
||
URL: fmt.Sprintf("/media/%s/%s/%s", year, month, id),
|
||
}
|
||
|
||
return file, info, nil
|
||
}
|
||
|
||
func (s *LocalStorage) Delete(ctx context.Context, id string) error {
|
||
// 从 id 中提取文件扩展名
|
||
ext := filepath.Ext(id)
|
||
baseID := strings.TrimSuffix(id, ext)
|
||
|
||
// 获取文件的创建时间(从元数据或当前时间)
|
||
metaPath := filepath.Join(s.metaDir, baseID+".meta")
|
||
var createTime time.Time
|
||
if stat, err := os.Stat(metaPath); err == nil {
|
||
createTime = stat.ModTime()
|
||
} else {
|
||
createTime = time.Now() // 如果找不到元数据,使用当前时间
|
||
}
|
||
|
||
// 生成完整的文件路径
|
||
relPath := s.generateFilePath(baseID, ext, createTime)
|
||
filePath := filepath.Join(s.rootDir, relPath)
|
||
|
||
// 删除文件
|
||
if err := os.Remove(filePath); err != nil {
|
||
if !os.IsNotExist(err) {
|
||
return fmt.Errorf("failed to delete file: %w", err)
|
||
}
|
||
}
|
||
|
||
// 删除元数据
|
||
if err := os.Remove(metaPath); err != nil {
|
||
if !os.IsNotExist(err) {
|
||
return fmt.Errorf("failed to delete metadata: %w", err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *LocalStorage) List(ctx context.Context, prefix string, limit int, offset int) ([]*FileInfo, error) {
|
||
var files []*FileInfo
|
||
var count int
|
||
|
||
err := filepath.Walk(s.rootDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Skip directories and metadata directory
|
||
if info.IsDir() || path == s.metaDir {
|
||
if path == s.metaDir {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Get the file ID (basename of the path)
|
||
id := filepath.Base(path)
|
||
|
||
// Load metadata to get the original name
|
||
name, contentType, err := s.loadMetadata(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Skip files that don't match the prefix
|
||
if prefix != "" && !strings.HasPrefix(name, prefix) {
|
||
return nil
|
||
}
|
||
|
||
// Skip files before offset
|
||
if count < offset {
|
||
count++
|
||
return nil
|
||
}
|
||
|
||
// Stop if we've reached the limit
|
||
if limit > 0 && len(files) >= limit {
|
||
return filepath.SkipDir
|
||
}
|
||
|
||
files = append(files, &FileInfo{
|
||
ID: id,
|
||
Name: name,
|
||
Size: info.Size(),
|
||
ContentType: contentType,
|
||
CreatedAt: info.ModTime(),
|
||
UpdatedAt: info.ModTime(),
|
||
URL: fmt.Sprintf("/api/media/file/%s", id),
|
||
})
|
||
|
||
count++
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to list files: %w", err)
|
||
}
|
||
|
||
return files, nil
|
||
}
|
||
|
||
func (s *LocalStorage) Exists(ctx context.Context, id string) (bool, error) {
|
||
filePath := filepath.Join(s.rootDir, id)
|
||
_, err := os.Stat(filePath)
|
||
if err == nil {
|
||
return true, nil
|
||
}
|
||
if os.IsNotExist(err) {
|
||
return false, nil
|
||
}
|
||
return false, fmt.Errorf("failed to check file existence: %w", err)
|
||
}
|