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 }