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, "/") }) }