From cb6caffe9875638589ff1fc6a23acf8aa55ce3a3 Mon Sep 17 00:00:00 2001 From: AiAe Date: Sat, 22 Aug 2026 21:23:25 +0300 Subject: [PATCH 1/2] Implement route to update user information fields --- cmd/api/server.go | 1 + db/users.go | 16 +++++++++ db/users_test.go | 28 ++++++++++++++++ handlers/users.go | 73 ++++++++++++++++++++++++++++++++++++++++++ handlers/users_test.go | 68 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 handlers/users_test.go diff --git a/cmd/api/server.go b/cmd/api/server.go index 2f12625..f03bf26 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -146,6 +146,7 @@ func initializeRoutes(engine *gin.Engine) { // User Profile engine.POST("/v2/user/profile/aboutme", middleware.RequireAuth, handlers.CreateHandler(handlers.UpdateUserAboutMe)) + engine.POST("/v2/user/profile/information", middleware.RequireAuth, handlers.CreateHandler(handlers.UpdateUserInformation)) engine.POST("/v2/user/profile/cover", middleware.RequireAuth, handlers.CreateHandler(handlers.UploadUserProfileCover)) engine.GET("/v2/user/profile/username/eligible", middleware.RequireAuth, handlers.CreateHandler(handlers.GetCanUserChangeUsername)) engine.GET("/v2/user/profile/username/available", middleware.RequireAuth, handlers.CreateHandler(handlers.IsUsernameAvailable)) diff --git a/db/users.go b/db/users.go index e345f5d..9ca2f1d 100644 --- a/db/users.go +++ b/db/users.go @@ -410,6 +410,22 @@ func UpdateUserDiscordId(userId int, discordId *string) error { return nil } +// UpdateUserInformation replaces a user's information JSON field. +func UpdateUserInformation(userId int, information UserInformation) error { + marshaled, err := json.Marshal(information) + if err != nil { + return err + } + + result := SQL.Model(&User{}).Where("id = ?", userId).Update("information", string(marshaled)) + + if result.Error != nil { + return result.Error + } + + return nil +} + // UpdateUserAccentColorCustomizable Updates whether the user can update their accent_color func UpdateUserAccentColorCustomizable(userId int, enabled bool) error { result := SQL.Model(&User{}).Where("id = ?", userId).Update("accent_color_customizable", enabled) diff --git a/db/users_test.go b/db/users_test.go index bc0a747..5a19073 100644 --- a/db/users_test.go +++ b/db/users_test.go @@ -1,11 +1,39 @@ package db import ( + "encoding/json" "github.com/Quaver/api2/config" + "github.com/Quaver/api2/enums" "gorm.io/gorm" + "reflect" "testing" ) +func TestUserInformationJSONOmitsEmptyFields(t *testing.T) { + marshaled, err := json.Marshal(UserInformation{ + Discord: "discord", + NotifyMapsetActions: false, + DefaultMode: enums.GameModeKeys7, + }) + if err != nil { + t.Fatal(err) + } + + var got map[string]any + if err := json.Unmarshal(marshaled, &got); err != nil { + t.Fatal(err) + } + + expected := map[string]any{ + "discord": "discord", + "default_mode": float64(enums.GameModeKeys7), + } + + if !reflect.DeepEqual(got, expected) { + t.Fatalf("expected %#v, got %#v", expected, got) + } +} + func TestGetUserById(t *testing.T) { _ = config.Load(testConfigPath) ConnectMySQL() diff --git a/handlers/users.go b/handlers/users.go index 7b2c191..be747ec 100644 --- a/handlers/users.go +++ b/handlers/users.go @@ -1,12 +1,15 @@ package handlers import ( + "bytes" + "encoding/json" "fmt" "github.com/Quaver/api2/db" "github.com/Quaver/api2/enums" "github.com/Quaver/api2/stringutil" "github.com/gin-gonic/gin" "gorm.io/gorm" + "io" "math" "net/http" "regexp" @@ -133,6 +136,76 @@ func UpdateUserAboutMe(c *gin.Context) *APIError { return nil } +// parseUserInformation parses a complete user information update payload. +func parseUserInformation(body io.Reader) (db.UserInformation, error) { + information := db.UserInformation{ + NotifyMapsetActions: true, + DefaultMode: enums.GameModeKeys4, + } + + var raw json.RawMessage + decoder := json.NewDecoder(body) + + if err := decoder.Decode(&raw); err != nil { + return db.UserInformation{}, err + } + + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return db.UserInformation{}, fmt.Errorf("request body must contain a single JSON object") + } + + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || raw[0] != '{' { + return db.UserInformation{}, fmt.Errorf("request body must be a JSON object") + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return db.UserInformation{}, err + } + + for _, value := range fields { + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return db.UserInformation{}, fmt.Errorf("user information fields cannot be null") + } + } + + decoder = json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&information); err != nil { + return db.UserInformation{}, err + } + + if information.DefaultMode != enums.GameModeKeys4 && information.DefaultMode != enums.GameModeKeys7 { + return db.UserInformation{}, fmt.Errorf("default mode must be 1 or 2") + } + + return information, nil +} + +// UpdateUserInformation Updates the authenticated user's information. +// Endpoint: POST /v2/user/profile/information +func UpdateUserInformation(c *gin.Context) *APIError { + user := getAuthedUser(c) + + if user == nil { + return nil + } + + information, err := parseUserInformation(c.Request.Body) + if err != nil { + return APIErrorBadRequest("Invalid request body") + } + + if err := db.UpdateUserInformation(user.Id, information); err != nil { + return APIErrorServerError("Error updating user information", err) + } + + c.JSON(http.StatusOK, gin.H{"message": "Your user information has been successfully updated."}) + return nil +} + // UnbanUser Unbans a user from the game // Endpoint: POST /v2/user/:id/unban func UnbanUser(c *gin.Context) *APIError { diff --git a/handlers/users_test.go b/handlers/users_test.go new file mode 100644 index 0000000..3e15080 --- /dev/null +++ b/handlers/users_test.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "github.com/Quaver/api2/db" + "github.com/Quaver/api2/enums" + "strings" + "testing" +) + +func TestParseUserInformationAppliesDefaults(t *testing.T) { + information, err := parseUserInformation(strings.NewReader(`{"discord":"user#1234"}`)) + if err != nil { + t.Fatal(err) + } + + expected := db.UserInformation{ + Discord: "user#1234", + NotifyMapsetActions: true, + DefaultMode: enums.GameModeKeys4, + } + + if information != expected { + t.Fatalf("expected %#v, got %#v", expected, information) + } +} + +func TestParseUserInformationAcceptsAllFields(t *testing.T) { + information, err := parseUserInformation(strings.NewReader(`{ + "discord":"discord", + "twitter":"twitter", + "twitch":"twitch", + "youtube":"youtube", + "notif_action_mapset":false, + "default_mode":2 + }`)) + if err != nil { + t.Fatal(err) + } + + if information.Discord != "discord" || information.Twitter != "twitter" || + information.Twitch != "twitch" || information.Youtube != "youtube" || + information.NotifyMapsetActions || information.DefaultMode != enums.GameModeKeys7 { + t.Fatalf("unexpected information: %#v", information) + } +} + +func TestParseUserInformationRejectsInvalidBodies(t *testing.T) { + tests := []string{ + `null`, + `[]`, + `{"unknown":"value"}`, + `{"discord":null}`, + `{"discord":123}`, + `{"notif_action_mapset":"true"}`, + `{"default_mode":0}`, + `{"default_mode":3}`, + `{"default_mode":11}`, + `{"discord":"discord"} {}`, + } + + for _, body := range tests { + t.Run(body, func(t *testing.T) { + if _, err := parseUserInformation(strings.NewReader(body)); err == nil { + t.Fatalf("expected body to be rejected: %s", body) + } + }) + } +} From 334a19a135861cd46cbc4338cc6cca8450561fe8 Mon Sep 17 00:00:00 2001 From: AiAe Date: Mon, 24 Aug 2026 16:05:43 +0300 Subject: [PATCH 2/2] Limit information values to 100 characters --- handlers/users.go | 14 ++++++++++++++ handlers/users_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/handlers/users.go b/handlers/users.go index be747ec..4b24a02 100644 --- a/handlers/users.go +++ b/handlers/users.go @@ -15,6 +15,7 @@ import ( "regexp" "strconv" "time" + "unicode/utf8" ) // SearchUsers Searches for users by username and returns them @@ -136,6 +137,8 @@ func UpdateUserAboutMe(c *gin.Context) *APIError { return nil } +const maxUserInformationValueLength = 100 + // parseUserInformation parses a complete user information update payload. func parseUserInformation(body io.Reader) (db.UserInformation, error) { information := db.UserInformation{ @@ -177,6 +180,17 @@ func parseUserInformation(body io.Reader) (db.UserInformation, error) { return db.UserInformation{}, err } + for _, value := range []string{ + information.Discord, + information.Twitter, + information.Twitch, + information.Youtube, + } { + if utf8.RuneCountInString(value) > maxUserInformationValueLength { + return db.UserInformation{}, fmt.Errorf("user information values cannot exceed 100 characters") + } + } + if information.DefaultMode != enums.GameModeKeys4 && information.DefaultMode != enums.GameModeKeys7 { return db.UserInformation{}, fmt.Errorf("default mode must be 1 or 2") } diff --git a/handlers/users_test.go b/handlers/users_test.go index 3e15080..06c8b87 100644 --- a/handlers/users_test.go +++ b/handlers/users_test.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "github.com/Quaver/api2/db" "github.com/Quaver/api2/enums" "strings" @@ -44,6 +45,34 @@ func TestParseUserInformationAcceptsAllFields(t *testing.T) { } } +func TestParseUserInformationAcceptsValuesUpTo100Characters(t *testing.T) { + value := strings.Repeat("a", maxUserInformationValueLength) + body := fmt.Sprintf(`{ + "discord":%q, + "twitter":%q, + "twitch":%q, + "youtube":%q + }`, value, value, value, value) + + if _, err := parseUserInformation(strings.NewReader(body)); err != nil { + t.Fatal(err) + } +} + +func TestParseUserInformationRejectsValuesOver100Characters(t *testing.T) { + value := strings.Repeat("a", maxUserInformationValueLength+1) + fields := []string{"discord", "twitter", "twitch", "youtube"} + + for _, field := range fields { + t.Run(field, func(t *testing.T) { + body := fmt.Sprintf(`{"%s":%q}`, field, value) + if _, err := parseUserInformation(strings.NewReader(body)); err == nil { + t.Fatalf("expected %s to be rejected when longer than %d characters", field, maxUserInformationValueLength) + } + }) + } +} + func TestParseUserInformationRejectsInvalidBodies(t *testing.T) { tests := []string{ `null`,