156 lines
7.0 KiB
TypeScript
156 lines
7.0 KiB
TypeScript
import type { Database } from '@tursodatabase/database';
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import sharp from 'sharp';
|
|
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
|
|
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
|
|
|
export const MANAGED_IMAGE_CATEGORIES = [
|
|
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
|
|
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System'
|
|
] as const;
|
|
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
const MAX_PIXELS = 24_000_000;
|
|
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
|
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
|
|
|
export class ManagedImageValidationError extends Error {}
|
|
export class ManagedImageDeletionError extends Error {}
|
|
|
|
export function managedImageStorageDirectory() {
|
|
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
|
|
}
|
|
|
|
const storagePath = (key: string) => {
|
|
if (!KEY_PATTERN.test(key)) throw new Error('Invalid managed image storage key');
|
|
return path.join(managedImageStorageDirectory(), key);
|
|
};
|
|
|
|
export function managedImageUrl(id: string, updatedAt?: unknown) {
|
|
if (!ID_PATTERN.test(id)) return '';
|
|
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
|
|
return `/images/library/${id}${version}`;
|
|
}
|
|
|
|
function validateSvg(input: Buffer) {
|
|
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 ManagedImageValidationError('SVG files cannot contain scripts, embedded images, or external resources.');
|
|
}
|
|
}
|
|
|
|
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) {
|
|
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
|
|
throw new ManagedImageValidationError('Choose an image no larger than 5 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) validateSvg(input);
|
|
let processedInput = input;
|
|
let source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
|
|
let metadata = await source.metadata();
|
|
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
|
|
throw new ManagedImageValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF image.');
|
|
}
|
|
const sourceStats = await source.stats();
|
|
if (icon && (!metadata.hasAlpha || sourceStats.isOpaque)) {
|
|
processedInput = await removeBackgroundPreview(processedInput);
|
|
source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
|
|
metadata = await source.metadata();
|
|
}
|
|
let pipeline = source.rotate();
|
|
if (metadata.hasAlpha) {
|
|
pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
|
|
}
|
|
pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, {
|
|
fit: 'inside',
|
|
withoutEnlargement: !icon
|
|
});
|
|
if (icon && forceWhite) {
|
|
const normalized = await pipeline
|
|
.resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
for (let offset = 0; offset < normalized.data.length; offset += normalized.info.channels) {
|
|
normalized.data[offset] = 255;
|
|
normalized.data[offset + 1] = 255;
|
|
normalized.data[offset + 2] = 255;
|
|
}
|
|
pipeline = sharp(normalized.data, {
|
|
raw: { width: normalized.info.width, height: normalized.info.height, channels: normalized.info.channels }
|
|
});
|
|
}
|
|
const output = await pipeline.webp({ quality: icon ? 90 : 84, alphaQuality: 100, effort: 5 }).toBuffer();
|
|
const result = await sharp(output).metadata();
|
|
return { output, width: result.width ?? 0, height: result.height ?? 0 };
|
|
} catch (error) {
|
|
if (error instanceof ManagedImageValidationError) throw error;
|
|
if (error instanceof BackgroundRemovalError) throw new ManagedImageValidationError(error.message);
|
|
throw new ManagedImageValidationError('That image could not be processed.');
|
|
}
|
|
}
|
|
|
|
export async function saveManagedImageFile(id: string, output: Buffer) {
|
|
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
|
|
const key = `library-${id}.webp`;
|
|
await mkdir(managedImageStorageDirectory(), { recursive: true });
|
|
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
|
|
return key;
|
|
}
|
|
|
|
export async function deleteManagedImage(database: Database, id: string) {
|
|
const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
|
|
if (!image) return null;
|
|
if (typeof image.storage_key !== 'string') throw new ManagedImageDeletionError('Managed image is missing its storage key.');
|
|
|
|
const sourceType = typeof image.source_entity_type === 'string' ? image.source_entity_type : null;
|
|
const sourceId = typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
|
|
if (Boolean(sourceType) !== Boolean(sourceId)) {
|
|
throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.');
|
|
}
|
|
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) {
|
|
throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.');
|
|
}
|
|
|
|
const sourceIcon = sourceType && sourceId
|
|
? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null
|
|
: null;
|
|
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
|
|
throw new ManagedImageDeletionError('The library image and its source assignment reference different files. Resolve the assignment before deleting.');
|
|
}
|
|
|
|
await database.exec('BEGIN');
|
|
try {
|
|
if (sourceIcon && sourceType && sourceId) {
|
|
await (await database.prepare(
|
|
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?'
|
|
)).run(sourceType, sourceId, image.storage_key);
|
|
}
|
|
await (await database.prepare('DELETE FROM managed_images WHERE id=? AND storage_key=?')).run(id, image.storage_key);
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
await rm(storagePath(image.storage_key), { force: true });
|
|
return { image, sourceIcon };
|
|
}
|
|
|
|
export async function readManagedImage(database: Database, id: string) {
|
|
if (!ID_PATTERN.test(id)) return null;
|
|
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
|
|
if (typeof row?.storage_key !== 'string') return null;
|
|
try {
|
|
return await readFile(storagePath(row.storage_key));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
throw error;
|
|
}
|
|
}
|