a314aab7ec
* Add support for Web Push Notifications when users receive a new Message or Friend Request on the main website. * Users opt in or out of this on their Notification Settings. They can also individually opt out of Message and Friend Request push notifications.
555 lines
18 KiB
Go
555 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,
|
|
}
|
|
|
|
// 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 {
|
|
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",
|
|
} {
|
|
value := r.PostFormValue(field)
|
|
user.SetProfileField(field, 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")
|
|
)
|
|
|
|
user.Visibility = models.UserVisibilityPublic
|
|
|
|
for _, cmp := range models.UserVisibilityOptions {
|
|
if visibility == cmp {
|
|
user.Visibility = visibility
|
|
}
|
|
}
|
|
|
|
// Set profile field prefs.
|
|
user.SetProfileField("dm_privacy", dmPrivacy)
|
|
|
|
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 _, 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, "/")
|
|
})
|
|
}
|