Files
EpicNext-Cms/src/lib/services/furniture-source-assets.ts
T
Simo 88790d1a0d
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s
feat(cms): recover drafts and failed imports with verified UI workflows
2026-09-11 08:58:10 +02:00

111 lines
3.1 KiB
TypeScript

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; 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.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")
) {
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, attempt });
return attempt;
}
export async function probeAsset(url: string): Promise<AssetAvailability> {
return (await probeAssetAttempt(url)).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;
}
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 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,
};
}