refactor: add AppError helpers and harden upload/download error handling

- refactor: replace inline AppError literals with model.New*Error
  constructors in service and handler layers
- feat: PromoteStaged falls back to copy/delete when os.Rename returns
  EXDEV (cross-device link)
- fix: upload error messages no longer leak internal multipart field
  names or parser implementation details
- fix: upload size violations are converted to domain error
  ErrUploadTooLarge at the handler boundary
- fix: download logs error and size mismatch when io.Copy fails or
  writes fewer bytes than expected
- test: add tests for field name leak prevention, parser error
  sanitization, read errors after response start, and EXDEV fallback
This commit is contained in:
2026-07-05 17:35:34 +08:00
parent fc2b9312fa
commit 28e17a5b08
7 changed files with 305 additions and 47 deletions
+21 -28
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
@@ -41,8 +40,6 @@ type FileList struct {
Total int64 `json:"total"`
}
var errUploadTooLarge = errors.New("upload exceeds maximum size")
// FileService handles file management business logic.
type FileService struct {
fileRepo repository.FileRepository
@@ -90,7 +87,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
// Check for name conflicts in the target directory.
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil {
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
return nil, model.NewConflictError("a file with this name already exists in this directory")
} else if !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err)
}
@@ -156,7 +153,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
// Compensate: remove the promoted file.
_ = s.storage.Delete(ctx, storagePath)
if errors.Is(err, model.ErrDuplicate) {
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
return nil, model.NewConflictError("a file with this name already exists in this directory")
}
return nil, model.NewInternalError("create file record", err)
}
@@ -179,7 +176,7 @@ func (r *uploadLimitReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(one[:])
if n > 0 {
p[0] = one[0]
return 1, errUploadTooLarge
return 1, model.ErrUploadTooLarge
}
return 0, err
}
@@ -197,18 +194,14 @@ func isUploadTooLargeError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errUploadTooLarge) {
return true
}
var maxBytesErr *http.MaxBytesError
return errors.As(err, &maxBytesErr)
return errors.Is(err, model.ErrUploadTooLarge)
}
func uploadTooLargeError(maxUploadSize int64) *model.AppError {
if maxUploadSize > 0 {
return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize)}
return model.NewPayloadTooLargeError(fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize), model.ErrUploadTooLarge)
}
return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: "request body is too large"}
return model.NewPayloadTooLargeError("request body is too large", model.ErrUploadTooLarge)
}
// Download returns a reader for the file's content and its metadata.
@@ -220,7 +213,7 @@ func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.R
}
if file.IsDir {
return nil, nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot download a directory"}
return nil, nil, model.NewBadRequestError("cannot download a directory")
}
reader, err := s.storage.Open(ctx, file.StoragePath)
@@ -279,7 +272,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
// If parent is changing, verify the new parent.
if newParentID != nil {
if *newParentID == fileID {
return nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot move a directory into itself"}
return nil, model.NewBadRequestError("cannot move a directory into itself")
}
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
return nil, err
@@ -291,7 +284,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
if newName != "" || newParentID != nil {
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
if err == nil && conflict.ID != fileID {
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"}
return nil, model.NewConflictError("a file with this name already exists in the target directory")
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err)
}
@@ -299,7 +292,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
if err := s.fileRepo.Update(ctx, file); err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"}
return nil, model.NewConflictError("a file with this name already exists in the target directory")
}
return nil, model.NewInternalError("update file", err)
}
@@ -325,7 +318,7 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
return model.NewInternalError("check directory contents", err)
}
if total > 0 {
return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"}
return model.NewConflictError("directory is not empty")
}
}
@@ -350,7 +343,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
// Check for name conflicts in the target directory.
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil {
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
return nil, model.NewConflictError("a file with this name already exists in this directory")
} else if !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err)
}
@@ -366,7 +359,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
if err := s.fileRepo.Create(ctx, dir); err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
return nil, model.NewConflictError("a file with this name already exists in this directory")
}
return nil, model.NewInternalError("create directory record", err)
}
@@ -379,13 +372,13 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (
file, err := s.fileRepo.FindByID(ctx, fileID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, &model.AppError{Status: http.StatusNotFound, Message: "file not found", Err: model.ErrNotFound}
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
}
return nil, model.NewInternalError("find file", err)
}
if file.UserID != userID {
return nil, &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
return nil, model.NewForbiddenError("access denied", model.ErrForbidden)
}
return file, nil
@@ -396,17 +389,17 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
parent, err := s.fileRepo.FindByID(ctx, parentID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return &model.AppError{Status: http.StatusNotFound, Message: "parent directory not found"}
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
}
return model.NewInternalError("find parent", err)
}
if parent.UserID != userID {
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
return model.NewForbiddenError("access denied", model.ErrForbidden)
}
if !parent.IsDir {
return &model.AppError{Status: http.StatusBadRequest, Message: "parent is not a directory"}
return model.NewBadRequestError("parent is not a directory")
}
return nil
@@ -416,13 +409,13 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
// null bytes, or are reserved names "." and "..".
func validateFileName(name string) error {
if name == "" {
return &model.AppError{Status: http.StatusBadRequest, Message: "file name must not be empty"}
return model.NewBadRequestError("file name must not be empty")
}
if strings.ContainsAny(name, "/\\\x00") {
return &model.AppError{Status: http.StatusBadRequest, Message: "file name contains invalid characters"}
return model.NewBadRequestError("file name contains invalid characters")
}
if name == "." || name == ".." {
return &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file name is reserved: %s", name)}
return model.NewBadRequestError(fmt.Sprintf("file name is reserved: %s", name))
}
return nil
}