import { promises as fs } from "node:fs"; import path from "node:path"; import type { RowDataPacket } from "mysql2"; import { db } from "@/lib/db"; import { getEffectMapPath } from "@/lib/services/effectmap"; import { getFigureDataPath } from "@/lib/services/figuredata"; import { getFigureMapPath } from "@/lib/services/figuremap"; import { getFurniAssetDirs, getGamedataRoot, } from "@/lib/services/furni-asset-dirs"; import { getFurnitureDataPath } from "@/lib/services/furni-data"; import { siteSettings } from "@/lib/services/site-settings"; import { CATALOG_LANGUAGE_ROOT, CATALOG_SQL_ROOT, type CatalogFile, sqlValue, } from "./catalog-git-core"; const CONFIG_FILES = [ "FurnitureData.json", "EffectMap.json", "ExternalTexts.json", "FigureData.json", "FigureMap.json", "HabboAvatarActions.json", "ProductData.json", ]; const TABLES = ["items_base", "catalog_pages", "catalog_items"] as const; const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``; export async function createCatalogSnapshot( directory: string, ): Promise { const sources = new Map(); const addTree = async (root: string, target: string, extensions: RegExp) => { async function walk(source: string, relative = "") { const entries = await fs .readdir(source, { withFileTypes: true }) .catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; }); for (const entry of entries) { if (entry.isSymbolicLink()) throw new Error("Symlink in catalog source"); const local = path.join(source, entry.name); const dest = relative ? `${relative}/${entry.name}` : entry.name; if (entry.isDirectory()) await walk(local, dest); else if (entry.isFile() && extensions.test(entry.name)) sources.set(`${target}/${dest}`, local); } } await walk(root); }; const gamedata = await getGamedataRoot(); const furni = await getFurniAssetDirs(); const furnitureData = await getFurnitureDataPath(); // Default files first; configured live locations take precedence. await addTree( path.join(process.cwd(), "public/swf/c_images"), "Gamedata/c_images", /\.(png|gif|webp|jpe?g)$/i, ); await addTree( path.join(process.cwd(), "public/nitro-assets/bundled"), "Gamedata/bundled", /\.nitro$/i, ); if (gamedata) { await addTree( path.join(gamedata, "bundled"), "Gamedata/bundled", /\.nitro$/i, ); await addTree( path.join(gamedata, "icons"), "Gamedata/icons", /\.(png|gif|webp|jpe?g)$/i, ); await addTree( path.join(gamedata, "c_images"), "Gamedata/c_images", /\.(png|gif|webp|jpe?g)$/i, ); } await addTree(furni.nitroDir, "Gamedata/bundled/furniture", /\.nitro$/i); await addTree(furni.iconDir, "Gamedata/icons", /\.(png|gif|webp|jpe?g)$/i); for (const type of ["figure", "effect"]) { const configured = await siteSettings.get(`${type}_nitro_dir`, ""); if (configured?.trim()) await addTree(configured, `Gamedata/bundled/${type}`, /\.nitro$/i); } // Pets currently write directly to the CMS directory. await addTree( path.join(process.cwd(), "public/nitro-assets/bundled/pet"), "Gamedata/bundled/pet", /\.nitro$/i, ); const configRoot = gamedata ? path.join(gamedata, "config") : path.dirname(furnitureData); const configuredJson: Record = { FurnitureData: furnitureData, FigureData: await getFigureDataPath(), FigureMap: await getFigureMapPath(), EffectMap: await getEffectMapPath(), }; for (const name of CONFIG_FILES) { const source = configuredJson[name.replace(/\.json$/, "")] ?? path.join(configRoot, name); try { await fs.access(source); sources.set(`Gamedata/config/${name}`, source); } catch (error) { if ( name === "FurnitureData.json" || (error as NodeJS.ErrnoException).code !== "ENOENT" ) throw error; } } for (const name of await fs.readdir(path.dirname(furnitureData))) { if (/^FurnitureData_[a-z]{2}\.json$/.test(name)) { sources.set( `${CATALOG_LANGUAGE_ROOT}/${name}`, path.join(path.dirname(furnitureData), name), ); } } const files: CatalogFile[] = []; const fingerprints = new Map(); const fingerprint = async (source: string) => { const stat = await fs.lstat(source); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Invalid catalog source"); return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; }; for (const [target, source] of sources) { fingerprints.set(source, await fingerprint(source)); const copy = path.join(directory, String(files.length)); await fs.copyFile(source, copy); if (target.endsWith(".json")) JSON.parse(await fs.readFile(copy, "utf8")); files.push({ source: copy, target }); } const connection = await db.$client.getConnection(); try { await connection.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"); await connection.query( "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY", ); const [available] = await connection.query("SHOW TABLES"); const tableNames = new Set( available.map((row) => String(Object.values(row)[0])), ); const tables = [ ...TABLES, ...["catalog_pages_bc", "catalog_items_bc"].filter((name) => tableNames.has(name), ), ]; for (const table of tables) { const [schemaRows] = await connection.query( `SHOW CREATE TABLE ${identifier(table)}`, ); const definition = String(schemaRows[0]["Create Table"]); if (!/ENGINE=InnoDB\b/i.test(definition)) throw new Error("Catalog SQL snapshots require InnoDB tables"); const [columns] = await connection.query( `SHOW COLUMNS FROM ${identifier(table)}`, ); const names = columns .filter( (c) => !/\b(?:VIRTUAL|STORED) GENERATED\b/.test(String(c.Extra)), ) .map((c) => String(c.Field)); const quoted = names.map(identifier).join(", "); const [rows] = await connection.query( `SELECT ${quoted} FROM ${identifier(table)} ORDER BY id`, ); const source = path.join(directory, `${table}.sql`); const output = await fs.open(source, "wx"); try { await output.writeFile( `-- Catalog Studio snapshot. Existing rows are updated; absent rows are not deleted.\nSET NAMES utf8mb4;\n${definition.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS")};\nSTART TRANSACTION;\n`, ); const update = names .filter((n) => n !== "id") .map((n) => `${identifier(n)}=VALUES(${identifier(n)})`) .join(", "); for (const row of rows) { await output.writeFile( `INSERT INTO ${identifier(table)} (${quoted}) VALUES (${names.map((n) => sqlValue(row[n])).join(", ")}) ON DUPLICATE KEY UPDATE ${update};\n`, ); } await output.writeFile("COMMIT;\n"); } finally { await output.close(); } files.push({ source, target: `${CATALOG_SQL_ROOT}/${table}.sql` }); } await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } for (const [source, stamp] of fingerprints) { if ((await fingerprint(source)) !== stamp) throw new Error("Assets changed during export; retry required"); } return files; }