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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
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",
|
||||
}
|
||||
78
classes/handle_generator_test.go
Normal file
78
classes/handle_generator_test.go
Normal 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
|
||||
}
|
||||
@@ -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), "", "")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
30
updates/202610160002_add_handle_to_users.go
Normal file
30
updates/202610160002_add_handle_to_users.go
Normal 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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user