pronounss/backend/routes/v1/user/patch_user.go

412 lines
12 KiB
Go

package user
import (
"fmt"
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/rs/xid"
)
type PatchUserRequest struct {
Username *string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"`
Links *[]string `json:"links"`
Names *[]db.FieldEntry `json:"names"`
Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
Timezone *string `json:"timezone"`
ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
Flags *[]xid.ID `json:"flags"`
}
// patchUser parses a PatchUserRequest and updates the user with the given ID.
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
var req PatchUserRequest
err := render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// get existing user, for comparison later
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting existing user")
}
// validate that *something* is set
if req.Username == nil &&
req.DisplayName == nil &&
req.Bio == nil &&
req.MemberTitle == nil &&
req.ListPrivate == nil &&
req.Links == nil &&
req.Fields == nil &&
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil &&
req.CustomPreferences == nil &&
req.Flags == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
}
}
// validate display name/bio
if common.StringLength(req.Username) > db.MaxUsernameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxUsernameLength, common.StringLength(req.Username)),
}
}
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
}
}
if common.StringLength(req.Bio) > db.MaxUserBioLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Bio)),
}
}
// validate timezone
if req.Timezone != nil {
if *req.Timezone != "" {
_, err := time.LoadLocation(*req.Timezone)
if err != nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%q is not a valid timezone", *req.Timezone),
}
}
}
}
// validate links
if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
}
}
for i, link := range *req.Links {
if len(link) > db.MaxLinkLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
}
}
}
}
// validate flag length
if req.Flags != nil {
if len(*req.Flags) > db.MaxPrideFlags {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
}
}
}
// validate custom preferences
if req.CustomPreferences != nil {
if count := len(*req.CustomPreferences); count > db.MaxFields {
return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)}
}
for k, v := range *req.CustomPreferences {
_, err := uuid.Parse(k)
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."}
}
if s := v.Validate(); s != "" {
return server.APIError{Code: server.ErrBadRequest, Details: s}
}
}
}
customPreferences := u.CustomPreferences
if req.CustomPreferences != nil {
customPreferences = *req.CustomPreferences
}
if err := validateSlicePtr("name", req.Names, customPreferences); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil {
return *err
}
// update avatar
var avatarHash *string = nil
if req.Avatar != nil {
if *req.Avatar == "" {
if u.Avatar != nil {
err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
if err != nil {
log.Errorf("deleting user avatar: %v", err)
return errors.Wrap(err, "deleting avatar")
}
}
avatarHash = req.Avatar
} else {
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar data URI",
}
} else if err == db.ErrInvalidContentType {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar content type",
}
}
log.Errorf("converting user avatar: %v", err)
return errors.Wrap(err, "converting avatar")
}
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil {
log.Errorf("uploading user avatar: %v", err)
return errors.Wrap(err, "uploading avatar")
}
avatarHash = &hash
// delete current avatar if user has one
if u.Avatar != nil {
err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar)
if err != nil {
log.Errorf("deleting existing avatar: %v", err)
}
}
}
}
// start transaction
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
// update username
if req.Username != nil && *req.Username != u.Username {
err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username)
if err != nil {
switch err {
case db.ErrUsernameTaken:
return server.APIError{Code: server.ErrUsernameTaken}
case db.ErrInvalidUsername:
return server.APIError{Code: server.ErrInvalidUsername}
case db.ErrBannedUsername:
return server.APIError{Code: server.ErrInvalidUsername, Details: "That username cannot be used."}
default:
return errors.Wrap(err, "updating username")
}
}
}
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err)
return errors.Wrap(err, "updating user")
}
if req.Names != nil || req.Pronouns != nil {
names := u.Names
pronouns := u.Pronouns
if req.Names != nil {
names = *req.Names
}
if req.Pronouns != nil {
pronouns = *req.Pronouns
}
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err)
return errors.Wrap(err, "setting names/pronouns")
}
u.Names = names
u.Pronouns = pronouns
}
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "setting fields")
}
fields = *req.Fields
} else {
fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "getting fields")
}
}
// update flags
if req.Flags != nil {
err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags)
if err != nil {
if err == db.ErrInvalidFlagID {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
}
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating flags")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return errors.Wrap(err, "committing transaction")
}
// get fedi instance name if the user has a linked fedi account
var fediInstance *string
if u.FediverseAppID != nil {
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
if err == nil {
fediInstance = &app.Instance
}
}
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
}
// echo the updated user back on success
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
Google: u.Google,
GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: fediInstance,
})
return nil
}
type validator interface {
Validate(custom db.CustomPreferences) string
}
// validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
if slice == nil {
return nil
}
max := db.MaxFields
if typ != "field" {
max = db.FieldEntriesLimit
}
// max 25 fields
if len(*slice) > max {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
}
}
// validate all fields
for i, pronouns := range *slice {
if s := pronouns.Validate(custom); s != "" {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
}
}
}
return nil
}
func (s *Server) rerollUserSID(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting existing user")
}
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
return server.APIError{Code: server.ErrRerollingTooQuickly}
}
newID, err := s.DB.RerollUserSID(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "updating user SID")
}
u.SID = newID
render.JSON(w, r, dbUserToResponse(u, nil, nil, nil))
return nil
}