fix(catalog): check source assets and offer reachable import alternatives

This commit is contained in:
Simo committed 2026-09-05 14:56:04 +02:00
1 parent 8c1efae296
commit 159b1f7d1b
10 files changed
+646 -56

No files matched your search

@@ -0,0 +1,86 @@
import { NextRequest } from "next/server";
import { beforeEach, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
const mocks = vi.hoisted(() => ({
guard: vi.fn(),
source: vi.fn(),
official: vi.fn(),
inspect: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
getOfficialHabboFurnidata: mocks.official,
}));
vi.mock("@/lib/services/furniture-source-assets", () => ({
inspectSourceAssets: mocks.inspect,
}));
import { POST } from "./route";
const item = {
id: 1,
classname: "chair",
name: "Chair",
description: "",
revision: 1,
type: "flooritem",
category: "other",
};
const request = (body: unknown) =>
new NextRequest("http://localhost/api/admin/studio/source-assets", {
method: "POST",
body: JSON.stringify(body),
});
beforeEach(() => {
mocks.source.mockReset().mockResolvedValue(null);
mocks.official.mockReset().mockResolvedValue(new Map());
mocks.inspect
.mockReset()
.mockResolvedValue({
classname: "chair",
state: "missing",
revision: 1,
alternatives: [],
});
});
it("requires import permission", () =>
expect(mocks.guard).toHaveBeenCalledWith({
permission: PERMS.ASSETS_IMPORT,
}));
it.each([
[],
Array(21).fill(item),
[{ ...item, classname: "../secret" }],
[{ ...item, revision: -1 }],
[null],
])("rejects invalid items before network checks: %j", async (items) => {
expect((await POST(request({ items }))).status).toBe(400);
expect(mocks.inspect).not.toHaveBeenCalled();
});
it("rejects unknown configured sources", async () => {
expect(
(await POST(request({ items: [item], sourceId: "missing" }))).status,
).toBe(404);
expect(mocks.inspect).not.toHaveBeenCalled();
});
it("uses server source configuration instead of client URLs", async () => {
const source = { id: "custom", nitroBaseUrl: "https://configured.example" };
mocks.source.mockResolvedValue(source);
const response = await POST(
request({
items: [item],
sourceId: "custom",
nitroBaseUrl: "https://untrusted.example",
}),
);
expect(response.status).toBe(200);
expect(mocks.inspect).toHaveBeenCalledWith(item, [], source);
expect((await response.json()).items[0].state).toBe("missing");
});
@@ -0,0 +1,59 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import type {
SourceAssetCheck,
SourceAssetItem,
} from "@/lib/furni/source-assets";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { getSource } from "@/lib/services/clone-sources";
import { inspectSourceAssets } from "@/lib/services/furniture-source-assets";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const body = await request.json().catch(() => null);
if (
!Array.isArray(body?.items) ||
body.items.length > 20 ||
!validateClassnames(
body.items.map((item: SourceAssetItem) => item?.classname),
)
)
return apiError("Provide 1–20 furniture items", 400);
if (
body.items.some(
(item: SourceAssetItem) =>
!Number.isSafeInteger(item.revision) ||
item.revision < 0 ||
typeof item.name !== "string" ||
item.name.length > 500,
)
)
return apiError("Invalid source metadata", 400);
const source = body.sourceId ? await getSource(body.sourceId) : null;
if (body.sourceId && !source) return apiError("Source not found", 404);
const entries = await getOfficialHabboFurnidata();
const official: SourceAssetItem[] = [...entries.values()].map((entry) => ({
id: entry.id,
classname: entry.classname,
name: entry.name,
description: entry.description,
type: entry.itemType === "i" ? "wallitem" : "flooritem",
revision: entry.revision,
category: entry.category,
}));
const results: SourceAssetCheck[] = [];
for (let offset = 0; offset < body.items.length; offset += 4)
results.push(
...(await Promise.all(
body.items
.slice(offset, offset + 4)
.map((item: SourceAssetItem) =>
inspectSourceAssets(item, official, source),
),
)),
);
return apiOk({ items: results });
},
);
+120 -5
View File
@@ -16,23 +16,33 @@ import {
useFurnitureInspection,
} from "./furniture-inspector";
import type { FurniItem } from "./studio-types";
import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({
items,
items: originalItems,
sourceId,
source,
translation,
onCancel,
onConfirm,
}: {
items: FurniItem[];
sourceId?: string;
source: string;
translation: string;
onCancel: () => void;
onConfirm: (items: FurniItem[]) => void;
onConfirm: (items: FurniItem[], replaced?: boolean) => void;
}) {
const [items, setItems] = useState(originalItems);
const [replacements, setReplacements] = useState<string[]>([]);
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
const assets = useSourceAssetChecks(items, sourceId, inspection);
const assetsByName = new Map(
assets.items.map((item) => [item.classname, item]),
);
const unavailable = assets.items.filter((item) => item.state === "missing");
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
const [step, setStep] = useState<"review" | "confirm">("review");
const [page, setPage] = useState(0);
@@ -42,7 +52,11 @@ export function ImportReview({
const complete = items.every((item) => localByName.has(item.classname));
const ready = items.filter((item) => {
const local = localByName.get(item.classname);
return local && compareFurniture(item, local).state !== "conflict";
return (
local &&
assetsByName.get(item.classname)?.state !== "missing" &&
compareFurniture(item, local).state !== "conflict"
);
});
const blocked = items.filter((item) => {
const local = localByName.get(item.classname);
@@ -98,6 +112,104 @@ export function ImportReview({
<strong>Selection:</strong> {items.length} furniture items
</p>
</div>
{replacements.length > 0 && (
<p className="text-sm">
Replacements selected: {replacements.join(" · ")}{" "}
<Button
variant="ghost"
onClick={() => {
setItems(originalItems);
setReplacements([]);
setStep("review");
setPage(0);
}}
>
Undo replacements
</Button>
</p>
)}
{assets.loading && !inspection.loading && (
<p role="status">
Checking source files… {assets.items.length} checked
</p>
)}
{assets.error && (
<div role="alert">
<p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}>
Retry source check
</Button>
</div>
)}
{assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to
download their files.
</p>
)}
{unavailable.map((check) => (
<section
key={check.classname}
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
>
<p className="text-sm font-medium">
Source files unavailable: {check.classname}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
This item will be skipped. Choose a reachable alternative below
to replace it in this import.
</p>
{check.alternatives.length === 0 && (
<p className="text-sm">
No verified alternative found. Upload the original .nitro from
the import page if you have it.
</p>
)}
{check.alternatives.map((alternative) => (
<div
key={alternative.classname}
className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-[var(--admin-canvas)] p-2"
>
<div className="text-sm">
<strong>{alternative.name}</strong>
<p className="font-mono text-xs">{alternative.classname}</p>
<p className="text-xs">
{alternative.type} · Revision {alternative.revision} · ID{" "}
{alternative.id}
</p>
</div>
<Button
variant="outline"
onClick={() => {
setItems((current) => [
...new Map(
current.map((item) => {
const next =
item.classname === check.classname
? {
...alternative,
alreadyImported: false,
nitroExists: false,
}
: item;
return [next.classname, next] as const;
}),
).values(),
]);
setReplacements((current) => [
...current,
`${check.classname} → ${alternative.classname}`,
]);
setStep("review");
setPage(0);
}}
>
Use this alternative
</Button>
</div>
))}
</section>
))}
{inspection.loading ? (
<p role="status">Checking local data and assets…</p>
) : inspection.error ? (
@@ -115,7 +227,8 @@ export function ImportReview({
<>
<p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL
(complete missing data) · {blocked} conflicts (skipped)
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} unavailable (skipped)
</p>
{inspection.items.some(
(item) =>
@@ -257,6 +370,8 @@ export function ImportReview({
}}
disabled={
inspection.loading ||
assets.loading ||
!!assets.error ||
!!inspection.error ||
!complete ||
ready.length === 0
@@ -265,7 +380,7 @@ export function ImportReview({
if (step === "review") {
setStep("confirm");
setPage(0);
} else onConfirm(ready);
} else onConfirm(ready, replacements.length > 0);
}}
>
{step === "review"
@@ -2391,6 +2391,7 @@ export function StudioClient({
{review && (
<ImportReview
items={review.items}
sourceId={activeSource || undefined}
source={
sources.find((entry) => entry.id === activeSource)?.name ??
source.label
@@ -2403,10 +2404,10 @@ export function StudioClient({
: "Off"
}
onCancel={() => setReview(null)}
onConfirm={(accepted) => {
onConfirm={(accepted, replaced) => {
const single = review.single;
setReview(null);
if (review.cloneAll)
if (review.cloneAll && !replaced)
void cloneAllMissing(accepted.map((item) => item.classname));
else if (single && accepted[0]) void importSingle(accepted[0]);
else
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { adminFetch } from "@/lib/admin-fetch";
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types";
export function useSourceAssetChecks(
items: FurniItem[],
sourceId: string | undefined,
local: { items: FurnitureInspection[]; loading: boolean; error: string },
) {
const [attempt, setAttempt] = useState(0);
const needed = items.filter(
(item) =>
!local.items.some(
(entry) =>
entry.classname === item.classname && entry.nitro.exists === true,
),
);
const key =
local.loading || local.error
? ""
: JSON.stringify({ items: needed, sourceId, attempt });
const [state, setState] = useState<{
key: string;
loading: boolean;
error: string;
items: SourceAssetCheck[];
}>({ key: "", loading: true, error: "", items: [] });
useEffect(() => {
if (!key) return;
const abort = new AbortController();
setState({ key, loading: true, error: "", items: [] });
void (async () => {
const { items, sourceId } = JSON.parse(key) as {
items: FurniItem[];
sourceId?: string;
};
const results: SourceAssetCheck[] = [];
try {
for (let offset = 0; offset < items.length; offset += 20) {
const res = await adminFetch("/api/admin/studio/source-assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: items.slice(offset, offset + 20),
sourceId,
}),
signal: abort.signal,
});
const data = await res.json();
if (!res.ok || !Array.isArray(data.items))
throw Error(data.error || "Could not check source assets");
results.push(...data.items);
if (!abort.signal.aborted)
setState({ key, items: [...results], loading: true, error: "" });
}
if (results.length !== items.length)
throw Error("Source check is incomplete. Retry before importing.");
if (!abort.signal.aborted)
setState({ key, items: results, loading: false, error: "" });
} catch (error) {
if (!abort.signal.aborted)
setState({
key,
items: [],
loading: false,
error: (error as Error).message,
});
}
})();
return () => abort.abort();
}, [key]);
return {
...state,
loading: !key || state.key !== key || state.loading,
items: state.key === key ? state.items : [],
retry: () => setAttempt((value) => value + 1),
};
}
+40
View File
@@ -0,0 +1,40 @@
export interface SourceAssetItem {
id: number;
classname: string;
name: string;
description: string;
type: string;
revision: number;
category: string;
}
export type AssetAvailability = "available" | "missing" | "unknown";
export interface SourceAssetCheck {
classname: string;
state: AssetAvailability;
revision: number;
alternatives: SourceAssetItem[];
}
export function relatedFurniture(
item: SourceAssetItem,
candidates: SourceAssetItem[],
) {
const tail = (name: string) =>
name.toLowerCase().split("_").slice(-2).join("_");
const label = (name: string) => (name ?? "").trim().toLowerCase();
return candidates
.filter(
(candidate) =>
candidate.classname !== item.classname &&
candidate.type === item.type &&
((label(item.name) && label(candidate.name) === label(item.name)) ||
(item.classname.split("_").length >= 3 &&
tail(candidate.classname) === tail(item.classname))),
)
.sort(
(a, b) =>
Number(label(b.name) === label(item.name)) -
Number(label(a.name) === label(item.name)) ||
a.classname.localeCompare(b.classname),
)
.slice(0, 5);
}
@@ -178,3 +178,22 @@ it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata
).toContain("2000037258");
expect(result.warnings.join(" ")).toContain("16940");
});
it("does not create or delete SQL rows when a new furniture asset is unavailable", async () => {
vi.clearAllMocks();
mocks.official.mockResolvedValue(null);
mocks.download.mockResolvedValue(false);
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => [] }) }),
}));
mocks.stat.mockRejectedValue(new Error("missing"));
mocks.conflict.mockResolvedValue(null);
const result = await importSingleFurni({
...input,
classname: "missing_new_asset",
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Import not started");
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
+42 -49
View File
@@ -619,45 +619,6 @@ export async function importSingleFurni(params: {
const bestRevision = revision || officialHabbo?.revision || 0;
const bestCategory = category || officialHabbo?.category || "unknown";
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
try {
if (preservingExisting) {
warnings.push(
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
);
} else if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages.
await db.execute(sql`
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
WHERE id = ${spriteId}
`);
} else {
itemId = await reserveFurnitureId(
originalId,
classname,
async (allocatedId) => {
await db.execute(
sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
);
},
);
spriteId = itemId;
if (spriteId !== originalId)
warnings.push(
`Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
);
}
} catch (err) {
return {
ok: false,
warnings,
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
};
}
// ── Derive file-safe names ────────────────────────────────────────
const rev = bestRevision;
const assetTargets = await getFurniAssetWriteTargets();
@@ -828,7 +789,7 @@ export async function importSingleFurni(params: {
.catch(() => null);
const nitroValid = nitroStat && nitroStat.size >= 128;
if (!nitroValid && !conversionResult) {
if (!nitroValid) {
if (nitroStat) {
warnings.push(
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
@@ -839,24 +800,56 @@ export async function importSingleFurni(params: {
/* ignore */
}
}
try {
if (!isUpdate)
await db.execute(
sql`DELETE FROM items_base WHERE id = ${itemId} AND item_name = ${classname}`,
);
} catch {
/* best effort cleanup */
}
return {
ok: false,
warnings,
error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import rolled back: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import not started: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
.filter((warning) => /conversion|download|SWF/.test(warning))
.slice(-3)
.join(". ")}`,
};
}
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
try {
if (preservingExisting) {
warnings.push(
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
);
} else if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages.
await db.execute(sql`
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
WHERE id = ${spriteId}
`);
} else {
itemId = await reserveFurnitureId(
originalId,
classname,
async (allocatedId) => {
await db.execute(
sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
);
},
);
spriteId = itemId;
if (spriteId !== originalId)
warnings.push(
`Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
);
}
} catch (err) {
return {
ok: false,
warnings,
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
};
}
await Promise.all([
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
mirrorFileToDirs(
@@ -0,0 +1,99 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import {
relatedFurniture,
type SourceAssetItem,
} from "@/lib/furni/source-assets";
import { inspectSourceAssets, probeAsset } from "./furniture-source-assets";
const source: SourceAssetItem = {
id: 1,
classname: "h25_monkeybzn_pole",
name: "Jungle flags",
description: "Flags",
type: "flooritem",
revision: 73253,
category: "other",
};
const alternative = {
...source,
id: 2,
classname: "br_c25_monkeybzn_pole",
revision: 73257,
};
afterEach(() => vi.unstubAllGlobals());
describe("source asset preflight", () => {
it("suggests reachable alternatives for unavailable source files", async () => {
const fetchMock = vi.fn(
async (url: string) =>
new Response(null, { status: url.includes("/73257/") ? 200 : 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const result = await inspectSourceAssets(source, [source, alternative]);
expect(result.state).toBe("missing");
expect(result.alternatives).toEqual([alternative]);
});
it("does not describe blocked or unreachable servers as missing files", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 403 })),
);
const result = await inspectSourceAssets(
{ ...source, classname: "blocked_test" },
[alternative],
);
expect(result.state).toBe("unknown");
expect(result.alternatives).toEqual([]);
});
it("accepts an available configured Nitro source", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async (url: string) =>
new Response(null, { status: url.endsWith(".nitro") ? 200 : 404 }),
),
);
const result = await inspectSourceAssets(
{ ...source, classname: "custom_test" },
[],
{
id: "custom",
name: "Custom",
hotel: "",
furnidataUrl: "https://source.example/data.json",
nitroBaseUrl: "https://source.example/furniture",
iconBaseUrl: "",
},
);
expect(result.state).toBe("available");
});
it("bounds suggestions and excludes different furniture types", () => {
expect(
relatedFurniture(source, [source, { ...alternative, type: "wallitem" }]),
).toEqual([]);
expect(
relatedFurniture(
source,
Array.from({ length: 20 }, (_, id) => ({
...alternative,
id,
classname: `f${id}_monkeybzn_pole`,
})),
),
).toHaveLength(5);
});
it("handles unnamed source entries while finding related assets", () => {
expect(
relatedFurniture(source, [
{ ...alternative, name: null as unknown as string },
]),
).toHaveLength(1);
});
it("caches a probe to avoid repeating CDN requests", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await probeAsset("https://example.test/cache.nitro");
await probeAsset("https://example.test/cache.nitro");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,98 @@
import type {
AssetAvailability,
SourceAssetCheck,
SourceAssetItem,
} from "@/lib/furni/source-assets";
import { relatedFurniture } from "@/lib/furni/source-assets";
import type { CloneSource } from "./clone-sources";
import { browserHeaders } from "./import/core/browser-headers";
const cache = new Map<string, { expires: number; state: AssetAvailability }>();
export async function probeAsset(url: string): Promise<AssetAvailability> {
const cached = cache.get(url);
if (cached && cached.expires > Date.now()) return cached.state;
let state: AssetAvailability = "unknown";
try {
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(6000),
headers: browserHeaders(),
});
if (
response.ok &&
!response.headers.get("content-type")?.includes("text/html")
)
state = "available";
else if (response.status === 404 || response.status === 410)
state = "missing";
} catch {
/* A network problem is not proof the asset is absent. */
}
if (cache.size >= 2000) cache.clear();
cache.set(url, { expires: Date.now() + 60000, state });
return state;
}
export function furnitureAssetUrls(
item: SourceAssetItem,
source?: CloneSource | null,
) {
const base = encodeURIComponent(item.classname.split("*")[0]);
const urls: string[] = [];
if (source?.nitroBaseUrl)
urls.push(`${source.nitroBaseUrl.replace(/\/+$/, "")}/${base}.nitro`);
if (source?.sourceSwfBaseUrl) {
const root = source.sourceSwfBaseUrl.replace(/\/+$/, "");
urls.push(
`${root}/hof_furni/${item.revision}/${base}.swf`,
`${root}/hof_furni/${base}.swf`,
);
}
urls.push(
`https://images.habbo.com/dcr/hof_furni/${item.revision}/${base}.swf`,
);
return urls;
}
async function available(
item: SourceAssetItem,
source?: CloneSource | null,
): Promise<AssetAvailability> {
const states = await Promise.all(
furnitureAssetUrls(item, source).map(probeAsset),
);
return states.includes("available")
? "available"
: states.every((state) => state === "missing")
? "missing"
: "unknown";
}
export async function inspectSourceAssets(
item: SourceAssetItem,
official: SourceAssetItem[],
source?: CloneSource | null,
): Promise<SourceAssetCheck> {
const canonical = official.find(
(entry) => entry.classname === item.classname,
);
const effective = {
...item,
revision: item.revision || canonical?.revision || 0,
};
const state = await available(effective, source);
const alternatives: SourceAssetItem[] = [];
if (state === "missing") {
for (const candidate of relatedFurniture(
{ ...effective, name: canonical?.name || item.name },
official,
)) {
if ((await available(candidate)) === "available")
alternatives.push(candidate);
if (alternatives.length === 3) break;
}
}
return {
classname: item.classname,
revision: effective.revision,
state,
alternatives,
};
}