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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: ""
1 change: 1 addition & 0 deletions hasura/metadata/databases/default/tables/tables.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.broadcast_huds;
116 changes: 116 additions & 0 deletions hasura/migrations/default/1887000000000_broadcast_huds/up.sql
Original file line number Diff line number Diff line change
@@ -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/<slug>/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;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -147,6 +148,7 @@ import { UtilityModule } from "./utility/utility.module";
K8sModule,
FileManagerModule,
BrandingModule,
BroadcastHudsModule,
AvatarsModule,
AwardsModule,
FixturesModule,
Expand Down
100 changes: 100 additions & 0 deletions src/broadcast-huds/broadcast-huds.controller.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
14 changes: 14 additions & 0 deletions src/broadcast-huds/broadcast-huds.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading