Files
2026-04-07 10:37:55 +02:00

859 lines
23 KiB
Go

// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package users
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/mail"
"regexp"
"strings"
"time"
"github.com/absmach/magistrala"
grpcTokenV1 "github.com/absmach/magistrala/api/grpc/token/v1"
apiutil "github.com/absmach/magistrala/api/http/util"
smqauth "github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/policies"
"github.com/gofrs/uuid/v5"
)
const defaultUsernamePrefix = "user"
var (
errRecoveryToken = errors.NewServiceError("failed to generate password recovery token")
errLoginDisableUser = errors.NewAuthNError("failed to login in disabled user")
errMatchUserVerification = errors.NewRequestError("user verification does not match with stored verification")
errSimilarUpdateEmail = errors.NewRequestError("new email is similar to the current email")
usernameRegExp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{34}[a-z0-9]$`)
)
type service struct {
token grpcTokenV1.TokenServiceClient
users Repository
idProvider magistrala.IDProvider
policies policies.Service
hasher Hasher
email Emailer
}
// NewService returns a new Users service implementation.
func NewService(token grpcTokenV1.TokenServiceClient, urepo Repository, policyService policies.Service, emailer Emailer, hasher Hasher, idp magistrala.IDProvider) Service {
return service{
token: token,
users: urepo,
policies: policyService,
hasher: hasher,
email: emailer,
idProvider: idp,
}
}
func (svc service) Register(ctx context.Context, session authn.Session, u User, selfRegister bool) (uc User, err error) {
if !selfRegister {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
userID, err := svc.idProvider.ID()
if err != nil {
return User{}, errors.Wrap(svcerr.ErrIssueProviderID, err)
}
if u.Credentials.Secret != "" {
hash, err := svc.hasher.Hash(u.Credentials.Secret)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrHashPassword, err)
}
u.Credentials.Secret = hash
}
if u.Status != DisabledStatus && u.Status != EnabledStatus {
return User{}, svcerr.ErrInvalidStatus
}
if u.Role != UserRole && u.Role != AdminRole {
return User{}, svcerr.ErrInvalidRole
}
u.ID = userID
u.CreatedAt = time.Now().UTC()
if err := svc.addUserPolicy(ctx, u.ID, u.Role); err != nil {
return User{}, errors.Wrap(svcerr.ErrAddPolicies, err)
}
defer func() {
if err != nil {
if errRollback := svc.addUserPolicyRollback(ctx, u.ID, u.Role); errRollback != nil {
err = errors.Wrap(errors.Wrap(apiutil.ErrRollbackTx, errRollback), err)
}
}
}()
user, err := svc.users.Save(ctx, u)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrCreateEntity, err)
}
return user, nil
}
func (svc service) SendVerification(ctx context.Context, session authn.Session) error {
dbUser, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return errors.Wrap(svcerr.ErrViewEntity, err)
}
if !dbUser.VerifiedAt.IsZero() {
return svcerr.ErrUserAlreadyVerified
}
uv, err := svc.users.RetrieveUserVerification(ctx, dbUser.ID, dbUser.Email)
if err != nil && err != repoerr.ErrNotFound {
return errors.Wrap(svcerr.ErrCreateEntity, err)
}
if err = uv.Valid(); err != nil {
uv, err = NewUserVerification(dbUser.ID, dbUser.Email)
if err != nil {
return errors.Wrap(svcerr.ErrCreateEntity, err)
}
if err := svc.users.AddUserVerification(ctx, uv); err != nil {
return errors.Wrap(svcerr.ErrCreateEntity, err)
}
}
uvs, err := uv.Encode()
if err != nil {
return errors.Wrap(svcerr.ErrCreateEntity, err)
}
if err := svc.email.SendVerification([]string{dbUser.Email}, dbUser.Credentials.Username, uvs); err != nil {
return errors.Wrap(svcerr.ErrCreateEntity, err)
}
return nil
}
func (svc service) VerifyEmail(ctx context.Context, token string) (User, error) {
var received UserVerification
if err := received.Decode(token); err != nil {
return User{}, errors.Wrap(svcerr.ErrInvalidUserVerification, err)
}
stored, err := svc.users.RetrieveUserVerification(ctx, received.UserID, received.Email)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
if err := stored.Match(received); err != nil {
return User{}, errors.Wrap(errMatchUserVerification, err)
}
if err := stored.Valid(); err != nil {
if err == svcerr.ErrUserVerificationExpired {
return User{}, err
}
return User{}, errors.Wrap(svcerr.ErrMalformedEntity, err)
}
stored.UsedAt = time.Now().UTC()
if err = svc.users.UpdateUserVerification(ctx, stored); err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
user := User{
ID: stored.UserID,
Email: stored.Email,
VerifiedAt: time.Now().UTC(),
}
user, err = svc.users.UpdateVerifiedAt(ctx, user)
if err == repoerr.ErrNotFound {
return User{}, svcerr.ErrInvalidUserVerification
}
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return user, nil
}
func (svc service) IssueToken(ctx context.Context, identity, secret, description string) (*grpcTokenV1.Token, error) {
var dbUser User
var err error
if _, parseErr := mail.ParseAddress(identity); parseErr != nil {
dbUser, err = svc.users.RetrieveByUsername(ctx, identity)
} else {
dbUser, err = svc.users.RetrieveByEmail(ctx, identity)
}
if err == repoerr.ErrNotFound {
return &grpcTokenV1.Token{}, errors.Wrap(svcerr.ErrLogin, err)
}
if err != nil {
return &grpcTokenV1.Token{}, errors.Wrap(svcerr.ErrAuthentication, err)
}
if err := svc.hasher.Compare(secret, dbUser.Credentials.Secret); err != nil {
return &grpcTokenV1.Token{}, errors.Wrap(svcerr.ErrLogin, err)
}
token, err := svc.token.Issue(ctx, &grpcTokenV1.IssueReq{
UserId: dbUser.ID,
UserRole: uint32(dbUser.Role + 1),
Type: uint32(smqauth.AccessKey),
Verified: !dbUser.VerifiedAt.IsZero(),
Description: description,
})
if err != nil {
return &grpcTokenV1.Token{}, err
}
return token, nil
}
func (svc service) RefreshToken(ctx context.Context, session authn.Session, refreshToken string) (*grpcTokenV1.Token, error) {
dbUser, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return &grpcTokenV1.Token{}, errors.Wrap(svcerr.ErrAuthentication, err)
}
if dbUser.Status == DisabledStatus {
return &grpcTokenV1.Token{}, errors.Wrap(svcerr.ErrAuthentication, errLoginDisableUser)
}
token, err := svc.token.Refresh(ctx, &grpcTokenV1.RefreshReq{RefreshToken: refreshToken, Verified: !dbUser.VerifiedAt.IsZero()})
if err != nil {
return &grpcTokenV1.Token{}, err
}
return token, nil
}
func (svc service) RevokeRefreshToken(ctx context.Context, session authn.Session, tokenID string) error {
dbUser, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return errors.Wrap(svcerr.ErrAuthentication, err)
}
if dbUser.Status == DisabledStatus {
return errors.Wrap(svcerr.ErrAuthentication, errLoginDisableUser)
}
_, err = svc.token.Revoke(ctx, &grpcTokenV1.RevokeReq{UserId: session.UserID, TokenId: tokenID})
if err != nil {
if errors.Contains(err, svcerr.ErrNotFound) {
return errors.Wrap(svcerr.ErrNotFound, err)
}
return errors.Wrap(svcerr.ErrRemoveEntity, err)
}
return nil
}
func (svc service) ListActiveRefreshTokens(ctx context.Context, session authn.Session) (*grpcTokenV1.ListUserRefreshTokensRes, error) {
dbUser, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return nil, errors.Wrap(svcerr.ErrAuthentication, err)
}
if dbUser.Status == DisabledStatus {
return nil, errors.Wrap(svcerr.ErrAuthentication, errLoginDisableUser)
}
refreshTokens, err := svc.token.ListUserRefreshTokens(ctx, &grpcTokenV1.ListUserRefreshTokensReq{UserId: session.UserID})
if err != nil {
return nil, errors.Wrap(svcerr.ErrAuthentication, err)
}
return refreshTokens, nil
}
func (svc service) View(ctx context.Context, session authn.Session, id string) (User, error) {
user, err := svc.users.RetrieveByID(ctx, id)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
if session.UserID != id {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{
FirstName: user.FirstName,
LastName: user.LastName,
ID: user.ID,
Metadata: user.Metadata,
Credentials: Credentials{Username: user.Credentials.Username},
}, nil
}
}
user.Credentials.Secret = ""
return user, nil
}
func (svc service) ViewProfile(ctx context.Context, session authn.Session) (User, error) {
user, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
user.Credentials.Secret = ""
return user, nil
}
func (svc service) ListUsers(ctx context.Context, session authn.Session, pm Page) (UsersPage, error) {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return UsersPage{}, err
}
pm.Role = AllRole
pg, err := svc.users.RetrieveAll(ctx, pm)
if err != nil {
return UsersPage{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
return pg, err
}
func (svc service) SearchUsers(ctx context.Context, pm Page) (UsersPage, error) {
page := Page{
Offset: pm.Offset,
Limit: pm.Limit,
FirstName: pm.FirstName,
LastName: pm.LastName,
Username: pm.Username,
Id: pm.Id,
Role: UserRole,
}
cp, err := svc.users.SearchUsers(ctx, page)
if err != nil {
return UsersPage{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
return cp, nil
}
func (svc service) Update(ctx context.Context, session authn.Session, id string, usr UserReq) (User, error) {
if session.UserID != id {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
u, err := svc.users.RetrieveByID(ctx, id)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
if u.AuthProvider != "" {
if changed(usr.FirstName, u.FirstName) ||
changed(usr.LastName, u.LastName) ||
changed(usr.ProfilePicture, u.ProfilePicture) {
return User{}, svcerr.ErrExternalAuthProviderCouldNotUpdate
}
}
updatedAt := time.Now().UTC()
usr.UpdatedAt = &updatedAt
usr.UpdatedBy = &session.UserID
user, err := svc.users.Update(ctx, id, usr)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return user, nil
}
func (svc service) UpdateTags(ctx context.Context, session authn.Session, id string, usr UserReq) (User, error) {
if session.UserID != id {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
updatedAt := time.Now().UTC()
usr.UpdatedAt = &updatedAt
usr.UpdatedBy = &session.UserID
user, err := svc.users.Update(ctx, id, usr)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return user, nil
}
func (svc service) UpdateProfilePicture(ctx context.Context, session authn.Session, id string, usr UserReq) (User, error) {
if session.UserID != id {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
u, err := svc.users.RetrieveByID(ctx, id)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
if u.AuthProvider != "" {
return User{}, svcerr.ErrExternalAuthProviderCouldNotUpdate
}
updatedAt := time.Now().UTC()
usr.UpdatedAt = &updatedAt
usr.UpdatedBy = &session.UserID
user, err := svc.users.Update(ctx, id, usr)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return user, nil
}
func (svc service) UpdateEmail(ctx context.Context, session authn.Session, userID, email string) (User, error) {
if session.UserID != userID {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
oldUsr, err := svc.users.RetrieveByID(ctx, userID)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
if oldUsr.AuthProvider != "" {
return User{}, svcerr.ErrExternalAuthProviderCouldNotUpdate
}
if oldUsr.Email == email {
return User{}, errSimilarUpdateEmail
}
usr := User{
ID: userID,
Email: email,
UpdatedAt: time.Now().UTC(),
UpdatedBy: session.UserID,
VerifiedAt: time.Time{},
}
user, err := svc.users.UpdateEmail(ctx, usr)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return user, nil
}
func (svc service) SendPasswordReset(ctx context.Context, email string) error {
user, err := svc.users.RetrieveByEmail(ctx, email)
if err != nil {
return errors.Wrap(svcerr.ErrViewEntity, err)
}
issueReq := &grpcTokenV1.IssueReq{
UserId: user.ID,
UserRole: uint32(user.Role + 1),
Type: uint32(smqauth.RecoveryKey),
}
token, err := svc.token.Issue(ctx, issueReq)
if err != nil {
return errors.Wrap(errRecoveryToken, err)
}
if err := svc.email.SendPasswordReset([]string{email}, user.Credentials.Username, token.AccessToken); err != nil {
return errors.NewInternalErrorWithErr(err)
}
return nil
}
func (svc service) ResetSecret(ctx context.Context, session authn.Session, secret string) error {
u, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return errors.Wrap(svcerr.ErrViewEntity, err)
}
secret, err = svc.hasher.Hash(secret)
if err != nil {
return errors.Wrap(svcerr.ErrMalformedEntity, err)
}
u = User{
ID: u.ID,
Email: u.Email,
Credentials: Credentials{
Secret: secret,
},
UpdatedAt: time.Now().UTC(),
UpdatedBy: session.UserID,
}
if _, err := svc.users.UpdateSecret(ctx, u); err != nil {
return errors.Wrap(svcerr.ErrAuthorization, err)
}
return nil
}
func (svc service) UpdateSecret(ctx context.Context, session authn.Session, oldSecret, newSecret string) (User, error) {
dbUser, err := svc.users.RetrieveByID(ctx, session.UserID)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
if _, err := svc.IssueToken(ctx, dbUser.Credentials.Username, oldSecret, ""); err != nil {
return User{}, err
}
newSecret, err = svc.hasher.Hash(newSecret)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrMalformedEntity, err)
}
dbUser.Credentials.Secret = newSecret
dbUser.UpdatedAt = time.Now().UTC()
dbUser.UpdatedBy = session.UserID
dbUser, err = svc.users.UpdateSecret(ctx, dbUser)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return dbUser, nil
}
func (svc service) UpdateUsername(ctx context.Context, session authn.Session, id, username string) (User, error) {
if session.UserID != id {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
usr := User{
ID: id,
Credentials: Credentials{
Username: username,
},
UpdatedAt: time.Now().UTC(),
UpdatedBy: session.UserID,
}
updatedUser, err := svc.users.UpdateUsername(ctx, usr)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return updatedUser, nil
}
func (svc service) UpdateRole(ctx context.Context, session authn.Session, usr User) (User, error) {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
usr = User{
ID: usr.ID,
Role: usr.Role,
UpdatedAt: time.Now().UTC(),
UpdatedBy: session.UserID,
}
if err := svc.updateUserPolicy(ctx, usr.ID, usr.Role); err != nil {
return User{}, err
}
u, err := svc.users.UpdateRole(ctx, usr)
if err != nil {
// If failed to update role in DB, then revert back to platform admin policies in spicedb
if errRollback := svc.updateUserPolicy(ctx, usr.ID, UserRole); errRollback != nil {
return User{}, errors.Wrap(errRollback, err)
}
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return u, nil
}
func (svc service) Enable(ctx context.Context, session authn.Session, id string) (User, error) {
u := User{
ID: id,
UpdatedAt: time.Now().UTC(),
Status: EnabledStatus,
}
user, err := svc.changeUserStatus(ctx, session, u)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrEnableUser, err)
}
return user, nil
}
func (svc service) Disable(ctx context.Context, session authn.Session, id string) (User, error) {
user := User{
ID: id,
UpdatedAt: time.Now().UTC(),
Status: DisabledStatus,
}
user, err := svc.changeUserStatus(ctx, session, user)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrDisableUser, err)
}
return user, nil
}
func (svc service) changeUserStatus(ctx context.Context, session authn.Session, user User) (User, error) {
if session.UserID != user.ID {
if err := svc.checkSuperAdmin(ctx, session); err != nil {
return User{}, err
}
}
dbu, err := svc.users.RetrieveByID(ctx, user.ID)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrViewEntity, err)
}
if dbu.Status == user.Status {
return User{}, svcerr.ErrStatusAlreadyAssigned
}
user.UpdatedBy = session.UserID
user, err = svc.users.ChangeStatus(ctx, user)
if err != nil {
return User{}, errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return user, nil
}
func (svc service) Delete(ctx context.Context, session authn.Session, id string) error {
user := User{
ID: id,
UpdatedAt: time.Now().UTC(),
Status: DeletedStatus,
}
if _, err := svc.changeUserStatus(ctx, session, user); err != nil {
return err
}
return nil
}
func (svc *service) checkSuperAdmin(ctx context.Context, session authn.Session) error {
if !session.SuperAdmin {
if err := svc.users.CheckSuperAdmin(ctx, session.UserID); err != nil {
return errors.Wrap(svcerr.ErrAuthorization, err)
}
}
return nil
}
func (svc service) OAuthCallback(ctx context.Context, user User) (User, error) {
u, err := svc.users.RetrieveByEmail(ctx, user.Email)
if errors.Contains(err, repoerr.ErrNotFound) {
user.Credentials.Username = generateUsername(user.Email)
u, err = svc.Register(ctx, authn.Session{}, user, true)
if err != nil {
if errors.Contains(err, errors.ErrUsernameNotAvailable) {
return User{}, errors.ErrTryAgain
}
return User{}, err
}
}
if err != nil && !errors.Contains(err, repoerr.ErrNotFound) {
return User{}, err
}
if u.VerifiedAt.IsZero() {
user.ID = u.ID
user.VerifiedAt = time.Now()
u, err = svc.users.UpdateVerifiedAt(ctx, user)
if err != nil {
return User{}, err
}
}
return User{ID: u.ID, Role: u.Role, VerifiedAt: u.VerifiedAt}, nil
}
func (svc service) OAuthAddUserPolicy(ctx context.Context, user User) error {
return svc.addUserPolicy(ctx, user.ID, user.Role)
}
func (svc service) Identify(ctx context.Context, session authn.Session) (string, error) {
return session.UserID, nil
}
func (svc service) addUserPolicy(ctx context.Context, userID string, role Role) error {
policyList := []policies.Policy{}
policyList = append(policyList, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Relation: policies.MemberRelation,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
})
if role == AdminRole {
policyList = append(policyList, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Relation: policies.AdministratorRelation,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
})
}
err := svc.policies.AddPolicies(ctx, policyList)
if err != nil {
return errors.Wrap(svcerr.ErrAddPolicies, err)
}
return nil
}
func (svc service) addUserPolicyRollback(ctx context.Context, userID string, role Role) error {
policyList := []policies.Policy{}
policyList = append(policyList, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Relation: policies.MemberRelation,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
})
if role == AdminRole {
policyList = append(policyList, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Relation: policies.AdministratorRelation,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
})
}
err := svc.policies.DeletePolicies(ctx, policyList)
if err != nil {
return errors.Wrap(svcerr.ErrDeletePolicies, err)
}
return nil
}
func (svc service) updateUserPolicy(ctx context.Context, userID string, role Role) error {
switch role {
case AdminRole:
err := svc.policies.AddPolicy(ctx, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Relation: policies.AdministratorRelation,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
})
if err != nil {
return errors.Wrap(svcerr.ErrAddPolicies, err)
}
return nil
case UserRole:
fallthrough
default:
err := svc.policies.DeletePolicyFilter(ctx, policies.Policy{
SubjectType: policies.UserType,
Subject: userID,
Relation: policies.AdministratorRelation,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
})
if err != nil {
return errors.Wrap(svcerr.ErrDeletePolicies, err)
}
return nil
}
}
func generateUsername(email string) string {
uniqueSuffix := generateRandomID()
emailPrefix := extractEmailPrefix(email)
return fmt.Sprintf("%s_%s", emailPrefix, uniqueSuffix)
}
func extractEmailPrefix(email string) string {
parts := strings.Split(email, "@")
if len(parts) == 0 {
return defaultUsernamePrefix
}
prefix := parts[0]
cleaned := usernameRegExp.ReplaceAllString(prefix, "")
cleaned = sanitizeForUsername(cleaned, 15)
if cleaned == "" {
cleaned = defaultUsernamePrefix
}
return cleaned
}
func generateRandomID() string {
// Generate 8 random bytes (will result in 16 hex chars, truncated to 10)
randomBytes := make([]byte, 8)
if _, err := rand.Read(randomBytes); err != nil {
// Fallback: use UUID if crypto/rand fails (should never happen)
id, uuidErr := uuid.NewV4()
if uuidErr != nil {
// Last resort fallback
return fmt.Sprintf("%x", time.Now().UnixNano())[:10]
}
return hex.EncodeToString(id.Bytes())[:10]
}
return hex.EncodeToString(randomBytes)[:10]
}
// sanitizeForUsername extracts and cleans a string for use in username generation.
// As per the username requirements:
// - It keeps only lowercase alphanumeric characters, hyphens, and underscores
// - ensures valid boundaries (no hyphens/underscores at start/end)
// - removes consecutive hyphens/underscores (to pass validation)
// and finally limits the result to maxLen characters.
func sanitizeForUsername(s string, maxLen int) string {
if s == "" {
return ""
}
// Convert to lowercase
s = strings.ToLower(s)
// Filter characters - keep only alphanumeric, hyphen, underscore
buf := make([]byte, 0, len(s))
var lastChar byte
for i := 0; i < len(s); i++ {
c := s[i]
// Keep alphanumeric, hyphen, underscore
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
// Skip if current char is hyphen/underscore and same as last char
if (c == '-' || c == '_') && c == lastChar {
continue // Skip consecutive hyphens or underscores
}
buf = append(buf, c)
lastChar = c
} else {
lastChar = 0 // Reset on special char
}
}
cleaned := string(buf)
// Trim invalid boundary characters
cleaned = strings.Trim(cleaned, "-_")
// Limit length
if len(cleaned) > maxLen {
cleaned = cleaned[:maxLen]
// Re-trim in case truncation exposed hyphen/underscore at end
cleaned = strings.TrimRight(cleaned, "-_")
}
return cleaned
}
func changed(updated *string, old string) bool {
if updated == nil {
return false
}
return *updated != old
}