432 lines
11 KiB
Go
432 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.lehouerou.net/laurent/sorare"
|
|
"git.lehouerou.net/laurent/sorare/football"
|
|
"git.lehouerou.net/laurent/sorare/types"
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
"github.com/pkg/errors"
|
|
"github.com/robfig/cron/v3"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/samber/lo"
|
|
"github.com/sourcegraph/conc"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
"github.com/uptrace/bun"
|
|
|
|
"git.lehouerou.net/laurent/sorarebuddy/cmd/common"
|
|
"git.lehouerou.net/laurent/sorarebuddy/db"
|
|
"git.lehouerou.net/laurent/sorarebuddy/model"
|
|
"git.lehouerou.net/laurent/sorarebuddy/sorare_utils"
|
|
)
|
|
|
|
var (
|
|
//go:embed all:dist
|
|
dist embed.FS
|
|
|
|
//go:embed dist/index.html
|
|
indexHTML embed.FS
|
|
distDirFS = echo.MustSubFS(dist, "dist/assets")
|
|
distIndexHtml = echo.MustSubFS(indexHTML, "dist")
|
|
)
|
|
|
|
var Cmd = &cobra.Command{
|
|
Use: "console",
|
|
Short: "console",
|
|
Long: `console`,
|
|
RunE: run,
|
|
PersistentPreRunE: common.CmdPreRunE,
|
|
}
|
|
|
|
func main() {
|
|
common.Start(Cmd)
|
|
}
|
|
|
|
func init() {
|
|
common.InitParams(Cmd)
|
|
|
|
Cmd.PersistentFlags().Bool("devmode", false, "Dev mode")
|
|
_ = viper.BindPFlag("devmode", Cmd.PersistentFlags().Lookup("devmode"))
|
|
viper.SetDefault("devmode", false)
|
|
}
|
|
|
|
func errorResponse(err error) map[string]interface{} {
|
|
return map[string]interface{}{"error": err.Error()}
|
|
}
|
|
|
|
func run(cmd *cobra.Command, _ []string) error {
|
|
var wg conc.WaitGroup
|
|
|
|
ctx := cmd.Context()
|
|
dbconn, ok := ctx.Value(common.DbContextKey).(*bun.DB)
|
|
if !ok {
|
|
return errors.New("db not found in context")
|
|
}
|
|
|
|
sorareClient, ok := ctx.Value(common.SorareContextKey).(*sorare.Sorare)
|
|
if !ok {
|
|
return errors.New("sorare not found in context")
|
|
}
|
|
|
|
devmode := viper.GetBool("devmode")
|
|
|
|
dbclient := db.NewClient(dbconn)
|
|
|
|
e := echo.New()
|
|
e.FileFS("/*", "index.html", distIndexHtml)
|
|
e.StaticFS("/assets", distDirFS)
|
|
|
|
e.Use(middleware.Recover())
|
|
e.HTTPErrorHandler = func(err error, c echo.Context) {
|
|
statusCode := http.StatusInternalServerError
|
|
if he, ok := err.(*echo.HTTPError); ok {
|
|
statusCode = he.Code
|
|
}
|
|
|
|
log.Error().
|
|
Err(err).
|
|
Str("method", c.Request().Method).
|
|
Str("url", c.Request().URL.String()).
|
|
Int("status", statusCode).
|
|
Msg("HTTP error encountered")
|
|
|
|
if statusCode == http.StatusInternalServerError {
|
|
if err := c.JSON(statusCode, errorResponse(errors.New("internal server error"))); err != nil {
|
|
log.Error().Err(err).Msg("error while sending error response")
|
|
}
|
|
} else {
|
|
if err := c.JSON(statusCode, errorResponse(err)); err != nil {
|
|
log.Error().Err(err).Msg("error while sending error response")
|
|
}
|
|
}
|
|
}
|
|
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
start := time.Now()
|
|
err := next(c)
|
|
latency := time.Since(start)
|
|
|
|
latencyDisplay := fmt.Sprintf("%dms", latency.Milliseconds())
|
|
if latency > time.Second {
|
|
latencyDisplay = fmt.Sprintf("%.2fs", latency.Seconds())
|
|
}
|
|
clientIP := c.RealIP()
|
|
|
|
log.Info().Msgf(
|
|
"%d %4s | %s | %6s | <- %6s | -> %6s | %s",
|
|
c.Response().Status,
|
|
c.Request().Method,
|
|
clientIP,
|
|
latencyDisplay,
|
|
humanize.Bytes(uint64(c.Request().ContentLength)),
|
|
humanize.Bytes(uint64(c.Response().Size)),
|
|
c.Request().RequestURI,
|
|
)
|
|
|
|
return err
|
|
}
|
|
})
|
|
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
|
Level: 5,
|
|
}))
|
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
|
AllowOrigins: []string{"https://www.sorareplus.xyz", "http://localhost:5173"},
|
|
}))
|
|
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
|
XFrameOptions: "DENY",
|
|
ContentTypeNosniff: "nosniff",
|
|
XSSProtection: "1; mode=block",
|
|
ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://assets.sorare.com https://frontend-assets.sorare.com; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'",
|
|
HSTSMaxAge: 31536000,
|
|
ReferrerPolicy: "no-referrer",
|
|
}))
|
|
|
|
s := &ApiServer{
|
|
sorare: sorareClient,
|
|
db: dbclient,
|
|
e: e,
|
|
}
|
|
|
|
api := e.Group("api")
|
|
|
|
api.POST("/search/multi", s.SearchMulti)
|
|
|
|
fixture := api.Group("/fixture")
|
|
fixture.GET("/all", s.GetAllFixtures)
|
|
fixture.GET("/:slug/games", s.GetFixtureGames)
|
|
|
|
player := api.Group("/player")
|
|
player.GET("/:slug", s.GetPlayer)
|
|
player.GET("/:slug/games", s.GetPlayerGames)
|
|
|
|
team := api.Group("/team")
|
|
team.GET("/:slug", s.GetTeam)
|
|
team.GET("/:slug/roster", s.GetTeamRoster)
|
|
team.GET("/:slug/memberships", s.GetTeamMemberships)
|
|
|
|
api.GET("/zone/all", s.GetAllZones)
|
|
api.GET("/competition/club", s.GetAllClubCompetitions)
|
|
api.POST("/rankings/single", s.SingleRankings)
|
|
api.GET("/user/:slug/cards", s.GetUserCards)
|
|
|
|
wg.Go(func() {
|
|
defer func() {
|
|
log.Debug().Msg("http server stopped")
|
|
}()
|
|
err := e.Start(":8080")
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Error().Err(err).Msg("running api http server")
|
|
}
|
|
})
|
|
|
|
c := cron.New()
|
|
if !devmode {
|
|
_, err := c.AddFunc("0 6 * * *", func() {
|
|
us := sorare_utils.NewUpdateService(sorareClient, db.NewClient(dbconn))
|
|
err := us.UpdateLastClosedStartedAndOpenedFixtures(ctx)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("syncing database")
|
|
}
|
|
})
|
|
if err != nil {
|
|
return errors.Wrap(err, "adding database sync job")
|
|
}
|
|
|
|
var updateMutex sync.Mutex
|
|
_, err = c.AddFunc("*/1 * * * *", func() {
|
|
if updateMutex.TryLock() {
|
|
defer updateMutex.Unlock()
|
|
us := sorare_utils.NewUpdateService(sorareClient, db.NewClient(dbconn))
|
|
err := us.UpdateCurrentlyPlayingGames(ctx)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("syncing database")
|
|
}
|
|
} else {
|
|
log.Info().Msg("Previous update still running, skipping this cycle.")
|
|
}
|
|
})
|
|
if err != nil {
|
|
return errors.Wrap(err, "adding database sync job")
|
|
}
|
|
}
|
|
c.Start()
|
|
|
|
log.Info().Msgf("%d scheduled jobs", len(c.Entries()))
|
|
for _, entry := range c.Entries() {
|
|
log.Info().Msgf("next @ %s", entry.Next)
|
|
}
|
|
|
|
<-ctx.Done()
|
|
c.Stop()
|
|
ctxhttp, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
err := e.Shutdown(ctxhttp)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("shutting down api http server")
|
|
}
|
|
|
|
wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
type ApiServer struct {
|
|
sorare *sorare.Sorare
|
|
db *db.Client
|
|
e *echo.Echo
|
|
}
|
|
|
|
func (s *ApiServer) GetAllZones(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
res, err := s.db.Zones.GetAll(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetPlayer(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
res, err := s.db.Players.GetOne(ctx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) SingleRankings(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
var opts db.SingleRankingsOptions
|
|
if err := c.Bind(&opts); err != nil {
|
|
return errors.Wrap(err, "parsing ranking request options")
|
|
}
|
|
res, err := s.db.Players.SingleRankings(ctx, opts)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting rankings")
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetAllClubCompetitions(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
zones := c.QueryParam("zones")
|
|
zoneArray := lo.Filter(strings.Split(zones, ","), func(s string, _ int) bool {
|
|
return s != ""
|
|
})
|
|
res, err := s.db.Competitions.GetAllClubCompetitions(ctx, lo.Map(zoneArray, func(s string, _ int) int {
|
|
return lo.Must(strconv.Atoi(s))
|
|
}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetAllFixtures(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
res, err := s.db.Fixtures.GetAll(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetFixtureGames(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
res, err := s.db.Games.GetByFixture(ctx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetUserCards(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
res, err := s.sorare.Users.FootballCards(slug).Get(ctx, football.CardsParams{
|
|
Rarities: []types.Rarity{types.RarityLimited, types.RarityRare, types.RaritySuperRare, types.RarityUnique},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, lo.Map(res, func(card football.Card, _ int) model.Card {
|
|
return model.NewCardFromSorare(card)
|
|
}))
|
|
}
|
|
|
|
func (s *ApiServer) GetPlayerGames(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
res, err := s.db.GamePlayers.GetByPlayer(ctx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) SearchMulti(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
|
|
var searchText struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := c.Bind(&searchText); err != nil {
|
|
return c.JSON(http.StatusBadRequest, errorResponse(err))
|
|
}
|
|
|
|
var res struct {
|
|
Players []model.Player `json:"players"`
|
|
Teams []model.Team `json:"teams"`
|
|
}
|
|
|
|
players, err := s.db.Players.SearchByDisplayName(ctx, searchText.Query, searchText.Limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res.Players = players
|
|
|
|
teams, err := s.db.Teams.SearchByDisplayName(ctx, searchText.Query, searchText.Limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res.Teams = teams
|
|
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetTeam(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
res, err := s.db.Teams.GetOne(ctx, slug, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetTeamRoster(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
|
|
team, err := s.db.Teams.GetOne(ctx, slug, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
roster, err := s.db.Teams.GetRoster(ctx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
roster = lo.Map(roster, func(player model.Player, _ int) model.Player {
|
|
player.GamePlayers = lo.Filter(player.GamePlayers, func(gp model.GamePlayer, _ int) bool {
|
|
return gp.TeamSlug == slug
|
|
})
|
|
return player
|
|
})
|
|
|
|
var res struct {
|
|
Team model.Team `json:"team"`
|
|
Roster []model.Player `json:"roster"`
|
|
}
|
|
res.Team = team
|
|
res.Roster = roster
|
|
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
func (s *ApiServer) GetTeamMemberships(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
slug := c.Param("slug")
|
|
memberships, err := s.db.Teams.GetMemberships(ctx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
team, err := s.db.Teams.GetOne(ctx, slug, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var res struct {
|
|
Team model.Team `json:"team"`
|
|
Memberships []model.Membership `json:"memberships"`
|
|
}
|
|
res.Team = team
|
|
res.Memberships = memberships
|
|
|
|
return c.JSON(http.StatusOK, res)
|
|
}
|