package main import ( "bytes" "database/sql" "errors" "fmt" "log" "os" "os/exec" "strconv" "strings" "git.tilde.town/tildetown/town/codes" "git.tilde.town/tildetown/town/external/lockingwriter" "git.tilde.town/tildetown/town/sshkey" "github.com/charmbracelet/lipgloss" _ "github.com/mattn/go-sqlite3" "github.com/mattn/go-tty" ) // TODO consider local-only help command for renaming, email mgmt, deleting account // TODO put colorscheme, prompting stuff into own packages for use in the other commands. would be good to get off of survey. // TODO use new lockingwriter for logging in the other external commands func connectDB() (*sql.DB, error) { db, err := sql.Open("sqlite3", "/town/var/codes/codes.db?mode=rw") if err != nil { return nil, err } return db, nil } type colorScheme struct { Header func(...string) string Subtitle func(...string) string Prompt func(...string) string Email func(...string) string Option func(...string) string Error func(...string) string } func newColorScheme() colorScheme { s2r := func(s lipgloss.Style) func(...string) string { return s.Render } c := func(s string) lipgloss.Color { return lipgloss.Color(s) } s := lipgloss.NewStyle return colorScheme{ Header: s2r(s().Bold(true).Foreground(c("#E0B0FF"))), Subtitle: s2r(s().Italic(true).Foreground(c("gray"))), Email: s2r(s().Bold(true).Underline(true)), Prompt: s2r(s().Bold(true).Foreground(c("#00752d"))), Option: s2r(s().Bold(true).Foreground(c("#38747a"))), Error: s2r(s().Bold(true).Foreground(c("#f43124"))), } } type Prompter struct { cs colorScheme tty *tty.TTY } func NewPrompter(tty *tty.TTY, cs colorScheme) *Prompter { return &Prompter{ cs: cs, tty: tty, } } func (p *Prompter) String(prompt string) (string, error) { // TODO assumes blank is no bueno var err error var answer string for answer == "" { fmt.Println("") fmt.Println(p.cs.Prompt(prompt)) fmt.Println(p.cs.Subtitle("(press enter to submit)")) answer, err = p.tty.ReadString() if err != nil { return "", fmt.Errorf("couldn't collect input: %w", err) } } return answer, nil } func (p *Prompter) Select(prompt string, opts []string) (int, error) { fmt.Println() fmt.Println(p.cs.Prompt(prompt)) fmt.Println(p.cs.Subtitle("(pick an option using the corresponding number)")) chosen := -1 for chosen < 0 { fmt.Println() for ix, o := range opts { fmt.Printf("%s: %s\n", p.cs.Option(fmt.Sprintf("%d", ix+1)), o) } r, err := p.tty.ReadRune() if err != nil { return -1, fmt.Errorf("could not collect answer for '%s': %w", prompt, err) } c, err := strconv.Atoi(string(r)) if err != nil { fmt.Println() fmt.Printf("I could not understand '%s'. Try again, please.\n", string(r)) continue } if c > len(opts) || c == 0 { fmt.Println() fmt.Printf("%s is not an option. Try again, please.\n", string(r)) continue } chosen = c - 1 } fmt.Println("") return chosen, nil } func _main(cs colorScheme) error { lw := lockingwriter.New() l := log.New(lw, "help: ", log.Ldate|log.Ltime|log.LUTC|log.Lshortfile|log.Lmsgprefix) db, err := connectDB() if err != nil { return fmt.Errorf("could not connect to database. please let root@tilde.town know about this.") } fmt.Println(cs.Header("Hi, you have reached the tilde town help desk.")) fmt.Println() fmt.Println("Please check out the options below.") fmt.Printf("If none of them apply to your case, you can email %s. \n", cs.Email("root@tilde.town")) tty, err := tty.Open() if err != nil { return fmt.Errorf("could not open tty: %w", err) } defer tty.Close() p := NewPrompter(tty, cs) options := []string{ "I need to request that a new SSH key be added to my account.", "I have a code from my e-mail to redeem for a new SSH key", "I just want out of here", } c, err := p.Select("What do you need help with?", options) defer func() { }() switch c { case 0: return collectEmail(l, db, cs, p) case 1: return redeemCode(l, db, cs, p) case 2: return nil } return nil } func emailToUsername(email string) (string, error) { cmd := exec.Command("sudo", "/town/bin/emailtouser", email) stderrBuff := bytes.NewBuffer([]byte{}) stdoutBuff := bytes.NewBuffer([]byte{}) cmd.Stderr = stderrBuff cmd.Stdout = stdoutBuff if err := cmd.Run(); err != nil { return "", fmt.Errorf("emailtouser failed with '%s': %w", stderrBuff.String(), err) } return strings.TrimSpace(stdoutBuff.String()), nil } func collectEmail(l *log.Logger, db *sql.DB, cs colorScheme, p *Prompter) error { fmt.Println(cs.Header("We can send a authorization code to an email associated with your town account.")) email, err := p.String("email to send reset code to?") if err != nil { return err } fmt.Println() fmt.Println(cs.Header("thanks!")) fmt.Println() fmt.Printf("If %s is associated with a town account we'll email an authorization code.\n", cs.Email(email)) mustHave := []string{"@", "."} found := 0 for _, s := range mustHave { if strings.Contains(email, s) { found++ } } if found != len(mustHave) { l.Printf("corrupt email '%s'", email) return nil } if _, err = emailToUsername(email); err != nil { l.Printf("no user for '%s'", email) return nil } code := codes.NewCode(email) ac := &AuthCode{ Code: code, Email: email, } if err = ac.Insert(db); err != nil { l.Printf("database error: %s", err.Error()) return errors.New("the database was sad") } if err = sendAuthCodeEmail(*ac); err != nil { l.Printf("mail send error: %s", err.Error()) return errors.New("email sending failed") } return nil } func redeemCode(l *log.Logger, db *sql.DB, cs colorScheme, p *Prompter) error { fmt.Println(cs.Header("redeem an auth code and add a new public key")) c, err := p.String("paste your auth code:") if err != nil { l.Printf("failed to prompt: %s", err.Error()) fmt.Println(cs.Error("sorry, I couldn't read that.")) return nil } parts, err := codes.Decode(c) if err != nil { l.Printf("failed to decode auth code: %s", err.Error()) fmt.Println(cs.Error("sorry, that doesn't look like an auth code...")) return nil } code := &AuthCode{ Code: c, Email: parts[1], } err = code.Hydrate(db) if err != nil { l.Printf("hydrate failed: %s", err.Error()) return errors.New("the database is sad") } if code.Used { fmt.Println(cs.Error("That code has already been redeemed. You'll have to request a new one.")) return nil } username, err := emailToUsername(code.Email) if err != nil { l.Printf("could not find user: %s", err.Error()) fmt.Println(cs.Error("That code doesn't seem to match an account.")) return nil } fmt.Println() fmt.Printf("hi, ~%s", username) key, err := p.String("paste your new public key:") if err != nil { l.Printf("failed to prompt: %s", err.Error()) fmt.Println(cs.Error("sorry, I couldn't read that.")) return nil } valid, err := sshkey.ValidKey(key) if err != nil { return fmt.Errorf("failed to validate key: %w", err) } if !valid { errMsg := fmt.Sprintf("that key is invalid: %s", err.Error()) fmt.Println(cs.Error(errMsg)) return nil } cmd := exec.Command("sudo", "--user", username, "/town/bin/appendkeyfile") cmd.Stdin = bytes.NewBufferString(key + "\n") stdoutBuff := bytes.NewBuffer([]byte{}) cmd.Stdout = stdoutBuff stderrBuff := bytes.NewBuffer([]byte{}) cmd.Stderr = stderrBuff if err = cmd.Run(); err != nil { l.Printf("appendkeyfile failed with '%s', '%s': %s", stderrBuff.String(), stdoutBuff.String(), err.Error()) return errors.New("adding to keys file failed") } err = code.MarkUsed(db) if err != nil { l.Printf("failed to mark used: %s", err.Error()) return errors.New("database was sad") } fmt.Println() fmt.Println("new key added! you should be able to use it to log in.") return nil } func main() { cs := newColorScheme() err := _main(cs) defer func() { fmt.Println() fmt.Println(cs.Header("bye~")) }() if err != nil { fmt.Println( cs.Error(fmt.Sprintf("sorry, something went wrong: %s", err.Error()))) fmt.Println("Please let an admin know by emailing a copy of this error to root@tilde.town") os.Exit(1) } } type AuthCode struct { ID int64 Code string Email string Used bool } func (c *AuthCode) Insert(db *sql.DB) error { stmt, err := db.Prepare(` INSERT INTO auth_codes (code, email) VALUES (?, ?)`) if err != nil { return err } defer stmt.Close() result, err := stmt.Exec(c.Code, c.Email) if err != nil { return err } liid, err := result.LastInsertId() if err != nil { return err } c.ID = liid return nil } func (c *AuthCode) Hydrate(db *sql.DB) error { stmt, err := db.Prepare(` SELECT id, used FROM auth_codes WHERE code = ? AND email = ?`) if err != nil { return err } defer stmt.Close() return stmt.QueryRow(c.Code, c.Email).Scan(&c.ID, &c.Used) } func (c *AuthCode) MarkUsed(db *sql.DB) error { if c.ID == 0 { return errors.New("not hydrated") } stmt, err := db.Prepare(` UPDATE auth_codes SET used = 1 WHERE id = ?`) if err != nil { return err } defer stmt.Close() result, err := stmt.Exec(c.ID) if err != nil { return err } var rowsAffected int64 if rowsAffected, err = result.RowsAffected(); err != nil { return err } if rowsAffected == 0 { return errors.New("no rows affected") } return nil }