Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions db/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions db/users_test.go
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
87 changes: 87 additions & 0 deletions handlers/users.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
}
Comment thread
AiAe marked this conversation as resolved.

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 {
Expand Down
97 changes: 97 additions & 0 deletions handlers/users_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading