package utils import ( "context" "csgowtfd/ent" "csgowtfd/ent/match" "csgowtfd/ent/matchplayer" "csgowtfd/ent/player" "csgowtfd/ent/roundstats" "csgowtfd/ent/spray" "csgowtfd/ent/weapon" "encoding/json" "entgo.io/ent/dialect/sql" "errors" "fmt" "github.com/an0nfunc/go-steamapi" log "github.com/sirupsen/logrus" "go.uber.org/ratelimit" "io" "io/ioutil" "net/http" "path" "regexp" "sort" "strconv" "strings" "time" ) type Conf struct { Logging struct { Level string } Db struct { Driver string ConnectTo string `yaml:"connect_to"` } Parser struct { Worker int } Steam struct { Username string Password string AuthCode string `yaml:"auth_code"` APIKey string `yaml:"api_key"` RatePerSecond int `yaml:"rate_per_sec"` Sentry string LoginKey string `yaml:"login_key"` } Redis struct { Address string Password string } Httpd struct { CORSAllowDomains []string `yaml:"cors_allow_domains"` Listen []struct { Socket string Host string Port int } } Csgowtfd struct { ProfileUpdate string `yaml:"profile_update"` SharecodeUpdate string `yaml:"sharecode_update"` DemosExpire int `yaml:"demos_expire"` SprayTimeout int `yaml:"spray_timeout"` } } type CommunityXML struct { SteamID64 uint64 `xml:"steamID64"` AvatarURL string `xml:"avatarFull"` VacBanned bool `xml:"vacBanned"` ProfileName string `xml:"steamID"` Error string `xml:"error"` VanityURL string `xml:"customURL"` } type ShareCodeResponse struct { Result struct { Code string `json:"nextcode"` } `json:"result"` } type MatchStats struct { Win int `json:"win,omitempty"` Tie int `json:"tie,omitempty"` Loss int `json:"loss,omitempty"` } type MultiKills struct { Duo uint `json:"duo,omitempty"` Triple uint `json:"triple,omitempty"` Quad uint `json:"quad,omitempty"` Pent uint `json:"pent,omitempty"` } type Rank struct { Old int `json:"old,omitempty"` New int `json:"new,omitempty"` } type Damage struct { Enemy uint `json:"enemy,omitempty"` Team uint `json:"team,omitempty"` } type SelfTeamEnemy struct { Self interface{} `json:"self,omitempty"` Team interface{} `json:"team,omitempty"` Enemy interface{} `json:"enemy,omitempty"` } type Flash struct { Duration *SelfTeamEnemy `json:"duration,omitempty"` Total *SelfTeamEnemy `json:"total,omitempty"` } 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 uint `json:"mvp"` Score int `json:"score"` Player interface{} `json:"player,omitempty"` Rank *Rank `json:"rank,omitempty"` MultiKills *MultiKills `json:"multi_kills,omitempty"` Dmg *Damage `json:"dmg,omitempty"` Flash *Flash `json:"flash,omitempty"` Crosshair string `json:"crosshair,omitempty"` Color string `json:"color,omitempty"` KAST int `json:"kast,omitempty"` } type PlayerResponse struct { SteamID64 uint64 `json:"steamid64,string"` Name string `json:"name,omitempty"` Avatar string `json:"avatar,omitempty"` VAC bool `json:"vac"` VACDate int64 `json:"vac_date,omitempty"` GameBan bool `json:"game_ban"` GameBanDate int64 `json:"game_ban_date,omitempty"` Tracked bool `json:"tracked"` VanityURL string `json:"vanity_url,omitempty"` MatchStats *MatchStats `json:"match_stats,omitempty"` Matches []*MatchResponse `json:"matches,omitempty"` } type MateResponse struct { Player *PlayerResponse `json:"player"` WinRate float32 `json:"win_rate,omitempty"` TieRate float32 `json:"tie_rate,omitempty"` Total int `json:"total,omitempty"` } type ChatResponse struct { Player *PlayerResponse `json:"player,omitempty"` Message string `json:"message"` AllChat bool `json:"teamChat"` Tick int `json:"tick"` } type WeaponDmg struct { Eq int `json:"eq"` Dmg uint `json:"dmg"` } type MetaStatsResponse struct { Player *PlayerResponse `json:"player"` BestMates []*MateResponse `json:"best_mates,omitempty"` MostMates []*MateResponse `json:"most_mates,omitempty"` EqMap map[int]string `json:"eq_map,omitempty"` WeaponDmg []*WeaponDmg `json:"weapon_dmg,omitempty"` WinMaps map[string]float32 `json:"win_maps,omitempty"` TieMaps map[string]float32 `json:"tie_maps,omitempty"` TotalMaps map[string]int `json:"total_maps,omitempty"` } type MatchResponse struct { MatchId uint64 `json:"match_id,string"` ShareCode string `json:"share_code,omitempty"` Map string `json:"map"` Date int64 `json:"date"` Score [2]int `json:"score"` Duration int `json:"duration"` MatchResult int `json:"match_result"` MaxRounds int `json:"max_rounds,omitempty"` Parsed bool `json:"parsed"` ReplayURL string `json:"replay_url,omitempty"` VAC bool `json:"vac"` GameBan bool `json:"game_ban"` Stats interface{} `json:"stats,omitempty"` AvgRank float64 `json:"avg_rank,omitempty"` } type ( AuthcodeUnauthorizedError struct { error } AuthcodeRateLimitError struct { error } SharecodeNoMatchError struct { error } ) const ( shareCodeURLEntry = "https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1?key=%s&steamid=%d&steamidkey=%s&knowncode=%s" SideMetaCacheKey = "csgowtfd_side_meta_%d" ) //goland:noinspection SpellCheckingInspection var ( SteamId64RegEx = regexp.MustCompile(`^\d{17}$`) ShareCodeRegEx = regexp.MustCompile(`^CSGO(?:-?[ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789]{5}){5}$`) AuthCodeRegEx = regexp.MustCompile(`^[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{5}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}$`) ) func SendJSON(data interface{}, w http.ResponseWriter) error { w.Header().Set("content-type", "application/json") playerJson, err := json.Marshal(data) if err != nil { return err } w.WriteHeader(http.StatusOK) _, err = w.Write(playerJson) if err != nil { return err } return nil } func GetMetaStats(dbPlayer *ent.Player) (*MetaStatsResponse, error) { mResponse := new(MetaStatsResponse) mResponse.Player = &PlayerResponse{SteamID64: dbPlayer.ID} tPlayers, err := dbPlayer.QueryMatches().QueryPlayers().Select(player.FieldID).All(context.Background()) if err != nil { return nil, err } matchIDs, err := dbPlayer.QueryMatches().IDs(context.Background()) if err != nil { return nil, err } mapWins := map[string]int{} mapTies := map[string]int{} mapMatchTotal := map[string]int{} matchSeen := map[uint64]bool{} mResponse.EqMap = map[int]string{} for _, s := range tPlayers { if s.ID == dbPlayer.ID { continue } mateRes := new(MateResponse) mostRes := new(MateResponse) playerRes := &PlayerResponse{ SteamID64: s.ID, } pMatches, err := s.QueryMatches(). Select(match.FieldID, match.FieldMatchResult, match.FieldMap). Where(match.IDIn(matchIDs...)). WithStats(). Where(match.HasStatsWith(matchplayer.Or(matchplayer.PlayerStats(dbPlayer.ID), matchplayer.PlayerStats(s.ID)))). All(context.Background()) if err != nil { return nil, err } mostRes.Player = playerRes var wins int var ties int for _, pm := range pMatches { var subjectStats *ent.MatchPlayer var currentStats *ent.MatchPlayer for _, ps := range pm.Edges.Stats { if ps.PlayerStats == dbPlayer.ID { subjectStats = ps } else if ps.PlayerStats == s.ID { currentStats = ps } } win := subjectStats.TeamID == pm.MatchResult tie := pm.MatchResult == 0 if _, ok := matchSeen[pm.ID]; !ok { if pm.Map != "" { mapMatchTotal[pm.Map]++ if win { mapWins[pm.Map]++ } else if tie { mapTies[pm.Map]++ } } wSs, err := subjectStats.QueryWeaponStats(). Select(weapon.FieldEqType, weapon.FieldDmg).All(context.Background()) if err != nil { return nil, err } for _, weaponStat := range wSs { found := false for _, dmgS := range mResponse.WeaponDmg { if dmgS.Eq == weaponStat.EqType { dmgS.Dmg += weaponStat.Dmg found = true } } if !found { mResponse.WeaponDmg = append(mResponse.WeaponDmg, &WeaponDmg{ Eq: weaponStat.EqType, Dmg: weaponStat.Dmg, }) } } matchSeen[pm.ID] = true } // check if same team if subjectStats.TeamID == currentStats.TeamID { mostRes.Total++ if win { wins++ } else if tie { ties++ } } } if mostRes.Total > 0 { mResponse.MostMates = append(mResponse.MostMates, mostRes) } if mostRes.Total > 1 && (wins > 0 || ties > 0) { mateRes.Player = playerRes mateRes.TieRate = float32(ties) / float32(mostRes.Total) mateRes.WinRate = float32(wins) / float32(mostRes.Total) mateRes.Total = mostRes.Total mResponse.BestMates = append(mResponse.BestMates, mateRes) } } mResponse.TieMaps = map[string]float32{} mResponse.WinMaps = map[string]float32{} mResponse.TotalMaps = map[string]int{} for tMap, wins := range mapWins { mResponse.WinMaps[tMap] = float32(wins) / float32(mapMatchTotal[tMap]) } for tMap, ties := range mapTies { mResponse.TieMaps[tMap] = float32(ties) / float32(mapMatchTotal[tMap]) } for tMap, total := range mapMatchTotal { mResponse.TotalMaps[tMap] = total } // sort all results sort.Slice(mResponse.BestMates, func(i, j int) bool { return mResponse.BestMates[i].WinRate > mResponse.BestMates[j].WinRate }) sort.Slice(mResponse.MostMates, func(i, j int) bool { return mResponse.MostMates[i].Total > mResponse.MostMates[j].Total }) sort.Slice(mResponse.WeaponDmg, func(i, j int) bool { return mResponse.WeaponDmg[i].Dmg > mResponse.WeaponDmg[j].Dmg }) if len(mResponse.BestMates) > 10 { mResponse.BestMates = mResponse.BestMates[:10] } if len(mResponse.MostMates) > 10 { mResponse.MostMates = mResponse.MostMates[:10] } if len(mResponse.WeaponDmg) > 10 { mResponse.WeaponDmg = mResponse.WeaponDmg[:10] } return mResponse, nil } func DeleteMatch(matchDel *ent.Match, db *ent.Client) error { tMatchPlayer, err := matchDel.QueryStats().IDs(context.Background()) if err != nil { return err } _, err = db.Spray.Delete().Where(spray.HasMatchPlayersWith(matchplayer.IDIn(tMatchPlayer...))).Exec(context.Background()) if err != nil { return err } _, err = db.Weapon.Delete().Where(weapon.HasStatWith(matchplayer.IDIn(tMatchPlayer...))).Exec(context.Background()) if err != nil { return err } _, err = db.RoundStats.Delete().Where(roundstats.HasMatchPlayerWith(matchplayer.IDIn(tMatchPlayer...))).Exec(context.Background()) if err != nil { return err } _, err = db.MatchPlayer.Delete().Where(matchplayer.IDIn(tMatchPlayer...)).Exec(context.Background()) if err != nil { return err } _, err = db.Match.Delete().Where(match.ID(matchDel.ID)).Exec(context.Background()) if err != nil { return err } return nil } func GetWinLossTieForPlayer(dbPlayer *ent.Player) (wins int, looses int, ties int, err error) { var res []struct { MatchResult int `json:"match_result"` Count int `json:"count"` } err = dbPlayer.QueryMatches().GroupBy(match.FieldMatchResult).Aggregate(func(s *sql.Selector) string { sT := sql.Table(matchplayer.Table) s.Join(sT).On(s.C(match.FieldID), sT.C(matchplayer.MatchesColumn)) s.Where(sql.And( sql.Or( sql.ColumnsEQ(match.FieldMatchResult, matchplayer.FieldTeamID), sql.EQ(s.C(match.FieldMatchResult), 0), ), sql.EQ(sT.C(matchplayer.PlayersColumn), dbPlayer.ID), )) return sql.Count("*") }).Scan(context.Background(), &res) if err != nil { return } total, err := dbPlayer.QueryMatches().Modify(func(s *sql.Selector) { s.Select("COUNT(*)") }).Int(context.Background()) if err != nil { return } for _, r := range res { switch r.MatchResult { case 0: ties = r.Count case 1, 2: wins += r.Count } } looses = total - wins - ties return } func IsAuthCodeValid(player *ent.Player, apiKey string, shareCode string, authCode string, rl ratelimit.Limiter) (bool, error) { var tMatch *ent.Match var err error if shareCode == "" { tMatch, err = player.QueryMatches().Order(ent.Asc(match.FieldDate)).First(context.Background()) if err != nil { return false, err } _, err := getNextShareCode(tMatch.ShareCode, apiKey, authCode, player.ID, rl) if err != nil { return false, err } return true, nil } else { _, err := getNextShareCode(shareCode, apiKey, authCode, player.ID, rl) if err != nil { return false, err } return true, nil } } func GetNewShareCodesForPlayer(player *ent.Player, apiKey string, rl ratelimit.Limiter) ([]string, error) { latestMatch, err := player.QueryMatches().Order(ent.Desc(match.FieldDate)).First(context.Background()) if err != nil { return nil, err } oldestMatch, err := player.QueryMatches().Order(ent.Asc(match.FieldDate)).First(context.Background()) if err != nil { return nil, err } var newShareCode string if oldestMatch.ShareCode == player.OldestSharecodeSeen { newShareCode, err = getNextShareCode(latestMatch.ShareCode, apiKey, player.AuthCode, player.ID, rl) } else { newShareCode, err = getNextShareCode(oldestMatch.ShareCode, apiKey, player.AuthCode, player.ID, rl) } if err != nil { return nil, err } var rCodes []string for newShareCode != "n/a" { rCodes = append(rCodes, newShareCode) newShareCode, err = getNextShareCode(rCodes[len(rCodes)-1], apiKey, player.AuthCode, player.ID, rl) if err != nil { return nil, err } } err = player.Update().SetSharecodeUpdated(time.Now().UTC()).SetOldestSharecodeSeen(oldestMatch.ShareCode).Exec(context.Background()) if err != nil { return nil, err } return rCodes, nil } func getNextShareCode(lastCode string, apiKey string, authCode string, steamId uint64, rl ratelimit.Limiter) (string, error) { if lastCode == "" || apiKey == "" || authCode == "" || steamId == 0 { return "", fmt.Errorf("invalid arguments") } if rl != nil { rl.Take() } log.Debugf("[SC] STEAMPI with %s", fmt.Sprintf(shareCodeURLEntry, "REDACTED", steamId, "REDACTED", lastCode)) r, err := http.Get(fmt.Sprintf(shareCodeURLEntry, apiKey, steamId, authCode, lastCode)) if err != nil { return "", err } switch r.StatusCode { case http.StatusAccepted: return "n/a", nil case http.StatusTooManyRequests: return "", AuthcodeRateLimitError{errors.New("temporary ratelimited")} case http.StatusServiceUnavailable: return "", AuthcodeRateLimitError{errors.New("temporary unavailable")} case http.StatusPreconditionFailed: return "", SharecodeNoMatchError{errors.New("sharecode not from player history")} case http.StatusForbidden: return "", AuthcodeUnauthorizedError{errors.New("authcode unauthorized")} case http.StatusOK: break default: return "", errors.New("temporary steamapi error") } defer func(Body io.ReadCloser) { _ = Body.Close() }(r.Body) bJson, err := ioutil.ReadAll(r.Body) if err != nil { return "", err } rJson := new(ShareCodeResponse) err = json.Unmarshal(bJson, rJson) if err != nil { return "", err } return rJson.Result.Code, nil } func Player(db *ent.Client, id interface{}, apiKey string, rl ratelimit.Limiter) (*ent.Player, error) { switch e := id.(type) { case uint64: return PlayerFromSteamID64(db, e, apiKey, rl) case string: if SteamId64RegEx.MatchString(e) { steamID64, err := strconv.ParseUint(e, 10, 64) if err != nil { return nil, err } return PlayerFromSteamID64(db, steamID64, apiKey, rl) } return PlayerFromVanityURL(db, e, apiKey, rl) default: return nil, fmt.Errorf("invalid arguments") } } func PlayerFromVanityURL(db *ent.Client, id string, apiKey string, rl ratelimit.Limiter) (*ent.Player, error) { if id == "" { return nil, fmt.Errorf("invalid arguments") } tPlayer, err := db.Player.Query().Where(player.VanityURL(strings.ToLower(id))).Only(context.Background()) if err == nil { return tPlayer, nil } else { if rl != nil { rl.Take() } resp, err := steamapi.ResolveVanityURL(id, apiKey) if err != nil { return nil, err } if resp.Success != 1 { return nil, fmt.Errorf("vanity url not found") } nPlayer, err := PlayerFromSteamID64(db, resp.SteamID, apiKey, rl) if err != nil { return nil, err } return nPlayer, nil } } func PlayerFromSteamID64(db *ent.Client, steamID uint64, apiKey string, rl ratelimit.Limiter) (*ent.Player, error) { tPlayer, err := db.Player.Get(context.Background(), steamID) if err == nil { return tPlayer, nil } else { nPlayer := &ent.Player{ID: steamID} uPlayer, err := PlayerFromSteam([]*ent.Player{nPlayer}, nil, apiKey, rl) if err != nil { return nil, err } if len(uPlayer) > 0 { nPlayer, err = db.Player.Create(). SetID(steamID). SetName(uPlayer[0].Name). SetAvatar(uPlayer[0].Avatar). SetSteamUpdated(uPlayer[0].SteamUpdated). SetVanityURL(uPlayer[0].VanityURL). SetVanityURLReal(uPlayer[0].VanityURLReal). SetProfileCreated(uPlayer[0].ProfileCreated). SetGameBanCount(uPlayer[0].GameBanCount). SetVacCount(uPlayer[0].VacCount). SetVacDate(uPlayer[0].VacDate). SetGameBanDate(uPlayer[0].GameBanDate). Save(context.Background()) if err != nil { return nil, err } return nPlayer, nil } return nil, fmt.Errorf("player %d not found", steamID) } } func PlayerFromSteam(players []*ent.Player, db *ent.Client, apiKey string, rl ratelimit.Limiter) ([]*ent.Player, error) { var idsToUpdate []uint64 for _, updatePlayer := range players { idsToUpdate = append(idsToUpdate, updatePlayer.ID) } if rl != nil { rl.Take() } playerSum, err := steamapi.GetPlayerSummaries(idsToUpdate, apiKey) if err != nil { return nil, err } // TODO: what happens if a player deleted their profile? var nPlayers []*ent.Player for _, pS := range playerSum { // check for vanityURL if SteamId64RegEx.MatchString(path.Base(pS.ProfileURL)) { pS.ProfileURL = "" } else { pS.ProfileURL = path.Base(pS.ProfileURL) } var tPlayer *ent.Player if db != nil { tPlayer, err = db.Player.UpdateOneID(pS.SteamID). SetName(pS.PersonaName). SetAvatar(pS.AvatarHash). SetVanityURL(strings.ToLower(pS.ProfileURL)). SetVanityURLReal(pS.ProfileURL). SetSteamUpdated(time.Now().UTC()). SetProfileCreated(time.Unix(pS.TimeCreated, 0).UTC()). Save(context.Background()) if err != nil { return nil, err } } else { tPlayer = &ent.Player{ ID: pS.SteamID, Name: pS.PersonaName, Avatar: pS.AvatarHash, VanityURL: strings.ToLower(pS.ProfileURL), VanityURLReal: pS.ProfileURL, SteamUpdated: time.Now().UTC(), ProfileCreated: time.Unix(pS.TimeCreated, 0), } } nPlayers = append(nPlayers, tPlayer) } if rl != nil { rl.Take() } bans, err := steamapi.GetPlayerBans(idsToUpdate, apiKey) if err != nil { return nil, err } for _, ban := range bans { if ban.VACBanned || ban.NumberOfGameBans > 0 { banDate := time.Now().UTC().AddDate(0, 0, -1*int(ban.DaysSinceLastBan)) if db != nil && ban.VACBanned { err := db.Player.UpdateOneID(ban.SteamID).SetVacCount(int(ban.NumberOfVACBans)).SetVacDate(banDate).Exec(context.Background()) if err != nil { return nil, err } } if db != nil && ban.NumberOfGameBans > 0 { err := db.Player.UpdateOneID(ban.SteamID).SetGameBanCount(int(ban.NumberOfGameBans)).SetGameBanDate(banDate).Exec(context.Background()) if err != nil { return nil, err } } for _, p := range nPlayers { if p.ID == ban.SteamID { if ban.NumberOfGameBans > 0 { p.GameBanCount = int(ban.NumberOfGameBans) p.GameBanDate = banDate } if ban.VACBanned { p.VacCount = int(ban.NumberOfVACBans) p.GameBanDate = banDate } } } } } return nPlayers, nil }