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) }