From 066765d2dcc1c9cee33ece7a68617af903c89588 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 19 Sep 2024 19:30:02 -0700 Subject: [PATCH] Chat Moderation Rules + Shy Accounts on Chat * Add chat moderation rules to the website, so admins can apply selective rules to problematic users. Available rules are: * redcam: user's camera is always NSFW. * nobroadcast: user can not broadcast their camera. * novideo: user can not broadcast OR watch any video. * noimage: user can not share OR see any shared image on chat. * The page to manage a user's active rules is available on their admin card of their profile page. When the user has rules active, a yellow counter is shown by the link to manage their rules. * Only chat moderator admins have access to the page or can see the yellow counter to know whether rules are active. * "Shy Accounts" are now permitted on the chat room! With some moderation rules automatically applied to them: novideo,noimage. * Update the Shy Account FAQ and messaging on the chat landing page. * Update the auto-kick from chat behavior regarding shy accounts: * They are kicked from chat only when an update to their profile settings will transition then FROM a non-shy into a shy account. * For example: when saving their profile settings (going private) or when editing or deleting a photo (if they will have no more public photos left) --- pkg/chat/chat_api.go | 7 +++-- pkg/config/enum.go | 32 +++++++++++++++++++ pkg/controller/account/profile.go | 13 ++++++++ pkg/controller/account/settings.go | 10 ++++-- pkg/controller/admin/user_actions.go | 42 +++++++++++++++++++++++++ pkg/controller/chat/chat.go | 37 +++++++++++----------- pkg/controller/photo/edit_delete.go | 20 +++++++++--- pkg/models/photo.go | 7 +++++ pkg/models/user.go | 9 ++++++ web/templates/account/profile.html | 11 +++++++ web/templates/admin/user_actions.html | 44 +++++++++++++++++++++++++++ web/templates/chat.html | 26 +++++++++++++--- web/templates/faq.html | 21 ++++++++++--- 13 files changed, 243 insertions(+), 36 deletions(-) diff --git a/pkg/chat/chat_api.go b/pkg/chat/chat_api.go index 62f3aa5..3e48a8d 100644 --- a/pkg/chat/chat_api.go +++ b/pkg/chat/chat_api.go @@ -37,9 +37,10 @@ func MaybeDisconnectUser(user *models.User) (bool, error) { }, { If: user.IsShy(), - Message: because + "you had updated your nonshy profile to become too private.

" + - "You may join the chat room after you have made your profile and (at least some) pictures " + - "viewable on 'public' so that you won't appear to be a blank, faceless profile to others on the chat room.

" + + Message: because + "you had updated your nonshy profile to become too private, and now are considered to have a 'Shy Account.'

" + + "You may refresh the page to log back into chat as a Shy Account, where your ability to use webcams and share photos " + + "will be restricted. To regain full access to the chat room, please edit your profile settings to make sure that at least one 'public' " + + "photo is viewable to other members of the website.

" + "Please see the Shy Account FAQ for more information.", }, { diff --git a/pkg/config/enum.go b/pkg/config/enum.go index f02717b..5988a68 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -79,6 +79,7 @@ var ( "dm_privacy", "blur_explicit", "site_gallery_default", // default view on site gallery (friends-only or all certified?) + "chat_moderation_rules", } // Choices for the Contact Us subject @@ -119,6 +120,30 @@ var ( regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`), regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`), } + + // Chat Moderation Rules. + ChatModerationRules = []ChecklistOption{ + { + Value: "redcam", + Label: "Red camera", + Help: "The user's camera is forced to 'explicit' when they are broadcasting.", + }, + { + Value: "nobroadcast", + Label: "No broadcast", + Help: "The user can not broadcast their webcam, but may still watch other peoples' webcams.", + }, + { + Value: "novideo", + Label: "No webcam privileges", + Help: "The user can not broadcast or watch any webcam. Note: this option supercedes all other video-related rules.", + }, + { + Value: "noimage", + Label: "No image sharing privileges", + Help: "The user can not share or see any image shared on chat.", + }, + } ) // ContactUs choices for the subject drop-down. @@ -133,6 +158,13 @@ type Option struct { Label string } +// ChecklistOption for checkbox-lists. +type ChecklistOption struct { + Value string + Label string + Help string +} + // NotificationOptout field values (stored in user ProfileField table) const ( NotificationOptOutFriendPhotos = "notif_optout_friends_photos" diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 38b4ec9..7b9da46 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -3,7 +3,9 @@ package account import ( "net/http" "net/url" + "strings" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/models" @@ -101,6 +103,14 @@ func Profile() http.HandlerFunc { log.Error("WhoLikes(user %d): %s", user.ID, err) } + // Chat Moderation Rule: count of rules applied to the user, for admin view. + var chatModerationRules int + if currentUser.HasAdminScope(config.ScopeChatModerator) { + if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 { + chatModerationRules = len(strings.Split(rules, ",")) + } + } + vars := map[string]interface{}{ "User": user, "LikeMap": likeMap, @@ -116,6 +126,9 @@ func Profile() http.HandlerFunc { "LikeRemainder": likeRemainder, "LikeTableName": "users", "LikeTableID": user.ID, + + // Admin numbers. + "NumChatModerationRules": chatModerationRules, } if err := tmpl.Execute(w, r, vars); err != nil { diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index 2784bc5..a6a77fe 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -62,6 +62,10 @@ func Settings() http.HandlerFunc { // 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": @@ -472,8 +476,10 @@ func Settings() http.HandlerFunc { } // 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) + 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+".") diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go index fb0ee1b..769553d 100644 --- a/pkg/controller/admin/user_actions.go +++ b/pkg/controller/admin/user_actions.go @@ -128,6 +128,48 @@ func UserActions() http.HandlerFunc { count, total := models.CountBlockedAdminUsers(user) vars["AdminBlockCount"] = count vars["AdminBlockTotal"] = total + case "chat.rules": + // Chat Moderation Rules. + if !currentUser.HasAdminScope(config.ScopeChatModerator) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator) + templates.Redirect(w, "/admin") + return + } + + if r.Method == http.MethodPost { + // Rules list for the change log. + var newRules = "(none)" + if rule, ok := r.PostForm["rules"]; ok { + newRules = strings.Join(rule, ",") + user.SetProfileField("chat_moderation_rules", newRules) + } else { + user.DeleteProfileField("chat_moderation_rules") + } + + if err := user.Save(); err != nil { + session.FlashError(w, r, "Error saving the user's chat rules: %s", err) + } else { + session.Flash(w, r, "Chat moderation rules have been updated!") + } + templates.Redirect(w, "/u/"+user.Username) + + // Log the new rules to the changelog. + models.LogEvent( + user, + currentUser, + "updated", + "chat.rules", + user.ID, + fmt.Sprintf( + "An admin has updated the chat moderation rules for this user.\n\n"+ + "The update rules are: %s", + newRules, + ), + ) + return + } + + vars["ChatModerationRules"] = config.ChatModerationRules case "essays": // Edit their profile essays easily. if !currentUser.HasAdminScope(config.ScopePhotoModerator) { diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 8347aaf..d867a66 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -25,13 +25,14 @@ import ( // JWT claims. type Claims struct { // Custom claims. - IsAdmin bool `json:"op,omitempty"` - VIP bool `json:"vip,omitempty"` - Avatar string `json:"img,omitempty"` - ProfileURL string `json:"url,omitempty"` - Nickname string `json:"nick,omitempty"` - Emoji string `json:"emoji,omitempty"` - Gender string `json:"gender,omitempty"` + IsAdmin bool `json:"op,omitempty"` + VIP bool `json:"vip,omitempty"` + Avatar string `json:"img,omitempty"` + ProfileURL string `json:"url,omitempty"` + Nickname string `json:"nick,omitempty"` + Emoji string `json:"emoji,omitempty"` + Gender string `json:"gender,omitempty"` + Rules []string `json:"rules,omitempty"` // Standard claims. Notes: // subject = username @@ -76,16 +77,6 @@ func Landing() http.HandlerFunc { return } - // If we are shy, block chat for now. - if isShy { - session.FlashError(w, r, - "You have a Shy Account and are not allowed in the chat room at this time where our non-shy members may "+ - "be on camera.", - ) - templates.Redirect(w, "/chat") - return - } - // Get our Chat JWT secret. var ( secret = []byte(config.Current.BareRTC.JWTSecret) @@ -120,6 +111,16 @@ func Landing() http.HandlerFunc { emoji = "🍰 It's my birthday!" } + // Apply chat moderation rules. + var rules = []string{} + if isShy { + // Shy account: no camera privileges. + rules = []string{"novideo", "noimage"} + } else if v := currentUser.GetProfileField("chat_moderation_rules"); len(v) > 0 { + // Specific mod rules applied to the current user. + rules = strings.Split(v, ",") + } + // Create the JWT claims. claims := Claims{ IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator), @@ -128,6 +129,8 @@ func Landing() http.HandlerFunc { Nickname: currentUser.NameOrUsername(), Emoji: emoji, Gender: Gender(currentUser), + VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat + Rules: rules, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go index 5425e40..9979fcf 100644 --- a/pkg/controller/photo/edit_delete.go +++ b/pkg/controller/photo/edit_delete.go @@ -71,6 +71,9 @@ func Edit() http.HandlerFunc { // Are we saving the changes? if r.Method == http.MethodPost { + // Record if this change is going to make them a Shy Account. + var wasShy = currentUser.IsShy() + var ( caption = strings.TrimSpace(r.FormValue("caption")) altText = strings.TrimSpace(r.FormValue("alt_text")) @@ -158,8 +161,11 @@ func Edit() http.HandlerFunc { 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) + currentUser.FlushCaches() + if !wasShy && currentUser.IsShy() { + 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. @@ -242,6 +248,9 @@ func Delete() http.HandlerFunc { // Confirm deletion? if r.Method == http.MethodPost { + // Record if this change is going to make them a Shy Account. + var wasShy = currentUser.IsShy() + confirm := r.PostFormValue("confirm") == "true" if !confirm { session.FlashError(w, r, "Confirm you want to delete this photo.") @@ -286,8 +295,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) + currentUser.FlushCaches() + if !wasShy && currentUser.IsShy() { + 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. diff --git a/pkg/models/photo.go b/pkg/models/photo.go index 2d5958c..ee422db 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -472,6 +472,13 @@ func (u *User) DistinctPhotoTypes() (result map[PhotoVisibility]struct{}) { return } +// FlushCaches clears any cached attributes (such as distinct photo types) for the user. +func (u *User) FlushCaches() { + u.cachePhotoTypes = nil + u.cacheBlockedUserIDs = nil + u.cachePhotoIDs = nil +} + // Gallery config for the main Gallery paginator. type Gallery struct { Explicit string // Explicit filter diff --git a/pkg/models/user.go b/pkg/models/user.go index 6d49315..1e84f69 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -807,6 +807,15 @@ func (u *User) SetProfileField(name, value string) { } } +// DeleteProfileField removes a stored profile field. +func (u *User) DeleteProfileField(name string) error { + res := DB.Raw( + "DELETE FROM profile_fields WHERE user_id=? AND name=?", + u.ID, name, + ) + return res.Error +} + // GetProfileField returns the value of a profile field or blank string. func (u *User) GetProfileField(name string) string { for _, field := range u.ProfileField { diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 4d51a4d..75fd6b1 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -492,6 +492,17 @@ +
  • + + + + Chat Moderation Rules + {{if .NumChatModerationRules}} + {{.NumChatModerationRules}} + {{end}} + + +
  • diff --git a/web/templates/admin/user_actions.html b/web/templates/admin/user_actions.html index fff5895..f9f17a6 100644 --- a/web/templates/admin/user_actions.html +++ b/web/templates/admin/user_actions.html @@ -12,6 +12,8 @@ + {{$Root := .}} +
    @@ -22,6 +24,9 @@ {{if eq .Intent "impersonate"}} Impersonate User + {{else if eq .Intent "chat.rules"}} + + Chat Moderation Rules {{else if eq .Intent "essays"}} Edit Profile Text @@ -171,6 +176,45 @@
    + {{else if eq .Intent "chat.rules"}} +
    + + {{range .ChatModerationRules}} +
    + +

    + {{.Help}} +

    +
    + {{end}} + +
    + +
    {{else if eq .Intent "essays"}}

    diff --git a/web/templates/chat.html b/web/templates/chat.html index f7b57d6..dc62177 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -48,11 +48,27 @@ {{end}} {{if .IsShyUser}} -

    - You have a Shy Account and you may not enter - the chat room at this time, where our {{PrettyTitle}} members may be sharing their cameras. You are - sharing no public photos with the community, so you get limited access to ours. - Learn more about how to resolve this issue. +
    +

    + You have a Shy Account, so you will experience + limited functionality on the chat room: +

    + +
      +
    • You may not broadcast or watch any webcam on chat.
    • +
    • You may not share or see pictures shared by other members on chat.
    • +
    + +

    + This is because, as a Shy Account, you are not sharing any public photos with the community on your profile + page, so to most other members of {{PrettyTitle}} you appear to be a "blank, faceless profile" and people on + the chat room generally feel uncomfortable having their webcams be watched by such a Shy Account. +

    + +

    + Click here to learn more about your Shy Account, including steps on how to + resolve this. +

    {{end}} diff --git a/web/templates/faq.html b/web/templates/faq.html index fbe52f8..52adb6d 100644 --- a/web/templates/faq.html +++ b/web/templates/faq.html @@ -1131,8 +1131,8 @@

    The chat room is available to all certified members who have public photos on their profile page. Shy Accounts who have private profiles or keep - all their pictures hidden are not permitted in the chat room at this time - but they may get - their own separate room later where they can bother other similarly shy members there. + all their pictures hidden MAY join the chat room, but have certain restrictions applied (such + as an inability to broadcast or watch any webcam, or share or see any picture shared on chat).

    @@ -1400,9 +1400,9 @@ your Friend or have shared their private pictures with you.

  • - You can not join the Chat Room. You guys - may soon get your own chat room, though. Many of us {{PrettyTitle}} nudists would not - enjoy our webcams being watched by blank profiles. + You can join the Chat Room, however + some features will be restricted to Shy Accounts: you will not be able to broadcast OR watch any webcam + on chat, nor can you share OR view any photo shared by others on the chat room.
  • @@ -1413,6 +1413,13 @@ kept with the other blank profiles until you choose to participate.

    +

    + On the chat room, many {{PrettyTitle}} members may be sharing their webcams and it is widely + regarded as awkward to have a "blank, faceless profile" silently lurking on your camera. So, a + Shy Account is allowed on the chat room but can not share or watch webcams, or share or view photos + posted by other members on the chat room. +

    +

    What can Shy Accounts do?

    @@ -1421,6 +1428,10 @@