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:
+21
-28
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user