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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"migration:cosmetics:run": "bun run src/scripts/run-cosmetics-migrations.ts",
"migration:cosmetics:revert": "bun run src/scripts/run-cosmetics-migrations.ts --revert",
"seed:cosmetics": "bun run src/scripts/seed-cosmetics.ts",
"index:cosmetic-assets": "bun run src/scripts/index-cosmetic-assets.ts",
"assign:cosmetic": "bun run src/scripts/assign-cosmetic.ts",
"lint": "biome check .",
"lint:fix": "biome check --write .",
Expand Down
14 changes: 14 additions & 0 deletions src/migrations/1781300000000-add-cosmetic-asset-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { MigrationInterface, QueryRunner } from "typeorm";

/** Stores relative object keys so request-time signing does not need R2 ListObjects. */
export class AddCosmeticAssetFiles1781300000000 implements MigrationInterface {
name = "AddCosmeticAssetFiles1781300000000";

async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cosmetics" ADD COLUMN "asset_files" text array`);
}

async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cosmetics" DROP COLUMN "asset_files"`);
}
}
7 changes: 6 additions & 1 deletion src/modules/assets/domain/AssetUrlSigner.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export interface SignedAssetManifest {
assets: Record<string, string>;
expiresAt: string;
}

export interface AssetUrlSigner {
/** Signs a short-lived read (GET) URL for a single asset reference. */
sign(assetRef: string): string;
Expand All @@ -11,5 +16,5 @@ export interface AssetUrlSigner {
* Lets clients fetch a multi-file asset (a gltf plus its .bin/texture, or a
* sleeve's render + preview) whose parts each need their own signed URL.
*/
signManifest(prefix: string): Promise<Record<string, string>>;
signManifest(prefix: string, assetFiles?: readonly string[]): Promise<SignedAssetManifest>;
}
30 changes: 16 additions & 14 deletions src/modules/assets/infrastructure/R2AssetUrlSigner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { S3Client } from "bun";

import { AssetUrlSigner } from "../domain/AssetUrlSigner";
import { AssetUrlSigner, SignedAssetManifest } from "../domain/AssetUrlSigner";

/**
* Signs read URLs for assets stored in a private R2 (S3-compatible) bucket.
Expand All @@ -13,6 +13,7 @@ export class R2AssetUrlSigner implements AssetUrlSigner {
constructor(
private readonly client: S3Client,
private readonly ttlSeconds: number,
private readonly clock: () => number = Date.now,
) {}

sign(assetRef: string): string {
Expand All @@ -23,19 +24,20 @@ export class R2AssetUrlSigner implements AssetUrlSigner {
return Object.fromEntries(assetRefs.map((ref) => [ref, this.sign(ref)]));
}

async signManifest(prefix: string): Promise<Record<string, string>> {
const listed = await this.client.list({ prefix });
async signManifest(prefix: string, assetFiles?: readonly string[]): Promise<SignedAssetManifest> {
const files = assetFiles ?? (await this.listRelativeFiles(prefix));
const expiresAt = new Date(this.clock() + this.ttlSeconds * 1_000).toISOString();

const assets = Object.fromEntries(files.map((file) => [file, this.sign(`${prefix}${file}`)]));

const manifest: Record<string, string> = {};
for (const object of listed.contents ?? []) {
const key = object.key;
// Skip the folder placeholder some tools create for an empty prefix.
if (!key || key.endsWith("/")) {
continue;
}
manifest[key.slice(prefix.length)] = this.sign(key);
}

return manifest;
return { assets, expiresAt };
}

private async listRelativeFiles(prefix: string): Promise<string[]> {
const listed = await this.client.list({ prefix });
return (listed.contents ?? [])
.map((object) => object.key)
.filter((key): key is string => Boolean(key) && !key?.endsWith("/"))
.map((key) => key.slice(prefix.length));
}
}
38 changes: 38 additions & 0 deletions src/modules/catalog/application/GetCosmeticAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AssetUrlSigner } from "../../assets/domain/AssetUrlSigner";
import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper";
import { NotFoundError } from "../../../shared/errors/NotFoundError";
import { CosmeticRepository } from "../domain/CosmeticRepository";

export interface CosmeticAssets {
assets: Record<string, string>;
assetsExpiresAt: string;
}

/** Refreshes one cosmetic manifest without re-signing the whole catalog. */
export class GetCosmeticAssets {
constructor(
private readonly repository: CosmeticRepository,
private readonly signer: AssetUrlSigner,
private readonly gatekeeper: EntitlementsGatekeeper,
) {}

async run(cosmeticId: string, userId: string | null): Promise<CosmeticAssets> {
const cosmetic = await this.repository.findById(cosmeticId);
const access = await this.gatekeeper.accessFor(userId);

// Use the same not-found response for unknown, inactive, and inaccessible
// cosmetics so the endpoint does not disclose gated catalog entries.
if (!cosmetic || !cosmetic.active || !access.canUse(cosmetic)) {
throw new NotFoundError(`Cosmetic "${cosmeticId}" not found`);
}

const signedManifest = await this.signer.signManifest(
cosmetic.assetRef,
cosmetic.assetFiles ?? undefined,
);
return {
assets: signedManifest.assets,
assetsExpiresAt: signedManifest.expiresAt,
};
}
}
24 changes: 16 additions & 8 deletions src/modules/catalog/application/GetCosmeticsCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface CatalogCosmetic {
tier: CosmeticTier;
displayName: string;
assets: Record<string, string>;
assetsExpiresAt: string;
animation?: CompanionAnimationDescriptor;
}

Expand Down Expand Up @@ -41,14 +42,21 @@ export class GetCosmeticsCatalog {
);

return Promise.all(
visible.map(async (cosmetic) => ({
id: cosmetic.id,
type: cosmetic.type,
tier: cosmetic.tier,
displayName: cosmetic.displayName,
assets: await this.signer.signManifest(cosmetic.assetRef),
animation: cosmetic.animation,
})),
visible.map(async (cosmetic) => {
const signedManifest = await this.signer.signManifest(
cosmetic.assetRef,
cosmetic.assetFiles ?? undefined,
);
return {
id: cosmetic.id,
type: cosmetic.type,
tier: cosmetic.tier,
displayName: cosmetic.displayName,
assets: signedManifest.assets,
assetsExpiresAt: signedManifest.expiresAt,
animation: cosmetic.animation,
};
}),
);
}
}
9 changes: 8 additions & 1 deletion src/modules/catalog/domain/Cosmetic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class Cosmetic {
public readonly displayName: string,
public readonly active: boolean,
public readonly animation?: CompanionAnimationDescriptor,
public readonly assetFiles: readonly string[] | null = null,
) {}

static create({
Expand All @@ -21,13 +22,15 @@ export class Cosmetic {
assetRef,
displayName,
animation,
assetFiles,
}: {
id: string;
type: CosmeticType;
tier: CosmeticTier;
assetRef: string;
displayName: string;
animation?: CompanionAnimationDescriptor;
assetFiles?: readonly string[];
}): Cosmetic {
if (!assetRef.trim()) {
throw new InvalidArgumentError("assetRef cannot be empty");
Expand All @@ -46,7 +49,7 @@ export class Cosmetic {
throw new InvalidArgumentError("animation is only allowed for COMPANION cosmetics");
}

return new Cosmetic(id, type, tier, assetRef, displayName, true, animation);
return new Cosmetic(id, type, tier, assetRef, displayName, true, animation, assetFiles ?? null);
}

static from(data: {
Expand All @@ -57,6 +60,7 @@ export class Cosmetic {
displayName: string;
active: boolean;
animation?: CompanionAnimationDescriptor;
assetFiles?: readonly string[] | null;
}): Cosmetic {
return new Cosmetic(
data.id,
Expand All @@ -66,6 +70,7 @@ export class Cosmetic {
data.displayName,
data.active,
data.animation,
data.assetFiles ?? null,
);
}

Expand All @@ -77,6 +82,7 @@ export class Cosmetic {
displayName: string;
active: boolean;
animation?: CompanionAnimationDescriptor;
assetFiles: readonly string[] | null;
} {
return {
id: this.id,
Expand All @@ -86,6 +92,7 @@ export class Cosmetic {
displayName: this.displayName,
active: this.active,
animation: this.animation,
assetFiles: this.assetFiles,
};
}
}
3 changes: 3 additions & 0 deletions src/modules/catalog/infrastructure/CosmeticEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export class CosmeticEntity {
@Column({ name: "animation", type: "jsonb", nullable: true })
animation?: CompanionAnimationDescriptor | null;

@Column({ name: "asset_files", type: "text", array: true, nullable: true })
assetFiles: string[] | null;

@CreateDateColumn({ name: "created_at" })
createdAt: Date;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository {
displayName: entity.displayName,
active: entity.active,
animation: entity.animation ?? undefined,
assetFiles: entity.assetFiles,
}),
);
}
Expand All @@ -46,6 +47,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository {
displayName: entity.displayName,
active: entity.active,
animation: entity.animation ?? undefined,
assetFiles: entity.assetFiles,
});
}

Expand All @@ -61,6 +63,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository {
displayName: data.displayName,
active: data.active,
animation: data.animation ?? null,
assetFiles: data.assetFiles ? [...data.assetFiles] : null,
});

await repository.save(entity);
Expand Down
7 changes: 6 additions & 1 deletion src/modules/loadout/application/GetMyLoadout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface MyLoadoutSlot {
cosmeticType: CosmeticType;
cosmeticId: string;
assets: Record<string, string>;
assetsExpiresAt?: string;
animation?: CompanionAnimationDescriptor;
}

Expand All @@ -24,10 +25,14 @@ export class GetMyLoadout {
return Promise.all(
loadout.items().map(async (item) => {
const cosmetic = await this.cosmetics.findById(item.cosmeticId);
const signedManifest = cosmetic
? await this.signer.signManifest(cosmetic.assetRef, cosmetic.assetFiles ?? undefined)
: undefined;
return {
cosmeticType: item.cosmeticType,
cosmeticId: item.cosmeticId,
assets: cosmetic ? await this.signer.signManifest(cosmetic.assetRef) : {},
assets: signedManifest?.assets ?? {},
assetsExpiresAt: signedManifest?.expiresAt,
animation: cosmetic?.animation,
};
}),
Expand Down
47 changes: 47 additions & 0 deletions src/scripts/index-cosmetic-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { cosmeticsDataSource } from "../cosmetics-data-source";
import { createR2AssetUrlSigner } from "../modules/assets/infrastructure/createR2AssetUrlSigner";
import { Cosmetic } from "../modules/catalog/domain/Cosmetic";
import { CosmeticPostgresRepository } from "../modules/catalog/infrastructure/CosmeticPostgresRepository";

/** One-time/backfill indexer. Lists each unindexed R2 prefix once and persists
* relative keys so normal catalog/loadout requests only perform local signing. */
async function main(): Promise<void> {
await cosmeticsDataSource.initialize();

try {
const repository = new CosmeticPostgresRepository();
const signer = createR2AssetUrlSigner();
const cosmetics = await repository.findAll();
let indexed = 0;
let skipped = 0;

for (const cosmetic of cosmetics) {
if (cosmetic.assetFiles !== null) {
skipped++;
continue;
}

const signed = await signer.signManifest(cosmetic.assetRef);
const assetFiles = Object.keys(signed.assets);
await repository.save(
Cosmetic.from({
...cosmetic.toPrimitives(),
assetFiles,
}),
);
indexed++;
console.log(`Indexed ${cosmetic.assetRef}: ${assetFiles.length} files`);
}

console.log(`Cosmetic asset index complete: ${indexed} indexed, ${skipped} skipped`);
} finally {
await cosmeticsDataSource.destroy();
}
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Loading
Loading