package main import ( "bytes" "context" "encoding/gob" "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "errors" "flag" "fmt" "git.harting.dev/csgowtf/csgowtfd/csgo" "git.harting.dev/csgowtf/csgowtfd/ent" "git.harting.dev/csgowtf/csgowtfd/ent/match" "git.harting.dev/csgowtf/csgowtfd/ent/matchplayer" "git.harting.dev/csgowtf/csgowtfd/ent/messages" "git.harting.dev/csgowtf/csgowtfd/ent/migrate" "git.harting.dev/csgowtf/csgowtfd/ent/player" "git.harting.dev/csgowtf/csgowtfd/utils" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/go-redis/cache/v8" "github.com/go-redis/redis/v8" _ "github.com/jackc/pgx/v4/stdlib" "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/common" log "github.com/sirupsen/logrus" "github.com/wercker/journalhook" "golang.org/x/text/language" "golang.org/x/time/rate" "gopkg.in/yaml.v3" "net" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" ) var ( conf = utils.Conf{} demoLoader = &csgo.DemoMatchLoader{} db *ent.Client rdb *redis.Client rdc *cache.Cache rL *rate.Limiter configFlag = flag.String("config", "config.yaml", "Set config file to use") journalLogFlag = flag.Bool("journal", false, "Log to systemd journal instead of stdout") sqlDebugFlag = flag.Bool("sqldebug", false, "Debug SQL queries") ) func housekeeping() { for { time.Sleep(5 * time.Minute) dur, err := time.ParseDuration(conf.Csgowtfd.ProfileUpdate) if err != nil { log.Warningf("[HK] Unable to parse config option profile_update %s: %v", conf.Csgowtfd.ProfileUpdate, err) dur, _ = time.ParseDuration("168h") } // update players from steam lastUpdated := new(time.Time) err = rdc.Get(context.Background(), utils.CachePrefix+"last_updated_profiles", &lastUpdated) if err != nil || time.Since(*lastUpdated) >= (time.Hour*24) { tPlayerNeedSteamUpdate, err := db.Player.Query().Where( player.SteamUpdatedLTE(time.Now().UTC().Add(dur * -1)), ).All(context.Background()) if err != nil { log.Errorf("[HK] Can't query players: %v", err) } if len(tPlayerNeedSteamUpdate) > 0 { log.Infof("[HK] Updating %d profiles from steam (last update %s)", len(tPlayerNeedSteamUpdate), *lastUpdated) _, err = utils.PlayerFromSteam(tPlayerNeedSteamUpdate, db, conf.Steam.APIKey, rL) if err != nil { log.Warningf("[HK] Unable to update profiles from steam: %v", err) } } err = rdc.Set(&cache.Item{ Ctx: context.Background(), Key: utils.CachePrefix + "last_updated_profiles", Value: time.Now().UTC(), TTL: time.Hour * 24 * 30, SkipLocalCache: true, }) if err != nil { log.Errorf("[HK] Failure setting cache: %v", err) } } // mark matches as vac/gameban bPlayers, err := db.Player.Query(). Select(player.FieldID, player.FieldGameBanDate, player.FieldVacDate). Where(player.Or(player.GameBanDateNotNil(), player.VacDateNotNil())). All(context.Background()) if err != nil { log.Warningf("[HK] Unable to query for banned players: %v", err) } for _, bp := range bPlayers { if !bp.GameBanDate.IsZero() { err := db.Match.Update(). Where( match.And( match.HasPlayersWith(player.ID(bp.ID)), match.DateLTE(bp.GameBanDate.AddDate(0, 0, 30)), )). SetGamebanPresent(true).Exec(context.Background()) if err != nil { log.Warningf("[HK] Unable to set gameban/vac for match: %v", err) } } if !bp.VacDate.IsZero() { err := db.Match.Update(). Where( match.And( match.HasPlayersWith(player.ID(bp.ID)), match.DateLTE(bp.VacDate.AddDate(0, 0, 30)), )).SetVacPresent(true).Exec(context.Background()) if err != nil { log.Warningf("[HK] Unable to set gameban/vac for match: %v", err) } } } // try parsing demos not parsed tMatches, err := db.Match.Query(). Where( match.And( match.DateGT(time.Now().UTC().AddDate(0, 0, -1*conf.Csgowtfd.DemosExpire)), match.DemoParsed(false))). All(context.Background()) if err != nil { log.Warningf("[HK] Failure getting matches to retry parsing: %v", err) continue } for _, m := range tMatches { demo := &csgo.Demo{MatchId: m.ID, ShareCode: m.ShareCode} if demoLoader.IsLoading(demo) { log.Infof("[HK] Skipping %s: parsing in progress", m.ShareCode) continue } log.Infof("[HK] Try reparsing match %d, played on %s", m.ID, m.Date) err := demoLoader.LoadDemo(demo) if err != nil { log.Warningf("[HK] Failure trying to parse match %d: %v", m.ID, err) } } // check for inconsistent matches tMatchIDs, err := db.Match.Query().IDs(context.Background()) for _, mid := range tMatchIDs { var v []struct { ID int `json:"match_stats"` Count int `json:"count"` } err = db.MatchPlayer.Query(). Where(matchplayer.MatchStats(mid)). GroupBy(matchplayer.FieldMatchStats). Aggregate(ent.Count()). Scan(context.Background(), &v) if err != nil { log.Warningf("[HK] Unable to query for matchplayers for match %d: %v", mid, err) continue } if v[0].Count < 10 { log.Warningf("[HK] Found match without all players, try to reload it.") tMatch, err := db.Match.Get(context.Background(), mid) if err != nil { log.Warningf("[HK] Unable to get match with id %d: %v", mid, err) continue } err = utils.DeleteMatch(tMatch, db) if err != nil { log.Warningf("[HK] Unable to delete match with id %d: %v", mid, err) continue } err = demoLoader.LoadDemo(&csgo.Demo{ShareCode: tMatch.ShareCode}) if err != nil { log.Warningf("[HK] Unable to requeue match with id %d: %v", mid, err) } } } // getting new sharecodes if !demoLoader.GCReady { log.Warningf("[HK] GC not ready, skipping sharecode refresh") continue } dur, err = time.ParseDuration(conf.Csgowtfd.SharecodeUpdate) if err != nil { log.Warningf("[HK] Unable to parse config option sharecode_update %s: %v", conf.Csgowtfd.SharecodeUpdate, err) dur, _ = time.ParseDuration("30m") } tPlayerNeedShareCodeUpdate, err := db.Player.Query().Where( player.And( player.Or( player.SharecodeUpdatedLTE(time.Now().UTC().Add(dur*-1)), player.SharecodeUpdatedIsNil(), ), player.Not(player.AuthCodeIsNil()), )).All(context.Background()) if err != nil { log.Errorf("[HK] Can't query players: %v", err) continue } for _, tPlayer := range tPlayerNeedShareCodeUpdate { shareCodes, err := utils.GetNewShareCodesForPlayer(tPlayer, conf.Steam.APIKey, rL) if err != nil { if errors.Is(err, utils.AuthcodeUnauthorizedError) { log.Infof("[HK] authCode for player %d is no longer valid", tPlayer.ID) err = tPlayer.Update().ClearAuthCode().ClearSharecodeUpdated().Exec(context.Background()) if err != nil { log.Errorf("[HK] Unable to clear authcode for player %d: %v", tPlayer.ID, err) } continue } else if errors.Is(err, utils.SharecodeNoMatchError) { log.Warningf("[HK] last shareCode for player %d does not match player", tPlayer.ID) continue } else if errors.Is(err, utils.NoMatchError) { log.Infof("[HK] tracked player with no matched found, untrack him") err = tPlayer.Update().ClearAuthCode().ClearSharecodeUpdated().Exec(context.Background()) if err != nil { log.Errorf("[HK] Unable to clear authcode for player %d: %v", tPlayer.ID, err) } continue } else { log.Errorf("[HK] Error while requesting sharecodes for %d: %v", tPlayer.ID, err) continue } } for _, code := range shareCodes { err := demoLoader.LoadDemo(&csgo.Demo{ ShareCode: code, }) if err != nil { log.Warningf("[HK] Failure to queue match: %v", err) } } } } } func getPlayerMeta(c *gin.Context) { id := c.Param("id") l := c.Param("limit") var limit int var err error if l != "" { limit, err = strconv.Atoi(l) if err != nil { log.Infof("[GPM] limit not an int: %v", err) c.Status(http.StatusBadRequest) return } } else { limit = 4 } if limit > 10 { log.Infof("[GPM] limit out of bounds: %d", limit) c.Status(http.StatusBadRequest) return } tPlayer, err := utils.Player(db, id, conf.Steam.APIKey, nil) if err != nil { log.Infof("[GPM] Player not found: %+v", err) c.Status(http.StatusNotFound) return } metaStats := new(utils.MetaStatsResponse) err = rdc.Get(context.Background(), fmt.Sprintf(utils.SideMetaCacheKey, tPlayer.ID), &metaStats) if err != nil { metaStats, err = utils.GetMetaStats(tPlayer) if err != nil { log.Infof("[GPM] Unable to get MetaStats: %v", err) c.Status(http.StatusInternalServerError) return } err = rdc.Set(&cache.Item{ Ctx: context.Background(), Key: fmt.Sprintf(utils.SideMetaCacheKey, tPlayer.ID), Value: metaStats, TTL: time.Hour * 24 * 30, }) if err != nil { log.Errorf("[GPM] Failure saving to cache: %v", err) c.Status(http.StatusInternalServerError) return } log.Debugf("[GPM] SideMetaStats for %d saved to cache", tPlayer.ID) } else { log.Debugf("[GPM] SideMetaStats for %d from cache", tPlayer.ID) } if len(metaStats.BestMates) > limit { metaStats.BestMates = metaStats.BestMates[:limit] } if len(metaStats.MostMates) > limit { metaStats.MostMates = metaStats.MostMates[:limit] } if len(metaStats.WeaponDmg) > limit { metaStats.WeaponDmg = metaStats.WeaponDmg[:limit] } for _, wD := range metaStats.WeaponDmg { if _, exist := metaStats.EqMap[wD.Eq]; !exist { metaStats.EqMap[wD.Eq] = common.EquipmentType(wD.Eq).String() } } for _, p := range append(metaStats.BestMates, metaStats.MostMates...) { if p.Player.Name == "" { tP, err := utils.Player(db, p.Player.SteamID64, conf.Steam.APIKey, nil) if err != nil { log.Warningf("[GPM] Failure getting player: %v", err) c.Status(http.StatusInternalServerError) return } p.Player.Avatar = tP.Avatar p.Player.Name = tP.Name p.Player.VAC = !tP.VacDate.IsZero() p.Player.Tracked = tP.AuthCode != "" p.Player.GameBan = !tP.GameBanDate.IsZero() p.Player.VanityURL = tP.VanityURLReal if !tP.GameBanDate.IsZero() { p.Player.GameBanDate = tP.GameBanDate.Unix() } if !tP.VacDate.IsZero() { p.Player.VACDate = tP.VacDate.Unix() } } } c.JSON(http.StatusOK, metaStats) } func getPlayer(c *gin.Context) { id := c.Param("id") t := c.Param("time") var offsetTime time.Time if t != "" { unixOffset, err := strconv.ParseInt(t, 10, 64) if err != nil { log.Infof("[GP] offset not an int: %v", err) c.Status(http.StatusBadRequest) return } offsetTime = time.Unix(unixOffset, 0).UTC() } tPlayer, err := utils.Player(db, id, conf.Steam.APIKey, nil) if err != nil || tPlayer == nil { log.Infof("[GP] Player %s not found (%+v)", id, err) c.Status(http.StatusNotFound) return } response := utils.PlayerResponse{ SteamID64: tPlayer.ID, Name: tPlayer.Name, Avatar: tPlayer.Avatar, VAC: !tPlayer.VacDate.IsZero(), VACDate: tPlayer.VacDate.Unix(), GameBan: !tPlayer.GameBanDate.IsZero(), GameBanDate: tPlayer.GameBanDate.Unix(), VanityURL: tPlayer.VanityURLReal, Tracked: tPlayer.AuthCode != "", MatchStats: &utils.MatchStats{ Win: tPlayer.Wins, Tie: tPlayer.Ties, Loss: tPlayer.Looses, }, Matches: []*utils.MatchResponse{}, } var tMatches []*ent.Match if !offsetTime.IsZero() { tMatches, err = tPlayer.QueryMatches().Where(match.DateLT(offsetTime)).Order(ent.Desc(match.FieldDate)).Limit(10).All(context.Background()) } else { tMatches, err = tPlayer.QueryMatches().Order(ent.Desc(match.FieldDate)).Limit(10).All(context.Background()) } if err != nil || len(tMatches) == 0 { log.Debugf("[GP] No matches found for player %s", id) c.JSON(http.StatusOK, response) return } for _, iMatch := range tMatches { mResponse := &utils.MatchResponse{ MatchId: iMatch.ID, Map: iMatch.Map, Date: iMatch.Date.Unix(), Score: [2]int{iMatch.ScoreTeamA, iMatch.ScoreTeamB}, Duration: iMatch.Duration, MatchResult: iMatch.MatchResult, MaxRounds: iMatch.MaxRounds, Parsed: iMatch.DemoParsed, VAC: iMatch.VacPresent, GameBan: iMatch.GamebanPresent, TickRate: iMatch.TickRate, } tStats, err := iMatch.QueryStats().Modify(func(s *sql.Selector) { s.Select(matchplayer.FieldTeamID, matchplayer.FieldKills, matchplayer.FieldDeaths, matchplayer.FieldAssists, matchplayer.FieldHeadshot, matchplayer.FieldMvp, matchplayer.FieldScore, matchplayer.FieldMk2, matchplayer.FieldMk3, matchplayer.FieldMk4, matchplayer.FieldMk5, matchplayer.FieldRankOld, matchplayer.FieldRankNew, matchplayer.FieldDmgTeam, matchplayer.FieldDmgEnemy) s.Where(sql.EQ(s.C(matchplayer.PlayersColumn), tPlayer.ID)) }).Only(context.Background()) if err != nil { response.Matches = append(response.Matches, mResponse) continue } sResponse := &utils.StatsResponse{ TeamID: tStats.TeamID, Kills: tStats.Kills, Deaths: tStats.Deaths, Assists: tStats.Assists, Headshot: tStats.Headshot, MVP: tStats.Mvp, Score: tStats.Score, } sResponse.MultiKills = &utils.MultiKills{ Duo: tStats.Mk2, Triple: tStats.Mk3, Quad: tStats.Mk4, Pent: tStats.Mk5, } sResponse.Rank = &utils.Rank{ Old: tStats.RankOld, New: tStats.RankNew, } sResponse.Dmg = &utils.Damage{ Enemy: tStats.DmgEnemy, Team: tStats.DmgTeam, } mResponse.Stats = sResponse response.Matches = append(response.Matches, mResponse) } c.JSON(http.StatusOK, response) } func deletePlayerTrack(c *gin.Context) { id := c.Param("id") authCode := c.PostForm("authcode") if id == "" || authCode == "" || !utils.AuthCodeRegEx.MatchString(authCode) { log.Infof("[PPTM] invalid arguments: %+v, %+v", id, authCode) c.Status(http.StatusBadRequest) return } tPlayer, err := utils.Player(db, id, conf.Steam.APIKey, nil) if err != nil { log.Infof("[PPT] player not found: %+v", err) c.Status(http.StatusNotFound) return } _, err = utils.IsAuthCodeValid(tPlayer, conf.Steam.APIKey, "", authCode, nil) if err != nil { if errors.Is(err, utils.AuthcodeUnauthorizedError) { log.Infof("[DPT] authCode provided for player %s is invalid: %v", id, err) c.Status(http.StatusUnauthorized) return } log.Infof("[DPT] Temporary Steam-API problem: %v", err) c.Status(http.StatusServiceUnavailable) return } err = tPlayer.Update().ClearAuthCode().ClearSharecodeUpdated().Exec(context.Background()) if err != nil { log.Warningf("[PPT] update player failed: %+v", err) c.Status(http.StatusInternalServerError) return } c.Status(http.StatusOK) } func postPlayerTrack(c *gin.Context) { id := c.Param("id") authCode := strings.TrimSpace(c.PostForm("authcode")) shareCode := strings.TrimSpace(c.PostForm("sharecode")) if id == "" || authCode == "" || !utils.AuthCodeRegEx.MatchString(authCode) { log.Infof("[PPT] invalid arguments: %+v, %+v, %+v", id, authCode, shareCode) c.Status(http.StatusBadRequest) return } tPlayer, err := utils.Player(db, id, conf.Steam.APIKey, rL) if err != nil { log.Infof("[PPT] player not found: %+v", err) c.Status(http.StatusNotFound) return } if shareCode != "" && utils.ShareCodeRegEx.MatchString(shareCode) { err := demoLoader.LoadDemo(&csgo.Demo{ShareCode: shareCode}) if err != nil { log.Warningf("[PPT] unable to queue match: %v", err) c.Status(http.StatusServiceUnavailable) return } } _, err = utils.IsAuthCodeValid(tPlayer, conf.Steam.APIKey, shareCode, authCode, rL) if err != nil { if errors.Is(err, utils.AuthcodeUnauthorizedError) { log.Infof("[PPT] authCode provided for player %s is invalid: %v", id, err) c.Status(http.StatusUnauthorized) return } else if errors.Is(err, utils.SharecodeNoMatchError) { log.Infof("[PPT] shareCode provided for player %s (%s) is invalid: %v", id, shareCode, err) c.Status(http.StatusPreconditionFailed) return } else { log.Infof("[PPT] problem with request: %v", err) c.Status(http.StatusServiceUnavailable) return } } err = tPlayer.Update().SetAuthCode(authCode).Exec(context.Background()) if err != nil { log.Warningf("[PPT] update player failed: %+v", err) c.Status(http.StatusInternalServerError) return } if shareCode != "" && utils.ShareCodeRegEx.MatchString(shareCode) { err := demoLoader.LoadDemo(&csgo.Demo{ShareCode: shareCode}) if err != nil { log.Warningf("[PPT] unable to queue match: %v", err) c.Status(http.StatusServiceUnavailable) return } } c.Status(http.StatusAccepted) } func getMatchParse(c *gin.Context) { shareCode := c.Param("sharecode") if shareCode == "" || !utils.ShareCodeRegEx.MatchString(shareCode) { log.Infof("[PPTM] invalid arguments: %s", shareCode) c.Status(http.StatusBadRequest) return } err := demoLoader.LoadDemo(&csgo.Demo{ ShareCode: shareCode, }) if err != nil { log.Warningf("[PPTM] unable to queue match: %v", err) c.Status(http.StatusServiceUnavailable) return } c.Status(http.StatusAccepted) } func getMatchRounds(c *gin.Context) { id := c.Param("id") if id == "" { c.Status(http.StatusBadRequest) return } matchId, err := strconv.ParseUint(id, 10, 64) if err != nil { log.Infof("[GMR] Error parsing matchID %s: %v", id, err) c.Status(http.StatusBadRequest) return } tStats, err := db.MatchPlayer.Query().Where(matchplayer.HasMatchesWith(match.ID(matchId))).All(context.Background()) if err != nil { log.Infof("[GMR] match %d not found: %+v", matchId, err) c.Status(http.StatusNotFound) return } resp := map[uint]map[string][]uint{} for _, stat := range tStats { tRoundStats, err := stat.QueryRoundStats().All(context.Background()) if err != nil { log.Warningf("[GMR] Unable to get RoundStats for player %d: %v", stat.PlayerStats, err) continue } for _, rStat := range tRoundStats { if _, ok := resp[rStat.Round]; !ok { resp[rStat.Round] = map[string][]uint{} } resp[rStat.Round][strconv.FormatUint(stat.PlayerStats, 10)] = []uint{rStat.Equipment, rStat.Spent, rStat.Bank} } } c.JSON(http.StatusOK, resp) } func getMatchChat(c *gin.Context) { id := c.Param("id") trans := c.Query("translate") if id == "" { c.Status(http.StatusBadRequest) return } tag, weights, err := language.ParseAcceptLanguage(c.GetHeader("Accept-Language")) if err != nil { log.Warningf("[GMC] Unable to parse Accept-Language %s: %v", c.GetHeader("Accept-Language"), err) c.Status(http.StatusBadRequest) return } var lang language.Base if len(tag) > 0 { lang, _ = tag[0].Base() } else { lang, _ = language.AmericanEnglish.Base() } log.Debugf("[GMC] Got header %s, selected %s from %v (w: %v)", c.GetHeader("Accept-Language"), lang, tag, weights) var translate bool if trans != "" { translate, err = strconv.ParseBool(trans) if err != nil { c.Status(http.StatusBadRequest) return } } matchId, err := strconv.ParseUint(id, 10, 64) if err != nil { log.Infof("[GMC] Error parsing matchID %s: %v", id, err) c.Status(http.StatusBadRequest) return } resp := map[string][]*utils.ChatResponse{} if translate { err = rdc.Get(context.Background(), fmt.Sprintf(utils.MatchChatCacheKey, matchId, lang.String()), &resp) if err != nil { tStats, err := db.Messages.Query().Where(messages.HasMatchPlayerWith(matchplayer.HasMatchesWith(match.ID(matchId)))).WithMatchPlayer().All(context.Background()) if err != nil { log.Infof("[GMC] match %d not found: %+v", matchId, err) c.Status(http.StatusNotFound) return } for _, stat := range tStats { steamid := strconv.FormatUint(stat.Edges.MatchPlayer.PlayerStats, 10) if _, ok := resp[steamid]; !ok { resp[steamid] = make([]*utils.ChatResponse, 0) } if translate { translated, srcLang, err := utils.TranslateWithDeepL(stat.Message, lang.String(), conf.DeepL.BaseURL, conf.DeepL.APIKey, conf.DeepL.Timeout) if err != nil { log.Warningf("[GMC] Unable to translate %s with DeepL: %v", stat.Message, err) goto sendNormalResp } if srcLang == lang.String() || strings.TrimSpace(translated) == strings.TrimSpace(stat.Message) { goto sendNormalResp } resp[steamid] = append(resp[steamid], &utils.ChatResponse{ Message: translated, AllChat: stat.AllChat, Tick: stat.Tick, TranslatedFrom: srcLang, TranslatedTo: lang.String(), }) continue } sendNormalResp: resp[steamid] = append(resp[steamid], &utils.ChatResponse{ Message: stat.Message, AllChat: stat.AllChat, Tick: stat.Tick, }) } err = rdc.Set(&cache.Item{ Ctx: context.Background(), Key: fmt.Sprintf(utils.MatchChatCacheKey, matchId, lang.String()), Value: resp, TTL: time.Hour * 24 * 30, }) if err != nil { log.Errorf("[GMC] Failure saving to cache: %v", err) c.Status(http.StatusInternalServerError) return } } } else { tStats, err := db.Messages.Query().Where(messages.HasMatchPlayerWith(matchplayer.HasMatchesWith(match.ID(matchId)))).WithMatchPlayer().All(context.Background()) if err != nil { log.Infof("[GMC] match %d not found: %+v", matchId, err) c.Status(http.StatusNotFound) return } for _, stat := range tStats { steamid := strconv.FormatUint(stat.Edges.MatchPlayer.PlayerStats, 10) if _, ok := resp[steamid]; !ok { resp[steamid] = make([]*utils.ChatResponse, 0) } resp[steamid] = append(resp[steamid], &utils.ChatResponse{ Message: stat.Message, AllChat: stat.AllChat, Tick: stat.Tick, }) } } c.JSON(http.StatusOK, resp) } func getMatchWeapons(c *gin.Context) { id := c.Param("id") if id == "" { c.Status(http.StatusBadRequest) return } matchId, err := strconv.ParseUint(id, 10, 64) if err != nil { log.Infof("[GMW] Error parsing matchID %s: %v", id, err) c.Status(http.StatusBadRequest) return } tStats, err := db.MatchPlayer.Query().Where(matchplayer.HasMatchesWith(match.ID(matchId))).All(context.Background()) if err != nil { log.Infof("[GMW] match %d not found: %+v", matchId, err) c.Status(http.StatusNotFound) return } mResponse := struct { EquipmentMap map[int]string `json:"equipment_map,omitempty"` Stats []map[string]map[string][][]int `json:"stats,omitempty"` Spray []map[string]map[int][][]float32 `json:"spray,omitempty"` }{ EquipmentMap: map[int]string{}, Stats: []map[string]map[string][][]int{}, Spray: []map[string]map[int][][]float32{}, } for _, stat := range tStats { mWs, err := stat.QueryWeaponStats().All(context.Background()) if err != nil { log.Warningf("[GMW] Unable to get WeaponStats for player %d: %v", stat.PlayerStats, err) continue } mWr := map[string]map[string][][]int{} playerId := strconv.FormatUint(stat.PlayerStats, 10) for _, wr := range mWs { if _, exists := mWr[playerId]; !exists { mWr[playerId] = map[string][][]int{} } victim := strconv.FormatUint(wr.Victim, 10) mWr[playerId][victim] = append(mWr[playerId][victim], []int{wr.EqType, wr.HitGroup, int(wr.Dmg)}) if _, exist := mResponse.EquipmentMap[wr.EqType]; !exist { mResponse.EquipmentMap[wr.EqType] = common.EquipmentType(wr.EqType).String() } } mResponse.Stats = append(mResponse.Stats, mWr) mSprays, err := stat.QuerySpray().All(context.Background()) if err != nil { log.Warningf("[GMW] Unable to get Sprays for player %d: %v", stat.PlayerStats, err) continue } rSprays := map[string]map[int][][]float32{} for _, spray := range mSprays { if _, exists := rSprays[playerId]; !exists { rSprays[playerId] = map[int][][]float32{} } bBuf := bytes.NewBuffer(spray.Spray) dec := gob.NewDecoder(bBuf) var dSpray [][]float32 err := dec.Decode(&dSpray) if err != nil { log.Warningf("[GMW] Unable to decode Sprays for player %d: %v", stat.PlayerStats, err) continue } log.Debugf("%+v", dSpray) rSprays[playerId][spray.Weapon] = dSpray } mResponse.Spray = append(mResponse.Spray, rSprays) } c.JSON(http.StatusOK, mResponse) } func getMatches(c *gin.Context) { t := c.Param("time") var offsetTime time.Time if t != "" { unixOffset, err := strconv.ParseInt(t, 10, 64) if err != nil { log.Infof("[GMS] offset not an int: %v", err) c.Status(http.StatusBadRequest) return } offsetTime = time.Unix(unixOffset, 0).UTC() } var mResponse []*utils.MatchResponse var err error var tMatches []*ent.Match if !offsetTime.IsZero() { tMatches, err = db.Match.Query().Where(match.DateLT(offsetTime)).Order(ent.Desc(match.FieldDate)).Limit(20).All(context.Background()) } else { tMatches, err = db.Match.Query().Order(ent.Desc(match.FieldDate)).Limit(20).All(context.Background()) } if err != nil || len(tMatches) == 0 { log.Debug("[GMS] No matches found") c.JSON(http.StatusOK, mResponse) return } for _, iMatch := range tMatches { var v []struct { Avg float64 `json:"avg"` MatchID uint64 `json:"match_stats"` } avgRank := 0.0 err := iMatch.QueryStats().Where(matchplayer.RankOldNEQ(0)).GroupBy(matchplayer.MatchesColumn).Aggregate(ent.Mean(matchplayer.FieldRankOld)).Scan(context.Background(), &v) if err != nil || len(v) == 0 { log.Debugf("[GMS] Unable to calc avg rank for match %d: %v", iMatch.ID, err) avgRank = 0.0 } else { avgRank = v[0].Avg } mResponse = append(mResponse, &utils.MatchResponse{ MatchId: iMatch.ID, Map: iMatch.Map, Date: iMatch.Date.Unix(), Score: [2]int{iMatch.ScoreTeamA, iMatch.ScoreTeamB}, Duration: iMatch.Duration, MatchResult: iMatch.MatchResult, MaxRounds: iMatch.MaxRounds, Parsed: iMatch.DemoParsed, VAC: iMatch.VacPresent, GameBan: iMatch.GamebanPresent, AvgRank: avgRank, TickRate: iMatch.TickRate, }) } c.JSON(http.StatusOK, mResponse) } func getMatch(c *gin.Context) { id := c.Param("id") if id == "" { c.Status(http.StatusBadRequest) return } matchId, err := strconv.ParseUint(id, 10, 64) if err != nil { log.Infof("[GM] Unable to parse matchID %s: %v", id, err) c.Status(http.StatusBadRequest) return } tMatch, err := db.Match.Query().Where(match.ID(matchId)).Only(context.Background()) if err != nil { log.Infof("[GM] match %d not found: %v", matchId, err) c.Status(http.StatusNotFound) return } var v []struct { Avg float64 `json:"avg"` MatchID uint64 `json:"match_stats"` } avgRank := 0.0 err = tMatch.QueryStats().Where(matchplayer.RankOldNEQ(0)).GroupBy(matchplayer.MatchesColumn).Aggregate(ent.Mean(matchplayer.FieldRankOld)).Scan(context.Background(), &v) if err != nil || len(v) == 0 { log.Debugf("[GM] Unable to calc avg rank for match %d: %v", tMatch.ID, err) avgRank = 0 } else { avgRank = v[0].Avg } mResponse := &utils.MatchResponse{ MatchId: tMatch.ID, ShareCode: tMatch.ShareCode, Map: tMatch.Map, Date: tMatch.Date.Unix(), Score: [2]int{tMatch.ScoreTeamA, tMatch.ScoreTeamB}, Duration: tMatch.Duration, MatchResult: tMatch.MatchResult, MaxRounds: tMatch.MaxRounds, Parsed: tMatch.DemoParsed, Stats: []*utils.StatsResponse{}, AvgRank: avgRank, TickRate: tMatch.TickRate, } if tMatch.Date.After(time.Now().AddDate(0, 0, -1*conf.Csgowtfd.DemosExpire)) { mResponse.ReplayURL = tMatch.ReplayURL } tStats, err := tMatch.QueryStats().WithPlayers().All(context.Background()) if err != nil { log.Errorf("[GM] Unable to find stats for match %d: %v", tMatch.ID, err) c.Status(http.StatusInternalServerError) return } tmpStats := make([]*utils.StatsResponse, 0) for _, iStats := range tStats { sResponse := &utils.StatsResponse{ Player: utils.PlayerResponse{ SteamID64: iStats.Edges.Players.ID, Name: iStats.Edges.Players.Name, Avatar: iStats.Edges.Players.Avatar, VAC: !iStats.Edges.Players.VacDate.IsZero(), VACDate: iStats.Edges.Players.VacDate.Unix(), GameBan: !iStats.Edges.Players.GameBanDate.IsZero(), GameBanDate: iStats.Edges.Players.GameBanDate.Unix(), 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, Dmg: &utils.Damage{ Team: iStats.DmgTeam, Enemy: iStats.DmgEnemy, }, Color: iStats.Color.String(), Crosshair: iStats.Crosshair, KAST: iStats.Kast, Rank: &utils.Rank{ Old: iStats.RankOld, New: iStats.RankNew, }, Flash: &utils.Flash{ Total: &utils.SelfTeamEnemy{ Enemy: iStats.FlashTotalEnemy, Team: iStats.FlashTotalTeam, Self: iStats.FlashTotalSelf, }, Duration: &utils.SelfTeamEnemy{ Enemy: iStats.FlashDurationEnemy, Team: iStats.FlashDurationTeam, Self: iStats.FlashDurationSelf, }, }, MultiKills: &utils.MultiKills{ Duo: iStats.Mk2, Triple: iStats.Mk3, Quad: iStats.Mk4, Pent: iStats.Mk5, }, } tmpStats = append(tmpStats, sResponse) } mResponse.Stats = tmpStats c.JSON(http.StatusOK, mResponse) } /* /player/ GET player details (last 10 matches) /player//track POST Track player FORM_DATA: authcode, [sharecode] /player//track DELETE Stop tracking player FORM_DATA: authcode /match/ GET details for match /match//weapons GET weapon-stats for match /match//rounds GET round-stats for match /match/parse/ GET parses sharecode provided /matches GET returns 20 latest matches in DB /matches/next/ GET returns 20 matches after time unix */ func main() { killSignals := make(chan os.Signal, 1) signal.Notify(killSignals, syscall.SIGINT, syscall.SIGTERM) reloadSignals := make(chan os.Signal, 1) signal.Notify(reloadSignals, syscall.SIGUSR1) flag.Parse() confStr, err := os.ReadFile(*configFlag) if err != nil { log.Fatalf("Unable to open config: %v", err) } err = yaml.Unmarshal(confStr, &conf) if err != nil { log.Fatalf("Unable to parse config: %v", err) } lvl, err := log.ParseLevel(conf.Logging.Level) if err != nil { log.Fatalf("Failure setting logging level: %v", err) } log.SetLevel(lvl) if *journalLogFlag { journalhook.Enable() } if conf.Db.Driver == "pgx" { pdb, err := sql.Open("pgx", conf.Db.ConnectTo) if err != nil { log.Fatalf("Failed to open database %s: %v", conf.Db.ConnectTo, err) } drv := sql.OpenDB(dialect.Postgres, pdb.DB()) db = ent.NewClient(ent.Driver(drv)) } else { db, err = ent.Open(conf.Db.Driver, conf.Db.ConnectTo) if err != nil { log.Panicf("Failed to open database %s: %v", conf.Db.ConnectTo, err) } defer func(Client *ent.Client) { _ = Client.Close() }(db) } if *sqlDebugFlag { db = db.Debug() } if err := db.Schema.Create( context.Background(), migrate.WithDropIndex(true), migrate.WithDropColumn(true), ); err != nil { log.Fatalf("Automigrate failed: %v", err) } rdb = redis.NewClient(&redis.Options{ Addr: conf.Redis.Address, Password: conf.Redis.Password, DB: 0, }) rdc = cache.New(&cache.Options{ Redis: rdb, LocalCache: cache.NewTinyLFU(1000, time.Minute), }) rL = rate.NewLimiter(rate.Limit(conf.Steam.RatePerSecond), 100) // setup GC err = demoLoader.Setup(&csgo.DemoMatchLoaderConfig{ Username: conf.Steam.Username, Password: conf.Steam.Password, AuthCode: conf.Steam.AuthCode, Sentry: conf.Steam.Sentry, LoginKey: conf.Steam.LoginKey, Db: db, Worker: conf.Parser.Worker, ApiKey: conf.Steam.APIKey, RateLimit: rL, Cache: rdc, SprayTimeout: conf.Csgowtfd.SprayTimeout, RetryTimeout: conf.Steam.MaxRetryWait, }) if err != nil { log.Fatalf("Error setting up DemoLoader: %v", err) } // start housekeeper go housekeeping() r := gin.New() r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { return fmt.Sprintf("%s - - \"%s %s %s\" %d %d \"%s\" \"%s\" %s %s\n", utils.RealIP(¶m.Request.Header, param.Request.RemoteAddr), param.Method, param.Path, param.Request.Proto, param.StatusCode, param.BodySize, param.Request.Header.Get("Referer"), param.Request.UserAgent(), param.Latency, strings.Trim(param.ErrorMessage, "\n"), ) }), gin.Recovery()) config := cors.DefaultConfig() config.AllowOrigins = conf.Httpd.CORSAllowDomains r.Use(cors.New(config)) r.GET("/player/:id", getPlayer) r.GET("/player/:id/next/:time", getPlayer) r.GET("/player/:id/meta/:limit", getPlayerMeta) r.GET("/player/:id/meta", getPlayerMeta) r.POST("/player/:id/track", postPlayerTrack) r.DELETE("/player/:id/track", deletePlayerTrack) r.GET("/match/parse/:sharecode", getMatchParse) r.GET("/match/:id", getMatch) r.GET("/match/:id/weapons", getMatchWeapons) r.GET("/match/:id/rounds", getMatchRounds) r.GET("/match/:id/chat", getMatchChat) r.GET("/matches", getMatches) r.GET("/matches/next/:time", getMatches) log.Info("Start listening...") sockets := make([]net.Listener, 0) for _, l := range conf.Httpd.Listen { if l.Socket != "" { sL, err := net.Listen("unix", l.Socket) log.Infof("Listening on %s", l.Socket) if err != nil { log.Fatalf("Failure listing on socket %s: %v", l.Socket, err) } sockets = append(sockets, sL) go func() { srv := &http.Server{ ReadTimeout: time.Duration(conf.Csgowtfd.Timeout.Read) * time.Second, WriteTimeout: time.Duration(conf.Csgowtfd.Timeout.Write) * time.Second, IdleTimeout: time.Duration(conf.Csgowtfd.Timeout.Idle) * time.Second, Handler: r, } _ = srv.Serve(sL) }() } else { log.Infof("Listening on %s:%d", l.Host, l.Port) tL, err := net.Listen("tcp", fmt.Sprintf("%s:%d", l.Host, l.Port)) if err != nil { log.Fatalf("Failure listing on %s:%d: %v", l.Host, l.Port, err) } go func() { srv := &http.Server{ ReadTimeout: time.Duration(conf.Csgowtfd.Timeout.Read) * time.Second, WriteTimeout: time.Duration(conf.Csgowtfd.Timeout.Write) * time.Second, IdleTimeout: time.Duration(conf.Csgowtfd.Timeout.Idle) * time.Second, Handler: r, } err = srv.Serve(tL) if err != nil { log.Fatalf("Failure serving on %s:%d: %v", l.Host, l.Port, err) } }() } } killLoop: for { select { case <-killSignals: break killLoop case <-reloadSignals: confStr, err := os.ReadFile(*configFlag) if err != nil { log.Fatalf("Unable to open config: %v", err) } err = yaml.Unmarshal(confStr, &conf) if err != nil { log.Fatalf("Unable to parse config: %v", err) } lvl, err := log.ParseLevel(conf.Logging.Level) if err != nil { log.Fatalf("Failure setting logging level: %v", err) } log.SetLevel(lvl) } } for _, s := range sockets { _ = s.Close() } }