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 == '_': 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 "" }