Files
csgowtfd/main.go
2021-10-15 01:24:54 +02:00

661 lines
19 KiB
Go

package main
import (
"context"
"csgowtfd/csgo"
"csgowtfd/ent"
"csgowtfd/ent/match"
"csgowtfd/ent/migrate"
"csgowtfd/ent/player"
"csgowtfd/ent/stats"
"csgowtfd/utils"
"entgo.io/ent/dialect/sql"
"flag"
"fmt"
"github.com/go-redis/cache/v8"
"github.com/go-redis/redis/v8"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
_ "github.com/mattn/go-sqlite3"
log "github.com/sirupsen/logrus"
"github.com/wercker/journalhook"
"go.uber.org/ratelimit"
"gopkg.in/yaml.v3"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
)
var (
conf = utils.Conf{}
demoLoader = &csgo.DemoMatchLoader{}
router *mux.Router
db *utils.DBWithLock
rdb *redis.Client
rdc *cache.Cache
firstHK = true
rL ratelimit.Limiter
configFlag = flag.String("config", "config.yaml", "Set config to use")
authCodeFlag = flag.String("authcode", "", "Provide Steam AuthCode to login")
journalLogFlag = flag.Bool("journal", false, "Log to systemd journal instead of stdout")
)
type PlayerResponse struct {
SteamID64 uint64 `json:"steamid64,string"`
Name string `json:"name"`
Avatar string `json:"avatar"`
VAC bool `json:"vac"`
VACDate time.Time `json:"vac_date,omitempty"`
Tracked bool `json:"tracked"`
VanityURL string `json:"vanity_url,omitempty"`
MatchStats utils.MatchStats `json:"match_stats,omitempty"`
Matches []*MatchResponse `json:"matches,omitempty"`
}
type MatchResponse struct {
MatchId uint64 `json:"match_id,string"`
ShareCode string `json:"share_code,omitempty"`
Map string `json:"map"`
Date time.Time `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"`
Stats interface{} `json:"stats,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 struct {
Old int `json:"old,omitempty"`
New int `json:"new,omitempty"`
} `json:"rank,omitempty"`
MultiKills struct {
Duo uint `json:"duo,omitempty"`
Triple uint `json:"triple,omitempty"`
Quad uint `json:"quad,omitempty"`
Pent uint `json:"pent,omitempty"`
} `json:"multi_kills,omitempty"`
Dmg struct {
Enemy uint `json:"enemy,omitempty"`
Team uint `json:"team,omitempty"`
UD struct {
HE uint `json:"he,omitempty"`
Flames uint `json:"flames,omitempty"`
Flash uint `json:"flash,omitempty"`
Decoy uint `json:"decoy,omitempty"`
Smoke uint `json:"smoke,omitempty"`
} `json:"ud,omitempty"`
HitGroup struct {
Head uint `json:"head,omitempty"`
Chest uint `json:"chest,omitempty"`
Stomach uint `json:"stomach,omitempty"`
LeftArm uint `json:"left_arm,omitempty"`
RightArm uint `json:"right_arm,omitempty"`
LeftLeg uint `json:"left_leg,omitempty"`
RightLeg uint `json:"right_leg,omitempty"`
Gear uint `json:"gear,omitempty"`
} `json:"hit_group,omitempty"`
} `json:"dmg,omitempty"`
Flash struct {
Duration struct {
Self float32 `json:"self,omitempty"`
Team float32 `json:"team,omitempty"`
Enemy float32 `json:"enemy,omitempty"`
} `json:"duration,omitempty"`
Total struct {
Team uint `json:"team,omitempty"`
Enemy uint `json:"enemy,omitempty"`
Self uint `json:"self,omitempty"`
} `json:"total,omitempty"`
} `json:"flash,omitempty"`
Crosshair string `json:"crosshair,omitempty"`
Color string `json:"color,omitempty"`
KAST int `json:"kast,omitempty"`
}
func housekeeping() {
for {
if !firstHK {
time.Sleep(5 * time.Minute)
}
firstHK = false
// update players from steam
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)
}
// getting new sharecodes
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 {
err := demoLoader.LoadDemo(&csgo.Demo{
ShareCode: code,
})
if err != nil {
log.Warningf("[HK] Failure to queue match: %v", err)
}
}
}
// try parsing demos not parsed
db.Lock.RLock()
tMatches, err := db.Client.Match.Query().Where(match.And(match.DateGT(time.Now().UTC().AddDate(0, 0, -30)), match.DemoParsed(false))).All(context.Background())
db.Lock.RUnlock()
if err != nil {
log.Warningf("[HK] Failure getting matches to retry parsing: %v", err)
continue
}
for _, m := range tMatches {
log.Infof("[HK] Try reparsing match %d, played on %s", m.ID, m.Date)
err := demoLoader.LoadDemo(&csgo.Demo{MatchId: m.ID, ShareCode: m.ShareCode})
if err != nil {
log.Warningf("[HK] Failure trying to parse match %d: %v", m.ID, err)
}
}
}
}
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.ID,
Name: tPlayer.Name,
Avatar: tPlayer.AvatarURL,
VAC: tPlayer.Vac,
VanityURL: tPlayer.VanityURLReal,
Tracked: tPlayer.AuthCode != "",
Matches: []*MatchResponse{},
}
if !tPlayer.VacDate.IsZero() {
response.VACDate = tPlayer.VacDate
}
db.Lock.RLock()
tMatches, err := tPlayer.QueryMatches().Order(ent.Desc(match.FieldDate)).Limit(10).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)
return
}
}
metaStats := new(utils.MatchStats)
err = rdc.Get(context.Background(), fmt.Sprintf("csgowtfd_meta_%d", tPlayer.ID), &metaStats)
if err != nil {
wins, ties, losses, err := utils.GetMatchStats(db.Client, db.Lock, tPlayer)
if err != nil {
log.Errorf("[GP] Error retrieving match-stats for player %s: %v", id, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
response.MatchStats.Win = wins
response.MatchStats.Tie = ties
response.MatchStats.Loss = losses
err = rdc.Set(&cache.Item{
Ctx: context.Background(),
Key: fmt.Sprintf("csgowtfd_meta_%d", tPlayer.ID),
Value: response.MatchStats,
TTL: time.Hour * 12,
})
if err != nil {
log.Errorf("[GP] Failure saving to cache: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Debugf("[GP] Metastats for %d saved to cache", tPlayer.ID)
} else {
log.Debugf("[GP] Metastats for %d from redis", tPlayer.ID)
response.MatchStats.Win = metaStats.Win
response.MatchStats.Tie = metaStats.Tie
response.MatchStats.Loss = metaStats.Loss
}
for _, iMatch := range tMatches {
mResponse := &MatchResponse{
MatchId: iMatch.ID,
Map: iMatch.Map,
Date: iMatch.Date,
Score: [2]int{iMatch.ScoreTeamA, iMatch.ScoreTeamB},
Duration: iMatch.Duration,
MatchResult: iMatch.MatchResult,
MaxRounds: iMatch.MaxRounds,
Parsed: iMatch.DemoParsed,
}
db.Lock.RLock()
tStats, err := iMatch.QueryStats().Modify(func(s *sql.Selector) {
s.Select(stats.FieldTeamID, stats.FieldKills, stats.FieldDeaths, stats.FieldAssists, stats.FieldHeadshot,
stats.FieldMvp, stats.FieldScore, stats.FieldMk2, stats.FieldMk3, stats.FieldMk4, stats.FieldMk5,
stats.FieldRankOld, stats.FieldRankNew, stats.FieldDmgTeam, stats.FieldDmgEnemy)
s.Where(sql.EQ(s.C(stats.PlayersColumn), tPlayer.ID))
}).Only(context.Background())
db.Lock.RUnlock()
if err != nil {
response.Matches = append(response.Matches, mResponse)
continue
}
sResponse := &StatsResponse{
TeamID: tStats.TeamID,
Kills: tStats.Kills,
Deaths: tStats.Deaths,
Assists: tStats.Assists,
Headshot: tStats.Headshot,
MVP: tStats.Mvp,
Score: tStats.Score,
}
sResponse.MultiKills.Duo = tStats.Mk2
sResponse.MultiKills.Triple = tStats.Mk3
sResponse.MultiKills.Quad = tStats.Mk4
sResponse.MultiKills.Pent = tStats.Mk5
sResponse.Rank.Old = tStats.RankOld
sResponse.Rank.New = tStats.RankNew
sResponse.Dmg.Enemy = tStats.DmgEnemy
sResponse.Dmg.Team = tStats.DmgTeam
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) {
err := demoLoader.LoadDemo(&csgo.Demo{ShareCode: shareCode})
if err != nil {
log.Warningf("[PPTM] unable to queue match: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
}
w.WriteHeader(http.StatusAccepted)
}
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
}
err := demoLoader.LoadDemo(&csgo.Demo{
ShareCode: shareCode,
})
if err != nil {
log.Warningf("[PPTM] unable to queue match: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
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.ID(matchId)).Only(context.Background())
db.Lock.RUnlock()
if err != nil {
log.Warningf("[GM] match %d not found: %+v", matchId, err)
w.WriteHeader(http.StatusNotFound)
return
}
mResponse := &MatchResponse{
MatchId: tMatch.ID,
ShareCode: tMatch.ShareCode,
Map: tMatch.Map,
Date: tMatch.Date,
Score: [2]int{tMatch.ScoreTeamA, tMatch.ScoreTeamB},
Duration: tMatch.Duration,
MatchResult: tMatch.MatchResult,
MaxRounds: 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.ID, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
tmpStats := make([]*StatsResponse, 0)
for _, iStats := range tStats {
sResponse := &StatsResponse{
Player: PlayerResponse{
SteamID64: iStats.Edges.Players.ID,
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,
}
sResponse.Color = iStats.Color.String()
sResponse.Crosshair = iStats.Crosshair
sResponse.KAST = iStats.Kast
sResponse.Dmg.Team = iStats.DmgTeam
sResponse.Dmg.Enemy = iStats.DmgEnemy
sResponse.Dmg.UD.HE = iStats.UdHe
sResponse.Dmg.UD.Smoke = iStats.UdSmoke
sResponse.Dmg.UD.Flash = iStats.UdFlash
sResponse.Dmg.UD.Decoy = iStats.UdDecoy
sResponse.Dmg.UD.Flames = iStats.UdFlames
sResponse.Dmg.HitGroup.Gear = iStats.HitGroupGear
sResponse.Dmg.HitGroup.LeftLeg = iStats.HitGroupLeftLeg
sResponse.Dmg.HitGroup.RightLeg = iStats.HitGroupRightLeg
sResponse.Dmg.HitGroup.RightArm = iStats.HitGroupRightArm
sResponse.Dmg.HitGroup.LeftArm = iStats.HitGroupLeftArm
sResponse.Dmg.HitGroup.Stomach = iStats.HitGroupStomach
sResponse.Dmg.HitGroup.Chest = iStats.HitGroupChest
sResponse.Dmg.HitGroup.Head = iStats.HitGroupHead
sResponse.Rank.Old = iStats.RankOld
sResponse.Rank.New = iStats.RankNew
sResponse.Flash.Total.Enemy = iStats.FlashTotalEnemy
sResponse.Flash.Total.Team = iStats.FlashTotalTeam
sResponse.Flash.Total.Self = iStats.FlashTotalSelf
sResponse.Flash.Duration.Enemy = iStats.FlashDurationEnemy
sResponse.Flash.Duration.Team = iStats.FlashDurationTeam
sResponse.Flash.Duration.Self = iStats.FlashDurationSelf
sResponse.MultiKills.Duo = iStats.Mk2
sResponse.MultiKills.Triple = iStats.Mk3
sResponse.MultiKills.Quad = iStats.Mk4
sResponse.MultiKills.Pent = iStats.Mk5
if !iStats.Edges.Players.VacDate.IsZero() {
switch s := sResponse.Player.(type) {
case PlayerResponse:
s.VACDate = iStats.Edges.Players.VacDate
}
}
tmpStats = append(tmpStats, sResponse)
}
mResponse.Stats = tmpStats
err = utils.SendJSON(mResponse, w)
if err != nil {
log.Errorf("[GM] JSON: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
}
}
/*
/player/<id> GET player internal or if not found: steamAPI data + overall stats
/player/trackme POST id, authcode, [sharecode]
/match/<id> GET CSGO-GC response + internal data if parsed <- may be big (ALL RELEVANT DATA)
/match/parse/<sharecode> GET parses sharecode provided
*/
func main() {
killSignals := make(chan os.Signal, 1)
signal.Notify(killSignals, syscall.SIGINT, syscall.SIGTERM)
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()
}
db = &utils.DBWithLock{
Lock: new(sync.RWMutex),
}
db.Client, 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(dbSQLite *ent.Client) {
_ = 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)
}
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 = ratelimit.New(conf.Steam.RatePerSecond)
// setup GC
err = demoLoader.Setup(&csgo.DemoMatchLoaderConfig{
Username: conf.Steam.Username,
Password: conf.Steam.Password,
AuthCode: *authCodeFlag,
Sentry: conf.Steam.Sentry,
LoginKey: conf.Steam.LoginKey,
ServerList: conf.Steam.ServerList,
Db: db.Client,
Lock: db.Lock,
Worker: conf.Parser.Worker,
ApiKey: conf.Steam.APIKey,
RateLimit: rL,
})
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")
go housekeeping()
// Define routes
router = mux.NewRouter().StrictSlash(true)
router.HandleFunc("/player/{id}", getPlayer).Methods(http.MethodGet, http.MethodOptions)
router.HandleFunc("/player/trackme", postPlayerTrackMe).Methods(http.MethodPost, http.MethodOptions)
router.HandleFunc("/match/parse/{sharecode}", getMatchParse).Methods(http.MethodGet, http.MethodOptions)
router.HandleFunc("/match/{id:[0-9]{19}}", getMatch).Methods(http.MethodGet, http.MethodOptions)
router.Use(mux.CORSMethodMiddleware(router))
loggedRouter := handlers.LoggingHandler(os.Stdout, router)
proxyRouter := handlers.ProxyHeaders(loggedRouter)
sockets := make([]net.Listener, 0)
for _, l := range conf.Httpd.Listen {
if l.Socket != "" {
sL, err := net.Listen("unix", l.Socket)
if err != nil {
log.Fatalf("Failure listing on socket %s: %v", l.Socket, err)
}
sockets = append(sockets, sL)
go func() {
_ = http.Serve(sL, proxyRouter)
}()
} else {
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() {
err = http.Serve(tL, proxyRouter)
if err != nil {
log.Fatalf("Failure serving on %s:%d: %v", l.Host, l.Port, err)
}
}()
}
}
<-killSignals
for _, s := range sockets {
_ = s.Close()
}
}