Files
shd.build/src/lib/server/managed-images.ts
T

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;
}
}