pronounss/backend/routes/v2/auth/email_signup.go

192 lines
5.4 KiB
Go

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,
})
render.NoContent(w, r)
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(req.Ticket)))
if err != nil {
return errors.Wrap(err, "getting email signup key")
}
if email == "" {
return server.APIError{Code: server.ErrBadRequest, Details: "Unknown ticket"}
}
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, tx, 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"`
}