tss-rocks/backend/internal/storage/local.go
CDN 3e6181e578
All checks were successful
Build Backend / Build Docker Image (push) Successful in 5m3s
[feature/backend] overall enhancement of image uploading
2025-02-23 04:42:48 +08:00

371 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}