diff --git a/classes/events.go b/classes/events.go index fc75496..0b8fa88 100644 --- a/classes/events.go +++ b/classes/events.go @@ -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 +} diff --git a/classes/events_test.go b/classes/events_test.go index dcb302a..4c3b46a 100644 --- a/classes/events_test.go +++ b/classes/events_test.go @@ -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) + } +} diff --git a/classes/handle_generator.go b/classes/handle_generator.go new file mode 100644 index 0000000..0c2f59b --- /dev/null +++ b/classes/handle_generator.go @@ -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", +} diff --git a/classes/handle_generator_test.go b/classes/handle_generator_test.go new file mode 100644 index 0000000..8a1b08e --- /dev/null +++ b/classes/handle_generator_test.go @@ -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 +} diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 69992d9..09f1a91 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -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), "", "") diff --git a/controllers/registration.go b/controllers/registration.go index 6575805..439e07d 100644 --- a/controllers/registration.go +++ b/controllers/registration.go @@ -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. diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index 9258825..26e90ed 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -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 diff --git a/models/user.go b/models/user.go index 4886905..ca41468 100644 --- a/models/user.go +++ b/models/user.go @@ -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. diff --git a/updates/202610160002_add_handle_to_users.go b/updates/202610160002_add_handle_to_users.go new file mode 100644 index 0000000..025def2 --- /dev/null +++ b/updates/202610160002_add_handle_to_users.go @@ -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...) +} diff --git a/updates/organisations_test.go b/updates/organisations_test.go index 128b0f7..32e3235 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -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) } diff --git a/updates/user_groups_test.go b/updates/user_groups_test.go index 3082abd..29446a5 100644 --- a/updates/user_groups_test.go +++ b/updates/user_groups_test.go @@ -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) } diff --git a/updates/user_session_test.go b/updates/user_session_test.go index 788b827..4e8fcc1 100644 --- a/updates/user_session_test.go +++ b/updates/user_session_test.go @@ -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) }