223 lines
5.6 KiB
Go
223 lines
5.6 KiB
Go
package storage
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"git.toowon.com/jimmy/go-common/config"
|
||
)
|
||
|
||
// LocalStorage 本地存储实现
|
||
// 将对象写入本地文件夹(BaseDir),对象键 objectKey 作为相对路径使用。
|
||
//
|
||
// 典型用法:
|
||
// - 上传:Upload(ctx, "uploads/2026/01/01/a.png", reader)
|
||
// - 查看:配合 ProxyHandler 或 http.FileServer 对外提供访问
|
||
type LocalStorage struct {
|
||
baseDir string
|
||
publicURL string
|
||
}
|
||
|
||
// NewLocalStorage 创建本地存储实例
|
||
func NewLocalStorage(cfg *config.LocalStorageConfig) (*LocalStorage, error) {
|
||
if cfg == nil {
|
||
return nil, fmt.Errorf("LocalStorage config is nil")
|
||
}
|
||
if strings.TrimSpace(cfg.BaseDir) == "" {
|
||
return nil, fmt.Errorf("LocalStorage baseDir is empty")
|
||
}
|
||
|
||
absBase, err := filepath.Abs(cfg.BaseDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get absolute baseDir: %w", err)
|
||
}
|
||
|
||
// 确保根目录存在
|
||
if err := os.MkdirAll(absBase, 0o755); err != nil {
|
||
return nil, fmt.Errorf("failed to create baseDir: %w", err)
|
||
}
|
||
|
||
return &LocalStorage{
|
||
baseDir: absBase,
|
||
publicURL: strings.TrimSpace(cfg.PublicURL),
|
||
}, nil
|
||
}
|
||
|
||
// Upload 上传文件到本地文件夹
|
||
func (s *LocalStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error {
|
||
_ = ctx
|
||
_ = contentType // 本地写文件不依赖 contentType;可由上层自行记录
|
||
|
||
dstPath, err := s.resolvePath(objectKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 确保目录存在
|
||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||
return fmt.Errorf("failed to create directory: %w", err)
|
||
}
|
||
|
||
// 原子写入:先写临时文件,再 rename
|
||
tmp, err := os.CreateTemp(filepath.Dir(dstPath), ".upload-*")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create temp file: %w", err)
|
||
}
|
||
|
||
tmpName := tmp.Name()
|
||
defer func() {
|
||
_ = tmp.Close()
|
||
_ = os.Remove(tmpName)
|
||
}()
|
||
|
||
if _, err := io.Copy(tmp, reader); err != nil {
|
||
return fmt.Errorf("failed to write temp file: %w", err)
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
return fmt.Errorf("failed to close temp file: %w", err)
|
||
}
|
||
|
||
// 如果目标文件已存在,先删除(保证跨平台兼容 rename 行为)
|
||
_ = os.Remove(dstPath)
|
||
|
||
if err := os.Rename(tmpName, dstPath); err != nil {
|
||
return fmt.Errorf("failed to move temp file to destination: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetURL 获取本地文件访问URL
|
||
// - 若配置了 publicURL:
|
||
// - 包含 "{objectKey}" 占位符:替换为 url.QueryEscape(objectKey)
|
||
// - 否则认为是 URL 前缀:自动拼接 objectKey(用 path.Join 处理斜杠)
|
||
//
|
||
// - 未配置 publicURL:返回 objectKey(相对路径)
|
||
func (s *LocalStorage) GetURL(objectKey string, expires int64) (string, error) {
|
||
_ = expires // 本地存储不提供签名URL,忽略 expires
|
||
|
||
cleanKey, err := normalizeObjectKey(objectKey)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if s.publicURL == "" {
|
||
return cleanKey, nil
|
||
}
|
||
|
||
if strings.Contains(s.publicURL, "{objectKey}") {
|
||
return strings.ReplaceAll(s.publicURL, "{objectKey}", url.QueryEscape(cleanKey)), nil
|
||
}
|
||
|
||
// 作为前缀拼接
|
||
trimmed := strings.TrimRight(s.publicURL, "/")
|
||
return trimmed + "/" + path.Clean("/" + cleanKey)[1:], nil
|
||
}
|
||
|
||
// Delete 删除本地文件
|
||
func (s *LocalStorage) Delete(ctx context.Context, objectKey string) error {
|
||
_ = ctx
|
||
|
||
dstPath, err := s.resolvePath(objectKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := os.Remove(dstPath); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("failed to delete file: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Exists 检查本地文件是否存在
|
||
func (s *LocalStorage) Exists(ctx context.Context, objectKey string) (bool, error) {
|
||
_ = ctx
|
||
|
||
dstPath, err := s.resolvePath(objectKey)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
info, err := os.Stat(dstPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return false, nil
|
||
}
|
||
return false, fmt.Errorf("failed to stat file: %w", err)
|
||
}
|
||
if info.IsDir() {
|
||
return false, nil
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// GetObject 获取本地文件内容
|
||
func (s *LocalStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||
_ = ctx
|
||
|
||
dstPath, err := s.resolvePath(objectKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
f, err := os.Open(dstPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||
}
|
||
return f, nil
|
||
}
|
||
|
||
func (s *LocalStorage) resolvePath(objectKey string) (string, error) {
|
||
cleanKey, err := normalizeObjectKey(objectKey)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 将 URL 风格路径转换为 OS 路径
|
||
full := filepath.Join(s.baseDir, filepath.FromSlash(cleanKey))
|
||
|
||
// 防御:确保仍在 baseDir 下
|
||
rel, err := filepath.Rel(s.baseDir, full)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
||
}
|
||
if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." {
|
||
return "", fmt.Errorf("invalid objectKey: %s", objectKey)
|
||
}
|
||
|
||
return full, nil
|
||
}
|
||
|
||
func normalizeObjectKey(objectKey string) (string, error) {
|
||
key := strings.TrimSpace(objectKey)
|
||
if key == "" {
|
||
return "", fmt.Errorf("objectKey is empty")
|
||
}
|
||
|
||
// 兼容 Windows 风格路径,统一为 URL 风格
|
||
key = strings.ReplaceAll(key, "\\", "/")
|
||
|
||
// 清洗路径,去除多余的 . / ..
|
||
// 加前缀 "/" 让 Clean 以绝对路径方式处理,避免出现空结果
|
||
clean := path.Clean("/" + key)
|
||
clean = strings.TrimPrefix(clean, "/")
|
||
if clean == "" || clean == "." {
|
||
return "", fmt.Errorf("invalid objectKey: %s", objectKey)
|
||
}
|
||
|
||
// 不允许以 "/" 结尾(必须指向文件)
|
||
if strings.HasSuffix(clean, "/") {
|
||
return "", fmt.Errorf("objectKey cannot be a directory: %s", objectKey)
|
||
}
|
||
|
||
return clean, nil
|
||
}
|