feat(cms): recover drafts and failed imports with verified UI workflows
This commit is contained in:
1 parent
410a1e466c
commit
88790d1a0d
84 files changed
+3952
-288
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DRAFT_TTL,
|
||||
decodeRecovery,
|
||||
encodeRecovery,
|
||||
recoveryKey,
|
||||
} from "./form-recovery";
|
||||
|
||||
const article = {
|
||||
title: "Draft",
|
||||
slug: "draft",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "<p>Unsaved</p>",
|
||||
status: "draft",
|
||||
publishAt: "",
|
||||
};
|
||||
describe("form recovery storage contract", () => {
|
||||
it("isolates account, form and record, including separator characters", () => {
|
||||
expect(
|
||||
new Set([
|
||||
recoveryKey("1", "article", "new"),
|
||||
recoveryKey("2", "article", "new"),
|
||||
recoveryKey("1", "event", "new"),
|
||||
recoveryKey("1", "article", "2"),
|
||||
recoveryKey("1:article", "article", "new"),
|
||||
]).size,
|
||||
).toBe(5);
|
||||
});
|
||||
it("round trips unfinished content and only retains allowlisted fields", () => {
|
||||
const raw = encodeRecovery(
|
||||
"article",
|
||||
{
|
||||
...article,
|
||||
password: "private",
|
||||
accessToken: "secret",
|
||||
baseToken: "version",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
expect(decodeRecovery("article", raw, 1001).payload).toEqual(article);
|
||||
expect(raw).not.toMatch(/private|secret|baseToken/);
|
||||
});
|
||||
it.each([
|
||||
"{",
|
||||
JSON.stringify({ version: 2, savedAt: 1000, payload: article }),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: 1000,
|
||||
payload: { ...article, title: 42 },
|
||||
}),
|
||||
])("rejects malformed or unsupported records", (raw) => {
|
||||
expect(() => decodeRecovery("article", raw, 1001)).toThrow();
|
||||
});
|
||||
it("expires old records and rejects future timestamps", () => {
|
||||
expect(() =>
|
||||
decodeRecovery(
|
||||
"article",
|
||||
encodeRecovery("article", article, 1000),
|
||||
1001 + DRAFT_TTL,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
decodeRecovery(
|
||||
"article",
|
||||
encodeRecovery("article", article, 100000),
|
||||
1000,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
it("limits content size", () => {
|
||||
expect(() =>
|
||||
encodeRecovery("article", { ...article, fullStory: "x".repeat(500001) }),
|
||||
).toThrow();
|
||||
});
|
||||
it("preserves optional event dates without coercing absent dates", () => {
|
||||
const event = {
|
||||
title: "",
|
||||
description: "",
|
||||
typeId: 1,
|
||||
status: "draft",
|
||||
isRecurring: 0,
|
||||
startsAt: "2030-01-01T12:00:00.000Z",
|
||||
endsAt: null,
|
||||
roomId: "",
|
||||
};
|
||||
expect(
|
||||
decodeRecovery("event", encodeRecovery("event", event)).payload,
|
||||
).toEqual(event);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const text = z.string().max(500_000);
|
||||
export const articleRecoverySchema = z.object({
|
||||
title: z.string().max(255),
|
||||
slug: z.string().max(255),
|
||||
image: z.string().max(255),
|
||||
shortStory: z.string().max(255),
|
||||
fullStory: text,
|
||||
status: z.enum(["draft", "scheduled", "published"]),
|
||||
publishAt: z.string().max(255).default(""),
|
||||
});
|
||||
const optionalNumber = z
|
||||
.union([z.string().max(30), z.number().finite(), z.null()])
|
||||
.optional();
|
||||
const date = z.union([z.string().datetime(), z.null()]).optional();
|
||||
export const eventRecoverySchema = z.object({
|
||||
title: z.string().max(255),
|
||||
description: text,
|
||||
image: z.string().max(500).nullable().optional(),
|
||||
typeId: z.number().int().nonnegative(),
|
||||
status: z.enum(["draft", "published", "cancelled", "completed"]),
|
||||
isRecurring: z.number().int().min(0).max(1),
|
||||
recurrenceRule: z.string().max(255).nullable().optional(),
|
||||
maxPlayers: optionalNumber,
|
||||
roomId: optionalNumber,
|
||||
startsAt: date,
|
||||
endsAt: date,
|
||||
});
|
||||
export type RecoveryKind = "article" | "event";
|
||||
export function recoveryKey(
|
||||
userId: string,
|
||||
kind: RecoveryKind,
|
||||
record: string,
|
||||
) {
|
||||
return `cms:draft:v1:${encodeURIComponent(userId)}:${kind}:${encodeURIComponent(record)}`;
|
||||
}
|
||||
export function validateRecovery(
|
||||
kind: RecoveryKind,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
return (
|
||||
kind === "article" ? articleRecoverySchema : eventRecoverySchema
|
||||
).parse(value);
|
||||
}
|
||||
export function encodeRecovery(
|
||||
kind: RecoveryKind,
|
||||
value: unknown,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: now,
|
||||
payload: validateRecovery(kind, value),
|
||||
});
|
||||
if (raw.length > 600_000) throw new Error("draftTooLarge");
|
||||
return raw;
|
||||
}
|
||||
export function decodeRecovery(
|
||||
kind: RecoveryKind,
|
||||
raw: string,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (raw.length > 600_000) throw new Error("draftTooLarge");
|
||||
const envelope = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
savedAt: z.number().int(),
|
||||
payload: z.unknown(),
|
||||
})
|
||||
.parse(JSON.parse(raw));
|
||||
if (envelope.savedAt > now + 60_000 || now - envelope.savedAt > DRAFT_TTL)
|
||||
throw new Error("draftExpired");
|
||||
return {
|
||||
savedAt: envelope.savedAt,
|
||||
payload: validateRecovery(kind, envelope.payload),
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,15 @@ export interface SourceAssetItem {
|
||||
category: string;
|
||||
}
|
||||
export type AssetAvailability = "available" | "missing" | "unknown";
|
||||
export interface SourceAssetAttempt {
|
||||
url: string;
|
||||
file: string;
|
||||
state: AssetAvailability;
|
||||
reason: "available" | "http" | "network" | "timeout" | "invalidResponse";
|
||||
status?: number;
|
||||
}
|
||||
export interface SourceAssetCheck {
|
||||
attempts?: SourceAssetAttempt[];
|
||||
classname: string;
|
||||
state: AssetAvailability;
|
||||
revision: number;
|
||||
|
||||
@@ -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 ────────────
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user