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)
This commit is contained in:
Noah Petherbridge 2024-09-19 19:30:02 -07:00
parent ae84ddf449
commit 066765d2dc
13 changed files with 243 additions and 36 deletions

View File

@ -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.<br><br>" +
"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.<br><br>" +
Message: because + "you had updated your nonshy profile to become too private, and now are considered to have a 'Shy Account.'<br><br>" +
"You may <strong>refresh</strong> 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.<br><br>" +
"Please see the <a href=\"https://www.nonshy.com/faq#shy-faqs\">Shy Account FAQ</a> for more information.",
},
{

View File

@ -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"

View File

@ -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 {

View File

@ -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+".")

View File

@ -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) {

View File

@ -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()),

View File

@ -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.

View File

@ -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

View File

@ -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 {

View File

@ -492,6 +492,17 @@
</li>
<p class="menu-label">Admin Actions</p>
<li>
<a href="/admin/user-action?intent=chat.rules&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>
Chat Moderation Rules
{{if .NumChatModerationRules}}
<span class="tag is-warning ml-2">{{.NumChatModerationRules}}</span>
{{end}}
</span>
</a>
</li>
<li>
<a href="/admin/user-action?intent=essays&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-pencil"></i></span>

View File

@ -12,6 +12,8 @@
</div>
</section>
{{$Root := .}}
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
@ -22,6 +24,9 @@
{{if eq .Intent "impersonate"}}
<i class="mr-2 fa fa-ghost"></i>
Impersonate User
{{else if eq .Intent "chat.rules"}}
<i class="mr-2 fa fa-gavel"></i>
Chat Moderation Rules
{{else if eq .Intent "essays"}}
<i class="mr-2 fa fa-pencil"></i>
Edit Profile Text
@ -171,6 +176,45 @@
</div>
</div>
</div>
{{else if eq .Intent "chat.rules"}}
<div class="block content">
<p>
You may use this page to add or remove <strong>chat moderation rules</strong> for this user account.
</p>
<p>
Moderation rules are useful to apply restrictions to certain problematic users who habitually break
the site rules. For example: somebody who insists on keeping their camera "blue" (non-explicit) while
always jerking off and resisting the admin request that their camera should be marked "red" can have that
choice taken away from them, and have their camera be forced red at all times when they are broadcasting.
</p>
<p>
<strong>Note:</strong> <a href="/faq#shy-faqs">"Shy Accounts"</a> automatically have the <strong>No webcam privileges</strong>
and <strong>No image sharing privileges</strong> rules applied when they log onto the chat room.
</p>
</div>
{{range .ChatModerationRules}}
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="rules"
value="{{.Value}}"
{{if $Root.User.ProfileFieldIn "chat_moderation_rules" .Value}}checked{{end}}>
{{.Label}}
</label>
<p class="help">
{{.Help}}
</p>
</div>
{{end}}
<div class="field has-text-centered">
<button type="submit" class="button is-success">
Save Changes
</button>
</div>
{{else if eq .Intent "essays"}}
<div class="block content">
<p>

View File

@ -48,11 +48,27 @@
{{end}}
{{if .IsShyUser}}
<div class="notification is-danger is-light">
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong> 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.
<a href="/faq#shy-faqs">Learn more about how to resolve this issue. <small class="fa fa-external-link"></small></a>
<div class="notification is-warning is-light content">
<p>
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong>, so you will experience
limited functionality on the chat room:
</p>
<ul>
<li>You may not broadcast or watch any webcam on chat.</li>
<li>You may not share or see pictures shared by other members on chat.</li>
</ul>
<p>
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.
</p>
<p>
<a href="/faq#shy-faqs">Click here to learn more</a> about your Shy Account, including steps on how to
resolve this.
</p>
</div>
{{end}}

View File

@ -1131,8 +1131,8 @@
<p>
The chat room is available to all <strong>certified</strong> members who have public photos
on their profile page. <a href="#shy-faqs">Shy Accounts</a> 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).
</p>
<p>
@ -1400,9 +1400,9 @@
your Friend or have shared their private pictures with you.
</li>
<li>
You can not join the <i class="fa fa-message"></i> <strong>Chat Room</strong>. 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 <strong>can</strong> join the <i class="fa fa-message"></i> <strong>Chat Room</strong>, 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.
</li>
</ul>
@ -1413,6 +1413,13 @@
kept with the other blank profiles until you choose to participate.
</p>
<p>
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.
</p>
<h3 id="shy-cando">What <em>can</em> Shy Accounts do?</h3>
<p>
@ -1421,6 +1428,10 @@
</p>
<ul>
<li>
You can still join the <i class="fa fa-message"></i> <strong>Chat Room</strong> and have text-based
conversations with people (with just webcam and image sharing support restricted).
</li>
<li>
You can still participate on the <i class="fa fa-comments"></i> <strong>Forums</strong> and meet new friends
that way - by contributing to discussions, ideally.