diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 13655828..198956c5 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -1,12 +1,12 @@ -/* prettier-ignore-start */ - /* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols -// This file is auto-generated by TanStack Router +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Import Routes @@ -91,264 +91,316 @@ const AdminRoute = AdminImport.update({ } as any) const GuestIndexRoute = GuestIndexImport.update({ + id: '/', path: '/', getParentRoute: () => GuestRoute, } as any) const GuestWikiRoute = GuestWikiImport.update({ + id: '/wiki', path: '/wiki', getParentRoute: () => GuestRoute, } as any) const GuestStvRoute = GuestStvImport.update({ + id: '/stv', path: '/stv', getParentRoute: () => GuestRoute, } as any) const GuestServersRoute = GuestServersImport.update({ + id: '/servers', path: '/servers', getParentRoute: () => GuestRoute, } as any) const GuestPrivacyPolicyRoute = GuestPrivacyPolicyImport.update({ + id: '/privacy-policy', path: '/privacy-policy', getParentRoute: () => GuestRoute, } as any) const GuestPatreonRoute = GuestPatreonImport.update({ + id: '/patreon', path: '/patreon', getParentRoute: () => GuestRoute, } as any) const GuestContestsRoute = GuestContestsImport.update({ + id: '/contests', path: '/contests', getParentRoute: () => GuestRoute, } as any) const GuestChangelogRoute = GuestChangelogImport.update({ + id: '/changelog', path: '/changelog', getParentRoute: () => GuestRoute, } as any) const AuthStatsRoute = AuthStatsImport.update({ + id: '/stats', path: '/stats', getParentRoute: () => AuthRoute, } as any) const AuthSettingsRoute = AuthSettingsImport.update({ + id: '/settings', path: '/settings', getParentRoute: () => AuthRoute, } as any) const AuthReportRoute = AuthReportImport.update({ + id: '/report', path: '/report', getParentRoute: () => AuthRoute, } as any) const AuthPermissionRoute = AuthPermissionImport.update({ + id: '/permission', path: '/permission', getParentRoute: () => AuthRoute, } as any) const AuthPageNotFoundRoute = AuthPageNotFoundImport.update({ + id: '/page-not-found', path: '/page-not-found', getParentRoute: () => AuthRoute, } as any) const AuthNotificationsRoute = AuthNotificationsImport.update({ + id: '/notifications', path: '/notifications', getParentRoute: () => AuthRoute, } as any) const AuthLogoutRoute = AuthLogoutImport.update({ + id: '/logout', path: '/logout', getParentRoute: () => AuthRoute, } as any) const AuthForumsRoute = AuthForumsImport.update({ + id: '/forums', path: '/forums', getParentRoute: () => AuthRoute, } as any) const AuthChatlogsRoute = AuthChatlogsImport.update({ + id: '/chatlogs', path: '/chatlogs', getParentRoute: () => AuthRoute, } as any) const GuestWikiIndexRoute = GuestWikiIndexImport.update({ + id: '/', path: '/', getParentRoute: () => GuestWikiRoute, } as any) const GuestLoginIndexRoute = GuestLoginIndexImport.update({ + id: '/login/', path: '/login/', getParentRoute: () => GuestRoute, } as any) const AuthStatsIndexRoute = AuthStatsIndexImport.update({ + id: '/', path: '/', getParentRoute: () => AuthStatsRoute, } as any) const AuthReportIndexRoute = AuthReportIndexImport.update({ + id: '/', path: '/', getParentRoute: () => AuthReportRoute, } as any) const AuthForumsIndexRoute = AuthForumsIndexImport.update({ + id: '/', path: '/', getParentRoute: () => AuthForumsRoute, } as any) const ModAdminVotesRoute = ModAdminVotesImport.update({ + id: '/admin/votes', path: '/admin/votes', getParentRoute: () => ModRoute, } as any) const ModAdminReportsRoute = ModAdminReportsImport.update({ + id: '/admin/reports', path: '/admin/reports', getParentRoute: () => ModRoute, } as any) const ModAdminPeopleRoute = ModAdminPeopleImport.update({ + id: '/admin/people', path: '/admin/people', getParentRoute: () => ModRoute, } as any) const ModAdminNewsRoute = ModAdminNewsImport.update({ + id: '/admin/news', path: '/admin/news', getParentRoute: () => ModRoute, } as any) const ModAdminFiltersRoute = ModAdminFiltersImport.update({ + id: '/admin/filters', path: '/admin/filters', getParentRoute: () => ModRoute, } as any) const ModAdminContestsRoute = ModAdminContestsImport.update({ + id: '/admin/contests', path: '/admin/contests', getParentRoute: () => ModRoute, } as any) const ModAdminAppealsRoute = ModAdminAppealsImport.update({ + id: '/admin/appeals', path: '/admin/appeals', getParentRoute: () => ModRoute, } as any) const GuestWikiSlugRoute = GuestWikiSlugImport.update({ + id: '/$slug', path: '/$slug', getParentRoute: () => GuestWikiRoute, } as any) const GuestProfileSteamIdRoute = GuestProfileSteamIdImport.update({ + id: '/profile/$steamId', path: '/profile/$steamId', getParentRoute: () => GuestRoute, } as any) const GuestLoginSuccessRoute = GuestLoginSuccessImport.update({ + id: '/login/success', path: '/login/success', getParentRoute: () => GuestRoute, } as any) const AuthReportReportIdRoute = AuthReportReportIdImport.update({ + id: '/$reportId', path: '/$reportId', getParentRoute: () => AuthReportRoute, } as any) const AuthMatchMatchIdRoute = AuthMatchMatchIdImport.update({ + id: '/match/$matchId', path: '/match/$matchId', getParentRoute: () => AuthRoute, } as any) const AuthForumsForumidRoute = AuthForumsForumidImport.update({ + id: '/$forum_id', path: '/$forum_id', getParentRoute: () => AuthForumsRoute, } as any) const AuthContestsContestidRoute = AuthContestsContestidImport.update({ + id: '/contests/$contest_id', path: '/contests/$contest_id', getParentRoute: () => AuthRoute, } as any) const AuthBanBanidRoute = AuthBanBanidImport.update({ + id: '/ban/$ban_id', path: '/ban/$ban_id', getParentRoute: () => AuthRoute, } as any) const AdminAdminSettingsRoute = AdminAdminSettingsImport.update({ + id: '/admin/settings', path: '/admin/settings', getParentRoute: () => AdminRoute, } as any) const AdminAdminServersRoute = AdminAdminServersImport.update({ + id: '/admin/servers', path: '/admin/servers', getParentRoute: () => AdminRoute, } as any) const AdminAdminGameAdminsRoute = AdminAdminGameAdminsImport.update({ + id: '/admin/game-admins', path: '/admin/game-admins', getParentRoute: () => AdminRoute, } as any) const ModAdminNetworkIndexRoute = ModAdminNetworkIndexImport.update({ + id: '/admin/network/', path: '/admin/network/', getParentRoute: () => ModRoute, } as any) const ModAdminNetworkPlayersbyipRoute = ModAdminNetworkPlayersbyipImport.update( { + id: '/admin/network/playersbyip', path: '/admin/network/playersbyip', getParentRoute: () => ModRoute, } as any, ) const ModAdminNetworkIphistRoute = ModAdminNetworkIphistImport.update({ + id: '/admin/network/iphist', path: '/admin/network/iphist', getParentRoute: () => ModRoute, } as any) const ModAdminNetworkIpInfoRoute = ModAdminNetworkIpInfoImport.update({ + id: '/admin/network/ipInfo', path: '/admin/network/ipInfo', getParentRoute: () => ModRoute, } as any) const ModAdminNetworkCidrblocksRoute = ModAdminNetworkCidrblocksImport.update({ + id: '/admin/network/cidrblocks', path: '/admin/network/cidrblocks', getParentRoute: () => ModRoute, } as any) const ModAdminBanSteamRoute = ModAdminBanSteamImport.update({ + id: '/admin/ban/steam', path: '/admin/ban/steam', getParentRoute: () => ModRoute, } as any) const ModAdminBanGroupRoute = ModAdminBanGroupImport.update({ + id: '/admin/ban/group', path: '/admin/ban/group', getParentRoute: () => ModRoute, } as any) const ModAdminBanCidrRoute = ModAdminBanCidrImport.update({ + id: '/admin/ban/cidr', path: '/admin/ban/cidr', getParentRoute: () => ModRoute, } as any) const ModAdminBanAsnRoute = ModAdminBanAsnImport.update({ + id: '/admin/ban/asn', path: '/admin/ban/asn', getParentRoute: () => ModRoute, } as any) const AuthStatsWeaponWeaponidRoute = AuthStatsWeaponWeaponidImport.update({ + id: '/weapon/$weapon_id', path: '/weapon/$weapon_id', getParentRoute: () => AuthStatsRoute, } as any) const AuthForumsThreadForumthreadidRoute = AuthForumsThreadForumthreadidImport.update({ + id: '/thread/$forum_thread_id', path: '/thread/$forum_thread_id', getParentRoute: () => AuthForumsRoute, } as any) const AuthLogsSteamIdRoute = AuthLogsSteamIdImport.update({ + id: '/logs/$steamId/', path: '/logs/$steamId/', getParentRoute: () => AuthRoute, } as any) @@ -930,7 +982,7 @@ const ModRouteChildren: ModRouteChildren = { const ModRouteWithChildren = ModRoute._addFileChildren(ModRouteChildren) -interface FileRoutesByFullPath { +export interface FileRoutesByFullPath { '': typeof ModRouteWithChildren '/chatlogs': typeof AuthChatlogsRoute '/forums': typeof AuthForumsRouteWithChildren @@ -986,7 +1038,7 @@ interface FileRoutesByFullPath { '/admin/network': typeof ModAdminNetworkIndexRoute } -interface FileRoutesByTo { +export interface FileRoutesByTo { '': typeof ModRouteWithChildren '/chatlogs': typeof AuthChatlogsRoute '/logout': typeof AuthLogoutRoute @@ -1038,7 +1090,8 @@ interface FileRoutesByTo { '/admin/network': typeof ModAdminNetworkIndexRoute } -interface FileRoutesById { +export interface FileRoutesById { + __root__: typeof rootRoute '/_admin': typeof AdminRouteWithChildren '/_auth': typeof AuthRouteWithChildren '/_guest': typeof GuestRouteWithChildren @@ -1097,7 +1150,7 @@ interface FileRoutesById { '/_mod/admin/network/': typeof ModAdminNetworkIndexRoute } -interface FileRouteTypes { +export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '' @@ -1205,6 +1258,7 @@ interface FileRouteTypes { | '/admin/network/playersbyip' | '/admin/network' id: + | '__root__' | '/_admin' | '/_auth' | '/_guest' @@ -1264,7 +1318,7 @@ interface FileRouteTypes { fileRoutesById: FileRoutesById } -interface RootRouteChildren { +export interface RootRouteChildren { AdminRoute: typeof AdminRouteWithChildren AuthRoute: typeof AuthRouteWithChildren GuestRoute: typeof GuestRouteWithChildren @@ -1282,8 +1336,6 @@ export const routeTree = rootRoute ._addFileChildren(rootRouteChildren) ._addFileTypes() -/* prettier-ignore-end */ - /* ROUTE_MANIFEST_START { "routes": { diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 11c946c4..4da1a95b 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -100,7 +100,7 @@ func firstTimeSetup(ctx context.Context, persons domain.PersonUsecase, news doma func createQueueWorkers(people domain.PersonUsecase, notifications domain.NotificationUsecase, discordUC domain.DiscordUsecase, authRepo domain.AuthRepository, memberships *steamgroup.Memberships, patreonUC domain.PatreonUsecase, bansSteam domain.BanSteamUsecase, bansNet domain.BanNetUsecase, bansASN domain.BanASNUsecase, - configUC domain.ConfigUsecase, fetcher *demo.Fetcher, demos domain.DemoUsecase, reports domain.ReportUsecase, + configUC domain.ConfigUsecase, demos domain.DemoUsecase, reports domain.ReportUsecase, blocklists domain.BlocklistUsecase, discordOAuth domain.DiscordOAuthUsecase, ) *river.Workers { workers := river.NewWorkers() @@ -110,7 +110,6 @@ func createQueueWorkers(people domain.PersonUsecase, notifications domain.Notifi river.AddWorker[steamgroup.MembershipArgs](workers, steamgroup.NewMembershipWorker(memberships)) river.AddWorker[patreon.AuthUpdateArgs](workers, patreon.NewSyncWorker(patreonUC)) river.AddWorker[ban.ExpirationArgs](workers, ban.NewExpirationWorker(bansSteam, bansNet, bansASN, people, notifications, configUC)) - river.AddWorker[demo.FetcherArgs](workers, demo.NewFetcherWorker(fetcher, configUC)) river.AddWorker[demo.CleanupArgs](workers, demo.NewCleanupWorker(demos)) river.AddWorker[report.MetaInfoArgs](workers, report.NewMetaInfoWorker(reports)) river.AddWorker[blocklist.ListUpdaterArgs](workers, blocklist.NewListUpdaterWorker(blocklists)) @@ -150,13 +149,6 @@ func createPeriodicJobs() []*river.PeriodicJob { }, &river.PeriodicJobOpts{RunOnStart: true}), - river.NewPeriodicJob( - river.PeriodicInterval(time.Minute*10), - func() (river.JobArgs, *river.InsertOpts) { - return demo.FetcherArgs{}, nil - }, - &river.PeriodicJobOpts{RunOnStart: true}), - river.NewPeriodicJob( river.PeriodicInterval(time.Hour*24), func() (river.JobArgs, *river.InsertOpts) { @@ -305,14 +297,14 @@ func serveCmd() *cobra.Command { //nolint:maintidx return err } - assetUsecase := asset.NewAssetUsecase(assetRepository) - serversUsecase := servers.NewServersUsecase(servers.NewServersRepository(dbConn)) - demoUsecase := demo.NewDemoUsecase(domain.BucketDemo, demo.NewDemoRepository(dbConn), assetUsecase, configUsecase, serversUsecase) + assets := asset.NewAssetUsecase(assetRepository) + serversUC := servers.NewServersUsecase(servers.NewServersRepository(dbConn)) + demos := demo.NewDemoUsecase(domain.BucketDemo, demo.NewDemoRepository(dbConn), assets, configUsecase, serversUC) - reportUsecase := report.NewReportUsecase(report.NewReportRepository(dbConn), notificationUsecase, configUsecase, personUsecase, demoUsecase) + reportUsecase := report.NewReportUsecase(report.NewReportRepository(dbConn), notificationUsecase, configUsecase, personUsecase, demos) stateUsecase := state.NewStateUsecase(eventBroadcaster, - state.NewStateRepository(state.NewCollector(serversUsecase)), configUsecase, serversUsecase) + state.NewStateRepository(state.NewCollector(serversUC)), configUsecase, serversUC) banUsecase := ban.NewBanSteamUsecase(ban.NewBanSteamRepository(dbConn, personUsecase, networkUsecase), personUsecase, configUsecase, notificationUsecase, reportUsecase, stateUsecase) @@ -334,10 +326,10 @@ func serveCmd() *cobra.Command { //nolint:maintidx appeals := appeal.NewAppealUsecase(appeal.NewAppealRepository(dbConn), banUsecase, personUsecase, notificationUsecase, configUsecase) - matchRepo := match.NewMatchRepository(eventBroadcaster, dbConn, personUsecase, serversUsecase, notificationUsecase, stateUsecase, weaponsMap) + matchRepo := match.NewMatchRepository(eventBroadcaster, dbConn, personUsecase, serversUC, notificationUsecase, stateUsecase, weaponsMap) go matchRepo.Start(ctx) - matchUsecase := match.NewMatchUsecase(matchRepo, stateUsecase, serversUsecase, notificationUsecase) + matchUsecase := match.NewMatchUsecase(matchRepo, stateUsecase, serversUC, notificationUsecase) if errWeapons := matchUsecase.LoadWeapons(ctx, weaponsMap); errWeapons != nil { slog.Error("Failed to import weapons", log.ErrAttr(errWeapons)) @@ -360,12 +352,12 @@ func serveCmd() *cobra.Command { //nolint:maintidx patreonUsecase := patreon.NewPatreonUsecase(patreon.NewPatreonRepository(dbConn), configUsecase) - srcdsUsecase := srcds.NewSrcdsUsecase(srcds.NewRepository(dbConn), configUsecase, serversUsecase, personUsecase, reportUsecase, notificationUsecase, banUsecase) + srcdsUsecase := srcds.NewSrcdsUsecase(srcds.NewRepository(dbConn), configUsecase, serversUC, personUsecase, reportUsecase, notificationUsecase, banUsecase) wikiUsecase := wiki.NewWikiUsecase(wiki.NewWikiRepository(dbConn)) authRepo := auth.NewAuthRepository(dbConn) - authUsecase := auth.NewAuthUsecase(authRepo, configUsecase, personUsecase, banUsecase, serversUsecase) + authUsecase := auth.NewAuthUsecase(authRepo, configUsecase, personUsecase, banUsecase, serversUC) voteUsecase := votes.NewVoteUsecase(votes.NewVoteRepository(dbConn), personUsecase, matchUsecase, notificationUsecase, configUsecase, eventBroadcaster) go voteUsecase.Start(ctx) @@ -393,7 +385,7 @@ func serveCmd() *cobra.Command { //nolint:maintidx } discordHandler := discord.NewDiscordHandler(discordUsecase, personUsecase, banUsecase, - stateUsecase, serversUsecase, configUsecase, networkUsecase, wordFilterUsecase, matchUsecase, banNetUsecase, banASNUsecase) + stateUsecase, serversUC, configUsecase, networkUsecase, wordFilterUsecase, matchUsecase, banNetUsecase, banASNUsecase) discordHandler.Start(ctx) appeal.NewAppealHandler(router, appeals, authUsecase) @@ -406,11 +398,11 @@ func serveCmd() *cobra.Command { //nolint:maintidx steamgroup.NewSteamgroupHandler(router, banGroupUsecase, authUsecase) blocklist.NewBlocklistHandler(router, blocklistUsecase, networkUsecase, authUsecase) chat.NewChatHandler(router, chatUsecase, authUsecase) - contest.NewContestHandler(router, contestUsecase, configUsecase, assetUsecase, authUsecase) - demo.NewDemoHandler(router, demoUsecase) + contest.NewContestHandler(router, contestUsecase, configUsecase, assets, authUsecase) + demo.NewDemoHandler(router, demos) forum.NewForumHandler(router, forumUsecase, authUsecase) - match.NewMatchHandler(ctx, router, matchUsecase, serversUsecase, authUsecase, configUsecase) - asset.NewAssetHandler(router, configUsecase, assetUsecase, authUsecase) + match.NewMatchHandler(ctx, router, matchUsecase, serversUC, authUsecase, configUsecase) + asset.NewAssetHandler(router, configUsecase, assets, authUsecase) metrics.NewMetricsHandler(router) network.NewNetworkHandler(router, networkUsecase, authUsecase) news.NewNewsHandler(router, newsUsecase, notificationUsecase, authUsecase) @@ -418,9 +410,9 @@ func serveCmd() *cobra.Command { //nolint:maintidx patreon.NewPatreonHandler(router, patreonUsecase, authUsecase, configUsecase) person.NewPersonHandler(router, configUsecase, personUsecase, authUsecase) report.NewReportHandler(router, reportUsecase, authUsecase, notificationUsecase) - servers.NewServerHandler(router, serversUsecase, stateUsecase, authUsecase, personUsecase) - srcds.NewSRCDSHandler(router, srcdsUsecase, serversUsecase, personUsecase, assetUsecase, - reportUsecase, banUsecase, networkUsecase, banGroupUsecase, demoUsecase, authUsecase, banASNUsecase, banNetUsecase, + servers.NewServerHandler(router, serversUC, stateUsecase, authUsecase, personUsecase) + srcds.NewSRCDSHandler(router, srcdsUsecase, serversUC, personUsecase, assets, + reportUsecase, banUsecase, networkUsecase, banGroupUsecase, demos, authUsecase, banASNUsecase, banNetUsecase, configUsecase, notificationUsecase, stateUsecase, blocklistUsecase) votes.NewVoteHandler(router, voteUsecase, authUsecase) wiki.NewWIkiHandler(router, wikiUsecase, authUsecase) @@ -430,8 +422,6 @@ func serveCmd() *cobra.Command { //nolint:maintidx go stateUsecase.LogAddressAdd(ctx, conf.Debug.AddRCONLogAddress) } - demoFetcher := demo.NewFetcher(dbConn, configUsecase, serversUsecase, assetUsecase, demoUsecase) - // River Queue workers := createQueueWorkers( personUsecase, @@ -444,8 +434,7 @@ func serveCmd() *cobra.Command { //nolint:maintidx banNetUsecase, banASNUsecase, configUsecase, - demoFetcher, - demoUsecase, + demos, reportUsecase, blocklistUsecase, discordOAuthUsecase) @@ -468,6 +457,9 @@ func serveCmd() *cobra.Command { //nolint:maintidx httpServer := httphelper.NewHTTPServer(conf.Addr(), router) + demoDownloader := demo.NewDownloader(configUsecase, dbConn, serversUC, assets, demos) + go demoDownloader.Start(ctx) + go func() { <-ctx.Done() diff --git a/internal/demo/demo_usecase.go b/internal/demo/demo_usecase.go index b7b55a57..82daee8b 100644 --- a/internal/demo/demo_usecase.go +++ b/internal/demo/demo_usecase.go @@ -1,10 +1,16 @@ package demo import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "log/slog" + "mime/multipart" + "net/http" + "os" "strings" "time" @@ -12,7 +18,6 @@ import ( "github.com/gin-gonic/gin" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/internal/queue" - "github.com/leighmacdonald/gbans/pkg/demoparser" "github.com/leighmacdonald/gbans/pkg/fs" "github.com/leighmacdonald/gbans/pkg/log" "github.com/ricochet2200/go-disk-usage/du" @@ -202,6 +207,60 @@ func (d demoUsecase) GetDemos(ctx context.Context) ([]domain.DemoFile, error) { return d.repository.GetDemos(ctx) } +func (d demoUsecase) SendAndParseDemo(ctx context.Context, path string) (*domain.DemoDetails, error) { + df, errDF := os.Open(path) + if errDF != nil { + return nil, errors.Join(errDF, domain.ErrDemoLoad) + } + + content, errContent := io.ReadAll(df) + if errContent != nil { + return nil, errors.Join(errDF, domain.ErrDemoLoad) + } + + info, errInfo := df.Stat() + if errInfo != nil { + return nil, errors.Join(errInfo, domain.ErrDemoLoad) + } + + log.Closer(df) + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + part, errCreate := writer.CreateFormFile("file", info.Name()) + if errCreate != nil { + return nil, errors.Join(errCreate, domain.ErrDemoLoad) + } + + if _, err := part.Write(content); err != nil { + return nil, errors.Join(errCreate, domain.ErrDemoLoad) + } + + if errClose := writer.Close(); errClose != nil { + return nil, errors.Join(errClose, domain.ErrDemoLoad) + } + + req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:8811/", body) + if errReq != nil { + return nil, errors.Join(errReq, domain.ErrDemoLoad) + } + + client := &http.Client{} + resp, errSend := client.Do(req) + if errSend != nil { + return nil, errors.Join(errSend, domain.ErrDemoLoad) + } + + var demo domain.DemoDetails + + if errDecode := json.NewDecoder(resp.Body).Decode(&demo); errDecode != nil { + return nil, errors.Join(errDecode, domain.ErrDemoLoad) + } + + return &demo, nil +} + func (d demoUsecase) CreateFromAsset(ctx context.Context, asset domain.Asset, serverID int) (*domain.DemoFile, error) { _, errGetServer := d.servers.Server(ctx, serverID) if errGetServer != nil { @@ -221,20 +280,16 @@ func (d demoUsecase) CreateFromAsset(ctx context.Context, asset domain.Asset, se mapName = nameParts[0] } + // TODO change this data shape as we have not needed this in a long time. Only keys the are used. intStats := map[string]gin.H{} - // temp thing until proper demo parsing is implemented - if d.config.Config().General.Mode != domain.TestMode { - var demoInfo demoparser.DemoInfo - if errParse := demoparser.Parse(ctx, asset.LocalPath, &demoInfo); errParse != nil { - return nil, errParse - } + demoDetail, errDetail := d.SendAndParseDemo(ctx, asset.LocalPath) + if errDetail != nil { + return nil, errDetail + } - for _, steamID := range demoInfo.SteamIDs() { - intStats[steamID.String()] = gin.H{} - } - } else { - intStats[d.config.Config().Owner] = gin.H{} + for key := range demoDetail.State.Users { + intStats[key] = gin.H{} } timeStr := fmt.Sprintf("%s-%s", namePartsAll[0], namePartsAll[1]) diff --git a/internal/demo/fetcher.go b/internal/demo/fetcher.go index 79e0d964..dbbc17e5 100644 --- a/internal/demo/fetcher.go +++ b/internal/demo/fetcher.go @@ -15,10 +15,8 @@ import ( "github.com/leighmacdonald/gbans/internal/database" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/internal/network" - "github.com/leighmacdonald/gbans/internal/queue" "github.com/leighmacdonald/gbans/pkg/log" "github.com/leighmacdonald/steamid/v4/steamid" - "github.com/riverqueue/river" "github.com/viant/afs/option" "github.com/viant/afs/storage" ) @@ -145,39 +143,36 @@ func (d Fetcher) OnClientConnect(ctx context.Context, client storage.Storager, s return nil } -type FetcherArgs struct{} +func NewDownloader(config domain.ConfigUsecase, dbConn database.Database, servers domain.ServersUsecase, assets domain.AssetUsecase, demos domain.DemoUsecase) Downloader { + fetcher := NewFetcher(dbConn, config, servers, assets, demos) -func (args FetcherArgs) Kind() string { - return "demo_fetch" -} - -func (args FetcherArgs) InsertOpts() river.InsertOpts { - return river.InsertOpts{Queue: string(queue.Demo), UniqueOpts: river.UniqueOpts{ByPeriod: time.Minute * 10}} -} - -func NewFetcherWorker(fetcher *Fetcher, config domain.ConfigUsecase) *FetcherWorker { - return &FetcherWorker{ - scpExec: network.NewSCPExecer(fetcher.database, fetcher.configUsecase, fetcher.serversUsecase, fetcher.OnClientConnect), + return Downloader{ + fetcher: fetcher, + scpExec: network.NewSCPExecer(dbConn, config, servers, fetcher.OnClientConnect), config: config, } } -type FetcherWorker struct { - river.WorkerDefaults[FetcherArgs] +type Downloader struct { + fetcher *Fetcher scpExec network.SCPExecer config domain.ConfigUsecase } -func (worker *FetcherWorker) Work(ctx context.Context, _ *river.Job[FetcherArgs]) error { - if !worker.config.Config().SSH.Enabled { - return nil - } - - if err := worker.scpExec.Update(ctx); err != nil { - slog.Error("Failed to execute demo fetcher", log.ErrAttr(err)) +func (d Downloader) Start(ctx context.Context) { + ticker := time.NewTicker(time.Second * 5) + for { + select { + case <-ticker.C: + if !d.config.Config().SSH.Enabled { + continue + } - return err + if err := d.scpExec.Update(ctx); err != nil { + slog.Error("Error trying to download demos", log.ErrAttr(err)) + } + case <-ctx.Done(): + return + } } - - return nil } diff --git a/internal/domain/config.go b/internal/domain/config.go index 73a93ab3..aa8ae54c 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -102,6 +102,7 @@ type ConfigSSH struct { UpdateInterval int `json:"update_interval,string"` Timeout int `json:"timeout,string"` DemoPathFmt string `json:"demo_path_fmt"` + // TODO configurable handling of host keys } type ConfigExports struct { diff --git a/internal/domain/demo.go b/internal/domain/demo.go index 086cf409..0ad9158e 100644 --- a/internal/domain/demo.go +++ b/internal/domain/demo.go @@ -16,6 +16,7 @@ type DemoUsecase interface { GetDemos(ctx context.Context) ([]DemoFile, error) CreateFromAsset(ctx context.Context, asset Asset, serverID int) (*DemoFile, error) Cleanup(ctx context.Context) + SendAndParseDemo(ctx context.Context, path string) (*DemoDetails, error) } type DemoRepository interface { @@ -58,3 +59,35 @@ type DemoInfo struct { Title string AssetID uuid.UUID } + +type DemoPlayer struct { + Classes struct { + } `json:"classes"` + Name string `json:"name"` + UserID int `json:"userId"` + SteamID string `json:"steamId"` + Team string `json:"team"` +} + +type DemoHeader struct { + DemoType string `json:"demo_type"` + Version int `json:"version"` + Protocol int `json:"protocol"` + Server string `json:"server"` + Nick string `json:"nick"` + Map string `json:"map"` + Game string `json:"game"` + Duration float64 `json:"duration"` + Ticks int `json:"ticks"` + Frames int `json:"frames"` + Signon int `json:"signon"` +} + +type DemoDetails struct { + State struct { + PlayerSummaries struct { + } `json:"player_summaries"` + Users map[string]DemoPlayer `json:"users"` + } `json:"state"` + Header DemoHeader `json:"header"` +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index ccb201d0..1f207b8a 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -166,4 +166,5 @@ var ( ErrOpenFile = errors.New("could not open output file") ErrFrontendRoutes = errors.New("failed to initialize frontend asset routes") ErrPathInvalid = errors.New("invalid path specified") + ErrDemoLoad = errors.New("could not load demo file") ) diff --git a/internal/match/match_repository.go b/internal/match/match_repository.go index fb5f6530..a90486d4 100644 --- a/internal/match/match_repository.go +++ b/internal/match/match_repository.go @@ -1333,7 +1333,7 @@ func (r *matchRepository) HealersOverallByHealing(ctx context.Context, count int coalesce(c.assists, 0) as assists, coalesce(c.kills, 0) + coalesce(c.assists, 0) as ka, coalesce(c.deaths, 0) as deaths, - case c.playtime WHEN 0 THEN 0 ELSE h.healing::float / (c.playtime::float / 60) END as hpm, + case c.playtime WHEN 0 THEN 0 ELSE coalesce(h.healing::float / (c.playtime::float / 60), 0) END as hpm, case c.deaths WHEN 0 THEN -1 ELSE ((c.assists::float + c.kills::float) / c.deaths::float) END kad, coalesce(c.playtime, 0) as playtime, coalesce(c.dominations, 0) as dominations, diff --git a/internal/network/scp.go b/internal/network/scp.go index 09cf9f7e..5de5d9e8 100644 --- a/internal/network/scp.go +++ b/internal/network/scp.go @@ -38,23 +38,23 @@ type OnClientConnect func(ctx context.Context, client storage.Storager, server [ // to implement this function and handle any required functionality within it. Caller does not need to close the // connection. type SCPExecer struct { - serversUsecase domain.ServersUsecase - database database.Database - configUsecase domain.ConfigUsecase - onConnect OnClientConnect + servers domain.ServersUsecase + database database.Database + config domain.ConfigUsecase + onConnect OnClientConnect } -func NewSCPExecer(database database.Database, configUsecase domain.ConfigUsecase, serversUsecase domain.ServersUsecase, onConnect OnClientConnect) SCPExecer { +func NewSCPExecer(database database.Database, config domain.ConfigUsecase, servers domain.ServersUsecase, onConnect OnClientConnect) SCPExecer { return SCPExecer{ - database: database, - configUsecase: configUsecase, - serversUsecase: serversUsecase, - onConnect: onConnect, + database: database, + config: config, + servers: servers, + onConnect: onConnect, } } func (f SCPExecer) Update(ctx context.Context) error { - servers, _, errServers := f.serversUsecase.Servers(ctx, domain.ServerQueryFilter{}) + servers, _, errServers := f.servers.Servers(ctx, domain.ServerQueryFilter{}) if errServers != nil { return errServers } @@ -72,7 +72,7 @@ func (f SCPExecer) Update(ctx context.Context) error { mappedServers[server.Address] = append(mappedServers[server.Address], server) } - sshConfig := f.configUsecase.Config().SSH + sshConfig := f.config.Config().SSH waitGroup := &sync.WaitGroup{} diff --git a/internal/state/state_usecase.go b/internal/state/state_usecase.go index 0b206f1f..8c0f7773 100644 --- a/internal/state/state_usecase.go +++ b/internal/state/state_usecase.go @@ -309,7 +309,8 @@ func (s *stateUsecase) Broadcast(ctx context.Context, serverIDs []int, cmd strin resp, errExec := s.state.ExecRaw(egCtx, serverConf.Addr(), serverConf.RconPassword, cmd) if errExec != nil { - slog.Error("Failed to exec server command", slog.Int("server_id", sid), log.ErrAttr(errExec)) + slog.Error("Failed to exec server command", slog.String("name", serverConf.DefaultHostname), + slog.Int("server_id", sid), log.ErrAttr(errExec)) // Don't error out since we don't want a single servers potentially temporary issue to prevent the rest // from executing. diff --git a/internal/test/demos_test.go b/internal/test/demos_test.go index deffdc16..55c2fdcd 100644 --- a/internal/test/demos_test.go +++ b/internal/test/demos_test.go @@ -52,3 +52,9 @@ func TestDemosCleanup(t *testing.T) { require.NoError(t, err) require.Len(t, allDemos, 5) } + +func TestDemoUpload(t *testing.T) { + detail, err := demoUC.SendAndParseDemo(context.Background(), "test_data/test.dem") + require.NoError(t, err) + require.True(t, len(detail.State.Users) == 10) +} diff --git a/pkg/demoparser/demo_parser.go b/pkg/demoparser/demo_parser.go deleted file mode 100644 index a652d13d..00000000 --- a/pkg/demoparser/demo_parser.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package demoparser provides a basic wrapper around https://github.com/demostf/parser -// If the binary does not exist, it will be downloaded to the current directory -package demoparser - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "io/fs" - "log/slog" - "net/http" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/leighmacdonald/gbans/pkg/log" - "github.com/leighmacdonald/steamid/v4/steamid" -) - -const ( - binPath = "parse_demo" - downloadURL = "https://github.com/demostf/parser/releases/download/v0.4.0/parse_demo" -) - -var ( - ErrDecode = errors.New("failed to decode into parse_demo output json") - ErrCreateRequest = errors.New("failed to create download request") - ErrDownload = errors.New("failed to download parse_demo binary") - ErrOpenFile = errors.New("failed to create new fd") - ErrWrite = errors.New("failed to write binary") - ErrCloseBin = errors.New("failed to close binary file") - ErrCall = errors.New("failed to call parser binary") -) - -//nolint:tagliatelle -type Player struct { - Classes map[string]int `json:"classes"` // class -> count? - Name string `json:"name"` - UserID int `json:"userId"` - SteamID string `json:"steamId"` - Team string `json:"team"` -} - -type Message struct { - Kind string `json:"kind"` - From string `json:"from"` - Text string `json:"text"` - Tick int `json:"tick"` -} - -type Death struct { - Weapon string `json:"weapon"` - Victim int `json:"victim"` - Assister *int `json:"assister"` - Killer int `json:"killer"` - Tick int `json:"tick"` -} - -type Round struct { - Winner string `json:"winner"` - Length float64 `json:"length"` - EndTick int `json:"end_tick"` -} - -//nolint:tagliatelle -type DemoInfo struct { - Chat []Message `json:"chat"` - Users map[string]Player `json:"users"` // userid -> player - Deaths []Death `json:"deaths"` - Rounds []Round `json:"rounds"` - StartTick int `json:"startTick"` - IntervalPerTick float64 `json:"intervalPerTick"` -} - -func (d DemoInfo) SteamIDs() steamid.Collection { - var ids steamid.Collection - - for _, user := range d.Users { - sid64 := steamid.New(user.SteamID) - if !sid64.Valid() { - continue - } - - ids = append(ids, sid64) - } - - return ids -} - -func Parse(ctx context.Context, demoPath string, info *DemoInfo) error { - if errEnsure := ensureBinary(ctx); errEnsure != nil { - return errEnsure - } - - output, errExec := callBin(demoPath) - if errExec != nil { - return errExec - } - - if errDecode := json.NewDecoder(bytes.NewReader(output)).Decode(info); errDecode != nil { - return errors.Join(errDecode, ErrDecode) - } - - return nil -} - -func Exists(path string) bool { - _, err := os.Stat(path) - - return err == nil -} - -func ensureBinary(ctx context.Context) error { - fullPath := fullBinPath() - - if Exists(fullPath) { - return nil - } - - client := http.Client{ - Timeout: time.Second * 60, - } - - req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if errReq != nil { - return errors.Join(errReq, ErrCreateRequest) - } - - resp, errResp := client.Do(req) - if errResp != nil { - return errors.Join(errResp, ErrDownload) - } - - defer func() { - if errClose := resp.Body.Close(); errClose != nil { - slog.Error("failed to close response body", log.ErrAttr(errClose)) - } - }() - - openFile, err := os.OpenFile(fullPath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0x755) - if err != nil { - return errors.Join(err, ErrOpenFile) - } - - defer func() { - if errClose := openFile.Close(); errClose != nil { - slog.Error("failed to close output file", log.ErrAttr(errClose)) - } - }() - - if _, errWrite := io.Copy(openFile, resp.Body); errWrite != nil { - return errors.Join(errWrite, ErrWrite) - } - - return nil -} - -func appDir() string { - dir, err := os.UserHomeDir() - if err != nil { - panic(err) - } - - fullDir := filepath.Join(dir, ".config", "parse_demo") - if errMkdir := os.MkdirAll(fullDir, fs.ModePerm); errMkdir != nil { - panic(errMkdir) - } - - return fullDir -} - -func fullBinPath() string { - return filepath.Join(appDir(), binPath) -} - -func callBin(arg string) ([]byte, error) { - cmd, errOutput := exec.Command(fullBinPath(), arg).Output() //nolint:gosec - if errOutput != nil { - var ee *exec.ExitError - if errors.As(errOutput, &ee) { - return nil, errors.Join(ee, ErrCall) - } - - return nil, errors.Join(errOutput, ErrCall) - } - - return cmd, nil -} diff --git a/pkg/demoparser/demo_parser_test.go b/pkg/demoparser/demo_parser_test.go deleted file mode 100644 index 2ef7ac3a..00000000 --- a/pkg/demoparser/demo_parser_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package demoparser_test - -import ( - "context" - "path/filepath" - "testing" - - "github.com/leighmacdonald/gbans/pkg/demoparser" - "github.com/stretchr/testify/require" -) - -func TestParse(t *testing.T) { - path, _ := filepath.Abs("testdata/test.dem") - if !demoparser.Exists(path) { - path, _ = filepath.Abs("../../testdata/test.dem") - if !demoparser.Exists(path) { - return - } - } - - var info demoparser.DemoInfo - - require.NoError(t, demoparser.Parse(context.Background(), path, &info)) - require.Len(t, info.Chat, 20) - require.Len(t, info.Deaths, 243) - require.Len(t, info.Rounds, 2) - require.Len(t, info.Users, 45) - require.Equal(t, 509, info.StartTick) - require.InEpsilon(t, 0.015, info.IntervalPerTick, 0.001) -}