feat: add user centric features
This commit is contained in:
@@ -32,6 +32,7 @@ var (
|
||||
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
|
||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
ErrHandleTaken = errors.New("handle already in use")
|
||||
)
|
||||
|
||||
// AuthService bundles every flow that mutates auth state.
|
||||
@@ -91,12 +92,29 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name))
|
||||
if err != nil {
|
||||
// Generate a handle and retry up to 8 times if it collides with an
|
||||
// existing user. The slug seed is derived from the email's local-part,
|
||||
// which is the closest thing to a free-form identifier the user gave us.
|
||||
base := slugForHandle(email)
|
||||
var u *model.User
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
handle := base
|
||||
if attempt > 0 {
|
||||
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||
}
|
||||
u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
return nil, ErrEmailAlreadyTaken
|
||||
}
|
||||
return nil, err
|
||||
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||
@@ -265,6 +283,43 @@ func newToken() (raw, hash string, err error) {
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
// slugForHandle turns the local-part of an email into a kebab-case handle.
|
||||
// Falls back to "user" for fully-stripped inputs so we always have a seed
|
||||
// the retry loop can append a suffix to.
|
||||
func slugForHandle(email string) string {
|
||||
at := strings.IndexByte(email, '@')
|
||||
local := email
|
||||
if at >= 0 {
|
||||
local = email[:at]
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(local))
|
||||
for _, r := range strings.ToLower(local) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
out = "user"
|
||||
}
|
||||
if len(out) > 32 {
|
||||
out = out[:32]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty
|
||||
// to disambiguate names that already exist in the user base.
|
||||
func randomHandleSuffix() string {
|
||||
b := make([]byte, 3)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)[:5]
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
Reference in New Issue
Block a user