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 00000000..29fc38f8 --- /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 cff6b5f3..917eb8bd 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 00000000..b74c790f --- /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 00000000..3c6d9c5f --- /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 f6c12de6..052b090a 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 c73c5fa5..f19f4a13 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 00000000..f6c3935b --- /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 00000000..3ba5197a --- /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 00000000..3f349efd --- /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 00000000..364f09b0 --- /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 ae474f14..57c8b964 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 d13ba4ca..049b5290 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 d1361ecc..0128ed7f 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 19e28f33..5dae5153 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 85fe3f18..887d432a 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 9c94fb3e..6f9c7c12 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 5f82c961..4de46482 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"