go-places/api/internal/blobstore/localfs.go
2026-10-07 15:14:50 +02:00

132 lines
3.8 KiB
Go

package blobstore
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// LocalFS stores blobs as files beneath a root directory.
type LocalFS struct {
root string
}
var _ BlobStore = (*LocalFS)(nil)
// NewLocalFS returns a blob store rooted at dir, creating it if necessary.
func NewLocalFS(dir string) (*LocalFS, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("blobstore: resolve root %q: %w", dir, err)
}
if err := os.MkdirAll(abs, 0o750); err != nil {
return nil, fmt.Errorf("blobstore: create root %q: %w", abs, err)
}
return &LocalFS{root: abs}, nil
}
// Root returns the absolute directory holding the blobs.
func (l *LocalFS) Root() string { return l.root }
// Put writes the stream to a temporary file and renames it into place, so a
// failed or oversized upload never leaves a partial object visible.
func (l *LocalFS) Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error) {
path, err := l.resolve(key)
if err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return 0, fmt.Errorf("blobstore: create parent of %q: %w", key, err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*")
if err != nil {
return 0, fmt.Errorf("blobstore: create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() {
tmp.Close()
os.Remove(tmpName) // no-op once the rename has succeeded
}()
// Copy one byte past the limit so an oversized payload is detectable.
written, err := io.Copy(tmp, io.LimitReader(r, maxBytes+1))
if err != nil {
return 0, fmt.Errorf("blobstore: write %q: %w", key, err)
}
if written > maxBytes {
return written, fmt.Errorf("%w: limit is %d bytes", ErrTooLarge, maxBytes)
}
if err := tmp.Sync(); err != nil {
return 0, fmt.Errorf("blobstore: sync %q: %w", key, err)
}
if err := tmp.Close(); err != nil {
return 0, fmt.Errorf("blobstore: close %q: %w", key, err)
}
if err := ctx.Err(); err != nil {
return written, err
}
if err := os.Rename(tmpName, path); err != nil {
return written, fmt.Errorf("blobstore: commit %q: %w", key, err)
}
return written, nil
}
// Get opens the stored file.
func (l *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) {
path, err := l.resolve(key)
if err != nil {
return nil, err
}
f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, key)
}
if err != nil {
return nil, fmt.Errorf("blobstore: open %q: %w", key, err)
}
return f, nil
}
// Delete removes the stored file, ignoring a missing one.
func (l *LocalFS) Delete(ctx context.Context, key string) error {
path, err := l.resolve(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("blobstore: delete %q: %w", key, err)
}
return nil
}
// resolve maps a key to a path inside the root, rejecting any key that escapes
// it. Keys reach this code from upload filenames, so traversal has to fail
// closed rather than being normalised away.
func (l *LocalFS) resolve(key string) (string, error) {
if key == "" {
return "", errors.New("blobstore: empty key")
}
if filepath.IsAbs(key) {
return "", fmt.Errorf("blobstore: key %q must be relative", key)
}
clean := filepath.Clean("/" + key)
// filepath.Clean on an absolute path collapses any ".." segments.
rel := strings.TrimPrefix(clean, "/")
if rel == "" {
return "", fmt.Errorf("blobstore: key %q resolves to the root", key)
}
path := filepath.Join(l.root, rel)
// Defence in depth: confirm the result really is under the root.
if path != l.root && !strings.HasPrefix(path, l.root+string(os.PathSeparator)) {
return "", fmt.Errorf("blobstore: key %q escapes the store root", key)
}
return path, nil
}