feat(isac): add game data icon management

This commit is contained in:
JB
2026-07-25 12:38:40 -04:00
parent 9f712e7790
commit 7a12b1d740
16 changed files with 323 additions and 39 deletions
+131
View File
@@ -0,0 +1,131 @@
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol'] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol)-[0-9a-f-]{36}\.webp$/i;
export class GameDataIconValidationError extends Error {}
export function gameDataIconStorageDirectory(): string {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
}
function assertType(type: string): asserts type is GameDataIconType {
if (!GAME_ICON_TYPES.includes(type as GameDataIconType)) throw new Error('Invalid game-data icon type');
}
function storageKey(type: GameDataIconType, entityId: string): string {
if (!ENTITY_ID_PATTERN.test(entityId)) throw new Error('Invalid game-data entity identifier');
return `${type}-${entityId}.webp`;
}
function storagePath(key: string): string {
if (!STORAGE_KEY_PATTERN.test(key)) throw new Error('Invalid game-data icon storage key');
return path.join(gameDataIconStorageDirectory(), key);
}
export function gameDataIconUrl(type: GameDataIconType, entityId: string, updatedAt?: unknown): string {
assertType(type);
if (!ENTITY_ID_PATTERN.test(entityId)) return '';
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
return `/icons/game-data/${type}/${entityId}${version}`;
}
export async function processGameDataIconUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) {
const source = input.toString('utf8');
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|| /<!ENTITY\b/i.test(source)
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|| /@import\b/i.test(source)
|| /url\(\s*["']?(?!#)/i.test(source)) {
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
}
}
const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
const metadata = await image.metadata();
if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) {
throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.');
}
const stats = await image.stats();
if (stats.isOpaque) {
throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.');
}
return await image
.rotate()
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
fit: 'contain',
position: 'centre',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.webp({ quality: 88, alphaQuality: 100, effort: 5 })
.toBuffer();
} catch (error) {
if (error instanceof GameDataIconValidationError) throw error;
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
}
}
export async function saveGameDataIcon(database: Database, type: GameDataIconType, entityId: string, output: Buffer): Promise<string> {
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(type, entityId, key, output.length, now);
return now;
}
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
await (await database.prepare('DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?')).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
}
export async function readGameDataIcon(database: Database, type: string, entityId: string): Promise<Buffer | null> {
assertType(type);
if (!ENTITY_ID_PATTERN.test(entityId)) return null;
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
if (typeof icon?.storage_key !== 'string') return null;
try {
return await readFile(storagePath(icon.storage_key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
export async function readIconMutation(form: FormData): Promise<{ output: Buffer | null; remove: boolean }> {
const file = form.get('icon');
const remove = form.get('removeIcon') === '1';
if (file instanceof File && file.size > 0) return { output: await processGameDataIconUpload(file), remove: false };
return { output: null, remove };
}
export async function applyIconMutation(database: Database, type: GameDataIconType, entityId: string, mutation: { output: Buffer | null; remove: boolean }): Promise<void> {
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
}
export async function gameDataIconMetadata(database: Database, type: GameDataIconType, entityId: string) {
return await (await database.prepare('SELECT byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId) ?? null;
}