066765d2dc
* 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)
316 lines
8.8 KiB
Go
316 lines
8.8 KiB
Go
package chat
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/middleware"
|
|
"code.nonshy.com/nonshy/website/pkg/models"
|
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
|
"code.nonshy.com/nonshy/website/pkg/session"
|
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
|
"code.nonshy.com/nonshy/website/pkg/worker"
|
|
"github.com/golang-jwt/jwt/v4"
|
|
)
|
|
|
|
// 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"`
|
|
Rules []string `json:"rules,omitempty"`
|
|
|
|
// Standard claims. Notes:
|
|
// subject = username
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// Gender returns the BareRTC gender string for the user's gender selection.
|
|
func Gender(u *models.User) string {
|
|
switch u.GetProfileField("gender") {
|
|
case "Man", "Trans (FTM)":
|
|
return "m"
|
|
case "Woman", "Trans (MTF)":
|
|
return "f"
|
|
case "Non-binary", "Trans", "Other":
|
|
return "o"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// Landing page for chat rooms.
|
|
func Landing() http.HandlerFunc {
|
|
tmpl := templates.Must("chat.html")
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the current user.
|
|
currentUser, err := session.CurrentUser(r)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Couldn't get current user: %s", err)
|
|
templates.Redirect(w, "/")
|
|
return
|
|
}
|
|
|
|
// Are they logging into the chat room?
|
|
var (
|
|
intent = r.FormValue("intent")
|
|
isShy = currentUser.IsShy()
|
|
birthday = r.FormValue("birthday") == "true" && currentUser.IsBirthday()
|
|
)
|
|
if intent == "join" {
|
|
// Maintenance mode?
|
|
if middleware.ChatMaintenance(currentUser, w, r) {
|
|
return
|
|
}
|
|
|
|
// Get our Chat JWT secret.
|
|
var (
|
|
secret = []byte(config.Current.BareRTC.JWTSecret)
|
|
chatURL = config.Current.BareRTC.URL
|
|
)
|
|
if len(secret) == 0 || chatURL == "" {
|
|
session.FlashError(w, r, "Couldn't sign you into the chat: JWT secret key or chat URL not configured!")
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
// Avatar URL - masked if non-public.
|
|
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
|
switch currentUser.ProfilePhoto.Visibility {
|
|
case models.PhotoPrivate:
|
|
avatar = "/static/img/shy-private.png"
|
|
case models.PhotoFriends:
|
|
avatar = "/static/img/shy-friends.png"
|
|
}
|
|
|
|
// Country flag emoji.
|
|
emoji, err := geoip.GetChatFlagEmoji(r)
|
|
if err != nil {
|
|
emoji, err = geoip.CountryFlagEmojiWithCode("US")
|
|
if err != nil {
|
|
emoji = "🏴☠️"
|
|
}
|
|
}
|
|
|
|
// Birthday cake emoji?
|
|
if birthday {
|
|
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),
|
|
Avatar: avatar,
|
|
ProfileURL: "/u/" + currentUser.Username,
|
|
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()),
|
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
Issuer: config.Title,
|
|
Subject: currentUser.Username,
|
|
ID: fmt.Sprintf("%d", currentUser.ID),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
ss, err := token.SignedString(secret)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
// Send over their blocklist to the chat server.
|
|
if err := SendBlocklist(currentUser); err != nil {
|
|
log.Error("SendBlocklist: %s", err)
|
|
}
|
|
|
|
// Mark them as online immediately: so e.g. on the Change Username screen we leave no window
|
|
// of time where they can exist in chat but change their name on the site.
|
|
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
|
|
|
// Ping their chat login usage statistic.
|
|
go func() {
|
|
if err := models.LogDailyChatUser(currentUser); err != nil {
|
|
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
|
|
}
|
|
}()
|
|
|
|
// Redirect them to the chat room.
|
|
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
|
return
|
|
}
|
|
|
|
// Get the ChatStatistics and select our friend names from it.
|
|
var (
|
|
stats = FilteredChatStatistics(currentUser)
|
|
friendsOnline = models.FilterFriendUsernames(currentUser, stats.Usernames)
|
|
)
|
|
|
|
sort.Strings(friendsOnline)
|
|
|
|
var vars = map[string]interface{}{
|
|
"ChatAPI": strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/statistics",
|
|
"IsShyUser": isShy,
|
|
|
|
// Pre-populate the "who's online" widget from backend cache data
|
|
"ChatStatistics": stats,
|
|
"FriendsOnline": friendsOnline,
|
|
}
|
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
})
|
|
}
|
|
|
|
// FilteredChatStatistics will return a copy of the cached ChatStatistics but where the Usernames list is
|
|
// filtered down (and the online user counts, accordingly) by blocklists.
|
|
func FilteredChatStatistics(currentUser *models.User) worker.ChatStatistics {
|
|
var stats = worker.GetChatStatistics()
|
|
var result = worker.ChatStatistics{
|
|
UserCount: stats.UserCount,
|
|
Usernames: []string{},
|
|
Cameras: stats.Cameras,
|
|
}
|
|
|
|
// Who are we blocking?
|
|
var blockedUsernames = map[string]interface{}{}
|
|
for _, username := range models.BlockedUsernames(currentUser) {
|
|
blockedUsernames[username] = nil
|
|
}
|
|
|
|
// Filter the online users listing.
|
|
for _, username := range stats.Usernames {
|
|
if _, ok := blockedUsernames[username]; !ok {
|
|
result.Usernames = append(result.Usernames, username)
|
|
}
|
|
}
|
|
|
|
// Sort the names.
|
|
sort.Strings(result.Usernames)
|
|
|
|
return result
|
|
}
|
|
|
|
// SendBlocklist syncs the user blocklist to the chat server prior to sending them over.
|
|
func SendBlocklist(user *models.User) error {
|
|
// Get the user's blocklist.
|
|
blockedUsernames := models.BlockedUsernames(user)
|
|
log.Info("SendBlocklist(%s) to BareRTC: %d blocked usernames", user.Username, len(blockedUsernames))
|
|
|
|
// API request struct for BareRTC /api/blocklist endpoint.
|
|
var request = struct {
|
|
APIKey string
|
|
Username string
|
|
Blocklist []string
|
|
}{
|
|
config.Current.CronAPIKey,
|
|
user.Username,
|
|
blockedUsernames,
|
|
}
|
|
|
|
// JSON request body.
|
|
jsonStr, err := json.Marshal(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Make the API request to BareRTC.
|
|
var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/blocklist"
|
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
log.Error("SendBlocklist: error syncing blocklist to BareRTC: status %d body %s", resp.StatusCode, body)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BlockUserNow syncs the new block action to the chat server now, in case the user is already online.
|
|
func BlockUserNow(currentUser, user *models.User) error {
|
|
// API request struct for BareRTC /api/block/now endpoint.
|
|
var request = struct {
|
|
APIKey string
|
|
Usernames []string
|
|
}{
|
|
config.Current.CronAPIKey,
|
|
[]string{
|
|
currentUser.Username,
|
|
user.Username,
|
|
},
|
|
}
|
|
|
|
// JSON request body.
|
|
jsonStr, err := json.Marshal(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Make the API request to BareRTC.
|
|
var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/block/now"
|
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
log.Error("BlockUserNow: error syncing block to BareRTC: status %d body %s", resp.StatusCode, body)
|
|
}
|
|
|
|
return nil
|
|
}
|