shoGambler/main.go

819 lines
23 KiB
Go

package main
import (
"shoGambler/lib"
"errors"
"log"
"os"
"plugin"
"strconv"
"strings"
"time"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"html/template"
"io/fs"
"math/big"
"net/http"
"path/filepath"
"github.com/MicahParks/keyfunc/v3"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
_ "modernc.org/sqlite"
)
func getYTChID(token string) (string, int, error) {
log.Println("[WARN] Scary, we are expending a Google API credit!")
// Ask Google for the user's channel ID
request, err := http.NewRequest("GET", "https://www.googleapis.com/youtube/v3/channels?mine=true", nil)
if err != nil {
return "", 500, errors.New("error creating Google auth request")
}
// Set the Authorization header
request.Header.Set("Authorization", "Bearer "+token)
// Send the request
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", 500, errors.New("error sending Google auth request")
}
// Check the status code
if response.StatusCode != 200 {
return "", response.StatusCode, errors.New("error getting Google auth response")
}
// Read the response
var responseJSON map[string]interface{}
err = json.NewDecoder(response.Body).Decode(&responseJSON)
if err != nil {
return "", 500, errors.New("error decoding Google auth response")
}
// Get the user's channel ID
channelID, ok := responseJSON["items"].([]interface{})[0].(map[string]interface{})["id"].(string)
if !ok {
return "", 400, errors.New("error getting channel ID")
}
return channelID, 200, nil
}
func giveUserPoints(channelID string, points *big.Int) {
// Add the points to the userPoints
var existingPoints []byte
err := conn.QueryRow("SELECT points FROM users WHERE channelID = ?", channelID).Scan(&existingPoints)
if errors.Is(err, sql.ErrNoRows) {
_, err := conn.Exec("INSERT INTO users (channelID, points) VALUES (?, ?)", channelID, points.Bytes())
if err != nil {
log.Fatal("[FATAL] Error adding user to database: ", err)
}
} else if err != nil {
log.Fatal("[FATAL] Error querying database: ", err)
} else {
_, err := conn.Exec("UPDATE users SET points = ? WHERE channelID = ?", new(big.Int).Add(points, new(big.Int).SetBytes(existingPoints)).Bytes(), channelID)
if err != nil {
log.Fatal("[FATAL] Error updating user points: ", err)
}
}
}
func subtractUserPoints(channelID string, points *big.Int) {
// Subtract the points from the userPoints
var existingPoints []byte
err := conn.QueryRow("SELECT points FROM users WHERE channelID = ?", channelID).Scan(&existingPoints)
if errors.Is(err, sql.ErrNoRows) {
_, err := conn.Exec("INSERT INTO users (channelID, points) VALUES (?, ?)", channelID, new(big.Int).Neg(points).Bytes())
if err != nil {
log.Fatal("[FATAL] Error adding user to database: ", err)
}
} else if err != nil {
log.Fatal("[FATAL] Error querying database: ", err)
} else {
_, err := conn.Exec("UPDATE users SET points = ? WHERE channelID = ?", new(big.Int).Add(new(big.Int).SetBytes(existingPoints), new(big.Int).Neg(points)).Bytes(), channelID)
if err != nil {
log.Fatal("[FATAL] Error updating user points: ", err)
}
}
}
func getUserPoints(channelID string) *big.Int {
// Get the user's points
var points []byte
err := conn.QueryRow("SELECT points FROM users WHERE channelID = ?", channelID).Scan(&points)
if errors.Is(err, sql.ErrNoRows) {
return big.NewInt(0)
} else if err != nil {
log.Fatal("[FATAL] Error querying database: ", err)
}
return new(big.Int).SetBytes(points)
}
func userIsModerator(accessToken string) bool {
// Get the channel ID
channelID, ok := userSessions[accessToken]
if !ok {
return false
}
if channelID != "UCHlTEt24Yb4ylJFuWz7hXIw" {
// Check if the user is a moderator
var channelIDCheck string
err := conn.QueryRow("SELECT channelID FROM moderators WHERE channelID = ?", channelID).Scan(&channelIDCheck)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false
} else {
log.Fatal("[FATAL] Error querying database: ", err)
return false
}
} else {
return channelID == channelIDCheck
}
} else {
// Bro it's literally shounic, of course they're a moderator
return true
}
}
func checkForChatMessages(liveChatID string) {
for {
if streaming == true {
// Check for chat messages
log.Println("[INFO] Scary, we are expending a Google API credit (on stream)!")
// Keeping this in a comment so the compiler doesn't remember it:
key, err := base64.StdEncoding.DecodeString(configFile.ApiKey)
if err != nil {
log.Println("[ERROR] Error decoding API key: ", err)
}
response, err := http.Get("https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId=" + liveChatID + "&part=snippet,authorDetails&key=" + string(key))
if err != nil {
log.Println("[ERROR] Error getting chat messages: ", err)
return
}
// Read the response
var responseJSON map[string]interface{}
err = json.NewDecoder(response.Body).Decode(&responseJSON)
if err != nil {
log.Println("[ERROR] Error decoding chat messages: ", err)
return
}
// Check the status code
if response.StatusCode != 200 {
log.Println("[ERROR] Error getting chat messages: ", response.Status, responseJSON)
return
} else {
// Iterate through each live chat message
for _, item := range responseJSON["items"].([]interface{}) {
log.Println("[INFO] Processing message from ", item.(map[string]interface{})["authorDetails"].(map[string]interface{})["displayName"].(string))
earliestSentMessage, ok := earliestSentMsg[item.(map[string]interface{})["authorDetails"].(map[string]interface{})["channelId"].(string)]
publishedTime, err := time.Parse(time.RFC3339Nano, item.(map[string]interface{})["snippet"].(map[string]interface{})["publishedAt"].(string))
if err != nil {
log.Println("[ERROR] Error parsing time: ", err)
} else if !ok || publishedTime.Before(earliestSentMessage) {
if publishedTime.After(streamingSince) {
log.Println("[INFO] New message from ", item.(map[string]interface{})["authorDetails"].(map[string]interface{})["displayName"].(string))
earliestSentMsg[item.(map[string]interface{})["authorDetails"].(map[string]interface{})["channelId"].(string)] = publishedTime
}
}
}
// Close the response body
err := response.Body.Close()
if err != nil {
log.Println("[ERROR] Error closing response body: ", err)
}
// Wait for the rate because Google likes to screw us over
time.Sleep(time.Second * time.Duration(configFile.Rate))
}
} else {
return
}
}
}
var (
plugins []lib.PluginData
conn *sql.DB
earliestSentMsg = make(map[string]time.Time)
userSessions = make(map[string]string)
streamingSince time.Time
configFile config
streaming bool
)
type config struct {
ApiKey string `json:"key"`
Rate int `json:"rate"`
}
func main() {
// Connect to the database
var err error
conn, err = sql.Open("sqlite", "database.db")
if err != nil {
log.Fatal("[FATAL] Error connecting to database: ", err)
}
// Read in config.json
configBytes, err := os.ReadFile("config.json")
if err != nil {
log.Fatal("[FATAL] Error reading config.json: ", err)
}
// Parse the JSON
err = json.Unmarshal(configBytes, &configFile)
if err != nil {
log.Fatal("[FATAL] Error parsing config.json: ", err)
}
// Create the blacklist table if it doesn't exist
_, err = conn.Exec("CREATE TABLE IF NOT EXISTS blacklist (nonce TEXT NOT NULL UNIQUE)")
if err != nil {
log.Fatal("[FATAL] Error creating blacklist table: ", err)
}
// Create the plugins table if it doesn't exist
_, err = conn.Exec("CREATE TABLE IF NOT EXISTS plugins (pluginName TEXT UNIQUE, pointsOverride BLOB)")
if err != nil {
log.Fatal("[FATAL] Error creating plugins table: ", err)
}
// Create the moderator table if it doesn't exist
_, err = conn.Exec("CREATE TABLE IF NOT EXISTS moderators (channelID TEXT NOT NULL UNIQUE, isStreamer BOOLEAN NOT NULL DEFAULT FALSE)")
if err != nil {
log.Fatal("[FATAL] Error creating moderators table: ", err)
}
// Create the user table if it doesn't exist
_, err = conn.Exec("CREATE TABLE IF NOT EXISTS users (channelID TEXT NOT NULL UNIQUE, points BLOB NOT NULL, sub TEXT NOT NULL UNIQUE)")
if err != nil {
log.Fatal("[FATAL] Error creating users table: ", err)
}
// Set up the JWT verification
keyVerifyFunction, err := keyfunc.NewDefault([]string{"https://www.googleapis.com/oauth2/v3/certs"})
if err != nil {
log.Fatal("[FATAL] Error setting up JWT verification: ", err)
}
// Set up plugins
err = filepath.WalkDir("plugins", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Ignore directories
if d.IsDir() {
return nil
}
// Load the plugin
gamblePlugin, err := plugin.Open(path)
if err != nil {
return err
}
// Run plugin.Metadata
metadata, err := gamblePlugin.Lookup("Metadata")
if err != nil {
return err
}
// Declare it as a function
metadataFunc, ok := metadata.(func() (lib.PluginData, error))
if !ok {
return errors.New("metadata is not a function")
}
// Call the function
data, err := metadataFunc()
// Give them the add points function if they want it
if data.CanAddPoints {
addPoints, err := gamblePlugin.Lookup("SetAddPointsFunc")
if err != nil {
return err
}
addPointsFunc, ok := addPoints.(func(func(string, *big.Int)))
if !ok {
return errors.New("addPoints is not a function")
}
addPointsFunc(giveUserPoints)
}
// Give them the check moderator function if they want it
if data.CanCheckMod {
checkMod, err := gamblePlugin.Lookup("SetCheckModFunc")
if err != nil {
return err
}
checkModFunc, ok := checkMod.(func(func(string) bool))
if !ok {
return errors.New("checkMod is not a function")
}
checkModFunc(userIsModerator)
}
// Append the plugin data to the plugins slice
plugins = append(plugins, data)
return nil
})
if err != nil {
log.Fatal("[FATAL] Error setting up plugins: ", err)
}
// Set up the router
gin.SetMode(gin.ReleaseMode)
router := gin.New()
// Set up the routes
router.Static("/static", "./static")
router.LoadHTMLGlob("./templates/*")
// Define routes for each plugin
for _, pluginData := range plugins {
log.Println("[INFO] Setting up plugin", pluginData.Name)
// Try to see if there is a point cost override
pointCost := pluginData.RecommendedPoints
var pointCostBytes []byte
err := conn.QueryRow("SELECT pointsOverride FROM plugins WHERE pluginName = ?", pluginData.Name).Scan(&pointCostBytes)
if err == nil {
pointCost = new(big.Int).SetBytes(pointCostBytes)
} else if !errors.Is(err, sql.ErrNoRows) {
log.Fatal("[FATAL] Error querying database: ", err)
}
router.GET("/"+pluginData.Name, func(c *gin.Context) {
var costsSupported string
if pluginData.CanAcceptArbitraryPointAmount {
costsSupported = "true"
} else {
costsSupported = "false"
}
var canReturn string
if pluginData.CanReturnPoints {
canReturn = "true"
} else {
canReturn = "false"
}
c.HTML(200, "plugin.html", gin.H{
"Name": pluginData.Name,
"Cost": pointCost,
"PluginHTML": template.HTML(pluginData.PluginHTML),
"PluginScript": template.JS(pluginData.PluginScript),
"CanReturn": canReturn,
"MultipleCostsSupported": costsSupported,
"OnDataReturn": template.JS(pluginData.OnDataReturn),
})
})
router.POST("/api/"+pluginData.Name, func(c *gin.Context) {
var data map[string]interface{}
err := c.BindJSON(&data)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// Get the user's access token
accessToken := c.GetHeader("Authorization")
if accessToken == "" {
c.JSON(400, gin.H{"error": "No token provided"})
return
}
// Get the user's channel ID
channelID, ok := userSessions[accessToken]
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
var inputPoints *big.Int
if pluginData.CanAcceptArbitraryPointAmount {
// Get the points
points, ok := data["points"].(string)
if !ok {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// Parse the points
inputPoints, ok = new(big.Int).SetString(points, 10)
if !ok {
c.JSON(400, gin.H{"error": "Invalid points"})
return
}
// Subtract the point cost from the user's points
userPointAmount := getUserPoints(channelID)
if userPointAmount == big.NewInt(0) {
c.JSON(400, gin.H{"error": "No points"})
return
}
remaining := new(big.Int).Sub(userPointAmount, inputPoints)
if remaining.Cmp(big.NewInt(0)) == 1 {
subtractUserPoints(channelID, inputPoints)
} else {
c.JSON(400, gin.H{"error": "Not enough points, want " + inputPoints.String() + " have " + userPointAmount.String() + ", would leave you with " + remaining.String()})
return
}
} else {
// Subtract the point cost from the user's points
userPointAmount := getUserPoints(channelID)
if userPointAmount == big.NewInt(0) {
c.JSON(400, gin.H{"error": "No points"})
return
}
remaining := new(big.Int).Sub(userPointAmount, pointCost)
if remaining.Cmp(big.NewInt(0)) == 1 {
subtractUserPoints(channelID, pointCost)
} else {
c.JSON(400, gin.H{"error": "Not enough points, want " + pointCost.String() + " have " + userPointAmount.String() + ", would leave you with " + remaining.String()})
return
}
}
optionalData, ok := data["optional"].(string)
if !ok {
optionalData = "none"
}
if pluginData.CanReturnPoints {
profit, err := pluginData.ApiCode(c, lib.ApiInput{
InputPoints: inputPoints,
OptionalData: optionalData,
ChannelID: channelID,
})
if err != nil {
c.JSON(424, gin.H{"error": err.Error()})
return
}
giveUserPoints(channelID, profit)
c.JSON(200, gin.H{"profit": profit.String()})
} else {
_, err := pluginData.ApiCode(c, lib.ApiInput{
InputPoints: inputPoints,
OptionalData: optionalData,
ChannelID: channelID,
})
if err != nil {
c.JSON(424, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"success": "true"})
}
})
if pluginData.HasExtraAPI {
router.POST("/api/extra/"+pluginData.Name, pluginData.ExtraAPICode)
}
}
// Define the route for /api/claimUnclaimedPoints
router.POST("/api/claimUnclaimedPoints", func(c *gin.Context) {
// Look for the token in the userMap
accessToken := c.GetHeader("Authorization")
id, ok := userSessions[accessToken]
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Get the users earliest sent message
earliestSentMessage, ok := earliestSentMsg[id]
if !ok {
c.JSON(400, gin.H{"error": "No messages sent"})
return
}
// Get the second difference between the earliest sent message and now
secondDifference := int64(time.Now().Sub(earliestSentMessage).Seconds())
// Clear the earliest sent message
delete(earliestSentMsg, id)
// Issue the points
giveUserPoints(id, big.NewInt(secondDifference))
// Return the points
c.JSON(200, gin.H{"points": strconv.FormatInt(secondDifference, 10)})
})
// Define the route for /api/getUnclaimedPoints
router.GET("/api/getUnclaimedPoints", func(c *gin.Context) {
// Look for the token in the userMap
accessToken := c.GetHeader("Authorization")
id, ok := userSessions[accessToken]
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Get the users earliest sent message
earliestSentMessage, ok := earliestSentMsg[id]
if !ok {
c.JSON(200, gin.H{"points": "0"})
return
}
// Get the second difference between the earliest sent message and now
secondDifference := int64(time.Now().Sub(earliestSentMessage).Seconds())
// Return the points
c.JSON(200, gin.H{"points": strconv.FormatInt(secondDifference, 10)})
})
// Define the route for /api/getPlugins
router.GET("/api/getPlugins", func(c *gin.Context) {
var pluginJSON []map[string]interface{}
for _, pluginData := range plugins {
// Try to see if there is a point cost override
pointCost := pluginData.RecommendedPoints
var pointCostBytes []byte
err := conn.QueryRow("SELECT pointsOverride FROM plugins WHERE pluginName = ?", pluginData.Name).Scan(&pointCostBytes)
if err == nil {
pointCost = new(big.Int).SetBytes(pointCostBytes)
} else if !errors.Is(err, sql.ErrNoRows) {
log.Fatal("[FATAL] Error querying database: ", err)
}
// Append the plugin data to the pluginJSON slice
var costsSupported string
if pluginData.CanAcceptArbitraryPointAmount {
costsSupported = "true"
} else {
costsSupported = "false"
}
pluginJSON = append(pluginJSON, map[string]interface{}{
"Name": pluginData.Name,
"CanReturnPoints": pluginData.CanReturnPoints,
"Cost": pointCost,
"MultipleCostsSupported": costsSupported,
})
}
c.JSON(200, pluginJSON)
})
// Define the route for /api/getPoints
router.GET("/api/getPoints", func(c *gin.Context) {
// Look for the token in the userMap
accessToken := c.GetHeader("Authorization")
id, ok := userSessions[accessToken]
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Get the user's points
points := getUserPoints(id)
c.JSON(200, gin.H{"points": points.String()})
})
// Ugh, why do we have to do this legally
router.POST("/api/delete", func(c *gin.Context) {
// Look for the token in the userMap
accessToken := c.GetHeader("Authorization")
id, ok := userSessions[accessToken]
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Delete everything from every map with the user's channel ID
delete(earliestSentMsg, id)
_, err := conn.Exec("DELETE FROM users WHERE channelID = ?", id)
if err != nil {
log.Fatal("[FATAL] Error deleting user: ", err)
}
// Delete all access tokens with the user's channel ID
for key, value := range userSessions {
if value == id {
delete(userSessions, key)
}
}
c.JSON(200, "Data will be deleted in the time it takes for the garbage collector to do its thing")
})
// Define the route for /api/startStream
router.POST("/api/startStream", func(c *gin.Context) {
var data map[string]interface{}
err := c.BindJSON(&data)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// Authenticate the user
accessToken := c.GetHeader("Authorization")
if accessToken == "" {
c.JSON(400, gin.H{"error": "No token provided"})
return
}
// Check if the user is a moderator
if !userIsModerator(accessToken) {
c.JSON(403, gin.H{"error": "You must be a moderator to start a stream"})
return
}
// Set the live status
streaming = true
streamingSince = time.Now()
liveChatID, ok := data["liveChatID"].(string)
if !ok {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// Start the chat message checker
log.Println("Starting chat message checker: ", liveChatID)
go checkForChatMessages(liveChatID)
// Return 200
c.JSON(200, gin.H{"message": "Stream started"})
})
// Define the route for /api/endStream
router.POST("/api/endStream", func(c *gin.Context) {
// Authenticate the user
accessToken := c.GetHeader("Authorization")
if accessToken == "" {
c.JSON(400, gin.H{"error": "No token provided"})
return
}
// Check if the user is a moderator
if !userIsModerator(accessToken) {
c.JSON(403, gin.H{"error": "You must be a moderator to end a stream"})
return
}
// Set the live status
streaming = false
// Clear all unclaimed points
for key := range earliestSentMsg {
delete(earliestSentMsg, key)
}
// Return 200
c.JSON(200, gin.H{"message": "Stream ended"})
})
router.POST("/api/authorize", func(c *gin.Context) {
var data map[string]interface{}
err := c.BindJSON(&data)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// Check if it's a valid google token via JWT
accessToken, ok := data["idToken"].(string)
if !ok {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
parsedToken, err := jwt.Parse(accessToken, keyVerifyFunction.Keyfunc)
if err != nil {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Get the claims
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Check if the user is already registered
var channelID string
err = conn.QueryRow("SELECT channelID FROM users WHERE sub = ?", claims["sub"]).Scan(&channelID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Get the at_hash
atHash, ok := claims["at_hash"].(string)
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Get the access token
accessToken, ok = data["accessToken"].(string)
if !ok {
c.JSON(403, gin.H{"error": "Invalid token"})
return
}
// Hash the access token
hashedAccessToken := sha256.Sum256([]byte(accessToken))
// Check if the hash matches the at_hash
if strings.ReplaceAll(base64.URLEncoding.EncodeToString(hashedAccessToken[:16]), "=", "") != atHash {
c.JSON(403, gin.H{"error": "Non-matching access token"})
return
}
// Get the user's channel ID
channelID, response, err := getYTChID(accessToken)
if err != nil {
c.JSON(response, gin.H{"error": "Error getting channel ID"})
return
}
_, err = conn.Exec("INSERT INTO users (channelID, sub, points) VALUES (?, ?, ?)", channelID, claims["sub"], big.NewInt(0).Bytes())
if err != nil {
log.Fatal("[FATAL] Error registering user: ", err)
}
} else {
log.Fatal("[FATAL] Error querying database: ", err)
}
}
// Create a new random session token
sessionToken := make([]byte, 32)
_, err = rand.Read(sessionToken)
if err != nil {
log.Fatal("[FATAL] Error generating session token: ", err)
}
// Hex encode the session token
sessionTokenHex := hex.EncodeToString(sessionToken)
// Add the session token to the userSessions map
userSessions[sessionTokenHex] = channelID
// Return the session token
c.JSON(200, gin.H{"sessionToken": sessionTokenHex})
})
// Now some static routes
router.GET("/", func(c *gin.Context) {
c.HTML(200, "index.html", gin.H{})
})
router.GET("/admin", func(c *gin.Context) {
c.HTML(200, "admin.html", gin.H{})
})
router.GET("/login", func(c *gin.Context) {
c.HTML(200, "login.html", gin.H{})
})
router.GET("/privacy", func(c *gin.Context) {
c.HTML(200, "privacy.html", gin.H{})
})
router.GET("/tos", func(c *gin.Context) {
c.HTML(200, "tos.html", gin.H{})
})
// Start the server
var address string
if len(os.Args) < 2 {
address = ":8080"
} else {
address = os.Args[1]
}
log.Println("[INFO] Start server on " + address)
err = router.Run(address)
if err != nil {
log.Fatal("[FATAL] Error starting server: ", err)
}
}