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:
Jakub Zych
2026-10-17 21:44:59 +02:00
parent 569768e657
commit 15b19c7021
12 changed files with 349 additions and 19 deletions

View File

@@ -1,6 +1,12 @@
package classes
import "git.golem15.com/golem15/sm-user-plugin/models"
import (
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
// CreatedEventName is the festival name of CreatedEvent (D-13 / RESEARCH A6).
const CreatedEventName = "user.Created"
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
type GetApiArrayEvent struct {
@@ -27,3 +33,11 @@ type RegisterEvent struct {
// or hashed. Listeners must treat it as read-only input.
Payload map[string]any
}
// CreatedEvent fires from every user create path (API register and admin
// create) so listeners can run inside the same write, including handle
// generation (D-13, D-15). Tx is the create transaction when there is one.
type CreatedEvent struct {
User *models.User
Tx *gorm.DB
}

View File

@@ -46,3 +46,22 @@ func TestRegisterEventFires(t *testing.T) {
t.Fatalf("event user = %#v", seen)
}
}
func TestCreatedEventFires(t *testing.T) {
if CreatedEventName != "user.Created" {
t.Fatalf("CreatedEventName = %q, want user.Created", CreatedEventName)
}
bus := festival.New()
var seen *models.User
bus.Listen[*CreatedEvent]("golem15.user", func(_ context.Context, e *CreatedEvent) error {
seen = e.User
return nil
})
user := &models.User{ID: 9, Email: "created@example.test"}
if err := bus.Fire(context.Background(), &CreatedEvent{User: user}); err != nil {
t.Fatal(err)
}
if seen == nil || seen.Email != user.Email {
t.Fatalf("created event user = %#v", seen)
}
}

160
classes/handle_generator.go Normal file
View 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",
}

View File

@@ -0,0 +1,78 @@
package classes
import "testing"
// TestHandle: generator charset is a subset of MentionParser, uniqueness
// uses the smallest free integer suffix, and short names fall back to user{id}.
func TestHandle(t *testing.T) {
t.Parallel()
g := HandleGenerator{}
if got := g.Slug("Paweł"); got != "pawel" {
t.Fatalf("slug Paweł = %q, want pawel", got)
}
if got := g.Slug("John Doe"); got != "johndoe" {
t.Fatalf("slug John Doe = %q, want johndoe", got)
}
if got := g.Slug("Zoë"); got != "zoe" {
t.Fatalf("slug Zoë = %q, want zoe", got)
}
handle := g.Generate("James", func(string) bool { return false }, nil)
if handle != "james" {
t.Fatalf("generate James = %q, want james", handle)
}
if !validHandle(handle) {
t.Fatalf("generated %q is outside [a-z0-9._-]{2,255}", handle)
}
taken := map[string]struct{}{}
isTaken := func(c string) bool {
_, ok := taken[c]
return ok
}
first := g.Generate("James", isTaken, nil)
taken[first] = struct{}{}
second := g.Generate("James", isTaken, nil)
taken[second] = struct{}{}
third := g.Generate("James", isTaken, nil)
if first != "james" || second != "james1" || third != "james2" {
t.Fatalf("suffixes = %q %q %q, want james james1 james2", first, second, third)
}
id := uint(42)
if got := g.Generate("", func(string) bool { return false }, &id); got != "user42" {
t.Fatalf("empty name = %q, want user42", got)
}
if got := g.Generate("x", func(string) bool { return false }, &id); got != "user42" {
t.Fatalf("short name = %q, want user42", got)
}
if got := g.Generate("???", func(string) bool { return false }, &id); got != "user42" {
t.Fatalf("punctuation name = %q, want user42", got)
}
if got := g.Generate("", func(string) bool { return false }, nil); got != "user" {
t.Fatalf("no id = %q, want user", got)
}
names := []string{"James", "Paweł", "John Doe", "???", "a", "Zoë", " ", "....", "O'Brien"}
for _, name := range names {
got := g.Generate(name, func(string) bool { return false }, &id)
if !validHandle(got) {
t.Errorf("generated %q for %q is outside the MentionParser subset", got, name)
}
}
}
func validHandle(s string) bool {
if len(s) < 2 || len(s) > 255 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
default:
return false
}
}
return true
}

View File

@@ -649,6 +649,7 @@ func Register(app *backpack.App) http.HandlerFunc {
return
}
_ = FireRegisterEvent(r.Context(), app, user, fields)
_ = FireCreatedEvent(r.Context(), app, user)
ctx := r.Context()
if mode == "user" {
vars := mailVars(displayName(user), "", "")

View File

@@ -77,6 +77,9 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
if err := db.WithContext(ctx).Create(user).Error; err != nil {
return nil, nil, err
}
if err := classes.AssignHandle(ctx, db, user); err != nil {
return nil, nil, err
}
return user, nil, nil
}
@@ -97,6 +100,16 @@ func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
}
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
// returns the listeners' error. /register ignores that error, as it does
// for FireRegisterEvent.
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
if app == nil || app.Events == nil {
return nil
}
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
}
// IssueToken mints the session JWT for user exactly as /register and
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
// URL as the issuer.

View File

@@ -302,23 +302,33 @@ func (c usersAdminController) storePassword(ctx context.Context, user *models.Us
return true, nil
}
// FormAfterCreate sends the invitation when the send_invite checkbox was
// set: an activation code is issued inside the create's transaction, and
// exactly one golem15.user::mail.invite message with the activation link
// leaves after the commit. A create that rolls back sends nothing.
// FormAfterCreate assigns a unique handle, fires user.Created
// (CreatedEventName) inside the create transaction, and sends the
// invitation when the send_invite checkbox was set: an activation code is
// issued inside the same transaction, and exactly one
// golem15.user::mail.invite message with the activation link leaves after
// the commit. A create that rolls back sends nothing.
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
return errors.New("users admin: form model is not a user")
}
values, _ := cabana.VirtualFieldsFromContext(ctx)
if invite, _ := values["send_invite"].(bool); !invite {
return nil
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("users admin: a create needs its transaction")
}
if err := classes.AssignHandle(ctx, tx.Session(&gorm.Session{NewDB: true}), user); err != nil {
return err
}
if app := c.application(); app != nil && app.Events != nil {
if err := app.Events.Fire(ctx, &classes.CreatedEvent{User: user, Tx: tx}); err != nil {
return fmt.Errorf("%s: %w", classes.CreatedEventName, err)
}
}
values, _ := cabana.VirtualFieldsFromContext(ctx)
if invite, _ := values["send_invite"].(bool); !invite {
return nil
}
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
if err != nil {
return err

View File

@@ -33,6 +33,10 @@ type User struct {
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
CreatedIPAddress *string `gorm:"column:created_ip_address"`
LastIPAddress *string `gorm:"column:last_ip_address"`
// Handle is the unique @mention identity (D-15). Generated on create,
// never mass-assigned, and omitted from user API payloads unless a
// golden requires it.
Handle *string `gorm:"column:handle" json:"-"`
// LastSeen is stamped by login and token refresh, at most once per five
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
// never part of a user API payload.

View File

@@ -0,0 +1,30 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// addHandleToUsers adds users.handle, the unique @mention identity (D-15),
// only when it is missing: an application whose users table already has the
// column (added on PHP by chatchannels) keeps it and its data. The rollback
// is a no-op for the same reason: the column may predate this plugin.
var addHandleToUsers = []*gormigrate.Migration{
{
ID: "202610160002_add_handle_to_users",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(255) NULL`).Error; err != nil {
return err
}
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS users_handle_unique ON users (handle)`).Error
},
Rollback: func(*gorm.DB) error {
// No-op: the shared users column may predate the plugin.
return nil
},
},
}
func init() {
Register(addHandleToUsers...)
}

View File

@@ -50,6 +50,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
"202610040002_add_users_last_seen",
"202610040003_create_frontend_permissions",
"202610050001_create_oauth_identities",
"202610160002_add_handle_to_users",
}
if len(ids) != len(want) {
t.Fatalf("history = %v", ids)
@@ -60,9 +61,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
}
}
// OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, extend-users, then organisations.
for range 10 {
for range 11 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
t.Fatalf("users_groups primary key = %q", pk)
}
// OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 6 {
// Handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 7 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
t.Fatalf("defaults = %+v", row)
}
// OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, then extend-users.
for range 9 {
for range 10 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 8 {
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 9 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 7 {
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 8 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}