Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/matches/game-streamer/game-streamer.controller.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -25,6 +26,7 @@ export class GameStreamerController {
constructor(
private readonly logger: Logger,
private readonly gameStreamer: GameStreamerService,
private readonly streamAccess: StreamAccessService,
) {}

@Post("status")
Expand DownExpand Up@@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/matches/game-streamer/game-streamer.module.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -33,9 +35,11 @@ import { loggerFactory } from "../../utilities/LoggerFactory";
GameServerNodeBakeController,
HudDataController,
SnapshotController,
StreamAccessController,
],
providers: [
GameStreamerService,
StreamAccessService,
SteamAccountService,
DemoSessionWatcherService,
DemoSessionWatcherGateway,
Expand Down
21 changes: 21 additions & 0 deletions src/matches/game-streamer/game-streamer.service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
25 changes: 22 additions & 3 deletions src/matches/game-streamer/snapshot.controller.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Expand Down
30 changes: 30 additions & 0 deletions src/matches/game-streamer/stream-access.controller.ts
Original file line numberDiff line numberDiff line change
@@ -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();
}
}
100 changes: 100 additions & 0 deletions src/matches/game-streamer/stream-access.service.ts
Original file line numberDiff line numberDiff line change
@@ -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<boolean> {
const [data] = await this.postgres.query<Array<{ value: string }>>(
`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<boolean> {
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<boolean> {
const [row] = await this.postgres.query<Array<{ blocked: boolean }>>(
`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;
}
}
11 changes: 10 additions & 1 deletion src/matches/match-assistant/match-assistant.service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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({
Expand All@@ -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) },
Expand Down
14 changes: 14 additions & 0 deletions src/matches/matches.controller.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/system/enums/SystemSettingName.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
}