feat(isac): add game data icon management
This commit is contained in:
@@ -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>
|
||||
@@ -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