Update Dockerfile and Compose configuration
This commit is contained in:
1 parent
35f66d879f
commit
d1003bb89b
9 files changed
+2045
-12
No files matched your search
Whitespace-only changes.
+19
-1
@@ -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.
|
||||
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.
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
# 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 host's www-data user, UID/GID 33). The node base image already ships a
|
||||
|
||||
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
|
||||
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) ──
|
||||
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
||||
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface AuditEvent {
|
||||
missingFromSources?: MissingFromSource[];
|
||||
unrepairable?: UnrepairableItem[];
|
||||
summary?: AuditSummary;
|
||||
furniDataIdConflicts?: Array<{
|
||||
classname: string;
|
||||
itemId: number;
|
||||
conflictingId: number;
|
||||
existingClassname: string;
|
||||
}>;
|
||||
repair?: RepairStats;
|
||||
repairNitro?: RepairStats;
|
||||
sql?: string;
|
||||
|
||||
@@ -3,10 +3,28 @@ const FLARESOLVERR_URL =
|
||||
|
||||
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,
|
||||
timeout = 30000,
|
||||
): Promise<string> {
|
||||
): Promise<FlareSolution> {
|
||||
const res = await fetch(FLARESOLVERR_API, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -24,10 +42,7 @@ export async function fetchWithFlareSolver(
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
solution?: { response: string };
|
||||
error?: string;
|
||||
};
|
||||
const data = (await res.json()) as FlareResult;
|
||||
|
||||
if (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");
|
||||
}
|
||||
|
||||
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 { promises as fs } from "node:fs";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
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
|
||||
* (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.
|
||||
*
|
||||
* 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 =
|
||||
"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,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync("curl", curlArgs(url, []), {
|
||||
const { stdout } = await execFileAsync(CURL_BIN, curlArgs(url, []), {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
});
|
||||
@@ -49,10 +58,12 @@ export async function curlDownload(
|
||||
url: string,
|
||||
destPath: string,
|
||||
timeoutMs = 30_000,
|
||||
cookieHeader?: string,
|
||||
): Promise<{ ok: boolean; size: number }> {
|
||||
try {
|
||||
const cookieArgs = cookieHeader ? ["-H", `Cookie: ${cookieHeader}`] : [];
|
||||
await execFileAsync(
|
||||
"curl",
|
||||
CURL_BIN,
|
||||
[
|
||||
"-sSL",
|
||||
"--compressed",
|
||||
@@ -67,6 +78,7 @@ export async function curlDownload(
|
||||
"Accept: application/octet-stream,*/*;q=0.9",
|
||||
"-H",
|
||||
"Accept-Language: en-US,en;q=0.9",
|
||||
...cookieArgs,
|
||||
"-o",
|
||||
destPath,
|
||||
url,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { getFlareSolverrCookies } from "@/lib/services/flare-solver";
|
||||
import { parseNitroBundle } from "../../swf/nitro-builder";
|
||||
|
||||
import { browserHeaders } from "./browser-headers";
|
||||
import { curlDownload } from "./curl-fetch";
|
||||
|
||||
export function validateSwfBytes(buffer: Buffer): boolean {
|
||||
if (buffer.length < 8) return false;
|
||||
@@ -58,6 +59,38 @@ export async function downloadFile(
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
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++) {
|
||||
let stage: "download" | "write" = "download";
|
||||
try {
|
||||
@@ -74,6 +107,13 @@ export async function downloadFile(
|
||||
const deterministic =
|
||||
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
|
||||
// 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(
|
||||
`[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 };
|
||||
} catch (err) {
|
||||
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(
|
||||
`[import-download] Download error ${url}:`,
|
||||
(err as Error).message,
|
||||
|
||||
Reference in new issue
Block a user