website/pkg/controller/account/signup.go
2024-09-09 20:59:46 -07:00

289 lines
9.3 KiB
Go

package account
import (
"fmt"
"net/http"
nm "net/mail"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility"
"github.com/google/uuid"
)
// SignupToken goes in Redis when the user first gives us their email address. They
// verify their email before signing up, so cache only in Redis until verified.
type SignupToken struct {
Email string
Token string
}
// Delete a SignupToken when it's been used up.
func (st SignupToken) Delete() error {
return redis.Delete(fmt.Sprintf(config.SignupTokenRedisKey, st.Token))
}
// Initial signup controller.
func Signup() http.HandlerFunc {
tmpl := templates.Must("account/signup.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Maintenance mode?
if middleware.SignupMaintenance(w, r) {
return
}
// Template vars.
var vars = map[string]interface{}{
"SignupToken": "", // non-empty if user has clicked verification link
"SkipEmailVerification": false, // true if email verification is disabled
"Email": "", // pre-filled user email
}
// Is email verification disabled?
if config.SkipEmailVerification {
vars["SkipEmailVerification"] = true
}
// Are we called with an email verification token?
var tokenStr = r.URL.Query().Get("token")
if r.Method == http.MethodPost {
tokenStr = r.PostFormValue("token")
}
var token SignupToken
if tokenStr != "" {
// Validate it.
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
session.FlashError(w, r, "Invalid email verification token. Please try signing up again.")
templates.Redirect(w, r.URL.Path)
return
}
vars["SignupToken"] = tokenStr
vars["Email"] = token.Email
}
// Posting?
if r.Method == http.MethodPost {
var (
// Collect form fields.
email = strings.TrimSpace(strings.ToLower(r.PostFormValue("email")))
confirm = r.PostFormValue("confirm") == "true"
// Only on full signup form
username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username")))
password = strings.TrimSpace(r.PostFormValue("password"))
password2 = strings.TrimSpace(r.PostFormValue("password2"))
dob = r.PostFormValue("dob")
// CAPTCHA response.
turnstileCAPTCHA = r.PostFormValue("cf-turnstile-response")
// Honeytrap fields for lazy spam bots.
honeytrap1 = r.PostFormValue("phone") == ""
honeytrap2 = r.PostFormValue("referral") == "Word of mouth"
// Validation errors but still show the form again.
hasError bool
)
// Honeytrap fields check.
if !honeytrap1 || !honeytrap2 {
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
templates.Redirect(w, r.URL.Path)
return
}
// Validate the CAPTCHA token.
if config.Current.Turnstile.Enabled {
if err := spam.ValidateTurnstileCAPTCHA(turnstileCAPTCHA, "signup"); err != nil {
session.FlashError(w, r, "There was an error validating your CAPTCHA response.")
templates.Redirect(w, r.URL.Path)
return
}
}
// Don't let them sneakily change their verified email address on us.
if vars["SignupToken"] != "" && email != vars["Email"] {
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
templates.Redirect(w, r.URL.Path)
return
}
// Cache username in case of passwd validation errors.
vars["Email"] = email
vars["Username"] = username
// Validate the email.
if _, err := nm.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Didn't confirm?
if !confirm {
session.FlashError(w, r, "Confirm that you have read the rules.")
templates.Redirect(w, r.URL.Path)
return
}
// Already an account?
if user, err := models.FindUser(email); err == nil {
// We don't want to admit that the email already is registered, so send an email to the
// address in case the user legitimately forgot, but flash the regular success message.
if user.IsBanned() {
log.Error("Do not send signup e-mail to %s: user is banned", email)
} else {
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
}
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
templates.Redirect(w, r.URL.Path)
return
}
// Email verification step!
if !config.SkipEmailVerification && vars["SignupToken"] == "" {
// Create a SignupToken verification link to send to their inbox.
token = SignupToken{
Email: email,
Token: uuid.New().String(),
}
if err := redis.Set(fmt.Sprintf(config.SignupTokenRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Error creating a link to send you: %s", err)
}
// Is the app not configured to send email?
if !config.Current.Mail.Enabled && !config.SkipEmailVerification {
// Log the signup token for local dev.
log.Error("Signup: the app is not configured to send email. To continue, visit the URL: /signup?token=%s", token.Token)
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
// Reminder to check their spam folder too (Gmail users)
session.Flash(w, r, "If you don't see the confirmation e-mail, check in case it went to your spam folder.")
templates.Redirect(w, r.URL.Path)
return
}
// DOB check.
birthdate, err := time.Parse("2006-01-02", dob)
if err != nil {
session.FlashError(w, r, "Incorrect format for birthdate; should be in yyyy-mm-dd format but got: %s", dob)
templates.Redirect(w, r.URL.Path)
return
} else {
// Validate birthdate is at least age 18.
if utility.Age(birthdate) < 18 {
session.FlashError(w, r, "You must be at least 18 years old to use this site.")
templates.Redirect(w, "/")
// Burn the signup token.
if token.Token != "" {
if err := token.Delete(); err != nil {
log.Error("SignupToken.Delete(%s): %s", token.Token, err)
}
}
return
}
}
// Full sign-up step (w/ email verification token), validate more things.
if len(password) < 3 {
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
hasError = true
} else if password != password2 {
session.FlashError(w, r, "Your passwords do not match.")
hasError = true
}
// Validate the username is OK: well formatted, not reserved, not existing.
if err := models.IsValidUsername(username); err != nil {
session.FlashError(w, r, err.Error())
hasError = true
}
// Looking good?
if !hasError {
user, err := models.CreateUser(username, email, password)
if err != nil {
session.FlashError(w, r, err.Error())
} else {
session.Flash(w, r, "User account created. Now logged in as %s.", user.Username)
// Burn the signup token.
if token.Token != "" {
if err := token.Delete(); err != nil {
log.Error("SignupToken.Delete(%s): %s", token.Token, err)
}
}
// Put their birthdate in.
user.Birthdate = birthdate
user.Save()
// Log in the user and send them to their dashboard.
session.LoginUser(w, r, user)
templates.Redirect(w, "/me")
}
}
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}