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
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import { onDestroy } from 'svelte';
let {
currentUrl = '',
label = 'ICON'
}: { currentUrl?: string | null; label?: string } = $props();
let previewUrl = $state('');
let fileName = $state('');
const choose = (event: Event) => {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = file && file.type !== 'image/svg+xml' ? URL.createObjectURL(file) : '';
fileName = file?.name ?? '';
};
onDestroy(() => { if (previewUrl) URL.revokeObjectURL(previewUrl); });
</script>
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<div class="icon-upload-preview">
{#if previewUrl || currentUrl}<img src={previewUrl || currentUrl || ''} alt="Current icon preview"/>{:else}<span>NO ICON</span>{/if}
</div>
<label><input name="icon" type="file" accept="image/svg+xml,image/png,image/webp,image/avif" onchange={choose}/><span>{fileName || (currentUrl ? 'REPLACE ICON' : 'UPLOAD ICON')}</span></label>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
</fieldset>
+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;
}