package control import ( "errors" "fmt" "io" "strings" "time" "golang.org/x/crypto/ssh" "gitbay.org/gitbay/internal/config" "gitbay.org/gitbay/internal/mail" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) func init() { register(Command{Path: []string{"email", "add"}, Summary: "add an address and mail a verification code: email add
", Run: runEmailAdd}) register(Command{Path: []string{"email", "verify"}, Summary: "confirm a verification code: email verify ", Run: runEmailVerify}) } func siteHost(cfg config.Config) string { h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://") return strings.TrimSuffix(h, "/") } func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error { code, hash, err := store.NewToken() if err != nil { return err } if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil { return err } body := fmt.Sprintf( "Someone (hopefully you) added this address to an account on %s.\n\n"+ "To verify it, run:\n\n ssh git@%s email verify %s\n\n"+ "The code expires in 24 hours. If this wasn't you, ignore this mail.\n", siteHost(cfg), siteHost(cfg), code) return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body) } func runEmailAdd(c *Ctx, args []string) int { if len(args) != 1 || !strings.Contains(args[0], "@") { return c.fail(protocol.ExitUsage, "usage: email add
") } if c.Cfg.Mail.SMTPHost == "" { return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)") } if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil { return c.fail(protocol.ExitFailure, "sending verification mail: %v", err) } return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) { fmt.Fprintf(w, "verification code sent to %s\n", args[0]) }) } func runEmailVerify(c *Ctx, args []string) int { if len(args) != 1 { return c.fail(protocol.ExitUsage, "usage: email verify ") } address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0])) if err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") } return c.fail(protocol.ExitFailure, "%v", err) } if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := c.Store.ClearPending(c.User.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) { fmt.Fprintf(w, "%s verified; your account is active\n", address) }) } // RunRegister handles the one command an UNAUTHENTICATED key may run. It is // dispatched outside the normal registry: the caller has already checked // that registration is enabled and that argv[0] == "register". func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string, stdout, stderr io.Writer) int { var username, email, invite string args := argv[1:] for i := 0; i < len(args); i++ { switch args[i] { case "--username", "--email", "--invite": if i+1 >= len(args) { fmt.Fprintf(stderr, "%s requires a value\n", args[i]) return protocol.ExitUsage } switch args[i] { case "--username": username = args[i+1] case "--email": email = args[i+1] case "--invite": invite = args[i+1] } i++ default: fmt.Fprintf(stderr, "unexpected argument %q\n", args[i]) return protocol.ExitUsage } } fail := func(code int, format string, a ...any) int { fmt.Fprintf(stderr, format+"\n", a...) return code } if username == "" { return fail(protocol.ExitUsage, "usage: register --username --email
| register --username --invite ") } if err := policy.ValidateOwnerName(username); err != nil { return fail(protocol.ExitUsage, "%v", err) } switch cfg.Registration.Mode { case "invite": if invite == "" { return fail(protocol.ExitDenied, "this instance is invite-only: register --username --invite ") } addr, err := st.ConsumeInvite(store.HashToken(invite)) if err != nil { return fail(protocol.ExitDenied, "that invite is invalid or already used") } uid, err := st.CreateRegisteredUser(username, false) if err != nil { return fail(protocol.ExitFailure, "%v", err) } // Possession of the emailed invite code proves the mailbox. if err := st.AddEmail(uid, addr, "smtp", true); err != nil { return fail(protocol.ExitFailure, "%v", err) } if err := addRegisteredKey(st, uid, pub); err != nil { return fail(protocol.ExitFailure, "%v", err) } fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username) return protocol.ExitOK case "open": if email == "" || !strings.Contains(email, "@") { return fail(protocol.ExitUsage, "usage: register --username --email
") } uid, err := st.CreateRegisteredUser(username, true) if err != nil { return fail(protocol.ExitFailure, "%v", err) } if err := st.AddEmail(uid, email, "", true); err != nil { return fail(protocol.ExitFailure, "%v", err) } if err := addRegisteredKey(st, uid, pub); err != nil { return fail(protocol.ExitFailure, "%v", err) } if err := sendVerification(cfg, st, uid, email); err != nil { return fail(protocol.ExitFailure, "sending verification mail: %v", err) } fmt.Fprintf(stdout, "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify \n", username, email, siteHost(cfg)) return protocol.ExitOK default: return fail(protocol.ExitDenied, "registration is closed on this instance") } } func addRegisteredKey(st *store.Store, uid int64, pub ssh.PublicKey) error { return st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full") }