116 lines
4.9 KiB
TypeScript
116 lines
4.9 KiB
TypeScript
import type { Database } from '@tursodatabase/database';
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import sharp from 'sharp';
|
|
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', '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-[0-9a-f-]{36}\.webp$/i;
|
|
|
|
export class ManagedImageValidationError 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 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;
|
|
}
|
|
}
|