Update Dockerfile and Compose configuration
This commit is contained in:
1 parent
35f66d879f
commit
d1003bb89b
9 files changed
+2046
-13
No files matched your search
Whitespace-only changes.
+20
-2
@@ -47,6 +47,13 @@ RUN --mount=type=cache,id=epicnext-pnpm,target=/pnpm/store,sharing=locked \
|
|||||||
# Source changes invalidate compilation, but keep the installed dependencies.
|
# Source changes invalidate compilation, but keep the installed dependencies.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# --- Automatische GitLab Pull ---
|
||||||
|
# Vraagt de meest recente commit op van gitlab.epicnabbo.nl (project: remco/EpicNext-Cms).
|
||||||
|
# Bij een nieuwe commit breekt Docker hier automatisch de cache en haalt hij direct de nieuwste code binnen.
|
||||||
|
# (remco%2FEpicNext-Cms is de URL-encoded variant van remco/EpicNext-Cms)
|
||||||
|
ADD https://gitlab.epicnabbo.nl/api/v4/projects/remco%2FEpicNext-Cms/repository/branches/main /tmp/commit.json
|
||||||
|
RUN git pull origin main
|
||||||
|
|
||||||
# Build the production bundle.
|
# Build the production bundle.
|
||||||
# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no
|
# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no
|
||||||
# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time
|
# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time
|
||||||
@@ -69,7 +76,18 @@ RUN --mount=type=cache,id=epicnext-next,target=/app/.next/cache,sharing=locked \
|
|||||||
FROM node:26.8.1-bookworm-slim AS runner
|
FROM node:26.8.1-bookworm-slim AS runner
|
||||||
|
|
||||||
# Catalog Studio publishes to self-hosted Git repositories over HTTPS.
|
# Catalog Studio publishes to self-hosted Git repositories over HTTPS.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
# curl is the fallback downloader for clone sources that block Node's TLS
|
||||||
|
# fingerprint (e.g. Leet.city) — see import/core/curl-fetch.ts.
|
||||||
|
# curl-impersonate ships a curl built with Chrome's exact TLS fingerprint
|
||||||
|
# (statically-linked BoringSSL; only libz/libc required). Several sources
|
||||||
|
# enable Cloudflare `cf-mitigated: challenge` against the distro curl's JA3,
|
||||||
|
# so prefer the impersonated binary at runtime (curl-fetch.ts resolves it).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates \
|
||||||
|
&& mkdir -p /opt/curl-impersonate \
|
||||||
|
&& curl -fsSL "https://github.com/lwthiker/curl-impersonate/releases/download/v0.6.1/curl-impersonate-v0.6.1.x86_64-linux-gnu.tar.gz" \
|
||||||
|
| tar -xz -C /opt/curl-impersonate \
|
||||||
|
&& chmod +x /opt/curl-impersonate/curl_chrome116 /opt/curl-impersonate/curl-impersonate-chrome \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
|
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
|
||||||
# the host's www-data user, UID/GID 33). The node base image already ships a
|
# the host's www-data user, UID/GID 33). The node base image already ships a
|
||||||
@@ -107,4 +125,4 @@ VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
|||||||
|
|
||||||
USER ${RUN_USER}
|
USER ${RUN_USER}
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 132,
|
||||||
|
"sprite_id": 132,
|
||||||
|
"item_name": "floortile",
|
||||||
|
"public_name": "Floor Tile",
|
||||||
|
"type": "s",
|
||||||
|
"width": 1,
|
||||||
|
"length": 1,
|
||||||
|
"stack_height": 0,
|
||||||
|
"allow_stack": 1,
|
||||||
|
"allow_sit": 0,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 1,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 0,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "429",
|
||||||
|
"rare": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 420,
|
||||||
|
"sprite_id": 420,
|
||||||
|
"item_name": "soft_jaggara_norja",
|
||||||
|
"public_name": "Norja-pehmojakkara",
|
||||||
|
"type": "s",
|
||||||
|
"width": 1,
|
||||||
|
"length": 3,
|
||||||
|
"stack_height": 1.7,
|
||||||
|
"allow_stack": 1,
|
||||||
|
"allow_sit": 1,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 0,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 1,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "429",
|
||||||
|
"rare": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"sprite_id": 1001,
|
||||||
|
"item_name": "Chess",
|
||||||
|
"public_name": "",
|
||||||
|
"type": "i",
|
||||||
|
"width": 1,
|
||||||
|
"length": 1,
|
||||||
|
"stack_height": 1,
|
||||||
|
"allow_stack": 1,
|
||||||
|
"allow_sit": 0,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 0,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 0,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "429",
|
||||||
|
"rare": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1011,
|
||||||
|
"sprite_id": 1011,
|
||||||
|
"item_name": "TicTacToe",
|
||||||
|
"public_name": "",
|
||||||
|
"type": "i",
|
||||||
|
"width": 1,
|
||||||
|
"length": 1,
|
||||||
|
"stack_height": 1,
|
||||||
|
"allow_stack": 1,
|
||||||
|
"allow_sit": 0,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 0,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 0,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "429",
|
||||||
|
"rare": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1021,
|
||||||
|
"sprite_id": 1021,
|
||||||
|
"item_name": "BattleShip",
|
||||||
|
"public_name": "",
|
||||||
|
"type": "i",
|
||||||
|
"width": 1,
|
||||||
|
"length": 1,
|
||||||
|
"stack_height": 1,
|
||||||
|
"allow_stack": 1,
|
||||||
|
"allow_sit": 0,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 0,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 0,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "429",
|
||||||
|
"rare": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1659,
|
||||||
|
"sprite_id": 1659,
|
||||||
|
"item_name": "ticket",
|
||||||
|
"public_name": "Big Ticket Bundle",
|
||||||
|
"type": "s",
|
||||||
|
"width": 1,
|
||||||
|
"length": 1,
|
||||||
|
"stack_height": 1,
|
||||||
|
"allow_stack": 1,
|
||||||
|
"allow_sit": 0,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 0,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 0,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "429",
|
||||||
|
"rare": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10056,
|
||||||
|
"sprite_id": 10056,
|
||||||
|
"item_name": "Vacuum",
|
||||||
|
"public_name": "laundry_r18_vacuum",
|
||||||
|
"type": "s",
|
||||||
|
"width": 1,
|
||||||
|
"length": 1,
|
||||||
|
"stack_height": 0,
|
||||||
|
"allow_stack": 0,
|
||||||
|
"allow_sit": 0,
|
||||||
|
"allow_lay": 0,
|
||||||
|
"allow_walk": 0,
|
||||||
|
"allow_gift": 1,
|
||||||
|
"allow_trade": 1,
|
||||||
|
"allow_recycle": 0,
|
||||||
|
"allow_marketplace_sell": 0,
|
||||||
|
"allow_inventory_stack": 1,
|
||||||
|
"interaction_type": "default",
|
||||||
|
"interaction_modes_count": 0,
|
||||||
|
"vending_ids": "0",
|
||||||
|
"multiheight": "",
|
||||||
|
"customparams": "",
|
||||||
|
"effect_id_male": 0,
|
||||||
|
"effect_id_female": 0,
|
||||||
|
"clothing_on_walk": "",
|
||||||
|
"page_id": "9966",
|
||||||
|
"rare": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -48,6 +48,27 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
mem_limit: 2g
|
mem_limit: 2g
|
||||||
|
|
||||||
|
# ── FlareSolverr (Cloudflare bypass for clone sources) ──
|
||||||
|
# Solves Cloudflare/TLS-fingerprint blocks via a headless Chrome browser.
|
||||||
|
# The CMS calls this on http://localhost:8191 for sources that block Node's
|
||||||
|
# TLS fingerprint (e.g. Leet.city). Used by src/lib/services/flare-solver.ts.
|
||||||
|
flaresolverr:
|
||||||
|
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||||
|
container_name: flaresolverr
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- BROWSER_TIMEOUT=60000
|
||||||
|
- BROWSER_WORKERS=1
|
||||||
|
mem_limit: 2g
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
|
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
|
||||||
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
||||||
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export interface AuditEvent {
|
|||||||
missingFromSources?: MissingFromSource[];
|
missingFromSources?: MissingFromSource[];
|
||||||
unrepairable?: UnrepairableItem[];
|
unrepairable?: UnrepairableItem[];
|
||||||
summary?: AuditSummary;
|
summary?: AuditSummary;
|
||||||
|
furniDataIdConflicts?: Array<{
|
||||||
|
classname: string;
|
||||||
|
itemId: number;
|
||||||
|
conflictingId: number;
|
||||||
|
existingClassname: string;
|
||||||
|
}>;
|
||||||
repair?: RepairStats;
|
repair?: RepairStats;
|
||||||
repairNitro?: RepairStats;
|
repairNitro?: RepairStats;
|
||||||
sql?: string;
|
sql?: string;
|
||||||
|
|||||||
@@ -3,10 +3,28 @@ const FLARESOLVERR_URL =
|
|||||||
|
|
||||||
const FLARESOLVERR_API = `${FLARESOLVERR_URL}/v1`;
|
const FLARESOLVERR_API = `${FLARESOLVERR_URL}/v1`;
|
||||||
|
|
||||||
export async function fetchWithFlareSolver(
|
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,
|
url: string,
|
||||||
timeout = 30000,
|
timeout = 30000,
|
||||||
): Promise<string> {
|
): Promise<FlareSolution> {
|
||||||
const res = await fetch(FLARESOLVERR_API, {
|
const res = await fetch(FLARESOLVERR_API, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -24,10 +42,7 @@ export async function fetchWithFlareSolver(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as FlareResult;
|
||||||
solution?: { response: string };
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
throw new Error(`FlareSolverr error: ${data.error}`);
|
throw new Error(`FlareSolverr error: ${data.error}`);
|
||||||
@@ -37,5 +52,31 @@ export async function fetchWithFlareSolver(
|
|||||||
throw new Error("FlareSolverr: no solution in response");
|
throw new Error("FlareSolverr: no solution in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.solution.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -8,8 +8,17 @@ const execFileAsync = promisify(execFile);
|
|||||||
* Some retro CDNs (e.g. Leet.city) block Node's fetch by TLS fingerprint
|
* Some retro CDNs (e.g. Leet.city) block Node's fetch by TLS fingerprint
|
||||||
* (JA3/JA4) even though the same request succeeds from a real browser or
|
* (JA3/JA4) even though the same request succeeds from a real browser or
|
||||||
* curl. This helper shells out to `curl` as a fallback for those hosts.
|
* curl. This helper shells out to `curl` as a fallback for those hosts.
|
||||||
|
*
|
||||||
|
* The runtime image ships `curl-impersonate` (a curl compiled with Chrome's
|
||||||
|
* exact TLS fingerprint, statically-bundled BoringSSL) at /opt/curl-impersonate
|
||||||
|
* because several sources enable Cloudflare `cf-mitigated: challenge` against
|
||||||
|
* the distro curl's fingerprint too. Prefer it when present, else fall back to
|
||||||
|
* the system curl.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const IMPERSONATE_CURL = "/opt/curl-impersonate/curl_chrome116";
|
||||||
|
const CURL_BIN = existsSync(IMPERSONATE_CURL) ? IMPERSONATE_CURL : "curl";
|
||||||
|
|
||||||
const CURL_USER_AGENT =
|
const CURL_USER_AGENT =
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
@@ -37,7 +46,7 @@ export async function curlFetchText(
|
|||||||
url: string,
|
url: string,
|
||||||
timeoutMs = 30_000,
|
timeoutMs = 30_000,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { stdout } = await execFileAsync("curl", curlArgs(url, []), {
|
const { stdout } = await execFileAsync(CURL_BIN, curlArgs(url, []), {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
maxBuffer: 512 * 1024 * 1024,
|
maxBuffer: 512 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
@@ -49,10 +58,12 @@ export async function curlDownload(
|
|||||||
url: string,
|
url: string,
|
||||||
destPath: string,
|
destPath: string,
|
||||||
timeoutMs = 30_000,
|
timeoutMs = 30_000,
|
||||||
|
cookieHeader?: string,
|
||||||
): Promise<{ ok: boolean; size: number }> {
|
): Promise<{ ok: boolean; size: number }> {
|
||||||
try {
|
try {
|
||||||
|
const cookieArgs = cookieHeader ? ["-H", `Cookie: ${cookieHeader}`] : [];
|
||||||
await execFileAsync(
|
await execFileAsync(
|
||||||
"curl",
|
CURL_BIN,
|
||||||
[
|
[
|
||||||
"-sSL",
|
"-sSL",
|
||||||
"--compressed",
|
"--compressed",
|
||||||
@@ -67,6 +78,7 @@ export async function curlDownload(
|
|||||||
"Accept: application/octet-stream,*/*;q=0.9",
|
"Accept: application/octet-stream,*/*;q=0.9",
|
||||||
"-H",
|
"-H",
|
||||||
"Accept-Language: en-US,en;q=0.9",
|
"Accept-Language: en-US,en;q=0.9",
|
||||||
|
...cookieArgs,
|
||||||
"-o",
|
"-o",
|
||||||
destPath,
|
destPath,
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
|
import { getFlareSolverrCookies } from "@/lib/services/flare-solver";
|
||||||
import { parseNitroBundle } from "../../swf/nitro-builder";
|
import { parseNitroBundle } from "../../swf/nitro-builder";
|
||||||
|
|
||||||
import { browserHeaders } from "./browser-headers";
|
import { browserHeaders } from "./browser-headers";
|
||||||
|
import { curlDownload } from "./curl-fetch";
|
||||||
|
|
||||||
export function validateSwfBytes(buffer: Buffer): boolean {
|
export function validateSwfBytes(buffer: Buffer): boolean {
|
||||||
if (buffer.length < 8) return false;
|
if (buffer.length < 8) return false;
|
||||||
@@ -58,6 +59,38 @@ export async function downloadFile(
|
|||||||
const maxRetries = options?.maxRetries ?? 3;
|
const maxRetries = options?.maxRetries ?? 3;
|
||||||
const baseDelay = 1000;
|
const baseDelay = 1000;
|
||||||
|
|
||||||
|
const curlFallback = async (): Promise<boolean> => {
|
||||||
|
let curlRes = await curlDownload(url, destPath);
|
||||||
|
if (!curlRes.ok) {
|
||||||
|
const cookieHeader = await getFlareSolverrCookies(url);
|
||||||
|
curlRes = await curlDownload(
|
||||||
|
url,
|
||||||
|
destPath,
|
||||||
|
30_000,
|
||||||
|
cookieHeader ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!curlRes.ok) return false;
|
||||||
|
const validate = options?.validate;
|
||||||
|
if (validate) {
|
||||||
|
let valid = false;
|
||||||
|
try {
|
||||||
|
const buffer = await fs.readFile(destPath);
|
||||||
|
valid =
|
||||||
|
(validate === "swf" && validateSwfBytes(buffer)) ||
|
||||||
|
(validate === "png" && validatePngBytes(buffer)) ||
|
||||||
|
(validate === "nitro" && validateNitroBytes(buffer));
|
||||||
|
} catch {
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
await fs.unlink(destPath).catch(() => {});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
let stage: "download" | "write" = "download";
|
let stage: "download" | "write" = "download";
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +107,13 @@ export async function downloadFile(
|
|||||||
const deterministic =
|
const deterministic =
|
||||||
res.status === 404 || res.status === 410 || res.status === 403;
|
res.status === 404 || res.status === 410 || res.status === 403;
|
||||||
if (deterministic || attempt === maxRetries) {
|
if (deterministic || attempt === maxRetries) {
|
||||||
|
// HTTP 403 is often a TLS-fingerprint / Cloudflare challenge
|
||||||
|
// block (e.g. Leet.city) — retry via curl, with FlareSolverr
|
||||||
|
// clearance cookies if the plain curl is also challenged.
|
||||||
|
if (res.status === 403 && (await curlFallback())) {
|
||||||
|
const stat = await fs.stat(destPath);
|
||||||
|
return { ok: true, size: stat.size };
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
|
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
|
||||||
);
|
);
|
||||||
@@ -108,6 +148,12 @@ export async function downloadFile(
|
|||||||
return { ok: true, size: buffer.length };
|
return { ok: true, size: buffer.length };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (attempt === maxRetries || stage === "write") {
|
if (attempt === maxRetries || stage === "write") {
|
||||||
|
// TLS fingerprint aborts land here too — try curl/FlareSolverr
|
||||||
|
// before reporting the network failure.
|
||||||
|
if (stage === "download" && (await curlFallback())) {
|
||||||
|
const stat = await fs.stat(destPath);
|
||||||
|
return { ok: true, size: stat.size };
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download error ${url}:`,
|
`[import-download] Download error ${url}:`,
|
||||||
(err as Error).message,
|
(err as Error).message,
|
||||||
|
|||||||
Reference in new issue
Block a user