diff --git a/src/lib/services/catalog-audit.ts b/src/lib/services/catalog-audit.ts index 99e2a751..8707823b 100644 --- a/src/lib/services/catalog-audit.ts +++ b/src/lib/services/catalog-audit.ts @@ -602,6 +602,7 @@ export async function runCatalogAudit( const sources = await listSources(); for (const source of sources) { + if (!source.furnidataUrl) continue; try { const entries = await fetchSourceFurnidata(source.furnidataUrl); for (const entry of entries) { diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index b5aa73eb..c5fce36b 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -13,6 +13,7 @@ import { ensureDirectories, getOrCreateCategoryPage, } from "@/lib/services/furni-import"; +import { browserHeaders } from "@/lib/services/import/core/browser-headers"; import { downloadFile } from "@/lib/services/import/core/download"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; @@ -87,7 +88,10 @@ export async function fetchSourceFurnidata( ): Promise { const hit = cache.get(url); if (hit && now && now - hit.ts < TTL) return hit.list; - const res = await fetch(url, { signal: AbortSignal.timeout(30000) }); + const res = await fetch(url, { + signal: AbortSignal.timeout(30000), + headers: browserHeaders(), + }); if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`); let list: SourceFurni[]; try { diff --git a/src/lib/services/clone-sources.ts b/src/lib/services/clone-sources.ts index b37be36b..d07338ed 100644 --- a/src/lib/services/clone-sources.ts +++ b/src/lib/services/clone-sources.ts @@ -11,47 +11,50 @@ export interface CloneSource { const KEY = "clone_sources"; -export const DEFAULT_ICON_SOURCES: CloneSource[] = [ +/** + * Verified working sources (checked Aug 2026). Used as the default list and + * as the fallback when the `clone_sources` site setting is empty. + */ +export const DEFAULT_SOURCES: CloneSource[] = [ { - id: "default-habboassets", - name: "HabboAssets", - furnidataUrl: "", + id: "default-habbo", + name: "Habbo", + furnidataUrl: + "https://raw.githubusercontent.com/Izzxt/habbo-resource/master/gamedata/furnidata.json", nitroBaseUrl: "", - iconBaseUrl: "https://www.habboassets.com/assets/furniture", + iconBaseUrl: "", }, - { - id: "default-hubbly", - name: "Hubbly", - furnidataUrl: "", - nitroBaseUrl: "", - iconBaseUrl: "https://hubbly.pw/swf/dcr/hof_furni/icons", - }, - { - id: "default-leet", - name: "Leet", - furnidataUrl: "", - nitroBaseUrl: "", - iconBaseUrl: "https://images.leet.city/library/hof_furni/icons", - }, -]; - -export const DEFAULT_NITRO_SOURCES: CloneSource[] = [ { id: "default-wibbo", name: "Wibbo", - furnidataUrl: "", + furnidataUrl: "https://assets.wibbo.org/gamedata/FurnitureData.json", nitroBaseUrl: "https://assets.wibbo.org/bundled/furniture", + iconBaseUrl: "https://assets.wibbo.org/icons", + }, + { + id: "default-hubba", + name: "Hubba.cc", + furnidataUrl: "https://nitro.hubba.cc/gamedata/FurnitureData.json", + nitroBaseUrl: "https://nitro.hubba.cc/bundled/furniture", iconBaseUrl: "", }, ]; +export const DEFAULT_ICON_SOURCES: CloneSource[] = DEFAULT_SOURCES.filter( + (s) => s.iconBaseUrl, +); + +export const DEFAULT_NITRO_SOURCES: CloneSource[] = DEFAULT_SOURCES.filter( + (s) => s.nitroBaseUrl, +); + export async function listSources(): Promise { const raw = (await siteSettings.get(KEY, "[]")) ?? "[]"; try { const arr = JSON.parse(raw); - return Array.isArray(arr) ? arr : []; + return Array.isArray(arr) && arr.length > 0 ? arr : DEFAULT_SOURCES; } catch { - return []; + return DEFAULT_SOURCES; } } diff --git a/src/lib/services/habbo-furnidata-cache.test.ts b/src/lib/services/habbo-furnidata-cache.test.ts index fe6e254d..56d9ff25 100644 --- a/src/lib/services/habbo-furnidata-cache.test.ts +++ b/src/lib/services/habbo-furnidata-cache.test.ts @@ -72,7 +72,11 @@ describe("official Habbo furnidata cache", () => { expect(fetchMock).toHaveBeenNthCalledWith( 2, "https://www.habbo.nl/gamedata/furnidata_json/1", - expect.objectContaining({ headers: { Accept: "application/json" } }), + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: expect.stringContaining("application/json"), + }), + }), ); }); }); diff --git a/src/lib/services/habbo-furnidata-cache.ts b/src/lib/services/habbo-furnidata-cache.ts index 065b0ed5..b4e5f11c 100644 --- a/src/lib/services/habbo-furnidata-cache.ts +++ b/src/lib/services/habbo-furnidata-cache.ts @@ -11,6 +11,7 @@ import { } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; +import { browserHeaders } from "@/lib/services/import/core/browser-headers"; import type { OfficialHabboFurniEntry } from "@/types/furni"; const CACHE_TTL = 30 * 60 * 1000; // 30 minutes @@ -42,7 +43,7 @@ export async function getOfficialHabboFurnidata(): Promise< console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`); const res = await fetch(url, { signal: AbortSignal.timeout(20000), - headers: { Accept: "application/json" }, + headers: browserHeaders(), }); if (!res.ok) { diff --git a/src/lib/services/import/core/browser-headers.ts b/src/lib/services/import/core/browser-headers.ts new file mode 100644 index 00000000..1a1013b2 --- /dev/null +++ b/src/lib/services/import/core/browser-headers.ts @@ -0,0 +1,49 @@ +export type BrowserFetchDest = + | "document" + | "image" + | "script" + | "style" + | "empty"; + +const CHROME_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"; + +const SEC_CH_UA = + '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"'; + +/** + * Realistic browser request headers so CDNs (Cloudflare, etc.) treat the + * server-side fetches as a normal browser instead of a bot/curl and block us. + */ +export function browserHeaders( + dest: BrowserFetchDest = "empty", + extra: Record = {}, +): Record { + const accept = + dest === "document" + ? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" + : dest === "image" + ? "image/avif,image/webp,image/apng,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5" + : dest === "script" + ? "*/*" + : "application/json,text/plain,*/*;q=0.9"; + + const headers: Record = { + "User-Agent": CHROME_UA, + "sec-ch-ua": SEC_CH_UA, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + Accept: accept, + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": + dest === "document" ? "navigate" : dest === "image" ? "no-cors" : "cors", + "Sec-Fetch-Dest": dest, + "Cache-Control": "no-cache", + Pragma: "no-cache", + }; + + if (dest === "document") headers["Upgrade-Insecure-Requests"] = "1"; + + return { ...headers, ...extra }; +} diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts index 38573968..e6987b28 100644 --- a/src/lib/services/import/core/download.ts +++ b/src/lib/services/import/core/download.ts @@ -1,5 +1,7 @@ import { promises as fs } from "node:fs"; +import { browserHeaders } from "./browser-headers"; + export function validateSwfBytes(buffer: Buffer): boolean { if (buffer.length < 8) return false; const sig = buffer.toString("ascii", 0, 3); @@ -31,11 +33,9 @@ export async function downloadFile( } const res = await fetch(url, { signal: AbortSignal.timeout(15000), - headers: { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36", + headers: browserHeaders("image", { Accept: "image/png,image/*,*/*;q=0.8", - }, + }), }); if (!res.ok) { const deterministic = diff --git a/src/lib/services/import/core/gordon.ts b/src/lib/services/import/core/gordon.ts index 28d44ebb..834a304c 100644 --- a/src/lib/services/import/core/gordon.ts +++ b/src/lib/services/import/core/gordon.ts @@ -1,3 +1,5 @@ +import { browserHeaders } from "./browser-headers"; + const EXTERNAL_VARIABLES_URL = "https://www.habbo.com/gamedata/external_variables/1"; const CACHE_TTL = 30 * 60 * 1000; @@ -14,6 +16,7 @@ export async function resolveGordonBuildUrl(): Promise { if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url; const res = await fetch(EXTERNAL_VARIABLES_URL, { signal: AbortSignal.timeout(20000), + headers: browserHeaders("document"), }); if (!res.ok) throw new Error(`external_variables fetch failed: ${res.status}`);