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) }