package main import ( "context" "csgowtfd/csgo" "csgowtfd/ent" "csgowtfd/ent/match" "csgowtfd/ent/migrate" "csgowtfd/ent/player" "csgowtfd/ent/stats" "csgowtfd/utils" "flag" "github.com/gorilla/handlers" "github.com/gorilla/mux" _ "github.com/mattn/go-sqlite3" log "github.com/sirupsen/logrus" "go.uber.org/ratelimit" "gopkg.in/yaml.v3" "net/http" "os" "strconv" "sync" "time" ) var ( conf = utils.Conf{} demoLoader = &csgo.DemoMatchLoader{} router *mux.Router db *utils.DBWithLock sendGC chan *csgo.Demo demoParser = &csgo.DemoParser{} firstHK = true rL ratelimit.Limiter ) type PlayerResponse struct { SteamID64 uint64 `json:"steamid64,string"` Name string `json:"name"` Avatar string `json:"avatar"` VAC bool `json:"vac"` Tracked bool `json:"tracked"` VanityURL string `json:"vanity_url,omitempty"` Matches []*MatchResponse `json:"matches,omitempty"` } type MatchResponse struct { MatchId uint64 `json:"match_id,string"` ShareCode string `json:"share_code"` Map string `json:"map"` Date time.Time `json:"date"` Score [2]int `json:"score"` Duration int `json:"duration"` MatchResult int `json:"match_result"` Rounds int `json:"rounds"` Parsed bool `json:"parsed"` Stats []*StatsResponse `json:"stats"` } type StatsResponse struct { TeamID int `json:"team_id"` Kills int `json:"kills"` Deaths int `json:"deaths"` Assists int `json:"assists"` Headshot int `json:"headshot"` MVP int `json:"mvp"` Score int `json:"score"` Player PlayerResponse `json:"player"` Extended interface{} `json:"extended,omitempty"` } func housekeeping() { for { if !firstHK { time.Sleep(5 * time.Minute) } firstHK = false db.Lock.RLock() tPlayerNeedSteamUpdate, err := db.Client.Player.Query().Where( player.SteamUpdatedLTE(time.Now().UTC().AddDate(0, 0, -1)), ).All(context.Background()) db.Lock.RUnlock() if err != nil { log.Errorf("[HK] Can't query players: %v", err) continue } for _, tPlayer := range tPlayerNeedSteamUpdate { _, err = utils.UpdatePlayerFromSteam(tPlayer, conf.Steam.APIKey, db.Lock, rL) } if !demoLoader.GCReady { log.Warningf("[HK] GC not ready, skipping sharecode refresh") continue } db.Lock.RLock() tPlayerNeedShareCodeUpdate, err := db.Client.Player.Query().Where( player.And( player.Or( player.SharecodeUpdatedLTE(time.Now().UTC().Add(time.Duration(-30)*time.Minute)), player.SharecodeUpdatedIsNil(), ), player.Not(player.AuthCodeIsNil()), )).All(context.Background()) db.Lock.RUnlock() if err != nil { log.Errorf("[HK] Can't query players: %v", err) continue } for _, tPlayer := range tPlayerNeedShareCodeUpdate { shareCodes, err := utils.GetNewShareCodesForPlayer(tPlayer, db.Lock, conf.Steam.APIKey, rL) if err != nil { log.Errorf("[HK] Error while request sharecodes: %v", err) continue } for _, code := range shareCodes { sendGC <- &csgo.Demo{ ShareCode: code, } } } } } func getPlayer(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", conf.Httpd.CORSAllowDomains) id := mux.Vars(r)["id"] tPlayer, err := utils.GetPlayer(db, id, conf.Steam.APIKey, rL) if err != nil { log.Warningf("[GP] Player not found: %+v", err) w.WriteHeader(http.StatusNotFound) return } response := PlayerResponse{ SteamID64: tPlayer.Steamid, Name: tPlayer.Name, Avatar: tPlayer.AvatarURL, VAC: tPlayer.Vac, VanityURL: tPlayer.VanityURLReal, Tracked: tPlayer.AuthCode != "", Matches: []*MatchResponse{}, } db.Lock.RLock() tMatches, err := tPlayer.QueryMatches().Order(ent.Desc(match.FieldDate)).Limit(20).All(context.Background()) db.Lock.RUnlock() if err != nil { log.Debugf("[GP] No matches found for player %s", id) err := utils.SendJSON(response, w) if err != nil { log.Errorf("[GP] Unable to marshal JSON: %v", err) w.WriteHeader(http.StatusInternalServerError) } } for _, iMatch := range tMatches { mResponse := &MatchResponse{ MatchId: iMatch.MatchID, ShareCode: iMatch.ShareCode, Map: iMatch.Map, Date: iMatch.Date, Score: [2]int{iMatch.ScoreTeamA, iMatch.ScoreTeamB}, Duration: iMatch.Duration, MatchResult: iMatch.MatchResult, Rounds: iMatch.MaxRounds, Parsed: iMatch.DemoParsed, Stats: []*StatsResponse{}, } db.Lock.RLock() tStats, err := iMatch.QueryStats().Where(stats.HasPlayersWith(player.Steamid(tPlayer.Steamid))).WithPlayers().All(context.Background()) db.Lock.RUnlock() if err != nil { response.Matches = append(response.Matches, mResponse) continue } for _, iStats := range tStats { sResponse := &StatsResponse{ TeamID: iStats.TeamID, Kills: iStats.Kills, Deaths: iStats.Deaths, Assists: iStats.Assists, Headshot: iStats.Headshot, MVP: iStats.Mvp, Score: iStats.Score, Extended: iStats.Extended, } mResponse.Stats = append(mResponse.Stats, sResponse) } response.Matches = append(response.Matches, mResponse) } err = utils.SendJSON(response, w) if err != nil { log.Errorf("[GP] Unable to marshal JSON: %v", err) w.WriteHeader(http.StatusInternalServerError) } } func postPlayerTrackMe(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", conf.Httpd.CORSAllowDomains) err := r.ParseForm() if err != nil { log.Errorf("[postPlayerTrackMe] %+v", err) w.WriteHeader(http.StatusBadRequest) return } id := r.Form.Get("id") authCode := r.Form.Get("authcode") shareCode := r.Form.Get("sharecode") if id == "" || authCode == "" || !utils.AuthCodeRegEx.MatchString(authCode) { log.Warningf("[PPTM] invalid arguments: %+v, %+v, %+v", id, authCode, shareCode) w.WriteHeader(http.StatusBadRequest) return } tPlayer, err := utils.GetPlayer(db, id, conf.Steam.APIKey, rL) if err != nil { log.Warningf("[PPTM] player not found: %+v", err) w.WriteHeader(http.StatusNotFound) return } _, err = utils.IsAuthCodeValid(tPlayer, db.Lock, conf.Steam.APIKey, shareCode, authCode, rL) if err != nil { log.Warningf("[PPTM] authCode provided for player %s is invalid: %v", id, err) w.WriteHeader(http.StatusUnauthorized) return } db.Lock.Lock() err = tPlayer.Update().SetAuthCode(authCode).Exec(context.Background()) db.Lock.Unlock() if err != nil { log.Warningf("[PPTM] update player failed: %+v", err) w.WriteHeader(http.StatusInternalServerError) return } if shareCode != "" && utils.ShareCodeRegEx.MatchString(shareCode) { sendGC <- &csgo.Demo{ShareCode: shareCode} } w.WriteHeader(http.StatusOK) } func getMatchParse(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", conf.Httpd.CORSAllowDomains) shareCode := mux.Vars(r)["sharecode"] if shareCode == "" || !utils.ShareCodeRegEx.MatchString(shareCode) { log.Warningf("[PPTM] invalid arguments") w.WriteHeader(http.StatusBadRequest) return } sendGC <- &csgo.Demo{ ShareCode: shareCode, } w.WriteHeader(http.StatusOK) } func getMatch(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", conf.Httpd.CORSAllowDomains) id := mux.Vars(r)["id"] if id == "" { w.WriteHeader(http.StatusBadRequest) return } matchId, err := strconv.ParseUint(id, 10, 64) if err != nil { log.Warningf("[GM] Error parsing matchID %s: %v", id, err) w.WriteHeader(http.StatusBadRequest) return } db.Lock.RLock() tMatch, err := db.Client.Match.Query().Where(match.MatchID(matchId)).Only(context.Background()) db.Lock.RUnlock() if err != nil { log.Warningf("[GM] match %d not found: %+v", matchId, err) w.WriteHeader(http.StatusNotFound) } mResponse := &MatchResponse{ MatchId: tMatch.MatchID, ShareCode: tMatch.ShareCode, Map: tMatch.Map, Date: tMatch.Date, Score: [2]int{tMatch.ScoreTeamA, tMatch.ScoreTeamB}, Duration: tMatch.Duration, MatchResult: tMatch.MatchResult, Rounds: tMatch.MaxRounds, Parsed: tMatch.DemoParsed, Stats: []*StatsResponse{}, } db.Lock.RLock() tStats, err := tMatch.QueryStats().WithPlayers().All(context.Background()) db.Lock.RUnlock() if err != nil { log.Errorf("[GM] can't find stats for match %d: %v", tMatch.MatchID, err) } for _, iStats := range tStats { sResponse := &StatsResponse{ Player: PlayerResponse{ SteamID64: iStats.Edges.Players.Steamid, Name: iStats.Edges.Players.Name, Avatar: iStats.Edges.Players.AvatarURL, VAC: iStats.Edges.Players.Vac, VanityURL: iStats.Edges.Players.VanityURLReal, Tracked: iStats.Edges.Players.AuthCode != "", }, TeamID: iStats.TeamID, Kills: iStats.Kills, Deaths: iStats.Deaths, Assists: iStats.Assists, Headshot: iStats.Headshot, MVP: iStats.Mvp, Score: iStats.Score, Extended: iStats.Extended, } mResponse.Stats = append(mResponse.Stats, sResponse) } err = utils.SendJSON(mResponse, w) if err != nil { log.Errorf("[GM] JSON: %+v", err) w.WriteHeader(http.StatusInternalServerError) } } /* /player/ GET player internal or if not found: steamAPI data + overall stats /player/trackme POST id, authcode, [sharecode] /match/ GET CSGO-GC response + internal data if parsed <- may be big (ALL RELEVANT DATA) /match/parse/ GET parses sharecode provided */ func main() { flag.Parse() confStr, err := os.ReadFile("config.yaml") utils.Check(err) err = yaml.Unmarshal(confStr, &conf) utils.Check(err) lvl, err := log.ParseLevel(conf.Logging.Level) utils.Check(err) log.SetLevel(lvl) db = &utils.DBWithLock{ Lock: new(sync.RWMutex), } db.Client, err = ent.Open("sqlite3", "file:opencsgo.db?_fk=1&cache=shared") if err != nil { log.Panicf("Failed to open database %s: %v", "opencsgo.db", err) } defer func(dbSQLite *ent.Client) { utils.Check(dbSQLite.Close()) }(db.Client) if err := db.Client.Schema.Create(context.Background(), migrate.WithDropIndex(true), migrate.WithDropColumn(true)); err != nil { log.Panicf("Automigrate failed: %v", err) } rL = ratelimit.New(conf.Steam.RatePerSecond) // setup GC err = demoLoader.Setup(conf.Steam.Username) if err != nil { log.Fatalf("Unbale to setup DemoLoader: %v", err) } log.Info("Waiting for GC to be ready") for demoLoader.GCReady != true { time.Sleep(time.Second) } log.Info("GC ready, starting HTTP server") sendGC = make(chan *csgo.Demo, 100) utils.Check(demoParser.Setup(db.Client, db.Lock, conf.Parser.Worker)) go utils.GCInfoParser(sendGC, demoLoader, demoParser, db, conf.Steam.APIKey, rL) go housekeeping() router = mux.NewRouter().StrictSlash(true) router.HandleFunc("/player/{id}", getPlayer).Methods("GET") router.HandleFunc("/player/trackme", postPlayerTrackMe).Methods("POST") router.HandleFunc("/match/parse/{sharecode}", getMatchParse).Methods("GET") router.HandleFunc("/match/{id:[0-9]{19}}", getMatch).Methods("GET") loggedRouter := handlers.LoggingHandler(os.Stdout, router) utils.Check(http.ListenAndServe(":8000", loggedRouter)) }