diff --git a/cmd/database/migrations/30_user_profile_version.down.sql b/cmd/database/migrations/30_user_profile_version.down.sql new file mode 100644 index 0000000..3d6c5fd --- /dev/null +++ b/cmd/database/migrations/30_user_profile_version.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + DROP COLUMN profile_version; diff --git a/cmd/database/migrations/30_user_profile_version.up.sql b/cmd/database/migrations/30_user_profile_version.up.sql new file mode 100644 index 0000000..1e52e14 --- /dev/null +++ b/cmd/database/migrations/30_user_profile_version.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN profile_version VARCHAR(16) NOT NULL DEFAULT '' AFTER avatar_url; diff --git a/db/users.go b/db/users.go index e345f5d..eec3c23 100644 --- a/db/users.go +++ b/db/users.go @@ -1,15 +1,18 @@ package db import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" + "strconv" + "strings" + "time" + "github.com/Quaver/api2/enums" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" "gorm.io/gorm" - "strconv" - "strings" - "time" ) type User struct { @@ -28,6 +31,7 @@ type User struct { Country string `gorm:"column:country" json:"country"` IP string `gorm:"column:ip" json:"-"` AvatarUrl *string `gorm:"column:avatar_url" json:"avatar_url"` + ProfileVersion string `gorm:"column:profile_version" json:"profile_version"` Twitter *string `gorm:"column:twitter" json:"twitter"` Title *string `gorm:"column:title" json:"title"` CheckedPreviousAchievements bool `gorm:"column:checked_previous_achievements" json:"-"` @@ -128,6 +132,36 @@ func (u *User) SetClanTagAndColor() error { return nil } +func generateUserProfileVersion() (string, error) { + bytes := make([]byte, 8) + + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + return hex.EncodeToString(bytes), nil +} + +// UpdateUserProfileVersion rotates the version used to cache-bust user profile assets. +func UpdateUserProfileVersion(user *User) error { + version, err := generateUserProfileVersion() + if err != nil { + return err + } + + result := SQL.Model(&User{}). + Where("id = ?", user.Id). + Update("profile_version", version) + + if result.Error != nil { + return result.Error + } + + user.ProfileVersion = version + + return nil +} + // Insert Inserts a new user to the database func (u *User) Insert() error { err := SQL.Transaction(func(tx *gorm.DB) error { diff --git a/db/users_profile_version_test.go b/db/users_profile_version_test.go new file mode 100644 index 0000000..969f51c --- /dev/null +++ b/db/users_profile_version_test.go @@ -0,0 +1,64 @@ +package db + +import ( + "encoding/hex" + "encoding/json" + "testing" +) + +func TestGenerateUserProfileVersion(t *testing.T) { + first, err := generateUserProfileVersion() + if err != nil { + t.Fatal(err) + } + + second, err := generateUserProfileVersion() + if err != nil { + t.Fatal(err) + } + + if len(first) != userProfileVersionBytes*2 { + t.Fatalf("expected profile version length %d, got %d", userProfileVersionBytes*2, len(first)) + } + + if _, err := hex.DecodeString(first); err != nil { + t.Fatalf("profile version is not valid hexadecimal: %v", err) + } + + if first == second { + t.Fatal("expected consecutive profile versions to differ") + } +} + +func TestUserProfileVersionIsSerializedWhenEmpty(t *testing.T) { + data, err := json.Marshal(&User{ProfileVersion: ""}) + if err != nil { + t.Fatal(err) + } + + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + + value, exists := payload["profile_version"] + if !exists { + t.Fatal("expected profile_version to be present in serialized user") + } + + if value != "" { + t.Fatalf("expected empty profile_version, got %v", value) + } +} + +func TestUserBeforeCreateLeavesProfileVersionEmpty(t *testing.T) { + user := &User{} + + if err := user.BeforeCreate(nil); err != nil { + t.Fatal(err) + } + + if user.ProfileVersion != "" { + t.Fatalf("expected profile_version to remain empty, got %q", user.ProfileVersion) + } +} diff --git a/handlers/user_images.go b/handlers/user_images.go index 8199b70..d857425 100644 --- a/handlers/user_images.go +++ b/handlers/user_images.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/Quaver/api2/azure" "github.com/Quaver/api2/cache" + "github.com/Quaver/api2/db" "github.com/Quaver/api2/enums" "github.com/gin-gonic/gin" "net/http" @@ -29,13 +30,17 @@ func UploadUserProfileCover(c *gin.Context) *APIError { } _ = cache.RemoveCacheServerProfileCover(user.Id) - + err := azure.Client.UploadFile("profile-covers", fmt.Sprintf("%v.jpg", user.Id), file) if err != nil { return APIErrorServerError("Failed to upload file", err) } + if err := db.UpdateUserProfileVersion(user); err != nil { + return APIErrorServerError("Failed to update profile version", err) + } + c.JSON(http.StatusOK, gin.H{ "message": "Your profile cover has been successfully updated!", })