From e42444253b2ed988d550e82f25441563e1d3542f Mon Sep 17 00:00:00 2001 From: Kristians Laukis <7798036+LCrew@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:51:58 +0300 Subject: [PATCH] feat(huds): import broadcast HUDs from the panel The HUD burned into live streams, demo playback and batch highlights was a closed two-value choice. resolveHudMode read `default_hud_mode`, stamped HUD_MODE onto the pod, and the spec-server forwarded it as a `?variant=` on the one bundle JTs Hud Manager ships -- horizontal and vertical were never separate HUDs, they are two layouts of `default`. This adds the dimension that was missing: a hudId. JTs Hud Manager already knows how to hold more than one (~/jthm-huds, POST /api/huds/upload-zip, and an /api/overlay/start that has always taken a hudId the pod never varied), so a broadcast_huds row is an entry in that library and the two shipped layouts are seeded as rows -- the pickers become "list the library" without the familiar options disappearing. The archive is stored whole and never extracted here: JTHud's own upload-zip does that inside the pod, signature verification included, and a second extractor would only be a second thing to keep in agreement with it. What we do own is refusing an archive it would mishandle -- its extract writes entries with path.join and no traversal guard, and takes the hud id straight out of the archive when hud.json sits one level deep. The panel is the only thing that ever uploads to it, so the panel is where a hostile bundle has to be stopped. An imported HUD carries no variant of its own, and the empty string is the answer rather than a fallback layout: handing an imported bundle `?variant=horizontal` names a layout its hud.json very likely does not declare. HUD_MODE keeps carrying a real layout, since an older game-streamer image reads that one. GET /huds/:slug/bundle.zip is deliberately unauthenticated -- JTs Hud Manager inside the pod holds no 5stack session, and it serves nothing but an archive an administrator uploaded. /hud-data/:matchId stays cluster-internal as before. Nothing regresses on an older game-streamer image: HUD_MODE is still sent carrying the variant, and setHudMode keeps its signature, with the three old layout names mapping onto the seeded builtin slugs. Needs the matching game-streamer change to load an imported bundle, and the /huds ingress path in 5stack-panel for the browser-side import. --- .../default/tables/public_broadcast_huds.yaml | 84 ++++ .../databases/default/tables/tables.yaml | 1 + .../1887000000000_broadcast_huds/down.sql | 1 + .../1887000000000_broadcast_huds/up.sql | 116 ++++++ package.json | 2 + src/app.module.ts | 2 + .../broadcast-huds.controller.ts | 100 +++++ src/broadcast-huds/broadcast-huds.module.ts | 14 + .../broadcast-huds.service.spec.ts | 335 ++++++++++++++++ src/broadcast-huds/broadcast-huds.service.ts | 359 ++++++++++++++++++ .../game-streamer/game-streamer.module.ts | 2 + .../game-streamer.nade-previews.spec.ts | 3 + .../game-streamer.service.spec.ts | 3 + .../game-streamer/game-streamer.service.ts | 132 +++++-- src/matches/matches.controller.ts | 10 +- src/system/enums/SystemSettingName.ts | 8 + yarn.lock | 12 + 17 files changed, 1154 insertions(+), 30 deletions(-) create mode 100644 hasura/metadata/databases/default/tables/public_broadcast_huds.yaml create mode 100644 hasura/migrations/default/1887000000000_broadcast_huds/down.sql create mode 100644 hasura/migrations/default/1887000000000_broadcast_huds/up.sql create mode 100644 src/broadcast-huds/broadcast-huds.controller.ts create mode 100644 src/broadcast-huds/broadcast-huds.module.ts create mode 100644 src/broadcast-huds/broadcast-huds.service.spec.ts create mode 100644 src/broadcast-huds/broadcast-huds.service.ts diff --git a/hasura/metadata/databases/default/tables/public_broadcast_huds.yaml b/hasura/metadata/databases/default/tables/public_broadcast_huds.yaml new file mode 100644 index 000000000..29fc38f8e --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_broadcast_huds.yaml @@ -0,0 +1,84 @@ +table: + name: broadcast_huds + schema: public +select_permissions: + # Defined on guest only: every other role inherits it up the chain in + # inherited_roles.yaml, and an explicit block overrides rather than merges -- + # so repeating this per role is how the copies drift apart. + # + # The HUD pickers in the demo player and the stream deck are not + # administrator-only surfaces, and a row carries nothing sensitive: a name, a + # layout id, a thumbnail. storage_key is withheld below administrator so the + # object key is only ever handled by the import flow. + - role: guest + permission: + columns: + - id + - slug + - jthud_id + - variant + - name + - author + - version + - description + - source + - enabled + - thumbnail + - is_signed + - created_at + - updated_at + filter: + enabled: + _eq: true + comment: "" + # Administrators see disabled rows too -- the settings page has to list what + # it is about to re-enable -- and the storage/manifest columns the importer + # writes. + - role: administrator + permission: + columns: + - id + - slug + - jthud_id + - variant + - name + - author + - version + - description + - source + - enabled + - storage_key + - size_bytes + - thumbnail + - hud_json + - is_signed + - uploaded_by_steam_id + - created_at + - updated_at + filter: {} + comment: "" +update_permissions: + # Enable/disable and rename only. Everything describing the archive (slug, + # jthud_id, storage_key, hud_json, is_signed) is written once by the import + # flow from the bytes themselves; letting it be edited afterwards would let a + # row stop describing the zip it points at. + - role: administrator + permission: + columns: + - name + - description + - enabled + filter: {} + check: {} + comment: "" +delete_permissions: + # Builtins have no archive to remove and are what the pod falls back to, so + # they are disabled rather than deleted. Imported rows are deleted through the + # api action, which also removes the object -- but the row delete is permitted + # here so a half-imported row is never stuck. + - role: administrator + permission: + filter: + source: + _eq: imported + comment: "" diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index cff6b5f3a..917eb8bd4 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -4,6 +4,7 @@ - "!include public_api_keys.yaml" - "!include public_award_recipients.yaml" - "!include public_awards.yaml" +- "!include public_broadcast_huds.yaml" - "!include public_chat_read_state.yaml" - "!include public_clip_render_jobs.yaml" - "!include public_custom_pages.yaml" diff --git a/hasura/migrations/default/1887000000000_broadcast_huds/down.sql b/hasura/migrations/default/1887000000000_broadcast_huds/down.sql new file mode 100644 index 000000000..b74c790f9 --- /dev/null +++ b/hasura/migrations/default/1887000000000_broadcast_huds/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.broadcast_huds; diff --git a/hasura/migrations/default/1887000000000_broadcast_huds/up.sql b/hasura/migrations/default/1887000000000_broadcast_huds/up.sql new file mode 100644 index 000000000..3c6d9c5f5 --- /dev/null +++ b/hasura/migrations/default/1887000000000_broadcast_huds/up.sql @@ -0,0 +1,116 @@ +-- The broadcast HUD library. +-- +-- Until now the HUD burned into every stream was a closed two-value choice: +-- resolveHudMode() read the `default_hud_mode` setting, stamped HUD_MODE onto +-- the pod, and the spec-server forwarded it as a `?variant=` query param on the +-- ONE bundle JTs Hud Manager ships (`default`). horizontal and vertical were +-- never separate HUDs -- they are two layouts of the same bundle. +-- +-- This table adds the dimension that was missing: a hudId. JTs Hud Manager +-- already knows how to hold more than one (`~/jthm-huds`, POST +-- /api/huds/upload-zip, and an /api/overlay/start that has always accepted a +-- hudId the pod never varied). So a row here is "an entry in that library", +-- and the two shipped layouts are seeded as rows so nothing regresses. +CREATE TABLE IF NOT EXISTS public.broadcast_huds ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + + -- Ours: names the row in the panel and the bundle download URL + -- (/huds//bundle.zip). Path-safe because it lands in a URL path. + slug text NOT NULL, + + -- Theirs: what the pod sends as `hudId` to JTs Hud Manager's + -- /api/overlay/start. Deliberately NOT the same column as slug -- on import + -- JTHud derives the id itself (the top-level folder name when hud.json sits + -- one level deep, otherwise the uploaded filename), so the two can differ + -- and the panel must remember which one the pod has to ask for. + jthud_id text NOT NULL, + + -- The `?variant=` appended to that hudId, when the bundle declares layouts + -- in its hud.json. Carries horizontal/vertical for the two seeded rows; + -- NULL means "the bundle's own default layout". + variant text, + + name text NOT NULL, + author text, + version text, + description text, + + -- 'builtin' rows describe what already ships inside the image: they own no + -- archive and can never be deleted, only disabled. 'imported' rows have a + -- zip in object storage. + source text NOT NULL DEFAULT 'imported', + + enabled boolean NOT NULL DEFAULT true, + + -- The whole archive as one object. We deliberately do not extract it: + -- JTs Hud Manager's own upload-zip endpoint does that inside the pod, + -- including signature verification, so a second extractor here would only + -- be a second thing to keep in agreement with it. + storage_key text, + size_bytes bigint, + + -- thumb.png/thumb.jpg out of the archive, inlined as a data URL. Small, and + -- it keeps the library listing from needing a second authenticated fetch per + -- row just to render a card. + thumbnail text, + + -- The parsed hud.json, kept whole. Its schema is not publicly specified + -- beyond name/author/version/thumb, so anything we do not model today is + -- still here when we learn what it meant. + hud_json jsonb, + + -- JTHud verifies a signed bundle against the `key` file beside hud.json. + -- Recorded at import so the library can show it without re-reading the zip. + is_signed boolean NOT NULL DEFAULT false, + + uploaded_by_steam_id bigint REFERENCES public.players (steam_id) + ON UPDATE CASCADE ON DELETE SET NULL, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (id), + UNIQUE (slug), + + -- Same shape the plugin catalog already enforces on game_plugins.slug, and + -- for the same reason: it is interpolated into a path. + CONSTRAINT broadcast_huds_slug_is_path_safe + CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'), + + -- jthud_id is what JTHud itself accepts as a directory name; it sanitises to + -- this set on upload, so anything outside it could never match a real HUD. + CONSTRAINT broadcast_huds_jthud_id_is_path_safe + CHECK (jthud_id ~ '^[A-Za-z0-9_-]+$'), + + CONSTRAINT broadcast_huds_source_is_known + CHECK (source IN ('builtin', 'imported')), + + -- A builtin has nothing to download and an import is useless without it. + CONSTRAINT broadcast_huds_archive_matches_source + CHECK ( + (source = 'builtin' AND storage_key IS NULL) + OR (source = 'imported' AND storage_key IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_broadcast_huds_enabled + ON public.broadcast_huds (enabled); + +-- Exactly the two layouts the image ships today, as rows. Seeding them is what +-- lets the pickers become "list the library" instead of "list a hardcoded +-- union", without the two familiar options disappearing from the UI. +-- +-- ON CONFLICT DO NOTHING rather than an upsert: an operator who renamed or +-- disabled one of these meant it, and this file is re-applied on every boot. +INSERT INTO public.broadcast_huds + (slug, jthud_id, variant, name, description, source) +VALUES + ('default-horizontal', 'default', 'horizontal', + 'JTs Hud (Horizontal)', + 'The layout bundled with JTs Hud Manager, arranged horizontally.', + 'builtin'), + ('default-vertical', 'default', 'vertical', + 'JTs Hud (Vertical)', + 'The layout bundled with JTs Hud Manager, arranged vertically.', + 'builtin') +ON CONFLICT (slug) DO NOTHING; diff --git a/package.json b/package.json index f6c12de62..052b090a9 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@nestjs/throttler": "^6.4.0", "@nestjs/websockets": "^11.1.3", "@types/uuid": "^11.0.0", + "adm-zip": "^0.6.0", "archiver": "^7.0.1", "bullmq": "^5.56.0", "cache-manager": "^7.0.1", @@ -93,6 +94,7 @@ "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.3", "@testcontainers/postgresql": "^12.0.4", + "@types/adm-zip": "^0.5.8", "@types/archiver": "^6.0.2", "@types/express": "^5.0.0", "@types/express-session": "^1.18.0", diff --git a/src/app.module.ts b/src/app.module.ts index c73c5fa57..f19f4a13c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -47,6 +47,7 @@ import { SanctionsModule } from "./sanctions/sanctions.module"; import { K8sModule } from "./k8s/k8s.module"; import { FileManagerModule } from "./file-manager/file-manager.module"; import { BrandingModule } from "./branding/branding.module"; +import { BroadcastHudsModule } from "./broadcast-huds/broadcast-huds.module"; import { AvatarsModule } from "./avatars/avatars.module"; import { AwardsModule } from "./awards/awards.module"; import { FixturesModule } from "./fixtures/fixtures.module"; @@ -147,6 +148,7 @@ import { UtilityModule } from "./utility/utility.module"; K8sModule, FileManagerModule, BrandingModule, + BroadcastHudsModule, AvatarsModule, AwardsModule, FixturesModule, diff --git a/src/broadcast-huds/broadcast-huds.controller.ts b/src/broadcast-huds/broadcast-huds.controller.ts new file mode 100644 index 000000000..f6c3935bd --- /dev/null +++ b/src/broadcast-huds/broadcast-huds.controller.ts @@ -0,0 +1,100 @@ +import { + BadRequestException, + Controller, + Delete, + ForbiddenException, + Get, + MaxFileSizeValidator, + NotFoundException, + Param, + ParseFilePipe, + Post, + Req, + Res, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { Request, Response } from "express"; +import { User } from "src/auth/types/User"; +import { isRoleAbove } from "src/utilities/isRoleAbove"; +import { BroadcastHudsService } from "./broadcast-huds.service"; + +const MAX_UPLOAD_BYTES = 64 * 1024 * 1024; + +@Controller("huds") +export class BroadcastHudsController { + constructor(private readonly huds: BroadcastHudsService) {} + + // Unauthenticated, and deliberately so: this is fetched by JTs Hud Manager + // running inside the game-streamer pod, which holds no 5stack session. It + // serves nothing but an archive an administrator uploaded -- no player data + // -- which is why /hud-data/:matchId next door stays cluster-internal and + // this does not have to. + @Get(":slug/bundle.zip") + public async bundle(@Param("slug") slug: string, @Res() response: Response) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { + throw new NotFoundException("no such hud"); + } + + const bundle = await this.huds.bundle(slug); + if (!bundle) { + throw new NotFoundException("no such hud"); + } + + response.setHeader("Content-Type", "application/zip"); + response.setHeader( + "Content-Disposition", + `attachment; filename="${slug}.zip"`, + ); + if (bundle.size > 0) { + response.setHeader("Content-Length", String(bundle.size)); + } + // The bundle at a given slug never changes -- a re-import mints a new slug + // -- so a pod re-fetching one can be told not to bother. + response.setHeader("Cache-Control", "public, max-age=3600"); + + bundle.stream.pipe(response); + } + + @Post("import") + @UseInterceptors(FileInterceptor("hud")) + public async import( + @Req() request: Request, + @UploadedFile( + new ParseFilePipe({ + validators: [new MaxFileSizeValidator({ maxSize: MAX_UPLOAD_BYTES })], + }), + ) + file: Express.Multer.File, + ) { + this.requireAdmin(request); + + if (!file?.buffer?.length) { + throw new BadRequestException("no archive uploaded"); + } + + const user = request.user as User; + const hud = await this.huds.import( + file.buffer, + file.originalname ?? "hud.zip", + user.steam_id, + ); + + return { success: true, hud }; + } + + @Delete(":slug") + public async remove(@Req() request: Request, @Param("slug") slug: string) { + this.requireAdmin(request); + await this.huds.remove(slug); + return { success: true }; + } + + private requireAdmin(request: Request) { + const user = request.user as User | undefined; + if (!user || !isRoleAbove(user.role, "administrator")) { + throw new ForbiddenException(); + } + } +} diff --git a/src/broadcast-huds/broadcast-huds.module.ts b/src/broadcast-huds/broadcast-huds.module.ts new file mode 100644 index 000000000..3ba5197a0 --- /dev/null +++ b/src/broadcast-huds/broadcast-huds.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { loggerFactory } from "src/utilities/LoggerFactory"; +import { PostgresModule } from "src/postgres/postgres.module"; +import { S3Module } from "src/s3/s3.module"; +import { BroadcastHudsController } from "./broadcast-huds.controller"; +import { BroadcastHudsService } from "./broadcast-huds.service"; + +@Module({ + imports: [PostgresModule, S3Module], + controllers: [BroadcastHudsController], + providers: [BroadcastHudsService, loggerFactory()], + exports: [BroadcastHudsService], +}) +export class BroadcastHudsModule {} diff --git a/src/broadcast-huds/broadcast-huds.service.spec.ts b/src/broadcast-huds/broadcast-huds.service.spec.ts new file mode 100644 index 000000000..3f349efd9 --- /dev/null +++ b/src/broadcast-huds/broadcast-huds.service.spec.ts @@ -0,0 +1,335 @@ +import AdmZip from "adm-zip"; +import { crc32 } from "zlib"; +import { BroadcastHudsService } from "./broadcast-huds.service"; + +// The panel is the only thing that ever uploads to JTs Hud Manager's +// upload-zip, and that endpoint extracts with no traversal guard and takes the +// hud id straight out of the archive. So these are the tests for the only place +// a hostile bundle can be stopped. +describe("BroadcastHudsService.import", () => { + const zipOf = ( + files: Array<[string, string | Buffer]>, + ): Buffer => { + const zip = new AdmZip(); + for (const [name, content] of files) { + zip.addFile( + name, + Buffer.isBuffer(content) ? content : Buffer.from(content), + ); + } + return zip.toBuffer(); + }; + + // AdmZip's *writer* sanitises entry names -- it strips leading slashes and + // resolves away `..` -- so a hostile archive cannot be built with it, and a + // test that tried would silently assert nothing. Real zips have no such + // manners, so these are emitted byte by byte: stored (uncompressed) entries + // with whatever name we say. + const rawZipOf = (files: Array<[string, string]>): Buffer => { + const locals: Array = []; + const centrals: Array = []; + let offset = 0; + + for (const [name, content] of files) { + const nameBuf = Buffer.from(name, "utf8"); + const data = Buffer.from(content, "utf8"); + const crc = crc32(data); + + const local = Buffer.alloc(30 + nameBuf.length); + local.writeUInt32LE(0x04034b50, 0); // local file header signature + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(0, 8); // method: stored + local.writeUInt16LE(0, 10); // mod time + local.writeUInt16LE(0, 12); // mod date + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); // compressed size + local.writeUInt32LE(data.length, 22); // uncompressed size + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); // extra length + nameBuf.copy(local, 30); + locals.push(local, data); + + const central = Buffer.alloc(46 + nameBuf.length); + central.writeUInt32LE(0x02014b50, 0); // central directory signature + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); // extra + central.writeUInt16LE(0, 32); // comment + central.writeUInt16LE(0, 34); // disk number + central.writeUInt16LE(0, 36); // internal attrs + central.writeUInt32LE(0, 38); // external attrs + central.writeUInt32LE(offset, 42); // local header offset + nameBuf.copy(central, 46); + centrals.push(central); + + offset += local.length + data.length; + } + + const centralBuf = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); // end of central directory + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralBuf.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...locals, centralBuf, end]); + }; + + const manifest = (extra: Record = {}) => + JSON.stringify({ name: "Test Hud", author: "Someone", version: "1.2.3", ...extra }); + + let postgres: { query: jest.Mock }; + let s3: { put: jest.Mock; remove: jest.Mock; get: jest.Mock }; + let logger: { warn: jest.Mock; log: jest.Mock; error: jest.Mock }; + let service: BroadcastHudsService; + let inserted: Array | null; + + beforeEach(() => { + inserted = null; + postgres = { + query: jest.fn(async (sql: string, params: Array) => { + if (sql.includes("INSERT INTO public.broadcast_huds")) { + inserted = params; + return [{ slug: params[0], jthud_id: params[1] }]; + } + // No existing rows -- every slug is free unless a test says otherwise. + return []; + }), + }; + s3 = { + put: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + get: jest.fn(), + }; + logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() }; + service = new BroadcastHudsService( + logger as never, + postgres as never, + s3 as never, + ); + }); + + const paramsByName = () => { + const [ + slug, + jthudId, + name, + author, + version, + description, + storageKey, + sizeBytes, + thumbnail, + hudJson, + isSigned, + ] = inserted as Array; + return { + slug, + jthudId, + name, + author, + version, + description, + storageKey, + sizeBytes, + thumbnail, + hudJson, + isSigned, + }; + }; + + it("accepts hud.json at the root and takes the hud id from the filename", async () => { + await service.import( + zipOf([ + ["hud.json", manifest()], + ["index.html", ""], + ]), + "My Cool HUD.zip", + ); + + const p = paramsByName(); + // Mirrors JTHud's own sanitise: non-alphanumerics to dashes, lowercased. + expect(p.jthudId).toBe("my-cool-hud"); + expect(p.slug).toBe("test-hud"); + expect(p.name).toBe("Test Hud"); + expect(p.author).toBe("Someone"); + expect(p.version).toBe("1.2.3"); + expect(p.isSigned).toBe(false); + expect(s3.put).toHaveBeenCalledWith( + "broadcast-huds/test-hud.zip", + expect.any(Buffer), + "application/zip", + ); + }); + + it("takes the hud id from the folder when hud.json is one level deep", async () => { + await service.import( + zipOf([ + ["my_hud/hud.json", manifest()], + ["my_hud/index.html", ""], + ]), + // Deliberately different from the folder: JTHud ignores the filename in + // this branch, so we must too or we would record an id it never creates. + "ignored-name.zip", + ); + + expect(paramsByName().jthudId).toBe("my_hud"); + }); + + it("refuses an archive with no hud.json", async () => { + await expect( + service.import(zipOf([["index.html", ""]]), "x.zip"), + ).rejects.toThrow(/no hud\.json/i); + expect(s3.put).not.toHaveBeenCalled(); + }); + + it("refuses hud.json buried more than one level deep", async () => { + await expect( + service.import(zipOf([["a/b/hud.json", manifest()]]), "x.zip"), + ).rejects.toThrow(/no hud\.json/i); + }); + + it("refuses a path that escapes the extraction directory", async () => { + const archive = rawZipOf([ + ["hud.json", manifest()], + ["../../etc/cron.d/pwn", "* * * * * root sh"], + ]); + // Guard the guard: prove the hostile name really survived into the archive, + // so this test cannot quietly start passing for the wrong reason. + expect( + new AdmZip(archive).getEntries().map((e) => e.entryName), + ).toContain("../../etc/cron.d/pwn"); + + await expect(service.import(archive, "x.zip")).rejects.toThrow( + /escapes it/i, + ); + expect(s3.put).not.toHaveBeenCalled(); + }); + + it("refuses an absolute path", async () => { + const archive = rawZipOf([ + ["hud.json", manifest()], + ["/etc/passwd", "root"], + ]); + expect( + new AdmZip(archive).getEntries().map((e) => e.entryName), + ).toContain("/etc/passwd"); + + await expect(service.import(archive, "x.zip")).rejects.toThrow( + /absolute path/i, + ); + }); + + it("refuses a windows drive-letter path", async () => { + await expect( + service.import( + zipOf([ + ["hud.json", manifest()], + ["C:/windows/system32/evil.dll", "MZ"], + ]), + "x.zip", + ), + ).rejects.toThrow(/absolute path/i); + }); + + it("ignores mac archive junk rather than rejecting the bundle", async () => { + await service.import( + zipOf([ + ["hud.json", manifest()], + ["__MACOSX/._hud.json", "junk"], + [".DS_Store", "junk"], + ]), + "mac.zip", + ); + expect(s3.put).toHaveBeenCalled(); + }); + + it("refuses something that is not a zip at all", async () => { + await expect( + service.import(Buffer.from("this is not a zip"), "x.zip"), + ).rejects.toThrow(/readable zip/i); + }); + + it("refuses an empty upload", async () => { + await expect(service.import(Buffer.alloc(0), "x.zip")).rejects.toThrow( + /empty/i, + ); + }); + + it("records a signed bundle, and still imports it when hud.json will not parse", async () => { + await service.import( + zipOf([ + // A signed bundle's hud.json is a signature envelope, not plain JSON. + ["hud.json", "-----BEGIN SIGNED-----\nnot json\n"], + ["key", "-----BEGIN PUBLIC KEY-----"], + ]), + "signed-hud.zip", + ); + + const p = paramsByName(); + expect(p.isSigned).toBe(true); + expect(p.hudJson).toBeNull(); + // Nothing to read a name out of, so the id it will install under is the name. + expect(p.name).toBe("signed-hud"); + }); + + it("inlines a thumbnail when the bundle carries one", async () => { + const png = Buffer.from("89504e470d0a1a0a", "hex"); + await service.import( + zipOf([ + ["hud.json", manifest()], + ["thumb.png", png], + ]), + "x.zip", + ); + + expect(paramsByName().thumbnail).toBe( + `data:image/png;base64,${png.toString("base64")}`, + ); + }); + + it("suffixes the slug rather than colliding with an existing HUD", async () => { + postgres.query.mockImplementation( + async (sql: string, params: Array) => { + if (sql.includes("INSERT INTO public.broadcast_huds")) { + inserted = params; + return [{ slug: params[0] }]; + } + if (sql.includes("SELECT slug FROM public.broadcast_huds")) { + return params[0] === "test-hud" ? [{ slug: "test-hud" }] : []; + } + return []; + }, + ); + + await service.import(zipOf([["hud.json", manifest()]]), "x.zip"); + expect(paramsByName().slug).toBe("test-hud-2"); + }); + + it("takes the stored object back out if the row insert fails", async () => { + postgres.query.mockImplementation(async (sql: string) => { + if (sql.includes("INSERT INTO public.broadcast_huds")) { + throw new Error("constraint violation"); + } + return []; + }); + + await expect( + service.import(zipOf([["hud.json", manifest()]]), "x.zip"), + ).rejects.toThrow(/constraint violation/); + expect(s3.remove).toHaveBeenCalledWith("broadcast-huds/test-hud.zip"); + }); +}); diff --git a/src/broadcast-huds/broadcast-huds.service.ts b/src/broadcast-huds/broadcast-huds.service.ts new file mode 100644 index 000000000..364f09b05 --- /dev/null +++ b/src/broadcast-huds/broadcast-huds.service.ts @@ -0,0 +1,359 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import AdmZip from "adm-zip"; +import { Readable } from "stream"; +import { PostgresService } from "src/postgres/postgres.service"; +import { S3Service } from "src/s3/s3.service"; +import { SystemSettingName } from "src/system/enums/SystemSettingName"; + +export type BroadcastHud = { + id: string; + slug: string; + jthud_id: string; + variant: string | null; + name: string; + author: string | null; + version: string | null; + description: string | null; + source: "builtin" | "imported"; + enabled: boolean; + storage_key: string | null; + size_bytes: string | null; + is_signed: boolean; +}; + +// A HUD bundle is a web app, not a media file: a few hundred KB of JS, CSS and +// images. The cap is generous against that and still small enough that holding +// one in memory to inspect it is unremarkable. +const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024; + +// Inlined into the row as a data URL, so it rides every listing query. Anything +// larger than this is a bundle shipping a poster instead of a thumbnail. +const MAX_THUMBNAIL_BYTES = 512 * 1024; + +// Mac zips carry these; JTs Hud Manager ignores them on extract and so must the +// validation, or a bundle zipped on a Mac is rejected for files its author +// never added. +const ARCHIVE_JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|\._)/; + +@Injectable() +export class BroadcastHudsService { + constructor( + private readonly logger: Logger, + private readonly postgres: PostgresService, + private readonly s3: S3Service, + ) {} + + // Which HUD the pod should boot. Falls back through the legacy setting so an + // instance that never opens the new page keeps exactly the layout it had: + // default_hud_mode named a *variant* of the one bundled HUD, which is now the + // pair of seeded builtin rows. + public async resolveDefault(): Promise { + const [preferred] = await this.postgres.query>( + `SELECT value FROM public.settings WHERE name = $1 LIMIT 1`, + [SystemSettingName.DefaultBroadcastHud], + ); + + if (preferred?.value) { + const hud = await this.bySlug(preferred.value); + if (hud?.enabled) { + return hud; + } + // Not an error worth failing a stream over -- a HUD can be deleted or + // disabled while it is still named as the default. + this.logger.warn( + `default broadcast hud "${preferred.value}" is missing or disabled — falling back`, + ); + } + + const [legacy] = await this.postgres.query>( + `SELECT value FROM public.settings WHERE name = $1 LIMIT 1`, + [SystemSettingName.DefaultHudMode], + ); + + const variant = legacy?.value === "vertical" ? "vertical" : "horizontal"; + return await this.bySlug(`default-${variant}`); + } + + public async bySlug(slug: string): Promise { + const [hud] = await this.postgres.query>( + `SELECT id, slug, jthud_id, variant, name, author, version, description, + source, enabled, storage_key, size_bytes, is_signed + FROM public.broadcast_huds + WHERE slug = $1 + LIMIT 1`, + [slug], + ); + return hud ?? null; + } + + // The archive is served whole; JTs Hud Manager inside the pod does the + // extracting. See import() for why we still read it here. + public async bundle( + slug: string, + ): Promise<{ stream: Readable; size: number } | null> { + const hud = await this.bySlug(slug); + if (!hud || !hud.enabled || !hud.storage_key) { + return null; + } + return { + stream: await this.s3.get(hud.storage_key), + size: Number(hud.size_bytes ?? 0), + }; + } + + public async import( + archive: Buffer, + originalName: string, + uploadedBySteamId?: string, + ): Promise { + const parsed = this.inspect(archive, originalName); + + const slug = await this.availableSlug(parsed.suggestedSlug); + const storageKey = `broadcast-huds/${slug}.zip`; + + await this.s3.put(storageKey, archive, "application/zip"); + + try { + const [hud] = await this.postgres.query>( + `INSERT INTO public.broadcast_huds + (slug, jthud_id, variant, name, author, version, description, + source, storage_key, size_bytes, thumbnail, hud_json, is_signed, + uploaded_by_steam_id) + VALUES ($1, $2, NULL, $3, $4, $5, $6, 'imported', $7, $8, $9, $10, $11, $12) + RETURNING id, slug, jthud_id, variant, name, author, version, + description, source, enabled, storage_key, size_bytes, + is_signed`, + [ + slug, + parsed.jthudId, + parsed.name, + parsed.author, + parsed.version, + parsed.description, + storageKey, + archive.length, + parsed.thumbnail, + parsed.hudJson ? JSON.stringify(parsed.hudJson) : null, + parsed.isSigned, + uploadedBySteamId ?? null, + ], + ); + return hud; + } catch (error) { + // The object is written before the row so a successful insert can never + // point at nothing. If the insert is what failed, take the object back + // out rather than leaving an orphan for the s3 sweeper to puzzle over. + await this.s3.remove(storageKey).catch(() => { + // Nothing useful to do about a failed cleanup here -- the insert error + // below is the one the caller needs. + }); + throw error; + } + } + + public async remove(slug: string): Promise { + const hud = await this.bySlug(slug); + if (!hud) { + throw new BadRequestException("no such hud"); + } + if (hud.source === "builtin") { + throw new BadRequestException( + "built-in HUDs ship inside the game-streamer image and can only be disabled", + ); + } + + await this.postgres.query( + `DELETE FROM public.broadcast_huds WHERE slug = $1`, + [slug], + ); + + if (hud.storage_key) { + await this.s3.remove(hud.storage_key).catch((error) => { + // The row is gone, so the HUD is gone as far as everything else is + // concerned; a stranded object is a storage cost, not a correctness bug. + this.logger.warn( + `removed broadcast hud ${slug} but its object survived: ${ + (error as Error)?.message ?? error + }`, + ); + }); + } + } + + // Read the archive well enough to describe it, and refuse the shapes JTs Hud + // Manager would mishandle. + // + // This is the only archive handling on our side -- we do not extract. It + // exists because JTHud's own upload-zip writes entries with + // `path.join(hudDir, relativePath)` and no traversal guard, and takes the + // hud id straight from the archive when hud.json sits one level deep. The + // panel is the only thing that ever uploads to it, so the panel is where a + // hostile archive has to be stopped. + private inspect(archive: Buffer, originalName: string) { + if (archive.length === 0) { + throw new BadRequestException("the uploaded file is empty"); + } + if (archive.length > MAX_ARCHIVE_BYTES) { + throw new BadRequestException( + `HUD bundles are limited to ${Math.floor( + MAX_ARCHIVE_BYTES / (1024 * 1024), + )}MB`, + ); + } + + let zip: AdmZip; + try { + zip = new AdmZip(archive); + } catch { + throw new BadRequestException("that file is not a readable zip archive"); + } + + const entries = zip + .getEntries() + .filter((entry) => !ARCHIVE_JUNK.test(entry.entryName)); + + if (entries.length === 0) { + throw new BadRequestException("the archive is empty"); + } + + for (const entry of entries) { + const name = entry.entryName; + if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) { + throw new BadRequestException( + `the archive contains an absolute path (${name})`, + ); + } + if (name.split("/").includes("..")) { + throw new BadRequestException( + `the archive contains a path that escapes it (${name})`, + ); + } + } + + // The same rule JTHud applies, so what we accept is exactly what it can + // install: hud.json at the root, or inside a single top-level folder. + const manifest = entries.find( + (entry) => + !entry.isDirectory && + (entry.entryName === "hud.json" || + /^[^/]+\/hud\.json$/.test(entry.entryName)), + ); + if (!manifest) { + throw new BadRequestException( + "no hud.json found — it must sit at the archive root or inside a single top-level folder", + ); + } + + const nested = manifest.entryName !== "hud.json"; + const prefix = nested + ? manifest.entryName.replace(/\/hud\.json$/, "") + "/" + : ""; + + // Mirror of JTHud's own derivation, so the id we record is the id it will + // create. Nested wins because that branch ignores the filename entirely. + const jthudId = nested + ? prefix.slice(0, -1) + : originalName + .replace(/\.zip$/i, "") + .replace(/[^a-zA-Z0-9_-]/g, "-") + .toLowerCase(); + + if (!/^[A-Za-z0-9_-]+$/.test(jthudId)) { + throw new BadRequestException( + `"${jthudId}" cannot be used as a HUD id — rename the folder inside the archive`, + ); + } + + const isSigned = entries.some( + (entry) => + !entry.isDirectory && + (entry.entryName === "key" || entry.entryName === prefix + "key"), + ); + + // A signed bundle's hud.json is a signature envelope rather than plain + // JSON. We do not verify it -- JTHud does that on install, against the key + // beside it -- so failing to parse is expected here, not an error. + let hudJson: Record | null = null; + try { + const parsed = JSON.parse(manifest.getData().toString("utf-8")) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + hudJson = parsed as Record; + } + } catch { + hudJson = null; + } + + const text = (key: string): string | null => { + const value = hudJson?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; + }; + + const name = text("name") ?? jthudId; + + return { + jthudId, + name, + author: text("author"), + version: text("version"), + description: text("description"), + isSigned, + hudJson, + thumbnail: this.readThumbnail(entries, prefix), + suggestedSlug: this.slugify(name), + }; + } + + private readThumbnail( + entries: Array, + prefix: string, + ): string | null { + const candidates: Array<[string, string]> = [ + [`${prefix}thumb.png`, "image/png"], + [`${prefix}thumb.jpg`, "image/jpeg"], + [`${prefix}thumb.jpeg`, "image/jpeg"], + ]; + + for (const [path, contentType] of candidates) { + const entry = entries.find( + (candidate) => !candidate.isDirectory && candidate.entryName === path, + ); + if (!entry) { + continue; + } + const data = entry.getData(); + if (data.length === 0 || data.length > MAX_THUMBNAIL_BYTES) { + continue; + } + return `data:${contentType};base64,${data.toString("base64")}`; + } + + return null; + } + + private slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "hud"; + } + + // The slug is unique and lands in a URL, so a second import of a HUD by the + // same name gets a suffix rather than an error the operator has to resolve by + // renaming a file. + private async availableSlug(base: string): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`; + const [existing] = await this.postgres.query>( + `SELECT slug FROM public.broadcast_huds WHERE slug = $1 LIMIT 1`, + [candidate], + ); + if (!existing) { + return candidate; + } + } + throw new BadRequestException( + `too many HUDs already named "${base}" — give this one a different name`, + ); + } +} diff --git a/src/matches/game-streamer/game-streamer.module.ts b/src/matches/game-streamer/game-streamer.module.ts index ae474f145..57c8b964b 100644 --- a/src/matches/game-streamer/game-streamer.module.ts +++ b/src/matches/game-streamer/game-streamer.module.ts @@ -18,9 +18,11 @@ import { RedisModule } from "../../redis/redis.module"; import { DemosModule } from "../../demos/demos.module"; import { K8sModule } from "../../k8s/k8s.module"; import { loggerFactory } from "../../utilities/LoggerFactory"; +import { BroadcastHudsModule } from "src/broadcast-huds/broadcast-huds.module"; @Module({ imports: [ + BroadcastHudsModule, HasuraModule, EncryptionModule, PostgresModule, 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 d13ba4caf..049b5290f 100644 --- a/src/matches/game-streamer/game-streamer.nade-previews.spec.ts +++ b/src/matches/game-streamer/game-streamer.nade-previews.spec.ts @@ -86,6 +86,9 @@ describe("GameStreamerService — nade previews", () => { {} as any, {} as any, steamAccounts as any, + // broadcastHuds -- resolveDefault() is only reached through the job-spec + // env builder, which these tests do not exercise. + { resolveDefault: jest.fn().mockResolvedValue(null) } 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 d1361eccd..0128ed7fe 100644 --- a/src/matches/game-streamer/game-streamer.service.spec.ts +++ b/src/matches/game-streamer/game-streamer.service.spec.ts @@ -30,6 +30,9 @@ describe("GameStreamerService", () => { {} as any, {} as any, {} as any, + // broadcastHuds -- resolveDefault() is only reached through the job-spec + // env builder, which these tests do not exercise. + { resolveDefault: jest.fn().mockResolvedValue(null) } as any, ); }); diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index 19e28f33c..5dae51532 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -19,6 +19,10 @@ import { GameStreamerStatusDto } from "./types/GameStreamerStatusDto"; import { AppConfig } from "../../configs/types/AppConfig"; import { SteamConfig } from "../../configs/types/SteamConfig"; import { resolveInClusterApiBase } from "../clips/clips.constants"; +import { + BroadcastHud, + BroadcastHudsService, +} from "src/broadcast-huds/broadcast-huds.service"; import { LoggingService } from "../../k8s/logging/logging.service"; import { SteamAccountService, @@ -171,6 +175,7 @@ export class GameStreamerService { private readonly demoMetadata: DemoMetadataService, private readonly loggingService: LoggingService, private readonly steamAccounts: SteamAccountService, + private readonly broadcastHuds: BroadcastHudsService, ) { this.gameServerConfig = this.config.get("gameServers"); this.appConfig = this.config.get("app"); @@ -288,29 +293,55 @@ export class GameStreamerService { return result; } - private async resolveHudMode(): Promise<"horizontal" | "vertical"> { - let value: string | undefined; + // Which HUD the pod boots, as pod env. + // + // Three variables rather than one because two different things are being + // named. HUD_ID is the JTs Hud Manager hud id -- the dimension that used to + // be permanently "default". HUD_VARIANT is the `?variant=` layout within that + // bundle, which is all HUD_MODE ever meant. HUD_MODE is still sent, carrying + // the variant, so an older game-streamer image that has never heard of + // HUD_ID boots exactly as it does today. + private async resolveHudEnv(): Promise> { + let hud: BroadcastHud | null = null; try { - const { settings_by_pk } = await this.hasura.query({ - settings_by_pk: { - __args: { name: "default_hud_mode" }, - value: true, - }, - }); - value = settings_by_pk?.value ?? undefined; + hud = await this.broadcastHuds.resolveDefault(); } catch (error) { this.logger.warn( - `failed to read default_hud_mode setting: ${(error as Error)?.message ?? error}`, + `failed to resolve the default broadcast hud: ${ + (error as Error)?.message ?? error + }`, ); } - const candidate = value || process.env.HUD_MODE || "horizontal"; - if (candidate === "vertical") return "vertical"; - if (candidate === "horizontal" || candidate === "default") - return "horizontal"; - this.logger.warn( - `default_hud_mode="${candidate}" is not one of horizontal|vertical — falling back to "horizontal"`, - ); - return "horizontal"; + + // No row at all means the library table is empty -- a fresh install whose + // migration seeded nothing, or a database mid-upgrade. The pod's own + // defaults are the bundled HUD, so saying nothing is the safe answer. + // + // An imported HUD carries no variant, and the empty string is the answer + // rather than a missing key: it means "whatever layout this bundle opens + // with". Handing an imported bundle `?variant=horizontal` names a layout + // its hud.json very likely does not declare, and a bundle that switches + // strictly on that param would render nothing. + const variant = hud ? (hud.variant ?? "") : (process.env.HUD_MODE ?? "horizontal"); + + return [ + { name: "HUD_ID", value: hud?.jthud_id ?? "default" }, + { name: "HUD_VARIANT", value: variant }, + // The legacy name, which an older image reads instead. It has to stay a + // layout it understands, so it never carries the empty string. + { name: "HUD_MODE", value: variant || "horizontal" }, + // Only set for an imported HUD: it tells the pod where to fetch the + // archive so JTs Hud Manager can install it before the overlay opens. + // A builtin is already inside the image and needs no download. + ...(hud && hud.source === "imported" + ? [{ name: "HUD_BUNDLE_URL", value: this.hudBundleUrl(hud.slug) }] + : []), + ]; + } + + // In-cluster, same base the HUD already uses to reach /hud-data/:matchId. + private hudBundleUrl(slug: string): string { + return `${resolveInClusterApiBase().replace(/\/$/, "")}/huds/${slug}/bundle.zip`; } private async readSetting(name: string): Promise { @@ -590,11 +621,53 @@ export class GameStreamerService { return { gsi: body?.gsi ?? null }; } - public async setLiveHudMode( - matchId: string, - mode: "default" | "horizontal" | "vertical", - ) { - return this.callSpec(matchId, "hud-mode", { mode }); + // `slug` names a broadcast_huds row, which resolves to the pair the pod needs: + // a JTHud hud id and an optional layout variant within it. The old + // horizontal|vertical arguments still arrive here from clients that have not + // been updated -- they are slugs of the two seeded builtin rows once mapped, + // so they resolve through the same path rather than needing a branch. + public async setLiveHud(matchId: string, slug: string) { + return this.callSpec( + matchId, + "hud-mode", + await this.resolveHudSwitchPayload(slug), + ); + } + + // What the pod's /spec/hud-mode needs in order to switch: which bundle, which + // layout inside it, and -- for an imported HUD -- where to fetch it from if + // this pod has never installed it. + // + // Shared by the live and demo paths so a HUD means the same thing in both. + // The demo path reaches the very same endpoint through demoControl, and + // having only one of them resolve slugs is how the two would drift. + public async resolveHudSwitchPayload( + slug: string, + ): Promise> { + const hud = await this.broadcastHuds.bySlug(this.normalizeHudSlug(slug)); + + if (!hud || !hud.enabled) { + throw new Error(`no enabled broadcast hud named "${slug}"`); + } + + return { + hudId: hud.jthud_id, + variant: hud.variant, + // Kept so a spec-server from an older image, which only understands a + // layout name, still switches layout instead of rejecting the call. + mode: hud.variant ?? "default", + bundleUrl: + hud.source === "imported" ? this.hudBundleUrl(hud.slug) : undefined, + }; + } + + // Back-compat: "horizontal"/"vertical"/"default" were layout names before + // they were rows. Map them onto the seeded builtin slugs so an un-updated + // client keeps working. + private normalizeHudSlug(slug: string): string { + if (slug === "vertical") return "default-vertical"; + if (slug === "horizontal" || slug === "default") return "default-horizontal"; + return slug; } public async refreshLiveHud(matchId: string) { @@ -800,7 +873,7 @@ export class GameStreamerService { { name: "DEMO_URL", value: options.presignedDemoUrl }, { name: "DEMO_FILE_NAME", value: options.demoFile }, { name: "DEMO_SESSION_ID", value: sessionId }, - { name: "HUD_MODE", value: await this.resolveHudMode() }, + ...(await this.resolveHudEnv()), { name: "CLIP_VIDEO_CODEC", value: await this.resolveClipVideoCodec() }, { name: "CLIP_BAKE_BRANDING", @@ -1029,6 +1102,13 @@ export class GameStreamerService { this.bumpDemoSessionActivityThrottled(session.id); + // The demo player sends a HUD by slug, exactly as the stream deck does. + // Resolve it here rather than forwarding the slug, so the pod is handed the + // same shape from both paths. + if (action === "hud-mode" && typeof body.slug === "string") { + body = await this.resolveHudSwitchPayload(body.slug); + } + const prefix = SPEC_PROXIED_DEMO_ACTIONS.has(action) ? "spec" : "demo"; const url = this.getDemoSpecUrl(session.id, action, prefix); const method = action === "state" ? "GET" : "POST"; @@ -1608,7 +1688,7 @@ export class GameStreamerService { const reporterEnv: V1EnvVar[] = [ { name: "MATCH_PASSWORD", value: match.password }, - { name: "HUD_MODE", value: await this.resolveHudMode() }, + ...(await this.resolveHudEnv()), { name: "LIVE_VIDEO_CODEC", value: await this.resolveLiveVideoCodec() }, { name: "CLIP_VIDEO_CODEC", value: await this.resolveClipVideoCodec() }, { @@ -2276,7 +2356,7 @@ export class GameStreamerService { { name: "DEMO_URL", value: presignedDemoUrl }, { name: "DEMO_FILE_NAME", value: demo.file as string }, { name: "STATUS_API_BASE", value: resolveInClusterApiBase() }, - { name: "HUD_MODE", value: await this.resolveHudMode() }, + ...(await this.resolveHudEnv()), { name: "CLIP_BATCH_MODE", value: "1" }, { name: "AUTODIRECTOR", value: "0" }, { diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 85fe3f18c..887d432a7 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -1811,6 +1811,11 @@ export class MatchesController { return state; } + // `mode` is a broadcast_huds slug now that HUDs are a library rather than two + // layouts. The argument keeps its name so the action signature -- and every + // client already calling it -- is unchanged, and the three old layout names + // still resolve, onto the seeded builtin rows. Validation moved into the + // service, which is the thing that knows what is installed. @HasuraAction() public async setHudMode(data: { match_id: string; @@ -1821,10 +1826,7 @@ export class MatchesController { if (!isRoleAbove(user.role, "streamer")) { throw Error("you must have the streamer role or above"); } - if (mode !== "default" && mode !== "horizontal" && mode !== "vertical") { - throw Error("mode must be one of default|horizontal|vertical"); - } - await this.gameStreamer.setLiveHudMode(match_id, mode); + await this.gameStreamer.setLiveHud(match_id, mode); return { success: true }; } diff --git a/src/system/enums/SystemSettingName.ts b/src/system/enums/SystemSettingName.ts index 9c94fb3e9..6f9c7c12f 100644 --- a/src/system/enums/SystemSettingName.ts +++ b/src/system/enums/SystemSettingName.ts @@ -74,6 +74,14 @@ export enum SystemSettingName { // self-generated -- there is no vendor to register with -- so it is stored // here rather than demanding an env var of every operator. The private half // is never exposed to any role; see public_settings.yaml. + // Which row of broadcast_huds the game-streamer pod boots. `public.`-prefixed + // deliberately, unlike the legacy default_hud_mode it supersedes: the HUD + // pickers in the demo player and the stream deck are not administrator-only + // surfaces, and the value is a HUD slug -- it reveals nothing. + DefaultBroadcastHud = "public.default_broadcast_hud", + // Superseded by DefaultBroadcastHud. Still read as the fallback so an + // instance that never opens the new settings page keeps the layout it had. + DefaultHudMode = "default_hud_mode", WebPushPublicKey = "web_push_public_key", WebPushPrivateKey = "web_push_private_key", } diff --git a/yarn.lock b/yarn.lock index 5f82c961e..4de464820 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2093,6 +2093,13 @@ dependencies: tslib "^2.4.0" +"@types/adm-zip@^0.5.8": + version "0.5.8" + resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.5.8.tgz#d4ee920d9fbef78061d039fa7c4776de7a0bb1af" + integrity sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q== + dependencies: + "@types/node" "*" + "@types/archiver@^6.0.2": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.4.tgz#c2497d7f009b97fdd9eed6fd1a15bc82abd9dc13" @@ -2916,6 +2923,11 @@ adm-zip@^0.5.10: resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a" integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ== +adm-zip@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.6.0.tgz#bbc5c6c333755e967a06dd98747f431e1d53a3cf" + integrity sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg== + agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"