Update Dockerfile and Compose configuration

This commit is contained in:
openhands committed 2026-09-06 19:19:25 +02:00
1 parent 35f66d879f
commit d1003bb89b
9 files changed
+2046 -13

No files matched your search

View File
Whitespace-only changes.
+20 -2
View File
@@ -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"
}
]
+21
View File
@@ -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.
+6
View File
@@ -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;
+48 -7
View File
@@ -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;
}
} }
+15 -3
View File
@@ -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,
+47 -1
View File
@@ -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,