diff --git a/internal/infra/storage/localfs.go b/internal/infra/storage/localfs.go new file mode 100644 index 0000000..bde2411 --- /dev/null +++ b/internal/infra/storage/localfs.go @@ -0,0 +1,111 @@ +package storage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// LocalFS 把对象写到本地文件系统的 base 之下。 +type LocalFS struct { + base string +} + +// NewLocalFS 构造时校验 base 是绝对路径且存在(不存在则尝试创建)。 +func NewLocalFS(base string) (*LocalFS, error) { + if !filepath.IsAbs(base) { + return nil, fmt.Errorf("storage: base must be absolute, got %q", base) + } + if err := os.MkdirAll(base, 0o755); err != nil { + return nil, fmt.Errorf("storage: mkdir base: %w", err) + } + return &LocalFS{base: filepath.Clean(base)}, nil +} + +// resolveSafe 把 key 限制在 base 之下,拒绝 ".." / 绝对路径。 +func (l *LocalFS) resolveSafe(key string) (string, error) { + // Reject absolute paths + if filepath.IsAbs(key) { + return "", errors.New("storage: key must be relative") + } + + // Check for .. components which could escape + parts := strings.FieldsFunc(key, func(r rune) bool { return r == '/' || r == '\\' }) + for _, part := range parts { + if part == ".." { + return "", errors.New("storage: key contains ..") + } + } + + // Safely join and clean + full := filepath.Join(l.base, key) + full = filepath.Clean(full) + + // Verify it's under base by checking relative path doesn't escape + rel, err := filepath.Rel(l.base, full) + if err != nil || strings.HasPrefix(rel, "..") { + return "", errors.New("storage: key escapes base") + } + + return full, nil +} + +func (l *LocalFS) Put(ctx context.Context, key string, r io.Reader, mimeType string) (PutResult, error) { + full, err := l.resolveSafe(key) + if err != nil { + return PutResult{}, err + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return PutResult{}, fmt.Errorf("storage: mkdir: %w", err) + } + tmp := full + ".tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return PutResult{}, err + } + h := sha256.New() + written, err := io.Copy(io.MultiWriter(f, h), r) + cerr := f.Close() + if err != nil { + _ = os.Remove(tmp) + return PutResult{}, err + } + if cerr != nil { + _ = os.Remove(tmp) + return PutResult{}, cerr + } + if err := os.Rename(tmp, full); err != nil { + _ = os.Remove(tmp) + return PutResult{}, err + } + return PutResult{ + StoragePath: filepath.ToSlash(strings.TrimPrefix(full, l.base+string(os.PathSeparator))), + SHA256: hex.EncodeToString(h.Sum(nil)), + Size: written, + }, nil +} + +func (l *LocalFS) Get(ctx context.Context, storagePath string) (io.ReadCloser, error) { + full, err := l.resolveSafe(storagePath) + if err != nil { + return nil, err + } + return os.Open(full) +} + +func (l *LocalFS) Delete(ctx context.Context, storagePath string) error { + full, err := l.resolveSafe(storagePath) + if err != nil { + return err + } + if err := os.Remove(full); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/infra/storage/localfs_test.go b/internal/infra/storage/localfs_test.go new file mode 100644 index 0000000..c35ae6c --- /dev/null +++ b/internal/infra/storage/localfs_test.go @@ -0,0 +1,54 @@ +package storage + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLocalFS_PutGetDelete_Roundtrip(t *testing.T) { + dir := t.TempDir() + fs, err := NewLocalFS(dir) + require.NoError(t, err) + + res, err := fs.Put(context.Background(), "2026/05/abc.txt", + bytes.NewReader([]byte("hello")), "text/plain") + require.NoError(t, err) + require.Equal(t, "2026/05/abc.txt", res.StoragePath) + require.Equal(t, int64(5), res.Size) + require.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", res.SHA256) + + r, err := fs.Get(context.Background(), res.StoragePath) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + r.Close() + require.Equal(t, "hello", string(got)) + + full := filepath.Join(dir, res.StoragePath) + _, err = os.Stat(full) + require.NoError(t, err) + + require.NoError(t, fs.Delete(context.Background(), res.StoragePath)) + _, err = os.Stat(full) + require.True(t, os.IsNotExist(err)) +} + +func TestLocalFS_RejectPathTraversal(t *testing.T) { + fs, _ := NewLocalFS(t.TempDir()) + _, err := fs.Put(context.Background(), "../escape.txt", bytes.NewReader(nil), "text/plain") + require.Error(t, err) + _, err = fs.Get(context.Background(), "../etc/passwd") + require.Error(t, err) + require.Error(t, fs.Delete(context.Background(), "../oops")) +} + +func TestLocalFS_DeleteNonexistentIsNoop(t *testing.T) { + fs, _ := NewLocalFS(t.TempDir()) + require.NoError(t, fs.Delete(context.Background(), "nope/nope.txt")) +} diff --git a/internal/infra/storage/storage.go b/internal/infra/storage/storage.go new file mode 100644 index 0000000..6a75b39 --- /dev/null +++ b/internal/infra/storage/storage.go @@ -0,0 +1,27 @@ +// Package storage 抽象附件二进制存储。MVP 默认 LocalFS;后续可加 S3。 +package storage + +import ( + "context" + "io" +) + +// PutResult 由 Put 返回,便于调用方一次拿到 path + sha256 + 实际写入字节数。 +type PutResult struct { + StoragePath string + SHA256 string + Size int64 +} + +// Storage 是后端无关的存储接口。 +type Storage interface { + // Put 将 r 流式写入存储,返回相对 path、sha256 和实际字节数。 + // key 由调用方指定(例如 "yyyy/mm/.");实现层校验并放入 base 之下。 + Put(ctx context.Context, key string, r io.Reader, mimeType string) (PutResult, error) + + // Get 返回 storagePath 对应的 ReadCloser。调用方负责关闭。 + Get(ctx context.Context, storagePath string) (io.ReadCloser, error) + + // Delete 删除文件。文件不存在视为成功(幂等)。 + Delete(ctx context.Context, storagePath string) error +}