From 742a5fa1af815766ad0c1eecfa6f0e1793f78730 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 14 Mar 2024 23:08:14 -0700 Subject: [PATCH] Auto-Disconnect Users from Chat Users whose accounts are no longer eligible to be in the chat room will be disconnected immediately from chat when their account status changes. The places in nonshy where these disconnects may happen include: * When the user deactivates or deletes their account. * When they modify their settings to mark their profile as 'private,' making them become a Shy Account. * When they edit or delete their photos in case they have moved their final public photo to be private, making them become a Shy Account. * When the user deletes their certification photo, or uploads a new cert photo to be reviewed (in both cases, losing account certified status). * When an admin user rejects their certification photo, even retroactively. * On admin actions against a user, including: banning them, deleting their user account. Other changes made include: * When signing up an account and e-mail sending is not enabled (e.g. local dev environment), the SignupToken is still created and logged to the console so you can continue the signup manually. * On the new account DOB prompt, add a link to manually input their birthdate as text similar to on the Age Gate page. --- pkg/chat/chat_api.go | 163 ++++++++++++++++++++++++++ pkg/controller/account/deactivate.go | 7 ++ pkg/controller/account/delete.go | 7 ++ pkg/controller/account/settings.go | 6 + pkg/controller/account/signup.go | 18 +-- pkg/controller/admin/user_actions.go | 12 ++ pkg/controller/photo/certification.go | 16 +++ pkg/controller/photo/edit_delete.go | 13 +- web/templates/account/age_gate.html | 1 + web/templates/account/signup.html | 31 ++++- 10 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 pkg/chat/chat_api.go diff --git a/pkg/chat/chat_api.go b/pkg/chat/chat_api.go new file mode 100644 index 0000000..017f138 --- /dev/null +++ b/pkg/chat/chat_api.go @@ -0,0 +1,163 @@ +package chat + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" +) + +// MaybeDisconnectUser may send a DisconnectUserNow to BareRTC if the user should not be allowed in the chat room. +// +// For example, they have set their profile to private and become a shy account, or they deactivated or got banned. +// +// If the user is presently in the chat room, they will be removed and given an appropriate ChatServer message. +// +// Returns a boolean OK (they were online in chat, and were removed) with the error only returning in case of a +// communication or JSON encode error with BareRTC. If they were online and removed, an admin feedback notice is +// also generated for visibility and confirmation of success. +func MaybeDisconnectUser(user *models.User) (bool, error) { + // What reason to remove them? If a message is provided, the DisconnectUserNow API will be called. + var because = "You have been signed out of chat because " + var reasons = []struct { + If bool + Message string + }{ + { + If: !user.Certified, + Message: because + "your nonshy account is not Certified, or its Certified status has been revoked.", + }, + { + If: user.IsShy(), + Message: because + "you had updated your nonshy profile to become too private. " + + "'Shy Accounts' are not permitted to remain in the chat room.", + }, + { + If: user.Status == models.UserStatusDisabled, + Message: because + "you have deactivated your nonshy account.", + }, + { + If: user.Status == models.UserStatusBanned, + Message: because + "your nonshy account has been banned.", + }, + { + // Catch-all for any non-active user status. + If: user.Status != models.UserStatusActive, + Message: because + "your nonshy account is no longer eligible to remain in the chat room.", + }, + } + + for _, reason := range reasons { + if reason.If { + i, err := DisconnectUserNow(user, reason.Message) + if err != nil { + return false, err + } + + // Were they online and were removed? Notify the admin for visibility. + if i > 0 { + fb := &models.Feedback{ + Intent: "report", + Subject: "Auto-Disconnect from Chat", + UserID: user.ID, + TableName: "users", + TableID: user.ID, + Message: fmt.Sprintf( + "A user was automatically disconnected from the chat room!\n\n"+ + "* Username: %s\n"+ + "* Number of users removed: %d\n"+ + "* Message sent to them: %s\n\n"+ + "Note: this is an informative message only. Users are expected to be removed from "+ + "chat when they do things such as deactivate their account, or private their profile "+ + "or pictures, and thus become ineligible to remain in the chat room.", + user.Username, + i, + reason.Message, + ), + } + + // Save the feedback. + if err := models.CreateFeedback(fb); err != nil { + log.Error("Couldn't save feedback from user updating their DOB: %s", err) + } + } + + // First removal reason wins. + break + } + } + + return false, nil +} + +// DisconnectUserNow tells the chat room to remove the user now if they are presently online. +func DisconnectUserNow(user *models.User, message string) (int, error) { + // API request struct for BareRTC /api/block/now endpoint. + var request = struct { + APIKey string + Usernames []string + Message string + Kick bool + }{ + APIKey: config.Current.CronAPIKey, + Usernames: []string{ + user.Username, + }, + Message: message, + Kick: false, + } + + type response struct { + OK bool + Removed int + Error string `json:",omitempty"` + } + + // JSON request body. + jsonStr, err := json.Marshal(request) + if err != nil { + return 0, err + } + + // Make the API request to BareRTC. + var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/disconnect/now" + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + // Ingest the JSON response to see the count and error. + var ( + result response + body, _ = io.ReadAll(resp.Body) + ) + err = json.Unmarshal(body, &result) + if err != nil { + return 0, err + } + + if resp.StatusCode != http.StatusOK || !result.OK { + log.Error("DisconnectUserNow: error from BareRTC: status %d body %s", resp.StatusCode, body) + return result.Removed, errors.New(result.Error) + } + + return result.Removed, nil +} diff --git a/pkg/controller/account/deactivate.go b/pkg/controller/account/deactivate.go index eaccbc1..c7052a7 100644 --- a/pkg/controller/account/deactivate.go +++ b/pkg/controller/account/deactivate.go @@ -4,6 +4,8 @@ import ( "net/http" "strings" + "code.nonshy.com/nonshy/website/pkg/chat" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" @@ -42,6 +44,11 @@ func Deactivate() http.HandlerFunc { session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.") templates.Redirect(w, "/") + // Maybe kick them from chat if this deletion makes them into a Shy Account. + if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) + } + // Log the change. models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Deactivated their account.") return diff --git a/pkg/controller/account/delete.go b/pkg/controller/account/delete.go index f65ddc4..d0c46e6 100644 --- a/pkg/controller/account/delete.go +++ b/pkg/controller/account/delete.go @@ -5,6 +5,8 @@ import ( "net/http" "strings" + "code.nonshy.com/nonshy/website/pkg/chat" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/session" @@ -43,6 +45,11 @@ func Delete() http.HandlerFunc { session.Flash(w, r, "Your account has been deleted.") templates.Redirect(w, "/") + // Kick them from the chat room if they are online. + if _, err := chat.DisconnectUserNow(currentUser, "You have been signed out of chat because you had deleted your account."); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) + } + // Log the change. models.LogDeleted(nil, nil, "users", currentUser.ID, fmt.Sprintf("Username %s has deleted their account.", currentUser.Username), nil) return diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index b454f49..da882a3 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -9,6 +9,7 @@ import ( "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" @@ -426,6 +427,11 @@ func Settings() http.HandlerFunc { 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 } diff --git a/pkg/controller/account/signup.go b/pkg/controller/account/signup.go index 3d0df19..4cb509f 100644 --- a/pkg/controller/account/signup.go +++ b/pkg/controller/account/signup.go @@ -102,14 +102,6 @@ func Signup() http.HandlerFunc { vars["Email"] = email vars["Username"] = username - // Is the app not configured to send email? - if !config.Current.Mail.Enabled && !config.SkipEmailVerification { - 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 - } - // Validate the email. if _, err := nm.ParseAddress(email); err != nil { session.FlashError(w, r, "The email address you entered is not valid: %s", err) @@ -157,6 +149,16 @@ func Signup() http.HandlerFunc { 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 + } + err := mail.Send(mail.Message{ To: email, Subject: "Verify your e-mail address", diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go index 5198a42..3e397c1 100644 --- a/pkg/controller/admin/user_actions.go +++ b/pkg/controller/admin/user_actions.go @@ -6,7 +6,9 @@ import ( "strconv" "strings" + "code.nonshy.com/nonshy/website/pkg/chat" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/session" @@ -157,6 +159,11 @@ func UserActions() http.HandlerFunc { session.Flash(w, r, "User ban status updated!") templates.Redirect(w, "/u/"+user.Username) + // Maybe kick them from chat room now. + if _, err := chat.MaybeDisconnectUser(user); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err) + } + // Log the change. models.LogEvent(user, currentUser, models.ChangeLogBanned, "users", currentUser.ID, fmt.Sprintf("User ban status updated to: %s", status)) return @@ -196,6 +203,11 @@ func UserActions() http.HandlerFunc { } templates.Redirect(w, "/admin") + // Kick them from the chat room if they are online. + if _, err := chat.DisconnectUserNow(user, "You have been signed out of chat because your account has been deleted."); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err) + } + // Log the change. models.LogDeleted(nil, currentUser, "users", user.ID, fmt.Sprintf("Username %s has been deleted by an admin.", user.Username), nil) return diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go index a28362d..f4c2ea2 100644 --- a/pkg/controller/photo/certification.go +++ b/pkg/controller/photo/certification.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" + "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" @@ -90,6 +91,11 @@ func Certification() http.HandlerFunc { // Log the change. models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert) + // Kick them from the chat room if they are online. + if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) + } + session.Flash(w, r, "Your certification photo has been deleted.") templates.Redirect(w, r.URL.Path) return @@ -141,6 +147,11 @@ func Certification() http.HandlerFunc { session.FlashError(w, r, "Error saving your User data: %s", err) } + // Kick them from the chat room if they are online. + if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) + } + // Notify the admin email to check out this photo. if err := mail.Send(mail.Message{ To: config.Current.AdminEmail, @@ -306,6 +317,11 @@ func AdminCertification() http.HandlerFunc { // Log the change. models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment) + // Kick them from the chat room if they are online. + if _, err := chat.MaybeDisconnectUser(user); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err) + } + // Did we silently ignore it? if comment == "(ignore)" { session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.") diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go index fcaed2d..de925c0 100644 --- a/pkg/controller/photo/edit_delete.go +++ b/pkg/controller/photo/edit_delete.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strconv" + "code.nonshy.com/nonshy/website/pkg/chat" "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" @@ -128,8 +129,6 @@ func Edit() http.HandlerFunc { setProfilePic = false } - log.Error("SAVING PHOTO: %+v", photo) - if err := photo.Save(); err != nil { session.FlashError(w, r, "Couldn't save photo: %s", err) } @@ -149,6 +148,11 @@ func Edit() http.HandlerFunc { // Log the change. models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs) + // Maybe kick them from the chat if this photo save makes them a Shy Account. + if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) + } + // If this picture has moved to Private, revoke any notification we gave about it before. if goingPrivate || goingCircle { log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility) @@ -272,6 +276,11 @@ func Delete() http.HandlerFunc { session.Flash(w, r, "Photo deleted!") + // Maybe kick them from chat if this deletion makes them into a Shy Account. + if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) + } + // Return the user to their gallery. templates.Redirect(w, "/u/"+currentUser.Username+"/photos") return diff --git a/web/templates/account/age_gate.html b/web/templates/account/age_gate.html index 8af2eb3..bdcc205 100644 --- a/web/templates/account/age_gate.html +++ b/web/templates/account/age_gate.html @@ -94,6 +94,7 @@ window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`); } else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) { window.alert(`Please enter the date in YYYY-MM-DD format.`); + return; } $dob.value = answer; diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html index 88f7666..f853444 100644 --- a/web/templates/account/signup.html +++ b/web/templates/account/signup.html @@ -154,6 +154,9 @@

Your birthdate won't be shown to other members and is used to show your current age on your profile. Please enter your correct birthdate. +
+ On mobile and scrolling for your year is tedious? + Click to type your birthdate instead.

{{end}} @@ -225,8 +228,32 @@ window.addEventListener("DOMContentLoaded", (event) => { }) }; - $username.addEventListener("change", onChange); - $username.addEventListener("blur", onChange); + if ($username != undefined) { + $username.addEventListener("change", onChange); + $username.addEventListener("blur", onChange); + } + + // DOB manual entry script, on signup completion page. + let $manualEntry = document.querySelector("#manualEntry"), + $dob = document.querySelector("#dob"); + + if ($manualEntry != undefined) { + $manualEntry.addEventListener("click", function(e) { + e.preventDefault(); + + let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-'); + if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) { + let group = answer.match(/^(\d{2})-(\d{2})-(\d{4})/); + answer = `${group[3]}-${group[1]}-${group[2]}`; + window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`); + } else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) { + window.alert(`Please enter the date in YYYY-MM-DD format.`); + return; + } + + $dob.value = answer; + }); + } }); {{end}}