- 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
723 lines
25 KiB
Go
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)
|
|
}
|