diff --git a/src/matches/game-streamer/game-streamer.controller.ts b/src/matches/game-streamer/game-streamer.controller.ts index dd55a26e7..8100b8b0e 100644 --- a/src/matches/game-streamer/game-streamer.controller.ts +++ b/src/matches/game-streamer/game-streamer.controller.ts @@ -16,6 +16,7 @@ import { import { FileInterceptor } from "@nestjs/platform-express"; import { Request, Response } from "express"; import { GameStreamerService } from "./game-streamer.service"; +import { StreamAccessService } from "./stream-access.service"; import { GameStreamerStatusDto } from "./types/GameStreamerStatusDto"; const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024; @@ -25,6 +26,7 @@ export class GameStreamerController { constructor( private readonly logger: Logger, private readonly gameStreamer: GameStreamerService, + private readonly streamAccess: StreamAccessService, ) {} @Post("status") @@ -105,14 +107,23 @@ export class GameStreamerController { @Get("snapshot") public async getSnapshot( @Param("matchId") matchId: string, + @Req() request: Request, @Res() response: Response, ) { + const requireLogin = await this.streamAccess.requireLoginForLiveStreams(); + if (requireLogin && !(await this.streamAccess.authorize(request, matchId))) { + return response.status(403).end(); + } + const image = await this.gameStreamer.getSnapshot("live", matchId); if (!image) { throw new NotFoundException("no snapshot available"); } response.setHeader("Content-Type", "image/jpeg"); - response.setHeader("Cache-Control", "public, max-age=15"); + response.setHeader( + "Cache-Control", + requireLogin ? "private, max-age=15" : "public, max-age=15", + ); response.setHeader("Content-Length", String(image.length)); return response.status(200).end(image); } diff --git a/src/matches/game-streamer/game-streamer.module.ts b/src/matches/game-streamer/game-streamer.module.ts index 725db7a0d..ae474f145 100644 --- a/src/matches/game-streamer/game-streamer.module.ts +++ b/src/matches/game-streamer/game-streamer.module.ts @@ -6,6 +6,8 @@ import { DemoSessionsController } from "./demo-sessions.controller"; import { GameServerNodeBakeController } from "./game-server-node-bake.controller"; import { HudDataController } from "./hud-data.controller"; import { SnapshotController } from "./snapshot.controller"; +import { StreamAccessController } from "./stream-access.controller"; +import { StreamAccessService } from "./stream-access.service"; import { DemoSessionWatcherService } from "./demo-session-watcher.service"; import { DemoSessionWatcherGateway } from "./demo-session-watcher.gateway"; import { HasuraModule } from "../../hasura/hasura.module"; @@ -33,9 +35,11 @@ import { loggerFactory } from "../../utilities/LoggerFactory"; GameServerNodeBakeController, HudDataController, SnapshotController, + StreamAccessController, ], providers: [ GameStreamerService, + StreamAccessService, SteamAccountService, DemoSessionWatcherService, DemoSessionWatcherGateway, diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index 9fd634a1f..42f8061b8 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -1697,6 +1697,27 @@ export class GameStreamerService { } } + public async stopLiveIfRunning(matchId: string) { + const { match_streams } = await this.hasura.query({ + match_streams: { + __args: { + where: { + match_id: { _eq: matchId }, + is_game_streamer: { _eq: true }, + }, + limit: 1, + }, + id: true, + }, + }); + + if (!match_streams?.length) { + return; + } + + await this.stopLive(matchId); + } + public async switchLive( fromMatchId: string, toMatchId: string, diff --git a/src/matches/game-streamer/snapshot.controller.ts b/src/matches/game-streamer/snapshot.controller.ts index 845e09aa9..80353f1e1 100644 --- a/src/matches/game-streamer/snapshot.controller.ts +++ b/src/matches/game-streamer/snapshot.controller.ts @@ -1,35 +1,54 @@ import { BadRequestException, Controller, + ForbiddenException, Get, NotFoundException, Param, + Req, Res, } from "@nestjs/common"; -import { Response } from "express"; +import { Request, Response } from "express"; import { GameStreamerService, SnapshotKind } from "./game-streamer.service"; +import { StreamAccessService } from "./stream-access.service"; const SNAPSHOT_KINDS: SnapshotKind[] = ["live", "demo", "bake", "clips"]; @Controller("snapshots") export class SnapshotController { - constructor(private readonly gameStreamer: GameStreamerService) {} + constructor( + private readonly gameStreamer: GameStreamerService, + private readonly streamAccess: StreamAccessService, + ) {} @Get(":kind/:id") public async getSnapshot( @Param("kind") kind: string, @Param("id") id: string, + @Req() request: Request, @Res() response: Response, ) { if (!SNAPSHOT_KINDS.includes(kind as SnapshotKind)) { throw new BadRequestException("invalid snapshot kind"); } + + // Live snapshots are gated by the same login requirement as live streams. + const requireLogin = + kind === "live" && + (await this.streamAccess.requireLoginForLiveStreams()); + if (requireLogin && !(await this.streamAccess.authorize(request, id))) { + throw new ForbiddenException("not authorized to view this stream"); + } + const image = await this.gameStreamer.getSnapshot(kind as SnapshotKind, id); if (!image) { throw new NotFoundException("no snapshot available"); } response.setHeader("Content-Type", "image/jpeg"); - response.setHeader("Cache-Control", "public, max-age=15"); + response.setHeader( + "Cache-Control", + requireLogin ? "private, max-age=15" : "public, max-age=15", + ); response.setHeader("Content-Length", String(image.length)); return response.status(200).end(image); } diff --git a/src/matches/game-streamer/stream-access.controller.ts b/src/matches/game-streamer/stream-access.controller.ts new file mode 100644 index 000000000..4c061d56c --- /dev/null +++ b/src/matches/game-streamer/stream-access.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Options, Req, Res } from "@nestjs/common"; +import { Request, Response } from "express"; +import { StreamAccessService } from "./stream-access.service"; + +@Controller("streams") +export class StreamAccessController { + constructor(private readonly streamAccess: StreamAccessService) {} + + @Get("authorize") + public async authorize(@Req() request: Request, @Res() response: Response) { + // nginx forward-auth runs this for every stream request, including the + // CORS preflight. Preflight (OPTIONS) requests never carry cookies, so + // gating them would 401 the preflight and surface as a CORS error in the + // browser before the real (credentialed) request is ever sent. Let them + // through — the actual GET/POST that follows still gets authorized. + if ( + String(request.headers["x-original-method"]).toUpperCase() === "OPTIONS" + ) { + return response.status(200).end(); + } + + const allowed = await this.streamAccess.authorize(request); + return response.status(allowed ? 200 : 401).end(); + } + + @Options("authorize") + public authorizePreflight(@Res() response: Response) { + return response.status(200).end(); + } +} diff --git a/src/matches/game-streamer/stream-access.service.ts b/src/matches/game-streamer/stream-access.service.ts new file mode 100644 index 000000000..c7124e899 --- /dev/null +++ b/src/matches/game-streamer/stream-access.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from "@nestjs/common"; +import { Request } from "express"; +import { PostgresService } from "src/postgres/postgres.service"; +import { SystemSettingName } from "src/system/enums/SystemSettingName"; + +@Injectable() +export class StreamAccessService { + constructor(private readonly postgres: PostgresService) {} + + public async requireLoginForLiveStreams(): Promise { + const [data] = await this.postgres.query>( + `SELECT value FROM public.settings WHERE name = $1 LIMIT 1`, + [SystemSettingName.RequireLoginForLiveStreams], + ); + + // Matches the web default (stores/ApplicationSettings.ts): enabled + // unless an admin has explicitly turned it off. + return data?.value !== "false"; + } + + // The session cookie (domain .${WEB_DOMAIN}, shared with the stream + // subdomain) identifies the viewer. When protection is on we require a + // logged-in user AND block competitors of a LIVE match from watching it — + // a player/coach seeing the live feed would have an in-game advantage. + // Once the match is over (a replay), no one is blocked. + public async authorize(request: Request, matchId?: string): Promise { + if (!(await this.requireLoginForLiveStreams())) { + return true; + } + + const user = request.user; + if (!user) { + return false; + } + + const id = matchId ?? this.matchIdFromRequest(request); + if (id && (await this.isCompetitorInLiveMatch(id, user.steam_id))) { + return false; + } + + return true; + } + + // True when the match is currently Live and the steam_id belongs to a + // player (either lineup) or a coach of that match. + private async isCompetitorInLiveMatch( + matchId: string, + steamId: string, + ): Promise { + const [row] = await this.postgres.query>( + `SELECT EXISTS ( + SELECT 1 FROM matches m + WHERE m.id = $1 + AND m.status = 'Live' + AND ( + EXISTS ( + SELECT 1 FROM match_lineup_players mlp + WHERE mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + AND mlp.steam_id = $2::bigint + ) + OR EXISTS ( + SELECT 1 FROM match_lineups ml + WHERE ml.match_id = m.id + AND ml.coach_steam_id = $2::bigint + ) + ) + ) AS blocked`, + [matchId, steamId], + ); + return row?.blocked === true; + } + + // Stream URLs are built as ${gameStreamDomain}/${matchId}/... so the match + // id is the first path segment. On an nginx forward-auth subrequest the + // original request line arrives as X-Original-URL. + private matchIdFromRequest(request: Request): string | null { + const originalUrl = request.headers["x-original-url"]; + const raw = + typeof originalUrl === "string" && originalUrl.length > 0 + ? originalUrl + : request.originalUrl; + if (!raw) { + return null; + } + + let path = raw; + const schemeIndex = path.indexOf("://"); + if (schemeIndex !== -1) { + const slash = path.indexOf("/", schemeIndex + 3); + path = slash === -1 ? "" : path.slice(slash); + } + const queryIndex = path.indexOf("?"); + if (queryIndex !== -1) { + path = path.slice(0, queryIndex); + } + + const segment = path.split("/").filter(Boolean)[0]; + return segment ? decodeURIComponent(segment) : null; + } +} diff --git a/src/matches/match-assistant/match-assistant.service.ts b/src/matches/match-assistant/match-assistant.service.ts index 2764e6140..ddf929797 100644 --- a/src/matches/match-assistant/match-assistant.service.ts +++ b/src/matches/match-assistant/match-assistant.service.ts @@ -1626,6 +1626,15 @@ export class MatchAssistantService { ? { team_id: side.team_id, team_name: await teamName(side.team_id) } : {}; + // When the requested kickoff is now/in the past (the "ASAP" case), skip the + // Scheduled status so the match doesn't sit waiting for the CheckForScheduledMatches + // cron sweep — go straight to WaitingForCheckIn like scheduleMatch does. + const scheduledMs = input.scheduled_at + ? new Date(input.scheduled_at).getTime() + : 0; + // 1 min grace absorbs client/server clock skew so "now" isn't pushed onto the cron. + const startNow = !scheduledMs || scheduledMs <= Date.now() + 60_000; + // Inserted via the admin client, so the tai_match auto-add-creator branch // (which is gated on a non-admin role) is skipped — no stray organizer. const { insert_matches_one } = await this.hasura.mutation({ @@ -1634,7 +1643,7 @@ export class MatchAssistantService { object: { organizer_steam_id: organizerSteamId, scheduled_at: input.scheduled_at, - status: "Scheduled", + status: startNow ? "WaitingForCheckIn" : "Scheduled", options: { data: input.options as any }, lineup_1: { data: await lineupData(input.lineup_1) }, lineup_2: { data: await lineupData(input.lineup_2) }, diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index c9dc6a031..b8ff4dc45 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -547,6 +547,20 @@ export class MatchesController { data.op === "DELETE" || MatchesController.TERMINAL_STATUSES.includes(status) ) { + try { + if (data.op === "DELETE") { + await this.gameStreamer.stopLive(matchId); + } else { + await this.gameStreamer.stopLiveIfRunning(matchId); + } + } catch (error) { + this.logger.error( + `[${matchId}] failed to stop live stream on match end: ${ + (error as Error)?.message + }`, + ); + } + this.matchRelayService.removeBroadcast(matchId); await this.removeDiscordIntegration(matchId); await this.matchmaking.cancelMatchMakingByMatchId(matchId); diff --git a/src/system/enums/SystemSettingName.ts b/src/system/enums/SystemSettingName.ts index 3679bfd96..92ac518a7 100644 --- a/src/system/enums/SystemSettingName.ts +++ b/src/system/enums/SystemSettingName.ts @@ -9,4 +9,5 @@ export enum SystemSettingName { NewsEnabled = "public.news_enabled", NewsLabel = "public.news_label", PostNewsRole = "public.post_news_role", + RequireLoginForLiveStreams = "public.require_login_for_live_streams", }