package main import ( "errors" "fmt" "net/mail" "os" "os/exec" "regexp" "strings" "git.tilde.town/tildetown/town/invites" "git.tilde.town/tildetown/town/sshkey" "git.tilde.town/tildetown/town/stats" "github.com/AlecAivazis/survey/v2" "github.com/charmbracelet/lipgloss" _ "embed" ) //go:embed welcome.txt var welcomeArt string // TODO move magic key machine to static page type newUserData struct { Username string DisplayName string Email string PubKey string } func surveyIconSet(icons *survey.IconSet) { icons.Question.Text = "~" icons.Question.Format = "magenta:b" } func promptCode() (code string, err error) { err = survey.AskOne(&survey.Input{ Message: "invite code?", }, &code, survey.WithValidator(survey.Required), survey.WithIcons(surveyIconSet)) code = strings.TrimSpace(code) return } func confirmContinue() (conf bool, err error) { err = survey.AskOne( &survey.Confirm{ Message: "Does the above look ok?", }, &conf, survey.WithValidator(survey.Required), survey.WithIcons(surveyIconSet)) return } type asker struct { UserData *newUserData Style lipgloss.Style Invite invites.Invite TownData stats.TildeData } func (a *asker) Ask() (err error) { // TODO somehow un and email getting set to "" but pubkey works fine? if err = a.promptUsername(); err != nil { return err } if err = a.promptEmail(); err != nil { return err } if err = a.promptKey(); err != nil { return err } s := a.Style.SetString( fmt.Sprintf(`ok! your account is about to be created with the following details: username: %s email: %s pubkey: %s`, a.UserData.Username, a.UserData.Email, a.UserData.PubKey)).Bold(true).MaxWidth(80) fmt.Println(s) return nil } func (a *asker) promptUsername() (err error) { // copied from /etc/adduser.conf usernameRE := regexp.MustCompile(`^[a-z][-a-z0-9_]*$`) err = survey.AskOne( &survey.Input{ Message: "desired username?", Default: a.UserData.Username, }, &a.UserData.Username, survey.WithValidator(survey.Required), survey.WithIcons(surveyIconSet), survey.WithValidator(func(val interface{}) error { un := val.(string) if len(un) > 32 { return fmt.Errorf("username '%s' is too long", un) } return nil }), survey.WithValidator(func(val interface{}) error { un := val.(string) if !usernameRE.MatchString(un) { return errors.New("usernames must start with a letter and only contain letters, nubers, - or _") } return nil }), survey.WithValidator(func(val interface{}) error { un := val.(string) for _, v := range a.TownData.Users { if v.Username == un { return fmt.Errorf("username '%s' is already in use", un) } } return nil })) return } func (a *asker) promptEmail() (err error) { err = survey.AskOne( &survey.Input{ Message: "e-mail (for account recovery only)?", Default: a.UserData.Email, }, &a.UserData.Email, survey.WithValidator(survey.Required), survey.WithIcons(surveyIconSet), survey.WithValidator(func(val interface{}) error { email := val.(string) _, err := mail.ParseAddress(email) if err != nil { return fmt.Errorf("'%s' doesn't look like an email: %w", email, err) } if !strings.Contains(email, ".") { return fmt.Errorf("'%s' doesn't look like an email: domain not fully qualified", email) } return nil })) return } func (a *asker) promptKey() (err error) { err = survey.AskOne( &survey.Input{ Message: "SSH public key?", Default: a.UserData.PubKey, }, &a.UserData.PubKey, survey.WithValidator(survey.Required), survey.WithIcons(surveyIconSet), survey.WithValidator(func(v interface{}) error { key := v.(string) valid, err := sshkey.ValidKey(key) if err != nil { return fmt.Errorf("failed to validate key: %w", err) } if !valid { return errors.New("that doesn't seem like a valid SSH key. try another public key?") } return nil })) return } func _main() error { inviteDB, err := invites.ConnectDB() if err != nil { return err } s := lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{ Light: "#7D19BD", Dark: "#E0B0FF", }) s = s.SetString(welcomeArt) fmt.Println(s) code, err := promptCode() if err != nil { return err } invite, err := invites.Get(inviteDB, code) if err != nil { return fmt.Errorf("could not look up invite code: %w", err) } if invite.Used { return errors.New("that invite code has already been used.") } s = s.SetString("thanks!! just gotta collect some information now and then your account will be ready.") fmt.Println(s) townData, err := stats.Stats() if err != nil { return err } data := &newUserData{ Email: invite.Email, } a := &asker{ UserData: data, Invite: *invite, TownData: townData, Style: s, } if err = a.Ask(); err != nil { return err } conf, err := confirmContinue() if err != nil { return err } if !conf { for !conf { if err = a.Ask(); err != nil { return err } if conf, err = confirmContinue(); err != nil { return err } } } s = s.SetString("cool, awesome, going to make your account now...") fmt.Println(s) if err = createUser(*data); err != nil { s = s.SetString(fmt.Sprintf(`augh, I'm sorry. the account creation failed. Please email root@tilde.town and paste the following error: %s Your invite code has not been marked as used and you're welcome to try again, though if there is a system issue you might need to wait for word from an admin.`, err.Error())) fmt.Println(s) return nil } // TODO assuming account creation succeeded, mark invite as used return nil } // TODO add logging like the signup tool has func createUser(data newUserData) (err error) { cmd := exec.Command("sudo", "/usr/sbin/adduser", "--quiet", "--disabled-password", data.Username) if err = cmd.Run(); err != nil { return fmt.Errorf("adduser failed: %w", err) } cmd = exec.Command("sudo", "/usr/sbin/usermod", "-a", "-G", "town", data.Username) if err = cmd.Run(); err != nil { return fmt.Errorf("usermod failed: %w", err) } // TODO create keyfile by running helper (helper should also make ~/.ssh) // TODO generate welcome gift // TODO any alerts return errors.New("not implemented") } func main() { // TODO friendlier error handling err := _main() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }