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 }