fix(file)!: stream uploads through staged storage
- fix: replace multipart form parsing with streaming multipart reads and apply request body limits when max_upload_size is configured. - refactor: route uploads through staging paths before promotion to long-term data paths, keeping incomplete uploads out of durable storage records. - test: cover oversized uploads, unlimited uploads, staged cleanup, and local storage promotion boundaries. - docs: document the staged upload model and multipart parent_id query parameter.
This commit is contained in:
@@ -9,6 +9,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
dataPathPrefix = "data"
|
||||
stagingPathPrefix = "staging"
|
||||
)
|
||||
|
||||
// LocalStorage implements StorageBackend using the local filesystem.
|
||||
type LocalStorage struct {
|
||||
basePath string
|
||||
@@ -24,12 +29,15 @@ func NewLocalStorage(basePath string) (*LocalStorage, error) {
|
||||
return &LocalStorage{basePath: abs}, nil
|
||||
}
|
||||
|
||||
// Save writes the contents of reader to path under the storage root.
|
||||
func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||
// SaveStaged writes the contents of reader to a staging path under the storage root.
|
||||
func (s *LocalStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if !isSubPath(s.basePath, fullPath) {
|
||||
return 0, fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||
}
|
||||
if !hasPathPrefix(path, stagingPathPrefix) {
|
||||
return 0, fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil {
|
||||
return 0, fmt.Errorf("create parent directory: %w", err)
|
||||
@@ -51,6 +59,41 @@ func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (i
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// PromoteStaged moves a staged file to its final path under the storage root.
|
||||
func (s *LocalStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
|
||||
fullStagedPath := filepath.Join(s.basePath, stagedPath)
|
||||
if !isSubPath(s.basePath, fullStagedPath) {
|
||||
return fmt.Errorf("storage: path traversal attempt: %s", stagedPath)
|
||||
}
|
||||
if !hasPathPrefix(stagedPath, stagingPathPrefix) {
|
||||
return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, stagedPath)
|
||||
}
|
||||
|
||||
fullFinalPath := filepath.Join(s.basePath, finalPath)
|
||||
if !isSubPath(s.basePath, fullFinalPath) {
|
||||
return fmt.Errorf("storage: path traversal attempt: %s", finalPath)
|
||||
}
|
||||
if !hasPathPrefix(finalPath, dataPathPrefix) {
|
||||
return fmt.Errorf("storage: final path must be under %s/: %s", dataPathPrefix, finalPath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullFinalPath); err == nil {
|
||||
return fmt.Errorf("final file already exists: %s", finalPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("check final file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullFinalPath), 0750); err != nil {
|
||||
return fmt.Errorf("create parent directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(fullStagedPath, fullFinalPath); err != nil {
|
||||
return fmt.Errorf("promote staged file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open returns a reader for the file at path under the storage root.
|
||||
func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
@@ -68,6 +111,23 @@ func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, erro
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// DeleteStaged removes a staged file under the storage root.
|
||||
func (s *LocalStorage) DeleteStaged(_ context.Context, path string) error {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if !isSubPath(s.basePath, fullPath) {
|
||||
return fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||
}
|
||||
if !hasPathPrefix(path, stagingPathPrefix) {
|
||||
return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path)
|
||||
}
|
||||
|
||||
err := os.Remove(fullPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes the file at path under the storage root.
|
||||
func (s *LocalStorage) Delete(_ context.Context, path string) error {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
@@ -95,3 +155,8 @@ func isSubPath(base, target string) bool {
|
||||
// filepath.Rel returns ".." or starts with "../" for paths outside base.
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func hasPathPrefix(path, prefix string) bool {
|
||||
clean := filepath.Clean(path)
|
||||
return clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user