package auth import ( "net/http" "strings" "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" ) type postEmailSignupRequest struct { Email string `json:"email"` } func (s *Server) postEmailSignup(w http.ResponseWriter, r *http.Request) (err error) { ctx := r.Context() var req postEmailSignupRequest err = render.Decode(r, &req) if err != nil { return server.APIError{Code: server.ErrBadRequest} } if !strings.Contains(req.Email, "@") { return server.APIError{Code: server.ErrBadRequest, Details: "Email seems to be invalid"} } ticket := common.RandBase64(48) err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET", emailSignupTicketKey(ticket), req.Email, "EX", "3600")) if err != nil { return errors.Wrap(err, "setting email signup key") } // if the email address already exists, pretend we sent an email and return // to prevent people from discovering valid email addresses exists, err := s.DB.EmailExists(ctx, req.Email) if err != nil { return errors.Wrap(err, "checking if email exists") } if exists { render.NoContent(w, r) return nil } go s.SendEmail(req.Email, "Confirm your email address", "signup", map[string]any{ "Ticket": ticket, }) return nil } func emailSignupTicketKey(ticket string) string { return "email-signup:" + ticket } type postEmailSignupConfirmRequest struct { Ticket string `json:"ticket"` Username string `json:"username"` Password string `json:"password"` } func (s *Server) postEmailSignupConfirm(w http.ResponseWriter, r *http.Request) (err error) { ctx := r.Context() var req postEmailSignupConfirmRequest err = render.Decode(r, &req) if err != nil { return server.APIError{Code: server.ErrBadRequest} } var email string err = s.DB.Redis.Do(ctx, radix.Cmd(&email, "GET", emailSignupTicketKey(email))) if err != nil { return errors.Wrap(err, "getting email signup key") } tx, err := s.DB.Begin(ctx) if err != nil { return errors.Wrap(err, "starting transaction") } defer func() { _ = tx.Rollback(ctx) }() u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { switch err { case db.ErrUsernameTaken: return server.APIError{Code: server.ErrUsernameTaken} case db.ErrUsernameTooShort: return server.APIError{Code: server.ErrBadRequest, Details: "Username is too short"} case db.ErrUsernameTooLong: return server.APIError{Code: server.ErrBadRequest, Details: "Username is too long"} case db.ErrInvalidUsername: return server.APIError{Code: server.ErrBadRequest, Details: "Username contains invalid characters"} case db.ErrBannedUsername: return server.APIError{Code: server.ErrBadRequest, Details: "Username is not allowed"} default: return errors.Wrap(err, "creating user") } } _, err = s.DB.AddEmail(ctx, tx, u.SnowflakeID, email) if err != nil { if err == db.ErrEmailInUse { // This should only happen if the email was *not* taken when the ticket was sent, but was taken in the meantime. // i.e. unless another person has access to the mailbox, the user will know what happened return server.APIError{Code: server.ErrBadRequest, Details: "Email is already in use"} } return errors.Wrap(err, "adding email to user") } err = s.DB.SetPassword(ctx, tx, u.SnowflakeID, req.Password) if err != nil { return errors.Wrap(err, "setting password for user") } // create token tokenID := xid.New() token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) if err != nil { return errors.Wrap(err, "creating token") } // save token to database _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } err = tx.Commit(ctx) if err != nil { return errors.Wrap(err, "committing transaction") } render.JSON(w, r, signupResponse{ User: *dbUserToUserResponse(u, nil), Token: token, }) return nil } type userResponse struct { ID common.UserID `json:"id"` Username string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` Fields []db.Field `json:"fields"` Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` Tumblr *string `json:"tumblr"` TumblrUsername *string `json:"tumblr_username"` Google *string `json:"google"` GoogleUsername *string `json:"google_username"` Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` } func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { return &userResponse{ ID: u.SnowflakeID, Username: u.Username, DisplayName: u.DisplayName, Bio: u.Bio, Avatar: u.Avatar, Links: db.NotNull(u.Links), Names: db.NotNull(u.Names), Pronouns: db.NotNull(u.Pronouns), Fields: db.NotNull(fields), } } type signupResponse struct { User userResponse `json:"user"` Token string `json:"token"` }