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>
161 lines
4.5 KiB
Go
161 lines
4.5 KiB
Go
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",
|
||
}
|