This commit is contained in:
Simo committed 2026-09-09 18:29:06 +02:00
commit b3a6531d7b
35 files changed
+398 -1048

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ export function ArticleForm({
action,
defaultValues,
}: {
action: (formData: FormData) => Promise<ArticleSaveResult | void>;
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
edit?: boolean;
defaultValues?: {
title?: string;
+7 -5
View File
@@ -5,11 +5,13 @@ const state = vi.hoisted(() => ({
queried: [] as unknown[],
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
handler(request, {
session: { user: { id: 1, rank: 7 } },
permissions: { isSuperAdmin: false },
}),
withAdmin:
(_: unknown, handler: (...args: unknown[]) => unknown) =>
(request: unknown) =>
handler(request, {
session: { user: { id: 1, rank: 7 } },
permissions: { isSuperAdmin: false },
}),
}));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
vi.mock("@/lib/permissions", async () => ({
+1 -1
View File
@@ -77,7 +77,7 @@ describe("deployment transaction", () => {
expect(result.calls.indexOf("pnpm db:migrate")).toBeLessThan(
result.calls.indexOf("docker stop"),
);
expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan(
expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan(
result.calls.indexOf(
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
),
+4 -3
View File
@@ -25,7 +25,7 @@ describe("CI workflow", () => {
it("check runs lint, typecheck, and test", () => {
expect(workflow).toContain("pnpm biome:lint");
expect(workflow).toContain("pnpm typecheck");
expect(workflow).toContain("pnpm test");
expect(workflow).toContain("pnpm test:coverage");
});
it("check job runs on self-hosted runner", () => {
@@ -61,7 +61,8 @@ describe("CI workflow", () => {
it("smoke-tests the deployed app within the deployment transaction", () => {
expect(workflow).toContain("bash scripts/ci-deploy.sh");
const deploy = readFileSync("scripts/ci-deploy.sh", "utf8");
expect(deploy).toContain("pnpm test:e2e");
expect(deploy).toContain("PLAYWRIGHT_BASE_URL");
expect(deploy).toContain(
"node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health",
);
});
});
+3 -3
View File
@@ -4,11 +4,11 @@ import { expect, it } from "vitest";
const workflow = readFileSync(".gitea/workflows/ci.yaml", "utf8");
const deploy = readFileSync("scripts/ci-deploy.sh", "utf8");
it("uses two test workers and runs smoke checks in the deployment transaction", () => {
expect(workflow).toContain("pnpm test --maxWorkers=2");
expect(workflow).toContain("pnpm test:coverage --maxWorkers=4");
expect(workflow).not.toContain("\n e2e:");
expect(workflow).toContain("bash scripts/ci-deploy.sh");
expect(deploy).toContain(
"PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e",
"node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health",
);
});
it("locks CI and scheduled deployments using the same production lock", () => {
@@ -27,7 +27,7 @@ it("preserves production runtime configuration and recent cache", () => {
expect(deploy).toContain("/app/storage");
expect(deploy).not.toContain("--env-file");
expect(deploy).toContain(
'docker builder prune -af --filter "until=72h" --keep-storage=2g',
'docker builder prune -af --filter "until=72h" --max-used-space=4g',
);
expect(deploy).not.toContain("docker volume prune");
});
+2 -2
View File
@@ -115,12 +115,12 @@ export class ErrorStore {
name.slice(0, 10) < cutoff
)
await unlink(path.join(this.directory, name)).catch(() => {});
const file = path.join(this.directory, record.at.slice(0, 10) + ".jsonl");
const file = path.join(this.directory, `${record.at.slice(0, 10)}.jsonl`);
const size = await stat(file)
.then((s) => s.size)
.catch(() => 0);
if (size >= DAY_LIMIT) throw new Error("Daily error storage limit reached");
await appendFile(file, JSON.stringify(record) + "\n", { mode: 0o600 });
await appendFile(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
}
async resolve(fingerprint: string) {
if (!/^[a-f0-9]{16}$/.test(fingerprint))
+90
View File
@@ -0,0 +1,90 @@
const BYPARR_URL =
process.env.BYPARR_URL ??
process.env.FLARESOLVERR_URL ?? // legacy env fallback
"http://localhost:8191";
const BYPARR_API = `${BYPARR_URL}/v1`;
type ByparrCookie = {
name: string;
value: string;
domain?: string;
path?: string;
};
type ByparrSolution = {
response: string;
status: number;
cookies: ByparrCookie[];
};
type ByparrResult = {
status?: "ok" | "error";
message?: string;
solution?: ByparrSolution;
error?: string;
};
async function byparrRequest(
url: string,
timeout = 30000,
): Promise<ByparrSolution> {
const res = await fetch(BYPARR_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cmd: "request.get",
url,
maxTimeout: timeout,
}),
signal: AbortSignal.timeout(timeout + 5000),
});
if (!res.ok) {
throw new Error(`Byparr request failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as ByparrResult;
if (data.error) {
throw new Error(`Byparr error: ${data.error}`);
}
// Byparr returns HTTP 200 with `status: "error"` for invalid requests
// (FlareSolverr used a top-level `error` field instead).
if (data.status && data.status !== "ok") {
throw new Error(`Byparr error: ${data.message ?? "request failed"}`);
}
if (!data.solution) {
throw new Error("Byparr: no solution in response");
}
return data.solution;
}
export async function fetchWithByparr(
url: string,
timeout = 30000,
): Promise<string> {
const solution = await byparrRequest(url, timeout);
return solution.response;
}
/**
* Solve a Cloudflare challenge for the given URL and return the clearance
* cookies as a `-b "name=value; ..."` argument string suitable for curl.
* Returns null if Byparr is unavailable or no cookies were set.
*/
export async function getByparrCookies(
url: string,
timeout = 30000,
): Promise<string | null> {
try {
const solution = await byparrRequest(url, timeout);
if (!solution.cookies?.length) return null;
return solution.cookies.map((c) => `${c.name}=${c.value}`).join("; ");
} catch {
return null;
}
}
+13 -13
View File
@@ -12,7 +12,7 @@ const {
selectLimit,
fsUnlink,
fsReadFile,
fetchWithFlareSolver,
fetchWithByparr,
curlFetchText,
curlDownload,
} = vi.hoisted(() => ({
@@ -23,7 +23,7 @@ const {
selectLimit: vi.fn<AnyFn>(async () => []),
fsUnlink: vi.fn<AnyFn>(async () => {}),
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
fetchWithFlareSolver: vi.fn<AnyFn>(async () => ""),
fetchWithByparr: vi.fn<AnyFn>(async () => ""),
curlFetchText: vi.fn<AnyFn>(async () => ""),
curlDownload: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
}));
@@ -33,7 +33,7 @@ vi.mock("@/lib/services/import/core/curl-fetch", () => ({
curlDownload,
curlFetchText,
}));
vi.mock("@/lib/services/flare-solver", () => ({ fetchWithFlareSolver }));
vi.mock("@/lib/services/byparr", () => ({ fetchWithByparr }));
vi.mock("@/lib/services/furni-data", () => ({ appendFurniEntry }));
vi.mock("@/lib/services/furni-import", () => ({
ensureDirectories: vi.fn<AnyFn>(async () => {}),
@@ -217,8 +217,8 @@ describe("fetchSourceFurnidata", () => {
const HTML_WRAPPED = `<html><head><meta charset="utf-8"></head><body><pre>${JSON_BODY}</pre></body></html>`;
beforeEach(() => {
fetchWithFlareSolver.mockReset();
fetchWithFlareSolver.mockResolvedValue("");
fetchWithByparr.mockReset();
fetchWithByparr.mockResolvedValue("");
vi.unstubAllGlobals();
});
@@ -235,10 +235,10 @@ describe("fetchSourceFurnidata", () => {
);
const list = await fetchSourceFurnidata("https://a.test/fd.json", 1);
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
expect(fetchWithFlareSolver).not.toHaveBeenCalled();
expect(fetchWithByparr).not.toHaveBeenCalled();
});
it("extracts JSON from an HTML-wrapped furnidata (e.g. Leet via FlareSolverr)", async () => {
it("extracts JSON from an HTML-wrapped furnidata (e.g. Leet via Byparr)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
@@ -249,13 +249,13 @@ describe("fetchSourceFurnidata", () => {
}),
),
);
fetchWithFlareSolver.mockResolvedValue(HTML_WRAPPED);
fetchWithByparr.mockResolvedValue(HTML_WRAPPED);
const list = await fetchSourceFurnidata("https://b.test/fd.json", 1);
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
expect(fetchWithFlareSolver).toHaveBeenCalledWith("https://b.test/fd.json");
expect(fetchWithByparr).toHaveBeenCalledWith("https://b.test/fd.json");
});
it("falls back to FlareSolverr on HTML body even when status is 200", async () => {
it("falls back to Byparr on HTML body even when status is 200", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
@@ -266,7 +266,7 @@ describe("fetchSourceFurnidata", () => {
}),
),
);
fetchWithFlareSolver.mockResolvedValue(HTML_WRAPPED);
fetchWithByparr.mockResolvedValue(HTML_WRAPPED);
const list = await fetchSourceFurnidata("https://c.test/fd.json", 1);
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
});
@@ -284,7 +284,7 @@ describe("fetchSourceFurnidata", () => {
);
const list = await fetchSourceFurnidata("https://g.test/fd.json", 1);
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
expect(fetchWithFlareSolver).not.toHaveBeenCalled();
expect(fetchWithByparr).not.toHaveBeenCalled();
});
it("throws when HTML has no embedded JSON payload", async () => {
@@ -298,7 +298,7 @@ describe("fetchSourceFurnidata", () => {
}),
),
);
fetchWithFlareSolver.mockResolvedValue("<html>still a challenge</html>");
fetchWithByparr.mockResolvedValue("<html>still a challenge</html>");
await expect(
fetchSourceFurnidata("https://d.test/fd.json", 1),
).rejects.toThrow(/Cloudflare challenge/);
+9 -9
View File
@@ -5,9 +5,9 @@ import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { fetchWithByparr } from "@/lib/services/byparr";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import type { CloneSource } from "@/lib/services/clone-sources";
import { fetchWithFlareSolver } from "@/lib/services/flare-solver";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry } from "@/lib/services/furni-data";
import {
@@ -101,7 +101,7 @@ const TTL = 5 * 60 * 1000;
/**
* Some sources serve their furnidata wrapped in an HTML document (e.g. Leet's
* JSON is embedded in a `<pre>` block inside the page, often surfaced by the
* FlareSolverr fallback). Extract the JSON payload before giving up.
* Byparr fallback). Extract the JSON payload before giving up.
*/
function extractJsonFromHtml(body: string): string | null {
const pre = body.match(/<pre[^>]*>([\s\S]*?)<\/pre>/);
@@ -117,20 +117,20 @@ function extractJsonFromHtml(body: string): string | null {
}
/**
* Fetch a source's furnidata via FlareSolverr, falling back to a curl
* Fetch a source's furnidata via Byparr, falling back to a curl
* subprocess. Some CDNs (e.g. Leet.city) block Node's TLS fingerprint while
* the same request succeeds from curl — so curl is a useful second fallback
* when FlareSolverr is unavailable.
* when Byparr is unavailable.
*/
async function fetchWithFallback(url: string): Promise<string> {
try {
return await fetchWithFlareSolver(url);
return await fetchWithByparr(url);
} catch {
try {
return await curlFetchText(url);
} catch (err) {
throw new Error(
`FlareSolverr + curl fallback failed for ${url}: ${(err as Error).message}`,
`Byparr + curl fallback failed for ${url}: ${(err as Error).message}`,
);
}
}
@@ -150,8 +150,8 @@ export async function fetchSourceFurnidata(
});
if (res.ok) {
// Some sources (e.g. GitHub raw) serve JSON with a text/plain
// content-type — only route to FlareSolverr when the body is
// actually HTML, otherwise parse the JSON directly.
// content-type — only route to Byparr when the body is actually
// HTML, otherwise parse the JSON directly.
const text = await res.text();
if (
text.trimStart().startsWith("{") ||
@@ -171,7 +171,7 @@ export async function fetchSourceFurnidata(
const extracted = extractJsonFromHtml(body);
if (!extracted) {
throw new Error(
`Source ${url} is behind a Cloudflare challenge that could not be bypassed. Start FlareSolverr and check its logs.`,
`Source ${url} is behind a Cloudflare challenge that could not be bypassed. Start Byparr and check its logs.`,
);
}
body = extracted;
+9 -1
View File
@@ -142,12 +142,20 @@ export const DEFAULT_SOURCES: CloneSource[] = [
{
id: "default-virtualcity",
name: "VirtualCity",
furnidataUrl: "https://virtualc.nl/gamedata/furnidata_json/1",
furnidataUrl: "https://virtualc.nl/gamedata/FurnitureData.json",
nitroBaseUrl: "https://virtualc.nl/nitro/bundled/furniture",
iconBaseUrl: "https://virtualc.nl/nitro/icons",
hotel: "com",
sourceSwfBaseUrl: "https://virtualc.nl/dcr",
},
{
id: "default-sodastudios",
name: "SodaStudios",
furnidataUrl: "https://assets.sodaho.tel/gamedata/FurnitureData.json",
nitroBaseUrl: "https://assets.sodaho.tel/bundled/furniture",
iconBaseUrl: "https://assets.sodaho.tel/dcr/hof_furni/icons",
hotel: "com",
},
{
id: "default-habboon",
name: "Habboon",
-82
View File
@@ -1,82 +0,0 @@
const FLARESOLVERR_URL =
process.env.FLARESOLVERR_URL ?? "http://localhost:8191";
const FLARESOLVERR_API = `${FLARESOLVERR_URL}/v1`;
type FlareCookie = {
name: string;
value: string;
domain: string;
path: string;
};
type FlareSolution = {
response: string;
status: number;
cookies: FlareCookie[];
};
type FlareResult = {
solution?: FlareSolution;
error?: string;
};
async function flareRequest(
url: string,
timeout = 30000,
): Promise<FlareSolution> {
const res = await fetch(FLARESOLVERR_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cmd: "request.get",
url,
maxTimeout: timeout,
}),
signal: AbortSignal.timeout(timeout + 5000),
});
if (!res.ok) {
throw new Error(
`FlareSolverr request failed: ${res.status} ${res.statusText}`,
);
}
const data = (await res.json()) as FlareResult;
if (data.error) {
throw new Error(`FlareSolverr error: ${data.error}`);
}
if (!data.solution) {
throw new Error("FlareSolverr: no solution in response");
}
return data.solution;
}
export async function fetchWithFlareSolver(
url: string,
timeout = 30000,
): Promise<string> {
const solution = await flareRequest(url, timeout);
return solution.response;
}
/**
* Solve a Cloudflare challenge for the given URL and return the clearance
* cookies as a `-b "name=value; ..."` argument string suitable for curl.
* Returns null if FlareSolverr is unavailable or no cookies were set.
*/
export async function getFlareSolverrCookies(
url: string,
timeout = 30000,
): Promise<string | null> {
try {
const solution = await flareRequest(url, timeout);
if (!solution.cookies?.length) return null;
return solution.cookies.map((c) => `${c.name}=${c.value}`).join("; ");
} catch {
return null;
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { promises as fs } from "node:fs";
import { getFlareSolverrCookies } from "@/lib/services/flare-solver";
import { getByparrCookies } from "@/lib/services/byparr";
import { parseNitroBundle } from "../../swf/nitro-builder";
import { browserHeaders } from "./browser-headers";
import { curlDownload } from "./curl-fetch";
@@ -62,7 +62,7 @@ export async function downloadFile(
const curlFallback = async (): Promise<boolean> => {
let curlRes = await curlDownload(url, destPath);
if (!curlRes.ok) {
const cookieHeader = await getFlareSolverrCookies(url);
const cookieHeader = await getByparrCookies(url);
curlRes = await curlDownload(
url,
destPath,
@@ -108,7 +108,7 @@ export async function downloadFile(
res.status === 404 || res.status === 410 || res.status === 403;
if (deterministic || attempt === maxRetries) {
// HTTP 403 is often a TLS-fingerprint / Cloudflare challenge
// block (e.g. Leet.city) — retry via curl, with FlareSolverr
// block (e.g. Leet.city) — retry via curl, with Byparr
// clearance cookies if the plain curl is also challenged.
if (res.status === 403 && (await curlFallback())) {
const stat = await fs.stat(destPath);
@@ -148,7 +148,7 @@ export async function downloadFile(
return { ok: true, size: buffer.length };
} catch (err) {
if (attempt === maxRetries || stage === "write") {
// TLS fingerprint aborts land here too — try curl/FlareSolverr
// TLS fingerprint aborts land here too — try curl/Byparr
// before reporting the network failure.
if (stage === "download" && (await curlFallback())) {
const stat = await fs.stat(destPath);
+8 -2
View File
@@ -13,7 +13,6 @@ flock() { echo "lock" >> "$TEST_DIR/calls"; [ "$SCENARIO" != lock-failure ]; }
pnpm() {
echo "pnpm $*" >> "$TEST_DIR/calls"
if [ "$1" = db:migrate ] && [ "$SCENARIO" = migration-failure ]; then return 1; fi
if [ "$1" = test:e2e ] && [ "$SCENARIO" = smoke-failure ]; then return 1; fi
return 0
}
curl() {
@@ -43,5 +42,12 @@ docker() {
}
export -f git flock pnpm curl sleep docker
node() { echo "node $*" >> "$TEST_DIR/calls"; [ "$SCENARIO" != release-failure ]; }
node() {
echo "node $*" >> "$TEST_DIR/calls"
case "$SCENARIO" in
release-failure) return 1 ;;
smoke-failure) case "$*" in *verify-deployed-release.mjs*) return 1 ;; esac ;;
esac
return 0
}
export -f node