feat(assets): choose furniture bundle ext via FURNI_BUNDLE_EXT
CI / check (push) Successful in 39s
CI / tests-imaging-browsers (push) Successful in 2m9s
CI / tests-integration (push) Successful in 2m47s
CI / tests-unit (push) Failing after 3m22s
CI / tests-ui (push) Failing after 4m14s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / check (push) Successful in 39s
CI / tests-imaging-browsers (push) Successful in 2m9s
CI / tests-integration (push) Successful in 2m47s
CI / tests-unit (push) Failing after 3m22s
CI / tests-ui (push) Failing after 4m14s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Bundle the remaining imaging hardening and the host/CI fixes from the
"Doe alles" pass into one release.
Furniture:
- Resolve the local bundle extension from FURNI_BUNDLE_EXT (".hab" default,
".nitro" for a stock Nitro client). The configured extension is written by
imports/repairs and preferred on lookup; the other stays a legacy fallback.
Imaging:
- Avatar proxy sends s-maxage=86400 + stale-while-revalidate (300/3600 for the
fallback path) so the edge can hold a render a day.
- Cloudflare cache rule now covers /api/imaging/ (verified MISS->HIT live).
- Document that bare /imaging is shadowed by the edge proxy to the renderer.
Ops/CI:
- docker-update.sh skips a CI-managed host (exit 0) instead of failing cron.
- ci-deploy.sh runs the cross-browser imaging gate (chromium+firefox+webkit)
against the candidate before cutover; DEPLOY_SKIP_CROSS_BROWSER=1 as escape.
- scripts/imager-health-alert.sh alerts (Discord) on imager up/down transitions;
runs from cron every 5 minutes.
Tests:
- Furniture bundle-ext resolver + FURNI_BUNDLE_EXT wiring.
- Imaging performance budget (bytes + warm latency).
- classifyDdos: /api/imaging/* stays in the api bucket.
This commit is contained in:
1 parent
640e5a5dd0
commit
3c8abb6fbf
17 files changed
+288
-20
No files matched your search
@@ -89,6 +89,13 @@ DB_BACKUP_KEEP=7
|
|||||||
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||||
HEALTH_ALERT_COOLDOWN_MIN=15
|
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||||
|
|
||||||
|
# --- FURNITURE ASSETS ---
|
||||||
|
# Extension the local Nitro client requests for furniture bundles. The CMS
|
||||||
|
# writes new/imported/repaired bundles with this extension and prefers it when
|
||||||
|
# resolving existing ones (the other stays readable as a legacy fallback).
|
||||||
|
# ".hab" (default) or ".nitro".
|
||||||
|
FURNI_BUNDLE_EXT=.hab
|
||||||
|
|
||||||
# --- RCON (Low Latency Loop) ---
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3003
|
RCON_PORT=3003
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public/cache/
|
|||||||
|
|
||||||
prod.log
|
prod.log
|
||||||
*.log
|
*.log
|
||||||
|
# Imager health-alert state (last-known up/down + timestamp)
|
||||||
|
logs/imager-health.state
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
db_backup_*.sql
|
db_backup_*.sql
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
|
|||||||
### Requirements (host)
|
### Requirements (host)
|
||||||
|
|
||||||
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
||||||
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab`, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
||||||
|
|
||||||
### Configuration — `/docker/Polaris-imager/.env`
|
### Configuration — `/docker/Polaris-imager/.env`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
|
||||||
|
// correctness; this one catches a regression that keeps the image correct but
|
||||||
|
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
|
||||||
|
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
|
||||||
|
// flag a real regression, not a cold cache or a slow CI network.
|
||||||
|
const STATIC_MAX_BYTES = 64 * 1024;
|
||||||
|
const ANIMATED_MAX_BYTES = 512 * 1024;
|
||||||
|
const WARM_MAX_MS = 3000;
|
||||||
|
|
||||||
|
test("the static avatar stays inside its byte budget", async ({ request }) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the animated avatar stays inside its byte budget", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
avatarPath({ img_format: "apng", effect: "14" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(
|
||||||
|
ANIMATED_MAX_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a warm avatar request is served quickly", async ({ request }) => {
|
||||||
|
const path = avatarPath({ img_format: "png" });
|
||||||
|
await request.get(path); // warm the disk and edge caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(path);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(elapsed).toBeLessThan(WARM_MAX_MS);
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||||
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx
|
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
|
||||||
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`).
|
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
|
||||||
|
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
|
||||||
|
# stuurt).
|
||||||
#
|
#
|
||||||
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
||||||
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||||
@@ -35,12 +37,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
BASE="https://api.cloudflare.com/client/v4"
|
BASE="https://api.cloudflare.com/client/v4"
|
||||||
PHASE="http_request_cache_settings"
|
PHASE="http_request_cache_settings"
|
||||||
DESCRIPTION="EpicNabbo CMS public API + gamedata edge cache (cms-public, cms-gamedata)"
|
DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
|
||||||
|
|
||||||
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
||||||
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||||
# `starts_with()`.
|
# `starts_with()`.
|
||||||
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
#
|
||||||
|
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
|
||||||
|
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
|
||||||
|
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
|
||||||
|
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
|
||||||
|
# tussen gebruikers.
|
||||||
|
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
||||||
|
|
||||||
load_env() {
|
load_env() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
|
|||||||
+21
-1
@@ -410,7 +410,10 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm exec playwright install chromium
|
# All three engines are needed: the chromium gate below plus the cross-browser
|
||||||
|
# imaging gate just before cutover. Idempotent — already-installed engines in
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
|
||||||
|
pnpm exec playwright install chromium firefox webkit
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||||
@@ -536,6 +539,18 @@ if [ "$blue_green" -eq 1 ]; then
|
|||||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||||
|
|
||||||
|
# 4b. Cross-browser imaging gate. The chromium suite above proves the
|
||||||
|
# candidate serves the right bytes; this proves every bundled engine can
|
||||||
|
# actually decode the render. The webkit path is the one that historically
|
||||||
|
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
|
||||||
|
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
|
||||||
|
# a release out in an emergency.
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||||
cutover_started=1
|
cutover_started=1
|
||||||
switch_upstream "$new_port"
|
switch_upstream "$new_port"
|
||||||
@@ -569,6 +584,11 @@ else
|
|||||||
healthy 3002
|
healthy 3002
|
||||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
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
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
|
||||||
|
fi
|
||||||
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
||||||
fi
|
fi
|
||||||
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
|||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
die() { log "ERROR: $*"; exit 1; }
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
# A run that legitimately has nothing to do (the host is owned by CI) must not
|
||||||
|
# look like a failure to cron. `die` stays reserved for real errors.
|
||||||
|
skip() { log "SKIP: $*"; exit 0; }
|
||||||
migration_image=""
|
migration_image=""
|
||||||
remote_migration_image=""
|
remote_migration_image=""
|
||||||
previous_image=""
|
previous_image=""
|
||||||
@@ -72,11 +75,11 @@ trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were dele
|
|||||||
# the only thing that still marks the host as blue/green when a slot is idle.
|
# the only thing that still marks the host as blue/green when a slot is idle.
|
||||||
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||||
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
||||||
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
||||||
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
||||||
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||||
|
|||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Alert when the local avatar imager stops rendering, and again when it recovers.
|
||||||
|
#
|
||||||
|
# Intended to run from cron every few minutes (see `crontab -l`). Steady-state is
|
||||||
|
# silent; a state change posts to DISCORD_WEBHOOK_URL (from the environment or
|
||||||
|
# .env). While the imager stays down it re-alerts at most once per
|
||||||
|
# HEALTH_ALERT_COOLDOWN_MIN. The script always exits 0 so an outage never turns
|
||||||
|
# into a noisy cron failure; the alert itself (and this log) is the signal.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOG_FILE="${IMAGER_HEALTH_LOG:-$DIR/logs/imager-health.log}"
|
||||||
|
STATE_FILE="${IMAGER_HEALTH_STATE:-$DIR/logs/imager-health.state}"
|
||||||
|
HEALTH_URL="${IMAGER_HEALTH_URL:-http://127.0.0.1:8082/health}"
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")"
|
||||||
|
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "${!name:-}" ]; then return 0; fi
|
||||||
|
if [ -f "$DIR/.env" ]; then
|
||||||
|
local line
|
||||||
|
line="$(grep -m1 "^$name=" "$DIR/.env" | cut -d= -f2- | tr -d "'\"")" || true
|
||||||
|
if [ -n "$line" ]; then printf -v "$name" '%s' "$line"; fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env DISCORD_WEBHOOK_URL || true
|
||||||
|
COOLDOWN_MIN="${HEALTH_ALERT_COOLDOWN_MIN:-30}"
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
local msg="$1"
|
||||||
|
log "ALERT: $msg"
|
||||||
|
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||||
|
log "no DISCORD_WEBHOOK_URL configured; alert written to log only"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local payload
|
||||||
|
payload="$(python3 -c 'import json,sys;print(json.dumps({"content":sys.argv[1]}))' "$msg")"
|
||||||
|
curl -sS -m 10 -H "Content-Type: application/json" -d "$payload" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" >/dev/null 2>&1 || log "failed to post alert to Discord"
|
||||||
|
}
|
||||||
|
|
||||||
|
body="$(curl -sS -m 5 "$HEALTH_URL" 2>/dev/null || true)"
|
||||||
|
ready=0
|
||||||
|
if printf '%s' "$body" | grep -q '"ready":true'; then ready=1; fi
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
prev_state="up"
|
||||||
|
prev_at=0
|
||||||
|
if [ -f "$STATE_FILE" ]; then read -r prev_state prev_at < "$STATE_FILE" || true; fi
|
||||||
|
[ -n "${prev_state:-}" ] || prev_state=up
|
||||||
|
[ -n "${prev_at:-}" ] || prev_at=0
|
||||||
|
|
||||||
|
if [ "$ready" = 1 ]; then
|
||||||
|
if [ "$prev_state" != "up" ]; then
|
||||||
|
notify "Avatar imager RECOVERED ($HEALTH_URL reports ready)"
|
||||||
|
fi
|
||||||
|
printf 'up %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$prev_state" != "down" ]; then
|
||||||
|
notify "Avatar imager DOWN ($HEALTH_URL not ready: ${body:-unreachable})"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(( now - prev_at ))" -ge "$(( COOLDOWN_MIN * 60 ))" ]; then
|
||||||
|
notify "Avatar imager STILL DOWN after ${COOLDOWN_MIN}m ($HEALTH_URL)"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -21,12 +21,16 @@ function cacheControlFor(source: string): string {
|
|||||||
switch (source) {
|
switch (source) {
|
||||||
case "fallback":
|
case "fallback":
|
||||||
case "cache-stale":
|
case "cache-stale":
|
||||||
// Reuse the render briefly but revalidate the upstream soon.
|
// Reuse the render briefly but revalidate the upstream soon. The
|
||||||
return "public, max-age=300";
|
// shared `s-maxage` lets the edge (Cloudflare) hold it for an hour
|
||||||
|
// without pinning a degraded fallback in the browser.
|
||||||
|
return "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600";
|
||||||
case "unavailable":
|
case "unavailable":
|
||||||
return "no-cache, max-age=0";
|
return "no-cache, max-age=0";
|
||||||
default:
|
default:
|
||||||
return "public, max-age=3600";
|
// s-maxage=86400 lets the edge cache a day off the origin, matching
|
||||||
|
// the sibling /imaging route; the URL already keys per figure.
|
||||||
|
return "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ import {
|
|||||||
} from "@/lib/imager-upstream";
|
} from "@/lib/imager-upstream";
|
||||||
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
||||||
|
|
||||||
|
// Site-wide default imager endpoint. NOTE ON TOPOLOGY: in the current
|
||||||
|
// production deployment the edge proxy (Traefik) fronts bare `/imaging` and
|
||||||
|
// `/imaging/*` directly to the local pixinode renderer container, so this CMS
|
||||||
|
// route is shadowed publicly and only answers when the CMS is reached directly
|
||||||
|
// (local/dev, self-hosted installs without that proxy rule, or the deploy gate).
|
||||||
|
// That is intentional: it keeps `habbo_imaging_url=/imaging` working without the
|
||||||
|
// CMS in the hot path, and this route remains the correct fallback for any
|
||||||
|
// install where no separate imager is proxied. Its behaviour is covered by
|
||||||
|
// `src/lib/imaging-route.test.ts` and the CMS-only e2e (`e2e/imaging/cms-imaging.spec.ts`).
|
||||||
|
//
|
||||||
|
// It differs from the sibling `/api/imaging/avatar` route in that it also
|
||||||
|
// transcodes PNG renders to WebP/AVIF (`?format=`) and is cached on disk per
|
||||||
|
// (figure, variant).
|
||||||
|
|
||||||
type AvatarFormat = "png" | "webp" | "avif";
|
type AvatarFormat = "png" | "webp" | "avif";
|
||||||
type TranscodeFormat = Exclude<AvatarFormat, "png">;
|
type TranscodeFormat = Exclude<AvatarFormat, "png">;
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ const schema = z
|
|||||||
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||||
// when unset.
|
// when unset.
|
||||||
BADGE_UPLOAD_DIR: z.string().optional(),
|
BADGE_UPLOAD_DIR: z.string().optional(),
|
||||||
|
// Extension local furniture bundles are written with and the client
|
||||||
|
// requests: ".hab" (default) or ".nitro". Accepts the bare form too.
|
||||||
|
FURNI_BUNDLE_EXT: z.string().optional(),
|
||||||
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
|
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
|
||||||
EMULATOR_JAR_PATH: z.string().optional(),
|
EMULATOR_JAR_PATH: z.string().optional(),
|
||||||
EMULATOR_BACKUP_DIR: z.string().optional(),
|
EMULATOR_BACKUP_DIR: z.string().optional(),
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ describe("classifyDdos", () => {
|
|||||||
["/api/auth/callback/credentials", "auth"],
|
["/api/auth/callback/credentials", "auth"],
|
||||||
["/api/articles/hello", "api"],
|
["/api/articles/hello", "api"],
|
||||||
["/api/radio/stream", "api"],
|
["/api/radio/stream", "api"],
|
||||||
|
// Imaging is deliberately in the `api` bucket: it is high-volume and
|
||||||
|
// cacheable, so it gets the generous api budget, not the page budget.
|
||||||
|
["/api/imaging/avatar", "api"],
|
||||||
|
["/api/imaging/badge", "api"],
|
||||||
])(`classifies %s as %s`, (pathname, expected) => {
|
])(`classifies %s as %s`, (pathname, expected) => {
|
||||||
expect(classifyDdos(pathname)).toBe(expected);
|
expect(classifyDdos(pathname)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
export type DdosCategory = "pages" | "api" | "auth";
|
export type DdosCategory = "pages" | "api" | "auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket a request path for rate limiting. Everything under `/api/` that is not
|
||||||
|
* an auth route counts as `api` — including `/api/imaging/*` (avatars, badges).
|
||||||
|
* That is intentional: imaging is a deliberate high-volume, cacheable surface,
|
||||||
|
* so it gets the wider api budget rather than the page budget. The bare
|
||||||
|
* `/imaging` renderer path never reaches this gate (it is excluded by the proxy
|
||||||
|
* matcher and, in production, fronted by the edge proxy directly).
|
||||||
|
*/
|
||||||
export function classifyDdos(pathname: string): DdosCategory {
|
export function classifyDdos(pathname: string): DdosCategory {
|
||||||
if (
|
if (
|
||||||
pathname === "/api/auth" ||
|
pathname === "/api/auth" ||
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
|
|||||||
/**
|
/**
|
||||||
* Local furniture-bundle lookups.
|
* Local furniture-bundle lookups.
|
||||||
*
|
*
|
||||||
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
|
* New bundles are written with the configured primary extension
|
||||||
* assets is still `.nitro`, so every existence check has to consider both. These
|
* (`FURNI_BUNDLE_EXT`, `.hab` by default), but a large body of pre-existing
|
||||||
|
* assets may be `.nitro`, so every existence check has to consider both. These
|
||||||
* helpers are the single place that knows that, which keeps "is this item's
|
* helpers are the single place that knows that, which keeps "is this item's
|
||||||
* asset present?" from silently flipping to `false` for legacy files and the
|
* asset present?" from silently flipping to `false` for legacy files and the
|
||||||
* nitro-cleanup scan from classifying them as fake leftovers.
|
* nitro-cleanup scan from classifying them as fake leftovers.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
getBaseClassname,
|
getBaseClassname,
|
||||||
getBundleFileName,
|
getBundleFileName,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getSwfFileName,
|
getSwfFileName,
|
||||||
isValidClassname,
|
isValidClassname,
|
||||||
normalizeClassname,
|
normalizeClassname,
|
||||||
|
resolveLocalBundleExt,
|
||||||
SOURCE_BUNDLE_EXT,
|
SOURCE_BUNDLE_EXT,
|
||||||
} from "./classname";
|
} from "./classname";
|
||||||
|
|
||||||
@@ -61,6 +62,44 @@ describe("normalizeClassname", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveLocalBundleExt", () => {
|
||||||
|
it("defaults to .hab", () => {
|
||||||
|
expect(resolveLocalBundleExt(undefined)).toBe(".hab");
|
||||||
|
expect(resolveLocalBundleExt("")).toBe(".hab");
|
||||||
|
});
|
||||||
|
it("accepts .nitro and the bare form", () => {
|
||||||
|
expect(resolveLocalBundleExt(".nitro")).toBe(".nitro");
|
||||||
|
expect(resolveLocalBundleExt("nitro")).toBe(".nitro");
|
||||||
|
expect(resolveLocalBundleExt("NITRO")).toBe(".nitro");
|
||||||
|
});
|
||||||
|
it("accepts .hab and the bare form", () => {
|
||||||
|
expect(resolveLocalBundleExt(".hab")).toBe(".hab");
|
||||||
|
expect(resolveLocalBundleExt("HAB")).toBe(".hab");
|
||||||
|
});
|
||||||
|
it("falls back to .hab for unknown values", () => {
|
||||||
|
expect(resolveLocalBundleExt("swf")).toBe(".hab");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FURNI_BUNDLE_EXT wiring", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured extension and keeps the other as fallback", async () => {
|
||||||
|
vi.stubEnv("FURNI_BUNDLE_EXT", ".nitro");
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import("./classname");
|
||||||
|
expect(mod.LOCAL_BUNDLE_EXT).toBe(".nitro");
|
||||||
|
expect(mod.getNitroFileName("sofa_blue")).toBe("sofa_blue.nitro");
|
||||||
|
expect(mod.getLocalBundleFileNames("sofa_blue")).toEqual([
|
||||||
|
"sofa_blue.nitro",
|
||||||
|
"sofa_blue.hab",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isValidClassname", () => {
|
describe("isValidClassname", () => {
|
||||||
it("accepts lowercase letters, digits and allowed symbols", () => {
|
it("accepts lowercase letters, digits and allowed symbols", () => {
|
||||||
expect(isValidClassname("chair_basic")).toBe(true);
|
expect(isValidClassname("chair_basic")).toBe(true);
|
||||||
|
|||||||
@@ -17,23 +17,61 @@ export function getSwfFileName(classname: string): string {
|
|||||||
return `${getBaseClassname(classname)}.swf`;
|
return `${getBaseClassname(classname)}.swf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The two extensions a furniture bundle may carry. */
|
||||||
|
export const BUNDLE_EXT_CHOICES = [".hab", ".nitro"] as const;
|
||||||
|
export type FurnitureBundleExt = (typeof BUNDLE_EXT_CHOICES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an env value via a computed key so Next does not inline it at build time
|
||||||
|
* — changing it should take effect on restart, not require a rebuild (same
|
||||||
|
* pattern as `runtime-asset-config.ts`).
|
||||||
|
*/
|
||||||
|
function readBundleExtEnv(): string | undefined {
|
||||||
|
const name = "FURNI_BUNDLE_EXT";
|
||||||
|
return process.env[name]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the local furniture-bundle extension this deployment's client asks
|
||||||
|
* for. Set `FURNI_BUNDLE_EXT` in `.env` to `hab`/`.hab` or `nitro`/`.nitro`;
|
||||||
|
* anything missing or unrecognised falls back to `.hab`.
|
||||||
|
*/
|
||||||
|
export function resolveLocalBundleExt(
|
||||||
|
raw: string | undefined = readBundleExtEnv(),
|
||||||
|
): FurnitureBundleExt {
|
||||||
|
const value = raw?.trim().toLowerCase();
|
||||||
|
if (!value) return ".hab";
|
||||||
|
const normalized = value.startsWith(".") ? value : `.${value}`;
|
||||||
|
return (BUNDLE_EXT_CHOICES as readonly string[]).includes(normalized)
|
||||||
|
? (normalized as FurnitureBundleExt)
|
||||||
|
: ".hab";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension locally stored furniture bundles are written with. This deployment's
|
* Extension locally stored furniture bundles are written with. This deployment's
|
||||||
* Nitro client requests `<classname>.hab`, so that is what new imports produce.
|
* Nitro client requests `<classname>.hab` by default, but a stock Nitro client
|
||||||
|
* asks for `.nitro` — choose with `FURNI_BUNDLE_EXT`. New imports and repairs
|
||||||
|
* produce this extension.
|
||||||
*/
|
*/
|
||||||
export const LOCAL_BUNDLE_EXT = ".hab";
|
export const LOCAL_BUNDLE_EXT: FurnitureBundleExt = resolveLocalBundleExt();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
|
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
|
||||||
* configured clone source) keeps shipping `.nitro`, so download URLs are built
|
* configured clone source) keeps shipping `.nitro`, so download URLs are built
|
||||||
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
|
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
|
||||||
*/
|
*/
|
||||||
export const SOURCE_BUNDLE_EXT = ".nitro";
|
export const SOURCE_BUNDLE_EXT = ".nitro" as const;
|
||||||
|
|
||||||
/** Both extensions a local bundle may carry, newest convention first. */
|
/**
|
||||||
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const;
|
* Every extension a local bundle may be stored under, the configured one first
|
||||||
|
* so it always wins over a same-stem legacy file.
|
||||||
|
*/
|
||||||
|
export const LOCAL_BUNDLE_EXTS: readonly FurnitureBundleExt[] = [
|
||||||
|
LOCAL_BUNDLE_EXT,
|
||||||
|
...BUNDLE_EXT_CHOICES.filter((ext) => ext !== LOCAL_BUNDLE_EXT),
|
||||||
|
];
|
||||||
|
|
||||||
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */
|
/** Primary local bundle name — the configured extension wins over the legacy one. */
|
||||||
export function getNitroFileName(classname: string): string {
|
export function getNitroFileName(classname: string): string {
|
||||||
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
|
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
// `/imaging` and `/images` are excluded: they are raw image responses that
|
||||||
|
// carry their own long-lived Cache-Control and must not be rewritten by the
|
||||||
|
// HTML/asset cache logic. In production the edge proxy also fronts
|
||||||
|
// `/imaging` straight to the renderer, so the CMS route never sees that
|
||||||
|
// traffic; `/api/imaging/*` (avatars, badges) is NOT excluded and therefore
|
||||||
|
// still passes through the anti-DDoS gate and keeps its asset headers.
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
|
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user