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..4b24a02 100644 --- a/handlers/users.go +++ b/handlers/users.go @@ -1,17 +1,21 @@ 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" "strconv" "time" + "unicode/utf8" ) // SearchUsers Searches for users by username and returns them @@ -133,6 +137,89 @@ 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{ + 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 + } + + 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") + } + + 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..06c8b87 --- /dev/null +++ b/handlers/users_test.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "fmt" + "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 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`, + `[]`, + `{"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) + } + }) + } +}