feat(cms): recover drafts and failed imports with verified UI workflows
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+7
View File
@@ -20,6 +20,7 @@ import {
getFurnitureDataWritePaths,
removeFurniEntry,
} from "@/lib/services/furni-data";
import { diagnosticAssetUrl } from "@/lib/services/furniture-source-assets";
import {
lookupOfficialHabboFurni,
type OfficialHabboFurniEntry,
@@ -707,6 +708,7 @@ export async function importSingleFurni(params: {
? Promise.resolve(true)
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
iconFailure = detail;
warnings.push(`Icon download failed: ${detail}`);
}),
params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
? Promise.resolve(false)
@@ -714,6 +716,7 @@ export async function importSingleFurni(params: {
? Promise.resolve(true)
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
swfFailure = detail;
warnings.push(`SWF download failed: ${detail}`);
}),
]);
let iconOk = iconOkResult;
@@ -729,6 +732,10 @@ export async function importSingleFurni(params: {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true;
else
warnings.push(
`Nitro download failed: ${diagnosticAssetUrl(nitroUrl)}: ${dl.error ?? "unknown cause"}`,
);
}
// ── Icon fallback: extract from SWF if download failed ────────────
+41
View File
@@ -201,3 +201,44 @@ it("paginates timestamp ties without duplicates and excludes other owners", asyn
);
expect(first.jobs.length + second.jobs.length).toBe(3);
});
it("attaches recovery files only to safe failed items and preserves retry history", async () => {
const s = await store();
const original: ImportJob = {
...job(),
state: "completed",
items: [
{
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "failed",
},
{
id: 2,
classname: "lamp",
name: "Lamp",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "done",
},
],
};
await s.create(original);
await expect(s.retry(original.id, 4, { lamp: randomUUID() })).rejects.toThrow(
"not eligible",
);
const attachmentId = randomUUID();
const child = await s.retry(original.id, 4, { chair: attachmentId });
expect(child?.items).toHaveLength(1);
expect(child?.items[0].attachmentId).toBe(attachmentId);
expect((await s.read(original.id)).items[0].attachmentId).toBeUndefined();
await expect(
s.retry(original.id, 4, { chair: randomUUID() }),
).rejects.toThrow("latest retry");
});
+32 -2
View File
@@ -74,7 +74,11 @@ export class ImportJobStore {
});
return this.read(id);
}
async retry(id: string, userId: number): Promise<ImportJob | null> {
async retry(
id: string,
userId: number,
attachments: Record<string, string> = {},
): Promise<ImportJob | null> {
const original = await this.read(id).catch((error) => {
if (error.code === "ENOENT") return null;
throw error;
@@ -82,11 +86,23 @@ export class ImportJobStore {
if (!original || original.userId !== userId) return null;
const items = retryableJobItems(original);
if (!items.length) return null;
const entries = Object.entries(attachments);
if (
entries.length &&
(original.syncKind ||
entries.some(
([classname]) =>
!items.some(
(item) => item.classname === classname && item.state === "failed",
),
))
)
throw Error("Furniture is not eligible for attachment recovery");
// A parent produces one durable child, including concurrent and uncertain responses.
const hex = createHash("sha256").update(`retry:${id}`).digest("hex");
const retryId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
const now = new Date().toISOString();
return this.create({
const child = await this.create({
...original,
id: retryId,
retryOf: id,
@@ -97,10 +113,24 @@ export class ImportJobStore {
items: items.map(
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
...item,
...(Object.hasOwn(attachments, item.classname)
? { attachmentId: attachments[item.classname] }
: {}),
state: "pending",
}),
),
});
if (
entries.some(
([classname, attachmentId]) =>
child.items.find((item) => item.classname === classname)
?.attachmentId !== attachmentId,
)
)
throw Error(
"Recovery already started. Use the latest retry in the history.",
);
return child;
}
async page(options: { userId: number; limit: number; before?: string }) {
const limit = Math.min(30, Math.max(1, options.limit));
@@ -74,3 +74,54 @@ describe("source asset preflight", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
const item = {
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 123,
category: "other",
};
it("reports every missing filename and HTTP reason without credentials", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
);
const result = await inspectSourceAssets(item, [], {
nitroBaseUrl: "https://user:[email protected]/private?token=secret",
} as never);
expect(result.state).toBe("missing");
expect(result.attempts).toHaveLength(2);
expect(result.attempts?.[1]).toMatchObject({
file: "chair.swf",
reason: "http",
status: 404,
});
expect(JSON.stringify(result)).not.toContain("secret");
});
it("keeps network and blocked sources unknown rather than calling them missing", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("https://secret.test/?token=secret")),
);
const result = await inspectSourceAssets({ ...item, revision: 124 }, []);
expect(result.state).toBe("unknown");
expect(result.attempts?.[0].reason).toBe("network");
expect(JSON.stringify(result)).not.toContain("secret");
});
it("distinguishes HTML responses from valid assets", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(null, { headers: { "content-type": "text/html" } }),
),
);
const result = await inspectSourceAssets({ ...item, revision: 125 }, []);
expect(result.attempts?.[0].reason).toBe("invalidResponse");
expect(result.state).toBe("unknown");
});
+49 -26
View File
@@ -1,35 +1,63 @@
import type {
AssetAvailability,
SourceAssetAttempt,
SourceAssetCheck,
SourceAssetItem,
} 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 cache = new Map<
string,
{ expires: number; attempt: SourceAssetAttempt }
>();
/** Only expose the address path; credentials, signed queries and fragments stay server-side. */
export function diagnosticAssetUrl(raw: string): string {
const url = new URL(raw);
return `${url.protocol}//${url.host}${url.pathname}`;
}
async function probeAssetAttempt(url: string): Promise<SourceAssetAttempt> {
const cached = cache.get(url);
if (cached && cached.expires > Date.now()) return cached.state;
let state: AssetAvailability = "unknown";
if (cached && cached.expires > Date.now()) return cached.attempt;
const safeUrl = diagnosticAssetUrl(url);
const attempt: SourceAssetAttempt = {
url: safeUrl,
file: new URL(url).pathname.split("/").pop() || "",
state: "unknown",
reason: "network",
};
try {
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(6000),
headers: browserHeaders(),
});
attempt.status = response.status;
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. */
) {
attempt.state = "available";
attempt.reason = "available";
} else if (response.ok) attempt.reason = "invalidResponse";
else {
attempt.reason = "http";
if (response.status === 404 || response.status === 410)
attempt.state = "missing";
}
} catch (error) {
attempt.reason =
error instanceof Error &&
(error.name === "TimeoutError" || error.name === "AbortError")
? "timeout"
: "network";
}
if (cache.size >= 2000) cache.clear();
cache.set(url, { expires: Date.now() + 60000, state });
return state;
cache.set(url, { expires: Date.now() + 60000, attempt });
return attempt;
}
export async function probeAsset(url: string): Promise<AssetAvailability> {
return (await probeAssetAttempt(url)).state;
}
export function furnitureAssetUrls(
item: SourceAssetItem,
@@ -51,19 +79,6 @@ export function furnitureAssetUrls(
);
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[],
@@ -76,12 +91,20 @@ export async function inspectSourceAssets(
...item,
revision: item.revision || canonical?.revision || 0,
};
const state = await available(effective, source);
const attempts = await Promise.all(
furnitureAssetUrls(effective, source).map(probeAssetAttempt),
);
const state = attempts.some((attempt) => attempt.state === "available")
? "available"
: attempts.every((attempt) => attempt.state === "missing")
? "missing"
: "unknown";
return {
classname: item.classname,
revision: effective.revision,
state,
alternatives: [],
attempts,
};
}