feat(catalog): store furniture bundles as .hab instead of .nitro
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
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
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.
This commit is contained in:
1 parent
fdb7af7ef5
commit
adffac7360
46 files changed
+1001
-210
No files matched your search
@@ -37,6 +37,10 @@ const CONFIG_FILES = [
|
||||
"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(
|
||||
@@ -75,13 +79,13 @@ export async function createCatalogSnapshot(
|
||||
await addTree(
|
||||
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled"),
|
||||
"Gamedata/bundled",
|
||||
/\.nitro$/i,
|
||||
BUNDLE_FILE_RE,
|
||||
);
|
||||
if (gamedata) {
|
||||
await addTree(
|
||||
path.join(gamedata, "bundled"),
|
||||
"Gamedata/bundled",
|
||||
/\.nitro$/i,
|
||||
BUNDLE_FILE_RE,
|
||||
);
|
||||
await addTree(
|
||||
path.join(gamedata, "icons"),
|
||||
@@ -94,18 +98,18 @@ export async function createCatalogSnapshot(
|
||||
/\.(png|gif|webp|jpe?g)$/i,
|
||||
);
|
||||
}
|
||||
await addTree(furni.nitroDir, "Gamedata/bundled/furniture", /\.nitro$/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}`, /\.nitro$/i);
|
||||
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",
|
||||
/\.nitro$/i,
|
||||
BUNDLE_FILE_RE,
|
||||
);
|
||||
const configRoot = gamedata
|
||||
? path.join(gamedata, "config")
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("clone-import", () => {
|
||||
expect(r.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects", async () => {
|
||||
it("rolls back: unlinks the bundle and icon, skips FurnitureData when items_base INSERT rejects", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
downloadFile.mockReset();
|
||||
downloadFile.mockResolvedValue({ ok: true, size: 200 });
|
||||
@@ -190,9 +190,9 @@ describe("clone-import", () => {
|
||||
entry: { ...ENTRY, itemType: "s" },
|
||||
});
|
||||
|
||||
// (a) .nitro must be unlinked.
|
||||
// (a) the bundle (`.hab`) must be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bc_sofa.nitro"),
|
||||
expect.stringContaining("bc_sofa.hab"),
|
||||
);
|
||||
// (b) icon must also be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { db, ItemsBase, queryRows } from "@/lib/db";
|
||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||
import { bundleWritePathInDir } from "@/lib/furni/bundle-file";
|
||||
import { normalizeClassname } from "@/lib/furni/classname";
|
||||
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -293,11 +294,12 @@ export async function cloneSingleFurni(params: {
|
||||
await ensureDirectories();
|
||||
const { iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
// Download the bundle + icon directly from the source hotel. Sources serve
|
||||
// `.nitro`; the local copy is stored as `.hab`.
|
||||
await onProgress?.("downloading");
|
||||
const nitroPath = getRuntimePath(
|
||||
const nitroPath = bundleWritePathInDir(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
classname,
|
||||
);
|
||||
const iconPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
@@ -331,8 +333,8 @@ export async function cloneSingleFurni(params: {
|
||||
clonedNitroJson = parsed.json as Record<string, unknown>;
|
||||
} catch {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
logger.warn("[clone-import] invalid .nitro bundle for", { classname });
|
||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||
logger.warn("[clone-import] invalid bundle for", { classname });
|
||||
return { ok: false, classname, warnings, error: "invalid bundle" };
|
||||
}
|
||||
// Hotels ship PNG textures, so normalise every clone to WebP Lossless and
|
||||
// make it match the SWF importer. A texture that will not re-encode is
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import {
|
||||
bundleExistsInDir,
|
||||
bundleWritePathInDir,
|
||||
existingBundlePaths,
|
||||
} from "@/lib/furni/bundle-file";
|
||||
import {
|
||||
listOfficialEffects,
|
||||
resolveEffectSwfUrl,
|
||||
@@ -63,10 +68,12 @@ export async function ensureEffectDirs(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Imports write `.hab`, the extension the client requests; a bundle still on
|
||||
// disk as legacy `.nitro` keeps counting as present.
|
||||
async function nitroPathFor(lib: string): Promise<string> {
|
||||
return getRuntimePath(
|
||||
return bundleWritePathInDir(
|
||||
/*turbopackIgnore: true*/ await getEffectNitroDir(),
|
||||
`${lib}.nitro`,
|
||||
lib,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,11 +153,15 @@ export async function deleteImportedEffect(
|
||||
const removed = await removeEffect(idOrLib);
|
||||
let deletedFile = false;
|
||||
if (entry) {
|
||||
const p = await nitroPathFor(entry.lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
// Clear both conventions so a re-import cannot resurrect a stale bundle.
|
||||
const paths = existingBundlePaths(
|
||||
[/*turbopackIgnore: true*/ await getEffectNitroDir()],
|
||||
entry.lib,
|
||||
);
|
||||
for (const p of paths) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
deletedFile = true;
|
||||
}
|
||||
deletedFile = paths.length > 0;
|
||||
}
|
||||
return { removed, deletedFile };
|
||||
}
|
||||
@@ -169,9 +180,7 @@ export async function getEffectList(search: string): Promise<EffectListItem[]> {
|
||||
const merged = base.map((e) => ({
|
||||
...e,
|
||||
present: localByLib.has(e.lib),
|
||||
nitroExists: existsSync(
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${e.lib}.nitro`),
|
||||
),
|
||||
nitroExists: bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, e.lib),
|
||||
}));
|
||||
return term
|
||||
? merged.filter(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import {
|
||||
bundleExistsInDir,
|
||||
bundleWritePathInDir,
|
||||
existingBundlePaths,
|
||||
} from "@/lib/furni/bundle-file";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
|
||||
import { listFigureLibraries } from "@/lib/services/figuremap";
|
||||
@@ -69,10 +74,12 @@ export async function ensureFigureDirs(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Imports write `.hab`, the extension the client requests; a bundle still on
|
||||
// disk as legacy `.nitro` keeps counting as present.
|
||||
async function nitroPathFor(lib: string): Promise<string> {
|
||||
return getRuntimePath(
|
||||
return bundleWritePathInDir(
|
||||
/*turbopackIgnore: true*/ await getFigureNitroDir(),
|
||||
`${lib}.nitro`,
|
||||
lib,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,14 +140,18 @@ export async function importSingleFigure(params: {
|
||||
export async function deleteImportedFigure(
|
||||
lib: string,
|
||||
): Promise<{ deletedFile: boolean }> {
|
||||
const p = await nitroPathFor(lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
// Clear both conventions so a re-import cannot resurrect a stale bundle.
|
||||
const paths = existingBundlePaths(
|
||||
[/*turbopackIgnore: true*/ await getFigureNitroDir()],
|
||||
lib,
|
||||
);
|
||||
if (paths.length === 0) return { deletedFile: false };
|
||||
for (const p of paths) {
|
||||
await fs
|
||||
.unlink(/*turbopackIgnore: true*/ p)
|
||||
.catch((error) => logServerError("figure.delete_failed", error, { lib }));
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
return { deletedFile: true };
|
||||
}
|
||||
|
||||
export async function getFigureList(params: {
|
||||
@@ -163,9 +174,7 @@ export async function getFigureList(params: {
|
||||
const items = filtered.slice(start, start + perPage).map((l) => ({
|
||||
lib: l.id,
|
||||
revision: l.revision,
|
||||
nitroExists: existsSync(
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
|
||||
),
|
||||
nitroExists: bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, l.id),
|
||||
previewType: l.parts?.[0]?.type,
|
||||
previewId: l.parts?.[0]?.id,
|
||||
}));
|
||||
@@ -182,9 +191,7 @@ export async function getFigureStats(): Promise<{
|
||||
getFigureNitroDir(),
|
||||
]);
|
||||
const imported = libs.filter((l) =>
|
||||
existsSync(
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
|
||||
),
|
||||
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, l.id),
|
||||
).length;
|
||||
return { total: libs.length, imported, missing: libs.length - imported };
|
||||
}
|
||||
@@ -12,10 +12,9 @@
|
||||
* regen/sync) clear everything via `clearFurniImportCache()`.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db, ItemsBase, queryRows } from "@/lib/db";
|
||||
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
|
||||
import {
|
||||
getBaseClassname,
|
||||
getSafeClassnameStem,
|
||||
@@ -163,12 +162,12 @@ export async function getCleanupValidStems(): Promise<Set<string>> {
|
||||
return set;
|
||||
}
|
||||
|
||||
/** `existsSync` memoized per (dir + base) — nitro files change rarely. */
|
||||
/** `existsSync` memoized per (dir + base) — bundle files change rarely. */
|
||||
export function nitroExistsCached(dir: string, base: string): boolean {
|
||||
const key = `${dir}/${base}`;
|
||||
const cached = nitroPresenceCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const present = existsSync(path.join(dir, `${base}.nitro`));
|
||||
const present = bundleExistsInDir(dir, base);
|
||||
nitroPresenceCache.set(key, present);
|
||||
return present;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ vi.mock("@/lib/db", async () => ({
|
||||
CatalogPages: {},
|
||||
}));
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: (path: string) => !path.endsWith(".nitro") || mocks.nitroExists,
|
||||
// Bundle presence covers both conventions: `.hab` is what imports write,
|
||||
// `.nitro` is what the legacy asset set holds.
|
||||
existsSync: (path: string) =>
|
||||
!/\.(?:nitro|hab)$/.test(path) || mocks.nitroExists,
|
||||
promises: {
|
||||
stat: mocks.stat,
|
||||
writeFile: mocks.write,
|
||||
@@ -113,7 +116,7 @@ describe("completion of existing furniture", () => {
|
||||
});
|
||||
expect(recover).toHaveBeenCalledWith(1);
|
||||
expect(mocks.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining("recycler_scrapbookA.nitro"),
|
||||
expect.stringContaining("recycler_scrapbookA.hab"),
|
||||
expect.any(Buffer),
|
||||
{ flag: "wx" },
|
||||
);
|
||||
|
||||
@@ -4,7 +4,14 @@ import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
|
||||
import { isSafeAssetName } from "@/lib/furni/classname";
|
||||
import {
|
||||
bundleExistsInDir,
|
||||
bundleFileNames,
|
||||
bundleWritePathInDir,
|
||||
resolveBundleInDir,
|
||||
stripBundleExt,
|
||||
} from "@/lib/furni/bundle-file";
|
||||
import { isSafeAssetName, LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
|
||||
import { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability";
|
||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -449,8 +456,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
const copied = await syncDirMissingFiles(
|
||||
nitroDir,
|
||||
nitroTarget,
|
||||
(entry) => stripBundleExt(entry) !== null,
|
||||
);
|
||||
copiedNitros.push(...copied);
|
||||
}
|
||||
@@ -721,10 +730,17 @@ export async function importSingleFurni(params: {
|
||||
/*turbopackIgnore: true*/ swfDir,
|
||||
`${safeSwfName}.swf`,
|
||||
);
|
||||
const nitroPath = getRuntimePath(
|
||||
// Imports write `.hab`; a pre-existing legacy `.nitro` for the same
|
||||
// classname still counts as "already present" so a colour variant sharing a
|
||||
// base classname never overwrites a bundle that is already on disk.
|
||||
const nitroPath = bundleWritePathInDir(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${safeNitroName}.nitro`,
|
||||
safeNitroName,
|
||||
);
|
||||
const nitroExists = () =>
|
||||
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
|
||||
const resolveNitro = () =>
|
||||
resolveBundleInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
|
||||
const iconPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
@@ -733,7 +749,7 @@ export async function importSingleFurni(params: {
|
||||
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
|
||||
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
|
||||
|
||||
if (params.providedNitro && !existsSync(nitroPath)) {
|
||||
if (params.providedNitro && !nitroExists()) {
|
||||
await fs.writeFile(
|
||||
nitroPath,
|
||||
await toWebpLosslessOrKeep(params.providedNitro, warnings),
|
||||
@@ -801,9 +817,9 @@ export async function importSingleFurni(params: {
|
||||
// disk — this happens for colour variants that share the base classname's
|
||||
// nitro file. The SWF is only used for Nitro conversion (not needed) and
|
||||
// icon extraction (the nitro bundle is used as a fallback for that below).
|
||||
existsSync(/*turbopackIgnore: true*/ nitroPath) ||
|
||||
nitroExists() ||
|
||||
params.providedNitro ||
|
||||
(params.preserveAssets && existsSync(nitroPath))
|
||||
(params.preserveAssets && nitroExists())
|
||||
? Promise.resolve(false)
|
||||
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
|
||||
? Promise.resolve(true)
|
||||
@@ -817,11 +833,7 @@ export async function importSingleFurni(params: {
|
||||
// ── Nitro bundle fallback: try downloading pre-made .nitro ────────
|
||||
// Only relevant when the SWF download failed, so it runs after SWF.
|
||||
let nitroDownloadOk = false;
|
||||
if (
|
||||
!swfOk &&
|
||||
sourceNitroBaseUrl &&
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||
) {
|
||||
if (!swfOk && sourceNitroBaseUrl && !nitroExists()) {
|
||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
|
||||
if (dl.ok) nitroDownloadOk = true;
|
||||
@@ -858,8 +870,8 @@ export async function importSingleFurni(params: {
|
||||
!swfOk &&
|
||||
!nitroDownloadOk &&
|
||||
!params.providedNitro &&
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath) &&
|
||||
!(params.preserveAssets && existsSync(nitroPath))
|
||||
!nitroExists() &&
|
||||
!(params.preserveAssets && nitroExists())
|
||||
)
|
||||
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
||||
|
||||
@@ -867,8 +879,8 @@ export async function importSingleFurni(params: {
|
||||
await onProgress?.("converting");
|
||||
let conversionResult: ConversionResult | null = null;
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
warnings.push("Nitro file already exists (shared across color variants)");
|
||||
if (nitroExists()) {
|
||||
warnings.push("Bundle file already exists (shared across color variants)");
|
||||
} else {
|
||||
try {
|
||||
if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
|
||||
@@ -897,7 +909,7 @@ export async function importSingleFurni(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(nitroPath) && params.resolveMissingNitro) {
|
||||
if (!nitroExists() && params.resolveMissingNitro) {
|
||||
await onProgress?.("checking_sources");
|
||||
const recovered = await params.resolveMissingNitro(rev);
|
||||
if (recovered)
|
||||
@@ -912,9 +924,11 @@ export async function importSingleFurni(params: {
|
||||
);
|
||||
}
|
||||
|
||||
if (!iconOk && existsSync(nitroPath)) {
|
||||
if (!iconOk && nitroExists()) {
|
||||
try {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroPath));
|
||||
const icon = extractFurniIconPng(
|
||||
await fs.readFile(resolveNitro() ?? nitroPath),
|
||||
);
|
||||
if (icon) {
|
||||
await fs.writeFile(iconPath, icon);
|
||||
iconOk = true;
|
||||
@@ -929,7 +943,7 @@ export async function importSingleFurni(params: {
|
||||
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare
|
||||
// Nitro bundle header is larger than that, anything smaller is corrupt/empty).
|
||||
const nitroStat = await fs
|
||||
.stat(/*turbopackIgnore: true*/ nitroPath)
|
||||
.stat(resolveNitro() ?? nitroPath)
|
||||
.catch(() => null);
|
||||
const nitroValid = nitroStat && nitroStat.size >= 128;
|
||||
|
||||
@@ -939,7 +953,7 @@ export async function importSingleFurni(params: {
|
||||
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
||||
);
|
||||
try {
|
||||
await fs.unlink(nitroPath);
|
||||
await fs.unlink(resolveNitro() ?? nitroPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -1003,8 +1017,8 @@ export async function importSingleFurni(params: {
|
||||
warnings,
|
||||
),
|
||||
mirrorFileToDirs(
|
||||
nitroPath,
|
||||
`${safeNitroName}.nitro`,
|
||||
resolveNitro() ?? nitroPath,
|
||||
`${safeNitroName}${LOCAL_BUNDLE_EXT}`,
|
||||
mirrorNitroDirs,
|
||||
warnings,
|
||||
),
|
||||
@@ -1077,11 +1091,11 @@ export async function importSingleFurni(params: {
|
||||
let nitroJsonForModes: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const parsed = parseNitroBundle(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||
await fs.readFile(resolveNitro() ?? nitroPath),
|
||||
);
|
||||
nitroJsonForModes = parsed.json as Record<string, unknown>;
|
||||
} catch {
|
||||
// no .nitro JSON available — keyword fallback only
|
||||
// no bundle JSON available — keyword fallback only
|
||||
}
|
||||
const autoInteraction = autoDetectInteraction(classname, bestName, {
|
||||
...realFlags,
|
||||
@@ -1257,17 +1271,15 @@ async function realNitroModes(classname: string): Promise<number | undefined> {
|
||||
const cached = nitroModesCache.get(base);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const nitroPath = getRuntimePath(
|
||||
const nitroPath = resolveBundleInDir(
|
||||
/*turbopackIgnore: true*/ FURNI_NITRO_DIR,
|
||||
`${base}.nitro`,
|
||||
base,
|
||||
);
|
||||
if (!existsSync(nitroPath)) {
|
||||
if (!nitroPath) {
|
||||
nitroModesCache.set(base, undefined);
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseNitroBundle(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||
);
|
||||
const parsed = parseNitroBundle(await fs.readFile(nitroPath));
|
||||
const modes = nitroAnimationStatesCount(
|
||||
parsed.json as Record<string, unknown>,
|
||||
);
|
||||
@@ -1481,11 +1493,13 @@ export async function deleteImportedItem(classname: string): Promise<{
|
||||
errors.push(`FurnitureData.json: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 4. Delete files (.nitro, .swf, icon)
|
||||
// 4. Delete files (bundle — `.hab` and any legacy `.nitro` — plus .swf, icon)
|
||||
const filesToDelete = [
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
...[nitroDir, ...assetTargets.mirrorDirs.map((d) => d.nitroDir)].flatMap(
|
||||
(dir) =>
|
||||
bundleFileNames(baseClassname).map((name) =>
|
||||
getRuntimePath(/*turbopackIgnore: true*/ dir, name),
|
||||
),
|
||||
),
|
||||
getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
|
||||
getRuntimePath(
|
||||
@@ -1493,10 +1507,6 @@ export async function deleteImportedItem(classname: string): Promise<{
|
||||
`${iconFileName}_icon.png`,
|
||||
),
|
||||
...assetTargets.mirrorDirs.flatMap((dirs) => [
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
),
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.swfDir,
|
||||
`${baseClassname}.swf`,
|
||||
@@ -1541,12 +1551,8 @@ export async function cleanupConvertedSwfs(): Promise<{
|
||||
|
||||
for (const swfFile of swfFiles) {
|
||||
const cn = swfFile.replace(".swf", "");
|
||||
const nitroFilePath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${cn}.nitro`,
|
||||
);
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
|
||||
if (bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, cn)) {
|
||||
try {
|
||||
await fs.unlink(
|
||||
getRuntimePath(/*turbopackIgnore: true*/ swfDir, swfFile),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { bundleFileNames } from "@/lib/furni/bundle-file";
|
||||
import { normalizeClassname } from "@/lib/furni/classname";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
@@ -111,7 +112,7 @@ export async function inspectFurniture(
|
||||
const [nitro, icon] = await Promise.all([
|
||||
assetCandidates(
|
||||
dirs.nitroDir,
|
||||
variants.map((name) => `${name.split("*")[0]}.nitro`),
|
||||
variants.flatMap((name) => bundleFileNames(name.split("*")[0])),
|
||||
),
|
||||
assetCandidates(
|
||||
dirs.iconDir,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { bundleFileNames } from "@/lib/furni/bundle-file";
|
||||
|
||||
/**
|
||||
* Classification of `items_base` rows so the catalog audit and repairs know
|
||||
* exactly which items are expected to ship normal furni assets (`.nitro`
|
||||
* exactly which items are expected to ship normal furni assets (`.hab`
|
||||
* bundles + `_icon.png` files) and which ones are special:
|
||||
*
|
||||
* - **badge** — stored with type `b`. Badges use album1584 `.gif` files and
|
||||
@@ -151,7 +153,9 @@ export function assetNameCandidates(
|
||||
}
|
||||
|
||||
return {
|
||||
nitro: unique(bases).map((n) => `${n}.nitro`),
|
||||
// Both bundle extensions per base — `.hab` first, legacy `.nitro` second,
|
||||
// so an item still resolves when only the older file is on disk.
|
||||
nitro: unique(bases).flatMap((n) => bundleFileNames(n)),
|
||||
icon: unique(bases).map((n) => `${n}_icon.png`),
|
||||
};
|
||||
}
|
||||
@@ -680,6 +680,42 @@ describe("repairBrokenNitros", () => {
|
||||
expect(downloadFileFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// `.hab` is accepted as a bundle name now, so it has to clear exactly the
|
||||
// same traversal guard as `.nitro` — stripping an extension is not a check.
|
||||
it("rejects unsafe `.hab` file names without downloading", async () => {
|
||||
const result = await repairBrokenNitros(["../evil.hab"]);
|
||||
|
||||
expect(result.repaired).toBe(0);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(downloadFileFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-downloads a broken `.hab` and writes it back under the same name", async () => {
|
||||
getTargetsFn.mockResolvedValue({
|
||||
nitroDir: "/a/nitro",
|
||||
swfDir: "/a/swf",
|
||||
iconDir: "/a/icons",
|
||||
mirrorDirs: [],
|
||||
});
|
||||
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
|
||||
parseNitroBundle.mockReturnValue({});
|
||||
downloadFileFn.mockResolvedValue({ ok: true, size: 512 });
|
||||
|
||||
const result = await repairBrokenNitros(["broken_thing.hab"]);
|
||||
|
||||
expect(result.repaired).toBe(1);
|
||||
// Sources still serve `.nitro`; the local file keeps its own name.
|
||||
expect(downloadFileFn).toHaveBeenCalledWith(
|
||||
"https://src.test/nitro/broken_thing.nitro",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ validate: "nitro" }),
|
||||
);
|
||||
expect(writeFileFn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/a/nitro/broken_thing.hab"),
|
||||
expect.any(Buffer),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when no source can provide the bundle", async () => {
|
||||
downloadFileFn.mockResolvedValue({ ok: false, size: 0, error: "HTTP 404" });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { stripBundleExt } from "@/lib/furni/bundle-file";
|
||||
import {
|
||||
DEFAULT_NITRO_SOURCES,
|
||||
listSources,
|
||||
@@ -105,7 +106,11 @@ export interface NitroAutoCleanResult extends NitroCleanupDeleteResult {
|
||||
skippedBroken: number;
|
||||
}
|
||||
|
||||
const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.nitro$/i;
|
||||
// Both bundle extensions: `.hab` is what imports write today, `.nitro` is the
|
||||
// legacy name still covering the bulk of the on-disk asset set. Both must be
|
||||
// scanned — matching only one would leave the other invisible to the fake /
|
||||
// broken classification and to the nightly auto-clean.
|
||||
const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.(?:nitro|hab)$/i;
|
||||
const SWF_FILE_RE = /^[a-z0-9_*\-.]+\.swf$/i;
|
||||
const ICON_FILE_RE = /^[a-z0-9_*\-.]+\.(?:gif|png)$/i;
|
||||
|
||||
@@ -795,14 +800,20 @@ export async function repairBrokenNitros(
|
||||
|
||||
await runPool(uniqueFileNames, 6, async (rawName) => {
|
||||
const fileName = String(rawName ?? "");
|
||||
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
|
||||
// `stripBundleExt` only peels the extension — it does not make the stem
|
||||
// safe to join into a path. The character-class regex is what rejects
|
||||
// traversal payloads like `../evil.nitro` before any download runs.
|
||||
const name =
|
||||
NITRO_FILE_RE.test(fileName) && stripBundleExt(fileName) !== null
|
||||
? stripBundleExt(fileName)
|
||||
: null;
|
||||
if (name === null) {
|
||||
const message = `Invalid nitro file name: ${fileName}`;
|
||||
errors.push(message);
|
||||
files.push({ fileName, ok: false, error: message });
|
||||
finish(fileName, false, message);
|
||||
return;
|
||||
}
|
||||
const name = fileName.slice(0, -".nitro".length);
|
||||
const candidates = [
|
||||
name,
|
||||
...(name.includes("*")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
|
||||
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
|
||||
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
@@ -54,8 +55,8 @@ const iconCache = new Map<string, Buffer | null>();
|
||||
|
||||
function findNitro(lib: string): string | null {
|
||||
for (const dir of PET_DIRS) {
|
||||
const p = getRuntimePath(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) return p;
|
||||
const p = resolveBundleInDir(/*turbopackIgnore: true*/ dir, lib);
|
||||
if (p) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/services/import/core/download", () => ({
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
@@ -17,28 +17,40 @@ vi.mock("@/lib/services/swf-to-nitro", () => ({
|
||||
convertSwfToNitro: (...a: unknown[]) =>
|
||||
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
|
||||
}));
|
||||
// `vi.mock` factories are hoisted, so these handles have to exist before the
|
||||
// module body runs or the fs factory hits the temporal dead zone.
|
||||
const { writeFile, unlink, exists } = vi.hoisted(() => ({
|
||||
writeFile: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
exists: vi.fn((_: string) => false),
|
||||
}));
|
||||
vi.mock("node:fs", async (orig) => {
|
||||
const real = (await orig()) as typeof import("node:fs");
|
||||
return {
|
||||
...real,
|
||||
existsSync: vi.fn(() => false),
|
||||
existsSync: exists,
|
||||
promises: {
|
||||
...real.promises,
|
||||
readFile: vi.fn(async () => Buffer.from("CWS")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
writeFile,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
unlink,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { importSinglePet } from "./pet-import";
|
||||
import { deleteImportedPet, importSinglePet } from "./pet-import";
|
||||
|
||||
describe("pet-import.importSinglePet", () => {
|
||||
it("downloads, converts, writes .nitro", async () => {
|
||||
it("downloads, converts, writes .hab", async () => {
|
||||
const r = await importSinglePet({ lib: "dog" });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(convertSwfToNitro).toHaveBeenCalled();
|
||||
// Pets follow the same convention as furniture: `.hab` on disk.
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("dog.hab"),
|
||||
expect.any(Buffer),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails cleanly when conversion throws", async () => {
|
||||
@@ -50,3 +62,32 @@ describe("pet-import.importSinglePet", () => {
|
||||
expect(r.error).toContain("conversion");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pet-import.deleteImportedPet", () => {
|
||||
beforeEach(() => {
|
||||
writeFile.mockClear();
|
||||
unlink.mockClear();
|
||||
exists.mockReset();
|
||||
});
|
||||
|
||||
it("reports nothing deleted when the pet was never imported", async () => {
|
||||
exists.mockReturnValue(false);
|
||||
|
||||
await expect(deleteImportedPet("dog")).resolves.toEqual({
|
||||
deletedFile: false,
|
||||
});
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A re-import writes `.hab`; leaving a legacy `.nitro` behind would let the
|
||||
// client pick up the stale bundle, so both are cleared.
|
||||
it("clears both the `.hab` and the legacy `.nitro`", async () => {
|
||||
exists.mockReturnValue(true);
|
||||
|
||||
await expect(deleteImportedPet("dog")).resolves.toEqual({
|
||||
deletedFile: true,
|
||||
});
|
||||
expect(unlink).toHaveBeenCalledWith(expect.stringContaining("dog.hab"));
|
||||
expect(unlink).toHaveBeenCalledWith(expect.stringContaining("dog.nitro"));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import {
|
||||
bundleExistsInDir,
|
||||
bundleWritePathInDir,
|
||||
existingBundlePaths,
|
||||
} from "@/lib/furni/bundle-file";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { CANONICAL_PETS, resolvePetSwfUrl } from "@/lib/services/pet-source";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
@@ -35,11 +40,14 @@ export async function ensurePetDirs(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Imports write `.hab`, the extension the client requests. A pet whose bundle
|
||||
// predates that (`.nitro`) still counts as imported until it is re-imported.
|
||||
function nitroPathFor(lib: string): string {
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ PET_NITRO_DIR,
|
||||
`${lib}.nitro`,
|
||||
);
|
||||
return bundleWritePathInDir(/*turbopackIgnore: true*/ PET_NITRO_DIR, lib);
|
||||
}
|
||||
|
||||
function petBundleExists(lib: string): boolean {
|
||||
return bundleExistsInDir(/*turbopackIgnore: true*/ PET_NITRO_DIR, lib);
|
||||
}
|
||||
|
||||
export async function importSinglePet(params: {
|
||||
@@ -99,19 +107,23 @@ export async function importSinglePet(params: {
|
||||
export async function deleteImportedPet(
|
||||
lib: string,
|
||||
): Promise<{ deletedFile: boolean }> {
|
||||
const p = nitroPathFor(lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
// Remove both conventions so a re-import cannot resurrect a stale bundle.
|
||||
const paths = existingBundlePaths(
|
||||
[/*turbopackIgnore: true*/ PET_NITRO_DIR],
|
||||
lib,
|
||||
);
|
||||
if (paths.length === 0) return { deletedFile: false };
|
||||
for (const p of paths) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
return { deletedFile: true };
|
||||
}
|
||||
|
||||
export function getPetList(search: string): PetListItem[] {
|
||||
const term = search.trim().toLowerCase();
|
||||
const list = CANONICAL_PETS.map((lib) => ({
|
||||
lib,
|
||||
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
|
||||
nitroExists: petBundleExists(lib),
|
||||
}));
|
||||
return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list;
|
||||
}
|
||||
@@ -121,9 +133,7 @@ export function getPetStats(): {
|
||||
imported: number;
|
||||
missing: number;
|
||||
} {
|
||||
const imported = CANONICAL_PETS.filter((lib) =>
|
||||
existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
|
||||
).length;
|
||||
const imported = CANONICAL_PETS.filter((lib) => petBundleExists(lib)).length;
|
||||
return {
|
||||
total: CANONICAL_PETS.length,
|
||||
imported,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||
import {
|
||||
@@ -263,13 +264,13 @@ export async function repairMissingIcons(
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Extract from a local .nitro bundle (checked across every target).
|
||||
// 2) Extract from a local bundle (checked across every target).
|
||||
if (!ok) {
|
||||
for (const n of names) {
|
||||
let foundNitro = false;
|
||||
for (const dir of nitroDirs) {
|
||||
const nitroPath = getRuntimePath(dir, `${n}.nitro`);
|
||||
if (!existsSync(nitroPath)) continue;
|
||||
const nitroPath = resolveBundleInDir(dir, n);
|
||||
if (!nitroPath) continue;
|
||||
foundNitro = true;
|
||||
try {
|
||||
const nitroBuf = await fs.readFile(nitroPath);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { bundleFileNames, stripBundleExt } from "@/lib/furni/bundle-file";
|
||||
import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
|
||||
import {
|
||||
type CloneSource,
|
||||
DEFAULT_NITRO_SOURCES,
|
||||
@@ -218,8 +220,9 @@ export async function repairMissingNitros(
|
||||
return { status: "skipped" } as const;
|
||||
}
|
||||
const names = classnameCandidates(item);
|
||||
const fileNameFor = (n: string) => `${n}.nitro`;
|
||||
const fileNames = names.map(fileNameFor);
|
||||
// Locally a bundle is either `.hab` (what repairs write) or a legacy
|
||||
// `.nitro`; both count as "the asset is already here".
|
||||
const fileNames = names.flatMap((n) => bundleFileNames(n));
|
||||
|
||||
const presentDirs = nitroDirs.filter((dir) =>
|
||||
fileNames.some((f) => existsSync(getRuntimePath(dir, f))),
|
||||
@@ -264,11 +267,9 @@ export async function repairMissingNitros(
|
||||
|
||||
for (const n of names) {
|
||||
const nitroCandidates = [
|
||||
fileNameFor(n),
|
||||
...(n.includes("*")
|
||||
? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))]
|
||||
: []),
|
||||
...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []),
|
||||
n,
|
||||
...(n.includes("*") ? [n.split("*")[0], n.replace(/\*/g, "_")] : []),
|
||||
...(n.includes(".") ? [n.replace(/\./g, "_")] : []),
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
@@ -288,10 +289,11 @@ export async function repairMissingNitros(
|
||||
// anywhere (mirrors clone-import.ts).
|
||||
const buffer = await fs.readFile(tmpPath);
|
||||
parseNitroBundle(buffer);
|
||||
// Repaired bundles are stored as `.hab`.
|
||||
const writeErrors = await writeNitroToDirs(
|
||||
buffer,
|
||||
missingDirs,
|
||||
fileNameFor(n),
|
||||
`${n}${LOCAL_BUNDLE_EXT}`,
|
||||
);
|
||||
warnings.push(...writeErrors);
|
||||
ok = writeErrors.length === 0;
|
||||
@@ -317,7 +319,7 @@ export async function repairMissingNitros(
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: warnings[0] || "could not obtain .nitro",
|
||||
message: warnings[0] || "could not obtain a bundle",
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -443,7 +445,7 @@ export async function addSize32AllNitros(
|
||||
try {
|
||||
const names = await fs.readdir(dir);
|
||||
for (const n of names) {
|
||||
if (n.toLowerCase().endsWith(".nitro")) fileNames.add(n);
|
||||
if (stripBundleExt(n) !== null) fileNames.add(n);
|
||||
}
|
||||
} catch {
|
||||
/* ignore unreadable dir */
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("uploadSingleFurni live asset mirrors", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("copies the Nitro bundle and icon into the directories served by production", async () => {
|
||||
it("copies the bundle and icon into the directories served by production", async () => {
|
||||
const nitroBuffer = Buffer.from("nitro-bundle");
|
||||
const iconBuffer = Buffer.from("png-icon");
|
||||
|
||||
@@ -143,11 +143,10 @@ describe("uploadSingleFurni live asset mirrors", () => {
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// Every upload is normalised to WebP Lossless, whatever the source
|
||||
// bundle carried, so what lands on disk is not the raw upload.
|
||||
// Uploads land as `.hab` — the extension the client requests.
|
||||
await expect(
|
||||
fs.readFile(
|
||||
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
|
||||
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.hab"),
|
||||
),
|
||||
).resolves.toEqual(Buffer.from("webp-normalised", "utf-8"));
|
||||
await expect(
|
||||
@@ -156,4 +155,46 @@ describe("uploadSingleFurni live asset mirrors", () => {
|
||||
),
|
||||
).resolves.toEqual(iconBuffer);
|
||||
});
|
||||
|
||||
// A `.hab` and a `.nitro` are the same bundle, so the uploaded file's own
|
||||
// extension only decides the name on disk — never whether it is accepted.
|
||||
it("stores a `.nitro` upload as `.hab` too", async () => {
|
||||
const result = await uploadSingleFurni({
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
itemType: "s",
|
||||
nitroBuffer: Buffer.from("nitro-bundle"),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
await expect(
|
||||
fs.readFile(getRuntimePath(assetTargets.nitroDir, "chair.hab")),
|
||||
).resolves.toEqual(Buffer.from("webp-normalised", "utf-8"));
|
||||
await expect(
|
||||
fs.stat(getRuntimePath(assetTargets.nitroDir, "chair.nitro")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
// Writing `.hab` beside a live `.nitro` would leave the client two bundles
|
||||
// to choose between, so the import has to refuse instead.
|
||||
it("refuses to overwrite a legacy `.nitro` already on disk", async () => {
|
||||
await fs.mkdir(assetTargets.nitroDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
getRuntimePath(assetTargets.nitroDir, "chair.nitro"),
|
||||
"existing",
|
||||
);
|
||||
|
||||
const result = await uploadSingleFurni({
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
itemType: "s",
|
||||
nitroBuffer: Buffer.from("nitro-bundle"),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/chair\.nitro already exists on disk/);
|
||||
await expect(
|
||||
fs.readFile(getRuntimePath(assetTargets.nitroDir, "chair.nitro")),
|
||||
).resolves.toEqual(Buffer.from("existing"));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db, ItemsBase, queryRows } from "@/lib/db";
|
||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||
import {
|
||||
bundleWritePathInDir,
|
||||
resolveBundleInDir,
|
||||
} from "@/lib/furni/bundle-file";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||
@@ -293,26 +297,35 @@ export async function uploadSingleFurni(params: {
|
||||
if (!isLosslessWebp(parsed.texture))
|
||||
warnings.push("texture converted to WebP Lossless");
|
||||
} catch {
|
||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||
return { ok: false, classname, warnings, error: "invalid bundle" };
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
const { iconDir, nitroDir } = assetTargets;
|
||||
|
||||
const nitroFileName = `${classname}.nitro`;
|
||||
const nitroPath = getRuntimePath(
|
||||
// Bundles are stored as `<classname>.hab` — the extension this deployment's
|
||||
// client requests. The uploaded file may arrive named `.hab` or `.nitro`;
|
||||
// parsing above is format-based, so both are the same bundle. A legacy
|
||||
// `.nitro` left on disk still counts as a collision, otherwise the item
|
||||
// would end up served by whichever the client happens to ask for first.
|
||||
const existingBundle = resolveBundleInDir(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
nitroFileName,
|
||||
classname,
|
||||
);
|
||||
if (existsSync(nitroPath)) {
|
||||
if (existingBundle) {
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
warnings,
|
||||
error: ".nitro file already exists on disk",
|
||||
error: `${path.basename(existingBundle)} already exists on disk`,
|
||||
};
|
||||
}
|
||||
const nitroFileName = path.basename(bundleWritePathInDir("", classname));
|
||||
const nitroPath = bundleWritePathInDir(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
classname,
|
||||
);
|
||||
await fs.writeFile(nitroPath, storedNitro);
|
||||
|
||||
let iconFileName: string | null = null;
|
||||
|
||||
Reference in new issue
Block a user