package member import ( "fmt" "net/http" "strings" "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/jackc/pgx/v5" ) type CreateMemberRequest struct { Name string `json:"name"` DisplayName *string `json:"display_name"` Bio string `json:"bio"` Avatar string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` Fields []db.Field `json:"fields"` } func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err 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 user") } memberCount, err := s.DB.MemberCount(ctx, claims.UserID) if err != nil { return errors.Wrap(err, "getting member count") } if memberCount > db.MaxMemberCount { return server.APIError{ Code: server.ErrMemberLimitReached, } } var cmr CreateMemberRequest err = render.Decode(r, &cmr) if err != nil { if _, ok := err.(server.APIError); ok { return err } return server.APIError{Code: server.ErrBadRequest} } // remove whitespace from all fields cmr.Name = strings.TrimSpace(cmr.Name) cmr.Bio = strings.TrimSpace(cmr.Bio) if cmr.DisplayName != nil { *cmr.DisplayName = strings.TrimSpace(*cmr.DisplayName) } // validate everything if cmr.Name == "" { return server.APIError{ Code: server.ErrBadRequest, Details: "Name may not be empty", } } else if len(cmr.Name) > 100 { return server.APIError{ Code: server.ErrBadRequest, Details: "Name may not be longer than 100 characters", } } if !db.MemberNameValid(cmr.Name) { return server.APIError{ Code: server.ErrBadRequest, Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.", } } if common.StringLength(&cmr.Name) > db.MaxMemberNameLength { return server.APIError{ Code: server.ErrBadRequest, Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(&cmr.Name)), } } if common.StringLength(cmr.DisplayName) > db.MaxDisplayNameLength { return server.APIError{ Code: server.ErrBadRequest, Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(cmr.DisplayName)), } } if common.StringLength(&cmr.Bio) > db.MaxUserBioLength { return server.APIError{ Code: server.ErrBadRequest, Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(&cmr.Bio)), } } if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil { return *err } if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil { return *err } if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil { return *err } tx, err := s.DB.Begin(ctx) if err != nil { return errors.Wrap(err, "starting transaction") } defer func() { err := tx.Rollback(ctx) if err != nil && !errors.Is(err, pgx.ErrTxClosed) { log.Error("rolling back transaction:", err) } }() m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links) if err != nil { if errors.Cause(err) == db.ErrMemberNameInUse { return server.APIError{Code: server.ErrMemberNameInUse} } return errors.Wrap(err, "creating member") } // set names, pronouns, fields err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns)) if err != nil { log.Errorf("setting names and pronouns for member %v: %v", m.ID, err) return errors.Wrap(err, "setting names/pronouns") } m.Names = cmr.Names m.Pronouns = cmr.Pronouns err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields) if err != nil { log.Errorf("setting fields for member %v: %v", m.ID, err) return errors.Wrap(err, "setting fields") } if cmr.Avatar != "" { webp, jpg, err := s.DB.ConvertAvatar(cmr.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 member avatar: %v", err) return errors.Wrap(err, "converting avatar") } hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) if err != nil { log.Errorf("uploading member avatar: %v", err) return errors.Wrap(err, "uploading avatar") } err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar) if err != nil { return errors.Wrap(err, "setting avatar urls in db") } } // 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 { return errors.Wrap(err, "committing transaction") } render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true)) 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 }