Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions db/hooks/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,45 @@ func UpdateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEve
}
}

// OAuth2UsernameHandler assigns the username reported by the OAuth2 provider
// to accounts created through OAuth2.
//
// PocketBase maps the provider username itself, but only when the raw value
// already satisfies the users.username field. Providers whose usernames are
// display names - OpenStreetMap, for instance - therefore end up with a
// generated "usersNNNNNN" name. Sanitising the value first keeps the name the
// user signed up with recognisable.
func OAuth2UsernameHandler() func(e *core.RecordAuthWithOAuth2RequestEvent) error {
return func(e *core.RecordAuthWithOAuth2RequestEvent) error {
if !e.IsNewRecord || e.OAuth2User == nil {
return e.Next()
}

// a username submitted by the client takes precedence
if submitted, _ := e.CreateData["username"].(string); submitted != "" {
return e.Next()
}

username := util.SanitizeUsername(e.OAuth2User.Username)
if username == "" {
return e.Next()
}

username = util.UniqueUsername(e.App, username)
if username == "" {
// nothing free; let PocketBase generate a username instead
return e.Next()
}

if e.CreateData == nil {
e.CreateData = map[string]any{}
}
e.CreateData["username"] = username

return e.Next()
}
}

func createDefaultUserSettings(app core.App, userId string) error {
collection, err := app.FindCollectionByNameOrId("settings")
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions db/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func registerMigrations(app *pocketbase.PocketBase) {
}

func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
app.OnRecordAuthWithOAuth2Request().BindFunc(hooks.OAuth2UsernameHandler())

app.OnRecordAfterCreateSuccess("users").BindFunc(hooks.CreateUserHandler(client))
app.OnRecordAfterUpdateSuccess("users").BindFunc(hooks.UpdateUserHandler(client))

Expand Down
125 changes: 125 additions & 0 deletions db/util/username.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package util

import (
"database/sql"
"errors"
"regexp"
"strconv"
"strings"
"unicode"

"github.com/pocketbase/pocketbase/core"
"golang.org/x/text/unicode/norm"
)

const (
usernameMinLength = 3
usernameMaxLength = 150
// how many suffixed variants to try before giving up on a name
usernameMaxAttempts = 50
)

var (
// characters the users.username field does not accept
usernameDisallowed = regexp.MustCompile(`[^\w.\-]+`)
// the field additionally requires the first character to be a word character
usernameLeading = regexp.MustCompile(`^[.\-]+`)
// characters that expand to more than one letter when transliterated
usernameExpansions = strings.NewReplacer(
"ä", "ae", "Ä", "Ae",
"ö", "oe", "Ö", "Oe",
"ü", "ue", "Ü", "Ue",
"ß", "ss",
"æ", "ae", "Æ", "Ae",
"ø", "oe", "Ø", "Oe",
)
)

// transliterate replaces accented Latin characters with their ASCII
// equivalents, so that "Karl Dörfinger" becomes "Karl Doerfinger" rather than
// losing the umlaut to an underscore.
//
// Characters that conventionally expand to two letters are mapped explicitly;
// the rest have their diacritics stripped ("José" -> "Jose"). Anything outside
// the Latin script is left alone and handled by SanitizeUsername.
func transliterate(raw string) string {
expanded := usernameExpansions.Replace(raw)

var b strings.Builder
for _, r := range norm.NFD.String(expanded) {
if unicode.Is(unicode.Mn, r) {
continue // combining mark left over from decomposition
}
b.WriteRune(r)
}

return b.String()
}

// SanitizeUsername converts a username coming from an OAuth2 provider into a
// value the users.username field accepts: word characters, dots and dashes,
// starting with a word character, between 3 and 150 characters long.
//
// Accented Latin characters are transliterated first, so "Karl Dörfinger"
// becomes "Karl_Doerfinger". Remaining disallowed characters are replaced with
// underscores, which keeps names such as "Jane Doe" recognisable as "Jane_Doe".
// Names that are too short are padded with underscores.
//
// Returns an empty string when nothing usable remains. Callers should then
// leave the username unset and let PocketBase generate one, rather than
// submitting a value the field will reject.
func SanitizeUsername(raw string) string {
username := usernameDisallowed.ReplaceAllString(transliterate(strings.TrimSpace(raw)), "_")
username = usernameLeading.ReplaceAllString(username, "")

// a name that transliterated to nothing recognisable - a script the field
// cannot represent, or punctuation only - is better left to PocketBase
// than turned into a meaningless "___"
if !strings.ContainsFunc(username, func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}) {
return ""
}

if len(username) > usernameMaxLength {
username = username[:usernameMaxLength]
}

for len(username) < usernameMinLength {
username += "_"
}

return username
}

// UniqueUsername returns username, or the first free variant of it suffixed
// with "_2", "_3" and so on.
//
// Returns an empty string when no free variant was found within
// usernameMaxAttempts, so that callers can fall back to a generated username
// instead of submitting a value that would collide with the unique index.
func UniqueUsername(app core.App, username string) string {
for attempt := 1; attempt <= usernameMaxAttempts; attempt++ {
candidate := username

if attempt > 1 {
suffix := "_" + strconv.Itoa(attempt)
if len(candidate)+len(suffix) > usernameMaxLength {
candidate = candidate[:usernameMaxLength-len(suffix)]
}
candidate += suffix
}

_, err := app.FindFirstRecordByData("users", "username", candidate)

switch {
case errors.Is(err, sql.ErrNoRows):
return candidate
case err != nil:
// treat a failed lookup as taken rather than risking a collision
continue
}
}

return ""
}
60 changes: 60 additions & 0 deletions db/util/username_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package util

import (
"regexp"
"strings"
"testing"
)

// the pattern and bounds of the users.username field
var usernameField = regexp.MustCompile(`^[\w][\w.\-]*$`)

func TestSanitizeUsername(t *testing.T) {
scenarios := []struct {
name string
raw string
expected string
}{
{"already valid", "jane_doe", "jane_doe"},
{"dots and dashes are kept", "jane.doe-1", "jane.doe-1"},
{"spaces become underscores", "Jane Doe", "Jane_Doe"},
{"umlauts are transliterated", "Jörg Müller", "Joerg_Mueller"},
{"eszett is transliterated", "Straßer", "Strasser"},
{"accents are stripped", "José Ángel", "Jose_Angel"},
{"capital umlauts keep their case", "Örjan", "Oerjan"},
{"unsupported scripts fall back", "Иван", ""},
{"runs collapse into one underscore", "Jane Doe", "Jane_Doe"},
{"surrounding whitespace is ignored", " Jane Doe ", "Jane_Doe"},
{"leading dot is dropped", ".jane", "jane"},
{"leading dash is dropped", "-jane", "jane"},
{"short names are padded", "ab", "ab_"},
{"single character is padded", "a", "a__"},
{"empty stays empty", "", ""},
{"punctuation only falls back", "!!!", ""},
{"only dots and dashes yields empty", ".-.", ""},
{"long names are truncated", strings.Repeat("a", 200), strings.Repeat("a", 150)},
}

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got := SanitizeUsername(s.raw)

if got != s.expected {
t.Fatalf("expected %q, got %q", s.expected, got)
}

// whatever comes out must be accepted by the field, or be empty
if got == "" {
return
}

if !usernameField.MatchString(got) {
t.Fatalf("%q does not match the username field pattern", got)
}

if len(got) < usernameMinLength || len(got) > usernameMaxLength {
t.Fatalf("%q has length %d, outside [%d, %d]", got, len(got), usernameMinLength, usernameMaxLength)
}
})
}
}