Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
This commit is contained in:
commit
b3a6531d7b
35 files changed
+398
-1048
No files matched your search
@@ -34,8 +34,5 @@ public/cache
|
||||
public/tmp
|
||||
db_backup_*.sql
|
||||
*.log
|
||||
playwright-report
|
||||
test-results
|
||||
blob-report
|
||||
.codex
|
||||
.agents
|
||||
+2
-2
@@ -76,8 +76,8 @@ PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
# --- LOGGING ---
|
||||
LOG_LEVEL=error
|
||||
|
||||
# --- FLARESOLVERR (Cloudflare bypass for clone sources) ---
|
||||
FLARESOLVERR_URL=http://localhost:8191
|
||||
# --- BYPARR (Cloudflare bypass for clone sources) ---
|
||||
BYPARR_URL=http://localhost:8191
|
||||
|
||||
# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials.
|
||||
CATALOG_GIT_CHECKOUT=
|
||||
|
||||
@@ -53,14 +53,11 @@ jobs:
|
||||
BCRYPT_ROUNDS: 4
|
||||
run: |
|
||||
if [ -x /usr/bin/time ]; then
|
||||
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test --maxWorkers=2
|
||||
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test:coverage --maxWorkers=4
|
||||
else
|
||||
time pnpm test --maxWorkers=2
|
||||
time pnpm test:coverage --maxWorkers=4
|
||||
fi
|
||||
|
||||
- name: Knip (unused files/exports)
|
||||
run: pnpm knip
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Docker build & deploy
|
||||
# Draait op de host (self-hosted) zodat Docker
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm typecheck
|
||||
pnpm biome:lint
|
||||
pnpm test
|
||||
pnpm test:coverage
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: 1
|
||||
NODE_ENV: test
|
||||
|
||||
@@ -33,11 +33,6 @@ public/tmp/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
|
||||
# Playwright test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
.aider*
|
||||
|
||||
/public/vendor/tinymce/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pnpm exec lint-staged
|
||||
@@ -1,2 +0,0 @@
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
+22
-7
@@ -4,22 +4,37 @@ FROM node:26.8.1-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Keep the bootstrap aligned with package.json packageManager.
|
||||
RUN apk add --no-cache git \
|
||||
# The apk cache is persisted in a BuildKit cache mount so git is not
|
||||
# re-downloaded on every build.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g [email protected]
|
||||
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
||||
# on the builder. This is what stops disk usage from growing unbounded: the
|
||||
# downloaded dependency store is shared and reused instead of being copied into
|
||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
||||
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
||||
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
# pnpm fetch: download all deps into $PNPM_STORE first, so only the lockfile
|
||||
# change (not source changes) invalidates the network-heavy download layer.
|
||||
RUN pnpm fetch --ignore-scripts
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
# pnpm fetch: download all deps into the shared cache-mounted store.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm fetch --ignore-scripts
|
||||
# Install offline from the cache-mounted store; the store itself stays in the
|
||||
# build cache between builds.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
COPY . .
|
||||
FROM migrations AS builder
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
# Fixture values exist only for this build command; production secrets are runtime-only.
|
||||
RUN DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
||||
# changed parts.
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||
AUTH_SECRET="build-fixture-not-for-runtime-use-000000000000" \
|
||||
pnpm run build
|
||||
@@ -32,7 +47,7 @@ ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3002 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN apk add --no-cache tini \
|
||||
RUN apk add --no-cache tini curl \
|
||||
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
|
||||
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
|
||||
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
|
||||
|
||||
@@ -812,16 +812,16 @@ pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
|
||||
|
||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||
|
||||
### FlareSolverr (Cloudflare Bypass)
|
||||
### Byparr (Cloudflare Bypass)
|
||||
|
||||
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
|
||||
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
|
||||
|
||||
```bash
|
||||
docker compose up -d flaresolverr
|
||||
docker compose up -d byparr
|
||||
```
|
||||
|
||||
```dotenv
|
||||
FLARESOLVERR_URL=http://localhost:8191
|
||||
BYPARR_URL=http://localhost:8191
|
||||
```
|
||||
|
||||
### Furniture Import with Auto-Translation
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
|
||||
"includes": ["**", "!setup", "!*.cjs", "!coverage", "!drizzle/drafts/meta"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
+8
-9
@@ -58,19 +58,18 @@ services:
|
||||
start_period: 40s
|
||||
mem_limit: 2g
|
||||
|
||||
# ── FlareSolverr (Cloudflare bypass for clone sources) ──
|
||||
# ── Byparr (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
|
||||
# Drop-in successor to FlareSolverr. 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/byparr.ts.
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- BROWSER_TIMEOUT=60000
|
||||
- BROWSER_WORKERS=1
|
||||
- LOG_LEVEL=INFO
|
||||
mem_limit: 2g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
|
||||
|
||||
@@ -44,7 +44,7 @@ stack is diagnostic evidence, not an automatic root-cause determination.
|
||||
## Dependencies
|
||||
|
||||
`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`,
|
||||
`pnpm knip`, `pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
||||
`pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
||||
`pnpm analyze --output` writes a Next.js bundle analysis (not an application build).
|
||||
|
||||
TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("health endpoint is reachable", async ({ request }) => {
|
||||
const res = await request.get("/api/health");
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("status");
|
||||
});
|
||||
|
||||
test("homepage renders", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/.+/);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": [
|
||||
"src/app/**/page.{ts,tsx}",
|
||||
"src/app/**/layout.{ts,tsx}",
|
||||
"src/app/**/route.{ts,tsx}",
|
||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||
"scripts/migrate-aes-cbc-to-gcm.ts",
|
||||
"scripts/build-languages-full.ts",
|
||||
"scripts/translate-furnidata-full.ts",
|
||||
"scripts/align-db-ids-with-furnidata.ts",
|
||||
"scripts/furni-diagnose-now.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
||||
"ignoreDependencies": ["pino-pretty"],
|
||||
"ignoreBinaries": ["sendmail"]
|
||||
}
|
||||
+2
-12
@@ -14,11 +14,10 @@
|
||||
"lint": "biome check .",
|
||||
"biome:lint": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "vitest run --coverage.enabled=false",
|
||||
"test:coverage": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
@@ -32,17 +31,12 @@
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"prepare": "node scripts/prepare-hooks.mjs",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "next experimental-analyze",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
||||
"*.{ts,tsx}": "node scripts/check-admin-colors.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
@@ -86,7 +80,6 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.29.8",
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
@@ -95,9 +88,6 @@
|
||||
"@types/react-dom": "19.2.7",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"husky": "9.1.7",
|
||||
"knip": "6.34.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: { baseURL, trace: "on-first-retry" },
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm dev --port 3000",
|
||||
url: "http://127.0.0.1:3000/api/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
Generated
+193
-800
File diff suppressed because it is too large.
Load diff
@@ -93,8 +93,6 @@ for managed_name in epicnext-cms epicnext-cms-app; do
|
||||
done
|
||||
cp "$deploy_dir/.env" .env
|
||||
pnpm install --frozen-lockfile
|
||||
# Prepare browser before cutover so installation failures cannot interrupt the site.
|
||||
pnpm exec playwright install chromium
|
||||
|
||||
echo "Building $image"
|
||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
||||
@@ -161,8 +159,7 @@ candidate_attempted=1
|
||||
)
|
||||
healthy
|
||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||
# Publish the latest alias only after health and browser checks pass.
|
||||
# Publish the latest alias only after health and release checks pass.
|
||||
docker tag "$image" epicnext-cms:latest
|
||||
cutover_started=0
|
||||
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
||||
@@ -176,5 +173,5 @@ while IFS= read -r tag; do
|
||||
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
|
||||
fi
|
||||
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms)
|
||||
docker builder prune -af --filter "until=72h" --keep-storage=2g || true
|
||||
docker builder prune -af --filter "until=72h" --max-used-space=4g || true
|
||||
docker image prune -f --filter "until=168h" || true
|
||||
@@ -1,20 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
URL="${FLARESOLVERR_URL:-http://localhost:8191}"
|
||||
MAX_RETRIES="${FLARESOLVERR_MAX_RETRIES:-30}"
|
||||
RETRY_INTERVAL="${FLARESOLVERR_RETRY_INTERVAL:-2}"
|
||||
URL="${BYPARR_URL:-${FLARESOLVERR_URL:-http://localhost:8191}}"
|
||||
MAX_RETRIES="${BYPARR_MAX_RETRIES:-30}"
|
||||
RETRY_INTERVAL="${BYPARR_RETRY_INTERVAL:-2}"
|
||||
|
||||
echo "Waiting for FlareSolverr at ${URL}..."
|
||||
echo "Waiting for Byparr at ${URL}..."
|
||||
|
||||
for i in $(seq 1 "$MAX_RETRIES"); do
|
||||
if curl -sf "${URL}/health" >/dev/null 2>&1; then
|
||||
echo "FlareSolverr is healthy."
|
||||
echo "Byparr is healthy."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt ${i}/${MAX_RETRIES} - FlareSolverr not ready, retrying in ${RETRY_INTERVAL}s..."
|
||||
echo "Attempt ${i}/${MAX_RETRIES} - Byparr not ready, retrying in ${RETRY_INTERVAL}s..."
|
||||
sleep "$RETRY_INTERVAL"
|
||||
done
|
||||
|
||||
echo "ERROR: FlareSolverr did not become healthy after ${MAX_RETRIES} attempts."
|
||||
echo "ERROR: Byparr did not become healthy after ${MAX_RETRIES} attempts."
|
||||
exit 1
|
||||
@@ -1,5 +0,0 @@
|
||||
// Production builds and CI do not need Git hooks or dev-only executables.
|
||||
if (process.env.NODE_ENV !== "production" && !process.env.CI) {
|
||||
const { default: husky } = await import("husky");
|
||||
husky();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -34,5 +34,5 @@
|
||||
".next-staging/types/**/*.ts",
|
||||
".next-staging/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "e2e", "playwright.config.ts", "src/generated"]
|
||||
"exclude": ["node_modules", "src/generated"]
|
||||
}
|
||||
+1
-1
@@ -1282,7 +1282,7 @@ with open(out, "w", encoding="utf-8") as f:
|
||||
|
||||
# Sanity-check a .env file for the failure modes that actually break a deploy:
|
||||
# duplicate keys, and a value concatenated onto another key (missing newline),
|
||||
# e.g. FLARESOLVERR_URL=http://localhost:8191AUTH_TRUST_HOST="..."
|
||||
# e.g. BYPARR_URL=http://localhost:8191AUTH_TRUST_HOST="..."
|
||||
validate_env_file() {
|
||||
local base_dir="${1:-$SCRIPT_DIR}"
|
||||
local en="$base_dir/.env"
|
||||
|
||||
Reference in new issue
Block a user