website/pkg/controller/chat/chat.go
Noah Petherbridge 2f31d678d0 Usage Statistics and Website Demographics
Adds two new features to collect and show useful analytics.

Usage Statistics:
* Begin tracking daily active users who log in and interact with major features
  of the website each day, such as the chat room, forum and gallery.

Demographics page:
* For marketing, the home page now shows live statistics about the breakdown of
  content (explicit vs. non-explicit) on the site, and the /insights page gives
  a lot more data in detail.
* Show the percent split in photo gallery content and how many users opt-in or
  share explicit content on the site.
* Show high-level demographics of the members (by age range, gender, orientation)

Misc cleanup:
* Rearrange model list in data export to match the auto-create statements.
* In data exports, include the forum_memberships, push_notifications and
  usage_statistics tables.
2024-09-11 19:28:52 -07:00

313 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)
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!"
}
// 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),
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
}