loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add configurable cooldown to claim usernames (#6422)

Add a new option that allows instances to set a cooldown period to claim
old usernames. In the context of public instances this can be used to
prevent old usernames to be claimed after they are free and allow
graceful migration (by making use of the redirect feature) to a new
username. The granularity of this cooldown is a day. By default this
feature is disabled and thus no cooldown period.

The `CreatedUnix` column is added the `user_redirect` table, for
existing redirects the timestamp is simply zero as we simply do not know
when they were created and are likely already over the cooldown period
if the instance configures one.

Users can always reclaim their 'old' user name again within the cooldown
period. Users can also always reclaim 'old' names of organization they
currently own within the cooldown period.

Creating and renaming users as an admin user are not affected by the
cooldown period for moderation and user support reasons.

To avoid abuse of the cooldown feature, such that a user holds a lot of
usernames, a new option is added `MAX_USER_REDIRECTS` which sets a limit
to the amount of user redirects a user may have, by default this is
disabled. If a cooldown period is set then the default is 5. This
feature operates independently of the cooldown period feature.

Added integration and unit testing.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6422
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>

authored by

Gusted
Gusted
and committed by
Gusted
a9c97110 a9c4a25f

+561 -16
+1
models/fixtures/user_redirect.yml
··· 2 2 id: 1 3 3 lower_name: olduser1 4 4 redirect_user_id: 1 5 + created_unix: 1730000000
+2
models/forgejo_migrations/migrate.go
··· 90 90 NewMigration("Migrate `secret` column to store keying material", MigrateTwoFactorToKeying), 91 91 // v26 -> v27 92 92 NewMigration("Add `hash_blake2b` column to `package_blob` table", AddHashBlake2bToPackageBlob), 93 + // v27 -> v28 94 + NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect), 93 95 } 94 96 95 97 // GetCurrentDBVersion returns the current Forgejo database version.
+18
models/forgejo_migrations/v27.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import ( 7 + "code.gitea.io/gitea/modules/timeutil" 8 + 9 + "xorm.io/xorm" 10 + ) 11 + 12 + func AddCreatedUnixToRedirect(x *xorm.Engine) error { 13 + type UserRedirect struct { 14 + ID int64 `xorm:"pk autoincr"` 15 + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL DEFAULT 0"` 16 + } 17 + return x.Sync(new(UserRedirect)) 18 + }
+94 -7
models/user/redirect.go
··· 6 6 import ( 7 7 "context" 8 8 "fmt" 9 + "slices" 10 + "strconv" 9 11 "strings" 12 + "time" 10 13 11 14 "code.gitea.io/gitea/models/db" 15 + "code.gitea.io/gitea/modules/setting" 16 + "code.gitea.io/gitea/modules/timeutil" 12 17 "code.gitea.io/gitea/modules/util" 18 + 19 + "xorm.io/builder" 13 20 ) 14 21 15 22 // ErrUserRedirectNotExist represents a "UserRedirectNotExist" kind of error. ··· 31 38 return util.ErrNotExist 32 39 } 33 40 41 + type ErrCooldownPeriod struct { 42 + ExpireTime time.Time 43 + } 44 + 45 + func IsErrCooldownPeriod(err error) bool { 46 + _, ok := err.(ErrCooldownPeriod) 47 + return ok 48 + } 49 + 50 + func (err ErrCooldownPeriod) Error() string { 51 + return fmt.Sprintf("cooldown period for claiming this username has not yet expired: the cooldown period ends at %s", err.ExpireTime) 52 + } 53 + 34 54 // Redirect represents that a user name should be redirected to another 35 55 type Redirect struct { 36 - ID int64 `xorm:"pk autoincr"` 37 - LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` 38 - RedirectUserID int64 // userID to redirect to 56 + ID int64 `xorm:"pk autoincr"` 57 + LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` 58 + RedirectUserID int64 // userID to redirect to 59 + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL DEFAULT 0"` 39 60 } 40 61 41 62 // TableName provides the real table name ··· 47 68 db.RegisterModel(new(Redirect)) 48 69 } 49 70 50 - // LookupUserRedirect look up userID if a user has a redirect name 51 - func LookupUserRedirect(ctx context.Context, userName string) (int64, error) { 71 + // GetUserRedirect returns the redirect for a given username, this is a 72 + // case-insenstive operation. 73 + func GetUserRedirect(ctx context.Context, userName string) (*Redirect, error) { 52 74 userName = strings.ToLower(userName) 53 75 redirect := &Redirect{LowerName: userName} 54 76 if has, err := db.GetEngine(ctx).Get(redirect); err != nil { 77 + return nil, err 78 + } else if !has { 79 + return nil, ErrUserRedirectNotExist{Name: userName} 80 + } 81 + return redirect, nil 82 + } 83 + 84 + // LookupUserRedirect look up userID if a user has a redirect name 85 + func LookupUserRedirect(ctx context.Context, userName string) (int64, error) { 86 + redirect, err := GetUserRedirect(ctx, userName) 87 + if err != nil { 55 88 return 0, err 56 - } else if !has { 57 - return 0, ErrUserRedirectNotExist{Name: userName} 58 89 } 59 90 return redirect.RedirectUserID, nil 60 91 } ··· 78 109 }) 79 110 } 80 111 112 + // LimitUserRedirects deletes the oldest entries in user_redirect of the user, 113 + // such that the amount of user_redirects is at most `n` amount of entries. 114 + func LimitUserRedirects(ctx context.Context, userID, n int64) error { 115 + // NOTE: It's not possible to combine these two queries into one due to a limitation of MySQL. 116 + keepIDs := make([]int64, n) 117 + if err := db.GetEngine(ctx).SQL("SELECT id FROM user_redirect WHERE redirect_user_id = ? ORDER BY created_unix DESC LIMIT "+strconv.FormatInt(n, 10), userID).Find(&keepIDs); err != nil { 118 + return err 119 + } 120 + 121 + _, err := db.GetEngine(ctx).Exec(builder.Delete(builder.And(builder.Eq{"redirect_user_id": userID}, builder.NotIn("id", keepIDs))).From("user_redirect")) 122 + return err 123 + } 124 + 81 125 // DeleteUserRedirect delete any redirect from the specified user name to 82 126 // anything else 83 127 func DeleteUserRedirect(ctx context.Context, userName string) error { ··· 85 129 _, err := db.GetEngine(ctx).Delete(&Redirect{LowerName: userName}) 86 130 return err 87 131 } 132 + 133 + // CanClaimUsername returns if its possible to claim the given username, 134 + // it checks if the cooldown period for claiming an existing username is over. 135 + // If there's a cooldown period, the second argument returns the time when 136 + // that cooldown period is over. 137 + // In the scenario of renaming, the doerID can be specified to allow the original 138 + // user of the username to reclaim it within the cooldown period. 139 + func CanClaimUsername(ctx context.Context, username string, doerID int64) (bool, time.Time, error) { 140 + // Only check for a cooldown period if UsernameCooldownPeriod is a positive number. 141 + if setting.Service.UsernameCooldownPeriod <= 0 { 142 + return true, time.Time{}, nil 143 + } 144 + 145 + userRedirect, err := GetUserRedirect(ctx, username) 146 + if err != nil { 147 + if IsErrUserRedirectNotExist(err) { 148 + return true, time.Time{}, nil 149 + } 150 + return false, time.Time{}, err 151 + } 152 + 153 + // Allow reclaiming of user's own username. 154 + if userRedirect.RedirectUserID == doerID { 155 + return true, time.Time{}, nil 156 + } 157 + 158 + // We do not know if the redirect user id was for an organization, so 159 + // unconditionally execute the following query to retrieve all users that 160 + // are part of the "Owner" team. If the redirect user ID is not an organization 161 + // the returned list would be empty. 162 + ownerTeamUIDs := []int64{} 163 + if err := db.GetEngine(ctx).SQL("SELECT uid FROM team_user INNER JOIN team ON team_user.`team_id` = team.`id` WHERE team.`org_id` = ? AND team.`name` = 'Owners'", userRedirect.RedirectUserID).Find(&ownerTeamUIDs); err != nil { 164 + return false, time.Time{}, err 165 + } 166 + 167 + if slices.Contains(ownerTeamUIDs, doerID) { 168 + return true, time.Time{}, nil 169 + } 170 + 171 + // Multiply the value of UsernameCooldownPeriod by the amount of seconds in a day. 172 + expireTime := userRedirect.CreatedUnix.Add(86400 * setting.Service.UsernameCooldownPeriod).AsLocalTime() 173 + return time.Until(expireTime) <= 0, expireTime, nil 174 + }
+12
models/user/user.go
··· 669 669 return err 670 670 } 671 671 672 + // Check if the new username can be claimed. 673 + // Skip this check if done by an admin. 674 + if !createdByAdmin { 675 + if ok, expireTime, err := CanClaimUsername(ctx, u.Name, -1); err != nil { 676 + return err 677 + } else if !ok { 678 + return ErrCooldownPeriod{ 679 + ExpireTime: expireTime, 680 + } 681 + } 682 + } 683 + 672 684 // set system defaults 673 685 u.KeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate 674 686 u.Visibility = setting.Service.DefaultUserVisibilityMode
+25
models/user/user_test.go
··· 393 393 assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd) 394 394 } 395 395 396 + func TestCreateUserClaimingUsername(t *testing.T) { 397 + require.NoError(t, unittest.PrepareTestDatabase()) 398 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 1)() 399 + 400 + _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(&user_model.Redirect{RedirectUserID: 1, LowerName: "redirecting", CreatedUnix: timeutil.TimeStampNow()}) 401 + require.NoError(t, err) 402 + 403 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 404 + 405 + user.Name = "redirecting" 406 + user.LowerName = strings.ToLower(user.Name) 407 + user.ID = 0 408 + user.Email = "unique@example.com" 409 + 410 + t.Run("Normal creation", func(t *testing.T) { 411 + err = user_model.CreateUser(db.DefaultContext, user) 412 + assert.True(t, user_model.IsErrCooldownPeriod(err)) 413 + }) 414 + 415 + t.Run("Creation as admin", func(t *testing.T) { 416 + err = user_model.AdminCreateUser(db.DefaultContext, user) 417 + require.NoError(t, err) 418 + }) 419 + } 420 + 396 421 func TestGetUserIDsByNames(t *testing.T) { 397 422 require.NoError(t, unittest.PrepareTestDatabase()) 398 423
+39
modules/setting/server_test.go
··· 9 9 "code.gitea.io/gitea/modules/test" 10 10 11 11 "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 12 13 ) 13 14 14 15 func TestDisplayNameDefault(t *testing.T) { ··· 34 35 displayName := generateDisplayName() 35 36 assert.Equal(t, "Forgejo - Beyond coding. We Forge.", displayName) 36 37 } 38 + 39 + func TestMaxUserRedirectsDefault(t *testing.T) { 40 + iniStr := `` 41 + cfg, err := NewConfigProviderFromData(iniStr) 42 + require.NoError(t, err) 43 + loadServiceFrom(cfg) 44 + 45 + assert.EqualValues(t, 0, Service.UsernameCooldownPeriod) 46 + assert.EqualValues(t, 0, Service.MaxUserRedirects) 47 + 48 + iniStr = `[service] 49 + MAX_USER_REDIRECTS = 8` 50 + cfg, err = NewConfigProviderFromData(iniStr) 51 + require.NoError(t, err) 52 + loadServiceFrom(cfg) 53 + 54 + assert.EqualValues(t, 0, Service.UsernameCooldownPeriod) 55 + assert.EqualValues(t, 8, Service.MaxUserRedirects) 56 + 57 + iniStr = `[service] 58 + USERNAME_COOLDOWN_PERIOD = 3` 59 + cfg, err = NewConfigProviderFromData(iniStr) 60 + require.NoError(t, err) 61 + loadServiceFrom(cfg) 62 + 63 + assert.EqualValues(t, 3, Service.UsernameCooldownPeriod) 64 + assert.EqualValues(t, 5, Service.MaxUserRedirects) 65 + 66 + iniStr = `[service] 67 + USERNAME_COOLDOWN_PERIOD = 3 68 + MAX_USER_REDIRECTS = 8` 69 + cfg, err = NewConfigProviderFromData(iniStr) 70 + require.NoError(t, err) 71 + loadServiceFrom(cfg) 72 + 73 + assert.EqualValues(t, 3, Service.UsernameCooldownPeriod) 74 + assert.EqualValues(t, 8, Service.MaxUserRedirects) 75 + }
+10
modules/setting/service.go
··· 85 85 DefaultOrgMemberVisible bool 86 86 UserDeleteWithCommentsMaxTime time.Duration 87 87 ValidSiteURLSchemes []string 88 + UsernameCooldownPeriod int64 89 + MaxUserRedirects int64 88 90 89 91 // OpenID settings 90 92 EnableOpenIDSignIn bool ··· 257 259 } 258 260 } 259 261 Service.ValidSiteURLSchemes = schemes 262 + Service.UsernameCooldownPeriod = sec.Key("USERNAME_COOLDOWN_PERIOD").MustInt64(0) 263 + 264 + // Only set a default if USERNAME_COOLDOWN_PERIOD's feature is active. 265 + maxUserRedirectsDefault := int64(0) 266 + if Service.UsernameCooldownPeriod > 0 { 267 + maxUserRedirectsDefault = 5 268 + } 269 + Service.MaxUserRedirects = sec.Key("MAX_USER_REDIRECTS").MustInt64(maxUserRedirectsDefault) 260 270 261 271 mustMapSetting(rootCfg, "service.explore", &Service.Explore) 262 272
+5
options/locale/locale_en-US.ini
··· 630 630 631 631 username_been_taken = The username is already taken. 632 632 username_change_not_local_user = Non-local users are not allowed to change their username. 633 + username_claiming_cooldown = The username cannot be claimed, because its cooldown period is not yet over. It can be claimed on %[1]s. 633 634 repo_name_been_taken = The repository name is already used. 634 635 repository_force_private = Force Private is enabled: private repositories cannot be made public. 635 636 repository_files_already_exist = Files already exist for this repository. Contact the system administrator. ··· 765 766 change_username = Your username has been changed. 766 767 change_username_prompt = Note: Changing your username also changes your account URL. 767 768 change_username_redirect_prompt = The old username will redirect until someone claims it. 769 + change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old username during the cooldown period. 770 + change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old username during the cooldown period. 768 771 continue = Continue 769 772 cancel = Cancel 770 773 language = Language ··· 2883 2886 settings.update_setting_success = Organization settings have been updated. 2884 2887 settings.change_orgname_prompt = Note: Changing the organization name will also change your organization's URL and free the old name. 2885 2888 settings.change_orgname_redirect_prompt = The old name will redirect until it is claimed. 2889 + settings.change_orgname_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old username during the cooldown period. 2890 + settings.change_orgname_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old username during the cooldown period. 2886 2891 settings.update_avatar_success = The organization's avatar has been updated. 2887 2892 settings.delete = Delete organization 2888 2893 settings.delete_account = Delete this organization
+1 -1
routers/api/v1/admin/user.go
··· 523 523 newName := web.GetForm(ctx).(*api.RenameUserOption).NewName 524 524 525 525 // Check if user name has been changed 526 - if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil { 526 + if err := user_service.AdminRenameUser(ctx, ctx.ContextUser, newName); err != nil { 527 527 switch { 528 528 case user_model.IsErrUserAlreadyExist(err): 529 529 ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken"))
+1 -1
routers/web/admin/users.go
··· 349 349 } 350 350 351 351 if form.UserName != "" { 352 - if err := user_service.RenameUser(ctx, u, form.UserName); err != nil { 352 + if err := user_service.AdminRenameUser(ctx, u, form.UserName); err != nil { 353 353 switch { 354 354 case user_model.IsErrUserIsNotLocal(err): 355 355 ctx.Data["Err_UserName"] = true
+4
routers/web/auth/auth.go
··· 9 9 "fmt" 10 10 "net/http" 11 11 "strings" 12 + "time" 12 13 13 14 "code.gitea.io/gitea/models/auth" 14 15 "code.gitea.io/gitea/models/db" ··· 555 556 case user_model.IsErrEmailAlreadyUsed(err): 556 557 ctx.Data["Err_Email"] = true 557 558 ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form) 559 + case user_model.IsErrCooldownPeriod(err): 560 + ctx.Data["Err_UserName"] = true 561 + ctx.RenderWithErr(ctx.Locale.Tr("form.username_claiming_cooldown", err.(user_model.ErrCooldownPeriod).ExpireTime.Format(time.RFC1123Z)), tpl, form) 558 562 case validation.IsErrEmailCharIsNotSupported(err): 559 563 ctx.Data["Err_Email"] = true 560 564 ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form)
+6
routers/web/org/setting.go
··· 7 7 import ( 8 8 "net/http" 9 9 "net/url" 10 + "time" 10 11 11 12 "code.gitea.io/gitea/models" 12 13 "code.gitea.io/gitea/models/db" ··· 48 49 ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility 49 50 ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess 50 51 ctx.Data["ContextUser"] = ctx.ContextUser 52 + ctx.Data["CooldownPeriod"] = setting.Service.UsernameCooldownPeriod 51 53 52 54 err := shared_user.LoadHeaderCount(ctx) 53 55 if err != nil { ··· 65 67 ctx.Data["PageIsOrgSettings"] = true 66 68 ctx.Data["PageIsSettingsOptions"] = true 67 69 ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility 70 + ctx.Data["CooldownPeriod"] = setting.Service.UsernameCooldownPeriod 68 71 69 72 if ctx.HasError() { 70 73 ctx.HTML(http.StatusOK, tplSettingsOptions) ··· 78 81 if user_model.IsErrUserAlreadyExist(err) { 79 82 ctx.Data["Err_Name"] = true 80 83 ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) 84 + } else if user_model.IsErrCooldownPeriod(err) { 85 + ctx.Data["Err_UserName"] = true 86 + ctx.RenderWithErr(ctx.Locale.Tr("form.username_claiming_cooldown", err.(user_model.ErrCooldownPeriod).ExpireTime.Format(time.RFC1123Z)), tplSettingsOptions, form) 81 87 } else if db.IsErrNameReserved(err) { 82 88 ctx.Data["Err_Name"] = true 83 89 ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form)
+5
routers/web/user/setting/profile.go
··· 14 14 "path/filepath" 15 15 "slices" 16 16 "strings" 17 + "time" 17 18 18 19 "code.gitea.io/gitea/models/avatars" 19 20 "code.gitea.io/gitea/models/db" ··· 51 52 ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() 52 53 ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) 53 54 ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns) 55 + ctx.Data["CooldownPeriod"] = setting.Service.UsernameCooldownPeriod 54 56 55 57 ctx.HTML(http.StatusOK, tplSettingsProfile) 56 58 } ··· 62 64 ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() 63 65 ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) 64 66 ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns) 67 + ctx.Data["CooldownPeriod"] = setting.Service.UsernameCooldownPeriod 65 68 66 69 if ctx.HasError() { 67 70 ctx.HTML(http.StatusOK, tplSettingsProfile) ··· 77 80 ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) 78 81 case user_model.IsErrUserAlreadyExist(err): 79 82 ctx.Flash.Error(ctx.Tr("form.username_been_taken")) 83 + case user_model.IsErrCooldownPeriod(err): 84 + ctx.Flash.Error(ctx.Tr("form.username_claiming_cooldown", err.(user_model.ErrCooldownPeriod).ExpireTime.Format(time.RFC1123Z))) 80 85 case db.IsErrNameReserved(err): 81 86 ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name)) 82 87 case db.IsErrNamePatternNotAllowed(err):
+26
services/user/user.go
··· 33 33 34 34 // RenameUser renames a user 35 35 func RenameUser(ctx context.Context, u *user_model.User, newUserName string) error { 36 + return renameUser(ctx, u, newUserName, false) 37 + } 38 + 39 + // RenameUser renames a user as an admin. 40 + func AdminRenameUser(ctx context.Context, u *user_model.User, newUserName string) error { 41 + return renameUser(ctx, u, newUserName, true) 42 + } 43 + 44 + func renameUser(ctx context.Context, u *user_model.User, newUserName string, doerIsAdmin bool) error { 36 45 if newUserName == u.Name { 37 46 return nil 38 47 } ··· 49 58 return err 50 59 } 51 60 61 + // Check if the new username can be claimed. 62 + if !doerIsAdmin { 63 + if ok, expireTime, err := user_model.CanClaimUsername(ctx, newUserName, u.ID); err != nil { 64 + return err 65 + } else if !ok { 66 + return user_model.ErrCooldownPeriod{ 67 + ExpireTime: expireTime, 68 + } 69 + } 70 + } 71 + 52 72 onlyCapitalization := strings.EqualFold(newUserName, u.Name) 53 73 oldUserName := u.Name 54 74 ··· 83 103 84 104 if err = user_model.NewUserRedirect(ctx, u.ID, oldUserName, newUserName); err != nil { 85 105 return err 106 + } 107 + 108 + if setting.Service.MaxUserRedirects > 0 { 109 + if err := user_model.LimitUserRedirects(ctx, u.ID, setting.Service.MaxUserRedirects); err != nil { 110 + return err 111 + } 86 112 } 87 113 88 114 if err := agit.UserNameChanged(ctx, u, newUserName); err != nil {
+23
services/user/user_test.go
··· 199 199 200 200 unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: user.Name}) 201 201 }) 202 + 203 + t.Run("Keep N redirects", func(t *testing.T) { 204 + defer test.MockProtect(&setting.Service.MaxUserRedirects)() 205 + // Start clean 206 + unittest.AssertSuccessfulDelete(t, &user_model.Redirect{RedirectUserID: user.ID}) 207 + 208 + setting.Service.MaxUserRedirects = 1 209 + 210 + require.NoError(t, RenameUser(db.DefaultContext, user, "redirect-1")) 211 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "user_rename"}) 212 + 213 + // The granularity of created_unix is a second. 214 + time.Sleep(time.Second) 215 + require.NoError(t, RenameUser(db.DefaultContext, user, "redirect-2")) 216 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "user_rename"}) 217 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "redirect-1"}) 218 + 219 + setting.Service.MaxUserRedirects = 2 220 + time.Sleep(time.Second) 221 + require.NoError(t, RenameUser(db.DefaultContext, user, "redirect-3")) 222 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "redirect-1"}) 223 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "redirect-2"}) 224 + }) 202 225 } 203 226 204 227 func TestCreateUser_Issue5882(t *testing.T) {
+11 -7
templates/org/settings/options.tmpl
··· 6 6 <div class="ui attached segment"> 7 7 <form class="ui form" action="{{.Link}}" method="post"> 8 8 {{.CsrfTokenHtml}} 9 - <div class="required field {{if .Err_Name}}error{{end}}"> 10 - <label for="org_name">{{ctx.Locale.Tr "org.org_name_holder"}} 11 - <span class="text red tw-hidden" id="org-name-change-prompt"> 12 - <br>{{ctx.Locale.Tr "org.settings.change_orgname_prompt"}}<br>{{ctx.Locale.Tr "org.settings.change_orgname_redirect_prompt"}} 13 - </span> 14 - </label> 9 + <label {{if .Err_Name}}class="field error"{{end}}> 10 + {{ctx.Locale.Tr "org.org_name_holder"}} 15 11 <input id="org_name" name="name" value="{{.Org.Name}}" data-org-name="{{.Org.Name}}" autofocus required maxlength="40"> 16 - </div> 12 + <span class="help"> 13 + {{ctx.Locale.Tr "org.settings.change_orgname_prompt"}} 14 + {{if gt .CooldownPeriod 0}} 15 + {{ctx.Locale.TrN .CooldownPeriod "org.settings.change_orgname_redirect_prompt.with_cooldown.one" "org.settings.change_orgname_redirect_prompt.with_cooldown.few" .CooldownPeriod}}</span> 16 + {{else}} 17 + {{ctx.Locale.Tr "org.settings.change_orgname_redirect_prompt"}} 18 + {{end}} 19 + </span> 20 + </label> 17 21 <div class="field {{if .Err_FullName}}error{{end}}"> 18 22 <label for="full_name">{{ctx.Locale.Tr "org.org_full_name_holder"}}</label> 19 23 <input id="full_name" name="full_name" value="{{.Org.FullName}}" maxlength="100">
+4
templates/user/settings/profile.tmpl
··· 16 16 {{else}} 17 17 <span class="help"> 18 18 {{ctx.Locale.Tr "settings.change_username_prompt"}} 19 + {{if gt .CooldownPeriod 0}} 20 + {{ctx.Locale.TrN .CooldownPeriod "settings.change_username_redirect_prompt.with_cooldown.one" "settings.change_username_redirect_prompt.with_cooldown.few" .CooldownPeriod}}</span> 21 + {{else}} 19 22 {{ctx.Locale.Tr "settings.change_username_redirect_prompt"}} 23 + {{end}} 20 24 </span> 21 25 {{end}} 22 26 </label>
+274
tests/integration/user_redirect_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package integration 5 + 6 + import ( 7 + "net/http" 8 + "testing" 9 + "time" 10 + 11 + "code.gitea.io/gitea/models/db" 12 + "code.gitea.io/gitea/models/unittest" 13 + user_model "code.gitea.io/gitea/models/user" 14 + "code.gitea.io/gitea/modules/setting" 15 + "code.gitea.io/gitea/modules/test" 16 + "code.gitea.io/gitea/modules/timeutil" 17 + forgejo_context "code.gitea.io/gitea/services/context" 18 + "code.gitea.io/gitea/tests" 19 + 20 + "github.com/stretchr/testify/assert" 21 + "github.com/stretchr/testify/require" 22 + ) 23 + 24 + func TestUserRedirect(t *testing.T) { 25 + defer tests.PrepareTestEnv(t)() 26 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 1)() 27 + 28 + session := loginUser(t, "user2") 29 + 30 + t.Run("Rename user normally", func(t *testing.T) { 31 + defer tests.PrintCurrentTest(t)() 32 + 33 + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ 34 + "_csrf": GetCSRF(t, session, "/user/settings"), 35 + "name": "user2-new", 36 + }) 37 + session.MakeRequest(t, req, http.StatusSeeOther) 38 + 39 + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) 40 + assert.NotNil(t, flashCookie) 41 + assert.EqualValues(t, "success%3DYour%2Bprofile%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value) 42 + 43 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "user2", RedirectUserID: 2}) 44 + }) 45 + 46 + t.Run("Create new user", func(t *testing.T) { 47 + defer tests.PrintCurrentTest(t)() 48 + 49 + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ 50 + "_csrf": GetCSRF(t, emptyTestSession(t), "/user/sign_up"), 51 + "user_name": "user2", 52 + "email": "doesnotexist@example.com", 53 + "password": "examplePassword!1", 54 + "retype": "examplePassword!1", 55 + }) 56 + resp := MakeRequest(t, req, http.StatusOK) 57 + 58 + htmlDoc := NewHTMLParser(t, resp.Body) 59 + flashMessage := htmlDoc.Find(`.flash-message`).Text() 60 + assert.Contains(t, flashMessage, "The username cannot be claimed, because its cooldown period is not yet over. It can be claimed on") 61 + }) 62 + 63 + t.Run("Rename another user", func(t *testing.T) { 64 + defer tests.PrintCurrentTest(t)() 65 + 66 + session := loginUser(t, "user4") 67 + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ 68 + "_csrf": GetCSRF(t, session, "/user/settings"), 69 + "name": "user2", 70 + }) 71 + session.MakeRequest(t, req, http.StatusSeeOther) 72 + 73 + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) 74 + assert.NotNil(t, flashCookie) 75 + assert.Contains(t, flashCookie.Value, "error%3DThe%2Busername%2Bcannot%2Bbe%2Bclaimed%252C%2Bbecause%2Bits%2Bcooldown%2Bperiod%2Bis%2Bnot%2Byet%2Bover.%2BIt%2Bcan%2Bbe%2Bclaimed%2Bon") 76 + }) 77 + 78 + t.Run("Admin rename user", func(t *testing.T) { 79 + defer tests.PrintCurrentTest(t)() 80 + 81 + session := loginUser(t, "user1") 82 + req := NewRequestWithValues(t, "POST", "/admin/users/4/edit", map[string]string{ 83 + "_csrf": GetCSRF(t, session, "/admin/users/4/edit"), 84 + "user_name": "user2", 85 + "email": "user4@example.com", 86 + "login_type": "0-0", 87 + }) 88 + session.MakeRequest(t, req, http.StatusSeeOther) 89 + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) 90 + assert.NotNil(t, flashCookie) 91 + assert.EqualValues(t, "success%3DThe%2Buser%2Baccount%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value) 92 + 93 + unittest.AssertExistsIf(t, true, &user_model.User{LowerName: "user2"}) 94 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "user2", RedirectUserID: 2}) 95 + }) 96 + 97 + t.Run("Reclaim username", func(t *testing.T) { 98 + defer tests.PrintCurrentTest(t)() 99 + 100 + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ 101 + "_csrf": GetCSRF(t, session, "/user/settings"), 102 + "name": "user2-new-2", 103 + }) 104 + session.MakeRequest(t, req, http.StatusSeeOther) 105 + 106 + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) 107 + assert.NotNil(t, flashCookie) 108 + assert.EqualValues(t, "success%3DYour%2Bprofile%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value) 109 + 110 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "user2-new", RedirectUserID: 2}) 111 + 112 + req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ 113 + "_csrf": GetCSRF(t, session, "/user/settings"), 114 + "name": "user2-new", 115 + }) 116 + session.MakeRequest(t, req, http.StatusSeeOther) 117 + 118 + flashCookie = session.GetCookie(forgejo_context.CookieNameFlash) 119 + assert.NotNil(t, flashCookie) 120 + assert.EqualValues(t, "success%3DYour%2Bprofile%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value) 121 + 122 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "user2-new", RedirectUserID: 2}) 123 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "user2-new-2", RedirectUserID: 2}) 124 + }) 125 + 126 + t.Run("Profile note", func(t *testing.T) { 127 + getPrompt := func(t *testing.T) string { 128 + req := NewRequest(t, "GET", "/user/settings") 129 + resp := session.MakeRequest(t, req, http.StatusOK) 130 + htmlDoc := NewHTMLParser(t, resp.Body) 131 + 132 + return htmlDoc.Find("input[name='name'] + .help").Text() 133 + } 134 + 135 + t.Run("No cooldown", func(t *testing.T) { 136 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 0)() 137 + defer tests.PrintCurrentTest(t)() 138 + 139 + assert.Contains(t, getPrompt(t), "The old username will redirect until someone claims it.") 140 + }) 141 + 142 + t.Run("With cooldown", func(t *testing.T) { 143 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 8)() 144 + defer tests.PrintCurrentTest(t)() 145 + 146 + assert.Contains(t, getPrompt(t), "The old username will be available to everyone after a cooldown period of 8 days, you can still reclaim the old username during the cooldown period.") 147 + }) 148 + }) 149 + 150 + t.Run("Org settings note", func(t *testing.T) { 151 + getPrompt := func(t *testing.T) string { 152 + req := NewRequest(t, "GET", "/org/org3/settings") 153 + resp := session.MakeRequest(t, req, http.StatusOK) 154 + htmlDoc := NewHTMLParser(t, resp.Body) 155 + 156 + return htmlDoc.Find("#org_name + .help").Text() 157 + } 158 + 159 + t.Run("No cooldown", func(t *testing.T) { 160 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 0)() 161 + defer tests.PrintCurrentTest(t)() 162 + 163 + assert.Contains(t, getPrompt(t), "The old name will redirect until it is claimed.") 164 + }) 165 + 166 + t.Run("With cooldown", func(t *testing.T) { 167 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 8)() 168 + defer tests.PrintCurrentTest(t)() 169 + 170 + assert.Contains(t, getPrompt(t), "The old username will be available to everyone after a cooldown period of 8 days, you can still reclaim the old username during the cooldown period.") 171 + }) 172 + }) 173 + } 174 + 175 + // NOTE: This is a unit test but written in the integration test to ensure this runs on all databases. 176 + func TestLimitUserRedirects(t *testing.T) { 177 + defer tests.PrepareTestEnv(t)() 178 + 179 + _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(&user_model.Redirect{RedirectUserID: 1, LowerName: "legacy", CreatedUnix: 0}, 180 + &user_model.Redirect{RedirectUserID: 1, LowerName: "past", CreatedUnix: timeutil.TimeStampNow().AddDuration(-48 * time.Hour)}, 181 + &user_model.Redirect{RedirectUserID: 1, LowerName: "recent", CreatedUnix: timeutil.TimeStampNow().AddDuration(-12 * time.Hour)}, 182 + &user_model.Redirect{RedirectUserID: 1, LowerName: "future", CreatedUnix: timeutil.TimeStampNow().AddDuration(time.Hour)}) 183 + require.NoError(t, err) 184 + 185 + require.NoError(t, user_model.LimitUserRedirects(db.DefaultContext, 1, 3)) 186 + 187 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "legacy"}) 188 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "past"}) 189 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "recent"}) 190 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "future"}) 191 + 192 + require.NoError(t, user_model.LimitUserRedirects(db.DefaultContext, 1, 1)) 193 + 194 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "legacy"}) 195 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "past"}) 196 + unittest.AssertExistsIf(t, false, &user_model.Redirect{LowerName: "recent"}) 197 + unittest.AssertExistsIf(t, true, &user_model.Redirect{LowerName: "future"}) 198 + } 199 + 200 + // NOTE: This is a unit test but written in the integration test to ensure this runs on all databases. 201 + func TestCanClaimUsername(t *testing.T) { 202 + require.NoError(t, unittest.PrepareTestDatabase()) 203 + 204 + _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(&user_model.Redirect{RedirectUserID: 1, LowerName: "legacy", CreatedUnix: 0}, 205 + &user_model.Redirect{RedirectUserID: 1, LowerName: "past", CreatedUnix: timeutil.TimeStampNow().AddDuration(-48 * time.Hour)}, 206 + &user_model.Redirect{RedirectUserID: 1, LowerName: "recent", CreatedUnix: timeutil.TimeStampNow().AddDuration(-12 * time.Hour)}, 207 + &user_model.Redirect{RedirectUserID: 1, LowerName: "future", CreatedUnix: timeutil.TimeStampNow().AddDuration(time.Hour)}, 208 + &user_model.Redirect{RedirectUserID: 3, LowerName: "recent-org", CreatedUnix: timeutil.TimeStampNow().AddDuration(-12 * time.Hour)}) 209 + require.NoError(t, err) 210 + 211 + testCase := func(t *testing.T, legacy, past, recent, future bool, doerID int64) { 212 + t.Helper() 213 + 214 + ok, _, err := user_model.CanClaimUsername(db.DefaultContext, "legacy", doerID) 215 + require.NoError(t, err) 216 + assert.Equal(t, legacy, ok) 217 + 218 + ok, _, err = user_model.CanClaimUsername(db.DefaultContext, "past", doerID) 219 + require.NoError(t, err) 220 + assert.Equal(t, past, ok) 221 + 222 + ok, _, err = user_model.CanClaimUsername(db.DefaultContext, "recent", doerID) 223 + require.NoError(t, err) 224 + assert.Equal(t, recent, ok) 225 + 226 + ok, _, err = user_model.CanClaimUsername(db.DefaultContext, "future", doerID) 227 + require.NoError(t, err) 228 + assert.Equal(t, future, ok) 229 + } 230 + 231 + t.Run("No cooldown", func(t *testing.T) { 232 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 0)() 233 + 234 + testCase(t, true, true, true, true, -1) 235 + }) 236 + 237 + t.Run("1 day cooldown", func(t *testing.T) { 238 + defer tests.PrintCurrentTest(t)() 239 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 1)() 240 + 241 + testCase(t, true, true, false, false, -1) 242 + }) 243 + 244 + t.Run("1 week cooldown", func(t *testing.T) { 245 + defer tests.PrintCurrentTest(t)() 246 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 7)() 247 + 248 + testCase(t, true, false, false, false, -1) 249 + 250 + t.Run("Own username", func(t *testing.T) { 251 + defer tests.PrintCurrentTest(t)() 252 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 7)() 253 + 254 + testCase(t, true, true, true, true, 1) 255 + }) 256 + }) 257 + 258 + t.Run("Organisation", func(t *testing.T) { 259 + defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 1)() 260 + 261 + t.Run("Not owner", func(t *testing.T) { 262 + defer tests.PrintCurrentTest(t)() 263 + ok, _, err := user_model.CanClaimUsername(db.DefaultContext, "recent-org", -1) 264 + require.NoError(t, err) 265 + assert.False(t, ok) 266 + }) 267 + t.Run("Owner", func(t *testing.T) { 268 + defer tests.PrintCurrentTest(t)() 269 + ok, _, err := user_model.CanClaimUsername(db.DefaultContext, "recent-org", 2) 270 + require.NoError(t, err) 271 + assert.True(t, ok) 272 + }) 273 + }) 274 + }