BareRTC/pkg/api.go

653 lines
16 KiB
Go

package barertc
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
)
// Statistics (/api/statistics) returns info about the users currently logged onto the chat,
// for your website to call via CORS. The URL to your site needs to be in the CORSHosts array
// of your settings.toml.
func (s *Server) Statistics() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle the CORS header from your trusted domains.
if origin := r.Header.Get("Origin"); origin != "" {
var found bool
for _, allowed := range config.Current.CORSHosts {
if allowed == origin {
found = true
}
}
if found {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
}
var result = struct {
UserCount int
Usernames []string
Cameras struct {
Blue int
Red int
}
}{
Usernames: []string{},
}
// Count all users + collect unique usernames.
var unique = map[string]struct{}{}
for _, sub := range s.IterSubscribers() {
if sub.authenticated && sub.ChatStatus != "hidden" {
result.UserCount++
if _, ok := unique[sub.Username]; ok {
continue
}
result.Usernames = append(result.Usernames, sub.Username)
unique[sub.Username] = struct{}{}
// Count cameras by color.
if sub.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
if sub.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW {
result.Cameras.Red++
} else {
result.Cameras.Blue++
}
}
}
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(result)
})
}
// Authenticate (/api/authenticate) for the chatbot API.
//
// This endpoint will sign a JWT token using the claims you pass in. It requires
// the shared secret `AdminAPIKey` from your settings.toml and will sign the
// JWT claims you give it.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// "Claims": {
// "sub": "username",
// "nick": "Display Name",
// "op": false,
// "img": "/static/photos/avatar.png",
// "url": "/users/username",
// "emoji": "🤖",
// "gender": "m"
// }
// }
//
// The return schema looks like:
//
// {
// "OK": true,
// "Error": "error string, omitted if none",
// "JWT": "jwt token string"
// }
func (s *Server) Authenticate() http.HandlerFunc {
type request struct {
APIKey string
Claims jwt.Claims
}
type result struct {
OK bool
Error string `json:",omitempty"`
JWT string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Encode the JWT token.
var claims = params.Claims
token, err := claims.ReSign()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
enc.Encode(result{
Error: "Error signing the JWT claims.",
})
return
}
enc.Encode(result{
OK: true,
JWT: token,
})
})
}
// Shutdown (/api/shutdown) the chat server, hopefully to reboot it.
//
// This endpoint is equivalent to the operator '/shutdown' command but may be
// invoked by your website, or your chatbot. It requires the AdminAPIKey.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// }
//
// The return schema looks like:
//
// {
// "OK": true,
// "Error": "error string, omitted if none",
// }
func (s *Server) ShutdownAPI() http.HandlerFunc {
type request struct {
APIKey string
Claims jwt.Claims
}
type result struct {
OK bool
Error string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Send the response.
enc.Encode(result{
OK: true,
})
// Defer a shutdown a moment later.
go func() {
time.Sleep(2 * time.Second)
os.Exit(1)
}()
// Attempt to broadcast, but if deadlocked this might not go out.
go func() {
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: "The chat server is going down for a reboot NOW!",
})
}()
})
}
// BlockList (/api/blocklist) allows your website to pre-sync mute lists between your
// user accounts, so that when they see each other in chat they will pre-emptively mute
// or boot one another.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// "Username": "soandso",
// "Blocklist": [ "list", "of", "other", "usernames" ],
// }
//
// The chat server will remember these mappings (until rebooted). How they are
// used is that the blocklist is embedded in the front-end page when the username
// signs in later. As part of the On Connect handler, the front-end will send the
// list of usernames in a bulk `mute` command to the server. This way even if the
// chat server reboots while the user is connected, when it comes back up and the user
// reconnects they will retransmit their block list.
func (s *Server) BlockList() http.HandlerFunc {
type request struct {
APIKey string
Username string
Blocklist []string
}
type result struct {
OK bool
Error string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Store the cached blocklist.
SetCachedBlocklist(params.Username, params.Blocklist)
enc.Encode(result{
OK: true,
})
})
}
// BlockNow (/api/block/now) allows your website to add to a current online chatter's
// blocked list immediately.
//
// For example: the BlockList endpoint does a bulk sync of the blocklist at the time
// a user joins the chat room, but if users are already on chat when the blocking begins,
// it doesn't take effect until one or the other re-joins the room. This API endpoint
// can apply the blocking immediately to the currently online users.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// "Usernames": [ "source", "target" ]
// }
//
// The pair of usernames will be the two users who block one another (in any order).
// If any of the users are currently connected to the chat, they will all mutually
// block one another immediately.
func (s *Server) BlockNow() http.HandlerFunc {
type request struct {
APIKey string
Usernames []string
}
type result struct {
OK bool
Error string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Check if any of these users are online, and update their blocklist accordingly.
var changed bool
for _, username := range params.Usernames {
if sub, err := s.GetSubscriber(username); err == nil {
for _, otherName := range params.Usernames {
if username == otherName {
continue
}
log.Info("BlockNow API: %s is currently on chat, add block for %+v", username, otherName)
sub.muteMu.Lock()
sub.muted[otherName] = struct{}{}
sub.blocked[otherName] = struct{}{}
sub.muteMu.Unlock()
// Changes have been made to online users.
changed = true
// Send a server-side "block" command to the subscriber, so their front-end page might
// update the cachedBlocklist so there's no leakage in case of chat server rebooting.
sub.SendJSON(messages.Message{
Action: messages.ActionBlock,
Username: otherName,
})
}
}
}
// If any changes to blocklists were made: send the Who List.
if changed {
s.SendWhoList()
}
enc.Encode(result{
OK: true,
})
})
}
// UserProfile (/api/profile) fetches profile information about a user.
//
// This endpoint will proxy to your WebhookURL for the "profile" endpoint.
// If your webhook is not configured or not reachable, this endpoint returns
// an error to the caller.
//
// Authentication: the caller must send their current chat JWT token when
// hitting this endpoint.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "JWTToken": "the caller's jwt token",
// "Username": [ "soandso" ]
// }
//
// The response JSON will look like the following (this also mirrors the
// response json as sent by your site's webhook URL):
//
// {
// "OK": true,
// "Error": "only on errors",
// "ProfileFields": [
// {
// "Name": "Age",
// "Value": "30yo",
// },
// {
// "Name": "Gender",
// "Value": "Man",
// },
// ...
// ]
// }
func (s *Server) UserProfile() http.HandlerFunc {
type request struct {
JWTToken string
Username string
}
type profileField struct {
Name string
Value string
}
type result struct {
OK bool
Error string `json:",omitempty"`
ProfileFields []profileField `json:",omitempty"`
}
type webhookRequest struct {
Action string
APIKey string
Username string
}
type webhookResponse struct {
StatusCode int
Data result
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
_, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Fetch the profile data from your website.
data, err := PostWebhook("profile", webhookRequest{
Action: "profile",
APIKey: config.Current.AdminAPIKey,
Username: params.Username,
})
if err != nil {
log.Error("Couldn't get profile information: %s", err)
}
// Success? Try and parse the response into our expected format.
var resp webhookResponse
if err := json.Unmarshal(data, &resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
// A nice error message?
if resp.Data.Error != "" {
enc.Encode(result{
Error: resp.Data.Error,
})
} else {
enc.Encode(result{
Error: fmt.Sprintf("Didn't get expected response for profile data: %s", err),
})
}
return
}
// At this point the expected resp mirrors our own, so return it.
if resp.StatusCode != http.StatusOK || resp.Data.Error != "" {
w.WriteHeader(http.StatusInternalServerError)
}
enc.Encode(resp.Data)
})
}
// Blocklist cache sent over from your website.
var (
// Map of username to the list of usernames they block.
cachedBlocklist map[string][]string
cachedBlocklistMu sync.RWMutex
)
func init() {
cachedBlocklist = map[string][]string{}
}
// GetCachedBlocklist returns the blocklist for a username.
func GetCachedBlocklist(username string) []string {
cachedBlocklistMu.RLock()
defer cachedBlocklistMu.RUnlock()
if list, ok := cachedBlocklist[username]; ok {
log.Debug("GetCachedBlocklist(%s) blocks %s", username, list)
return list
}
log.Debug("GetCachedBlocklist(%s): no blocklist stored", username)
return []string{}
}
// SetCachedBlocklist sets the blocklist cache for a user.
func SetCachedBlocklist(username string, blocklist []string) {
log.Info("SetCachedBlocklist: %s mutes users %s", username, strings.Join(blocklist, ", "))
cachedBlocklistMu.Lock()
defer cachedBlocklistMu.Unlock()
cachedBlocklist[username] = blocklist
}