0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-02-22 22:06:21 -05:00
forgejo/models/user/redirect.go
Gusted a9c97110f9 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>
2025-01-24 04:16:56 +00:00

174 lines
5.6 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
)
// ErrUserRedirectNotExist represents a "UserRedirectNotExist" kind of error.
type ErrUserRedirectNotExist struct {
Name string
}
// IsErrUserRedirectNotExist check if an error is an ErrUserRedirectNotExist.
func IsErrUserRedirectNotExist(err error) bool {
_, ok := err.(ErrUserRedirectNotExist)
return ok
}
func (err ErrUserRedirectNotExist) Error() string {
return fmt.Sprintf("user redirect does not exist [name: %s]", err.Name)
}
func (err ErrUserRedirectNotExist) Unwrap() error {
return util.ErrNotExist
}
type ErrCooldownPeriod struct {
ExpireTime time.Time
}
func IsErrCooldownPeriod(err error) bool {
_, ok := err.(ErrCooldownPeriod)
return ok
}
func (err ErrCooldownPeriod) Error() string {
return fmt.Sprintf("cooldown period for claiming this username has not yet expired: the cooldown period ends at %s", err.ExpireTime)
}
// Redirect represents that a user name should be redirected to another
type Redirect struct {
ID int64 `xorm:"pk autoincr"`
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
RedirectUserID int64 // userID to redirect to
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL DEFAULT 0"`
}
// TableName provides the real table name
func (Redirect) TableName() string {
return "user_redirect"
}
func init() {
db.RegisterModel(new(Redirect))
}
// GetUserRedirect returns the redirect for a given username, this is a
// case-insenstive operation.
func GetUserRedirect(ctx context.Context, userName string) (*Redirect, error) {
userName = strings.ToLower(userName)
redirect := &Redirect{LowerName: userName}
if has, err := db.GetEngine(ctx).Get(redirect); err != nil {
return nil, err
} else if !has {
return nil, ErrUserRedirectNotExist{Name: userName}
}
return redirect, nil
}
// LookupUserRedirect look up userID if a user has a redirect name
func LookupUserRedirect(ctx context.Context, userName string) (int64, error) {
redirect, err := GetUserRedirect(ctx, userName)
if err != nil {
return 0, err
}
return redirect.RedirectUserID, nil
}
// NewUserRedirect create a new user redirect
func NewUserRedirect(ctx context.Context, ID int64, oldUserName, newUserName string) error {
oldUserName = strings.ToLower(oldUserName)
newUserName = strings.ToLower(newUserName)
if err := DeleteUserRedirect(ctx, oldUserName); err != nil {
return err
}
if err := DeleteUserRedirect(ctx, newUserName); err != nil {
return err
}
return db.Insert(ctx, &Redirect{
LowerName: oldUserName,
RedirectUserID: ID,
})
}
// LimitUserRedirects deletes the oldest entries in user_redirect of the user,
// such that the amount of user_redirects is at most `n` amount of entries.
func LimitUserRedirects(ctx context.Context, userID, n int64) error {
// NOTE: It's not possible to combine these two queries into one due to a limitation of MySQL.
keepIDs := make([]int64, n)
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 {
return err
}
_, err := db.GetEngine(ctx).Exec(builder.Delete(builder.And(builder.Eq{"redirect_user_id": userID}, builder.NotIn("id", keepIDs))).From("user_redirect"))
return err
}
// DeleteUserRedirect delete any redirect from the specified user name to
// anything else
func DeleteUserRedirect(ctx context.Context, userName string) error {
userName = strings.ToLower(userName)
_, err := db.GetEngine(ctx).Delete(&Redirect{LowerName: userName})
return err
}
// CanClaimUsername returns if its possible to claim the given username,
// it checks if the cooldown period for claiming an existing username is over.
// If there's a cooldown period, the second argument returns the time when
// that cooldown period is over.
// In the scenario of renaming, the doerID can be specified to allow the original
// user of the username to reclaim it within the cooldown period.
func CanClaimUsername(ctx context.Context, username string, doerID int64) (bool, time.Time, error) {
// Only check for a cooldown period if UsernameCooldownPeriod is a positive number.
if setting.Service.UsernameCooldownPeriod <= 0 {
return true, time.Time{}, nil
}
userRedirect, err := GetUserRedirect(ctx, username)
if err != nil {
if IsErrUserRedirectNotExist(err) {
return true, time.Time{}, nil
}
return false, time.Time{}, err
}
// Allow reclaiming of user's own username.
if userRedirect.RedirectUserID == doerID {
return true, time.Time{}, nil
}
// We do not know if the redirect user id was for an organization, so
// unconditionally execute the following query to retrieve all users that
// are part of the "Owner" team. If the redirect user ID is not an organization
// the returned list would be empty.
ownerTeamUIDs := []int64{}
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 {
return false, time.Time{}, err
}
if slices.Contains(ownerTeamUIDs, doerID) {
return true, time.Time{}, nil
}
// Multiply the value of UsernameCooldownPeriod by the amount of seconds in a day.
expireTime := userRedirect.CreatedUnix.Add(86400 * setting.Service.UsernameCooldownPeriod).AsLocalTime()
return time.Until(expireTime) <= 0, expireTime, nil
}