website/pkg/controller/chat/chat.go
Noah Petherbridge 4f04323d5a Public Avatar Consent Page
The nonshy website is changing the policy on profile pictures. From August 30,
the square cropped avatar images will need to be publicly viewable to everyone.

This implements the first pass of the rollout:

* Add the Public Avatar Consent Page which explains the change to users and
  asks for their acknowledgement. The link is available from their User Settings
  page, near their Certification Photo link.
* When users (with non-public avatars) accept the change: their square cropped
  avatar will become visible to everybody, instead of showing a placeholder
  avatar.
* Users can change their mind and opt back out, which will again show the
  placeholder avatar.
* The Certification Required middleware will automatically enforce the consent
  page once the scheduled go-live date arrives.

Next steps are:

1. Post an announcement on the forum about the upcoming change and link users
   to the consent form if they want to check it out early.
2. Update the nonshy site to add banners to places like the User Dashboard for
   users who will be affected by the change, to link them to the forum post
   and the consent page.
2024-06-29 16:44:18 -07:00

311 lines
8.5 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"`
// 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
}
// 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)
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)
if currentUser.GetProfileField("public_avatar_consent") != "true" {
switch currentUser.ProfilePhoto.Visibility {
case models.PhotoPrivate:
avatar = "/static/img/shy-private.png"
case models.PhotoFriends:
avatar = "/static/img/shy-friends.png"
case models.PhotoInnerCircle:
avatar = "/static/img/shy-secret.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!"
}
// Create the JWT claims.
claims := Claims{
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
VIP: currentUser.IsInnerCircle(),
Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(),
Emoji: emoji,
Gender: Gender(currentUser),
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)
// 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
}