feat(19-04): add users.handle, HandleGenerator and user.Created
Generate a unique mention handle on API register and admin create, and fire user.Created from both paths so listeners run in the same write. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
160
classes/handle_generator.go
Normal file
160
classes/handle_generator.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
handleMinLen = 2
|
||||
handleMaxLen = 255
|
||||
)
|
||||
|
||||
// HandleGenerator ports the PHP ChatChannels HandleGenerator. The emitted
|
||||
// charset [a-z0-9._-] is a strict subset of MentionParser [A-Za-z0-9._-],
|
||||
// length 2–255, so every generated handle is matchable as an @-token.
|
||||
type HandleGenerator struct{}
|
||||
|
||||
// Slug transliterates name to ASCII, lowercases, drops every rune outside
|
||||
// [a-z0-9._-], then trims leading/trailing `._-`. A result shorter than 2
|
||||
// is not a valid base (Generate falls back to user{id}).
|
||||
func (HandleGenerator) Slug(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
b.WriteString(foldHandleRune(r))
|
||||
}
|
||||
return strings.Trim(b.String(), "._-")
|
||||
}
|
||||
|
||||
// Generate returns a unique handle for name. The base is Slug(name); if
|
||||
// that is shorter than 2 it falls back to "user{fallbackID}" (or "user"
|
||||
// when no id is known). The smallest free integer suffix is then appended
|
||||
// until isTaken returns false: first "James" → "james", second → "james1".
|
||||
func (g HandleGenerator) Generate(name string, isTaken func(string) bool, fallbackID *uint) string {
|
||||
base := g.Slug(name)
|
||||
if len(base) < handleMinLen {
|
||||
if fallbackID != nil {
|
||||
base = "user" + strconv.FormatUint(uint64(*fallbackID), 10)
|
||||
} else {
|
||||
base = "user"
|
||||
}
|
||||
}
|
||||
base = clipHandle(base)
|
||||
candidate := base
|
||||
i := 0
|
||||
for isTaken != nil && isTaken(candidate) {
|
||||
i++
|
||||
candidate = clipHandle(base + strconv.Itoa(i))
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// AssignHandle writes a unique handle onto user when it has none, using
|
||||
// the user's display name and id. It is idempotent when handle is already
|
||||
// set. The write uses db (the create transaction when there is one).
|
||||
func AssignHandle(ctx context.Context, db *gorm.DB, user *models.User) error {
|
||||
if user == nil {
|
||||
return errors.New("golem15.user: a handle needs a user")
|
||||
}
|
||||
if user.Handle != nil && strings.TrimSpace(*user.Handle) != "" {
|
||||
return nil
|
||||
}
|
||||
if db == nil {
|
||||
return errors.New("golem15.user: a handle needs a database")
|
||||
}
|
||||
name := ""
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
var fallback *uint
|
||||
if user.ID != 0 {
|
||||
id := user.ID
|
||||
fallback = &id
|
||||
}
|
||||
gen := HandleGenerator{}
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
handle := gen.Generate(name, func(candidate string) bool {
|
||||
var n int64
|
||||
q := db.WithContext(ctx).Model(&models.User{}).Where("handle = ?", candidate)
|
||||
if user.ID != 0 {
|
||||
q = q.Where("id <> ?", user.ID)
|
||||
}
|
||||
if err := q.Count(&n).Error; err != nil {
|
||||
return true
|
||||
}
|
||||
return n > 0
|
||||
}, fallback)
|
||||
user.Handle = &handle
|
||||
res := db.WithContext(ctx).Model(user).Where("id = ?", user.ID).Update("handle", handle)
|
||||
if res.Error == nil {
|
||||
return nil
|
||||
}
|
||||
if !isUniqueHandleConflict(res.Error) {
|
||||
return res.Error
|
||||
}
|
||||
}
|
||||
return errors.New("golem15.user: could not allocate a unique handle")
|
||||
}
|
||||
|
||||
func clipHandle(s string) string {
|
||||
if len(s) <= handleMaxLen {
|
||||
return s
|
||||
}
|
||||
return s[:handleMaxLen]
|
||||
}
|
||||
|
||||
func isUniqueHandleConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "users_handle_unique") || strings.Contains(msg, "duplicate key")
|
||||
}
|
||||
|
||||
// foldHandleRune maps r into the handle charset [a-z0-9._-] or "".
|
||||
func foldHandleRune(r rune) string {
|
||||
r = unicode.ToLower(r)
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
|
||||
return string(r)
|
||||
}
|
||||
if mapped, ok := latinHandleFold[r]; ok {
|
||||
return mapped
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// latinHandleFold covers Latin-1 / Latin Extended-A letters PHP Str::ascii
|
||||
// would emit for the names we generate handles from (Polish, French, German).
|
||||
var latinHandleFold = map[rune]string{
|
||||
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ą': "a", 'ā': "a",
|
||||
'æ': "ae",
|
||||
'ç': "c", 'ć': "c", 'č': "c",
|
||||
'ď': "d", 'đ': "d", 'ð': "d",
|
||||
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ę': "e", 'ē': "e", 'ě': "e",
|
||||
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i",
|
||||
'ł': "l",
|
||||
'ñ': "n", 'ń': "n", 'ň': "n",
|
||||
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o",
|
||||
'œ': "oe",
|
||||
'ř': "r",
|
||||
'ś': "s", 'š': "s", 'ş': "s", 'ß': "ss",
|
||||
'ť': "t", 'þ': "th",
|
||||
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u",
|
||||
'ý': "y", 'ÿ': "y",
|
||||
'ź': "z", 'ż': "z", 'ž': "z",
|
||||
}
|
||||
Reference in New Issue
Block a user