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:
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/middleware"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,13 @@ const (
|
||||
multipartFileField = "file"
|
||||
)
|
||||
|
||||
var (
|
||||
errMissingFileField = errors.New("missing file field")
|
||||
errMissingFileName = errors.New("missing file name")
|
||||
errUnexpectedMultipartField = errors.New("unexpected multipart field")
|
||||
errInvalidMultipartForm = errors.New("invalid multipart form")
|
||||
)
|
||||
|
||||
// FileHandler handles file management endpoints.
|
||||
type FileHandler struct {
|
||||
fileService *service.FileService
|
||||
@@ -111,12 +119,12 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
||||
api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large")
|
||||
return
|
||||
}
|
||||
api.Error(c, http.StatusBadRequest, err.Error())
|
||||
api.Error(c, http.StatusBadRequest, uploadPartErrorMessage(err))
|
||||
return
|
||||
}
|
||||
defer part.Close()
|
||||
|
||||
info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, part)
|
||||
info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, uploadBodyReader{reader: part})
|
||||
if err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
@@ -129,30 +137,55 @@ func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, erro
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, "", errors.New("missing file field")
|
||||
return nil, "", errMissingFileField
|
||||
}
|
||||
return nil, "", err
|
||||
return nil, "", fmt.Errorf("%w: %v", errInvalidMultipartForm, err)
|
||||
}
|
||||
|
||||
if part.FormName() != multipartFileField {
|
||||
part.Close()
|
||||
return nil, "", fmt.Errorf("unexpected multipart field %q", part.FormName())
|
||||
return nil, "", fmt.Errorf("%w: %q", errUnexpectedMultipartField, part.FormName())
|
||||
}
|
||||
|
||||
fileName := part.FileName()
|
||||
if fileName == "" {
|
||||
part.Close()
|
||||
return nil, "", errors.New("missing file name")
|
||||
return nil, "", errMissingFileName
|
||||
}
|
||||
|
||||
return part, fileName, nil
|
||||
}
|
||||
|
||||
func uploadPartErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, errMissingFileField):
|
||||
return "missing file field"
|
||||
case errors.Is(err, errMissingFileName):
|
||||
return "missing file name"
|
||||
case errors.Is(err, errUnexpectedMultipartField):
|
||||
return "unexpected multipart field"
|
||||
default:
|
||||
return "invalid multipart form"
|
||||
}
|
||||
}
|
||||
|
||||
func isRequestBodyTooLarge(err error) bool {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
return errors.As(err, &maxBytesErr)
|
||||
}
|
||||
|
||||
type uploadBodyReader struct {
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (r uploadBodyReader) Read(p []byte) (int, error) {
|
||||
n, err := r.reader.Read(p)
|
||||
if isRequestBodyTooLarge(err) {
|
||||
return n, model.ErrUploadTooLarge
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/files.
|
||||
func (h *FileHandler) List(c *gin.Context) {
|
||||
userID := middleware.MustGetUserID(c)
|
||||
@@ -223,7 +256,23 @@ func (h *FileHandler) Download(c *gin.Context) {
|
||||
c.Header("Content-Length", strconv.FormatInt(info.Size, 10))
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
written, err := io.Copy(c.Writer, reader)
|
||||
if err != nil {
|
||||
slog.WarnContext(c.Request.Context(), "download copy failed",
|
||||
"user_id", userID,
|
||||
"file_id", fileID,
|
||||
"written", written,
|
||||
"expected_size", info.Size,
|
||||
"error", err)
|
||||
return
|
||||
}
|
||||
if written != info.Size {
|
||||
slog.WarnContext(c.Request.Context(), "download size mismatch",
|
||||
"user_id", userID,
|
||||
"file_id", fileID,
|
||||
"written", written,
|
||||
"expected_size", info.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/files/:id.
|
||||
|
||||
Reference in New Issue
Block a user