package storage import ( "context" "fmt" "io" "os" "path/filepath" "strings" ) // LocalStore 把文件落在本地磁盘(DataDir/uploads),零配置兜底。 // key 允许带「2026/09/」这类前缀,实现里做路径清洗防目录穿越。 type LocalStore struct { Root string } func NewLocal(root string) *LocalStore { return &LocalStore{Root: root} } func (s *LocalStore) path(key string) (string, error) { clean := filepath.Clean("/" + strings.TrimPrefix(key, "/"))[1:] // 锚到根再去掉,防 ../ if clean == "" || strings.HasPrefix(clean, "..") { return "", fmt.Errorf("bad key: %q", key) } return filepath.Join(s.Root, filepath.FromSlash(clean)), nil } func (s *LocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { dst, err := s.path(key) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := io.Copy(tmp, r); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), dst) } func (s *LocalStore) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) { dst, err := s.path(key) if err != nil { return nil, 0, err } f, err := os.Open(dst) if err != nil { return nil, 0, err } st, err := f.Stat() if err != nil { f.Close() return nil, 0, err } return f, st.Size(), nil } func (s *LocalStore) Delete(ctx context.Context, key string) error { dst, err := s.path(key) if err != nil { return err } if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { return err } return nil }