feat: browser headers on audit fetches + verified clone furnidata sources
CI / check (push) Successful in 32s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m18s

This commit is contained in:
openhands committed 2026-08-03 19:00:45 +02:00
1 parent 080dc34e23
commit 2fba923a3a
8 files changed
+97 -32

No files matched your search

+1
View File
@@ -602,6 +602,7 @@ export async function runCatalogAudit(
const sources = await listSources(); const sources = await listSources();
for (const source of sources) { for (const source of sources) {
if (!source.furnidataUrl) continue;
try { try {
const entries = await fetchSourceFurnidata(source.furnidataUrl); const entries = await fetchSourceFurnidata(source.furnidataUrl);
for (const entry of entries) { for (const entry of entries) {
+5 -1
View File
@@ -13,6 +13,7 @@ import {
ensureDirectories, ensureDirectories,
getOrCreateCategoryPage, getOrCreateCategoryPage,
} from "@/lib/services/furni-import"; } from "@/lib/services/furni-import";
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
@@ -87,7 +88,10 @@ export async function fetchSourceFurnidata(
): Promise<SourceFurni[]> { ): Promise<SourceFurni[]> {
const hit = cache.get(url); const hit = cache.get(url);
if (hit && now && now - hit.ts < TTL) return hit.list; 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}`); if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
let list: SourceFurni[]; let list: SourceFurni[];
try { try {
+28 -25
View File
@@ -11,47 +11,50 @@ export interface CloneSource {
const KEY = "clone_sources"; 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", id: "default-habbo",
name: "HabboAssets", name: "Habbo",
furnidataUrl: "", furnidataUrl:
"https://raw.githubusercontent.com/Izzxt/habbo-resource/master/gamedata/furnidata.json",
nitroBaseUrl: "", 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", id: "default-wibbo",
name: "Wibbo", name: "Wibbo",
furnidataUrl: "", furnidataUrl: "https://assets.wibbo.org/gamedata/FurnitureData.json",
nitroBaseUrl: "https://assets.wibbo.org/bundled/furniture", 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: "", 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<CloneSource[]> { export async function listSources(): Promise<CloneSource[]> {
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]"; const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
try { try {
const arr = JSON.parse(raw); const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : []; return Array.isArray(arr) && arr.length > 0 ? arr : DEFAULT_SOURCES;
} catch { } catch {
return []; return DEFAULT_SOURCES;
} }
} }
@@ -72,7 +72,11 @@ describe("official Habbo furnidata cache", () => {
expect(fetchMock).toHaveBeenNthCalledWith( expect(fetchMock).toHaveBeenNthCalledWith(
2, 2,
"https://www.habbo.nl/gamedata/furnidata_json/1", "https://www.habbo.nl/gamedata/furnidata_json/1",
expect.objectContaining({ headers: { Accept: "application/json" } }), expect.objectContaining({
headers: expect.objectContaining({
Accept: expect.stringContaining("application/json"),
}),
}),
); );
}); });
}); });
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/habbo-gamedata-hotel"; } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
import type { OfficialHabboFurniEntry } from "@/types/furni"; import type { OfficialHabboFurniEntry } from "@/types/furni";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes 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}...`); console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
const res = await fetch(url, { const res = await fetch(url, {
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20000),
headers: { Accept: "application/json" }, headers: browserHeaders(),
}); });
if (!res.ok) { if (!res.ok) {
@@ -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<string, string> = {},
): Record<string, string> {
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<string, string> = {
"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 };
}
+4 -4
View File
@@ -1,5 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { browserHeaders } from "./browser-headers";
export function validateSwfBytes(buffer: Buffer): boolean { export function validateSwfBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false; if (buffer.length < 8) return false;
const sig = buffer.toString("ascii", 0, 3); const sig = buffer.toString("ascii", 0, 3);
@@ -31,11 +33,9 @@ export async function downloadFile(
} }
const res = await fetch(url, { const res = await fetch(url, {
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
headers: { headers: browserHeaders("image", {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
Accept: "image/png,image/*,*/*;q=0.8", Accept: "image/png,image/*,*/*;q=0.8",
}, }),
}); });
if (!res.ok) { if (!res.ok) {
const deterministic = const deterministic =
+3
View File
@@ -1,3 +1,5 @@
import { browserHeaders } from "./browser-headers";
const EXTERNAL_VARIABLES_URL = const EXTERNAL_VARIABLES_URL =
"https://www.habbo.com/gamedata/external_variables/1"; "https://www.habbo.com/gamedata/external_variables/1";
const CACHE_TTL = 30 * 60 * 1000; const CACHE_TTL = 30 * 60 * 1000;
@@ -14,6 +16,7 @@ export async function resolveGordonBuildUrl(): Promise<string> {
if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url; if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url;
const res = await fetch(EXTERNAL_VARIABLES_URL, { const res = await fetch(EXTERNAL_VARIABLES_URL, {
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20000),
headers: browserHeaders("document"),
}); });
if (!res.ok) if (!res.ok)
throw new Error(`external_variables fetch failed: ${res.status}`); throw new Error(`external_variables fetch failed: ${res.status}`);