initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
29
api/internal/blobstore/blobstore.go
Normal file
29
api/internal/blobstore/blobstore.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Package blobstore stores the bytes of uploaded pictures. Keeping it behind an
|
||||
// interface means the local filesystem can later be replaced by object storage
|
||||
// without changing the upload handler.
|
||||
package blobstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a key has no stored object.
|
||||
ErrNotFound = errors.New("blob not found")
|
||||
// ErrTooLarge is returned when the payload exceeds the configured limit.
|
||||
ErrTooLarge = errors.New("blob exceeds maximum size")
|
||||
)
|
||||
|
||||
// BlobStore saves and serves opaque byte payloads addressed by key.
|
||||
type BlobStore interface {
|
||||
// Put stores the contents of r under key and returns the number of bytes
|
||||
// written. It must not read more than maxBytes from r.
|
||||
Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error)
|
||||
// Get opens the object stored under key. The caller closes the reader.
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
// Delete removes the object stored under key. Deleting a missing key is not
|
||||
// an error.
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
132
api/internal/blobstore/localfs.go
Normal file
132
api/internal/blobstore/localfs.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue