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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { MigrationInterface, QueryRunner } from "typeorm";

/**
* Adds the EMOTE_PACK cosmetic type: a folder of Lottie emotes described by a
* `pack.json`, owned like any other cosmetic but never equipped in the loadout.
*
* No row is inserted here. `ALTER TYPE ... ADD VALUE` runs inside a
* transaction on PG >= 12, but the freshly added value cannot be USED in that
* same transaction.
*/
export class AddEmotePackCosmeticType1781700000000 implements MigrationInterface {
name = "AddEmotePackCosmeticType1781700000000";

async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "cosmetic_type_enum" ADD VALUE IF NOT EXISTS 'EMOTE_PACK'`);
}

async down(queryRunner: QueryRunner): Promise<void> {
// Postgres cannot remove a single enum value without recreating the whole
// type and rewriting every column that uses it. Deleting the rows is the
// reversible part; the value itself is left in place, inert.
await queryRunner.query(`DELETE FROM "cosmetics" WHERE "type" = 'EMOTE_PACK'`);
}
}
17 changes: 15 additions & 2 deletions src/modules/catalog/application/PublishCosmetic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import { Cosmetic } from "../domain/Cosmetic";
import type { CosmeticRepository } from "../domain/CosmeticRepository";
import { CosmeticTier } from "../domain/CosmeticTier";
import { CosmeticType } from "../domain/CosmeticType";
import { assertEmotePackFiles, MAX_EMOTE_PACK_FILES } from "../domain/EmotePack";

const MAX_FILES = 8;
const MAX_FILES_BY_TYPE: Partial<Record<CosmeticType, number>> = {
[CosmeticType.EMOTE_PACK]: MAX_EMOTE_PACK_FILES,
};
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
const SAFE_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
Expand All @@ -18,6 +22,7 @@ const PREFIX_BY_TYPE: Partial<Record<CosmeticType, string>> = {
[CosmeticType.PLAYMAT]: "playmats",
[CosmeticType.AVATAR]: "avatars",
[CosmeticType.LANE]: "lanes",
[CosmeticType.EMOTE_PACK]: "emote-packs",
};

export interface PublishCosmeticFile {
Expand Down Expand Up @@ -72,6 +77,10 @@ function assertAssetRef(type: CosmeticType, assetRef: string): void {
}
}

function slugOf(assetRef: string): string {
return assetRef.split("/")[1] ?? "";
}

function assertRequiredFiles(type: CosmeticType, names: ReadonlySet<string>): void {
const lower = new Set(Array.from(names, (name) => name.toLowerCase()));
if ((type === CosmeticType.SLEEVE || type === CosmeticType.AVATAR) && !lower.has("render.jpg")) {
Expand Down Expand Up @@ -100,8 +109,9 @@ export class PublishCosmetic {
async run(input: PublishCosmeticInput): Promise<PublishedCosmeticDto> {
assertAssetRef(input.type, input.assetRef);

if (input.files.length === 0 || input.files.length > MAX_FILES) {
throw new InvalidArgumentError(`A cosmetic requires between 1 and ${MAX_FILES} files`);
const maxFiles = MAX_FILES_BY_TYPE[input.type] ?? MAX_FILES;
if (input.files.length === 0 || input.files.length > maxFiles) {
throw new InvalidArgumentError(`A cosmetic requires between 1 and ${maxFiles} files`);
}

const names = new Set<string>();
Expand All @@ -124,6 +134,9 @@ export class PublishCosmetic {
throw new InvalidArgumentError("Cosmetic assets exceed the 25 MB upload limit");
}
assertRequiredFiles(input.type, names);
if (input.type === CosmeticType.EMOTE_PACK) {
assertEmotePackFiles(slugOf(input.assetRef), input.files);
}

const duplicate = (await this.cosmetics.findAll()).find(
(cosmetic) => cosmetic.assetRef === input.assetRef,
Expand Down
7 changes: 7 additions & 0 deletions src/modules/catalog/domain/CosmeticType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ export enum CosmeticType {
TITLE = "TITLE",
/** The frame a player's zones wear, worn beside the playmat. */
LANE = "LANE",
/** A set of Lottie emotes a player can place on the emote bar. */
EMOTE_PACK = "EMOTE_PACK",
}

/** Types worn through the one-item-per-type loadout; an EMOTE_PACK is picked emote by emote. */
export function isEquippableType(type: CosmeticType): boolean {
return type !== CosmeticType.EMOTE_PACK;
}
126 changes: 126 additions & 0 deletions src/modules/catalog/domain/EmotePack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError";
import { assertVectorLottie } from "./LottieAnimation";

export const EMOTE_PACK_MANIFEST = "pack.json";
export const MAX_EMOTES_PER_PACK = 12;
/** The manifest plus one Lottie per emote. */
export const MAX_EMOTE_PACK_FILES = MAX_EMOTES_PER_PACK + 1;

const MANIFEST_VERSION = 1;
const MAX_KEY_LENGTH = 20;
const MAX_LABEL_LENGTH = 32;
/** `<slug>/<key>` travels over the duel wire as the emote reference. */
const MAX_REF_BYTES = 48;
const EMOTE_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

export interface EmotePackFile {
readonly name: string;
readonly bytes: Uint8Array;
}

interface ManifestEmote {
readonly key: string;
readonly label: string;
readonly file: string;
}

function parseManifest(bytes: Uint8Array): unknown {
try {
return JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} is not valid JSON`);
}
}

function parseEmote(raw: unknown, slug: string): ManifestEmote {
const entry = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const { key, label, file } = entry;

if (typeof key !== "string" || key.length > MAX_KEY_LENGTH || !EMOTE_KEY.test(key)) {
throw new InvalidArgumentError(
`${EMOTE_PACK_MANIFEST} emote key ${JSON.stringify(key)} must be 1 to ${MAX_KEY_LENGTH} lowercase kebab-case characters`,
);
}
const trimmedLabel = typeof label === "string" ? label.trim() : "";
if (trimmedLabel.length < 1 || trimmedLabel.length > MAX_LABEL_LENGTH) {
throw new InvalidArgumentError(
`${EMOTE_PACK_MANIFEST} emote "${key}" needs a label of 1 to ${MAX_LABEL_LENGTH} characters`,
);
}
if (file !== `${key}.json`) {
throw new InvalidArgumentError(
`${EMOTE_PACK_MANIFEST} emote "${key}" must use file "${key}.json"`,
);
}
const ref = `${slug}/${key}`;
if (new TextEncoder().encode(ref).byteLength > MAX_REF_BYTES) {
throw new InvalidArgumentError(
`${EMOTE_PACK_MANIFEST} emote ref "${ref}" exceeds ${MAX_REF_BYTES} bytes`,
);
}
return { key, label: trimmedLabel, file };
}

function parseEmotes(manifest: unknown, slug: string): ManifestEmote[] {
const root = (typeof manifest === "object" && manifest !== null ? manifest : {}) as Record<
string,
unknown
>;
if (root.version !== MANIFEST_VERSION) {
throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} version must be ${MANIFEST_VERSION}`);
}
if (!Array.isArray(root.emotes)) {
throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} requires an emotes array`);
}
if (root.emotes.length < 1 || root.emotes.length > MAX_EMOTES_PER_PACK) {
throw new InvalidArgumentError(
`EMOTE_PACK requires between 1 and ${MAX_EMOTES_PER_PACK} emotes`,
);
}

const seen = new Set<string>();
return root.emotes.map((raw) => {
const emote = parseEmote(raw, slug);
if (seen.has(emote.key)) {
throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} repeats emote key "${emote.key}"`);
}
seen.add(emote.key);
return emote;
});
}

/**
* Checks that an emote pack is exactly one `pack.json` plus the Lottie files it lists,
* and that every Lottie is valid vector-only animation.
*/
export function assertEmotePackFiles(slug: string, files: readonly EmotePackFile[]): void {
for (const file of files) {
if (file.name !== EMOTE_PACK_MANIFEST && !file.name.endsWith(".json")) {
throw new InvalidArgumentError(
`EMOTE_PACK file "${file.name}" must be ${EMOTE_PACK_MANIFEST} or a .json Lottie`,
);
}
}

const manifests = files.filter((file) => file.name === EMOTE_PACK_MANIFEST);
if (manifests.length !== 1) {
throw new InvalidArgumentError(`EMOTE_PACK requires exactly one ${EMOTE_PACK_MANIFEST}`);
}
const emotes = parseEmotes(parseManifest(manifests[0].bytes), slug);

const lottieFiles = files.filter((file) => file.name !== EMOTE_PACK_MANIFEST);
const listed = new Set(emotes.map((emote) => emote.file));
for (const emote of emotes) {
if (!lottieFiles.some((file) => file.name === emote.file)) {
throw new InvalidArgumentError(
`${EMOTE_PACK_MANIFEST} lists "${emote.file}" but it was not uploaded`,
);
}
}
for (const file of lottieFiles) {
if (!listed.has(file.name)) {
throw new InvalidArgumentError(`"${file.name}" is not listed in ${EMOTE_PACK_MANIFEST}`);
}
assertVectorLottie(file.name, file.bytes);
}
}
111 changes: 111 additions & 0 deletions src/modules/catalog/domain/LottieAnimation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError";

export const MAX_LOTTIE_BYTES = 200 * 1024;
export const MAX_LOTTIE_SECONDS = 3;
export const MAX_LOTTIE_DEPTH = 64;

const TEXT_LAYER_TYPE = 5;
const VERSION = /^\d+(?:\.\d+)*$/;

type Json = Record<string, unknown>;

function isObject(value: unknown): value is Json {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}

// Lottie keeps expressions in an `x` string next to the animated value it drives, at any
// depth, so the whole tree has to be walked. The walk is iterative and bounded so hostile
// nesting inside the size cap is rejected instead of exhausting the stack.
function scanTree(fileName: string, root: unknown): { hasExpression: boolean } {
const pending: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 0 }];
for (let entry = pending.pop(); entry; entry = pending.pop()) {
const { node, depth } = entry;
if (typeof node !== "object" || node === null) continue;
if (depth > MAX_LOTTIE_DEPTH) {
throw new InvalidArgumentError(`${fileName} is nested too deeply`);
}
if (Array.isArray(node)) {
for (const child of node) pending.push({ node: child, depth: depth + 1 });
continue;
}
for (const [key, value] of Object.entries(node)) {
if (key === "x" && typeof value === "string") return { hasExpression: true };
pending.push({ node: value, depth: depth + 1 });
}
}
return { hasExpression: false };
}

function layerGroups(rootLayers: unknown[], assets: unknown[]): unknown[][] {
const groups: unknown[][] = [rootLayers];
for (const asset of assets) {
if (isObject(asset) && Array.isArray(asset.layers)) groups.push(asset.layers);
}
return groups;
}

/**
* Accepts only the vector subset the client renders safely with lottie-web's SVG
* renderer: no raster or external assets, no text or fonts, no expressions, and a
* bounded size and duration.
*/
export function assertVectorLottie(fileName: string, bytes: Uint8Array): void {
if (bytes.byteLength > MAX_LOTTIE_BYTES) {
throw new InvalidArgumentError(`${fileName} exceeds ${MAX_LOTTIE_BYTES / 1024} KB`);
}

let animation: unknown;
try {
animation = JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new InvalidArgumentError(`${fileName} is not valid JSON`);
}
if (!isObject(animation)) {
throw new InvalidArgumentError(`${fileName} must be a JSON object`);
}

if (typeof animation.v !== "string" || !VERSION.test(animation.v)) {
throw new InvalidArgumentError(`${fileName} must declare a version string in v`);
}
if (!isFiniteNumber(animation.fr) || animation.fr <= 0) {
throw new InvalidArgumentError(`${fileName} requires a frame rate fr greater than 0`);
}
const { ip, op, w, h } = animation;
if (!isFiniteNumber(ip) || !isFiniteNumber(op) || !isFiniteNumber(w) || !isFiniteNumber(h)) {
throw new InvalidArgumentError(`${fileName} requires numeric ip, op, w and h`);
}
if (op <= ip) {
throw new InvalidArgumentError(`${fileName} requires op greater than ip`);
}
if (!Array.isArray(animation.layers)) {
throw new InvalidArgumentError(`${fileName} requires a layers array`);
}
if ((op - ip) / animation.fr > MAX_LOTTIE_SECONDS) {
throw new InvalidArgumentError(`${fileName} lasts longer than ${MAX_LOTTIE_SECONDS} seconds`);
}

const assets = animation.assets ?? [];
if (!Array.isArray(assets)) {
throw new InvalidArgumentError(`${fileName} requires assets to be an array`);
}
if (assets.some((asset) => isObject(asset) && ("p" in asset || "u" in asset))) {
throw new InvalidArgumentError(`${fileName} embeds an image or external asset`);
}
if ("fonts" in animation || "chars" in animation) {
throw new InvalidArgumentError(`${fileName} must not declare fonts or chars`);
}
if (
layerGroups(animation.layers, assets).some((layers) =>
layers.some((layer) => isObject(layer) && layer.ty === TEXT_LAYER_TYPE),
)
) {
throw new InvalidArgumentError(`${fileName} contains a text layer`);
}
if (scanTree(fileName, animation).hasExpression) {
throw new InvalidArgumentError(`${fileName} contains an expression`);
}
}
7 changes: 6 additions & 1 deletion src/modules/loadout/application/EquipCosmetic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ForbiddenError } from "../../../shared/errors/ForbiddenError";
import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError";
import { NotFoundError } from "../../../shared/errors/NotFoundError";
import { CosmeticRepository } from "../../catalog/domain/CosmeticRepository";
import { CosmeticType } from "../../catalog/domain/CosmeticType";
import { CosmeticType, isEquippableType } from "../../catalog/domain/CosmeticType";
import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper";
import { LoadoutRepository } from "../domain/LoadoutRepository";

Expand All @@ -26,6 +26,11 @@ export class EquipCosmetic {
if (!cosmetic || !cosmetic.active) {
throw new NotFoundError(`Cosmetic ${cosmeticId} not found`);
}
if (!isEquippableType(cosmetic.type)) {
throw new InvalidArgumentError(
`${cosmetic.type} cosmetics cannot be equipped in the loadout`,
);
}
if (cosmetic.type !== cosmeticType) {
throw new InvalidArgumentError(
`Cosmetic ${cosmeticId} cannot be equipped in the ${cosmeticType} slot`,
Expand Down
2 changes: 1 addition & 1 deletion src/server/routes/admin-cosmetics-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function createAdminCosmeticsRouter(deps: AdminCosmeticsRouterDependencie
tier: t.Enum(CosmeticTier),
assetRef: t.String({ minLength: 1, maxLength: 180 }),
displayName: t.String({ minLength: 1, maxLength: 80 }),
files: t.Files({ minItems: 1, maxItems: 8, maxSize: "12m" }),
files: t.Files({ minItems: 1, maxItems: 13, maxSize: "12m" }),
}),
detail: {
tags: ["Cosmetics Admin"],
Expand Down
Loading
Loading