2022-08-11 03:59:59 +00:00
package account
import (
2022-08-14 21:40:57 +00:00
"fmt"
2022-08-11 03:59:59 +00:00
"net/http"
2022-08-14 21:40:57 +00:00
nm "net/mail"
2023-12-26 23:44:34 +00:00
"regexp"
2023-08-20 02:11:33 +00:00
"strconv"
2022-08-11 03:59:59 +00:00
"strings"
"time"
2024-03-15 06:08:14 +00:00
"code.nonshy.com/nonshy/website/pkg/chat"
2022-08-26 04:21:46 +00:00
"code.nonshy.com/nonshy/website/pkg/config"
2023-08-20 02:11:33 +00:00
"code.nonshy.com/nonshy/website/pkg/geoip"
2022-08-26 04:21:46 +00:00
"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"
2024-03-16 06:19:26 +00:00
"code.nonshy.com/nonshy/website/pkg/spam"
2022-08-26 04:21:46 +00:00
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility"
2024-01-27 21:57:24 +00:00
"code.nonshy.com/nonshy/website/pkg/worker"
2022-08-14 21:40:57 +00:00
"github.com/google/uuid"
2022-08-11 03:59:59 +00:00
)
2022-08-14 21:40:57 +00:00
// 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 ) )
}
2022-08-11 03:59:59 +00:00
// User settings page. (/settings).
func Settings ( ) http . HandlerFunc {
tmpl := templates . Must ( "account/settings.html" )
2023-12-26 23:44:34 +00:00
var reHexColor = regexp . MustCompile ( ` ^#[a-fA-F0-9] { 6}$ ` )
2022-08-11 03:59:59 +00:00
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
vars := map [ string ] interface { } {
2024-11-24 07:49:29 +00:00
"Enum" : config . ProfileEnums ,
"WebsiteThemeHueChoices" : config . WebsiteThemeHueChoices ,
2022-08-11 03:59:59 +00:00
}
2022-08-13 22:39:31 +00:00
// 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
}
2024-01-27 21:57:24 +00:00
// Is the user currently in the chat room? Gate username changes when so.
var isOnChat = worker . GetChatStatistics ( ) . IsOnline ( user . Username )
vars [ "OnChat" ] = isOnChat
2023-06-24 05:18:09 +00:00
// URL hashtag to redirect to
var hashtag string
2022-08-11 03:59:59 +00:00
// Are we POSTing?
if r . Method == http . MethodPost {
2024-09-20 02:30:02 +00:00
// Will they BECOME a Shy Account with this change?
var wasShy = user . IsShy ( )
2022-08-11 03:59:59 +00:00
intent := r . PostFormValue ( "intent" )
switch intent {
case "profile" :
// Setting profile values.
2023-06-24 05:18:09 +00:00
hashtag = "#profile"
2022-08-11 03:59:59 +00:00
var (
displayName = r . PostFormValue ( "display_name" )
dob = r . PostFormValue ( "dob" )
)
// Set user attributes.
user . Name = & displayName
2023-06-16 05:12:01 +00:00
// 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
}
2023-09-12 02:24:09 +00:00
// If the user changes their birthdate, notify the admin.
2023-06-16 05:12:01 +00:00
if ! user . Birthdate . IsZero ( ) && user . Birthdate . Format ( "2006-01-02" ) != dob {
2023-09-12 02:24:09 +00:00
// 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 )
}
2022-08-11 03:59:59 +00:00
}
2023-09-12 02:24:09 +00:00
// Work around DST issues: set the hour to noon.
user . Birthdate = birthdate . Add ( 12 * time . Hour )
2022-08-11 03:59:59 +00:00
}
// Set profile attributes.
for _ , attr := range config . ProfileFields {
2024-03-16 06:19:26 +00:00
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 )
2022-08-11 03:59:59 +00:00
}
// "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!" )
2023-12-26 23:44:34 +00:00
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" ,
2024-11-24 07:49:29 +00:00
"website-theme" , // light, dark, auto
2023-12-26 23:44:34 +00:00
} {
value := r . PostFormValue ( field )
user . SetProfileField ( field , value )
}
2024-11-24 07:49:29 +00:00
// Website theme color: constrain to available options.
for _ , field := range [ ] struct {
Name string
Options [ ] config . Option
} {
{ "website-theme-hue" , config . WebsiteThemeHueChoices } ,
} {
value := utility . StringInOptions (
r . PostFormValue ( field . Name ) ,
field . Options ,
"" ,
)
user . SetProfileField ( field . Name , value )
}
2023-12-26 23:44:34 +00:00
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!" )
2022-08-13 22:39:31 +00:00
case "preferences" :
2023-06-24 05:18:09 +00:00
hashtag = "#prefs"
2022-08-13 22:39:31 +00:00
var (
2023-09-20 01:24:57 +00:00
explicit = r . PostFormValue ( "explicit" ) == "true"
blurExplicit = r . PostFormValue ( "blur_explicit" )
2023-09-24 18:41:19 +00:00
autoplayGif = r . PostFormValue ( "autoplay_gif" )
2022-08-13 22:39:31 +00:00
)
user . Explicit = explicit
2023-09-19 00:22:50 +00:00
2023-09-20 01:24:57 +00:00
// Set profile field prefs.
user . SetProfileField ( "blur_explicit" , blurExplicit )
2023-09-24 18:41:19 +00:00
if autoplayGif != "true" {
autoplayGif = "false"
}
user . SetProfileField ( "autoplay_gif" , autoplayGif )
2023-09-20 01:24:57 +00:00
2023-09-19 00:22:50 +00:00
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" )
2024-10-19 19:44:47 +00:00
ppPrivacy = r . PostFormValue ( "private_photo_gate" )
2023-09-19 00:22:50 +00:00
)
2022-08-30 03:00:15 +00:00
user . Visibility = models . UserVisibilityPublic
for _ , cmp := range models . UserVisibilityOptions {
if visibility == cmp {
user . Visibility = visibility
}
2022-08-22 00:29:39 +00:00
}
2022-08-13 22:39:31 +00:00
2023-06-24 05:18:09 +00:00
// Set profile field prefs.
2023-09-19 00:22:50 +00:00
user . SetProfileField ( "dm_privacy" , dmPrivacy )
2024-10-19 19:44:47 +00:00
user . SetProfileField ( "private_photo_gate" , ppPrivacy )
2023-06-24 05:18:09 +00:00
2022-08-13 22:39:31 +00:00
if err := user . Save ( ) ; err != nil {
session . FlashError ( w , r , "Failed to save user to database: %s" , err )
}
2023-09-19 00:22:50 +00:00
session . Flash ( w , r , "Privacy settings updated!" )
2023-10-28 21:34:35 +00:00
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!" )
}
}
2024-07-21 02:44:22 +00:00
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 )
}
2023-08-20 02:11:33 +00:00
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!" )
}
2022-08-11 03:59:59 +00:00
case "settings" :
2023-06-24 05:18:09 +00:00
hashtag = "#account"
2022-08-14 21:40:57 +00:00
var (
2024-01-27 21:57:24 +00:00
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" ) )
2022-08-14 21:40:57 +00:00
)
// 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." )
2024-01-27 21:57:24 +00:00
templates . Redirect ( w , r . URL . Path + hashtag )
2022-08-14 21:40:57 +00:00
return
}
2024-01-27 21:57:24 +00:00
// 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
}
2024-04-13 17:44:09 +00:00
// 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 )
2024-01-27 21:57:24 +00:00
// 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 )
}
}
}
2022-08-14 21:40:57 +00:00
// 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 )
2024-01-27 21:57:24 +00:00
templates . Redirect ( w , r . URL . Path + hashtag )
2022-08-14 21:40:57 +00:00
return
}
// Email must not already exist.
if _ , err := models . FindUser ( changeEmail ) ; err == nil {
session . FlashError ( w , r , "That email address is already in use." )
2024-01-27 21:57:24 +00:00
templates . Redirect ( w , r . URL . Path + hashtag )
2022-08-14 21:40:57 +00:00
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 )
2024-01-27 21:57:24 +00:00
templates . Redirect ( w , r . URL . Path + hashtag )
2022-08-14 21:40:57 +00:00
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 {
2022-08-22 01:19:30 +00:00
log . Error ( "pw1=%s pw2=%s" , password1 , password2 )
2022-08-14 21:40:57 +00:00
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." )
}
}
}
}
2022-08-11 03:59:59 +00:00
default :
session . FlashError ( w , r , "Unknown POST intent value. Please try again." )
}
2024-03-15 06:08:14 +00:00
// Maybe kick them from the chat room if they had become a Shy Account.
2024-09-20 02:30:02 +00:00
if ! wasShy && user . IsShy ( ) {
if _ , err := chat . MaybeDisconnectUser ( user ) ; err != nil {
log . Error ( "chat.MaybeDisconnectUser(%s#%d): %s" , user . Username , user . ID , err )
}
2024-03-15 06:08:14 +00:00
}
2023-06-24 05:18:09 +00:00
templates . Redirect ( w , r . URL . Path + hashtag + "." )
2022-08-11 03:59:59 +00:00
return
}
2023-08-20 02:11:33 +00:00
// 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 )
2023-09-19 00:22:50 +00:00
// Show enabled status for 2FA.
vars [ "TwoFactorEnabled" ] = models . Get2FA ( user . ID ) . Enabled
2023-10-28 21:34:35 +00:00
// Count of subscribed comment threads.
vars [ "SubscriptionCount" ] = models . CountSubscriptions ( user )
2024-07-21 02:44:22 +00:00
// Count of push notification subscriptions.
vars [ "PushNotificationsCount" ] = models . CountPushNotificationSubscriptions ( user )
2022-08-11 03:59:59 +00:00
if err := tmpl . Execute ( w , r , vars ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
} )
}
2022-08-14 21:40:57 +00:00
// 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 , "/" )
} )
}