sorarebuddy/cmd/server/root.go
2024-06-06 09:52:54 +04:00

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