From 3d7c68a0ed08d45509a51f7c42c36c228e5665ea Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 21:46:47 +0200 Subject: [PATCH 1/8] feature: pure outro-branding helpers (version, cache key, env) + tests --- .../game-streamer/outro-branding.spec.ts | 48 +++++++++++++++++++ src/matches/game-streamer/outro-branding.ts | 45 +++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/matches/game-streamer/outro-branding.spec.ts create mode 100644 src/matches/game-streamer/outro-branding.ts diff --git a/src/matches/game-streamer/outro-branding.spec.ts b/src/matches/game-streamer/outro-branding.spec.ts new file mode 100644 index 000000000..16fadd5a8 --- /dev/null +++ b/src/matches/game-streamer/outro-branding.spec.ts @@ -0,0 +1,48 @@ +import { + DEFAULT_OUTRO_ACCENT, + computeOutroVersion, + outroCacheKey, + buildOutroEnv, +} from "./outro-branding"; + +describe("outro-branding", () => { + it("default accent is the stock amber triple", () => { + expect(DEFAULT_OUTRO_ACCENT).toBe("33 94% 58%"); + }); + + it("version is deterministic and 12 chars", () => { + const a = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" }); + const b = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" }); + expect(a).toBe(b); + expect(a).toHaveLength(12); + }); + + it("version changes when the logo etag changes", () => { + const a = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" }); + const b = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e2" }); + expect(a).not.toBe(b); + }); + + it("cache key embeds version + dims + fps", () => { + expect(outroCacheKey({ version: "abc123abc123", dims: "1920x1080", fps: 60 })) + .toBe("branding/outro_abc123abc123_1920x1080_60.mp4"); + }); + + it("hit env is just the cache URL", () => { + expect(buildOutroEnv({ hit: true, cacheUrl: "http://s3/x.mp4" })) + .toEqual({ CLIP_OUTRO_URL: "http://s3/x.mp4" }); + }); + + it("miss env carries render instruction + branding props", () => { + expect(buildOutroEnv({ + hit: false, putUrl: "http://put", logoUrl: "http://logo", + brandName: "ACME", accent: "33 94% 58%", + })).toEqual({ + CLIP_OUTRO_RENDER: "1", + CLIP_OUTRO_PUT_URL: "http://put", + CLIP_BRAND_LOGO_URL: "http://logo", + CLIP_BRAND_NAME: "ACME", + CLIP_BRAND_ACCENT: "33 94% 58%", + }); + }); +}); diff --git a/src/matches/game-streamer/outro-branding.ts b/src/matches/game-streamer/outro-branding.ts new file mode 100644 index 000000000..abc2d4ddf --- /dev/null +++ b/src/matches/game-streamer/outro-branding.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; + +export const DEFAULT_OUTRO_ACCENT = "33 94% 58%"; + +export function computeOutroVersion(parts: { + brandName: string; + accent: string; + etag: string; +}): string { + return createHash("sha1") + .update(`${parts.brandName}|${parts.accent}|${parts.etag}`) + .digest("hex") + .slice(0, 12); +} + +export function outroCacheKey(args: { + version: string; + dims: string; + fps: number; +}): string { + return `branding/outro_${args.version}_${args.dims}_${args.fps}.mp4`; +} + +export type OutroEnvState = + | { hit: true; cacheUrl: string } + | { + hit: false; + putUrl: string; + logoUrl: string; + brandName: string; + accent: string; + }; + +export function buildOutroEnv(state: OutroEnvState): Record { + if (state.hit) { + return { CLIP_OUTRO_URL: state.cacheUrl }; + } + return { + CLIP_OUTRO_RENDER: "1", + CLIP_OUTRO_PUT_URL: state.putUrl, + CLIP_BRAND_LOGO_URL: state.logoUrl, + CLIP_BRAND_NAME: state.brandName, + CLIP_BRAND_ACCENT: state.accent, + }; +} From 7e7c2928de7e8ee92b60bc9d35cabdd927abfcb0 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 21:51:45 +0200 Subject: [PATCH 2/8] fix: cast outro env state variant (api tsconfig strict off doesn't narrow the union) --- src/matches/game-streamer/outro-branding.ts | 34 ++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/matches/game-streamer/outro-branding.ts b/src/matches/game-streamer/outro-branding.ts index abc2d4ddf..e8e7fdcac 100644 --- a/src/matches/game-streamer/outro-branding.ts +++ b/src/matches/game-streamer/outro-branding.ts @@ -21,25 +21,31 @@ export function outroCacheKey(args: { return `branding/outro_${args.version}_${args.dims}_${args.fps}.mp4`; } -export type OutroEnvState = - | { hit: true; cacheUrl: string } - | { - hit: false; - putUrl: string; - logoUrl: string; - brandName: string; - accent: string; - }; +export interface OutroEnvHit { + hit: true; + cacheUrl: string; +} +export interface OutroEnvMiss { + hit: false; + putUrl: string; + logoUrl: string; + brandName: string; + accent: string; +} +export type OutroEnvState = OutroEnvHit | OutroEnvMiss; +// The api's tsconfig has strict mode off, so a boolean discriminant does not +// narrow the union in the else branch; cast to the concrete variant instead. export function buildOutroEnv(state: OutroEnvState): Record { if (state.hit) { - return { CLIP_OUTRO_URL: state.cacheUrl }; + return { CLIP_OUTRO_URL: (state as OutroEnvHit).cacheUrl }; } + const miss = state as OutroEnvMiss; return { CLIP_OUTRO_RENDER: "1", - CLIP_OUTRO_PUT_URL: state.putUrl, - CLIP_BRAND_LOGO_URL: state.logoUrl, - CLIP_BRAND_NAME: state.brandName, - CLIP_BRAND_ACCENT: state.accent, + CLIP_OUTRO_PUT_URL: miss.putUrl, + CLIP_BRAND_LOGO_URL: miss.logoUrl, + CLIP_BRAND_NAME: miss.brandName, + CLIP_BRAND_ACCENT: miss.accent, }; } From 8b8a43dca9d23a7245b1e37d317ff9479e5519ab Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 21:51:46 +0200 Subject: [PATCH 3/8] feature: resolveOutroBranding (presigned cache/render env from panel branding) --- .../game-streamer.nade-previews.spec.ts | 1 + .../game-streamer.service.spec.ts | 1 + .../game-streamer/game-streamer.service.ts | 64 +++++++++++++++++++ src/matches/matches.controller.spec.ts | 1 + 4 files changed, 67 insertions(+) diff --git a/src/matches/game-streamer/game-streamer.nade-previews.spec.ts b/src/matches/game-streamer/game-streamer.nade-previews.spec.ts index 7642fe923..8143373ff 100644 --- a/src/matches/game-streamer/game-streamer.nade-previews.spec.ts +++ b/src/matches/game-streamer/game-streamer.nade-previews.spec.ts @@ -87,6 +87,7 @@ describe("GameStreamerService — nade previews", () => { {} as any, steamAccounts as any, { resolveDefault: jest.fn().mockResolvedValue(null) } as any, + {} as any, ); }); diff --git a/src/matches/game-streamer/game-streamer.service.spec.ts b/src/matches/game-streamer/game-streamer.service.spec.ts index ed9f47665..b9f2275be 100644 --- a/src/matches/game-streamer/game-streamer.service.spec.ts +++ b/src/matches/game-streamer/game-streamer.service.spec.ts @@ -48,6 +48,7 @@ describe("GameStreamerService", () => { {} as any, {} as any, broadcastHuds as any, + {} as any, ); }); diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index 8b3d842be..f16966320 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -24,6 +24,13 @@ import { BroadcastHud, BroadcastHudsService, } from "src/broadcast-huds/broadcast-huds.service"; +import { S3Service } from "../../s3/s3.service"; +import { + DEFAULT_OUTRO_ACCENT, + computeOutroVersion, + outroCacheKey, + buildOutroEnv, +} from "./outro-branding"; import { LoggingService } from "../../k8s/logging/logging.service"; import { SteamAccountService, @@ -177,6 +184,7 @@ export class GameStreamerService { private readonly loggingService: LoggingService, private readonly steamAccounts: SteamAccountService, private readonly broadcastHuds: BroadcastHudsService, + private readonly s3: S3Service, ) { this.gameServerConfig = this.config.get("gameServers"); this.appConfig = this.config.get("app"); @@ -370,6 +378,62 @@ export class GameStreamerService { return value === "false" || value === "0" ? "0" : "1"; } + // Branded outro env for the render pod. Active only when a custom logo is + // set. Returns {} (stock outro), a presigned cache URL (hit), or a render + // instruction + presigned PUT + branding props (miss). Best-effort: any + // failure returns {} so the pod falls back to the baked stock outro. + public async resolveOutroBranding( + dims: string, + fps: number, + ): Promise> { + try { + const logoPath = await this.readSetting("public.logo_url"); + if (!logoPath) { + return {}; + } + const brandName = (await this.readSetting("public.brand_name")) ?? ""; + // The web themes from the dark palette only (the light-mode + // `public.color_*` rows are deleted at boot), so this is its accent. + const accent = + (await this.readSetting("public.color_dark_tactical_amber")) ?? + DEFAULT_OUTRO_ACCENT; + + let etag = logoPath; + try { + etag = (await this.s3.stat(logoPath))?.etag ?? logoPath; + } catch { + /* keep logoPath as the version seed */ + } + + const version = computeOutroVersion({ brandName, accent, etag }); + const key = outroCacheKey({ version, dims, fps }); + + if (await this.s3.has(key)) { + return buildOutroEnv({ + hit: true, + cacheUrl: await this.s3.getPresignedUrl(key, undefined, 3600, "get"), + }); + } + return buildOutroEnv({ + hit: false, + putUrl: await this.s3.getPresignedUrl(key, undefined, 3600, "put"), + logoUrl: await this.s3.getPresignedUrl( + logoPath, + undefined, + 3600, + "get", + ), + brandName, + accent, + }); + } catch (error) { + this.logger.warn( + `resolveOutroBranding failed: ${(error as Error)?.message ?? error}`, + ); + return {}; + } + } + public async resolveClipFps(): Promise<30 | 60> { const value = (await this.readSetting(SystemSettingName.ClipFps)) ?? diff --git a/src/matches/matches.controller.spec.ts b/src/matches/matches.controller.spec.ts index 33ee1279f..f540bfdea 100644 --- a/src/matches/matches.controller.spec.ts +++ b/src/matches/matches.controller.spec.ts @@ -309,6 +309,7 @@ describe("MatchesController", () => { {} as any, {} as any, {} as any, + {} as any, ); clips = { From 8db6d755e332a2314357676ebb6477094276d220 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 21:52:52 +0200 Subject: [PATCH 4/8] feature: pass branded outro env into batch + on-demand clip dispatch --- src/matches/clips/clips.service.ts | 6 ++ .../game-streamer/game-streamer.service.ts | 22 ++++++ .../game-streamer/outro-branding.spec.ts | 79 ++++++++++++++++--- src/matches/game-streamer/outro-branding.ts | 30 +++++++ 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/matches/clips/clips.service.ts b/src/matches/clips/clips.service.ts index aa3672f19..50ace77ed 100644 --- a/src/matches/clips/clips.service.ts +++ b/src/matches/clips/clips.service.ts @@ -216,6 +216,11 @@ export class ClipsService { `dest=${spec.destination}`, ); + const outroEnv = await this.gameStreamer.resolveOutroBranding( + dims, + spec.output.fps, + ); + try { await this.gameStreamer.dispatchClipRenderToPod(session.id, { job_id: jobId, @@ -229,6 +234,7 @@ export class ClipsService { })), output_dims: dims, output_fps: spec.output.fps, + outro_env: outroEnv, }); } catch (error) { this.logger.error( diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index f16966320..9f1fbfd1c 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -30,6 +30,7 @@ import { computeOutroVersion, outroCacheKey, buildOutroEnv, + sharedClipOutput, } from "./outro-branding"; import { LoggingService } from "../../k8s/logging/logging.service"; import { @@ -1204,6 +1205,7 @@ export class GameStreamerService { }>; output_dims: string; output_fps: number; + outro_env?: Record; }, ) { const url = this.getDemoSpecUrl(sessionId, "render-clip", "demo"); @@ -2452,6 +2454,26 @@ export class GameStreamerService { name: "CLIP_BAKE_BRANDING", value: await this.resolveClipBakeBranding(), }); + { + // The pod gets one outro env, but each job renders at its own spec + // output (a re-queued job keeps the output it was created with). Key + // the outro on the output all the jobs share so it matches every clip + // it is appended to; when they differ, keep the stock outro. + const output = sharedClipOutput(jobs.map((j) => j.spec)); + if (output) { + const outroEnv = await this.resolveOutroBranding( + output.dims, + output.fps, + ); + for (const [name, value] of Object.entries(outroEnv)) { + env.push({ name, value }); + } + } else { + this.logger.warn( + `[batch-highlights ${matchMapId}] jobs mix clip outputs, using the stock outro`, + ); + } + } env.push(...(await this.buildNodeCs2OptionsEnv(nodeId))); this.logger.log( diff --git a/src/matches/game-streamer/outro-branding.spec.ts b/src/matches/game-streamer/outro-branding.spec.ts index 16fadd5a8..dd18d1fae 100644 --- a/src/matches/game-streamer/outro-branding.spec.ts +++ b/src/matches/game-streamer/outro-branding.spec.ts @@ -3,6 +3,8 @@ import { computeOutroVersion, outroCacheKey, buildOutroEnv, + clipOutputFromSpec, + sharedClipOutput, } from "./outro-branding"; describe("outro-branding", () => { @@ -11,33 +13,56 @@ describe("outro-branding", () => { }); it("version is deterministic and 12 chars", () => { - const a = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" }); - const b = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" }); + const a = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e1", + }); + const b = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e1", + }); expect(a).toBe(b); expect(a).toHaveLength(12); }); it("version changes when the logo etag changes", () => { - const a = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e1" }); - const b = computeOutroVersion({ brandName: "ACME", accent: "1 2% 3%", etag: "e2" }); + const a = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e1", + }); + const b = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e2", + }); expect(a).not.toBe(b); }); it("cache key embeds version + dims + fps", () => { - expect(outroCacheKey({ version: "abc123abc123", dims: "1920x1080", fps: 60 })) - .toBe("branding/outro_abc123abc123_1920x1080_60.mp4"); + expect( + outroCacheKey({ version: "abc123abc123", dims: "1920x1080", fps: 60 }), + ).toBe("branding/outro_abc123abc123_1920x1080_60.mp4"); }); it("hit env is just the cache URL", () => { - expect(buildOutroEnv({ hit: true, cacheUrl: "http://s3/x.mp4" })) - .toEqual({ CLIP_OUTRO_URL: "http://s3/x.mp4" }); + expect(buildOutroEnv({ hit: true, cacheUrl: "http://s3/x.mp4" })).toEqual({ + CLIP_OUTRO_URL: "http://s3/x.mp4", + }); }); it("miss env carries render instruction + branding props", () => { - expect(buildOutroEnv({ - hit: false, putUrl: "http://put", logoUrl: "http://logo", - brandName: "ACME", accent: "33 94% 58%", - })).toEqual({ + expect( + buildOutroEnv({ + hit: false, + putUrl: "http://put", + logoUrl: "http://logo", + brandName: "ACME", + accent: "33 94% 58%", + }), + ).toEqual({ CLIP_OUTRO_RENDER: "1", CLIP_OUTRO_PUT_URL: "http://put", CLIP_BRAND_LOGO_URL: "http://logo", @@ -45,4 +70,34 @@ describe("outro-branding", () => { CLIP_BRAND_ACCENT: "33 94% 58%", }); }); + + it("clip output mirrors the pod's job-fields", () => { + expect( + clipOutputFromSpec({ output: { resolution: "720p", fps: 30 } }), + ).toEqual({ dims: "1280x720", fps: 30 }); + expect( + clipOutputFromSpec({ output: { resolution: "1080p", fps: 60 } }), + ).toEqual({ dims: "1920x1080", fps: 60 }); + expect(clipOutputFromSpec({})).toEqual({ dims: "1920x1080", fps: 60 }); + expect(clipOutputFromSpec(null)).toEqual({ dims: "1920x1080", fps: 60 }); + }); + + it("shared clip output is the output every spec has", () => { + const hd30 = { output: { resolution: "720p", fps: 30 } }; + expect(sharedClipOutput([hd30, hd30])).toEqual({ + dims: "1280x720", + fps: 30, + }); + }); + + it("shared clip output is null when the specs differ or there are none", () => { + const hd30 = { output: { resolution: "720p", fps: 30 } }; + expect( + sharedClipOutput([hd30, { output: { resolution: "720p", fps: 60 } }]), + ).toBeNull(); + expect( + sharedClipOutput([hd30, { output: { resolution: "1080p", fps: 30 } }]), + ).toBeNull(); + expect(sharedClipOutput([])).toBeNull(); + }); }); diff --git a/src/matches/game-streamer/outro-branding.ts b/src/matches/game-streamer/outro-branding.ts index e8e7fdcac..e12d11d62 100644 --- a/src/matches/game-streamer/outro-branding.ts +++ b/src/matches/game-streamer/outro-branding.ts @@ -21,6 +21,36 @@ export function outroCacheKey(args: { return `branding/outro_${args.version}_${args.dims}_${args.fps}.mp4`; } +export interface ClipOutput { + dims: string; + fps: number; +} + +// The dims and fps the render pod gives a clip. Mirrors game-streamer's +// clip-helpers.mjs job-fields, which sets each batch job's CLIP_OUTPUT_DIMS +// and CLIP_OUTPUT_FPS from its spec. +export function clipOutputFromSpec(spec: unknown): ClipOutput { + const output = ( + spec as { output?: { resolution?: unknown; fps?: unknown } } | null + )?.output; + const fps = Number.parseInt(String(output?.fps), 10); + return { + dims: output?.resolution === "720p" ? "1280x720" : "1920x1080", + fps: Number.isFinite(fps) ? fps : 60, + }; +} + +// The output every spec shares, or null when they differ (or there are none). +export function sharedClipOutput(specs: unknown[]): ClipOutput | null { + const [first, ...rest] = specs.map((spec) => clipOutputFromSpec(spec)); + if (!first) { + return null; + } + return rest.every((o) => o.dims === first.dims && o.fps === first.fps) + ? first + : null; +} + export interface OutroEnvHit { hit: true; cacheUrl: string; From f03f8affb80b50e0717b25c55c7f7d35cb2e2165 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 21:57:01 +0200 Subject: [PATCH 5/8] docs: note an empty CLIP_BRAND_NAME keeps the stock wordmark --- src/matches/game-streamer/game-streamer.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index 9f1fbfd1c..cee6dbd95 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -392,6 +392,10 @@ export class GameStreamerService { if (!logoPath) { return {}; } + // An empty brandName (no public.brand_name) keeps the stock 5STACK.gg + // wordmark and tagline next to the custom logo, the same fallback the + // web uses (brandName || "5Stack"). The empty name is still part of the + // version hash, so keep the CLIP_BRAND_NAME key with an empty value. const brandName = (await this.readSetting("public.brand_name")) ?? ""; // The web themes from the dark palette only (the light-mode // `public.color_*` rows are deleted at boot), so this is its accent. From d6e401388c6dcd0f01cbc86bf5b5c45a58344f31 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 22:19:22 +0200 Subject: [PATCH 6/8] fix: compute resolveOutroBranding inside the dispatch try (code review) --- src/matches/clips/clips.service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/matches/clips/clips.service.ts b/src/matches/clips/clips.service.ts index 50ace77ed..4e985a121 100644 --- a/src/matches/clips/clips.service.ts +++ b/src/matches/clips/clips.service.ts @@ -216,12 +216,11 @@ export class ClipsService { `dest=${spec.destination}`, ); - const outroEnv = await this.gameStreamer.resolveOutroBranding( - dims, - spec.output.fps, - ); - try { + const outroEnv = await this.gameStreamer.resolveOutroBranding( + dims, + spec.output.fps, + ); await this.gameStreamer.dispatchClipRenderToPod(session.id, { job_id: jobId, token: sessionToken, From e9c8c94fd44c74eb81635b6735202157a9e1459c Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 20 Jun 2026 22:30:15 +0200 Subject: [PATCH 7/8] fix: pass S3_PUBLIC_ORIGIN to the demo-session pod for the outro URL allowlist --- src/matches/game-streamer/game-streamer.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index cee6dbd95..f18418aca 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -918,6 +918,9 @@ export class GameStreamerService { name: "CLIP_BAKE_BRANDING", value: await this.resolveClipBakeBranding(), }, + // Trusted S3 presign origin for render-clip.mjs's outro-env URL allowlist + // (independent of the demo source, so faceit/external demos still brand). + { name: "S3_PUBLIC_ORIGIN", value: this.appConfig.demosDomain }, ]; if (options.roundTicks != null) { env.push({ From 6a885278c1d754faccf1a397c82270e46866ca63 Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 2 Oct 2026 10:53:52 +0200 Subject: [PATCH 8/8] fix: derive S3_PUBLIC_ORIGIN from a presigned URL, validate the accent The demo-session pod got S3_PUBLIC_ORIGIN=https://DEMOS_DOMAIN, but S3Service.getPresignedUrl signs against the demos domain only for the in-cluster store (rustfs or minio). A remote store is signed against its own endpoint, path or virtual-host style, so on those installs every outro URL failed render-clip.mjs's allowlist (it accepts the outro URLs only when their origin equals S3_PUBLIC_ORIGIN) and every on-demand clip fell back to the stock outro. Batch highlights were not affected: their pod gets the outro env directly, without the allowlist. S3Service.getPresignedUrlOrigin presigns a probe key for the default bucket the way resolveOutroBranding does and returns the URL's origin, so the value follows whatever addressing files-sdk picks instead of re-implementing it. Checked against files-sdk 2.3.0 and the AWS presigner: signing is local (the presign middleware returns before the HTTP handler) and the GET and PUT URLs come from the same client, so one probe covers the cache GET, the cache PUT and the logo GET. On any error the pod still gets the demos domain, with a warning. resolveOutroBranding also sent public.color_dark_tactical_amber raw as CLIP_BRAND_ACCENT, which game-streamer interpolates into CSS in headless Chromium. outroAccentFromSetting keeps the setting only when it is an HSL triple as the web saves it ("33 94% 58%" from its color picker, "224.3 76.3% 48%" from its defaults) and falls back to DEFAULT_OUTRO_ACCENT otherwise, so the version hash covers the accent that is really rendered and game-streamer, which now drops the whole outro env for an invalid accent, never gets one it must reject. The pattern is the one game-streamer's outro-env.mjs uses. --- .../game-streamer.service.spec.ts | 81 ++++++++++++++++++- .../game-streamer/game-streamer.service.ts | 34 ++++++-- .../game-streamer/outro-branding.spec.ts | 32 ++++++++ src/matches/game-streamer/outro-branding.ts | 17 ++++ src/s3/s3.service.spec.ts | 25 ++++++ src/s3/s3.service.ts | 15 ++++ 6 files changed, 195 insertions(+), 9 deletions(-) diff --git a/src/matches/game-streamer/game-streamer.service.spec.ts b/src/matches/game-streamer/game-streamer.service.spec.ts index b9f2275be..71ded6d2b 100644 --- a/src/matches/game-streamer/game-streamer.service.spec.ts +++ b/src/matches/game-streamer/game-streamer.service.spec.ts @@ -6,6 +6,11 @@ jest.mock("@kubernetes/client-node", () => ({ })); import { GameStreamerService } from "./game-streamer.service"; +import { + DEFAULT_OUTRO_ACCENT, + computeOutroVersion, + outroCacheKey, +} from "./outro-branding"; describe("GameStreamerService", () => { let service: GameStreamerService; @@ -16,6 +21,12 @@ describe("GameStreamerService", () => { resolveEnabled: jest.Mock; bundleUrl: jest.Mock; }; + let s3: { + stat: jest.Mock; + has: jest.Mock; + getPresignedUrl: jest.Mock; + getPresignedUrlOrigin: jest.Mock; + }; const config = { get: (key: string) => { @@ -23,7 +34,10 @@ describe("GameStreamerService", () => { return { namespace: "test" }; } if (key === "app") { - return { relayDomain: "https://tv.example.test" }; + return { + relayDomain: "https://tv.example.test", + demosDomain: "https://demos.example.test", + }; } return {} as any; }, @@ -37,6 +51,12 @@ describe("GameStreamerService", () => { resolveEnabled: jest.fn(), bundleUrl: jest.fn().mockResolvedValue(null), }; + s3 = { + stat: jest.fn(), + has: jest.fn(), + getPresignedUrl: jest.fn(), + getPresignedUrlOrigin: jest.fn(), + }; service = new GameStreamerService( logger as any, @@ -48,7 +68,7 @@ describe("GameStreamerService", () => { {} as any, {} as any, broadcastHuds as any, - {} as any, + s3 as any, ); }); @@ -334,6 +354,63 @@ describe("GameStreamerService", () => { }); }); + describe("resolveS3PublicOrigin", () => { + // A remote store signs the outro URLs against its own host, so the + // demos domain would fail the pod's outro URL allowlist. + it("is the origin the outro urls are presigned against", async () => { + s3.getPresignedUrlOrigin.mockResolvedValue( + "https://5stack.s3.example.test", + ); + + await expect((service as any).resolveS3PublicOrigin()).resolves.toBe( + "https://5stack.s3.example.test", + ); + }); + + it("falls back to the demos domain so the pod still starts", async () => { + s3.getPresignedUrlOrigin.mockRejectedValue(new Error("bad endpoint")); + + await expect((service as any).resolveS3PublicOrigin()).resolves.toBe( + "https://demos.example.test", + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("bad endpoint"), + ); + }); + }); + + describe("resolveOutroBranding", () => { + // game-streamer drops the whole outro env for an accent that is not an + // HSL triple, so the stock amber is what gets rendered, and the version + // has to hash that accent rather than the setting. + it("renders and hashes the stock accent when the setting is not an HSL triple", async () => { + const settings: Record = { + "public.logo_url": "branding/logo.png", + "public.brand_name": "Adria", + "public.color_dark_tactical_amber": "0 0% 0%) url(http://10.0.0.1/x", + }; + hasura.query.mockImplementation(async (query: any) => ({ + settings_by_pk: { value: settings[query.settings_by_pk.__args.name] }, + })); + s3.stat.mockResolvedValue({ etag: "logo-etag" }); + s3.has.mockResolvedValue(false); + s3.getPresignedUrl.mockImplementation( + async (key: string) => `https://s3.example.test/${key}`, + ); + + const env = await service.resolveOutroBranding("1920x1080", 60); + + expect(env.CLIP_BRAND_ACCENT).toBe(DEFAULT_OUTRO_ACCENT); + const version = computeOutroVersion({ + brandName: "Adria", + accent: DEFAULT_OUTRO_ACCENT, + etag: "logo-etag", + }); + const key = outroCacheKey({ version, dims: "1920x1080", fps: 60 }); + expect(env.CLIP_OUTRO_PUT_URL).toBe(`https://s3.example.test/${key}`); + }); + }); + describe("reportStatus", () => { const streamSetOf = () => hasura.mutation.mock.calls[0][0].update_match_streams.__args._set; diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index f18418aca..86a4198be 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -26,7 +26,7 @@ import { } from "src/broadcast-huds/broadcast-huds.service"; import { S3Service } from "../../s3/s3.service"; import { - DEFAULT_OUTRO_ACCENT, + outroAccentFromSetting, computeOutroVersion, outroCacheKey, buildOutroEnv, @@ -399,9 +399,11 @@ export class GameStreamerService { const brandName = (await this.readSetting("public.brand_name")) ?? ""; // The web themes from the dark palette only (the light-mode // `public.color_*` rows are deleted at boot), so this is its accent. - const accent = - (await this.readSetting("public.color_dark_tactical_amber")) ?? - DEFAULT_OUTRO_ACCENT; + // Anything but an HSL triple renders the stock amber, which is then + // what the version hash covers. + const accent = outroAccentFromSetting( + await this.readSetting("public.color_dark_tactical_amber"), + ); let etag = logoPath; try { @@ -439,6 +441,22 @@ export class GameStreamerService { } } + // The origin render-clip.mjs accepts the outro URLs from. They are signed + // through getPresignedUrl, which uses the demos domain only for the + // in-cluster store; a remote store signs against its own host. + private async resolveS3PublicOrigin(): Promise { + try { + return await this.s3.getPresignedUrlOrigin(); + } catch (error) { + this.logger.warn( + `failed to resolve the S3 presign origin, using the demos domain: ${ + (error as Error)?.message ?? error + }`, + ); + return this.appConfig.demosDomain; + } + } + public async resolveClipFps(): Promise<30 | 60> { const value = (await this.readSetting(SystemSettingName.ClipFps)) ?? @@ -918,9 +936,11 @@ export class GameStreamerService { name: "CLIP_BAKE_BRANDING", value: await this.resolveClipBakeBranding(), }, - // Trusted S3 presign origin for render-clip.mjs's outro-env URL allowlist - // (independent of the demo source, so faceit/external demos still brand). - { name: "S3_PUBLIC_ORIGIN", value: this.appConfig.demosDomain }, + // Trusted origin for render-clip.mjs's outro-env URL allowlist: the + // origin resolveOutroBranding's presigned URLs really carry (a remote + // store signs against its own host, not the demos domain). Independent + // of the demo source, so faceit/external demos still brand. + { name: "S3_PUBLIC_ORIGIN", value: await this.resolveS3PublicOrigin() }, ]; if (options.roundTicks != null) { env.push({ diff --git a/src/matches/game-streamer/outro-branding.spec.ts b/src/matches/game-streamer/outro-branding.spec.ts index dd18d1fae..399269a03 100644 --- a/src/matches/game-streamer/outro-branding.spec.ts +++ b/src/matches/game-streamer/outro-branding.spec.ts @@ -5,6 +5,7 @@ import { buildOutroEnv, clipOutputFromSpec, sharedClipOutput, + outroAccentFromSetting, } from "./outro-branding"; describe("outro-branding", () => { @@ -12,6 +13,37 @@ describe("outro-branding", () => { expect(DEFAULT_OUTRO_ACCENT).toBe("33 94% 58%"); }); + // The web saves whole numbers from its color picker and keeps the + // decimals of its stock palette. + it.each([ + ["33 94% 58%", "33 94% 58%"], + ["0 0% 100%", "0 0% 100%"], + ["360 100% 100%", "360 100% 100%"], + ["224.3 76.3% 48%", "224.3 76.3% 48%"], + ["217.2 91.2% 59.8%", "217.2 91.2% 59.8%"], + [" 224.3 76.3% 48%\n", "224.3 76.3% 48%"], + ])("accent keeps the saved triple %p as %p", (value, accent) => { + expect(outroAccentFromSetting(value)).toBe(accent); + }); + + it.each([ + undefined, + "", + " ", + "orange", + "#f59e0b", + "hsl(33 94% 58%)", + "33, 94%, 58%", + "33 94 58", + "33 94% 58%", + "1000 94% 58%", + "33 94% 58%; background: url(http://attacker.test/x)", + "33 94% 58%)} body { display: none", + "33 94% 58%\n", + ])("accent falls back to the stock amber for %p", (value) => { + expect(outroAccentFromSetting(value)).toBe(DEFAULT_OUTRO_ACCENT); + }); + it("version is deterministic and 12 chars", () => { const a = computeOutroVersion({ brandName: "ACME", diff --git a/src/matches/game-streamer/outro-branding.ts b/src/matches/game-streamer/outro-branding.ts index e12d11d62..a25719523 100644 --- a/src/matches/game-streamer/outro-branding.ts +++ b/src/matches/game-streamer/outro-branding.ts @@ -2,6 +2,23 @@ import { createHash } from "node:crypto"; export const DEFAULT_OUTRO_ACCENT = "33 94% 58%"; +// An HSL triple as the web saves a theme color: whole numbers from its +// color picker ("33 94% 58%") or the decimals of its stock palette +// ("224.3 76.3% 48%"). game-streamer checks CLIP_BRAND_ACCENT against the +// same pattern and drops the branded outro for anything else. +const OUTRO_ACCENT_PATTERN = + /^\d{1,3}(\.\d+)? \d{1,3}(\.\d+)?% \d{1,3}(\.\d+)?%$/; + +// The accent the outro renders with: the setting when it is such a triple +// (trimmed), the stock amber otherwise, so the outro version covers the +// accent that is really rendered. +export function outroAccentFromSetting(value: string | undefined): string { + const accent = value?.trim(); + return accent && OUTRO_ACCENT_PATTERN.test(accent) + ? accent + : DEFAULT_OUTRO_ACCENT; +} + export function computeOutroVersion(parts: { brandName: string; accent: string; diff --git a/src/s3/s3.service.spec.ts b/src/s3/s3.service.spec.ts index a05024dea..7cec56f76 100644 --- a/src/s3/s3.service.spec.ts +++ b/src/s3/s3.service.spec.ts @@ -198,3 +198,28 @@ describe("S3Service.removePrefix", () => { ).rejects.toThrow(/AccessDenied/); }); }); + +// getPresignedUrl is stubbed for the same reason as above: its files-sdk path +// cannot run here. +describe("S3Service presigned url origin", () => { + it("is the origin of a url getPresignedUrl signs for the bucket", async () => { + const service = build("s3.us-east-005.backblazeb2.com", "443", true, { + forcePathStyle: false, + }); + const sign = jest + .spyOn(service, "getPresignedUrl") + .mockResolvedValue( + "https://5stack.s3.us-east-005.backblazeb2.com/probe?X-Amz-Signature=x", + ); + + await expect(service.getPresignedUrlOrigin()).resolves.toBe( + "https://5stack.s3.us-east-005.backblazeb2.com", + ); + + // The default bucket, signed for outside the cluster like the URLs that + // get handed to pods. + const [, bucket, , , useLocal] = sign.mock.calls[0]; + expect(bucket).toBe("5stack"); + expect(useLocal).toBeFalsy(); + }); +}); diff --git a/src/s3/s3.service.ts b/src/s3/s3.service.ts index 4e1090559..3696fc974 100644 --- a/src/s3/s3.service.ts +++ b/src/s3/s3.service.ts @@ -531,6 +531,21 @@ export class S3Service implements OnModuleDestroy { return await client.url(key, { expiresIn: expires }); } + // The origin of the URLs getPresignedUrl signs for a bucket (without + // useLocal): the public demos domain for the in-cluster store, otherwise the + // store's own host (with the bucket in it under virtual-host style). It is + // read off a real signed URL rather than rebuilt from the config, so it + // follows whatever addressing the SDK picks; GET and PUT URLs come from the + // same client and share it. Signing is local, so the probe key is never + // requested. + public async getPresignedUrlOrigin( + bucket: string = this.bucket, + ): Promise { + const url = await this.getPresignedUrl("origin-probe", bucket, 60, "get"); + + return new URL(url).origin; + } + public async createMultipartUpload( key: string, bucket: string = this.bucket,