website/pkg/controller/account/settings.go
Noah Petherbridge d8593d89c0 Iterate on color themes + Forum style fixes
* Move the forum box colors into dedicated styles that are easier to
  override for the new theme colors.
* Updated the themes so forums and comment thread background cards now
  match your chosen style.
* Add yellow and orange theme variants.
2024-11-24 13:22:28 -08:00

579 lines
18 KiB
Go

package account
import (
"fmt"
"net/http"
nm "net/mail"
"regexp"
"strconv"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail"
"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"
"code.nonshy.com/nonshy/website/pkg/worker"
"github.com/google/uuid"
)
// ChangeEmailToken for Redis.
type ChangeEmailToken struct {
Token string
UserID uint64
NewEmail string
}
// Delete the change email token.
func (t ChangeEmailToken) Delete() error {
return redis.Delete(fmt.Sprintf(config.ChangeEmailRedisKey, t.Token))
}
// User settings page. (/settings).
func Settings() http.HandlerFunc {
tmpl := templates.Must("account/settings.html")
var reHexColor = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := map[string]interface{}{
"Enum": config.ProfileEnums,
"WebsiteThemeHueChoices": config.WebsiteThemeHueChoices,
}
// Load the current user in case of updates.
user, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Is the user currently in the chat room? Gate username changes when so.
var isOnChat = worker.GetChatStatistics().IsOnline(user.Username)
vars["OnChat"] = isOnChat
// URL hashtag to redirect to
var hashtag string
// Are we POSTing?
if r.Method == http.MethodPost {
// Will they BECOME a Shy Account with this change?
var wasShy = user.IsShy()
intent := r.PostFormValue("intent")
switch intent {
case "profile":
// Setting profile values.
hashtag = "#profile"
var (
displayName = r.PostFormValue("display_name")
dob = r.PostFormValue("dob")
)
// Set user attributes.
user.Name = &displayName
// Birthdate, now required.
if birthdate, err := time.Parse("2006-01-02", dob); err != nil {
session.FlashError(w, r, "Incorrect format for birthdate; should be in yyyy-mm-dd format but got: %s", dob)
} else {
// Validate birthdate is at least age 18.
if utility.Age(birthdate) < 18 {
session.FlashError(w, r, "Invalid birthdate: you must be at least 18 years old to use this site.")
templates.Redirect(w, r.URL.Path)
return
}
// If the user changes their birthdate, notify the admin.
if !user.Birthdate.IsZero() && user.Birthdate.Format("2006-01-02") != dob {
// Create an admin Feedback model.
fb := &models.Feedback{
Intent: "report",
Subject: "report.dob",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"A user has modified their birthdate on their profile page!\n\n"+
"* Original: %s (age %d)\n* Updated: %s (age %d)",
user.Birthdate, utility.Age(user.Birthdate),
birthdate, utility.Age(birthdate),
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
// Work around DST issues: set the hour to noon.
user.Birthdate = birthdate.Add(12 * time.Hour)
}
// Set profile attributes.
for _, attr := range config.ProfileFields {
var value = strings.TrimSpace(r.PostFormValue(attr))
// Look for spammy links to restricted video sites or things.
if err := spam.DetectSpamMessage(value); err != nil {
session.FlashError(w, r, "On field '%s': %s", attr, err.Error())
continue
}
user.SetProfileField(attr, value)
}
// "Looking For" checkbox list.
if hereFor, ok := r.PostForm["here_for"]; ok {
user.SetProfileField("here_for", strings.Join(hereFor, ","))
}
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Profile settings updated!")
case "look":
hashtag = "#look"
// Resetting all styles?
if r.PostFormValue("reset") == "true" {
// Blank out all profile fields.
for _, field := range []string{
"hero-color-start",
"hero-color-end",
"hero-text-dark",
"card-title-bg",
"card-title-fg",
"card-link-color",
"card-lightness",
} {
user.SetProfileField(field, "")
}
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Profile look & feel reset to defaults!")
break
}
// Set color preferences.
for _, field := range []string{
"hero-color-start",
"hero-color-end",
"card-title-bg",
"card-title-fg",
"card-link-color",
} {
// Ensure valid.
value := r.PostFormValue(field)
if !reHexColor.Match([]byte(value)) {
value = ""
}
user.SetProfileField(field, value)
}
// Set other fields.
for _, field := range []string{
"hero-text-dark",
"card-lightness",
"website-theme", // light, dark, auto
} {
value := r.PostFormValue(field)
user.SetProfileField(field, value)
}
// Website theme color: constrain to available options.
for _, field := range []struct {
Name string
Options []config.OptGroup
}{
{"website-theme-hue", config.WebsiteThemeHueChoices},
} {
value := utility.StringInOptGroup(
r.PostFormValue(field.Name),
field.Options,
"",
)
user.SetProfileField(field.Name, value)
}
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Profile look & feel updated!")
case "preferences":
hashtag = "#prefs"
var (
explicit = r.PostFormValue("explicit") == "true"
blurExplicit = r.PostFormValue("blur_explicit")
autoplayGif = r.PostFormValue("autoplay_gif")
)
user.Explicit = explicit
// Set profile field prefs.
user.SetProfileField("blur_explicit", blurExplicit)
if autoplayGif != "true" {
autoplayGif = "false"
}
user.SetProfileField("autoplay_gif", autoplayGif)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Website preferences updated!")
case "privacy":
hashtag = "#privacy"
var (
visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy")
ppPrivacy = r.PostFormValue("private_photo_gate")
)
user.Visibility = models.UserVisibilityPublic
for _, cmp := range models.UserVisibilityOptions {
if visibility == cmp {
user.Visibility = visibility
}
}
// Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy)
user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Privacy settings updated!")
case "notifications":
hashtag = "#notifications"
// Store their notification opt-outs.
for _, key := range config.NotificationOptOutFields {
var value = r.PostFormValue(key)
// Boolean flip for DB storage:
// - Pre-existing users before these options are added have no pref stored in the DB
// - The default pref is opt-IN (receive all notifications)
// - The checkboxes on front-end are on by default, uncheck them to opt-out, checkbox value="true"
// - So when they post as "true" (default), we keep the notifications sending
// - If they uncheck the box, no value is sent and that's an opt-out.
if value == "" {
value = "true" // opt-out, store opt-out=true in the DB
} else if value == "true" {
value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB
}
// Save it. TODO: fires off inserts/updates for each one,
// probably not performant to do.
user.SetProfileField(key, value)
}
session.Flash(w, r, "Notification preferences updated!")
// Save the user for new fields to be committed to DB.
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
// Are they unsubscribing from all threads?
if r.PostFormValue("unsubscribe_all_threads") == "true" {
if err := models.UnsubscribeAllThreads(user); err != nil {
session.FlashError(w, r, "Couldn't unsubscribe from threads: %s", err)
} else {
session.Flash(w, r, "Unsubscribed from all comment threads!")
}
}
case "push_notifications":
hashtag = "#notifications"
// Store their notification opt-outs.
for _, key := range config.PushNotificationOptOutFields {
var value = r.PostFormValue(key)
if value == "" {
value = "true" // opt-out, store opt-out=true in the DB
} else if value == "true" {
value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB
}
// Save it.
user.SetProfileField(key, value)
}
session.Flash(w, r, "Notification preferences updated!")
// Save the user for new fields to be committed to DB.
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
case "location":
hashtag = "#location"
var (
source = r.PostFormValue("source")
latStr = r.PostFormValue("latitude")
lonStr = r.PostFormValue("longitude")
)
// Get and update the user's location.
location := models.GetUserLocation(user.ID)
location.Source = source
if lat, err := strconv.ParseFloat(latStr, 64); err == nil {
location.Latitude = lat
} else {
location.Latitude = 0
}
if lon, err := strconv.ParseFloat(lonStr, 64); err == nil {
location.Longitude = lon
} else {
location.Longitude = 0
}
// Save it.
if err := location.Save(); err != nil {
session.FlashError(w, r, "Couldn't save your location preference: %s", err)
} else {
session.Flash(w, r, "Location settings updated!")
}
case "settings":
hashtag = "#account"
var (
oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
changeUsername = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_username")))
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
)
// Their old password is needed to make any changes to their account.
if err := user.CheckPassword(oldPassword); err != nil {
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Changing their username?
if changeUsername != user.Username {
// Not if they are in the chat room!
if isOnChat {
session.FlashError(w, r, "Your username could not be changed right now because you are logged into the chat room. Please exit the chat room, wait a minute, and try your request again.")
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Check if the new name is OK.
if err := models.IsValidUsername(changeUsername); err != nil {
session.FlashError(w, r, "Could not change your username: %s", err.Error())
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Clear their history on the chat room.
go func(username string) {
log.Error("Change of username, clear chat history for old name %s", username)
i, err := chat.EraseChatHistory(username)
if err != nil {
log.Error("EraseChatHistory(%s): %s", username, err)
return
}
session.Flash(w, r, "Notice: due to your recent change in username, your direct message history on the Chat Room has been reset. %d message(s) had been removed.", i)
}(user.Username)
// Set their name.
origUsername := user.Username
user.Username = changeUsername
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving your new username: %s", err)
} else {
session.Flash(w, r, "Your username has been updated to: %s", user.Username)
// Notify the admin about this to keep tabs if someone is acting strangely
// with too-frequent username changes.
fb := &models.Feedback{
Intent: "report",
Subject: "Change of username",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"A user has modified their username on their profile page!\n\n"+
"* Original: %s\n* Updated: %s",
origUsername, changeUsername,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Changing their email?
if changeEmail != user.Email {
// Validate the email.
if _, err := nm.ParseAddress(changeEmail); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Email must not already exist.
if _, err := models.FindUser(changeEmail); err == nil {
session.FlashError(w, r, "That email address is already in use.")
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Create a tokenized link.
token := ChangeEmailToken{
Token: uuid.New().String(),
UserID: user.ID,
NewEmail: changeEmail,
}
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Failed to create change email token: %s", err)
templates.Redirect(w, r.URL.Path+hashtag)
return
}
err := mail.Send(mail.Message{
To: changeEmail,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/settings/confirm-email?token=" + token.Token,
"ChangeEmail": true,
},
})
if err != nil {
session.FlashError(w, r, "Error sending a confirmation email to %s: %s", changeEmail, err)
} else {
session.Flash(w, r, "Please verify your new email address. A link has been sent to %s to confirm.", changeEmail)
}
}
// Changing their password?
if password1 != "" {
if password2 != password1 {
log.Error("pw1=%s pw2=%s", password1, password2)
session.FlashError(w, r, "Couldn't change your password: your new passwords do not match.")
} else {
// Hash the new password.
if err := user.HashPassword(password1); err != nil {
session.FlashError(w, r, "Failed to hash your new password: %s", err)
} else {
// Save the user row.
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to update your password in the database: %s", err)
} else {
session.Flash(w, r, "Your password has been updated.")
}
}
}
}
default:
session.FlashError(w, r, "Unknown POST intent value. Please try again.")
}
// Maybe kick them from the chat room if they had become a Shy Account.
if !wasShy && user.IsShy() {
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
}
templates.Redirect(w, r.URL.Path+hashtag+".")
return
}
// For the Location tab: get GeoIP insights.
insights, err := geoip.GetRequestInsights(r)
if err != nil {
log.Error("GetRequestInsights: %s", err)
}
vars["GeoIPInsights"] = insights
vars["UserLocation"] = models.GetUserLocation(user.ID)
// Show enabled status for 2FA.
vars["TwoFactorEnabled"] = models.Get2FA(user.ID).Enabled
// Count of subscribed comment threads.
vars["SubscriptionCount"] = models.CountSubscriptions(user)
// Count of push notification subscriptions.
vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user)
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// ConfirmEmailChange after a user tries to change their email.
func ConfirmEmailChange() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenStr = r.FormValue("token")
if tokenStr != "" {
var token ChangeEmailToken
if err := redis.Get(fmt.Sprintf(config.ChangeEmailRedisKey, tokenStr), &token); err != nil {
session.FlashError(w, r, "Invalid token. Please try again to change your email address.")
templates.Redirect(w, "/")
return
}
// Verify new email still doesn't already exist.
if _, err := models.FindUser(token.NewEmail); err == nil {
session.FlashError(w, r, "Couldn't update your email address: it is already in use by another member.")
templates.Redirect(w, "/")
return
}
// Look up the user.
user, err := models.GetUser(token.UserID)
if err != nil {
session.FlashError(w, r, "Didn't find the user that this email change was for. Please try again.")
templates.Redirect(w, "/")
return
}
// Burn the token.
if err := token.Delete(); err != nil {
log.Error("ChangeEmail: couldn't delete Redis token: %s", err)
}
// Make the change.
user.Email = token.NewEmail
if err := user.Save(); err != nil {
session.FlashError(w, r, "Couldn't save the change to your user: %s", err)
} else {
session.Flash(w, r, "Your email address has been confirmed and updated.")
templates.Redirect(w, "/")
}
} else {
session.FlashError(w, r, "Invalid change email token. Please try again.")
}
templates.Redirect(w, "/")
})
}