mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
join@: let new members pick their own username
Onboarding hard-coded the account name to member-<fp8>, so everyone got an unmemorable handle like member-zafztqdk for ssh <name>@host and /~<name>. New keys are now prompted for a username during join@. auth.SanitizeUsername folds input to the hub/subdomain charset (lowercase [a-z0-9-], 3–20 chars, '_'/space -> '-', no doubled/edge dashes); auth.IsReservedName blocks route and infra labels (bbs/join/pod/domain/admin/agent/video/video-*/www/...). The name must be free (UserByName) or we re-prompt; pressing enter keeps the member-<fp8> default. Returning keys keep the name they already chose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
31c00cd65a
commit
ac4b0873d9
3 changed files with 143 additions and 7 deletions
|
|
@ -332,21 +332,25 @@ func (a *app) handleJoin(s ssh.Session) {
|
||||||
_ = s.Exit(1)
|
_ = s.Exit(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
in := bufio.NewReader(s)
|
||||||
|
|
||||||
u, found, err := a.st.UserByFingerprint(fp)
|
u, found, err := a.st.UserByFingerprint(fp)
|
||||||
if err == nil && !found {
|
|
||||||
name := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8]
|
|
||||||
u, err = a.st.EnsureUser(name, string(auth.Member), fp)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wish.Fatalln(s, "registration error: "+err.Error())
|
wish.Fatalln(s, "registration error: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
// New key: let the visitor pick their own handle before we create the
|
||||||
|
// account (a returning key keeps the name it already chose).
|
||||||
|
wish.Println(s, "\n Welcome to AgentBBS — let's set up your account.")
|
||||||
|
if u, err = a.registerNewMember(s, in, fp); err != nil {
|
||||||
|
wish.Fatalln(s, "registration error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
|
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
|
||||||
|
|
||||||
in := bufio.NewReader(s)
|
|
||||||
wish.Println(s, "\n"+strings.Join([]string{
|
wish.Println(s, "\n"+strings.Join([]string{
|
||||||
" Welcome to AgentBBS — let's set up your account.",
|
|
||||||
"",
|
|
||||||
" account " + u.Name,
|
" account " + u.Name,
|
||||||
" key " + fp,
|
" key " + fp,
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
|
|
@ -376,6 +380,46 @@ func (a *app) handleJoin(s ssh.Session) {
|
||||||
_ = s.Exit(0)
|
_ = s.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerNewMember asks the visitor to choose a username, then creates their
|
||||||
|
// member account under it. The name is sanitized to the hub/subdomain charset,
|
||||||
|
// rejected if reserved, and must be free; pressing enter accepts a generated
|
||||||
|
// member-<fp8> default. Returns the created user.
|
||||||
|
func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) {
|
||||||
|
def := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8]
|
||||||
|
wish.Println(s, "\n Pick a username — letters, numbers and dashes, 3–20 chars.")
|
||||||
|
wish.Println(s, " It's your handle for ssh <name>@"+a.host+" and https://"+a.host+"/~<name>.")
|
||||||
|
|
||||||
|
for tries := 0; tries < 5; tries++ {
|
||||||
|
wish.Print(s, "\n Username ["+def+"]: ")
|
||||||
|
line, err := in.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return store.User{}, err
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(line)
|
||||||
|
if raw == "" {
|
||||||
|
return a.st.EnsureUser(def, string(auth.Member), fp)
|
||||||
|
}
|
||||||
|
name, ok := auth.SanitizeUsername(raw)
|
||||||
|
switch {
|
||||||
|
case !ok && auth.IsReservedName(name):
|
||||||
|
wish.Println(s, " \""+name+"\" is reserved — pick another.")
|
||||||
|
continue
|
||||||
|
case !ok:
|
||||||
|
wish.Println(s, " needs 3–20 chars of letters, numbers or dashes — try again.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, taken, err := a.st.UserByName(name); err != nil {
|
||||||
|
return store.User{}, err
|
||||||
|
} else if taken {
|
||||||
|
wish.Println(s, " \""+name+"\" is taken — try another.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return a.st.EnsureUser(name, string(auth.Member), fp)
|
||||||
|
}
|
||||||
|
wish.Println(s, " Keeping "+def+" for now.")
|
||||||
|
return a.st.EnsureUser(def, string(auth.Member), fp)
|
||||||
|
}
|
||||||
|
|
||||||
// verifyEmailInteractive collects an email, emails a 6-digit code, and prompts
|
// verifyEmailInteractive collects an email, emails a 6-digit code, and prompts
|
||||||
// the visitor to type it back. It updates *u and returns true once verified.
|
// the visitor to type it back. It updates *u and returns true once verified.
|
||||||
func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.User) bool {
|
func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.User) bool {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,53 @@ func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
||||||
// IsAdminName reports whether the SSH username requests the admin console.
|
// IsAdminName reports whether the SSH username requests the admin console.
|
||||||
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
// systemReserved are names that don't drive an SSH route but would still
|
||||||
|
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||||
|
// infra hostnames — so members may not claim them as account names.
|
||||||
|
var systemReserved = map[string]bool{
|
||||||
|
"agent": true, "video": true, "www": true, "api": true, "mail": true,
|
||||||
|
"smtp": true, "imap": true, "ftp": true, "ns": true, "ns1": true, "ns2": true,
|
||||||
|
"cdn": true, "static": true, "assets": true, "root": true, "abuse": true,
|
||||||
|
"postmaster": true, "webmaster": true, "support": true, "help": true,
|
||||||
|
"admin": true, "sysop": true, "bbs": true, "guest": true, "pod": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReservedName reports whether name is claimed by a route or infra label and
|
||||||
|
// therefore cannot be used as a member's account name.
|
||||||
|
func IsReservedName(name string) bool {
|
||||||
|
n := strings.ToLower(name)
|
||||||
|
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || systemReserved[n] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeUsername normalizes a requested account name to the charset the hub
|
||||||
|
// and per-user subdomains allow: lowercased [a-z0-9-], 3–20 chars, with '_' and
|
||||||
|
// spaces folded to '-', no doubled, leading, or trailing dashes. It returns the
|
||||||
|
// cleaned name and whether it is usable (right length and not reserved).
|
||||||
|
func SanitizeUsername(raw string) (string, bool) {
|
||||||
|
var b strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
case r == '-' || r == '_' || r == ' ':
|
||||||
|
if b.Len() > 0 && !lastDash {
|
||||||
|
b.WriteByte('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := strings.Trim(b.String(), "-")
|
||||||
|
if len(name) < 3 || len(name) > 20 || IsReservedName(name) {
|
||||||
|
return name, false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
// IsGameName reports whether the SSH username requests the AgentGames protocol.
|
// IsGameName reports whether the SSH username requests the AgentGames protocol.
|
||||||
func IsGameName(u string) bool { return GameNames[strings.ToLower(u)] }
|
func IsGameName(u string) bool { return GameNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,48 @@ func TestAdminsEmpty(t *testing.T) {
|
||||||
t.Error("nobody is admin when allowlist is empty")
|
t.Error("nobody is admin when allowlist is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUsername(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"anthony", "anthony", true},
|
||||||
|
{" Cool_Name 42 ", "cool-name-42", true},
|
||||||
|
{"a--b__c", "a-b-c", true},
|
||||||
|
{"-Edge--", "edge", true},
|
||||||
|
{"MixedCASE", "mixedcase", true},
|
||||||
|
{"ab", "ab", false}, // too short
|
||||||
|
{"!!", "", false}, // nothing usable
|
||||||
|
{"this-name-is-way-too-long-to-accept", "", false}, // >20 after... actually long
|
||||||
|
{"admin", "admin", false}, // reserved (route/infra)
|
||||||
|
{"pod", "pod", false}, // reserved route
|
||||||
|
{"video-7f3a", "video-7f3a", false}, // reserved call route
|
||||||
|
{"WWW", "www", false}, // reserved infra label
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := SanitizeUsername(c.in)
|
||||||
|
if ok != c.ok {
|
||||||
|
t.Errorf("SanitizeUsername(%q) ok=%v, want %v (got name %q)", c.in, ok, c.ok, got)
|
||||||
|
}
|
||||||
|
// For valid results the cleaned name must match; for invalid ones we
|
||||||
|
// only assert the usability flag (the cleaned form is advisory).
|
||||||
|
if c.ok && got != c.want {
|
||||||
|
t.Errorf("SanitizeUsername(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsReservedName(t *testing.T) {
|
||||||
|
for _, n := range []string{"admin", "bbs", "pod", "join", "domain", "agent", "www", "video", "video-abc", "ROOT"} {
|
||||||
|
if !IsReservedName(n) {
|
||||||
|
t.Errorf("IsReservedName(%q) = false, want true", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []string{"anthony", "cool-name-42", "member-zafztqdk"} {
|
||||||
|
if IsReservedName(n) {
|
||||||
|
t.Errorf("IsReservedName(%q) = true, want false", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue