feat(isac): add game data icon management
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user