Files
EpicNext-Cms/src/lib/services/catalog-git-snapshot.ts
T
openhands adffac7360
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat(catalog): store furniture bundles as .hab instead of .nitro
Every bundle the CMS writes — upload, clone, sync, repair and the pet /
effect / figure importers — now lands as `<classname>.hab`, the extension
this deployment's renderer asks for. `.hab` and `.nitro` are the same
container, so an upload of either extension is accepted.

Resolution goes through one module, src/lib/furni/bundle-file.ts, so
nothing has to know the extension twice. Every existence check probes
`.hab` first and falls back to `.nitro`: the on-disk asset set is still
predominantly `.nitro`, and without the fallback Studio would report every
imported item as missing and the cleanup scan would classify 18k live
bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every
configured clone source still serve `.nitro`, so the conversion happens on
write, not on request.

Deliberately unchanged: the staged-attachment store in furni-attachment.ts
keys on a UUID and never reaches the client, so renaming it would break
in-flight recovery jobs.

Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It
refuses to run without --dry-run or --yes, never overwrites an existing
.hab, never deletes, and is idempotent.

Note: renderer-config.json lives outside this repo and was patched to
.hab separately; that file is served with a 30-day max-age, so returning
clients need a cms-client cache purge to pick the change up.
2026-10-10 17:09:36 +02:00

241 lines
8.1 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
import type { RowDataPacket } from "mysql2";
import {
digestFile,
sourceFingerprint,
} from "@/features/catalog/releases/files";
import {
CATALOG_RELEASE_MANIFEST_PATH,
createReleaseManifest,
} from "@/features/catalog/releases/manifest";
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 { getPublicAssetRoot } from "@/lib/services/public-asset-root";
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;
// The catalog export must ship both bundle extensions for every asset type:
// `.hab` is what imports write now (furniture, pets, effects and figures all
// go through it), `.nitro` is what the existing asset set is made of.
const BUNDLE_FILE_RE = /\.(?:nitro|hab)$/i;
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
export async function createCatalogSnapshot(
directory: string,
): Promise<CatalogFile[]> {
const sources = new Map<string, string>();
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(getPublicAssetRoot(), "public/swf/c_images"),
"Gamedata/c_images",
/\.(png|gif|webp|jpe?g)$/i,
);
await addTree(
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled"),
"Gamedata/bundled",
BUNDLE_FILE_RE,
);
if (gamedata) {
await addTree(
path.join(gamedata, "bundled"),
"Gamedata/bundled",
BUNDLE_FILE_RE,
);
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", BUNDLE_FILE_RE);
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}`, BUNDLE_FILE_RE);
}
// Pets currently write directly to the CMS directory.
await addTree(
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"),
"Gamedata/bundled/pet",
BUNDLE_FILE_RE,
);
const configRoot = gamedata
? path.join(gamedata, "config")
: path.dirname(furnitureData);
const configuredJson: Record<string, string> = {
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<string, string>();
const copiedHashes = new Map<string, string>();
const fingerprint = sourceFingerprint;
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);
copiedHashes.set(source, (await digestFile(copy)).sha256);
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<RowDataPacket[]>("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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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 ||
(await digestFile(source)).sha256 !== copiedHashes.get(source)
)
throw new Error("Assets changed during export; retry required");
}
const entries = [];
for (const file of files) {
const { bytes, sha256 } = await digestFile(file.source);
entries.push({ path: file.target, bytes, sha256 });
}
const manifest = createReleaseManifest(entries);
const manifestSource = path.join(directory, "catalog-release.json");
await fs.writeFile(manifestSource, `${JSON.stringify(manifest, null, 2)}\n`, {
flag: "wx",
});
files.push({ source: manifestSource, target: CATALOG_RELEASE_MANIFEST_PATH });
return files;
}