Files
summercms/modules/cabana/account.go
Jakub Zych 4f1cda33d7 fix(cabana): answer an empty or malformed avatar upload body with 422
- POST /account/avatar with no multipart part (NextPart io.EOF) or a broken
  boundary fell through to the generic 500; it is now the invalid-body 422
- a body over the cap still answers 413 payload_too_large
2026-10-09 17:53:21 +02:00

723 lines
25 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/jackc/pgx/v5/pgconn"
"gocloud.dev/blob"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// metadataSubtitle is the backend_users.metadata key of the admin subtitle,
// the key WinterCMS's Journal author component reads.
const metadataSubtitle = "subtitle"
// AvatarMaxBytes is the largest avatar image POST /account/avatar accepts:
// 2 MiB. http.body_limits.upload_bytes still caps the whole request.
const AvatarMaxBytes int64 = 2 << 20
// avatarThumbEdge is the edge of the avatar's crop thumbnail, WinterCMS's
// myaccount avatar size.
const avatarThumbEdge = 250
// avatarLimits are the attach.Store rules of an avatar: a raster image (jpg,
// jpeg, png, gif or webp, a sniffed image MIME type and the decode guard) of
// at most AvatarMaxBytes.
var avatarLimits = attach.Limits{
MaxBytes: AvatarMaxBytes,
Extensions: attach.DefaultImageExtensions,
MIMETypes: attach.AllowedImageMIMEs,
Image: true,
}
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
const passwordMaxBytes = 72
// accountKeys are the only keys PUT /account accepts. Any other key (login,
// role_id, is_superuser, permissions, is_activated, id and so on) is refused
// with a 422 and nothing is written.
var accountKeys = []string{
"first_name", "last_name", "email", "subtitle",
"current_password", "password", "password_confirmation",
}
// accountRules are the field rules of PUT /account. The confirmation, the
// current password and the email uniqueness are checked in Go afterwards.
var accountRules = []lagoon.RequestRule{
{Field: "first_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "last_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "email", Rules: lagoon.ParseRules("required|string|email|between:6,255")},
{Field: "subtitle", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "current_password", Rules: lagoon.ParseRules("nullable|string")},
{Field: "password", Rules: lagoon.ParseRules("nullable|string|min:4|max:72")},
{Field: "password_confirmation", Rules: lagoon.ParseRules("nullable|string")},
}
// errAccountGone is a signed-in admin whose row is missing or deactivated.
var errAccountGone = errors.New("cabana: account is missing or deactivated")
// errMetadataNotObject is stored metadata that is neither empty, [] nor a
// JSON object; it is never overwritten.
var errMetadataNotObject = errors.New("cabana: backend user metadata is not a JSON object")
// decodeMetadata parses Winter's jsonable metadata. NULL, blank, null and []
// (PHP's json_encode of an empty array) are an empty object. ok is false for
// text that is not a JSON object.
func decodeMetadata(raw *string) (map[string]json.RawMessage, bool) {
if raw == nil {
return map[string]json.RawMessage{}, true
}
text := strings.TrimSpace(*raw)
if text == "" || text == "null" || text == "[]" {
return map[string]json.RawMessage{}, true
}
var out map[string]json.RawMessage
if err := json.Unmarshal([]byte(text), &out); err != nil || out == nil {
return nil, false
}
return out, true
}
// metadataString returns the string value of key in Winter's jsonable
// metadata, or "" when the metadata is empty or malformed or the value is
// not a string.
func metadataString(raw *string, key string) string {
values, ok := decodeMetadata(raw)
if !ok {
return ""
}
encoded, ok := values[key]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(encoded, &s); err != nil {
return ""
}
return s
}
// setMetadataString returns metadata with key set to value, every other key
// kept as stored. An empty value removes the key, and metadata without keys
// is nil (SQL NULL). Stored text that is not empty, [] or a JSON object is
// an error and is never overwritten.
func setMetadataString(raw *string, key, value string) (*string, error) {
values, ok := decodeMetadata(raw)
if !ok {
return nil, errMetadataNotObject
}
if value == "" {
delete(values, key)
} else {
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
values[key] = encoded
}
if len(values) == 0 {
return nil, nil
}
out, err := json.Marshal(values)
if err != nil {
return nil, err
}
text := string(out)
return &text, nil
}
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
// the My account routes. The avatar is looked up through db; a lookup or
// thumbnail failure is logged and leaves the avatar (or its thumb_url) out,
// so the profile never fails on it.
func (s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Subtitle: user.Subtitle(),
IsSuperuser: user.IsSuperuser,
}
if user.Role.ID != 0 {
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
if db != nil {
profile.Avatar = s.avatarOf(ctx, db, user.ID)
}
return profile
}
// avatarOf is the public URL and the 250x250 crop thumbnail URL of an
// admin's avatar, or nil when there is none. A protected row (is_public
// false, possible only in copied data) has no public URL and is left out.
func (s *service) avatarOf(ctx context.Context, db *gorm.DB, userID uint) *AdminAvatar {
f, err := BackendUserAvatar(ctx, db, userID)
if err != nil {
slog.Default().WarnContext(ctx, "cabana: avatar lookup failed", "admin_id", userID, "error", err)
return nil
}
if f == nil || !f.Public() {
return nil
}
out := &AdminAvatar{URL: f.URL()}
if bucket := s.bucket(); bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
thumb, err := f.Thumb(ctx, bucket, avatarThumbEdge, avatarThumbEdge, "crop")
if err != nil {
slog.Default().WarnContext(ctx, "cabana: avatar thumbnail failed", "admin_id", userID, "file_id", f.ID, "error", err)
} else {
out.ThumbURL = thumb
}
}
return out
}
// BackendUserAvatar returns a backend user's avatar: the newest system_files
// row with attachment_type cabana.BackendUserMorphType, field
// cabana.BackendUserAvatarField and the user's id, or nil, nil when there is
// none. Plugins call it to show an author's picture (File.URL, File.Thumb).
func BackendUserAvatar(ctx context.Context, db *gorm.DB, userID uint) (*attach.File, error) {
if db == nil {
return nil, errors.New("cabana: avatar lookup without a database")
}
if ctx == nil {
ctx = context.Background()
}
var files []attach.File
err := db.Session(&gorm.Session{NewDB: true, Context: ctx}).
Where("attachment_type = ? AND field = ? AND attachment_id = ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID)).
Order("id DESC").Limit(1).Find(&files).Error
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, nil
}
return &files[0], nil
}
// accountPrincipal is the signed-in backend principal, or nil after a 401
// was written.
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return nil
}
return principal
}
// lockAccount loads the admin's own row FOR UPDATE with the role, by the
// principal id only. A missing or deactivated row is errAccountGone.
func lockAccount(ctx context.Context, tx *gorm.DB, id uint) (BackendUser, error) {
var user BackendUser
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Role").Where("id = ?", id).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return BackendUser{}, errAccountGone
}
if err != nil {
return BackendUser{}, err
}
if !user.IsActivated {
return BackendUser{}, errAccountGone
}
return user, nil
}
// backendMessage is a backend::lang line in the request locale, or fallback
// when no translator has the key.
func backendMessage(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
full := "backend::lang." + key
if tr != nil && tr.Has(full) {
if s := tr.Get(ctx, full, nil); s != "" && s != full {
return s
}
}
return fallback
}
// accountMessage is a lagoon::validation line for a My account field, with
// Laravel's English text for the rules fieldMessage has no fallback for.
func accountMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
if params == nil {
params = map[string]string{}
}
key := "lagoon::validation." + rule
if tr == nil || !tr.Has(key) {
attr := strings.ReplaceAll(field, "_", " ")
switch rule {
case "confirmed":
return "The " + attr + " confirmation does not match."
case "unique":
return "The " + attr + " has already been taken."
case "max.string":
return "The " + attr + " may not be greater than " + params["max"] + " characters."
}
}
return fieldMessage(ctx, tr, rule, field, params)
}
// accountString is the trimmed string value of key, "" when it is absent,
// null or not a string.
func accountString(body map[string]any, key string) string {
s, _ := body[key].(string)
return strings.TrimSpace(s)
}
// accountUpdate serves PUT /account: the signed-in admin edits their own
// first name, last name, email and subtitle and, with the current password,
// changes the password. The row is chosen by the token subject, never by the
// request. A password change ends every other session (tokens_valid_after)
// and gives a cookie session a fresh cookie.
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeRelationError(w, err)
return
}
tr := s.translator()
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
writeCRUDError(w, &ValidationError{Details: details})
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
// Laravel's TrimStrings: names, email and subtitle are trimmed before
// they are validated; passwords are taken as typed.
for _, key := range []string{"first_name", "last_name", "email", "subtitle"} {
if v, ok := body[key].(string); ok {
body[key] = strings.TrimSpace(v)
}
}
password, _ := body["password"].(string)
changePassword := password != ""
var saved BackendUser
var failedCurrent bool
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
errs, err := lagoon.ValidateRequest(ctx, tx, body, accountRules, tr)
if err != nil {
return err
}
if errs == nil {
errs = map[string][]string{}
}
add := func(field, message string) { errs[field] = append(errs[field], message) }
if changePassword {
if _, failed := errs["password"]; !failed && len(password) > passwordMaxBytes {
add("password", accountMessage(ctx, tr, "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)}))
}
if confirmation, _ := body["password_confirmation"].(string); confirmation != password {
add("password", accountMessage(ctx, tr, "confirmed", "password", nil))
}
current, _ := body["current_password"].(string)
switch {
case len(errs["current_password"]) > 0:
case current == "":
add("current_password", fieldMessage(ctx, tr, "required", "current_password", nil))
case !bouncer.CheckPassword(user.Password, current):
add("current_password", backendMessage(ctx, tr, "myaccount.current_password_invalid", "The current password is incorrect."))
failedCurrent = true
}
}
email := accountString(body, "email")
if len(errs["email"]) == 0 {
taken, err := emailTaken(ctx, tx, user.ID, email)
if err != nil {
return err
}
if taken {
add("email", accountMessage(ctx, tr, "unique", "email", nil))
}
}
now := time.Now().UTC()
updates := map[string]any{
"first_name": accountString(body, "first_name"),
"last_name": accountString(body, "last_name"),
"email": email,
"updated_at": now,
}
if subtitle := accountString(body, "subtitle"); len(errs["subtitle"]) == 0 && subtitle != user.Subtitle() {
metadata, err := setMetadataString(user.Metadata, metadataSubtitle, subtitle)
if err != nil {
add("subtitle", backendMessage(ctx, tr, "myaccount.subtitle_unavailable", "The subtitle cannot be saved because the stored profile metadata is not a JSON object."))
} else {
updates["metadata"] = metadata
}
}
if len(errs) > 0 {
return &ValidationError{Details: accountDetails(errs)}
}
if changePassword {
hash, err := bouncer.HashPassword(s.bcryptCost, password)
if err != nil {
return err
}
updates["password"] = hash
// JWT iat has whole-second precision: the cutoff is the start of
// this second, so the fresh cookie minted after it still passes
// while every token issued in an earlier second is refused.
updates["tokens_valid_after"] = now.Truncate(time.Second)
}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
if emailConflict(err) {
return &ValidationError{Details: fieldDetail("email", accountMessage(ctx, tr, "unique", "email", nil))}
}
return err
}
saved, err = lockAccount(ctx, tx, user.ID)
return err
})
if failedCurrent {
s.logAuth(r, "failed", principal.ID)
}
if err != nil {
s.writeAccountError(w, r, err)
return
}
if changePassword {
s.logAuth(r, "password_changed", principal.ID)
s.revokePresentedToken(r)
if _, fromCookie := sessionToken(r); fromCookie {
token, _, err := bouncer.MintAudience(s.secret, uitoa(principal.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err)
} else {
s.writeSessionCookie(w, token)
}
}
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// revokePresentedToken blacklists the token the request was made with, so
// the session that changed the password cannot be replayed even within the
// second the tokens_valid_after cutoff cannot separate.
func (s *service) revokePresentedToken(r *http.Request) {
if s.bl == nil {
return
}
raw, _ := sessionToken(r)
_, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, s.secret, bouncer.AudienceBackend, s.refreshTTL)
if err != nil {
return
}
expiresAt := iat.Add(s.refreshTTL).Add(time.Minute)
if until := exp.Add(time.Minute); until.After(expiresAt) {
expiresAt = until
}
if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil {
slog.Default().WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err)
}
}
// refusedAccountKeys answers one detail per body key PUT /account does not
// accept.
func refusedAccountKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
var details map[string]any
for key := range body {
if slices.Contains(accountKeys, key) {
continue
}
if details == nil {
details = map[string]any{}
}
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed on My account.")}
}
return details
}
// accountDetails orders each field's messages as lagoon.ErrorKeys ranks the
// fields and returns them as validation details.
func accountDetails(errs map[string][]string) map[string]any {
details := make(map[string]any, len(errs))
for _, key := range lagoon.ErrorKeys(errs, accountRules) {
details[key] = errs[key]
}
return details
}
// emailTaken reports whether email equals, ignoring case, the email or login
// of any other admin, soft-deleted admins included.
func emailTaken(ctx context.Context, tx *gorm.DB, id uint, email string) (bool, error) {
if email == "" {
return false, nil
}
var n int64
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Unscoped().Model(&BackendUser{}).
Where("id <> ? AND (lower(email) = lower(?) OR lower(login) = lower(?))", id, email, email).
Count(&n).Error
return n > 0, err
}
// emailConflict reports a unique violation on a backend_users email index,
// the race the emailTaken check cannot close.
func emailConflict(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
return false
}
return pgErr.ConstraintName == "backend_users_email_lower_unique" || pgErr.ConstraintName == "backend_users_email_key"
}
// writeAccountError maps a My account failure: a missing or deactivated
// admin is 401, a body past the cap 413, validation 422 and anything else a
// logged 500.
func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, errAccountGone) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
var ve *ValidationError
var tooBig *http.MaxBytesError
if !errors.As(err, &ve) && !errors.As(err, &tooBig) {
slog.Default().ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err)
}
writeCRUDError(w, err)
}
// avatarCap is the request body cap of an avatar upload: AvatarMaxBytes plus
// the multipart framing, lowered to http.body_limits.upload_bytes.
func (s *service) avatarCap() int64 {
limit := AvatarMaxBytes + multipartOverhead
if s != nil && s.uploadBytes > 0 && s.uploadBytes < limit {
limit = s.uploadBytes
}
return limit
}
// detachAvatars deletes every avatar row of an admin inside tx and returns
// the blob keys of the originals and their thumbnails. The rows are locked
// first; the caller deletes the blobs after the commit.
func detachAvatars(ctx context.Context, tx *gorm.DB, userID uint, keep uint) ([]string, error) {
var rows []attach.File
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("attachment_type = ? AND field = ? AND attachment_id = ? AND id <> ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID), keep).
Order("id").Find(&rows).Error
if err != nil || len(rows) == 0 {
return nil, err
}
ids := make([]uint, 0, len(rows))
var keys []string
for _, f := range rows {
ids = append(ids, f.ID)
keys = append(keys, attach.BlobKeys(f)...)
}
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id IN ?", ids).Delete(&attach.File{}).Error; err != nil {
return nil, err
}
return keys, nil
}
// deleteAvatarBlobs removes detached avatar blobs once tx has committed; a
// failure is logged and never fails the request.
func deleteAvatarBlobs(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, userID uint, keys []string) {
if len(keys) == 0 {
return
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
if bucket == nil {
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a removed avatar were kept", "admin_id", userID)
return
}
if err := attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys); err != nil {
slog.Default().WarnContext(ctx, "cabana: deleting a removed avatar's blobs failed", "admin_id", userID, "error", err)
}
})
}
// avatarUpload serves POST /account/avatar: one multipart file_data part,
// stored through attach.Store as a public image of at most AvatarMaxBytes
// under a server-generated disk name, attached to the signed-in admin as the
// avatar attachOne. The previous avatar row is deleted in the same
// transaction and its blobs after the commit.
func (s *service) avatarUpload(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a storage bucket")
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.avatarCap())}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
if err != nil {
writeCRUDError(w, invalidBody())
return
}
part, err := mr.NextPart()
if err != nil {
s.writeAvatarError(w, r, body, multipartFailure(body, err))
return
}
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
writeCRUDError(w, invalidBody())
return
}
var stored *attach.File
var saved BackendUser
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
// The row lock serialises concurrent avatar changes of one admin.
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
// Store keeps only a sanitized base name as file_name; the disk name
// and the blob key are generated, never taken from the client.
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: true}, avatarLimits)
if err != nil {
return err
}
stored = f
// Exactly one part: anything after file_data is refused.
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
if err == nil {
return invalidBody()
}
return multipartFailure(body, err)
}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
if err := q.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{
"attachment_id": uitoa(user.ID),
"attachment_type": BackendUserMorphType,
"field": BackendUserAvatarField,
}).Error; err != nil {
return err
}
keys, err := detachAvatars(ctx, tx, user.ID, f.ID)
if err != nil {
return err
}
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
saved = user
return nil
})
if err != nil {
// attach.Store wrote the blob before the row; a rolled-back
// transaction leaves it to this caller.
if stored != nil {
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
}
s.writeAvatarError(w, r, body, err)
return
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// avatarRemove serves DELETE /account/avatar: it deletes the signed-in
// admin's avatar rows now and their blobs after the commit. Removing an
// avatar that is not there also answers 200.
func (s *service) avatarRemove(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: avatar removal without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
bucket := s.bucket()
var saved BackendUser
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
keys, err := detachAvatars(ctx, tx, user.ID, 0)
if err != nil {
return err
}
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
saved = user
return nil
})
if err != nil {
s.writeAccountError(w, r, err)
return
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// multipartFailure is the error of a multipart read: a body past the cap
// stays a *http.MaxBytesError (413), and an empty or malformed body (no
// part at all, a broken boundary) is the invalid-body 422, never a 500.
func multipartFailure(body *bodyReader, err error) error {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) {
return err
}
return invalidBody()
}
// writeAvatarError maps an avatar upload failure as writeFileError does,
// with the field name avatar: a body past the cap is 413, an attach.Store
// refusal a 422 on avatar and a malformed multipart body a 422 on body.
func (s *service) writeAvatarError(w http.ResponseWriter, r *http.Request, body *bodyReader, err error) {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
ctx, tr := r.Context(), s.translator()
var detail string
switch {
case errors.Is(err, attach.ErrTooLarge):
detail = fieldMessage(ctx, tr, "max.file", BackendUserAvatarField, map[string]string{"max": strconv.FormatInt(AvatarMaxBytes/1024, 10)})
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType):
detail = fieldMessage(ctx, tr, "mimes", BackendUserAvatarField, map[string]string{"values": strings.Join(attach.DefaultImageExtensions, ", ")})
case errors.Is(err, attach.ErrNotImage):
detail = fieldMessage(ctx, tr, "image", BackendUserAvatarField, nil)
}
if detail != "" {
writeCRUDError(w, &ValidationError{Details: fieldDetail(BackendUserAvatarField, detail)})
return
}
if body != nil && body.err != nil {
writeCRUDError(w, invalidBody())
return
}
s.writeAccountError(w, r, err)
}