Extend usernameRE to [a-z0-9_.-], preserve dots in SanitizeForUsername, and add tests for validation, sanitization, and nip05 sync precedence. Co-authored-by: Cursor <cursoragent@cursor.com>
102 lines
2.4 KiB
Go
102 lines
2.4 KiB
Go
package user
|
|
|
|
import (
|
|
"strings"
|
|
)
|
|
|
|
// SanitizeForUsername coerces an arbitrary profile string into a candidate
|
|
// that matches usernameRE: lowercase ASCII alphanumerics, `_`, `-`, and `.`,
|
|
// length <= 30, with an alphanumeric first character. Returns "" when no
|
|
// usable handle can be derived.
|
|
func SanitizeForUsername(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
prevSep := false
|
|
for _, r := range s {
|
|
switch {
|
|
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
|
b.WriteRune(r)
|
|
prevSep = false
|
|
case r == '-' || r == '_' || r == '.':
|
|
if b.Len() == 0 {
|
|
continue
|
|
}
|
|
if prevSep {
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
prevSep = true
|
|
default:
|
|
if b.Len() == 0 {
|
|
continue
|
|
}
|
|
if prevSep {
|
|
continue
|
|
}
|
|
b.WriteRune('_')
|
|
prevSep = true
|
|
}
|
|
}
|
|
out := strings.TrimRight(b.String(), "_-.")
|
|
if len(out) > 30 {
|
|
out = strings.TrimRight(out[:30], "_-.")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nip05LocalPart returns the local part of a NIP-05 identifier (`local@domain`)
|
|
// when its domain matches `serviceDomain` (case-insensitive). The bare form
|
|
// `local` (no `@`) per NIP-05 implies `_@domain`, which we ignore for sync
|
|
// since we cannot prove the domain match.
|
|
func nip05LocalPart(nip05, serviceDomain string) string {
|
|
nip05 = strings.TrimSpace(nip05)
|
|
if nip05 == "" || serviceDomain == "" {
|
|
return ""
|
|
}
|
|
at := strings.LastIndex(nip05, "@")
|
|
if at <= 0 || at == len(nip05)-1 {
|
|
return ""
|
|
}
|
|
local := nip05[:at]
|
|
domain := nip05[at+1:]
|
|
if !strings.EqualFold(domain, serviceDomain) {
|
|
return ""
|
|
}
|
|
return local
|
|
}
|
|
|
|
// CandidateFromMetadata returns a sanitized, validated username derived from a
|
|
// kind:0 profile, or "" if no field yields a usable handle. Precedence:
|
|
// 1. NIP-05 local part when its domain matches serviceDomain.
|
|
// 2. `name` (NIP-01).
|
|
// 3. `display_name` / `displayName` (NIP-24).
|
|
// 4. Deprecated `username` field.
|
|
func CandidateFromMetadata(name, displayName, displayNameAlt, username, nip05, serviceDomain string, reserved []string) string {
|
|
tryFields := []string{
|
|
nip05LocalPart(nip05, serviceDomain),
|
|
name,
|
|
displayName,
|
|
displayNameAlt,
|
|
username,
|
|
}
|
|
for _, raw := range tryFields {
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
c := SanitizeForUsername(raw)
|
|
if c == "" {
|
|
continue
|
|
}
|
|
if err := ValidateUsername(c, reserved); err != nil {
|
|
continue
|
|
}
|
|
return c
|
|
}
|
|
return ""
|
|
}
|