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) } }