1 Commits
Author SHA1 Message Date
remco c9130ecd2d chore(deps): update All dependencies
CI / check (pull_request) Successful in 24s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-03 00:00:47 +00:00
935 changed files with 19202 additions and 71824 deletions

No files matched your search

View File
Whitespace-only changes.
-24
View File
@@ -1,24 +0,0 @@
.git
.gitignore
.next
node_modules
coverage
storage
prod.log
update.log
.pm2
# Only the pnpm lockfile is used. Ignore other lockfile formats and stray
# package managers so they never taint the build context by accident.
package-lock.json
yarn.lock
bun.lockb
.npmrc.bak
# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside
# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values.
# It is not copied into the runtime image (the runner stage copies only
# .next/standalone, public/, and .next/static).
# .env
*.tsbuildinfo
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
public/nitro-assets
public/swf
+3 -12
View File
@@ -17,8 +17,6 @@ NODE_ENV=production
PORT=3002
NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16
# Production requires this kill switch plus housekeeping.preview.access.
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL
@@ -34,8 +32,9 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here=
CONVERT_PASSWORDS=true
# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
BCRYPT_COST=12
ARGON2_MEMORY_KB=65536
ARGON2_ITERATIONS=4
ARGON2_PARALLELISM=1
# --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges
@@ -73,11 +72,3 @@ PAYPAL_API=https://api-m.sandbox.paypal.com
# --- LOGGING ---
LOG_LEVEL=error
# --- FLARESOLVERR (Cloudflare bypass for clone sources) ---
FLARESOLVERR_URL=http://localhost:8191
# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials.
CATALOG_GIT_CHECKOUT=
# Persistent directory shared by CMS and worker, outside the catalog clone.
CATALOG_GIT_STATE_DIR=
-2
View File
@@ -1,2 +0,0 @@
.husky/* text eol=lf
*.sh text eol=lf
+511 -66
View File
@@ -1,84 +1,529 @@
name: CI
on:
push:
branches: [main, master]
tags: ["v*"]
branches:
- main
tags:
- "v*"
pull_request:
branches: [main, master]
branches:
- main
workflow_dispatch:
jobs:
# ─────────────────────────────────────────────
# Lint, typecheck & unit tests
# Draait op de host (self-hosted) waar Node 26 +
# pnpm 11 geïnstalleerd zijn en internet beschikbaar is.
# De job-container (docker mode) heeft GEEN outbound
# internet, dus we draaien alles direct op de host.
# ─────────────────────────────────────────────
check:
runs-on: self-hosted
if: startsWith(gitea.ref_name, 'v') == false
runs-on: shell
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Toolchain check
run: node scripts/check-node-toolchain.mjs
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Dependency security audit
run: pnpm deps:audit
- name: Lint
run: pnpm biome:lint
- name: CMS translation contracts
run: pnpm i18n:check
- name: Typecheck
run: pnpm typecheck
- name: Test
env:
SKIP_ENV_VALIDATION: 1
NODE_ENV: test
DATABASE_URL: "mysql://test:test@localhost:3306/test?charset=utf8mb4"
REDIS_URL: "redis://127.0.0.1:6379?connect_timeout=2"
AUTH_SECRET: "ci-test-secret-key-that-is-long-enough"
BCRYPT_ROUNDS: 4
- name: Typecheck, lint, and test
run: |
if [ -x /usr/bin/time ]; then
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test --maxWorkers=2
else
time pnpm test --maxWorkers=2
set -e
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
cleanup() { rm -rf "${WORK}"; }
trap cleanup EXIT
echo "--- CI checks (${WORK}) ---"
git clone --depth 50 \
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
"${WORK}"
cd "${WORK}"
REF="${{ gitea.sha }}"
if [ -z "${REF}" ]; then
echo "ERROR: missing gitea.sha" >&2
exit 1
fi
git fetch --depth 50 origin "${REF}"
git checkout -f "${REF}"
- name: Knip (unused files/exports)
run: pnpm knip
export SKIP_ENV_VALIDATION=1
export NODE_ENV=test
export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4"
export AUTH_SECRET="ci-test-secret-key-that-is-long-enough"
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
export BCRYPT_ROUNDS=4
pnpm install --frozen-lockfile
# Types come from the committed Drizzle schema (src/db/schema.ts).
pnpm biome:lint
pnpm typecheck
pnpm test
echo "--- CI checks passed ---"
# ─────────────────────────────────────────────
# Docker build & deploy
# Draait op de host (self-hosted) zodat Docker
# toegang heeft tot de daemon en volumes.
# ─────────────────────────────────────────────
deploy:
needs: check
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
runs-on: self-hosted
if: gitea.event_name == 'push' && gitea.ref_name == 'main'
runs-on: shell
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Deploy
run: |
set -e
- name: Build, deploy and smoke test
shell: bash
exec 9>/var/tmp/epic_web_control_deploy.lock
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
echo "--- Deploying ---"
LIVE="/var/www/atom-nexst"
STAGE=""
CUTOVER_STARTED=0
error_handler() {
cd /var/www/atom-nexst 2>/dev/null || cd / || true
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
# Leave the healthy current process untouched when staging fails.
# Restart only when cutover has already stopped or replaced it.
if [ "${CUTOVER_STARTED}" = "1" ]; then
if [ -d "${LIVE}/.next.prev" ]; then
echo "Rolling back .next to previous artifact..." >&2
rm -rf "${LIVE}/.next" || true
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
fi
if [ -d "${LIVE}/node_modules.prev" ]; then
echo "Rolling back node_modules to previous artifact..." >&2
rm -rf "${LIVE}/node_modules" || true
mv "${LIVE}/node_modules.prev" "${LIVE}/node_modules" || true
fi
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
fi
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
fi
exit 1
}
trap 'error_handler $LINENO' ERR
docker image prune -f
DEPLOY_USER="$(id -un)"
DEPLOY_GROUP="$(id -gn)"
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
git config --global --add safe.directory "${LIVE}"
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
echo "Fetching origin/main..."
git -C "${LIVE}" fetch origin --prune
echo "Clearing sticky git index bits (if any)..."
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
if [ -n "${STICKY_LIST}" ]; then
echo "${STICKY_LIST}" | while IFS= read -r f; do
[ -n "$f" ] || continue
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
done
fi
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
echo "APP_VERSION=${APP_VERSION}"
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
fi
cd "${STAGE}"
rm -f tsconfig.tsbuildinfo .tsbuildinfo
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
rm -rf .output dist .next .next/types .next/dev
# Restore build cache from last deploy so Turbopack can do
# incremental compilation (much faster rebuilds).
if [ -d "${LIVE}/.next/cache" ]; then
mkdir -p .next/cache
cp -r "${LIVE}/.next/cache/." .next/cache/
fi
# Stage shares MySQL with the live app + emulator. Keep the stage pool
# tiny so install/test/build cannot exhaust max_connections.
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
pnpm install --frozen-lockfile
# Types come from the committed Drizzle schema (src/db/schema.ts).
export BCRYPT_ROUNDS=4
pnpm typecheck
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
pnpm build
if [ ! -d "${STAGE}/.next" ]; then
echo "ERROR: stage build produced no .next/" >&2
exit 1
fi
echo "Migrating staged release while the current app stays online..."
cd "${STAGE}"
MIGRATE_OK=0
for i in $(seq 1 10); do
if pnpm db:migrate; then
MIGRATE_OK=1
break
fi
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
sleep 5
done
if [ "${MIGRATE_OK}" != "1" ]; then
echo "ERROR: db:migrate failed after retries" >&2
exit 1
fi
cd "${LIVE}"
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
git reset --hard origin/main
# Keep env, uploads, runtime-imported furni assets, and deps we are
# about to replace from stage. The app can write furni files while it
# remains online during staging, so cleaning those paths races with
# active imports and can fail with "Directory not empty".
git clean -fd \
-e .env -e .env.local -e .env.production -e .env*.local \
-e storage -e public/cache \
-e public/swf/dcr/hof_furni \
-e public/nitro-assets/bundled/furniture \
-e node_modules -e node_modules.prev -e .next -e .next.prev
if ! git diff --exit-code HEAD -- src >/dev/null; then
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
git diff --stat HEAD -- src >&2 || true
exit 1
fi
echo "Verified live src/ matches HEAD"
# Next.js prefers an already-set process PORT over .env. PM2 may still
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
# Export PORT before start so the process matches health checks.
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
export PORT="${DEPLOY_PORT}"
echo "PM2/health PORT=${PORT}"
free_tcp_port() {
local port="$1"
[ -n "${port}" ] || return 0
if command -v fuser >/dev/null 2>&1; then
fuser -k "${port}/tcp" 2>/dev/null || true
elif command -v lsof >/dev/null 2>&1; then
# Portable fallback when fuser is unavailable.
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
fi
}
echo "Cutover: atomically swap artifacts and restart on PORT=${PORT}..."
CUTOVER_STARTED=1
pm2 stop next --kill-timeout 10000 || true
cd "${LIVE}"
# Rename both current artifacts so cutover and rollback stay fast.
if [ -d .next ]; then
mv .next .next.prev
fi
mv "${STAGE}/.next" .next
if [ -d node_modules ]; then
mv node_modules node_modules.prev
fi
mv "${STAGE}/node_modules" node_modules
free_tcp_port "${PORT}"
free_tcp_port 3000
echo "Starting PM2 with a clean PORT=${PORT} listener..."
pm2 delete next 2>/dev/null || true
PORT="${PORT}" pm2 start pnpm --name next -- start
pm2 save 2>/dev/null || true
sleep 3
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
echo "ERROR: PM2 next failed to start!" >&2
pm2 logs next --lines 20 --nostream >&2 || true
exit 1
fi
echo "Waiting for HTTP health check on port ${PORT}..."
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
HEALTH_OK=0
for i in $(seq 1 20); do
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
if echo "${BODY}" | grep -q '"database":true'; then
echo "Health OK (${HEALTH_URL})"
HEALTH_OK=1
break
fi
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
sleep 2
done
if [ "${HEALTH_OK}" != "1" ]; then
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
echo "Last body: ${BODY:-<empty>}" >&2
echo "Listeners on PORT ${PORT}:" >&2
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
pm2 logs next --lines 40 --nostream >&2 || true
exit 1
fi
echo "Cleaning stage worktree and previous artifact backups..."
rm -rf "${LIVE}/.next.prev" "${LIVE}/node_modules.prev"
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
STAGE=""
echo "--- Deployed successfully ---"
release:
if: startsWith(gitea.ref_name, 'v')
runs-on: shell
steps:
- name: Build and deploy
env:
DEPLOY_BRANCH: ${{ gitea.ref_name }}
run: bash scripts/ci-deploy.sh
VERSION: ${{ gitea.ref_name }}
run: |
set -e
exec 2>&1
WORK="$(mktemp -d /var/tmp/epicnext-deploy.XXXXXX)"
cleanup() { rm -rf "${WORK}"; }
trap cleanup EXIT
echo "=== Deploying ${VERSION} ==="
git clone --depth 50 \
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
"${WORK}"
cd "${WORK}"
git checkout "${VERSION}"
export NODE_ENV=production
export SKIP_ENV_VALIDATION=1
pnpm install --frozen-lockfile
# Types come from the committed Drizzle schema (src/db/schema.ts).
# Tag releases must apply CMS SQL migrations against the live DB
# (same path as push-to-main deploy), using the production .env.
LIVE="/var/www/atom-nexst"
ln -sfn "${LIVE}/.env" "${WORK}/.env"
MIGRATE_OK=0
for i in $(seq 1 10); do
if pnpm db:migrate; then
MIGRATE_OK=1
break
fi
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
sleep 5
done
if [ "${MIGRATE_OK}" != "1" ]; then
echo "ERROR: db:migrate failed after retries" >&2
exit 1
fi
pnpm build
pm2 restart next --update-env
pm2 save
echo "=== Deploy complete ==="
- name: Create Release
env:
VERSION: ${{ gitea.ref_name }}
GITEA_API: ${{ gitea.api_url }}
GITEA_REPO: ${{ gitea.repository }}
run: |
set -e
exec 2>&1
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
echo "=== Creating release for ${VERSION} ==="
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
else
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
fi
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
# Pin the other components at their current commits so the release is reproducible.
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
{
echo "# EpicNext-CMS ${VERSION}"
echo ""
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Drizzle ORM. Integrates with Polaris / Arcturus Morningstar databases."
echo ""
echo "## Menu"
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
echo "- [System Requirements](#system-requirements)"
echo "- [Installation Wizard](#installation-wizard)"
echo "- [How it is used](#how-it-is-used)"
echo "- [Changes](#changes)"
echo "- [Linked repositories](#linked-repositories)"
echo ""
echo '<a id="what-is-epicnext-cms"></a>'
echo "## What is EpicNext-CMS?"
echo ""
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
echo ""
echo '<a id="system-requirements"></a>'
echo "## System Requirements"
echo ""
echo "What you need to install before running the CMS:"
echo ""
echo "| Component | Version | Notes |"
echo "| --------- | ------- | ----- |"
echo "| Node.js | >= 22 | Required by Next.js 16 |"
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
echo "| Java | 17+ | Only if building the emulator |"
echo "| Maven | 3.9+ | Only if building the emulator |"
echo ""
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
echo ""
echo '<a id="installation-wizard"></a>'
echo "## Installation Wizard"
echo ""
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
echo ""
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
echo ""
echo "### 1. Clone & Install the CMS"
echo '```bash'
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
echo "cd EpicNext-Cms"
echo "pnpm install"
echo '```'
echo ""
echo "### 2. Database Setup"
echo ""
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
echo '```sql'
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
echo '```'
echo ""
echo "### 3. Configure Environment"
echo '```bash'
echo "cp .env.example .env"
echo '```'
echo ""
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
echo ""
echo "### 4. Run CMS Migrations"
echo '```bash'
echo "pnpm db:migrate"
echo '```'
echo ""
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status. Runtime types come from the committed Drizzle schema (src/db/schema.ts) via '@/lib/db'."
echo ""
echo "### 5. Polaris Emulator"
echo ""
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
echo '```bash'
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
echo "cd /var/www/emulator/Emulator"
echo "mvn clean package"
echo '```'
echo ""
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
echo '```bash'
echo "./update-Nitrov3.sh"
echo '```'
echo ""
echo "### 6. Nitro V3 & Renderer"
echo ""
echo "Clone both Nitro repos and build the client:"
echo '```bash'
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
echo '```'
echo ""
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
echo ""
echo "### 7. Catalogus (catalog & gamedata)"
echo ""
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
echo '```bash'
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
echo '```'
echo ""
echo "### 8. Build & Start the CMS"
echo '```bash'
echo "# Development (hot reload)"
echo "pnpm dev"
echo ""
echo "# Production"
echo "pnpm build && pnpm start"
echo '```'
echo ""
echo "Open http://localhost:3000 in your browser."
echo ""
echo "### 9. First Login"
echo ""
echo "1. Register at /register, or log in with an existing emulator account."
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
echo ""
echo '<a id="how-it-is-used"></a>'
echo "## How it is used"
echo ""
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
echo ""
echo '<a id="changes"></a>'
echo "## Changes"
echo '```'
echo "${CHANGELOG}"
echo '```'
echo ""
echo '<a id="linked-repositories"></a>'
echo "## Linked repositories (exact commits)"
echo ""
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
echo ""
echo "| Component | Repository | Commit |"
echo "|-----------|------------|--------|"
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
echo ""
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
echo ""
echo "---"
echo "*Automated release from Gitea Actions*"
} > /tmp/release-body.md
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")"
if [ "${HTTP_CODE}" = "409" ]; then
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
-H "Authorization: token ${TOKEN}")"
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")"
fi
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
echo "SUCCESS: Release ${VERSION} created/updated"
cat /tmp/release-resp.json | jq -r '.html_url // .id'
else
echo "FAILED HTTP ${HTTP_CODE}"
cat /tmp/release-resp.json
exit 1
fi
+2 -5
View File
@@ -6,12 +6,8 @@ on:
jobs:
renovate:
runs-on: ubuntu-latest
runs-on: shell
steps:
- name: Fix Git safe directory
run: "git config --global --add safe.directory '*' && git remote set-url origin https://epicnabbo.nl || true"
- name: Install Bash in Alpine
run: "if [ -f /etc/alpine-release ]; then apk add --no-cache bash; fi"
- name: Self-hosted Renovate
run: |
set -e
@@ -25,6 +21,7 @@ jobs:
-e RENOVATE_AUTODISCOVER=false \
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
-e RENOVATE_ONBOARDING=false \
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
-e LOG_LEVEL=info \
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
ghcr.io/renovatebot/renovate:latest
-10
View File
@@ -1,9 +1,7 @@
node_modules/
node_modules.prev/
.turbo/
.next/
.next.prev/
.next-staging/
next-env.d.ts
.env
*.tsbuildinfo
@@ -33,11 +31,3 @@ public/tmp/
# Test coverage reports
coverage/
# Playwright test artifacts
test-results/
playwright-report/
blob-report/
.aider*
/public/vendor/tinymce/
-1
View File
@@ -1 +0,0 @@
strict-peer-dependencies=false
+1 -1
View File
@@ -1 +1 @@
26.8.1
22
-73
View File
@@ -1,73 +0,0 @@
# Catalog Studio repository export
Studio mutations automatically queue an export to
`https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git`, branch
`Beta-3`. The jobs worker processes pending exports every minute. Streaming
imports stay active until their stream completes. Server actions for catalog
pages, offers, deletion and maintenance are covered as well.
## Server setup
1. Create a **dedicated clean clone** of the repository on `Beta-3`, outside the
CMS directory. Configure non-interactive Git push authentication for the
worker OS account using its credential helper. Do not put tokens in URLs.
2. Set `CATALOG_GIT_CHECKOUT` to that absolute clone directory in the CMS and
worker environments. Set `CATALOG_GIT_STATE_DIR` to a persistent, writable
directory outside the clone, shared by both processes on the same host.
3. Run `pnpm jobs:worker` alongside the CMS under your process supervisor.
Both processes must have access to the configured asset directories and DB.
The worker command enables the React server condition for server-only modules.
4. Restart the CMS after setting the environment. In **Studio → Sync**, use
**Export now / retry** for the initial export. Subsequent mutations queue
automatically. Status shows pending/active operations and the last commit.
Leaving `CATALOG_GIT_CHECKOUT` empty disables export. No credentials are shipped.
This source change alone does not configure or deploy the production service.
## Exported content
| Source | Repository destination |
| --- | --- |
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
| Configured furni icons | `Gamedata/icons` |
| Existing badge/catalog images | `Gamedata/c_images` |
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
Import `items_base.sql`, then `catalog_pages.sql`, then `catalog_items.sql`.
Existing schemas are not migrated by these dumps. Rows absent from the source
are omitted; importing an upsert dump into another existing database does not
delete that database's extra rows. No user, session or credential tables are exported.
Only existing local assets are exported. Translation generation follows Studio's
existing setting; export does not generate missing languages or download assets.
Public JSON is explicitly allowlisted so translation caches and private runtime
files cannot enter the repository. Invalid JSON or concurrent Studio changes
prevent publication of that snapshot.
## Failure and concurrency behavior
- Pending events survive process restarts; events added during publication remain
pending for the next run. Partial imports are exported as their settled local
state, including successful items from a batch containing failures.
- A single filesystem lock serializes the worker. Dead local process markers are
recovered on the next run. For an unreadable marker or a marker from another
host, stop the CMS and worker before repairing the queue directory.
- A failed push retains the local commit and pending events for retry. Concurrent
upstream commits are rebased; a conflict aborts the rebase and leaves the event
pending. Resolve conflicts in the dedicated clone, then retry.
- The checkout must be clean before each run. Unrelated files are preserved and
no force push is used. Missing local files do not cause remote deletions.
- Status errors omit raw Git output to avoid exposing authentication material.
## Verification
Run the `catalog-git-*` service tests and `src/lib/catalog-export-api.test.ts` with
Vitest. The integration test creates a temporary bare remote and verifies push,
failed-push recovery, no-op export and preservation of unrelated files. SQL
snapshot tests mock the database; production DB import and production push need
verification in the deployed environment.
-30
View File
@@ -1,30 +0,0 @@
# CMS translation audit and editor
The CMS editor at `/admin/translations/cms` now uses the same bundled catalogs as the request-time translator. Runtime changes are stored separately in `storage/cms-translations/<locale>.json`, inside the existing persistent `/app/storage` mount. No source-file write, environment-variable change or rebuild is required to apply an edit.
Only overrides are saved. Unchanged messages continue to receive updates from Git. Saves use a file lock, an atomic replacement and a revision check; a stale editor cannot overwrite another operator's changes. ICU syntax, argument names, rich-text tags and allowed keys are checked server-side. Invalid or obsolete overrides are excluded when reading a new release. Storage read errors are reported to the CMS error monitor and public pages fall back to bundled text.
The page starts in the operator's language. It shows all English reference keys, including missing translations, and supports search by key, translated text or English source. Filters separate missing, identical and modified text. Drafts survive language switches and failed saves. Users without `SETTINGS_EDIT` can review and export but cannot save.
## Audit outcome, 6 September 2026
- Scanned 25 JSON catalogs; 22 languages are selectable. The small Arabic, Finnish and Japanese catalogs are legacy files and remain outside the supported locale list.
- Repaired 66 malformed ICU messages across the 22 active catalogs, including HTML fragments and unescaped JSON examples.
- Added 199 missing English reference keys used by page components, with Italian translations, and fixed the incorrect navigation namespace in the admin error page.
- Completed the 31 previously missing Italian reference keys.
- Repaired missing `count` and `preset` variables in other locales.
- Final checks: zero malformed messages, zero argument/tag mismatches and zero missing references among the statically resolved translation calls.
- English contains 3,423 reference keys. Italian covers all of them; 629 values match English. Dutch is missing 524 reference keys and has 618 identical values. Matching English can be intentional for names and technical labels; this is not proof of translation quality.
- Found 1,577 literal JSX text candidates outside translation calls. These include labels, technical strings and names; they are an editorial inventory, not 1,577 confirmed bugs. The largest concentrations are the catalog item table (118), Studio main component (79), import audit (53), sound management (50) and permission editor (42).
## Repeatable checks
- `pnpm i18n:check`: fails on malformed messages, incompatible variables/tags, empty messages, source parsing errors or missing statically referenced keys. Runs in Gitea CI.
- `pnpm i18n:audit`: prints coverage and findings.
- `node scripts/audit-cms-translations.mjs --json`: full machine-readable inventory, including file and line references for literal JSX candidates.
Static analysis resolves literal translator namespaces and literal message keys. Dynamic key construction, prose embedded in arbitrary JavaScript strings, and the linguistic accuracy of all 22 translations still require targeted review. English fallback remains explicit; copying English into other catalogs would hide untranslated entries and is intentionally avoided.
## Validation
Automated tests exercise message syntax, actual translator output, persistent overrides, invalid-message rejection, revision conflicts and reset behavior. A browser fixture mounts the real editor and verifies missing-key editing, validation, failed-save preservation, language switching, successful saves, read-only access and mobile layout. Server actions are simulated in that fixture; authenticated production editing requires a staff session.
-48
View File
@@ -1,48 +0,0 @@
# syntax=docker/dockerfile:1
# node:alpine = latest Node within the supported LTS major (tracks the newest
# patch automatically; currently v26.x, which satisfies package.json's
# engines ">=26.8.1 <27").
FROM node:alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
# entirely (there is no .git in the build context). Defaults to "unknown".
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# pnpm install is always pinned to the version in package.json's
# `packageManager` field (pnpm auto-selects it on install), so this global
# install only needs to exist as a bootstrap and follows the active major.
RUN apk add --no-cache git \
&& npm install -g pnpm@latest
# 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
COPY . .
RUN pnpm run build
FROM node:alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3002 \
HOSTNAME=0.0.0.0
RUN apk add --no-cache tini \
&& 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
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3002
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
# health; docker-compose overrides this with its own probe if needed.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
+267 -502
View File
@@ -1,4 +1,4 @@
# EpicNext-CMS v1.0.3
# EpicNext-CMS v1.0.1
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
@@ -10,13 +10,12 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- |
| Node.js | 26.8.1 | Current release pinned in `.nvmrc` |
| pnpm | >= 11.25.0 | Recommended package manager |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| Node.js | >= 22 | Required by Next.js 16 |
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
| Redis | 7.x+ | Optional — caching, rate limiting, SSE |
| Java | 17+ | Required only if building the emulator |
| Maven | 3.9+ | Required only if building the emulator |
---
@@ -24,28 +23,12 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
### 1. Clone & Install
Choose your preferred package manager:
```bash
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
```
**pnpm (recommended):**
```bash
pnpm install
```
**npm:**
```bash
npm install
```
**yarn:**
```bash
yarn install
```
### 2. Database Setup
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
@@ -56,7 +39,7 @@ CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/`.
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/` (`pnpm db:migrate`).
### 3. Configure Environment
@@ -70,240 +53,14 @@ Edit `.env` with at minimum:
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
AUTH_SECRET=<random 32+ character string>
HOTEL_NAME=YourHotel
APP_URL=http://localhost:3002
APP_URL=http://localhost:3000
```
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
### 4. Run CMS Migrations
### 4. ORM Setup & Type Generation
```bash
# pnpm
pnpm db:migrate
# npm
npm run db:migrate
# yarn
yarn db:migrate
```
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
### 5. Build & Start
```bash
# Development (hot reload)
pnpm dev # or: npm run dev / yarn dev
# Production
pnpm build && pnpm start # or: npm run build && npm start
```
Open `http://localhost:3002` in your browser.
### 6. First Login
1. Register an account at `/register`, or log in with an existing emulator account.
2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';`
3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**.
---
## Docker Deployment
The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. The **CMS** runs in Docker; the Nitro/Octane client stays outside and is served by nginx on the host. The server-side **avatar imager** also runs in its own Docker container (`avatar-imaging-pixinode`, port 8082) — see [Avatar Imaging](#avatar-imaging).
### Prerequisites
- Docker 24+ and Docker Compose v2
- `.env` file configured (see step 3 above)
- A MySQL/MariaDB database reachable from the container (`DATABASE_URL` host should point to the DB server, not `127.0.0.1` unless it's reachable from inside the container)
- (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below)
### Quick Start with Docker
```bash
# 1. Clone and configure
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
cp .env.example .env
# Edit .env with your database credentials and settings.
# The container runs on the host network, so all 127.0.0.1 references
# (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is.
# 2. Stop any existing host-side CMS that occupies port 3002 (if present).
pm2 stop next 2>/dev/null || true
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
# container user can write imports/uploads to them.
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
# 4. Build and start
docker compose up -d --build
# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
# on the HOST (against the same database) before/after starting the container.
pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
# 6. Check logs
docker compose logs -f cms
```
The CMS will be available at `http://localhost:3002`.
> **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL.
### Automatic Updates
Updating is fully scripted — no need to touch Docker or nginx by hand. The
script `scripts/docker-update.sh` pulls the latest `main`, runs CMS migrations
on the host, rebuilds the image, recreates the container and waits for a healthy
status. It also makes sure a stale host-side PM2 CMS (`pm2 stop next`) stays
stopped so it can't clash on port 3002.
**Automatically (recommended):** a nightly cron job is already configured on a
production server that installed this setup. It runs the script every night at
03:30 and appends to `logs/docker-update.cron.log`:
```bash
crontab -e
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
```
**Manually** — to update right now (same steps as the cron runs):
```bash
cd /var/www/atom-nexst
./scripts/docker-update.sh
# log: logs/docker-update.log
```
The script aborts safely (exit 1) if the working tree has uncommitted changes so
a `git pull` can never clobber local edits, and leaves the container running if
health fails so you can debug it (exit 3). Failed runs are reported in the log;
an exit of 0 means the CMS is healthy on the new commit.
### Docker Commands
| Command | Description |
| ------------------------------------------ | ------------------------------------ |
| `docker compose up -d --build` | Build and start in background |
| `docker compose down` | Stop and remove containers |
| `docker compose logs -f cms` | Follow CMS logs |
| `docker compose exec cms sh` | Open a shell in the CMS container |
| `docker compose restart cms` | Restart the CMS container |
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` bulk-load container |
### How Package Manager Detection Works
The Dockerfile checks for lockfiles in this order:
1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended)
2. **`yarn.lock`** → uses yarn
3. **`package-lock.json`** → uses npm
4. **No lockfile** → falls back to `npm install`
This means you can use any package manager on your host machine — the Docker build will automatically match.
> Override the detection explicitly with `docker compose build --build-arg PACKAGE_MANAGER=pnpm` (or `npm` / `yarn`).
### Volumes
Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
| Container Path | Host Path | Mode | Purpose |
| -------------------------- | -------------------------- | ---- | ------------------------------------ |
| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets |
| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) |
| `/app/storage` | `./storage` | rw | Uploaded media (persistent) |
| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)|
**About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory.
**The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly:
| Component | Host path | How it's served |
| -------------------- | ----------------------- | ---------------------------------------------------------------------- |
| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
| Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) |
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
```bash
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
```
If your host user UID differs, override the run user at build time (defaults to `www-data`, which already exists in the base image with UID/GID 33):
```bash
docker compose build --build-arg RUN_USER=1000
```
### Networking
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), DragonflyDB/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
### Health Check
The container includes a health check that hits `/api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:
```bash
docker inspect --format='{{.State.Health.Status}}' epicnext-cms
```
### MariaDB Turbo (bulk loads)
`docker-compose.yml` ships an opt-in **`mariadb-turbo`** service — a dedicated MariaDB 11 container tuned for loading >50 MB JSON dumps (furnidata, external texts, etc.). It configures itself via mysqld flags on the `command:`, so no external `.cnf` file has to be mounted or kept in sync:
```bash
pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo)
```
Key settings (see the `command:` block in `docker-compose.yml`):
- `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling.
- `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes.
- `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`.
- `net_read_timeout` / `net_write_timeout = 600`, `wait_timeout = 3600` — fixes the `drizzle-kit` **"Pulling schema from database..."** hang by never starving introspection/DDL sessions behind a long import.
- `performance_schema = OFF` — saves ~1-2 GB RAM.
The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bind-mount: shared-filesystem sync trashes InnoDB files the same way it corrupts pnpm's `node_modules`. Named volumes stay inside the container filesystem, so 50 MB of JSON writes never cross a host-sync boundary.
> **Port note:** the service uses `network_mode: host` and binds `127.0.0.1:3306` — stop the host MariaDB first, otherwise the port collides with the standalone `.env` `DATABASE_URL` (`localhost:3306`).
#### node_modules & host-sync corruption
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
#### Loading a 50 MB JSON dump
```bash
# Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes)
pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json
# Flat array of documents → generic JSON store
pnpm db:bulk --file=/data/products.json --table=docs --category=furni
# Key/value texts
pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default
```
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design.
---
## ORM Setup & Type Generation
### Drizzle ORM (Primary Data Layer)
#### Drizzle ORM (Primary Data Layer)
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
@@ -319,276 +76,277 @@ const found = await db.select()
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
| Command | What it does |
| ---------------------- | ---------------------------------------------------------------------- |
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
| Command | What it does |
| ------- | ------------ |
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema names + live DB |
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
#### Bulk JSON storage (MariaDB)
Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.ts` for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time.
Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern:
- The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`).
- The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed.
- Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer.
Three tables are exported:
| Table | Purpose | Unique key |
| -------------------- | ---------------------------------------------- | --------------------------- |
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
| Flag | Default | Description |
| -------------------- | ----------- | ------------------------------------------------- |
| `--file=<path>` | — (required)| JSON file (habbo furnidata_json or flat array) |
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
| `--limit=N` | `0` | Stop after N rows (dry-test) |
| `--truncate` | off | DELETE rows for this source/category first |
The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending.
---
## DragonflyDB (Caching, Rate Limiting, SSE)
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-process memory.
### Install (Ubuntu/Debian)
### 5. Run CMS Migrations
```bash
curl -fsSL -o /tmp/dragonfly_amd64.deb \
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
apt-get install -y /tmp/dragonfly_amd64.deb
systemctl enable --now dragonfly
pnpm db:migrate
```
### Configure
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
Edit `/etc/dragonfly/dragonfly.conf`:
Check migration status:
```ini
--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false
```bash
pnpm db:migrate:status
```
### Point the CMS at it
### 6. Build & Start
```dotenv
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
```bash
# Development (hot reload)
pnpm dev
# Production
pnpm build && pnpm start
```
Verify via `/api/health` — it should report `"redis": true`.
Open `http://localhost:3000` in your browser.
### 7. First Login
1. Register an account at `/register`, or log in with an existing emulator account.
2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';`
3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**.
---
## Nginx Configuration
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration.
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
### Prerequisites
- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt)
- CMS running on `127.0.0.1:3002`
- Next.js running on `127.0.0.1:3000` (default) or your configured port
- Habbo imager (optional) running on `127.0.0.1:3030`
### Reference Configuration
```nginx
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`:
```nginx
# ==========================================
# GLOBAL SETTINGS
# ==========================================
server_tokens off;
gzip on;
gzip_vary on;
gzip_proxied off;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css text/javascript application/json
application/javascript application/xml application/xml+rss
image/svg+xml font/opentype font/ttf font/woff font/woff2;
# ==========================================
# REDIRECT HTTP → HTTPS
# ==========================================
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
location /.well-known/acme-challenge/ {
root /var/www/epicnext/public;
}
location / {
return 301 https://$host$request_uri;
}
}
# ==========================================
# MAIN HTTPS SERVER
# ==========================================
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name yourdomain.com;
server_name yourdomain.com www.yourdomain.com;
root /var/www/epicnext/public;
index index.html;
# SSL Certificates
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20m;
client_body_timeout 30s;
client_header_timeout 10s;
keepalive_timeout 15s;
send_timeout 10s;
# Shared Proxy Settings
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache html_cache;
proxy_cache_valid 200 60s;
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status always;
# ------------------------------------------
# Static Files
# ------------------------------------------
location ^~ /nitro-client/ {
alias /var/www/Nitro-V3/dist/;
expires 7d;
add_header Cache-Control "public";
access_log off;
}
location /api/ {
proxy_pass http://127.0.0.1:3002;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ------------------------------------------
# Next.js Assets (immutable, long cache)
# ------------------------------------------
location /_next/static/ {
proxy_pass http://127.0.0.1:3002;
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /imaging/ {
proxy_pass http://127.0.0.1:8082/;
proxy_set_header Connection "";
proxy_http_version 1.1;
add_header Cache-Control "public, max-age=300";
location /_next/data/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# ------------------------------------------
# API Routes (never cached)
# ------------------------------------------
location /api/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ------------------------------------------
# Habbo Imager (optional)
# ------------------------------------------
# Proxies to a Docker container that renders Habbo avatars.
# The imager caches renders to disk, so a long s-maxage is safe.
location /imaging {
proxy_pass http://127.0.0.1:3030;
add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
}
# ------------------------------------------
# WebSocket (Radio / SSE)
# ------------------------------------------
location /ws {
proxy_pass http://127.0.0.1:3030;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
# ------------------------------------------
# Main Page Proxy (with HTML caching)
# ------------------------------------------
# The CMS middleware sets:
# Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous)
# Cache-Control: private, no-store (authenticated)
#
# nginx caches anonymous responses and serves them directly, bypassing
# the Node.js process entirely. Authenticated responses are never cached.
#
# proxy_cache_valid: cache 200 responses for 60 seconds
# proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
# next-router-*, Accept-Encoding) that would fragment the cache key.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_http_version 1.1;
proxy_buffering on;
proxy_cache html_cache;
proxy_cache_valid 200 60s;
proxy_cache_key "$host$request_uri";
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_revalidate on;
add_header X-Cache-Status $upstream_cache_status always;
}
# ------------------------------------------
# Health Check
# ------------------------------------------
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
# Block hidden files
location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
deny all;
access_log off;
log_not_found off;
}
}
```
> **`/imaging` trailing-slash gotcha:** `location /imaging` (no trailing slash) combined with `proxy_pass http://…8082/;` rewrites `/imaging/avatarimage` into `//avatarimage` and 404s. Always use `location /imaging/` + a trailing-slash `proxy_pass` so `/imaging/avatarimage` reaches the imager's `/avatarimage` route.
### HTML Caching
---
The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means:
## Avatar Imaging
- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
- **Authenticated visitors** always hit Node.js (personalized content).
- The cache is **auto-invalidated** after 60 seconds and revalidates in the background.
Avatar images (profile figures, chat badges, forum avatars, admin previews) are rendered server-side by **Polaris-imager** ([duckietm/Polaris-imager](https://github.com/duckietm/Polaris-imager)) — a headless Nitro/Octane renderer that runs `@pixi/node` (real WebGL via `gl` + Xvfb) inside a Docker container named **`avatar-imaging-pixinode`**. It serves `GET /avatarimage?figure=…` on port **8082** and can emit PNG/APNG/GIF, gestures, actions, scenes and more.
The proxy cache zone is defined in the `http` block (above any `server` block):
### Request flow
```
browser → Cloudflare → (page rule /origin for /imaging/*) → host:8082
browser → nginx origin → location /imaging/ → 127.0.0.1:8082/avatarimage
```nginx
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
```
URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&img_format=png&size=l&direction=4`. The public `NEXT_PUBLIC_IMAGER_URL` points at that endpoint (see `src/lib/imager.ts`). Responses carry `Cache-Control: public, max-age=300`, so browsers and Cloudflare reuse a render for 5 minutes.
### Requirements (host)
- 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` (`.nitro` assets) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
### Configuration — `/docker/Polaris-imager/.env`
```dotenv
NITRO_GAMEDATA_URL=http://host.docker.internal:8081/gamedata/config
NITRO_ASSET_URL=http://host.docker.internal:8081/gamedata/bundled
AVATAR_IMAGING_FPS=12
AVATAR_IMAGING_MAX_FRAMES=60
AVATAR_IMAGING_HOST=0.0.0.0
AVATAR_IMAGING_PORT=8082
AVATAR_IMAGING_RATELIMIT_MAX=600
AVATAR_IMAGING_SCENE=1
AVATAR_IMAGING_WARDROBE=1
AVATAR_IMAGING_HABBO_FONTS=1
AVATAR_IMAGING_CHAT_BUBBLES=1
AVATAR_IMAGING_HOTEL_URL=https://<hotel>
AVATAR_IMAGING_HOTEL_NAME=<hotel>
```
### Build & start
The image is self-contained — the `Dockerfile` clones the pinned Nitro/Octane renderer and runs the full `yarn`/`vite` bundle at build time, then produces a slim Debian runtime with Xvfb + Mesa software rendering:
Verify caching works by checking the `X-Cache-Status` response header:
```bash
cd /docker/Polaris-imager
docker compose build
docker compose up -d # container: avatar-imaging-pixinode
docker logs -f avatar-imaging-pixinode # expect: "listening on http://0.0.0.0:8082"
# First request (MISS = fetched from Node.js, now cached)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: MISS
# Second request (HIT = served from nginx cache)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: HIT
```
The container runs `restart: unless-stopped` and ships a healthcheck that probes `/health` (reports `{ "ready": true }` once the headless renderer has booted). Verify a render:
### Key Points
```bash
curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l'
```
A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which takes the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`).
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens.
---
## Performance Tuning (production)
The CMS shares its MySQL/MariaDB database with the emulator, so the hot wins come from caching, asset serving and database knob-tuning — not from a storage-engine migration.
### 1. gzip_static pre-compression of gamedata JSON
The client downloads `FurnitureData.json` (~48 MB, one file per supported language) plus other large JSON from `/gamedata/`. nginx has `gzip` and `gzip_static on` (see `/etc/nginx/nginx.conf`), but with no pre-built `.gz` file it re-compressed the 48 MB **on every request** (~0.25–0.30 s of CPU per miss).
`scripts/compress-gamedata.mjs` pre-compresses every JSON >1 MB in `<Gamedata>/config` and `<Gamedata>/bundled/config` once (gzip level 9): `FurnitureData*.json` go from ~48 MB to ~2.4 MB (~95 % smaller). It is idempotent — a file is only re-compressed when the source mtime is newer than its `.gz` (e.g. after a Studio catalog export).
```bash
pnpm gamedata:compress # manual run
```
Once the `.gz` exists, nginx `gzip_static` serves it straight from disk with `Content-Encoding: gzip` and effectively zero CPU. **Measured result on the production origin: 0.25–0.30 s → ~0.005 s per file.**
The production host runs the script on a cron so newly exported catalogs are compressed shortly after they land:
```bash
crontab -e
# */15 * * * * node /var/www/atom-nexst/scripts/compress-gamedata.mjs >> /var/www/atom-nexst/logs/gamedata-compress.log 2>&1
```
### 2. MariaDB tuning
The whole `habbo` datadir is roughly 0.5 GB; the default InnoDB buffer pool (128 MB) pushed index/row reads to disk. The pool is raised to **4 GB** and the slow-query log is enabled (threshold 2 s), both live (`SET GLOBAL …`) and persisted for boot in `/etc/mysql/mariadb.conf.d/zz-cms-performance.cnf`:
```ini
[mysqld]
innodb_buffer_pool_size = 4G
slow_query_log = 1
long_query_time = 2
slow_query_log_file = /var/log/mysql/mariadb-slow.log
```
The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, `connection_limit` in `DATABASE_URL`).
For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-turbo`** variant is available — see [MariaDB Turbo](#mariadb-turbo-bulk-loads).
### 3. Asset caching headers
| Path | Cache-Control |
| ------------------------------ | -------------------------------------------------------------- |
| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` |
| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) |
| `/imaging/*` | `public, max-age=300` |
| `/camera/**` | `public, max-age=31536000, immutable` |
| `/_next/static/**` | `public, max-age=31536000, immutable` |
### 4. Cache layers (Redis + CDN)
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (DragonflyDB-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
| Setting | Value | Why |
| ------- | ----- | --- |
| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer |
| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses |
| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache |
| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance |
| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages |
---
@@ -609,31 +367,30 @@ pnpm build
pm2 restart next
```
The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via `/api/media/*` and survive rebuilds.
---
## Scripts
| Command | Description |
| ------------------------- | -------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build |
| `pnpm start` | Start production server |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| Command | Description |
| ---------------------- | -------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build |
| `pnpm start` | Start production server |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm jobs:worker` | Start background task worker |
| `pnpm biome:check` | Lint and format code |
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
| `pnpm biome:check` | Lint and format code |
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
**Drizzle Kit notes:** `db:generate` / `db:introspect` write drafts only. Reviewed SQL must be copied into `drizzle/migrations/` as a new numbered file, then applied with `pnpm db:migrate`. Never run `drizzle-kit push` or `drizzle-kit migrate` against production.
---
@@ -643,14 +400,16 @@ pm2 restart next
| ------------------------------ | -------------------------------------------------------------- |
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
| **View Transitions API** | Native browser transitions between page navigations |
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
| **DragonflyDB Caching** | Caches API responses up to 30s in DragonflyDB |
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
| **CSS-based Animations** | `input-glow`, `btn-shine`, `Reveal` scroll animations, floating orbs |
| **Compression** | Gzip compression enabled on all responses |
| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU |
| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
@@ -664,14 +423,12 @@ pm2 restart next
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── bulk-import-json.ts # 50 MB+ JSON importer (2000-row chunks, UPSERT)
│ ├── jobs-worker.ts # Background task scheduler
│ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
│ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static)
│ ├── merge-config.cjs # Utility: merge split config files
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ ├── schema-gamedata.ts # Large-JSON storage schema (furnidata/docs/texts)
│ │ └── relations.ts # Drizzle relations
│ ├── app/ # Next.js App Router (pages & API routes)
│ ├── actions/ # Server Actions
@@ -680,20 +437,38 @@ pm2 restart next
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── db.ts # Drizzle connection singleton (runtime)
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
│ │ └── motion.ts # Framer Motion animation variants
│ │ ├── cached-db.ts # Redis-backed query cache helpers
│ │ ├── redis.ts # Redis client (ioredis)
│ │ ├── redis-cache.ts # Redis caching utility for API routes
│ │ ├── cache.ts # In-memory cache fallback
│ │ ├── motion.ts # Framer Motion animation variants
│ │ └── use-event-source.ts # React hook for SSE subscriptions
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
│ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker)
│ └── swf/ # SWF client files (volume-mounted in Docker)
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Multi-stage, multi-package-manager Docker build
│ └── scripts/ # Client-side scripts (theme-init.js)
├── update-Nitrov3.sh # Emulator & Nitro updater utility
├── next.config.ts # Next.js configuration
└── .env.example # Environment template with all options
```
### Database Ownership
| Component | Type | Migrations |
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` |
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
### ORM Architecture
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. Schema lives in `src/db/schema.ts`.
- **Schema regeneration**: `pnpm db:schema:generate` reuses field/table names from the previous `src/db/schema.ts` and refreshes column types from the live DB.
- **Drizzle Kit**: studio / generate / introspect for local tooling; CMS apply path remains `pnpm db:migrate`.
Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
---
## Optional Integrations
@@ -720,36 +495,25 @@ The radio player uses SSE for real-time updates (10s interval, no polling).
Run as a persistent process:
```bash
pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
pnpm jobs:worker
```
### AI Content Moderation
Scheduled tasks:
- **Daily 03:00** — Emulator JAR backup (requires `EMULATOR_JAR_PATH` + `EMULATOR_BACKUP_DIR`)
- **Daily 04:00** — Cleanup login logs (>30 days) and expired password reset tokens (>7 days)
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
### CAPTCHA
### FlareSolverr (Cloudflare Bypass)
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
```bash
docker compose up -d flaresolverr
```
```dotenv
FLARESOLVERR_URL=http://localhost:8191
```
### Furniture Import with Auto-Translation
The CMS automatically translates furniture names and descriptions to **13 languages** on every import:
- **Native languages** (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
- **Additional languages** (via LibreTranslate Docker at `127.0.0.1:5000`): Portuguese, Finnish, Polish, Russian, Arabic, Japanese
Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
### Theming
12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`).
### AI Content Moderation
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
---
## Development
@@ -765,10 +529,11 @@ pnpm biome:check # Lint and format
### Contributing
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
2. Follow existing code conventions (Server Components where possible)
3. SQL migrations in `drizzle/migrations/` must be idempotent
4. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`)
5. Avoid `any` — use `biome-ignore` comments only when unavoidable
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
3. Use the `src/lib/motion.ts` animation variants for consistent animations
4. SQL migrations in `drizzle/migrations/` must be idempotent
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
---
File diff suppressed because it is too large. Load diff
@@ -1,205 +0,0 @@
[
{
"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"
}
]
-177
View File
@@ -1,177 +0,0 @@
services:
cms:
build:
context: .
dockerfile: Dockerfile
# The host disables Docker iptables (daemon.json: "iptables": false), so
# build containers on the bridge network have no outbound NAT/DNS. Build on
# the host network instead so pnpm/npm/yarn can reach the registry.
network: host
container_name: epicnext-cms
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
# and the imaging renderer (8082). The container then listens directly on the
# host's 3002 (the same port the current host-side CMS uses).
# NOTE: stop the host CMS (next-server on 3002) first, otherwise the port is taken.
network_mode: host
restart: unless-stopped
env_file:
- .env
environment:
- HOSTNAME=0.0.0.0
volumes:
# ── Write targets (runtime imports/uploads, persistent on the host) ──
# The CMS writes imported furni/figures/pets/effects here (see
# src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by
# UID/GID 33 (www-data) on the host so the container user can write to them:
# sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
- ./public/nitro-assets:/app/public/nitro-assets
- ./public/swf:/app/public/swf
# Runtime uploaded media (persistent on the host).
- ./storage:/app/storage
# ── Shared gamedata (absolute path the CMS hardcodes & writes to) ──
# src/lib/services/furni-asset-dirs.ts: `DEFAULT_GAMEDATA_ROOT =
# /var/www/Gamedata`. nginx on the host also serves /gamedata/ from this
# same directory, so mount it into the container at the same absolute path.
# Must be RW so furniture/badge imports can write mirrors to it.
- /var/www/Gamedata:/var/www/Gamedata
#
# ── node_modules corruption (§ host-sync) ──
# pnpm node_modules must NEVER be a host bind-mount: shared-filesystem
# sync (Docker Desktop gRPC-FUSE/VirtioFS, Unison, Syncthing) corrupts
# pnpm's hardlinked store and .bin shims. The production image already
# bakes node_modules in at build time and is NOT bind-mounted here.
# For local dev use a *named volume* instead of a host bind:
# - node_modules:/app/node_modules
# and never share `node_modules` / `pnpm store` via the Docker bind.
# On macOS add `:cached`/`:delegated` consistency labels per mount.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
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.
# Disabled by default — uncomment to run the renderer as a container.
# imager:
# build:
# context: /var/www/Octane-Renderer
# container_name: epicnext-octane-renderer
# ports:
# - "3030:3030"
# restart: unless-stopped
# volumes:
# - /var/www/Gamedata:/var/www/Gamedata
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
# Dedicated MariaDB tuned for >50 MB JSON imports. All tuning lives inline
# in the service `command:` (bulk_insert_buffer_size,
# innodb_flush_log_at_trx_commit=2, max_allowed_packet=512M, ...) so the
# container configures itself — no external .cnf to mount or keep in sync.
# Opt-in via profile so `docker compose up` keeps running the standalone
# stack as today.
#
# Start: docker compose --profile db up -d (= pnpm db:up)
# Note: host networking binds 127.0.0.1:3306 → STOP the host MariaDB
# first (the app's .env DATABASE_URL points at localhost:3306).
# Fixes the "Pulling schema from database..." hang: raise
# net_read/net_write_timeout (done above) + stop imports while
# running `pnpm db:generate` / drizzle-kit introspection.
mariadb-turbo:
image: mariadb:11
container_name: mariadb-turbo
profiles: ["db"]
network_mode: host
restart: unless-stopped
environment:
# mariadb:11 uses MARIADB_*; MYSQL_* also works but is deprecated there.
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
- MARIADB_USER=${MARIADB_USER:-cms}
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
- MARIADB_AUTO_UPGRADE=1
# MariaDB tunes itself — the Official image appends these flags to mysqld,
# no external .cnf file needed.
command: [
# Charset
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
# 50 MB JSON batches: raise the 16 MB default packet ceiling.
"--max-allowed-packet=512M",
"--net-buffer-length=1M",
# Timeouts — fixes the "Pulling schema from database..." hang
# (drizzle-kit introspection no longer starves behind big imports).
"--connect-timeout=30",
"--wait-timeout=3600",
"--interactive-timeout=3600",
"--net-read-timeout=600",
"--net-write-timeout=600",
# InnoDB: durability/performance trade-off for bulk writes.
"--innodb-flush-log-at-trx-commit=2",
"--innodb-buffer-pool-size=2G",
"--innodb-buffer-pool-instances=4",
"--innodb-log-file-size=1G",
"--innodb-log-buffer-size=64M",
"--innodb-flush-method=O_DIRECT",
"--innodb-autoextend-increment=512",
"--innodb-max-dirty-pages-pct=90",
"--bulk-insert-buffer-size=512M",
# Raise so the engine nearly never double-writes during a 50 MB load.
"--innodb-doublewrite=0",
# libaio/native_aio misbehaves in some containers; io_uring path is fine.
"--innodb-use-native-aio=0",
# Temp tables used when MariaDB scans JSON blobs cannot be indexed.
"--tmp-table-size=256M",
"--max-heap-table-size=256M",
"--read-buffer-size=4M",
"--read-rnd-buffer-size=16M",
# Save ~1-2 GB RAM; bulk loads don't need the instrumentation.
"--performance-schema=OFF",
"--skip-name-resolve",
]
volumes:
# Named volume, NOT a host bind — shared-filesystem sync trashes InnoDB
# files the same way it trashes pnpm's node_modules. Named volumes live
# inside the container filesystem, so 50 MB of JSON writes never cross a
# host-sync boundary.
- mariadb-turbo-data:/var/lib/mysql
healthcheck:
# healthcheck.sh ships in the official mariadb image.
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
mem_limit: 4g
# Named volumes declared once; used by the MariaDB service above.
volumes:
mariadb-turbo-data:
driver: local
-118
View File
@@ -1,118 +0,0 @@
# Refactor del catalogo HK — analisi e programma
Data: 6 settembre 2026. Base verificata: main, commit 9b0ea2fb. Documento di proposta, non implementazione approvata. Il sito /admin/catalog reindirizza al login senza sessione staff: nessuna prova delle mutazioni sul database di produzione. Le criticità indicate sono percorsi verificati nel codice; gli effetti concorrenti richiedono riproduzione controllata.
## Obiettivo e perimetro
Un catalogo HK con un solo ambiente di lavoro, regole coerenti e operazioni recuperabili. Conservare stile HK, icone reali, salvataggio diretto, catalogo normale e Builder Club. Nessun ritorno dei Preferiti.
Inclusi: albero, categorie, offerte, prezzi, bundle, disponibilità, proprietà condivise dei furni, traduzioni, ricerca, anteprima, operazioni massive, manutenzione, collegamenti a Catalog Studio e sincronizzazione Git/hotel.
Catalog Studio conserva la responsabilità di importare, convertire e riparare .nitro, icone e furnidata. Il refactor collega questi strumenti alla selezione del catalogo; non comporta riscrivere il convertitore, cambiare protocollo dell'emulatore o sostituire il sistema Git/Gitea già esistente.
## Inventario verificato
Sono già presenti virtualizzazione dell'albero, trascinamento, multiselezione, griglia/tabella, editor dei prezzi, anteprima negozio, import massivo, traduzioni, manutenzione e coda di export Git. Vanno riutilizzati.
| File | Righe attuali | Responsabilità da separare |
|---|---:|---|
| src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx | 2342 | Rendering, selezione, editor offerta/furno, prezzi massivi, drag and drop, dialoghi |
| src/components/admin/catalog-manager/sortable-tree.tsx | 1135 | Lettura albero, mutazioni, ricerca, trascinamento, comandi |
| src/components/admin/catalog-manager/inline-editor.tsx | 775 | Fetch, stato modifiche, schede, salvataggio, anteprima |
| src/app/admin/catalog/[id]/catalog-page-form.tsx | 738 | Campi, layout, media, salvataggio |
| src/app/admin/catalog/[id]/catalog-translate-tab.tsx | 617 | Selezione, proposta traduzioni, applicazione |
| src/app/admin/catalog/builder-club/bc-manager.tsx | 608 | Gestione parallela BC |
| src/app/admin/catalog/page.tsx | 526 | Query, conteggi, varianti normale/BC, composizione UI |
Le dimensioni aiutano a trovare i punti di intervento: l'obiettivo non è un limite arbitrario di righe, ma responsabilità verificabili e riutilizzabili.
## Problemi e interventi
| Priorità | Evidenza | Intervento |
|---|---|---|
| P0 | sortable-tree.tsx invia il riordino dei fratelli con Promise.allSettled, una action per riga; catalog.ts aggiorna RCON per ciascuna | Un comando batch con lista completa, validazione, transazione e un solo evento di aggiornamento |
| P0 | catalog-items.ts riordina le offerte con update sequenziali fuori transazione | Stesso contratto atomico per l'ordine delle offerte |
| P0 | updateCatalogPage accetta parentId direttamente; il controllo cicli è separato in movePage | Validazione comune per creazione, form, spostamento e API; controllare destinazioni inesistenti e concorrenza |
| P0 | deletePage normale sposta figli, elimina offerte e pagina separatamente | Transazione, analisi dell'impatto e snapshot ripristinabile |
| P0 | updateCatalogItem modifica pagina, offerta e items_base con scritture separate | Transazione e verifica che il furno appartenga all'offerta; scope distinto per proprietà condivise |
| P1 | cascadeDelete non mantiene un insieme di nodi visitati; il calcolo profondità BC è ricorsivo senza guardia ai cicli | Lettura tollerante di dati incoerenti, diagnostica e arresto sicuro delle traversate |
| P1 | handleEditTab modifica lo stato prima della conferma; chiusura X bypassa la protezione | Un unico controllo delle modifiche per cambio pagina, offerta, scheda, uscita e navigazione |
| P1 | loadPage/loadItemsData non annullano o identificano la richiesta precedente | AbortController e identità della selezione; solo la risposta corrente può aggiornare l'editor |
| P1 | Il salvataggio ignora il booleano restituito da RCON; Git opera in coda | Distinguere DB salvato, invio hotel riuscito/fallito e stato Git; retry senza risalvare i dati |
| P1 | loadCatalogItemsData usa Number(value) || fallback per order_number, offer_id e amount | Definire semantica di zero/null per campo e testare il round trip prima di cambiare i fallback |
| P2 | Tutte le offerte e metadati sono caricati insieme; filtro con CAST(page_id AS CHAR) | Misurare query/payload, separare elenco e dettagli, paginazione e adapter compatibile INT/VARCHAR |
| P2 | Editor normale/BC e form condividono solo parte delle regole; testi anche letterali | Contratti comuni, differenze BC esplicite, traduzioni e permessi coerenti |
Riferimenti principali: src/actions/catalog.ts, src/actions/catalog-items.ts, src/actions/catalog-bc.ts, src/lib/services/catalog-tree.ts, src/lib/services/catalog-items-loader.ts, src/app/api/admin/catalog/tree/route.ts, src/components/admin/catalog-manager/catalog-manager-dialog.tsx, src/components/admin/catalog-manager/inline-editor.tsx.
## Alternative
1. **Pulizia dei file mantenendo tutti gli editor:** rischio iniziale basso, ma conserva duplicazioni e differenze operative. Utile solo come passaggio iniziale.
2. **Refactor progressivo con un editor principale — consigliato:** servizi comuni prima, poi promozione del Visual Manager a pagina. Permette piccoli rilasci e confronti tra vecchio e nuovo percorso.
3. **Riscrittura completa:** libertà maggiore, ma più rischio di perdere casi speciali, compatibilità DB e funzioni già presenti. Non giustificata dall'inventario attuale.
## Architettura proposta
Modulo src/features/catalog con confini chiari:
- domain/: tipi Page, Offer, FurnitureReference, CatalogKind; validazione gerarchie, prezzi, bundle e disponibilità; nessuna dipendenza React/DB.
- server/queries/: letture albero, elenco offerte, dettaglio e ricerca; output serializzabile esplicito.
- server/commands/: create/update/move/reorder/delete; autorizzazione, validazione, transazioni, controllo revisione e audit.
- server/repositories/: accesso Drizzle e compatibilità delle colonne; adapter normale/BC senza fingere che tutti i campi coincidano.
- client/: stato selezione, modifiche locali, operazioni in corso, caricamento e gestione conflitti.
- components/: albero, elenco offerte, editor categoria, editor offerta, dettagli furno, diagnostica, stato sincronizzazione.
Le route e le action attuali rimangono inizialmente adapter sottili. Un unico risultato di operazione include ID operazione, revisione, elementi modificati, eventuali errori di campo e stato sincronizzazione. Non introdurre nuove librerie prima di verificare i limiti degli strumenti già installati.
Flusso di scrittura: permesso → validazione → verifica revisione → transazione DB con audit → risposta di salvataggio → aggiornamento hotel/export Git. La durabilità del passaggio DB→coda va garantita con un evento persistito nella transazione o meccanismo equivalente verificato. Un fallimento Git/RCON non deve far ripetere una creazione già committata. Riutilizzare il worker e la coda esistenti, aggiungendo idempotenza dove manca.
## UX proposta
Pagina /admin/catalog con barra: Normale/BC, ricerca, nuova categoria, aggiungi furni, stato operazioni. Sotto: categorie a sinistra, offerte al centro, dettagli a destra. Il pannello dettagli si richiude; su schermi piccoli diventa una vista dedicata. Un solo scorrimento per ciascuna area, azioni di salvataggio sempre raggiungibili.
La URL conserva catalogo, categoria, offerta, vista e ricerca; i campi non salvati restano nello stato locale. Indietro/avanti e ricaricamento devono riaprire il contesto corretto. I vecchi URL dei dettagli continuano a funzionare.
Tre oggetti riconoscibili:
- Categoria: percorso, titolo, icona, layout, visibilità e requisiti.
- Offerta: prezzo, valuta, quantità, componenti bundle, disponibilità e ordine.
- Furno condiviso: classname, sprite, dimensioni e interazioni; mostrare quante offerte lo referenziano prima di una modifica globale.
Idee operative:
- Ricerca trasversale per nome, classname, ID pagina/offerta/furno e sprite ID, con percorso nei risultati.
- Selettore visuale di categoria e layout; proprietà tecniche nelle Avanzate.
- Prezzi con icone reali delle valute; mostrare il prima/dopo delle operazioni massive, arrotondamenti ed elementi esclusi.
- Multiselezione con riepilogo di spostamento/eliminazione; dopo un errore mantenere selezionati i falliti.
- Anteprima del negozio già esistente integrata nel contesto; non presentarla come prova completa del comportamento del client hotel.
- Diagnostica su richiesta: offerta, SQL, furnidata, Nitro e icona separati. Collegamento a Catalog Studio sul furno esatto; nessuna scansione pesante a ogni apertura.
- Storico di chi/cosa/quando con differenze e ripristino. Il ripristino controlla revisioni successive: non sovrascrive in silenzio modifiche di altri operatori e non annulla acquisti già avvenuti.
- Riepilogo visibile: salvato, invio hotel, Git. Gli errori hanno riferimento al monitor CMS.
- Stati vuoti, errori, caricamento e sola lettura distinti; traduzioni complete e uso da tastiera.
## Programma di lavoro e criteri di uscita
| Lotto | Consegna | Criterio per proseguire |
|---|---|---|
| 1. Baseline | Matrice funzioni/route/permessi normale e BC; fixture con bundle, LTD, offerte speciali, zeri/null, alberi incoerenti; misure query e rete | Tutti i flussi esistenti hanno una destinazione nel piano, senza omissioni |
| 2. Integrità | Validatori, transazioni di riordino/spostamento/eliminazione, gerarchie sicure, revisioni | Un fallimento intermedio non lascia dati parziali; due operatori non si sovrascrivono |
| 3. Servizi condivisi | Query/command/repository e risultato comune; vecchie route come adapter | Vecchie UI superano le stesse prove con il nuovo backend |
| 4. Stato editor | Unica gestione delle modifiche, richieste annullabili, risposta coerente con selezione | Annullare l'uscita conserva tutto; cambi rapidi mostrano sempre l'ultima selezione |
| 5. Pagina unificata | Visual Manager nella pagina, griglia/tabella condivise, URL, layout adattivo | Parità normale/BC e vecchi link conservati; niente perdita di scroll o azioni nascoste |
| 6. Operazioni avanzate | Ricerca, editor bundle, prezzi massivi con differenze, storico e diagnosi contestuale | Gli effetti sono spiegati prima dell'applicazione; retry applica solo ciò che manca |
| 7. Sincronizzazione | Stato DB/hotel/Git, operazioni persistenti, retry/idempotenza | Guasto dopo commit e riavvio worker non duplicano né perdono l'operazione |
| 8. Prestazioni e rimozione duplicati | Paginazione, caricamento progressivo, accessibilità, eliminazione vecchi componenti | Confronto misurato e prove finali; nessuna route o funzione rimasta senza equivalente |
I lotti 2 e 7 condividono il contratto delle operazioni: progettare subito evento persistente e idempotenza, anche se la UI di stato arriva dopo. Nessuna stima in giorni finché non sono note dimensioni reali del catalogo, varianti DB e casi speciali attivi. Ogni lotto può richiedere più PR piccole; niente sostituzione monolitica.
## Verifica e rilascio
Test unitari delle regole; integrazione su MariaDB per rollback, concorrenza e varianti INT/VARCHAR; browser con permessi lettura/modifica, desktop e schermo ridotto. Simulare doppio submit, timeout, risposta fuori ordine, fallimento RCON, Git non raggiungibile, riavvio dopo commit. Conservare test e componenti esistenti finché la parità non è dimostrata.
Registrare baseline e risultati per categorie grandi/piccole: richieste per riordino, tempo DB, payload, tempo fino a editor utilizzabile, risposte fallite. Non promettere percentuali senza dati.
Rilascio progressivo con selezione reversibile del nuovo editor. Il ritorno alla UI precedente deve usare gli stessi servizi corretti. Migrazioni additive e compatibili; il rollback dell'app non deve richiedere la cancellazione di dati. Eliminare le vecchie UI solo dopo parità verificata. Confermare CI, deploy, health e prove staff prima di dichiarare risolto il flusso live.
## Primo passo consigliato
Lotti 1 e 2: inventario di compatibilità e correzione delle operazioni a rischio, mantenendo inizialmente l'aspetto corrente. Poi estrarre i servizi e unificare l'editor. È la sequenza che permette di migliorare UX senza portare avanti gli stessi difetti dentro una nuova schermata.
-65
View File
@@ -1,65 +0,0 @@
# CMS error center and dependency maintenance
Open **HK > DevOps > CMS error center** (`/admin/devops/cms-errors`).
The previous `/admin/devops/errors` page still displays emulator errors.
## Access and workflow
- Read: existing `admin.devops.view` permission, checked on the server.
- Mark resolved: `admin.devops.edit`, checked again in the server action; recorded in the staff audit log.
- Search by event reference, Next.js digest, route, message or deployment version.
- Expand a group for its latest stack, sanitized context and occurrence references.
- Marking a group resolved does not delete evidence. A later occurrence reopens it.
- Refresh is manual so inspecting an expanded error is not interrupted by polling.
## What is collected
Pino `logger.error`, `logServerError`, unexpected action errors, Next.js request
failures, React error boundaries, uncaught browser errors and unhandled browser
promise rejections. API wrapper failures return an `errorId`; Next.js boundary
errors can be correlated by their digest. Release identifiers come from the build.
Browser reports retain their own client release separately from the receiving server.
Reports are stored in `storage/cms-errors`, using the existing persistent storage
mount. No third-party service, token or new database table is required.
Storage does not depend on database availability; viewing the HK and its permission
checks still require authentication/database availability. Server console logs
remain the fallback when the CMS itself cannot serve requests.
Retention: seven days; approximately 10 MB/day, 100 queued server writes, and the
latest 1,000 events in the viewer. Limits are per CMS process/storage volume;
this is intended for the existing single-instance deployment. Daily expiry runs
on the next write. The browser endpoint is same-origin, body-size bounded and
rate-limited. Browser reports are untrusted observations and cannot grant access.
Known credential patterns and URL parameters are redacted; arbitrary object
metadata and request bodies are excluded. Avoid putting personal data in error
messages: this is pattern-based redaction, not a universal data-loss filter.
This does not collect historical console logs, process crashes before framework
startup, nginx failures, all `console.error` calls, or every handled business
validation error. A browser stack may point at minified chunks; private source-map
symbolication and distributed traces are not part of this local viewer. A recorded
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 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
license notices to ignored `public/vendor/tinymce`; dev/build run this first.
The Docker build includes the generated assets. The editor retains its existing
HTML fields and toolbar; validate saved content and preview when upgrading it.
Lenis has been removed; the public site now uses native scrolling. Other used
runtime libraries remain. Vitest and its coverage provider are upgraded together;
`clearMocks: false` preserves initialization-time permission contract assertions.
Renovate uses separate development/UI/Vitest groups with manual merge and a
three-day release age. The existing external Gitea bot configuration is retained.
Node/pnpm upgrades remain coordinated with Docker and the runner toolchain.
Obsolete global overrides were removed; a scoped esbuild override remains because
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
Kit; replacing the ORM is not warranted for this tooling issue.
File diff suppressed because it is too large. Load diff
@@ -1,21 +0,0 @@
# Catalog operations upgrade plan
User approved bulk editing and complete category duplication, keeping Visual Manager in its button-opened modal.
Use subagent-driven-development for the independent duplication task; root owns bulk operations and final review.
- [x] Duplicate category subtree and offers atomically for normal/BC; preview counts, destination/name, fresh IDs, preserve furniture references; fail on stale source, invalid destination, cycles and insert failure. Shared permissions, single export/RCON outcome. UI in Visual Manager.
- [x] Extend selected-offer operations to preview and atomically apply prices, currency, and destination. Visibility belongs to categories, so expose it separately with exact affected categories; no fictitious per-offer flag. Preserve unselected and unrelated fields; conflicts prevent overwriting concurrent changes.
- [x] Meaningful domain/transaction/action tests, browser fixtures for modal preview/confirm, typecheck/Biome/i18n/Knip; review limits and commit exact scope.
No production data mutations, added dependencies or schema migrations. Full category duplication shares existing items_base definitions and asset files. Repeated confirmations must be disabled while saving. Preview should be revalidated under locks before write. Existing direct save and permissions remain.
## Implementation notes
Bulk editing now works from selected normal-catalog offers and from selection across global searches. Only explicitly chosen prices/currency/destination fields change. Prices support set/add/percentage with integer rounding and range validation; 500 selected offers per transaction. Preview binds rows, changes and category names; concurrent changes reject confirmation. Category visibility remains on the existing category controls, not a fabricated offer property. BC has no offer pricing and does not expose that editor.
Duplication copies up to 500 categories and 5000 offers, preserving bundle and asset references. New root starts disabled and hidden. Includes and internal offer IDs are remapped. Explicit normal offer IDs use the existing allocator and do not require AUTO_INCREMENT; database collisions roll back the whole copy. Preview binds destination siblings as well as source data so confirmed preview replay is rejected. Dirty editor state blocks copying saved data until drafts are saved/reset.
No database migration or dependency added. Database transaction tests use mocks and do not prove live MariaDB locking behavior. Browser fixtures use real components with mocked actions, not production writes. Existing allocator is process-local; competing external imports may cause a safe rollback requiring a fresh preview. No automatic retry of uncertain copy commits.
Verification complete: 1339 unit tests passed, 5 skipped. Typecheck/Knip/i18n and changed-file Biome checked. Real-component browser fixtures passed bulk selection, preview, conflicts, pending guards, mobile bounds and table refresh without phantom drafts; duplication nested inside actual manager verified menus, picker, preview and dirty-state guards. Fixed manager portal layering and invalid menu-label nesting uncovered by those tests. Browser actions are mocked; no live data written or deployment claimed.
@@ -1,29 +0,0 @@
# Catalog refactor implementation plan
> For agentic workers: use superpowers:subagent-driven-development for independent changes and review each deliverable.
Goal: deliver the approved progressive catalog refactor while preserving current features.
Architecture: shared domain validation and transactional commands behind existing action contracts; unified editor reuses current views and tools.
Stack: Next, React, Drizzle/MariaDB, Zod, Vitest, Playwright; no added dependencies.
Spec: docs/CATALOG_REFACTOR_PLAN.md
Constraints: preserve normal/BC differences, direct save, real icons, permissions, existing routes, no favorites. No production data mutations for testing.
- [x] Characterize compatibility and command rules with tests; capture baseline source map.
- [x] Domain hierarchy/reorder validation and transactional page commands (normal/BC), deterministic locking, common updates/deletion.
- [x] Offer update/reorder atomicity and safe numeric normalization, shared mutation contracts.
- [x] Editor cancellation/dirty state protection (agent catalog_editor_state), review and browser verification.
- [x] Promote editor as an in-page view with reversible classic view, URL context, responsive panels and coherent tool access.
- [ ] Sync outcomes/diagnostics/history integration; safe retry and explicit limits.
- [ ] Verify unit tests, database integration if runtime available, browser, typecheck, lint/i18n, full suite; review final scope and remaining environment-only checks.
Execution notes: changes are progressive, no destructive migration. Existing UI adapters stay until functional parity is tested. Whole-program completion must not be claimed from the first deliverable.
## First implementation delivery (2026-09-06)
Completed: shared normal/BC page and offer commands; transactional page reorder/delete/move and offer create/update/reorder; hierarchy validation and optimistic page-save checks; embedded default manager with classic views retained; request cancellation, editor unsaved-change guards and URL selection; bounded global category/offer/furniture search; separate hotel/Git status and hotel retry; export-finalization failures no longer mask committed server actions. Existing permissions and direct-save behavior retained. No added dependency or DB migration.
Verification: full unit suite 1308 passed / 5 skipped before final retry-classification regression; final focused catalog suite 96 passed. TypeScript, i18n static validation, Knip and Biome on all 54 changed source files pass. Browser fixtures cover editor loading races, retry failures, unsaved changes, URL history, read-only, normal/BC, search and 375px layout. Database calls and mutations are mocked in those fixtures. Full-repository formatter check reports pre-existing Windows CRLF formatting differences; unrelated files were not reformatted.
Remaining roadmap: per-operation durable dispatch/outbox, category/offer snapshot history and conflict-aware undo, contextual maintenance diagnosis, pagination/performance measurement against representative real data, further decomposition of retained legacy views. Existing coarse audit/export infrastructure remains; the new status file is not a durable outbox and RCON socket success does not prove client application. External furni importer mutation internals remain outside shared editor commands.
Environment checks still required: real MariaDB locking/rollback integration, staff-authenticated end-to-end smoke test and pipeline/deployment health. No production mutations performed; no production deployment claimed.
@@ -1,69 +0,0 @@
# Public Avatar Thumbnail and Currency Icon Design
## Goal
Make avatar and currency presentation consistent across the public site:
- user thumbnails in lists and compact cards show only the avatar head at a fixed 40 x 40 pixel size;
- full-body avatars remain available on profile pages and deliberately large previews;
- currency amounts use the existing graphical currency icons instead of placeholder letters such as `c`, `cr`, `du`, or `di`.
## Scope
The change covers public-facing pages and shared public components. It includes rankings, leaderboards, shop and badge-purchase currency rows, plus every other compact user list or card that currently renders an avatar directly.
Admin-only screens are outside this visual cleanup unless they reuse a shared public component changed by this work. Profile hero avatars, the main current-user avatar, registration/login previews, and other intentionally large previews retain their full-avatar presentation.
## Avatar Design
Compact user representations will use one shared semantic thumbnail path rather than choosing imager options independently in each page.
The thumbnail contract is:
- request `headOnly: true` from the avatar imager;
- render at 40 x 40 CSS and image dimensions;
- preserve pixel-art rendering and contain the image without stretching;
- prevent the thumbnail container from shrinking into adjacent text;
- use the user's actual figure and the existing avatar URL fallback behavior;
- keep useful alternative text based on the displayed username where that context is available.
The existing shared avatar component will be extended with an explicit compact/head-thumbnail variant, or a narrowly focused wrapper will be added if that keeps call sites clearer. Public list and compact-card call sites will migrate to this shared contract. Large/profile call sites will remain explicit so they cannot be accidentally cropped by a global CSS rule.
## Currency Design
All public currency amount rows will use the existing `CurrencyIcon` component and the existing assets under `public/assets/images/icons/currency`.
The mapping is:
- credits: `credits.png`;
- duckets: `duckets.png`;
- diamonds/crystals: `diamonds.png`.
Icons will be decorative when the surrounding UI already names the currency, using an empty alternative text to avoid repeated screen-reader announcements. The numeric amount and existing pill/layout styling remain unchanged. Icon size will be fixed consistently for compact amount rows, with no textual placeholder left visible.
## Migration Strategy
1. Add the shared compact avatar contract and focused tests.
2. Inventory public avatar call sites and classify each as compact thumbnail or large/profile preview.
3. Migrate every compact call site to the shared head-only 40 x 40 rendering.
4. Replace textual and empty CSS currency markers in public amount rows with `CurrencyIcon` and the correct currency kind.
5. Remove CSS rules that exist only to draw obsolete letter-based or background-only markers, while retaining layout classes still used by the amount pills.
This semantic migration is preferred over a global CSS crop because it sends the correct head-only request to the imager and does not risk changing profile avatars.
## Verification
Verification will include:
- automated tests for the compact avatar contract (`headOnly`, fixed dimensions, actual figure propagation);
- source/component checks ensuring public currency rows use `CurrencyIcon` with the correct mapping and no placeholder letters remain;
- the existing test, type-check, and build commands relevant to the changed files;
- visual checks at desktop and narrow widths for rankings, leaderboard, shop, badge purchase, and representative user lists/cards;
- explicit checks that profile pages and large avatar previews still render full avatars.
## Non-goals
- changing balances or currency business logic;
- changing the avatar imager service itself;
- redesigning profile hero sections;
- modifying admin-only layouts that do not share the affected public components.
@@ -1,438 +0,0 @@
# Housekeeping modernization design
Date: 2026-08-24
Status: approved in design review; awaiting review of this written specification
## Purpose
Replace the current administration experience with one coherent, role-adaptive Housekeeping (HK) at `/admin`.
The new HK is a modular part of the existing Next.js application. It is built in parallel, validated against the current system, and exposed with one atomic cutover. It unifies the current `/admin` and `/mod` surfaces, removes duplicated workflows, and preserves reliable domain services without automatically preserving their current pages.
This document is the master architecture for the program. It is deliberately not one giant implementation plan. Delivery is split into independently specified and verified subprojects, beginning with **Inventory & Foundation**.
## Current-state findings
- The repository currently contains 124 `page.tsx` files below `src/app/admin` and 13 below `src/app/mod`: 137 administration pages in total.
- `src/lib/admin-nav.ts` currently exposes nine navigation groups and seven hub definitions.
- `/admin` and `/mod` provide overlapping moderation, ticket, ban, team, and user workflows with separate shells.
- `/admin/housekeeping` is a legacy permission archive/comparison/export surface, while `/admin/permissions` is the live permission-management surface.
- The current dashboard reports useful counts but is not an operational work queue.
- Page composition, localization, ACL checks, filtering, error handling, and action feedback are not yet uniform across the administration surface.
The migration must therefore classify every current page. A visual refresh without workflow and boundary changes is insufficient.
## Approved decisions
| Area | Decision |
| --- | --- |
| Audience | One role-adaptive HK. Effective capabilities, not rank names alone, determine what an operator sees and can do. |
| Entry point | `/admin` is the only administration entry point after cutover. `/mod` is removed. |
| Layout | Command Deck: compact domain rail, contextual navigation, global command palette, operational workspace. |
| Personalization | Hybrid: the system supplies mandatory capability-derived content; the operator may pin and reorder allowed shortcuts and optional widgets. |
| Compatibility | Clean break. Old subroute compatibility and legacy UX are not preserved through redirects. |
| Build strategy | Build the new HK in parallel, keep it unavailable to normal production operators, then switch atomically. |
| Work queue | “Da fare ora” is derived from existing sources. It is not a second task database and never owns workflow state. |
| Command palette | It navigates, searches entities, and executes only safe commands. Sensitive actions open a dedicated contextual flow. |
| Architecture | Modular hybrid replacement inside the current application: reuse sound services, rebuild weak UI/workflows, merge duplicates, and remove obsolete surfaces. |
## Goals
1. Give each operator one clear, capability-appropriate place to work.
2. Replace feature sprawl with six stable domains and consistent page contracts.
3. Make urgent work visible without copying or diverging from source workflow state.
4. Enforce authorization, validation, transaction boundaries, error semantics, and audit behavior server-side.
5. Remove `/mod`, the legacy HK archive page, duplicate hubs, and manual navigation concepts that the new foundation owns.
6. Reach explicit functional, authorization, audit, localization, accessibility, and data-parity gates before cutover.
7. Keep rollback practical without exposing a mixed legacy/new experience.
## Non-goals
- Creating a separate HK application, microservice, or deployment.
- Creating a new assignment/task system for the operational inbox.
- Preserving every current page, route, component, or interaction.
- Adding backward-compatible redirects for removed administration subroutes.
- Providing full sensitive-workflow parity on phones. The target is desktop-first with usable tablet layouts.
- Redesigning public CMS or game-client experiences as part of this program.
- Replacing sound domain logic solely for architectural uniformity.
## Architecture
### Modular monolith
The HK remains inside EpicNext CMS and uses the application's existing authentication, database, service, localization, and deployment infrastructure.
The target source organization separates composition from behavior:
```text
src/app/admin/ route composition only
src/features/housekeeping/
foundation/ shell, registry, ACL context, preferences
domains/
operations/ derived inbox, global search, recent work
people/
content/
economy/
hotel/
system/
src/lib/services/ existing and extracted domain services
```
The exact filenames are an implementation-plan concern, but the boundaries are mandatory:
- App Router files compose pages and bind route parameters; they do not own business rules.
- The foundation owns cross-cutting HK behavior and does not mutate domain data.
- Each domain owns its queries, commands, search providers, inbox providers, widgets, and page composition.
- Domains do not import another domain's UI internals. Cross-domain interaction uses registered contracts or links to the owning route.
- Existing reliable services are adapted behind domain contracts rather than copied into the new UI.
### Module manifest and registry
Every domain exports a manifest with stable identifiers for:
- domain metadata and localized labels;
- routes and contextual navigation;
- required capabilities;
- command-palette entries;
- entity-search providers;
- derived-inbox sources;
- mandatory and optional dashboard widgets.
The foundation composes these manifests into the rail, contextual navigation, palette, dashboard, and route metadata. Contract tests reject duplicate IDs, duplicate routes, missing localization keys, unknown capability slugs, and commands without an owner.
The manifest registry replaces hand-maintained duplication between the sidebar, hubs, search, and dashboards. It is code-owned and reviewable. Operator preferences can alter presentation only within what the registry and capability context permit.
### Capability context
The server creates one request-scoped capability context from the authenticated operator and the existing ACL source.
- Capability checks are based on effective permission slugs.
- Super-administrator behavior remains explicit and testable.
- Rank may help choose default presentation, but never grants access by itself.
- Navigation filtering is a usability feature, not an authorization boundary.
- Every query and command rechecks its capability on the server and defaults to deny.
## Functional domains
| Domain | Owns | Representative current areas |
| --- | --- | --- |
| Da fare & operations | Derived inbox, global search, recent work, favorites, operational summaries | Dashboard, selected alerts and cross-domain counts; projections only |
| People & community | Users, online state, accounts, guilds, applications, staff directory, moderation, support | Users, multi-accounts, guilds, applications, CFH, moderation actions, bans, IP/VPN, word filter, tickets, help tickets, `/mod/*` |
| Content & engagement | Public/editorial content and engagement workflows | Articles, photos, media, banners, ads, events, polls, help content, tags, prefixes, writable boxes, email content, branding/localization surfaces |
| Economy & catalog | Products, value, commercial assets, and economic history | Catalog, items, import/maintenance, shop, marketplace, transactions, vouchers, subscriptions, rare values, badges, achievements, sounds |
| Hotel & world | Live hotel surfaces and world-management tools | Rooms, navigator, radio, studio/runtime asset tools, contextual hotel actions |
| System, access & observability | Configuration, authorization, diagnostics, and privileged operations | Permissions, access audit, settings, maintenance, emulator, command center, logs, analytics, alerts, DevOps |
Where an existing feature spans two domains, responsibility follows the action rather than the old route. For example, the staff directory belongs to People, while the policy granting staff capabilities belongs to System and Access.
Domain landing pages summarize their own workflows. They do not recreate the global dashboard or become a second source of state.
## Operator experience
### Command Deck shell
The shared shell contains:
1. A compact rail for the six domains.
2. Contextual navigation generated from the active domain manifest.
3. A global command/search field available by keyboard.
4. A main workspace using consistent title, context, primary action, filters, content, and feedback regions.
5. Operator identity, effective-capability context, notifications, and session controls.
The shell is desktop-first, fully keyboard operable, and responsive for tablets. Phone layouts may support inspection and low-risk triage, but sensitive multi-step operations are not optimized for phone use.
### Adaptive dashboard
ACL and capability data determine:
- visible domains and routes;
- mandatory queues and warnings;
- permitted metrics and widgets;
- available commands and search providers.
The operator may:
- pin allowed routes and safe commands;
- reorder shortcuts and optional widgets;
- add or remove optional allowed widgets;
- persist preferred filters and presentation density where supported.
The operator may not hide mandatory warnings, reveal unauthorized data, or preserve a shortcut after its required capability is lost.
Preferences are server-persisted, user-scoped, schema-versioned, and non-authoritative. If no suitable existing preference store exists, the foundation adds one additive `housekeeping_user_preferences` store containing presentation state only. It never stores task status or authorization decisions. Every preference is reconciled with the current manifest and capability context when read.
### Standard page contract
Every target page follows the same structural contract:
- localized title, description, breadcrumb/context, and one clear primary action;
- capability-derived actions with server authorization;
- shared filtering, pagination, empty, loading, partial, and error states;
- explicit unsaved-change behavior for editable forms;
- consistent confirmation and outcome feedback;
- stable deep links to owned entities and workflows;
- responsive table-to-detail behavior without hiding critical fields;
- audit context for mutations.
## Operational inbox
The inbox is a read model over domain-owned sources such as tickets, CFH reports, alerts, emulator errors, and detected anomalies.
Each source emits normalized work items containing at least:
- stable source and item IDs;
- domain and required capability;
- severity and source timestamp;
- localized summary and optional context;
- stable destination route and entity target;
- deduplication key;
- freshness/availability metadata.
The aggregator:
1. Requests sources independently with bounded timeouts.
2. Filters every result against the operator's capability context.
3. Deduplicates by stable source identity.
4. Orders by severity, age, and domain policy.
5. Returns both items and per-source availability.
The aggregator never creates, assigns, dismisses, or completes work. Selecting an item opens the owning workflow. If that workflow supports assignment or resolution, those state changes occur there.
A failed or timed-out source does not erase successful sources. The UI labels the missing source and the freshness of remaining data instead of presenting the whole system as healthy.
## Global search and command palette
The palette has three provider types:
1. **Navigation providers** for permitted routes and favorites.
2. **Entity providers** for capability-filtered entities such as users, rooms, tickets, articles, or catalog entries.
3. **Safe command providers** for narrowly scoped, validated, idempotent or reversible actions.
A mutation may run directly from the palette only when it is single-target, low impact, reviewable in the palette, protected by a specific capability, and safe against duplicate submission. It still uses the normal server command and audit path.
Destructive, economic, moderation, permission, bulk, or otherwise sensitive actions return a navigation intent. The target page receives validated context and shows impact, current state, required reason, confirmation, and final outcome.
## Data and command flow
### Queries
```text
page or shell
-> request-scoped capability context
-> typed domain query
-> existing API/repository through an adapter
-> sanitized response
```
The UI does not query arbitrary tables or reproduce sensitive filter rules. Authorization-sensitive results are filtered at the query boundary. Short-lived caching may be used for operational counts, but authorization is applied after cache lookup and sensitive per-user results are not shared across capability contexts.
### Commands
```text
intent
-> server capability check
-> schema validation
-> current-state/concurrency check
-> domain transaction or controlled external call
-> audit outcome
-> typed result and cache invalidation
```
Every command receives a server-issued action ID used as an idempotency key. Duplicate submissions return the original known outcome rather than repeating the mutation.
For records with a revision or update timestamp, edits use optimistic concurrency. A stale edit returns a conflict result and current-state reference; it is not silently overwritten. Where a source cannot expose a revision, the command performs the strongest available transactional re-read before mutation.
## Security and audit
- Default-deny server checks protect every query and command.
- Sensitive actions require a dedicated flow, an explicit target, an impact summary, confirmation, and a non-empty operator reason.
- Domain validation occurs after authorization and before mutation.
- Audit is append-only from the HK application: no HK route can edit or delete audit events.
- Audit records include actor, target, command, reason, sanitized before/after details where appropriate, outcome, timestamp, action ID, and correlation ID.
- Secrets, credentials, tokens, and unnecessary personal data are excluded from audit payloads.
- When data and audit share a transactional store, a privileged mutation and its audit record commit together.
- For external operations, an intent/pending audit record is written before dispatch and completed with success or failure afterward.
- A privileged mutation fails closed if its required audit trail cannot be established.
## Error model
Domain boundaries return typed outcomes rather than leaking raw infrastructure errors:
- validation failure;
- authentication required;
- capability denied;
- not found;
- stale/conflicting state;
- dependency unavailable;
- partial aggregate result;
- unexpected internal failure.
Expected outcomes have localized, actionable messages. Unexpected failures expose a correlation ID to the operator and retain technical detail only in server logs. Forms preserve safe input after recoverable failures. Lists and the operational dashboard distinguish empty results from unavailable data.
## Migration inventory
The first subproject creates a committed migration matrix covering all 137 current pages. Each row contains:
- legacy path and source surface (`admin` or `mod`);
- target domain and owning workflow;
- target path;
- decision: `REHOST`, `REBUILD`, `MERGE`, or `REMOVE`;
- required read and mutation capabilities;
- source queries and mutations;
- audit requirement;
- localization and accessibility status;
- required unit, integration, and E2E coverage;
- parity evidence and migration status.
Decision meanings:
- **REHOST**: the current UI and service are sound enough to enter the new shell after contract and ACL adaptation.
- **REBUILD**: preserve the workflow and sound service logic, but reconstruct its interaction and page composition.
- **MERGE**: combine duplicated routes or variants into one owning workflow with contextual views.
- **REMOVE**: eliminate obsolete or foundation-owned behavior at cutover.
Mandatory consolidations:
- All 13 `/mod` pages merge into People and Community workflows. `/mod` does not redirect after cutover.
- `/admin/housekeeping` ceases to exist as a named feature. Useful comparison/export history moves into System, Access, and Audit.
- `/admin/permissions` remains the live policy editor under System and Access.
- Legacy dashboard, hub, and manual HK-navigation concepts are removed when their responsibilities are supplied by the registry and Command Deck.
No page is considered migrated merely because it renders in the new shell. Its matrix row closes only after data, actions, capability behavior, audit, localization, accessibility, and required tests pass.
## Delivery decomposition
This master design controls the program. For delivery purposes it is also the approved design specification for subproject 01. Subprojects 02 through 06 require their own scoped design specifications before their implementation plans. Subprojects are delivered in this order:
### 01. Inventory & Foundation
This is the first and only scope of the initial implementation plan.
Deliverables:
- the complete 137-page migration matrix;
- HK manifest contracts and registry validation;
- request-scoped capability context and server guard interfaces;
- domain query, command, search, inbox, and widget contracts;
- the Command Deck shell primitives and standard page-state contract;
- six domain manifests with no migrated business workflow yet;
- a non-production/test-only entry mechanism that cannot expose a mixed HK to normal production operators;
- contract, capability, localization-key, accessibility-smoke, and shell tests.
Explicit exclusions:
- no current `/admin` or `/mod` route changes;
- no production operator exposure;
- no operational inbox aggregation;
- no entity search implementation;
- no domain mutation migration;
- no legacy deletion.
### 02. Access, audit & system core
Implement the capability enforcement adapters, audit command path, error taxonomy, correlation IDs, and core observability used by every later vertical.
### 03. People, moderation & support
Deliver the first complete vertical and unify user, ticket, CFH, moderation-action, and ban workflows. This vertical proves the future removal of `/mod` without exposing a partial cutover.
### 04. Command Deck operations
Implement global search, safe commands, favorites, preferences, and the derived inbox against the sources available from completed verticals.
### 05. Remaining domain verticals
Deliver separate scoped specifications and plans for:
1. Content and Engagement;
2. Hotel and World;
3. Economy and Catalog;
4. remaining System, Access, and Observability pages.
Economy and permission-affecting mutations receive the strictest confirmation, concurrency, and audit coverage.
### 06. Parity, cutover & cleanup
Close the migration matrix, run cross-role journeys and data comparisons, switch `/admin`, make `/mod` unreachable, observe the release, then delete unreachable legacy code and later remove obsolete schema safely.
Subproject 01 uses this specification; every later subproject has its own spec, implementation plan, tests, review, and completion gate. A later subproject may not silently expand an earlier approved scope.
## Verification strategy
Every subproject runs proportionate checks from these layers:
1. **Unit tests** for manifest parsing, normalizers, policy functions, reducers, and domain services.
2. **Contract tests** for unique IDs/routes, capability declarations, localization keys, command ownership, and provider behavior.
3. **Integration tests** against representative repository/API implementations, including transactions, external failures, idempotency, and conflicts.
4. **ACL matrix tests** covering permitted, denied, capability-revoked, and super-administrator cases at both render and server boundaries.
5. **E2E journeys** for moderation, support, editorial, economy, hotel operations, and administration roles defined by capabilities rather than rank labels.
6. **Audit assertions** after every tested mutation.
7. **Accessibility checks** for keyboard use, focus order, names, contrast, live feedback, dialogs, and table/detail transitions.
8. **Localization checks** rejecting new hard-coded operator copy and missing translation keys.
9. **Visual regression checks** for the shared shell and high-risk standard states.
10. **Performance comparison** against a recorded legacy baseline using the same environment and dataset. Comparable new flows may not regress median or p95 response time by more than 10% without an explicit reviewed exception. Performance improvements are reported only from measurements.
## Cutover gate
The atomic switch is permitted only when all of the following are true:
- all 137 migration rows are closed with evidence;
- every exposed query and command has a declared and tested capability;
- every mutation has validation and required audit coverage;
- no blocking or critical defect remains open;
- equivalent legacy/new counts and records have been compared for migrated read workflows;
- role journeys for moderator, support operator, editor, economy operator, hotel operator, and administrator pass;
- localization, accessibility, build, type, lint, test, and visual checks pass;
- production-like smoke tests, backup verification, rollback procedure, and health checks have been rehearsed;
- the new HK is not dependent on legacy UI routes;
- communication and operator runbooks are ready for the clean break.
## Cutover and rollback
Before cutover, the new HK is exercised through test/staging or an explicit non-production mechanism. Read-only shadow comparisons may run against representative data. There is no production dual-write.
At cutover:
1. `/admin` changes to the new route composition in one release/flag transition.
2. `/mod` and removed legacy subroutes become unreachable without compatibility redirects.
3. Smoke tests verify authentication, capability filtering, representative reads, one controlled mutation per risk class, audit, and health signals.
Database changes required before cutover are additive and backward-compatible for the emergency rollback window. A flag or previous release can temporarily restore the legacy application if the cutover fails. During normal operation, only one HK is exposed.
After the agreed stability window, unreachable legacy code and flags are removed. Destructive schema cleanup is a later migration and is not coupled to the cutover release.
## Success criteria
The program is complete when:
- `/admin` is the single role-adaptive administration surface;
- `/mod` and the legacy Housekeeping archive surface are gone;
- all 137 legacy pages have an evidenced migration decision;
- all exposed data, navigation, commands, widgets, and inbox items are capability-correct;
- the operational inbox derives live work without owning duplicate workflow state;
- all mutations use the domain command, validation, concurrency, idempotency, and audit path appropriate to their risk;
- no mixed legacy/new production experience exists;
- measured performance meets the approved comparison gate;
- rollback and eventual legacy cleanup are complete.
## Rejected alternatives
### Full greenfield rewrite
Rejected because it would discard reliable existing services and maximize parity, timing, and regression risk across 137 pages.
### Cosmetic refactor of the existing HK
Rejected because it would preserve duplicated `/admin` and `/mod` workflows, inconsistent page boundaries, and manual navigation debt.
### Separate HK service/application
Rejected because the current requirement does not justify another deployment, authentication boundary, or distributed consistency problem.
### Persistent cross-domain task database
Rejected because it would duplicate ticket, moderation, alert, and anomaly state and create reconciliation failure modes.
## Final design invariant
The migration may be incremental internally, but the operator-facing product is not. Until the cutover gate passes, the current HK remains the only normal production surface. After cutover, the new HK is the only surface.
+1
View File
@@ -1,3 +1,4 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
// Schema source of truth for the query builder: src/db/schema.ts
@@ -1,12 +0,0 @@
-- 0020_performance_indexes.sql
-- Adds indexes for the hot CMS read paths (shared DB with the Arcturus
-- emulator — additive only, no schema changes to emulator-owned columns).
--
-- users.credits → credits leaderboard (ORDER BY credits DESC LIMIT 20)
-- users_currency(type, amount) → duckets/diamonds leaderboard (WHERE type=? ORDER BY amount DESC LIMIT 20)
-- users_settings.respects_received → respects leaderboard (ORDER BY respects_received DESC LIMIT 20)
-- camera_web.timestamp → homepage recent photos (ORDER BY timestamp DESC LIMIT 4)
CREATE INDEX IF NOT EXISTS `idx_users_credits` ON `users` (`credits`);
CREATE INDEX IF NOT EXISTS `idx_users_currency_type_amount` ON `users_currency` (`type`, `amount`);
CREATE INDEX IF NOT EXISTS `idx_users_settings_respects_received` ON `users_settings` (`respects_received`);
CREATE INDEX IF NOT EXISTS `idx_camera_web_timestamp` ON `camera_web` (`timestamp`);
@@ -1,4 +0,0 @@
-- 0021_add_messenger_offline_user_id_index.sql
-- The /me dashboard counts unread offline messages with
-- `WHERE user_id = ?`; messenger_offline previously had no index there.
CREATE INDEX IF NOT EXISTS `idx_messenger_offline_user_id` ON `messenger_offline` (`user_id`);
@@ -1,4 +0,0 @@
-- Add terms/age consent columns expected by the users schema (register flow).
ALTER TABLE `users`
ADD COLUMN `terms_accepted` TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN `age_verified` TINYINT(1) NOT NULL DEFAULT 0;
@@ -1,37 +0,0 @@
-- Theme Builder: scoped theme system for per-route, per-module, and multi-site theming.
-- Idempotent: safe to re-run.
CREATE TABLE IF NOT EXISTS theme_scopes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
type ENUM('global','site','module','route') NOT NULL,
parent_id BIGINT UNSIGNED NULL,
site_domain VARCHAR(255) NULL,
route_path VARCHAR(255) NULL,
module_id VARCHAR(100) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY theme_scopes_parent_idx (parent_id),
KEY theme_scopes_type_idx (type),
UNIQUE KEY theme_scopes_unique_lookup (type, site_domain, route_path, module_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS theme_scope_values (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
scope_id BIGINT UNSIGNED NOT NULL,
setting_key VARCHAR(100) NOT NULL,
setting_val VARCHAR(255) NOT NULL,
mode ENUM('light','dark') NOT NULL DEFAULT 'light',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY theme_scope_values_unique (scope_id, setting_key, mode),
KEY theme_scope_values_scope_idx (scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed: create the global scope from existing website_settings theme data.
INSERT IGNORE INTO theme_scopes (id, name, type, parent_id, is_active, sort_order)
VALUES (1, 'Global', 'global', NULL, 1, 0);
@@ -1,3 +0,0 @@
INSERT INTO `acl_permissions` (`slug`, `title`)
VALUES ('housekeeping.preview.access', 'Access Housekeeping preview')
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
@@ -1,7 +0,0 @@
-- Existing articles remain published. Preserve any publication settings already present.
ALTER TABLE website_articles
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published',
ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_website_articles_publication
ON website_articles (status, publish_at, created_at);
-13
View File
@@ -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(/.+/);
});
+17 -15
View File
@@ -1,19 +1,21 @@
module.exports = {
apps: [
{
name: 'epic-next-app',
script: 'node_modules/next/dist/bin/next',
args: 'start',
cwd: '/var/www/atom-nexst',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
node_args: '--max-old-space-size=1024',
name: "next",
script: ".next/standalone/server.js",
exec_mode: "cluster",
instances: 0,
max_memory_restart: "512M",
env: {
NODE_ENV: 'production',
PORT: 3002
}
}
]
};
NODE_ENV: "production",
PORT: 3002,
RCON_PORT: 3003,
NEXT_PHASE: "phase-production-server",
},
merge_logs: true,
error_file: ".pm2/errors.log",
out_file: ".pm2/out.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
},
],
};
+47
View File
@@ -0,0 +1,47 @@
import js from "@eslint/js";
import nextPlugin from "@next/eslint-plugin-next";
import reactHooks from "eslint-plugin-react-hooks";
import security from "eslint-plugin-security";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["node_modules", ".next", "dist", "build"],
},
js.configs.recommended,
tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
plugins: {
"unused-imports": unusedImports,
security: security,
"react-hooks": reactHooks,
"@next/next": nextPlugin,
},
rules: {
// Laad automatisch alle aanbevolen beveiligings- en framework-regels in
...security.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
// Zorg dat variabelen die beginnen met een underscore (_req) genegeerd worden
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"unused-imports/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"no-console": "warn",
},
},
);
-27
View File
@@ -1,27 +0,0 @@
import { eq } from "drizzle-orm";
import { WebsiteSetting } from "@/db/schema";
import { db } from "./src/lib/db";
async function main() {
const result = await db
.select()
.from(WebsiteSetting)
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
console.log("Current:", result);
if (result[0]?.value !== "/imaging") {
await db
.update(WebsiteSetting)
.set({ value: "/imaging" })
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
console.log("Updated to /imaging");
} else {
console.log("Already correct");
}
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+9 -8
View File
@@ -4,14 +4,15 @@
"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"
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}"
],
"project": ["src/**/*.{ts,tsx}"],
"ignoreDependencies": [
"tailwindcss-animate",
"@tailwindcss/forms",
"@tailwindcss/typography",
"pino-pretty",
"tailwindcss"
],
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
"ignoreDependencies": ["pino-pretty"],
"ignoreBinaries": ["sendmail"]
}
+33 -111
View File
@@ -1,14 +1,21 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import withBundleAnalyzer from "@next/bundle-analyzer";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const getGitCommit = () => {
function resolveDeploymentId(): string | undefined {
const configuredId = process.env.NEXT_DEPLOYMENT_ID?.trim();
if (configuredId) return configuredId;
try {
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
return execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
return process.env.APP_VERSION?.trim() || undefined;
}
};
}
const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" },
@@ -23,99 +30,28 @@ const securityHeaders = [
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
];
const nextConfig: NextConfig = {
output: "standalone",
env: {
NEXT_PUBLIC_CMS_RELEASE:
process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit() || "unknown",
},
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
reactStrictMode: true,
deploymentId: resolveDeploymentId(),
turbopack: {},
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
// Enable React Compiler for automatic memoization
reactCompiler: true,
// Compress responses with gzip/brotli
compress: true,
// Disable Next.js telemetry and browser sourcemaps in production
productionBrowserSourceMaps: false,
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
async redirects() {
return [
{
source: "/admin/import",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/badges",
destination: "/admin/studio/badges",
permanent: true,
},
{
source: "/admin/import/furni",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/furni/upload",
destination: "/admin/studio/upload",
permanent: true,
},
{
source: "/admin/import/clothing",
destination: "/admin/studio/clothing",
permanent: true,
},
{
source: "/admin/import/effects",
destination: "/admin/studio/effects",
permanent: true,
},
{
source: "/admin/import/pets",
destination: "/admin/studio/pets",
permanent: true,
},
{
source: "/admin/import/clone",
destination: "/admin/studio/clone",
permanent: true,
},
{
source: "/admin/import/sync",
destination: "/admin/studio/sync",
permanent: true,
},
{
source: "/admin/import/repair-icons",
destination: "/admin/studio/repair-icons",
permanent: true,
},
{
source: "/admin/import/audit",
destination: "/admin/studio/audit",
permanent: true,
},
];
},
turbopack: {
ignoreIssue: [
{
path: "**/src/lib/**",
},
],
},
experimental: {
optimizePackageImports: ["lucide-react", "date-fns"],
useTypeScriptCli: true,
hideLogsAfterAbort: true,
},
images: {
formats: ["image/avif", "image/webp"],
},
// Add caching headers for static assets
async headers() {
return [
{
@@ -131,28 +67,6 @@ const nextConfig: NextConfig = {
},
],
},
{
// Nitro client payload (~2.8GB across swf/nitro-assets): without
// caching every client open re-downloads hundreds of files.
// Fresh for 7 days, then serve stale + revalidate in background
// so asset updates still propagate without blocking players.
source: "/swf/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=604800, stale-while-revalidate=2592000",
},
],
},
{
source: "/nitro-assets/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=604800, stale-while-revalidate=2592000",
},
],
},
{
source: "/images/(.*)",
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
@@ -161,6 +75,14 @@ const nextConfig: NextConfig = {
},
};
// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
export default withNextIntl(nextConfig);
const config = withNextIntl(nextConfig);
const withBA = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
export default withBA(config);
+80 -87
View File
@@ -3,106 +3,99 @@
"private": true,
"type": "module",
"engines": {
"node": ">=26.8.1 <27"
"node": ">=22"
},
"packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
"packageManager": "pnpm@10.33.4",
"scripts": {
"dev": "pnpm assets:editor && next dev",
"build": "pnpm assets:editor && next build",
"dev": "next dev",
"build": "next build",
"start": "next start",
"toolchain:check": "node scripts/check-node-toolchain.mjs",
"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",
"typecheck": "tsc --noEmit --incremental",
"biome:check": "biome check --write .",
"biome:lint": "biome lint .",
"biome:format": "biome format --write .",
"knip": "knip",
"analyze": "ANALYZE=true pnpm build",
"test": "vitest run",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
"db:bulk": "tsx scripts/bulk-import-json.ts",
"db:up": "docker compose --profile db up -d mariadb-turbo",
"db:down": "docker compose --profile db stop mariadb-turbo",
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio",
"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"
"db:introspect": "drizzle-kit introspect",
"jobs:worker": "tsx scripts/jobs-worker.ts",
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
"*.{ts,tsx}": "node scripts/check-admin-colors.mjs"
"*.{js,jsx,ts,tsx}": [
"biome check --write"
],
"*.{json,md,css,scss,html}": [
"biome format --write --no-errors-on-unmatched"
]
},
"dependencies": {
"@base-ui/react": "1.8.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.17",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-virtual": "3.14.10",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"croner": "10.0.1",
"drizzle-orm": "0.45.2",
"hash-wasm": "4.12.0",
"ioredis": "6.0.0",
"isomorphic-dompurify": "^4.1.0",
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
"lucide-react": "1.41.0",
"lzma-wasm": "1.0.7",
"motion": "13.2.0",
"music-metadata": "11.15.0",
"mysql2": "3.24.3",
"next": "16.3.4",
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.7.1",
"@tanstack/react-virtual": "^3.14.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"croner": "^10.0.1",
"drizzle-orm": "^0.45.2",
"hash-wasm": "^4.12.0",
"ioredis": "^5.11.1",
"isomorphic-dompurify": "^3.21.0",
"jpeg-js": "^0.4.4",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"lenis": "^1.3.25",
"lucide-react": "^1.28.0",
"lzma": "^2.3.2",
"motion": "^12.43.0",
"music-metadata": "^11.14.0",
"mysql2": "^3.23.2",
"next": "^16.2.12",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.2",
"otplib": "13.5.0",
"pino": "10.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.87.0",
"resend": "6.26.0",
"server-only": "0.0.1",
"sharp": "^0.35.4",
"sonner": "2.0.8",
"tailwind-merge": "3.6.0",
"tinymce": "8.9.0",
"zod": "4.5.4"
"next-intl": "^4.13.4",
"otplib": "^13.4.1",
"pino": "^10.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.84.0",
"resend": "^6.18.1",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3"
},
"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",
"@types/node": "26.4.1",
"@types/react": "19.2.18",
"@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",
"tsx": "4.23.13",
"typescript": "7.0.2",
"vitest": "5.0.0"
"@biomejs/biome": "2.5.6",
"@next/bundle-analyzer": "^16.2.12",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitest/coverage-v8": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"knip": "^6.31.0",
"lint-staged": "^17.3.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.5",
"typescript": "^7.0.2",
"vite": "8.2.0",
"vitest": "4.1.10"
}
}
-20
View File
@@ -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"] } }],
});
+2857 -1170
View File
File diff suppressed because it is too large. Load diff
+24 -6
View File
@@ -1,13 +1,31 @@
# pnpm-workspace.yaml
# pnpm v11 vervanger voor onlyBuiltDependencies
allowBuilds:
esbuild: true
sharp: true
"@parcel/watcher": true
"@swc/core": true
bcrypt: true
minimumReleaseAge: 1440
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
overrides:
# drizzle-kit still uses an obsolete development-server dependency.
"@esbuild-kit/core-utils>esbuild": "^0.25.9"
"@types/react": "19.2.18"
"@types/react-dom": "19.2.7"
glob: "^10.4.5"
inflight: "npm:lru-cache@^10.2.2"
rimraf: "^5.0.7"
uuid: "^9.0.1"
fast-uri: "^3.1.3"
"@hono/node-server": "^1.19.13"
postcss: "^8.5.25"
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
tmp: "^0.2.6"
# NIEUWE SECURITY PATCHES:
sharp: "^0.35.3"
brace-expansion: "^5.0.8"
# Zorgt voor een schone terminal zonder de Next-Auth / Nodemailer waarschuwing
peerDependencyRules:
allowedVersions:
nodemailer: "9.0.3"
ignoreMissing:
- nodemailer
@@ -1,402 +0,0 @@
{
"badge_name_Z75": "Training camp!",
"badge_desc_Z75": "Per esser diventato un guerriero DOC!",
"badge_name_Z67": "Coniglio Scaccia-Mito",
"badge_desc_Z67": "La Sicurezza in Habbo non è una leggenda!",
"badge_name_Z64": "Topo da Laboratorio",
"badge_desc_Z64": "Beta Tester II",
"badge_name_Z63": "Topo da Laboratorio",
"badge_desc_Z63": "Beta Tester I",
"badge_name_Z39": "Hotel Bau",
"badge_desc_Z39": "Tutti a casa finalmente!",
"badge_name_Z38": "Direttore Hotel Bau",
"badge_desc_Z38": "Hotel a ***3 stelle",
"badge_name_Z37": "Direttore Hotel Bau",
"badge_desc_Z37": "Hotel a **2 stelle",
"badge_name_Z36": "Direttore Hotel Bau",
"badge_desc_Z36": "Hotel a *1 stella",
"badge_name_Z35": "Hotel Bau",
"badge_desc_Z35": "Per aver ritrovato i cuccioli",
"badge_name_Z26": "Futuro Re",
"badge_desc_Z26": "Sarai tu il prossimo Re?",
"badge_name_Z16": "Campagna di Sicurezza Gennaio 2017",
"badge_desc_Z16": "Per essere un esperto nel proteggere il mio account!",
"badge_name_Z09": "In Campeggio",
"badge_desc_Z09": "Campagna Campeggio con i Lupetti - 2009",
"badge_name_Z05": "Ex Habbo eXpert",
"badge_desc_Z05": "Gli Habbo X erano guide volontarie scelte dello Staff tra il 2006 e il 2008.",
"badge_name_Z02": "W la Terra",
"badge_desc_Z02": "Per i veri amanti della natura!",
"badge_name_Z01": "Wow or not?",
"badge_desc_Z01": "Hai impressionato la giuria con la tua sfilata!",
"badge_name_YAK": "x",
"badge_desc_YAK": "x",
"badge_name_XXX": "x",
"badge_desc_XXX": "x",
"badge_name_XRLTD": "Distintivo Palla di Vetro con Neve",
"badge_desc_XRLTD": "Per avere acquistato questo Raro LTD di Natale",
"badge_name_XMSR1": "Macchina Magica",
"badge_desc_XMSR1": "Per comprare una Macchina Rara Magica",
"badge_name_XMS14": "Gnomi all'avventura!",
"badge_desc_XMS14": "Per aver vinto la Competizione Video!",
"badge_name_XMS13": "Cin cin!",
"badge_desc_XMS13": "Attento a non versartelo addosso",
"badge_name_XMS12": "Organizzatore di Feste",
"badge_desc_XMS12": "... da urlo!",
"badge_name_XMS10": "Postazione di Controllo",
"badge_desc_XMS10": "Per comprare una Postazione di Controllo Rara",
"badge_name_XMS07": "Babbo Natale e i 3 Re Magi",
"badge_desc_XMS07": "Babbo Natale ha molte conoscenze...",
"badge_name_XMS06": "Natale in Europa",
"badge_desc_XMS06": "Un Natale dei più tipici!",
"badge_name_XMS05": "Natale sulla Spiaggia",
"badge_desc_XMS05": "Festeggia come un Brasiliano!",
"badge_name_XMS04": "Natale senza Natale",
"badge_desc_XMS04": "Festeggia come in Turchia",
"badge_name_XMS03": "Decoratore di Alberi Natalizi",
"badge_desc_XMS03": "Per avere decorato l'Albero di Natale Habbo 2013",
"badge_name_XMS01": "Bottega di Babbo Natale",
"badge_desc_XMS01": "Per avere acquistato l'Affare Stanza natalizio",
"badge_name_XMH20": "Pupazzo Gatto Scheletrico",
"badge_desc_XMH20": "Per avere acquistato il Pupazzo Gatto Scheletrico",
"badge_name_XMH19": "Pupazzo Tristo Mietitore",
"badge_desc_XMH19": "Per avere acquistato il Pupazzo Tristo Mietitore",
"badge_name_XMH18": "Pupazzo Punk Rock",
"badge_desc_XMH18": "Per avere acquistato il Pupazzo Punk Rock",
"badge_name_XMH17": "Pupazzo Ragazza Habbo",
"badge_desc_XMH17": "Per avere acquistato il Pupazzo Ragazza Habbo",
"badge_name_XMH16": "Pupazzo Lucha Libre",
"badge_desc_XMH16": "Per avere acquistato il Pupazzo Lucha Libre",
"badge_name_XMH15": "Pupazzo Scheletro Tatuato",
"badge_desc_XMH15": "Per avere acquistato il Pupazzo Scheletro Tatuato",
"badge_name_XMH14": "Pupazzo Ragazzo Habbo",
"badge_desc_XMH14": "Per avere acquistato il Pupazzo Ragazzo Habbo",
"badge_name_XMH13": "Pupazzo in Giacca e Cravatta",
"badge_desc_XMH13": "Per avere acquistato il Pupazzo in Giacca e Cravatta",
"badge_name_XMH12": "Pupazzo Catrina",
"badge_desc_XMH12": "Per avere acquistato il Pupazzo Catrina",
"badge_name_XMH11": "Pupazzo Mariachi",
"badge_desc_XMH11": "Per avere acquistato il Pupazzo Mariachi",
"badge_name_XMB": "Mr.Pupazzo",
"badge_desc_XMB": "Con cilindro e carota è il signore delle nevi!",
"badge_name_XmasRoom_Top100": "Top 100 Castello di Natale",
"badge_desc_XmasRoom_Top100": "Per essere rientrat@ nella top 100 della Competizione Stanza Castello di Natale",
"badge_name_XmasRoom_Top10": "Top 10 Castello di Natale",
"badge_desc_XmasRoom_Top10": "Per essere rientrat@ nella top 10 della Competizione Stanza Castello di Natale",
"badge_name_XmasRoom": "Partecipante Castello di Natale",
"badge_desc_XmasRoom": "Per aver partecipato alla Competizione Stanza Castello di Natale",
"badge_name_XMAS2": "Ninnolo",
"badge_desc_XMAS2": "Livello III",
"badge_name_XMAS1": "Ninnolo",
"badge_desc_XMAS1": "Livello II",
"badge_name_XMAS0": "Ninnolo",
"badge_desc_XMAS0": "Livello I",
"badge_name_XMA": "Smile Congelato",
"badge_desc_XMA": "Il suo sorriso ti scalda il cuore",
"badge_name_XM9": "Palla di Neve",
"badge_desc_XM9": "Deve ancora fare parecchia strada per diventare un pupazzo!",
"badge_name_XM8": "Boccale di Birra",
"badge_desc_XM8": "Habbo Natale 2007",
"badge_name_XM7": "Moneta",
"badge_desc_XM7": "Habbo Natale 2007",
"badge_name_XM6": "Robot di Santa 3000",
"badge_desc_XM6": "Habbo Natale 2007",
"badge_name_XM5": "Fiocco di Neve",
"badge_desc_XM5": "Habbo Natale 2007",
"badge_name_XM4": "Natale 2006",
"badge_desc_XM4": "Ero su Habbo a Natale del 2006!",
"badge_name_XM3": "Renna",
"badge_desc_XM3": "Habbo Natale 2005",
"badge_name_XM2": "DJ-Bling",
"badge_desc_XM2": "Habbo Natale 2005",
"badge_name_XM10E": "FREEZE!",
"badge_desc_XM10E": "Hai costruito un'Arena Fantastica!",
"badge_name_XM10D": "Campione Wired-Freeze",
"badge_desc_XM10D": "L'ennesima potenza del divertimento!",
"badge_name_XM10C": "Regalo Natalizio Fuori Stagione",
"badge_desc_XM10C": "Saluti dai Caraibi!",
"badge_name_XM10B": "Pattino d'Oro",
"badge_desc_XM10B": "Il Miglior Palazzetto del Ghiaccio",
"badge_name_XM10A": "Città Natalizia!",
"badge_desc_XM10A": "Per gli addobbi Natalizi più Originali!",
"badge_name_XM1": "Rasta Santa",
"badge_desc_XM1": "Habbo Natale 2005 e 2006",
"badge_name_XGH1": "Il Fantasma del Natale",
"badge_desc_XGH1": "Ho trovato il Fantasma del Natale",
"badge_name_X535": "Conduttore di slitta - Sig.ra Claus",
"badge_desc_X535": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
"badge_name_X534": "Conduttore di slitta - Habbo Natale",
"badge_desc_X534": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
"badge_name_X533": "Pranzo di Natale - Sig.ra Claus",
"badge_desc_X533": "Non c'è di niente di meglio di un bel Pranzo di Natale",
"badge_name_X532": "Pranzo di Natale - Habbo Natale",
"badge_desc_X532": "Non c'è di niente di meglio di un bel Pranzo di Natale",
"badge_name_X531": "Silent night - Sig.ra Claus",
"badge_desc_X531": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come la Sig.ra Claus",
"badge_name_X530": "Silent night - Habbo Natale",
"badge_desc_X530": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come Habbo Natale",
"badge_name_X529": "Preparazione Regali di Natale - Sig.ra Claus",
"badge_desc_X529": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
"badge_name_X528": "Preparazione Regali di Natale - Habbo Natale",
"badge_desc_X528": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
"badge_name_X527": "Mercato di Natale - Sig.ra Claus",
"badge_desc_X527": "Per aver completato il labirinto del mercato di Natale",
"badge_name_X526": "Mercato di Natale - Habbo Natale",
"badge_desc_X526": "Per aver completato il labirinto del mercato di Natale",
"badge_name_X525": "Proprietario di Renna - Sig.ra Claus",
"badge_desc_X525": "Vixen, la renna della Sig.ra Claus, è stata riportata sana e salva nella stalla",
"badge_name_X524": "Proprietario di Renna - Habbo Natale",
"badge_desc_X524": "Dasher, la renna preferita di Habbo Natale, è stata riportata sana e salva nella stalla",
"badge_name_X523": "Film di Natale - Sig.ra Claus",
"badge_desc_X523": "Sei un vero Fan dei film di Natale",
"badge_name_X522": "Film di Natale - Habbo Natale",
"badge_desc_X522": "Sei un vero Fan dei film di Natale",
"badge_name_X521": "Meraviglioso Albero di Natale - Sig.ra Natale",
"badge_desc_X521": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
"badge_name_X520": "Meraviglioso Albero di Natale - Habbo Natale",
"badge_desc_X520": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
"badge_name_X519": "Canti Natalizi - Sig.ra Claus",
"badge_desc_X519": "Jingle bell, jingle bell, jingle bell rock",
"badge_name_X518": "Canti Natalizi - Habbo Natale",
"badge_desc_X518": "Haaaabbo Natale sta arrivando in città!",
"badge_name_X2535": "L'Alimentatore Preferito di Esophagor",
"badge_desc_X2535": "",
"badge_name_X2534": "Stazione dei Treni",
"badge_desc_X2534": "",
"badge_name_X2533": "Collezione Paese delle Meraviglie di Cacao",
"badge_desc_X2533": "",
"badge_name_X2532": "Affare Stanza Ufficio di Habbo Natale",
"badge_desc_X2532": "",
"badge_name_X2531": "Affare Stanza Natale Accogliente di Habbo",
"badge_desc_X2531": "",
"badge_name_X2530": "Uovo Glassato LTD",
"badge_desc_X2530": "",
"badge_name_X2529": "Borsa Kitty Rara",
"badge_desc_X2529": "",
"badge_name_X2528": "Cuscino di Ghiaccio Raro",
"badge_desc_X2528": "",
"badge_name_X2527": "Habbo Night Hotel Raro",
"badge_desc_X2527": "",
"badge_name_X2526": "Coda di cavallo Ghiacciata Scintillante Rara",
"badge_desc_X2526": "",
"badge_name_X2525": "Buon Natale 2025!",
"badge_desc_X2525": "",
"badge_name_X2521": "A caccia di vacanze!",
"badge_desc_X2521": "",
"badge_name_X2520": "Alimentatore di Esophagor",
"badge_desc_X2520": "",
"badge_name_X2518": "Battaglia con Palle di neve!",
"badge_desc_X2518": "",
"badge_name_X2516": "Spirito delle Feste",
"badge_desc_X2516": "",
"badge_name_X2515": "Straordinario Creatore delle Feste",
"badge_desc_X2515": "",
"badge_name_X2514": "Felice & Moderno",
"badge_desc_X2514": "",
"badge_name_X2513": "Eroe delle Tradizioni Natalizie",
"badge_desc_X2513": "",
"badge_name_X2512": "Corona dello Scontro di Natale",
"badge_desc_X2512": "",
"badge_name_X2511": "Habubu Glam",
"badge_desc_X2511": "",
"badge_name_X2510": "Habubu Cozy",
"badge_desc_X2510": "",
"badge_name_X2509": "Habubu Dream",
"badge_desc_X2509": "",
"badge_name_X2508": "Habubu Calm",
"badge_desc_X2508": "",
"badge_name_X2507": "Habubu Luck",
"badge_desc_X2507": "",
"badge_name_X2506": "Habubu Hope",
"badge_desc_X2506": "",
"badge_name_X2505": "Habubu Happy",
"badge_desc_X2505": "",
"badge_name_X2504": "Habubu Fun",
"badge_desc_X2504": "",
"badge_name_X2503": "Habubu Love",
"badge_desc_X2503": "",
"badge_name_X2502": "Squadra - DJ Bling",
"badge_desc_X2502": "",
"badge_name_X2501": "Squadra - Rasta Santa",
"badge_desc_X2501": "",
"badge_name_X2330": "Competizione Anatroccolo",
"badge_desc_X2330": "",
"badge_name_X2329": "Competizione Stanza Sweet Suite NL, TR",
"badge_desc_X2329": "",
"badge_name_X2328": "Offerta Crafting",
"badge_desc_X2328": "",
"badge_name_X2327": "Trono Bianco",
"badge_desc_X2327": "",
"badge_name_X2326": "Buon 2024!",
"badge_desc_X2326": "",
"badge_name_X2325": "Buon Natale!",
"badge_desc_X2325": "",
"badge_name_X2324": "Goditi il Natale Habbo",
"badge_desc_X2324": "",
"badge_name_X2323": "Aiuta Frank Wobbah a cucinare",
"badge_desc_X2323": "",
"badge_name_X2322": "Salvataggio delle ricette di Frank Wobbah",
"badge_desc_X2322": "",
"badge_name_X2321": "Missione Babbo Natale per un giorno",
"badge_desc_X2321": "",
"badge_name_X2320": "Missione Brilla come un Biglietto d'oro",
"badge_desc_X2320": "",
"badge_name_X2319": "Missione Delizie Arcobaleno",
"badge_desc_X2319": "",
"badge_name_X2318": "Missione Frutti di Bosco",
"badge_desc_X2318": "",
"badge_name_X2317": "Missione Mentine Rinfrescanti",
"badge_desc_X2317": "",
"badge_name_X2316": "Pazzo per la Missione del Cioccolato",
"badge_desc_X2316": "",
"badge_name_X2315": "Torta Trono Rara",
"badge_desc_X2315": "",
"badge_name_X2314": "Offerta di Capodanno 2023",
"badge_desc_X2314": "",
"badge_name_X2313": "Offerta Indumenti Natale 2023",
"badge_desc_X2313": "",
"badge_name_X2312": "Carosello di Cioccolato Artigianale LTD",
"badge_desc_X2312": "",
"badge_name_X2311": "Corona d'Oro 24K LTD",
"badge_desc_X2311": "",
"badge_name_X2310": "Ali sulla Testa Mitiche Rare",
"badge_desc_X2310": "",
"badge_name_X2309": "Seduta Aerea Rara",
"badge_desc_X2309": "",
"badge_name_X2308": "Nastro Scartami RARO",
"badge_desc_X2308": "",
"badge_name_X2307": "Albero di Zucchero Filato RARO",
"badge_desc_X2307": "",
"badge_name_X2306": "Affare Stanza di Capodanno",
"badge_desc_X2306": "",
"badge_name_X2305": "Affare Stanza Pista di Pattinaggio sul Ghiaccio",
"badge_desc_X2305": "",
"badge_name_X2304": "Affare Stanza Ritrovo dell'Elfo",
"badge_desc_X2304": "",
"badge_name_X2303": "Bottega Moderna di Babbo Natale",
"badge_desc_X2303": "",
"badge_name_X2302": "Emporio del Cioccolato Magico",
"badge_desc_X2302": "",
"badge_name_X2301": "Collezione Paese delle Meraviglie di Cacao",
"badge_desc_X2301": "",
"badge_name_X2234": "Sanrio Christmas Room Competition",
"badge_desc_X2234": "",
"badge_name_X2232": "Affare Stanza Salone da Pranzo",
"badge_desc_X2232": "",
"badge_name_X2231": "Offerta Bevande per soldi Reali",
"badge_desc_X2231": "",
"badge_name_X2230": "Renna Abile",
"badge_desc_X2230": "",
"badge_name_X2229": "Pinguino Abile",
"badge_desc_X2229": "",
"badge_name_X2228": "Labirinto dei Pinguini: Livello Facile - Completato",
"badge_desc_X2228": "",
"badge_name_X2227": "Labirinto dei Pinguini: Livello Medio - Completato",
"badge_desc_X2227": "",
"badge_name_X2226": "Labirinto dei Pinguini: Livello Folle - Completato",
"badge_desc_X2226": "",
"badge_name_X2224": "Natale Habbo 2022 - Pinguino di Tokyo",
"badge_desc_X2224": "",
"badge_name_X2223": "Natale Habbo 2022 - Pinguino Timido",
"badge_desc_X2223": "",
"badge_name_X2222": "Natale Habbo 2022 - Pinguino Accademico",
"badge_desc_X2222": "",
"badge_name_X2220": "Natale Habbo 2022 - Pinguino Unicorno",
"badge_desc_X2220": "",
"badge_name_X2219": "Natale Habbo 2022 - Pinguino Albino",
"badge_desc_X2219": "",
"badge_name_X2218": "Natale Habbo 2022 - Pinguino Fantasma",
"badge_desc_X2218": "",
"badge_name_X2217": "Natale Habbo 2022 - Pinguino Egizio",
"badge_desc_X2217": "",
"badge_name_X2216": "Natale Habbo 2022 - Pinguino Artista",
"badge_desc_X2216": "",
"badge_name_X2215": "Natale Habbo 2022 - Pinguino Addormentato",
"badge_desc_X2215": "",
"badge_name_X2214": "Natale Habbo 2022 - Pinguino Testa a Molla",
"badge_desc_X2214": "",
"badge_name_X2213": "Offerta Cibo Soldi Veri",
"badge_desc_X2213": "",
"badge_name_X2212": "Uovo Habberge Palla di Neve LTD",
"badge_desc_X2212": "",
"badge_name_X2211": "Ali d'Angelo LTD",
"badge_desc_X2211": "",
"badge_name_X2210": "RARO Husky Addestrato",
"badge_desc_X2210": "",
"badge_name_X2209": "Raro Sauna Nordica",
"badge_desc_X2209": "",
"badge_name_X2208": "RARO Capelli con Treccine",
"badge_desc_X2208": "",
"badge_name_X2207": "Raro Look da Albero Festivo",
"badge_desc_X2207": "",
"badge_name_X2206": "Affare Stanza Sauna Finlandese",
"badge_desc_X2206": "",
"badge_name_X2205": "Affare Stanza Snowboard sulle Alpi in Inverno",
"badge_desc_X2205": "",
"badge_name_X2204": "Affare Stanza Natale Bavarese",
"badge_desc_X2204": "",
"badge_name_X2203": "Affare Stanza Inverno ad Habbo Stonehenge",
"badge_desc_X2203": "",
"badge_name_X2202": "Affare Stanza Baita del Resort Invernale",
"badge_desc_X2202": "",
"badge_name_X2201": "Affare Stanza Resort degli Sport Invernali",
"badge_desc_X2201": "",
"badge_name_X2139": "Buon Natale!",
"badge_desc_X2139": "",
"badge_name_X2138": "Sulla lista dei buoni di Babbo Natale!",
"badge_desc_X2138": "",
"badge_name_X2137": "La Squadra di Babbo Natale",
"badge_desc_X2137": "",
"badge_name_X2136": "Chi è il vero Babbo Natale?",
"badge_desc_X2136": "",
"badge_name_X2135": "Il Coro Natalizio di Habbo",
"badge_desc_X2135": "",
"badge_name_X2134": "Battaglia di Palle di Neve!",
"badge_desc_X2134": "",
"badge_name_X2133": "Oh Albero di Natale",
"badge_desc_X2133": "",
"badge_name_X2132": "Si prepara una Tempesta!",
"badge_desc_X2132": "",
"badge_name_X2131": "Pattinando sul Ghiaccio Sottile",
"badge_desc_X2131": "",
"badge_name_X2130": "Piante Malridotte dall'Inverno",
"badge_desc_X2130": "",
"badge_name_X2129": "Nobile Uccello d'Oro",
"badge_desc_X2129": "",
"badge_name_X2128": "Caso Risolto!",
"badge_desc_X2128": "",
"badge_name_X2127": "Passi nella neve",
"badge_desc_X2127": "",
"badge_name_X2126": "La Grande Fuga",
"badge_desc_X2126": "",
"badge_name_X2125": "Il Sospetto Sbagliato",
"badge_desc_X2125": "",
"badge_name_X2124": "Scappato appena in tempo!",
"badge_desc_X2124": "",
"badge_name_X2123": "Identità Segreta",
"badge_desc_X2123": "",
"badge_name_X2122": "Una Mappa Segreta",
"badge_desc_X2122": "",
"badge_name_X2121": "Zzzz..Zzzz..",
"badge_desc_X2121": "",
"badge_name_X2120": "Si è intrufolato nella stanza del commesso viaggiatore",
"badge_desc_X2120": "",
"badge_name_X2119": "Un Look strepitoso!",
"badge_desc_X2119": "",
"badge_name_X2118": "Chiavi prestate",
"badge_desc_X2118": "",
"badge_name_X2117": "Il Treno sta arrivando",
"badge_desc_X2117": "",
"badge_name_X2116": "Bandito del Treno",
"badge_desc_X2116": "",
"badge_name_X2115": "Il Treno Habbo Express",
"badge_desc_X2115": "",
"badge_name_X2114": "Uovo Habberge Art Deco LTD",
"badge_desc_X2114": "",
"badge_name_X2113": "Locomotiva a Vapore Rara",
"badge_desc_X2113": "",
"badge_name_X2112": "Costume da Treno Raro",
"badge_desc_X2112": "",
"badge_name_X2111": "Macchina della Cioccolata Calda di Lusso Rara",
"badge_desc_X2111": ""
}
Binary file not shown.
+17 -24
View File
@@ -14,36 +14,29 @@
"prConcurrentLimit": 5,
"packageRules": [
{
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "Development tools",
"automerge": false
},
{
"matchPackageNames": [
"@base-ui/react",
"lucide-react",
"motion",
"@dnd-kit/**"
],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "Interface libraries",
"automerge": false
},
{
"matchPackageNames": ["vitest", "@vitest/coverage-v8"],
"groupName": "Vitest",
"automerge": false
"description": "Group all dependency updates into a single PR",
"matchPackagePatterns": ["*"],
"groupName": "All dependencies",
"groupSlug": "all",
"automerge": true
},
{
"description": "Major updates need manual review",
"matchUpdateTypes": ["major"],
"automerge": false
"labels": ["dependencies", "major"],
"automerge": false,
"assignees": [],
"reviewers": []
},
{
"description": "Disable updates for engine pins",
"matchPackageNames": ["node", "pnpm"],
"enabled": false
},
{
"description": "Disable server-only (abandoned, pinned at 0.0.1)",
"matchPackageNames": ["server-only"],
"enabled": false
}
],
"minimumReleaseAge": "3 days",
"automerge": false
]
}
-365
View File
@@ -1,365 +0,0 @@
import "./load-env";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { readFurniData, writeFurniData } from "@/lib/services/furni-data";
/**
* Make FurnitureData.json the single source of truth for items_base ids.
*
* 1. Duplicate ids inside furnidata (same id claimed by several classnames)
* are resolved globally and iteratively: the claimant matching the current
* DB occupant keeps the id; losing entries are patched in the file back to
* their own classname's DB id (and lose their claim).
* 2. Every items_base row is then moved (pass-based PK swap, cascading into
* every referencing table) to its furnidata id. Moves whose target is
* still occupied wait for a later pass; cycles break via scratch ids;
* rows squatting on a contested id without any furnidata entry are
* displaced to fresh ids past the global maximum.
* 3. sprite_id = id and catalog_name = item_name afterwards.
*
* Run with --apply to write; without it, only reports.
*/
const APPLY = process.argv.includes("--apply");
interface Entry {
id: number;
classname: string;
offerid?: unknown;
}
async function main(): Promise<void> {
const t0 = Date.now();
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Entry[] }
>;
const [rows] = (await db.execute(
sql`SELECT id, item_name FROM items_base`,
)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
console.log(`[align] items_base rows: ${rows.length}`);
const nameById = new Map<number, string>();
const idByName = new Map<string, number>();
for (const r of rows) {
nameById.set(r.id, r.item_name);
idByName.set(r.item_name, r.id);
}
const allEntries: Array<{ section: string; e: Entry }> = [];
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
if (typeof e?.classname === "string" && Number.isFinite(Number(e?.id))) {
allEntries.push({ section, e });
}
}
}
// ── global iterative duplicate-id resolution ────────────────────
const desired = new Map<string, number>(); // classname -> furnidata id claim
for (const { e } of allEntries) {
const cn = e.classname;
const id = Number(e.id);
if (!cn || !Number.isFinite(id) || id <= 0 || desired.has(cn)) continue;
desired.set(cn, id);
}
console.log(`[align] furnidata classnames: ${desired.size}`);
const losers: Array<{ cn: string; keepId: number }> = [];
for (;;) {
const claimsById = new Map<number, string[]>();
for (const [cn, id] of desired) {
const arr = claimsById.get(id) ?? [];
arr.push(cn);
claimsById.set(id, arr);
}
let found = false;
for (const [id, claimants] of [...claimsById].sort((a, b) => a[0] - b[0])) {
if (claimants.length < 2) continue;
const live = claimants.filter((cn) => desired.get(cn) === id);
if (live.length < 2) continue;
found = true;
const dbHolder = nameById.get(id);
const winner =
dbHolder !== undefined && live.includes(dbHolder) ? dbHolder : live[0];
for (const cn of live) {
if (cn === winner) continue;
const keep = idByName.get(cn);
if (keep !== undefined && keep !== id)
losers.push({ cn, keepId: keep });
desired.delete(cn); // loser loses its claim entirely
}
}
if (!found) break;
}
console.log(
`[align] duplicate-id losers (patched to their own DB id): ${losers.length}`,
);
// ── compute moves ───────────────────────────────────────────────
let fresh =
Math.max(
rows.reduce((m, r) => Math.max(m, r.id), 0),
[...desired.values()].reduce((m, v) => Math.max(m, v), 0),
losers.reduce((m, l) => Math.max(m, l.keepId), 0),
) + 1000;
const moves = new Map<number, number>(); // old_id -> new_id
for (const r of rows) {
const target = desired.get(r.item_name);
if (target !== undefined && target !== r.id) moves.set(r.id, target);
}
// squatter displacement: a row occupying a contested id with no furnidata
// entry of its own must make room
for (;;) {
const destCount = new Map<number, number>();
for (const [, t] of moves) destCount.set(t, (destCount.get(t) ?? 0) + 1);
let changed = false;
for (const [, target] of moves) {
const holder = nameById.get(target);
if (holder === undefined) continue; // free
if (moves.has(target)) continue; // vacated by its own move
if (desired.get(holder) === target) continue; // legit stayer
moves.set(target, fresh++);
changed = true;
break;
}
if (!changed) break;
}
// drop moves onto ids that a legit stayer keeps forever
for (;;) {
let changed = false;
for (const [oldId, target] of moves) {
const holder = nameById.get(target);
if (holder === undefined || moves.has(target)) continue;
if (desired.get(holder) === target) {
moves.delete(oldId);
changed = true;
break;
}
}
if (!changed) break;
}
console.log(`[align] planned id moves: ${moves.size}`);
let n = 0;
for (const [oldId, target] of moves) {
if (n++ >= 10) break;
console.log(` '${nameById.get(oldId)}' : ${oldId} -> ${target}`);
}
if (!APPLY) {
console.log("[align] DRY RUN — rerun with --apply to execute");
await db.$client.end();
process.exit(0);
}
// ── patch losing furnidata entries back to their DB id ──────────
if (losers.length > 0) {
const keepByClass = new Map(losers.map((l) => [l.cn, l.keepId]));
for (const { e } of allEntries) {
const keep = keepByClass.get(e.classname);
if (keep !== undefined && Number(e.id) !== keep) {
e.id = keep;
e.offerid = keep;
}
}
await writeFurniData(furniData as Record<string, unknown>);
console.log(`[align] furnidata patched: ${losers.length} losing entries`);
}
// ── pass-based PK moves (values stay in unsigned range) ─────────
await db.execute(sql`DROP TABLE IF EXISTS _id_map`);
await db.execute(sql`DROP TABLE IF EXISTS _id_map_pass`);
await db.execute(sql`
CREATE TABLE _id_map_pass (
old_id INT PRIMARY KEY,
new_id INT NOT NULL,
UNIQUE KEY uniq_new (new_id)
) ENGINE=InnoDB`);
const intCols: Array<[string, string]> = [
["items", "item_id"],
["room_templates_items", "item_id"],
["catalog_items_limited", "item_id"],
["crafting_recipes_ingredients", "item_id"],
["items_crackable", "item_id"],
["gift_wrappers", "sprite_id"],
["gift_wrappers", "item_id"],
["trax_playlist", "item_id"],
["pet_drinks", "item_id"],
["pet_foods", "item_id"],
["pet_items", "item_id"],
["marketplace_items", "item_id"],
["calendar_rewards", "item_id"],
["builders_club_items", "item_id"],
["youtube_playlists", "item_id"],
["room_trax_playlist", "item_id"],
["recycler_prizes", "item_id"],
["website_event_prizes", "item_id"],
["website_rare_values", "item_id"],
["catalog_products", "item_id"],
["room_trade_log_items", "item_id"],
["logs_economy", "item_id"],
];
const strCols: Array<[string, string]> = [
["catalog_items", "item_ids"],
["catalog_items_bc", "item_ids"],
["logs_shop_purchases", "item_ids"],
["catalog_version_offers", "item_ids"],
];
const [csRows] = (await db.execute(sql`
SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND ((TABLE_NAME = 'catalog_items' AND COLUMN_NAME = 'item_ids')
OR (TABLE_NAME = 'catalog_items_bc' AND COLUMN_NAME = 'item_ids')
OR (TABLE_NAME = 'logs_shop_purchases' AND COLUMN_NAME = 'item_ids')
OR (TABLE_NAME = 'catalog_version_offers' AND COLUMN_NAME = 'item_ids'))
`)) as unknown as [
Array<{
TABLE_NAME: string;
COLUMN_NAME: string;
CHARACTER_SET_NAME: string;
COLLATION_NAME: string;
}>,
unknown,
];
const collationByCol = new Map<string, [string, string]>();
for (const r of csRows ?? []) {
collationByCol.set(`${r.TABLE_NAME}.${r.COLUMN_NAME}`, [
r.CHARACTER_SET_NAME,
r.COLLATION_NAME,
]);
}
// numeric -> string in the target column's own charset/collation so the
// JOIN comparison never mixes collations (CAST alone yields utf8mb4)
const numToStr = (table: string, col: string, expr: string) => {
const info = collationByCol.get(`${table}.${col}`);
if (!info) return sql.raw(`CAST(${expr} AS CHAR)`);
return sql.raw(
`CONVERT(${expr}, CHAR CHARACTER SET ${info[0]}) COLLATE ${info[1]}`,
);
};
const occupiedIds = new Set(rows.map((r) => r.id));
let scratch = fresh;
async function execPass(pairs: Array<[number, number]>): Promise<void> {
if (pairs.length === 0) return;
await db.transaction(async (tx) => {
await tx.execute(sql`SET FOREIGN_KEY_CHECKS = 0`);
for (let i = 0; i < pairs.length; i += 500) {
const chunk = pairs.slice(i, i + 500);
await tx.execute(sql`
INSERT INTO _id_map_pass (old_id, new_id)
VALUES ${sql.join(
chunk.map(([o, nw]) => sql`(${o}, ${nw})`),
sql`, `,
)}`);
}
await tx.execute(sql`
UPDATE items_base b JOIN _id_map_pass m ON b.id = m.old_id
SET b.id = m.new_id`);
for (const [table, col] of intCols) {
await tx.execute(sql`
UPDATE ${sql.raw(table)} t JOIN _id_map_pass m
ON t.${sql.raw(col)} = m.old_id
SET t.${sql.raw(col)} = m.new_id`);
}
for (const [table, col] of strCols) {
await tx.execute(sql`
UPDATE ${sql.raw(table)} t JOIN _id_map_pass m
ON t.${sql.raw(col)} = ${numToStr(table, col, "m.old_id")}
SET t.${sql.raw(col)} = CAST(m.new_id AS CHAR)`);
}
for (const [o] of pairs) {
await tx.execute(sql`DELETE FROM _id_map_pass WHERE old_id = ${o}`);
}
await tx.execute(sql`SET FOREIGN_KEY_CHECKS = 1`);
});
for (const [o, nw] of pairs) {
occupiedIds.delete(o);
occupiedIds.add(nw);
}
}
const pending = new Map(moves);
let passes = 0;
while (pending.size > 0) {
passes++;
const safe: Array<[number, number]> = [];
for (const [oldId, target] of pending) {
if (!occupiedIds.has(target)) safe.push([oldId, target]);
}
if (safe.length === 0) {
// cycle: park the smallest row on a scratch id to break it
const oldest = [...pending.keys()].sort((a, b) => a - b)[0];
const target = pending.get(oldest);
if (target === undefined) break;
pending.delete(oldest);
await execPass([[oldest, scratch]]);
pending.set(scratch, target);
scratch++;
continue;
}
await execPass(safe);
for (const [oldId] of safe) pending.delete(oldId);
console.log(
`[align] pass ${passes}: moved ${safe.length} ids (${pending.size} left)`,
);
}
await db.execute(sql`DROP TABLE _id_map_pass`);
await db.transaction(async (tx) => {
await tx.execute(
sql`UPDATE items_base SET sprite_id = id WHERE sprite_id <> id`,
);
const [res] = (await tx.execute(sql`
UPDATE catalog_items ci JOIN items_base ib
ON ci.item_ids = ${numToStr("catalog_items", "item_ids", "ib.id")}
SET ci.catalog_name = ib.item_name
WHERE ci.catalog_name <> ib.item_name`)) as unknown as [
Record<string, unknown>,
unknown,
];
console.log(
`[align] catalog_name normalized rows: ${Number(res.affectedRows ?? 0)}`,
);
});
const [maxRows] = (await db.execute(
sql`SELECT COALESCE(MAX(id), 0) + 1 AS nxt FROM items_base`,
)) as unknown as [Array<{ nxt: number }>, unknown];
await db.execute(
sql`ALTER TABLE items_base AUTO_INCREMENT = ${sql.raw(String(Number(maxRows[0]?.nxt ?? 1)))}`,
);
// leftover compound lists ("a;b") containing moved ids
const [compound] = (await db.execute(sql`
SELECT id, item_ids FROM catalog_items WHERE item_ids LIKE '%;%'
`)) as unknown as [Array<{ id: number; item_ids: string }>, unknown];
for (const row of compound) {
const mapped = String(row.item_ids)
.split(/[;,]/)
.map((p) => {
const v = Number(p.trim());
return Number.isFinite(v) ? v : p;
})
.join(";");
await db.execute(
sql`UPDATE catalog_items SET item_ids = ${mapped} WHERE id = ${row.id}`,
);
}
console.log(`[align] compound item_ids rewritten: ${compound.length}`);
console.log(`[align] done in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
await db.$client.end();
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+1 -1
View File
@@ -1,4 +1,4 @@
import "./load-env";
import "dotenv/config";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-189
View File
@@ -1,189 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import * as babel from "@babel/parser";
import { parse } from "@formatjs/icu-messageformat-parser";
const flat = (obj, p = "", r = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = p ? `${p}.${k}` : k;
if (v && typeof v === "object") flat(v, key, r);
else r[key] = v;
}
return r;
};
const messages = Object.fromEntries(
fs
.readdirSync("src/messages")
.filter((n) => n.endsWith(".json"))
.map((n) => [
n.slice(0, -5),
flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))),
]),
);
const base = messages.en,
invalid = [],
mismatches = [];
function vars(ast, r = new Set()) {
for (const n of ast) {
if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value);
if (n.options) for (const o of Object.values(n.options)) vars(o.value, r);
if (n.children) vars(n.children, r);
}
return [...r].sort().join(",");
}
const signatures = {};
for (const [k, v] of Object.entries(base)) {
try {
signatures[k] = vars(parse(v));
} catch {}
}
const locales = Object.entries(messages).map(([locale, m]) => {
for (const [k, v] of Object.entries(m)) {
try {
const sig = vars(parse(v));
if (k in signatures && sig !== signatures[k])
mismatches.push({
locale,
key: k,
expected: signatures[k],
actual: sig,
value: v,
});
} catch (e) {
invalid.push({ locale, key: k, value: v, error: e.message });
}
}
return {
locale,
total: Object.keys(m).length,
missing: Object.keys(base).filter((k) => !(k in m)).length,
empty: Object.values(m).filter((v) => !v?.trim()).length,
sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length,
};
});
const missingCalls = [],
hardcoded = [],
unparsedFiles = [];
function walk(n, fn) {
if (!n || typeof n !== "object") return;
fn(n);
for (const [k, v] of Object.entries(n)) {
if (k === "loc" || k === "extra") continue;
if (Array.isArray(v))
v.forEach((x) => {
walk(x, fn);
});
else if (v && typeof v === "object") walk(v, fn);
}
}
for (const f of fs
.readdirSync("src", { recursive: true })
.filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) {
let ast;
try {
ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
} catch (error) {
unparsedFiles.push({ file: f, error: error.message });
continue;
}
const bindings = {};
walk(ast, (n) => {
if (n.type === "VariableDeclarator" && n.id.type === "Identifier") {
let c = n.init;
if (c?.type === "AwaitExpression") c = c.argument;
if (
c?.type === "CallExpression" &&
["useTranslations", "getTranslations"].includes(c.callee.name)
) {
const arg = c.arguments[0];
const ns =
arg?.type === "StringLiteral"
? arg.value
: arg?.type === "ObjectExpression"
? arg.properties.find((p) => p.key?.name === "namespace")?.value
?.value
: arg
? null
: "";
if (ns !== null) {
bindings[n.id.name] ??= new Set();
bindings[n.id.name].add(ns);
}
}
}
});
walk(ast, (n) => {
if (n.type === "CallExpression") {
const name = n.callee.name ?? n.callee.object?.name;
const ns = bindings[name];
const k = n.arguments[0];
if (ns?.size === 1 && k?.type === "StringLiteral") {
const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value;
if (
!(full in base) &&
!Object.keys(base).some((b) => b.startsWith(`${full}.`))
)
missingCalls.push({ file: f, line: n.loc.start.line, key: full });
}
}
if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim()))
hardcoded.push({
file: f,
line: n.loc.start.line,
text: n.value.trim().replace(/\s+/g, " "),
});
});
}
const result = {
locales,
invalid,
mismatches,
missingCalls,
hardcoded,
unparsedFiles,
};
if (process.argv.includes("--json"))
console.log(JSON.stringify(result, null, 2));
else {
console.table(locales);
console.log(
"Invalid ICU:",
invalid.length,
"Variable mismatches:",
mismatches.length,
"Missing static message references:",
missingCalls.length,
);
console.log(
"Literal JSX text candidates requiring editorial review:",
hardcoded.length,
);
console.log(
"Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.",
);
if (
invalid.length ||
mismatches.length ||
missingCalls.length ||
unparsedFiles.length
)
console.log(
JSON.stringify(
{ invalid, mismatches, missingCalls, unparsedFiles },
null,
2,
),
);
}
if (
process.argv.includes("--check") &&
(invalid.length ||
mismatches.length ||
missingCalls.length ||
unparsedFiles.length ||
locales.some((locale) => locale.empty > 0))
)
process.exitCode = 1;
-276
View File
@@ -1,276 +0,0 @@
#!/usr/bin/env python3
"""
Full furnidata translation script using LibreTranslate.
Translates all customs (missing official translations) for all 13 languages.
Run from /var/www/atom-nexst
"""
import json
import pathlib
import os
import time
import concurrent.futures
import urllib.request
import sys
# ── Config ─────────────────────────────────────────────────────────────
MASTER_PATH = "/var/www/Gamedata/config/FurnitureData.json"
OUT_DIR = "/var/www/Gamedata/config"
CACHE_PATH = "/var/www/Gamedata/config/.translations_libre_cache.json"
LIBRE_URL = "http://127.0.0.1:5000/translate"
CONCURRENCY = 6
LANGUAGES = [
("nl", "nl"),
("en", "com"),
("de", "de"),
("fr", "fr"),
("es", "es"),
("tr", "com.tr"),
("it", "it"),
("pt", "com.br"),
("fi", "fi"),
("pl", "com"),
("ru", "com"),
("ar", "com"),
("ja", "com"),
]
OFFICIAL_HOTEL_LANGS = {"nl", "en", "de", "fr", "es", "tr", "it", "pt", "fi"}
# ────────────────────────────────────────────────────────────────────────
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept": "application/json,text/plain,*/*;q=0.9",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
}
def load_json(path):
return json.loads(pathlib.Path(path).read_text())
def save_json(path, data):
tmp = path + ".tmp"
pathlib.Path(tmp).write_text(json.dumps(data, ensure_ascii=False))
os.rename(tmp, path)
os.chmod(path, 0o640)
try:
import pwd, grp
os.chown(path, pwd.getpwnam("www-data").pw_uid, grp.getgrnam("www-data").gr_gid)
except:
pass
def build_map(data):
m = {}
for sec in ("roomitemtypes", "wallitemtypes"):
for e in data[sec]["furnitype"]:
cn = e["classname"]
if cn not in m:
m[cn] = {"name": e.get("name", ""), "description": e.get("description", "")}
return m
def is_meaningful(text, classname):
if not isinstance(text, str):
return False
t = text.strip()
if not t:
return False
if t == classname:
return False
if t == f"{classname} desc":
return False
if t == f"{classname} name":
return False
if len(t) < 2:
return False
return True
def libre_translate(text, target, cache):
key = f"{target}|{text}"
if key in cache:
return cache[key]
data = json.dumps({"q": text, "source": "en", "target": target, "format": "text"}).encode()
req = urllib.request.Request(LIBRE_URL, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
out = json.loads(r.read().decode()).get("translatedText", text)
except Exception as e:
print(f" LibreTranslate error for {target} '{text[:40]}': {e}")
out = text
cache[key] = out
return out
def fetch_official(lang, hotel):
if lang not in OFFICIAL_HOTEL_LANGS:
return {}
url = f"https://www.habbo.{hotel}/gamedata/furnidata_json/1"
print(f" Fetching official {lang} from habbo.{hotel}...")
try:
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=30) as r:
data = json.loads(r.read().decode())
except Exception as e:
print(f" Failed to fetch official {lang}: {e}")
return {}
fmap = {}
for sec in ("roomitemtypes", "wallitemtypes"):
for e in data.get(sec, {}).get("furnitype", []):
cn = e.get("classname", "")
if not cn or cn in fmap:
continue
fmap[cn] = {"name": e.get("name", ""), "description": e.get("description", "")}
print(f" Official {lang}: {len(fmap)} translations")
return fmap
def translate_batch(texts, target, cache, concurrency=CONCURRENCY):
if not texts:
return {}
print(f" Translating {len(texts)} unique texts to {target} with {concurrency} workers...")
start = time.time()
out = {}
missing = [t for t in texts if f"{target}|{t}" not in cache]
if not missing:
return {t: cache[f"{target}|{t}"] for t in texts}
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
futs = {ex.submit(libre_translate, t, target, cache): t for t in missing}
done = 0
for f in concurrent.futures.as_completed(futs):
t = futs[f]
try:
out[t] = f.result()
except Exception as e:
print(f" Failed {t[:40]}: {e}")
out[t] = t
done += 1
if done % 100 == 0:
elapsed = time.time() - start
print(f" {done}/{len(missing)} in {elapsed:.1f}s ({elapsed/max(done,1):.2f}s/req)")
elapsed = time.time() - start
print(f" Done {len(texts)} texts to {target} in {elapsed:.1f}s")
return out
def main():
print("Loading master FurnitureData.json...")
master = load_json(MASTER_PATH)
master_map = build_map(master)
print(f"Master: {len(master_map)} entries")
# Load or create cache
cache = {}
if os.path.exists(CACHE_PATH):
try:
cache = load_json(CACHE_PATH)
print(f"Loaded cache: {len(cache)} entries")
except Exception:
print("Cache corrupt, starting fresh")
cache = {}
# For each language
for lang, hotel in LANGUAGES:
if lang == "en":
print(f"\n=== {lang} (source) ===")
# en file is just copy of master
out_path = f"{OUT_DIR}/FurnitureData_en.json"
save_json(out_path, master)
print(f" Wrote {out_path}")
continue
out_path = f"{OUT_DIR}/FurnitureData_{lang}.json"
print(f"\n=== {lang} (hotel: {hotel}) ===")
# Fetch official translations if available
official = fetch_official(lang, hotel) if lang in OFFICIAL_HOTEL_LANGS else {}
# Clone master
cloned = json.loads(json.dumps(master))
translated = 0
official_count = 0
libre_count = 0
# Collect all texts needing translation (customs not covered by official)
missing_names = set()
missing_descs = set()
for sec in ("roomitemtypes", "wallitemtypes"):
for e in cloned[sec]["furnitype"]:
cn = e["classname"]
t = official.get(cn)
if not t and "*" in cn:
base = cn.split("*")[0]
t = official.get(f"{base}*0") or official.get(base)
if not t and "*" not in cn:
t = official.get(f"{cn}*0")
orig_name = e["name"]
orig_desc = e.get("description", "")
# Apply official if available
if t:
did = False
if t.get("name"):
e["name"] = t["name"]
did = True
if t.get("description"):
e["description"] = t["description"]
did = True
if did:
translated += 1
official_count += 1
continue
# Collect missing for LibreTranslate
if is_meaningful(orig_name, cn):
missing_names.add(orig_name.strip())
if is_meaningful(orig_desc, cn):
missing_descs.add(orig_desc.strip())
print(f" Official applied: {official_count}, missing for Libre: {len(missing_names)} names, {len(missing_descs)} descs")
# Translate missing via LibreTranslate
if missing_names:
tr_names = translate_batch(missing_names, lang, cache, CONCURRENCY)
for sec in ("roomitemtypes", "wallitemtypes"):
for e in cloned[sec]["furnitype"]:
cn = e["classname"]
orig = e["name"]
if is_meaningful(orig, cn):
tr = tr_names.get(orig.strip())
if tr and tr != orig:
e["name"] = tr
translated += 1
libre_count += 1
if missing_descs:
tr_descs = translate_batch(missing_descs, lang, cache, CONCURRENCY)
for sec in ("roomitemtypes", "wallitemtypes"):
for e in cloned[sec]["furnitype"]:
cn = e["classname"]
orig = e.get("description", "")
if is_meaningful(orig, cn):
tr = tr_descs.get(orig.strip())
if tr and tr != orig:
e["description"] = tr
translated += 1
libre_count += 1
# Save cache after each language
save_json(CACHE_PATH, cache)
print(f" LibreTranslate applied: {libre_count}, total translated: {translated}")
# Write output
save_json(out_path, cloned)
print(f" Wrote {out_path} ({pathlib.Path(out_path).stat().st_size / 1024 / 1024:.1f} MB)")
# Final cache save
save_json(CACHE_PATH, cache)
print(f"\n=== All done! Cache: {len(cache)} entries ===")
# Summary
for lang, _ in LANGUAGES:
fmap = build_map(load_json(f"{OUT_DIR}/FurnitureData_{lang}.json"))
ident = sum(1 for k, v in master_map.items() if fmap.get(k) and fmap[k]["name"] == v["name"])
pct = (len(master_map) - ident) / len(master_map) * 100
print(f" {lang}: {len(master_map)-ident}/{len(master_map)} translated ({pct:.1f}%)")
if __name__ == "__main__":
main()
-39
View File
@@ -1,39 +0,0 @@
import "./load-env";
import { buildLocalizedFurniDataFiles } from "../src/lib/services/furni-data-i18n";
async function main() {
const args = process.argv.slice(2);
const full = args.includes("--full");
const maxPerLang = full
? undefined
: args.includes("--limit")
? parseInt(args[args.indexOf("--limit") + 1] || "2000", 10)
: 1500;
const concurrency = args.includes("--concurrency")
? parseInt(args[args.indexOf("--concurrency") + 1] || "6", 10)
: 6;
console.log(
`[build-languages] Starting ${full ? "FULL" : `limited ${maxPerLang} per lang`} build (concurrency ${concurrency})...`,
);
const start = Date.now();
try {
const results = await buildLocalizedFurniDataFiles();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`[build-languages] Done in ${elapsed}s`);
for (const r of results) {
const totalTranslated = r.translatedRoom + r.translatedWall;
const pct =
r.total > 0 ? ((totalTranslated / r.total) * 100).toFixed(1) : "0.0";
console.log(
` ${r.lang} (${r.hotel}): ${totalTranslated}/${r.total} translated (${pct}%) -> ${r.file} ${r.ok ? "OK" : `FAIL ${r.error}`}`,
);
}
process.exit(0);
} catch (e) {
console.error("[build-languages] Failed:", e);
process.exit(1);
}
}
main();
-254
View File
@@ -1,254 +0,0 @@
// Bulk-load >50 MB JSON into MariaDB in resumable chunks (2000 rows/batch).
//
// Drizzle multi-row INSERTs are wrapped in ONE statement per chunk, so a
// 50 MB dump becomes a few dozen statements instead of hundreds of thousands
// of round-trips — no connect/packet timeouts, no "Pulling schema from
// database..." freeze (that hang is introspection racing a saturated server).
//
// Usage (via pnpm, as required):
// pnpm exec tsx scripts/bulk-import-json.ts \
// --file=/var/www/Gamedata/.../FurnitureData.json [--chunk-size=2000]
//
// Input shapes supported automatically:
// A) habbo furnidata_json → { roomitemtypes:{furnitype:[…]}, wallitemtypes:{…} }
// B) flat array of objects → [ {…}, {…} ]
//
// Flags:
// --file=<path> JSON file to load (required)
// --table=<name> one of: furnidata | docs | texts (default: furnidata)
// --category=<x> category value for docs/texts tables
// --source=<x> source value for furnidata table (default: habbo)
// --chunk-size=N rows per multi-row INSERT (default: 2000)
// --limit=N stop after N rows (dry-test without loading everything)
// --truncate DELETE all rows for this table's source/category first
//
// Idempotent by default: rows re-import on their unique key with
// ON DUPLICATE KEY UPDATE, so interrupted runs resume safely.
import "./load-env";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import {
GamedataDocs,
GamedataFurnidata,
GamedataTexts,
} from "@/db/schema-gamedata";
import { mysqlConnectionUrl } from "./db-url";
interface Args {
file: string;
table: "furnidata" | "docs" | "texts";
category: string;
source: string;
chunkSize: number;
limit: number;
truncate: boolean;
}
const FURNIDATA_SECTIONS = {
roomitemtypes: "s",
flooritemtypes: "s",
wallitemtypes: "i",
effecttypes: "e",
} as const;
function parseArgs(raw: string[]): Args {
const get = (name: string) => {
const hit = raw.find((a) => a.startsWith(`--${name}=`));
return hit?.slice(name.length + 3);
};
const has = (name: string) => raw.includes(`--${name}`);
const file = get("file");
if (!file) {
console.error(
"Usage: pnpm exec tsx scripts/bulk-import-json.ts --file=<path> [--table=furnidata|docs|texts] [--category=<x>] [--source=<x>] [--chunk-size=2000] [--limit=N] [--truncate]",
);
process.exit(1);
}
const table = (get("table") ?? "furnidata") as Args["table"];
return {
file: resolve(file),
table,
category: get("category") ?? "default",
source: get("source") ?? "habbo",
chunkSize: Number(get("chunk-size") ?? "2000"),
limit: Number(get("limit") ?? "0"),
truncate: has("truncate"),
};
}
// A >50 MB file parses fine with a single JSON.parse (V8 string limit is far
// higher); streaming row-by-row would slow the seed down for no memory win.
async function readJsonFile(file: string): Promise<unknown> {
const { readFile } = await import("node:fs/promises");
return JSON.parse(await readFile(file, "utf8"));
}
interface NormalizedRow {
[key: string]: unknown;
}
// Normalize any supported shape into a flat list of JSON documents.
function normalize(json: unknown, table: Args["table"]): NormalizedRow[] {
if (table !== "furnidata") {
if (Array.isArray(json)) return json as NormalizedRow[];
throw new Error(`Expected a top-level array for --table=${table}`);
}
if (Array.isArray(json)) return json as NormalizedRow[];
// habbo furnidata_json: { roomitemtypes: { furnitype: [...] }, ... }
if (json && typeof json === "object") {
const rows: NormalizedRow[] = [];
for (const [section, kind] of Object.entries(FURNIDATA_SECTIONS)) {
const block = (json as Record<string, unknown>)[section];
if (!block || typeof block !== "object") continue;
const list = (block as Record<string, unknown>).furnitype;
if (!Array.isArray(list)) continue;
for (const item of list) rows.push({ ...item, kind });
}
return rows;
}
throw new Error("Unsupported JSON shape: expected array or furnidata_json");
}
function toFurnidataRow(
row: NormalizedRow,
source: string,
): Record<string, unknown> {
return {
source,
kind: (row.kind as "s" | "i" | "e") ?? "s",
className: String(row.classname ?? row.className ?? ""),
publicName: String(row.public_name ?? row.publicName ?? ""),
spriteId: Number(row.id ?? 0),
payload: JSON.stringify(row),
};
}
function toDocsRow(
row: NormalizedRow,
category: string,
): Record<string, unknown> {
const payload = JSON.stringify(row);
return {
category,
docKey: String(row.key ?? row.docKey ?? row.id ?? ""),
payload,
payloadSha1: createHash("sha1").update(payload).digest("hex"),
};
}
function toTextsRow(
row: NormalizedRow,
category: string,
): Record<string, unknown> {
return {
category,
textKey: String(row.key ?? row.id ?? ""),
value: String(row.value ?? row.text ?? row),
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const url = process.env.DATABASE_URL;
if (!url)
throw new Error("DATABASE_URL is required (load-env.ts loaded .env)");
const conn = await mysql.createConnection(mysqlConnectionUrl(url));
const db = drizzle(conn);
// Session bulk-load flags: worst case per chunk is a lost commit, not a
// corrupt table. FOREIGN_KEY_CHECKS off keeps each 50 MB chunk off FK
// validation work; UNIQUE_CHECK is a per-statement no-op vs ON DUPLICATE.
await conn.query("SET SESSION FOREIGN_KEY_CHECKS=0");
await conn.query("SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'");
const tableRef =
args.table === "furnidata"
? GamedataFurnidata
: args.table === "docs"
? GamedataDocs
: GamedataTexts;
const keyWhere =
args.table === "furnidata"
? sql`${GamedataFurnidata.source} = ${args.source}`
: args.table === "docs"
? sql`${GamedataDocs.category} = ${args.category}`
: sql`${GamedataTexts.category} = ${args.category}`;
if (args.truncate) {
await db.execute(sql`DELETE FROM ${tableRef} WHERE ${keyWhere}`);
console.log(`[bulk] truncated rows for ${args.table}`);
}
console.log(`[bulk] reading ${args.file} …`);
const json = await readJsonFile(args.file);
const rows = normalize(json, args.table);
console.log(`[bulk] parsed ${rows.length} documents (${args.table})`);
const mapper: (r: NormalizedRow) => Record<string, unknown> =
args.table === "furnidata"
? (r) => toFurnidataRow(r, args.source)
: args.table === "docs"
? (r) => toDocsRow(r, args.category)
: (r) => toTextsRow(r, args.category);
const values = rows
.slice(0, args.limit || rows.length)
.map<Record<string, unknown>>(mapper);
const total = values.length;
const chunkSize = args.chunkSize;
const started = Date.now();
let inserted = 0;
for (let i = 0; i < total; i += chunkSize) {
const chunk = values.slice(i, Math.min(i + chunkSize, total));
// `tableRef` is a 3-way union; drizzle's insert is typed per single
// table, so narrow through a custom shape here (runtime is unaffected).
const insert = db.insert(tableRef) as unknown as {
values: (rows: typeof chunk) => {
onDuplicateKeyUpdate: (opts: {
set: Record<string, unknown>;
}) => Promise<unknown>;
};
};
await insert.values(chunk).onDuplicateKeyUpdate({
// MariaDB `VALUES(col)` = the incoming row value; re-runs overwrite.
set: Object.fromEntries(
Object.keys(chunk[0] ?? {}).map((k) => [
k,
sql.raw(`values(\`${k}\`)`),
]),
),
});
inserted += chunk.length;
const remaining = total - i - chunk.length;
if (inserted % (chunkSize * 10) < chunkSize || remaining <= 0) {
const elapsedSec = (Date.now() - started) / 1000;
const rate = Math.round(inserted / Math.max(elapsedSec, 0.001));
const etaMin = Math.round(remaining / Math.max(rate, 1) / 60);
console.log(
`[bulk] ${inserted}/${total} (${Math.round((inserted / total) * 100)}%) ${rate} rows/s, ETA ~${etaMin}m`,
);
}
}
const sec = ((Date.now() - started) / 1000).toFixed(1);
console.log(`[bulk] DONE: ${inserted}/${total} rows in ${sec}s`);
if (total === args.limit && args.limit > 0) {
console.log(
" NOTE: --limit was set; run again without it for the full dump.",
);
}
await conn.end();
}
main().catch((err) => {
console.error("[bulk] FATAL:", err instanceof Error ? err.message : err);
process.exit(1);
});
-143
View File
@@ -1,143 +0,0 @@
#!/usr/bin/env node
/**
* Checks admin source files for hardcoded color utilities that should use
* theme CSS variables instead. Exits 1 on violations.
*
* Usage: node scripts/check-admin-colors.mjs [file ...]
* When called with file arguments (by lint-staged), only those files are
* checked. Without arguments, scans all ROOTS.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const ROOTS = [
"src/app",
"src/components",
"src/lib",
"src/features",
"src/hooks",
];
const ALLOWLIST = new Set([
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
"src/components/admin/catalog/items-shop-preview.tsx",
"src/components/admin/media-grid.tsx",
// shadcn/ui primitives — hardcoded colors are intentional design tokens
"src/components/ui/button.tsx",
"src/components/ui/badge.tsx",
"src/components/ui/dialog.tsx",
]);
// Patterns that indicate hardcoded theme colors
const RULES = [
{
name: "no-text-white",
pattern: /text-white(?:\/\d+)?/g,
message:
"Use theme text colors (e.g. text-foreground, text-card-foreground)",
},
{
name: "no-bg-white",
pattern: /bg-white(?:\/\d+)?/g,
message:
"Use theme background colors (e.g. bg-background, bg-card, bg-surface)",
},
{
name: "no-text-black",
pattern: /text-black(?:\/\d+)?/g,
message:
"Use theme text colors (e.g. text-foreground, text-card-foreground)",
},
{
name: "no-bg-black-hardcoded",
pattern: /bg-black(?:\/\d+)?/g,
message:
"Use theme overlay vars (e.g. bg-foreground/50) or documented overrides",
},
{
name: "no-gray-palette",
pattern:
/(?:text|bg|border|ring)-(?:gray|slate|zinc|neutral|stone)-(?:[1-9]00|50)(?:\/\d+)?/g,
message: "Use theme CSS variables instead of Tailwind gray palette",
},
{
name: "no-colored-palette",
pattern:
/(?:text|bg|border|ring)-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g,
message:
"Use theme CSS variables (e.g. text-destructive, bg-accent) instead",
},
];
function sourceFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory()
? sourceFiles(path)
: /\.(?:ts|tsx)$/.test(entry.name) &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".test.tsx")
? [path]
: [];
});
}
const cwd = process.cwd();
const files =
process.argv.length > 2
? process.argv.slice(2).map((f) => resolve(f))
: ROOTS.flatMap((r) => sourceFiles(r));
const violations = [];
for (const file of files) {
const normalized = relative(cwd, file).replaceAll("\\", "/");
if (ALLOWLIST.has(normalized)) continue;
if (normalized.endsWith(".test.ts") || normalized.endsWith(".test.tsx"))
continue;
if (!/\.(?:ts|tsx)$/.test(normalized)) continue;
const source = readFileSync(file, "utf8");
const lines = source.split("\n");
for (const rule of RULES) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// skip comments
const trimmed = line.trimStart();
if (
trimmed.startsWith("//") ||
trimmed.startsWith("*") ||
trimmed.startsWith("/*")
)
continue;
for (const match of line.matchAll(rule.pattern)) {
violations.push({
file: normalized,
line: i + 1,
match: match[0],
rule: rule.name,
message: rule.message,
});
}
}
}
}
if (violations.length > 0) {
console.error("\n🚫 Hardcoded color violations found in admin files:\n");
for (const v of violations) {
console.error(` ${v.file}:${v.line} ${v.match}`);
console.error(` → ${v.message}`);
}
console.error(
`\n${violations.length} violation(s) found. Use theme CSS variables instead.\n`,
);
process.exit(1);
} else {
console.log("✅ No hardcoded color violations found.");
}
-39
View File
@@ -1,39 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const readProjectFile = (path) =>
readFileSync(resolve(projectRoot, path), "utf8");
const pinnedVersion = readProjectFile(".nvmrc").trim();
assert.match(
pinnedVersion,
/^\d+\.\d+\.\d+$/,
".nvmrc must pin an exact Node.js version",
);
const major = Number.parseInt(pinnedVersion.split(".")[0], 10);
const packageJson = JSON.parse(readProjectFile("package.json"));
assert.equal(
packageJson.engines?.node,
`>=${pinnedVersion} <${major + 1}`,
"package.json engines.node must match the .nvmrc release line",
);
assert.equal(
Number.parseInt(packageJson.devDependencies?.["@types/node"], 10),
major,
"@types/node must match the pinned Node.js major",
);
if (!process.argv.includes("--static")) {
assert.equal(
process.versions.node,
pinnedVersion,
"the active Node.js runtime must match .nvmrc",
);
}
console.log(`Node.js toolchain is aligned on ${pinnedVersion}`);
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env bash
# One lock covers build, migrations, cutover, health checks and smoke tests.
set -Eeuo pipefail
deploy_dir="${CMS_DEPLOY_DIR:-/var/www/atom-nexst}"
branch="${DEPLOY_BRANCH:-main}"
case "$branch" in main|master) ;; *) echo "Unsupported deployment branch" >&2; exit 1 ;; esac
exec 9>"$deploy_dir/.deploy.lock"
flock -w 1800 9
sha="$(git rev-parse HEAD)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit" >&2; exit 1; }
image="epicnext-cms:$sha"
previous_name=""
previous_image=""
backup_name="epicnext-cms-rollback"
cutover_started=0
candidate_attempted=0
backup_created=0
is_current() {
local head
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
head="${head%%[[:space:]]*}"
if [ "$head" != "$sha" ]; then
echo "Skipping superseded commit $sha (branch now at $head)"
return 1
fi
}
check_current() {
local status=0
is_current || status=$?
case "$status" in 0) ;; 1) exit 0 ;; *) echo "Cannot verify remote branch" >&2; exit 1 ;; esac
}
healthy() {
local attempt
for attempt in $(seq 1 30); do
if curl -sf --max-time 5 http://127.0.0.1:3002/api/health | grep -q '"database":true'; then return 0; fi
sleep 3
done
return 1
}
finish() {
local status=$?
trap - EXIT
if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
echo "Deployment failed; restoring previous container" >&2
docker logs epicnext-cms-app --tail 50 >&2 || true
if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi
if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi
if [ -n "$previous_name" ]; then
if docker start "$previous_name" && healthy; then
echo "Rollback verified: $previous_image"
else
echo "ERROR: previous container could not be restored to healthy state" >&2
fi
else
echo "No previous container exists; rollback is unavailable" >&2
fi
fi
exit "$status"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
check_current
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"
# Throw away the previous build cache before each build so disk usage doesn't
# grow unbounded across deployments. The current release image (`epicnext-cms`)
# is still reused as a base layer via `--cache-from`; only the accumulated
# BuildKit intermediate cache is discarded.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current
pnpm db:migrate
check_current
# Prefer the active CI container, or the active legacy compose container.
for name in epicnext-cms-app epicnext-cms; do
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
previous_name="$name"
break
fi
done
if [ -z "$previous_name" ]; then
for name in epicnext-cms-app epicnext-cms; do
if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi
done
fi
if docker inspect "$backup_name" >/dev/null 2>&1; then
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
exit 1
fi
if [ -n "$previous_name" ]; then
previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")"
docker tag "$previous_image" epicnext-cms:previous
fi
# Remove a stopped leftover CI container when the compose container is active.
if [ "$previous_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
docker rm epicnext-cms-app
fi
cutover_started=1
if [ -n "$previous_name" ]; then
docker stop "$previous_name"
docker rename "$previous_name" "$backup_name"
backup_created=1
fi
candidate_attempted=1
(
set -a
# shellcheck disable=SC1091
. "$deploy_dir/.env"
set +a
ENV_ARGS=()
while IFS='=' read -r key _; do
case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac
ENV_ARGS+=(-e "$key")
done < "$deploy_dir/.env"
docker run -d --name epicnext-cms-app --restart always --net=host \
"${ENV_ARGS[@]}" \
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
-v "$deploy_dir/public/swf:/app/public/swf" \
-v "$deploy_dir/storage:/app/storage" \
-v /var/www/Gamedata:/var/www/Gamedata \
"$image"
)
healthy
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
# Publish the latest alias only after health and browser checks pass.
docker tag "$image" epicnext-cms:latest
cutover_started=0
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
echo "Deployment verified: $sha"
# Retain the current and previous releases; do not remove arbitrary named tags.
while IFS= read -r tag; do
if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
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 image prune -f --filter "until=168h" || true
-130
View File
@@ -1,130 +0,0 @@
/**
* Pre-compress large gamedata JSON files to .gz so nginx `gzip_static`
* serves them via sendfile instead of re-compressing up to 48 MB on every
* request (see /etc/nginx/nginx.conf: `gzip_static on`).
*
* Idempotent: a file is only re-compressed when its source mtime is newer
* than the existing .gz (e.g. after a Studio catalog export rewrites it).
*
* Usage: node scripts/compress-gamedata.mjs
* Env: GAMEDATA_ROOT (default /var/www/Gamedata)
* GAMEDATA_GZIP_MIN_BYTES (default 1048576)
*/
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
import { cpus } from "node:os";
import { join, relative } from "node:path";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata";
const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024);
const GZIP_LEVEL = 9;
const TARGET_DIRS = ["config", "bundled/config"];
const CONCURRENCY = Math.max(1, Math.min(4, cpus().length));
function isCompressible(name) {
return (
name.endsWith(".json") &&
!name.endsWith(".gz") &&
!name.endsWith(".min.json")
);
}
async function collectCandidates() {
const files = [];
for (const dir of TARGET_DIRS) {
const root = join(GAMEDATA_ROOT, dir);
let entries;
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !isCompressible(entry.name)) continue;
const full = join(root, entry.name);
const stat = await fs.stat(full);
if (stat.size < MIN_BYTES) continue;
files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs });
}
}
return files;
}
async function needsCompression({ src, mtimeMs }) {
const gz = `${src}.gz`;
try {
const stat = await fs.stat(gz);
return stat.mtimeMs < mtimeMs;
} catch {
return true;
}
}
async function compressOne({ src }) {
const gz = `${src}.gz`;
const tmp = `${gz}.tmp-${process.pid}`;
await pipeline(
createReadStream(src),
createGzip({ level: GZIP_LEVEL }),
createWriteStream(tmp),
);
await fs.rename(tmp, gz);
try {
await fs.chmod(gz, 0o644);
} catch {
// Best-effort; ownership is up to the caller.
}
return gz;
}
async function main() {
const candidates = await collectCandidates();
const work = [];
const skipped = [];
for (const candidate of candidates) {
if (await needsCompression(candidate)) {
work.push(candidate);
} else {
skipped.push(candidate);
}
}
const results = [];
let idx = 0;
async function worker() {
while (idx < work.length) {
const item = work[idx++];
try {
const gz = await compressOne(item);
const stat = await fs.stat(gz);
results.push(
`compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`,
);
} catch (err) {
results.push(
`FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
const workers = Array.from(
{ length: Math.min(CONCURRENCY, work.length) },
() => worker(),
);
await Promise.all(workers);
console.log(
`gamedata: ${work.length} to compress, ${skipped.length} up to date`,
);
for (const line of results) console.log(`gamedata: ${line}`);
}
main().catch((err) => {
console.error(
`gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
});
-22
View File
@@ -1,22 +0,0 @@
import { cp, mkdir } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
const source = path.dirname(require.resolve("tinymce/package.json"));
const target = path.resolve("public/vendor/tinymce");
await mkdir(target, { recursive: true });
for (const name of [
"tinymce.min.js",
"icons",
"models",
"plugins",
"skins",
"themes",
"license.md",
"notices.txt",
]) {
await cp(path.join(source, name), path.join(target, name), {
recursive: true,
});
}
-116
View File
@@ -1,116 +0,0 @@
import "./load-env";
import mysql, { type RowDataPacket } from "mysql2/promise";
import { mysqlConnectionUrl } from "./db-url";
type DbError = Error & { code?: string };
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const connection = await mysql.createConnection(
mysqlConnectionUrl(databaseUrl),
);
let failed = false;
async function probe(
name: string,
statement: string,
values: Array<string | number> = [],
): Promise<RowDataPacket[] | null> {
try {
const [rows] = await connection.execute<RowDataPacket[]>(statement, values);
console.log(`[permissions-diag] ${name}: ok (${rows.length} rows)`);
return rows;
} catch (error) {
failed = true;
const dbError = error as DbError;
console.error(
`[permissions-diag] ${name}: failed code=${dbError.code ?? "unknown"} message=${dbError.message}`,
);
return null;
}
}
try {
const rankRows = await probe(
"select rank id",
"SELECT id FROM permission_ranks ORDER BY id DESC LIMIT 1",
);
const rankId = Number(rankRows?.[0]?.id ?? 11);
console.log(`[permissions-diag] probing rank id ${rankId}`);
await probe(
"rank detail",
`SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
auto_gotw_amount, auto_points_amount
FROM permission_ranks WHERE id = ?`,
[rankId],
);
const definitionColumns = await probe(
"permission_definitions columns",
`SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'permission_definitions'`,
);
const permissionLimits = await probe(
"permission max_value limits",
`SELECT MIN(max_value) AS min_value,
MAX(max_value) AS max_value,
SUM(max_value > 20) AS values_over_20
FROM permission_definitions`,
);
if (permissionLimits?.[0]) {
console.log(
`[permissions-diag] max_value range ${permissionLimits[0].min_value}..${permissionLimits[0].max_value}; values_over_20=${permissionLimits[0].values_over_20}`,
);
}
const rankColumn = `rank_${rankId}`;
if (definitionColumns?.some((row) => row.column_name === rankColumn)) {
await probe(
"normalized permissions",
`SELECT permission_key, max_value, \`${rankColumn}\` AS value
FROM permission_definitions ORDER BY permission_key ASC`,
);
} else {
await probe(
"legacy permissions",
"SELECT * FROM permissions WHERE id = ? LIMIT 1",
[rankId],
);
}
await probe(
"rank users",
"SELECT id, username, look FROM users WHERE rank = ? ORDER BY username LIMIT 200",
[rankId],
);
await probe(
"rank user count",
"SELECT COUNT(*) AS total FROM users WHERE rank = ?",
[rankId],
);
await probe(
"CMS permissions",
"SELECT id, slug, title FROM acl_permissions ORDER BY slug",
);
const roles = await probe(
"CMS role",
"SELECT id FROM acl_roles WHERE slug = ? LIMIT 1",
[`rank_${rankId}`],
);
if (roles?.[0]?.id) {
await probe(
"CMS role permissions",
`SELECT p.slug FROM acl_model_permissions mp
INNER JOIN acl_permissions p ON mp.permission_id = p.id
WHERE mp.model_id = ?`,
[roles[0].id],
);
}
} finally {
await connection.end();
}
if (failed) process.exitCode = 1;
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
# ==============================================================================
# docker-preflight.sh — Verify a VPS is ready to run the Dockerized EpicNext-CMS.
#
# Lets any supplied .env drive the checks. Checks (all idempotent, none mutating):
# 1. Docker + compose available and usable.
# 2. Required runtime dirs exist (RW for UID 33 where the CMS writes).
# 3. Volume owners are UID/GID 33 (www-data) on the host.
# 4. .env exists and required host-network services are reachable.
# 5. Port 3002 free (or the host CMS that must be stopped).
#
# Usage: ./scripts/docker-preflight.sh [--fix]
# --fix attempts to auto-correct permission/owner issues (chown).
#
# Exit codes: 0 ready, 1 not ready (or fixed nothing).
# ==============================================================================
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR" || exit 1
FIX=0
[ "${1:-}" = "--fix" ] && FIX=1
ENV_FILE="${ENV_FILE:-$DIR/.env}"
fail=0
warn=0
# Ports that are expected to be reachable ON THE HOST (network_mode: host).
# These mirror the defaults in .env / the emulator stack; override via env.
CMS_PORT="${CMS_PORT:-3002}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
REDIS_PORT="${REDIS_PORT:-6379}"
RCON_PORT="${RCON_PORT:-3003}"
API_PORT="${API_PORT:-3001}"
IMAGER_PORT="${IMAGER_PORT:-8082}"
# Relative dirs the CMS writes to, plus the absolute shared gamedata root.
RW_DIRS=(
"$DIR/public/nitro-assets"
"$DIR/public/swf"
"$DIR/storage"
"/var/www/Gamedata"
)
say() { printf ' %s\n' "$*"; }
ok() { printf ' \033[32m[OK] \033[0m%s\n' "$*"; }
err() { printf ' \033[31m[FAIL]\033[0m %s\n' "$*"; fail=1; }
wrn() { printf ' \033[33m[WARN]\033[0m %s\n' "$*"; warn=1; }
doing(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
doing "1. Docker engine + compose"
if command -v docker >/dev/null 2>&1; then
ok "docker binary present"
if docker info >/dev/null 2>&1; then ok "daemon reachable"; else err "daemon NOT reachable (is it running / does this user have access?)"; fi
else
err "docker binary missing"
fi
if docker compose version >/dev/null 2>&1; then
ok "docker compose plugin present"
else
err "docker compose plugin missing (install docker-compose-plugin)"
fi
doing "2. Runtime directories exist + RW for UID 33"
for d in "${RW_DIRS[@]}"; do
if [ ! -e "$d" ]; then
err "missing dir: $d"
if [ "$FIX" -eq 1 ]; then
mkdir -p "$d" && chown 33:33 "$d" && say " created + chown 33:33 $d" || err " could not create $d"
fi
continue
fi
if [ ! -d "$d" ]; then err "not a directory: $d"; continue; fi
# Test write as the target owner via a temp file drop (as root), then remove.
if [ "$(id -u)" -eq 0 ]; then
if touch "$d/.preflight-write-test" 2>/dev/null; then
rm -f "$d/.preflight-write-test"
ok "writable: $d"
else
err "not writable: $d (needs owner 33:33)"
[ "$FIX" -eq 1 ] && { chown -R 33:33 "$d" && say " chown -R 33:33 $d" || err " chown failed"; }
fi
else
if [ -w "$d" ]; then ok "writable: $d"; else err "not writable: $d"; fi
fi
done
doing "3. Volume ownership (UID/GID 33 = www-data)"
for d in "${RW_DIRS[@]}"; do
[ -e "$d" ] || continue
owner="$(stat -c '%u:%g' "$d" 2>/dev/null || true)"
if [ "$owner" = "33:33" ]; then
ok "owner 33:33: $d"
else
err "owner ${owner:-unknown} (want 33:33): $d"
[ "$FIX" -eq 1 ] && { chown 33:33 "$d" && say " chown 33:33 $d" || err " chown failed"; }
fi
done
doing "4. Configuration + required services (.env, host network)"
if [ -f "$ENV_FILE" ]; then
ok ".env present: $ENV_FILE"
### shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
else
err ".env missing: $ENV_FILE (copy your production env here)"
fi
check_port() { # name port
if command -v nc >/dev/null 2>&1; then
if nc -z 127.0.0.1 "$2" >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
else
if (echo >/dev/tcp/127.0.0.1/"$2") >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
fi
}
check_port "MariaDB" "$MYSQL_PORT"
check_port "Redis" "$REDIS_PORT"
check_dep() { # name
if command -v "$1" >/dev/null 2>&1; then ok "host binary: $1"; else wrn "host binary not found: $1 (may still work via container)"; fi
}
check_dep git
doing "5. Port 3002 state (host CMS clash)"
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
else
ok "port $CMS_PORT free"
fi
printf '\n'
if [ "$fail" -eq 1 ]; then
printf '\033[31m=== NOT READY: %s issue(s) found%s ===\033[0m\n' "$fail" "$([ "$FIX" -eq 1 ] && echo ' after --fix' || echo ' (re-run with --fix to auto-correct)')"
exit 1
fi
if [ "$warn" -eq 1 ]; then printf '\033[33m=== READY (with %s warning(s)) ===\033[0m\n' "$warn"; exit 0; fi
printf '\033[32m=== READY ===\033[0m\n'
exit 0
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env bash
# ==============================================================================
# docker-update.sh — Automatic daily update for the Dockerized EpicNext-CMS.
#
# Steps:
# 1. Verify the working tree is clean (uncommitted changes abort).
# 2. git pull (fast-forward only).
# 3. Run CMS migrations on the HOST (the slim runtime container has no source).
# 4. docker compose build (auto-detects pnpm/yarn/npm via lockfile).
# 5. docker compose up -d && wait for a healthy container.
# 6. Record everything in update.log.
#
# Exit codes: 0 ok, 1 update skipped, 2 build/deploy failed, 3 health failed.
# ==============================================================================
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR" || exit 2
# Share the CI lock before pulling or touching the live application.
exec 9>"$DIR/.deploy.lock"
flock -w 1800 9 || exit 2
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002)
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit "${2:-2}"; }
touch "$LOG_FILE"
log "=== Start docker-update ==="
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
if ! "$DIR/scripts/docker-preflight.sh"; then
die "preflight failed — fix issues first (see '--fix' flag)" 1
fi
log "preflight OK"
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
die "working tree has uncommitted changes; commit or stash first" 1
fi
# --- 1. Pull latest ---
git pull --ff-only --quiet 2>>"$LOG_FILE"
pull_status=$?
if [ $pull_status -ne 0 ]; then
die "git pull failed (status $pull_status)" 1
fi
log "git pull OK: $(git rev-parse --short HEAD)"
# --- 2. Host-side migrations (idempotent; only applies CMS-owned tables) ---
if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then
pnpm db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (pnpm) failed"
elif command -v npm >/dev/null 2>&1; then
npm run db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (npm) failed"
else
die "no package manager found for migrations" 2
fi
log "db:migrate OK"
# --- 3. Node major gate (patches auto, major upgrades need review) ---
# `node:alpine` floats within, then across, Node majors. Patches/minors are
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
# native addons / Next compatibility, so require an explicit review before it
# goes live. Compare the major of the deployed runtime image vs the floating
# tag; abort (not deploy) when they differ.
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
# Resolve the currently-deployed Node major from its image.
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
fi
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
fi
log "Node major gate OK (major=${float_major:-?})"
# --- 4. Rebuild the image ---
# Reset the BuildKit cache first so the build doesn't accumulate unbounded
# layers on disk across daily rebuilds.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>>"$LOG_FILE" || true
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
log "docker compose build OK"
# --- 5. Recreate the container ---
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
log "docker compose up OK"
# --- 6. Wait for health (up to ~4 min) ---
healthy=0
for i in $(seq 1 16); do
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
case "$status" in
healthy) healthy=1; break ;;
unhealthy) break ;;
esac
sleep 15
done
if [ "$healthy" -eq 1 ]; then
log "CMS healthy after update (commit $(git rev-parse --short HEAD))"
else
log "WARNING: container not healthy (status='${status:-unknown}')"
# Leave the container running so it can be debugged; report failure exit.
exit 3
fi
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
fi
fi
log "=== docker-update finished OK ==="
exit 0
-207
View File
@@ -1,207 +0,0 @@
import "./load-env";
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { readFurniData } from "@/lib/services/furni-data";
const ICON_DIR = path.join(
process.cwd(),
"public",
"swf",
"dcr",
"hof_furni",
"icons",
);
const NITRO_DIR = path.join(
process.cwd(),
"public",
"swf",
"dcr",
"hof_furni",
"nitro",
);
function md5(p: string): string {
return createHash("md5").update(readFileSync(p)).digest("hex");
}
async function main(): Promise<void> {
// ── 1. DB state ──────────────────────────────────────────────────
const [spriteDrift] = (await db.execute(
sql`SELECT COUNT(*) AS n FROM items_base WHERE sprite_id <> id`,
)) as unknown as [Array<{ n: number }>, unknown];
console.log(
`[1] items_base rows with sprite_id <> id: ${Number(spriteDrift[0]?.n ?? 0)}`,
);
const baseRows = await db
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
.from(ItemsBase);
console.log(` items_base total: ${baseRows.length}`);
const catRows = await db
.select({
id: CatalogItems.id,
itemIds: CatalogItems.itemIds,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems);
console.log(` catalog_items total: ${catRows.length}`);
// catalog_items.item_ids -> dangling items_base refs
let dangling = 0;
let nameMismatch = 0;
const nameById = new Map(baseRows.map((r) => [r.id, r.itemName]));
for (const c of catRows) {
const first = Number(String(c.itemIds ?? "").split(/[;,]/)[0]);
if (!Number.isFinite(first) || !nameById.has(first)) {
dangling++;
continue;
}
if (c.catalogName && nameById.get(first) !== c.catalogName) nameMismatch++;
}
console.log(`[2] catalog_items with dangling item_ids: ${dangling}`);
console.log(
` catalog_items with catalog_name <> items_base.item_name: ${nameMismatch}`,
);
// ── 2. Furnidata vs items_base ───────────────────────────────────
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Array<Record<string, unknown>> }
>;
interface Entry {
id: number;
classname: string;
offerid: number;
}
const entries: Entry[] = [];
const seenClass = new Map<string, number>();
let dupClass = 0;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
const id = Number(e?.id);
const cn = typeof e?.classname === "string" ? e.classname : "";
if (!Number.isFinite(id) || !cn) continue;
if (seenClass.has(cn)) dupClass++;
seenClass.set(cn, (seenClass.get(cn) ?? 0) + 1);
entries.push({ id, classname: cn, offerid: Number(e?.offerid) });
}
}
console.log(
`[3] furnidata entries: ${entries.length} (duplicate classnames: ${dupClass})`,
);
const dbByClass = new Map(baseRows.map((r) => [r.itemName, r.id]));
const dbById = new Map(baseRows.map((r) => [r.id, r.itemName]));
let fdWrongId = 0;
let _fdIdConflict = 0; // id belongs to a different classname in DB
let fdMissingInDb = 0;
const fdExamplesWrong: string[] = [];
for (const e of entries) {
const dbId = dbByClass.get(e.classname);
if (dbId === undefined) {
fdMissingInDb++;
continue;
}
if (dbId !== e.id) {
fdWrongId++;
if (fdExamplesWrong.length < 10)
fdExamplesWrong.push(
`${e.classname}: furnidata id=${e.id} vs db id=${dbId}`,
);
continue;
}
if (dbById.get(e.id) !== e.classname) _fdIdConflict++;
}
console.log(
` furnidata entries whose id != items_base.id for same classname: ${fdWrongId}`,
);
console.log(
` furnidata entries not present in items_base at all: ${fdMissingInDb}`,
);
if (fdExamplesWrong.length)
console.log(` examples:\n ${fdExamplesWrong.join("\n ")}`);
// items_base rows missing from furnidata
const fdClasses = new Set(entries.map((e) => e.classname));
const missingFd = baseRows.filter((r) => !fdClasses.has(r.itemName));
console.log(
` items_base rows missing from furnidata: ${missingFd.length}`,
);
if (missingFd.length > 0)
console.log(
` examples: ${missingFd
.slice(0, 10)
.map((r) => `${r.id}:${r.itemName}`)
.join(", ")}`,
);
// ── 3. Assets on disk ────────────────────────────────────────────
const catalogClasses = new Set<string>();
for (const c of catRows) {
const nm = nameById.get(Number(String(c.itemIds ?? "").split(/[;,]/)[0]));
if (nm) catalogClasses.add(nm.replace(/\*/g, "_"));
}
const files = readdirSync(ICON_DIR);
const iconSet = new Set(files);
let missingIcons = 0;
const missingIconExamples: string[] = [];
for (const cls of catalogClasses) {
const f = `${cls}_icon.png`;
if (!iconSet.has(f)) {
missingIcons++;
if (missingIconExamples.length < 15) missingIconExamples.push(f);
}
}
console.log(
`[4] catalog items without local icon file: ${missingIcons} of ${catalogClasses.size}`,
);
if (missingIconExamples.length)
console.log(` examples: ${missingIconExamples.join(", ")}`);
// duplicate-content icons across different classnames (suspicious downloads)
const byHash = new Map<string, string[]>();
for (const f of files) {
if (!f.endsWith("_icon.png")) continue;
const p = path.join(ICON_DIR, f);
const st = statSync(p);
if (st.size === 0) continue;
const h = md5(p);
const arr = byHash.get(h) ?? [];
arr.push(f);
byHash.set(h, arr);
}
let dupFiles = 0;
const dupGroups: string[][] = [];
for (const [, arr] of byHash) {
if (arr.length < 2) continue;
dupFiles += arr.length - 1;
if (dupGroups.length < 8) dupGroups.push(arr);
}
console.log(
` byte-identical icon files across different classnames: ${dupFiles} extra copies`,
);
for (const g of dupGroups)
console.log(
` ${g.slice(0, 6).join(" == ")}${g.length > 6 ? ` (+${g.length - 6} more)` : ""}`,
);
let nitroMissing = 0;
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
for (const cls of catalogClasses) {
if (!nitroSet.has(`${cls}.nitro`)) nitroMissing++;
}
console.log(`[5] catalog items without .nitro bundle: ${nitroMissing}`);
await db.$client.end();
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
-20
View File
@@ -1,20 +0,0 @@
#!/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}"
echo "Waiting for FlareSolverr at ${URL}..."
for i in $(seq 1 "$MAX_RETRIES"); do
if curl -sf "${URL}/health" >/dev/null 2>&1; then
echo "FlareSolverr is healthy."
exit 0
fi
echo "Attempt ${i}/${MAX_RETRIES} - FlareSolverr not ready, retrying in ${RETRY_INTERVAL}s..."
sleep "$RETRY_INTERVAL"
done
echo "ERROR: FlareSolverr did not become healthy after ${MAX_RETRIES} attempts."
exit 1
+2 -53
View File
@@ -1,18 +1,10 @@
import "./load-env";
import { Cron } from "croner";
import { and, eq, lt, lte, sql } from "drizzle-orm";
import { lt, sql } from "drizzle-orm";
import { env } from "../src/env";
import {
db,
PasswordReset,
WebsiteArticles,
WebsiteLoginLogs,
} from "../src/lib/db";
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis";
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
import { invalidateNewsCache } from "../src/lib/services/news-cache";
import { rcon } from "../src/lib/services/rcon";
function captureWorkerError(err: unknown, context: string): void {
@@ -231,41 +223,7 @@ async function cleanupOldSessions(): Promise<void> {
}
}
async function publishScheduledArticles(): Promise<void> {
try {
const now = new Date();
const result = await db
.update(WebsiteArticles)
.set({
status: "published",
publishedAt: now,
updatedAt: now,
})
.where(
and(
eq(WebsiteArticles.status, "scheduled"),
lte(WebsiteArticles.publishAt, now),
),
);
const [info] = result;
const published = info.affectedRows ?? 0;
if (published > 0) {
await invalidateNewsCache();
logger.info(`Published ${published} scheduled article(s)`, {
module: "jobs",
});
}
} catch (err) {
captureWorkerError(err, "Scheduled article publish failed");
}
}
async function main() {
new Cron("* * * * *", () => {
runCatalogExport().catch((e) =>
captureWorkerError(e, "Catalog export failed"),
);
});
logger.info("Worker started", { module: "jobs" });
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
@@ -298,15 +256,6 @@ async function main() {
});
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
new Cron("* * * * *", () => {
publishScheduledArticles().catch((e) =>
captureWorkerError(e, "ScheduledArticlePublish"),
);
});
logger.info("Scheduled: publish scheduled articles (every minute)", {
module: "jobs",
});
await Promise.all([
backupEmulatorJar(),
cleanupOldLogs(),
-19
View File
@@ -1,19 +0,0 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
// Standalone scripts (tsx) don't auto-load .env like Next.js does, so the
// deploy step symlinks ${LIVE}/.env into the stage but nothing ever read it.
// Node's loadEnvFile() never overrides already-set variables, so real shell
// env wins; we load .env.local first so it takes precedence over .env.
function loadEnvFile(): void {
for (const name of [".env.local", ".env"]) {
const file = resolve(ROOT, name);
if (existsSync(file)) process.loadEnvFile(file);
}
}
loadEnvFile();
+20 -78
View File
@@ -1,5 +1,5 @@
const fs = require("node:fs");
const { parse, printParseErrorCode } = require("jsonc-parser");
const JSON5 = require("json5");
const exampleFile = process.argv[2];
const targetFile = process.argv[3];
@@ -8,72 +8,37 @@ if (!exampleFile || !targetFile) {
process.exit(1);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
// Merge the upstream example into the live config with strict "add missing,
// never remove or overwrite" semantics so hotel configs survive every update:
// - every key already present in the live config is PRESERVED (scalars,
// arrays and nested objects always win over the upstream example),
// - nested objects are merged recursively so upstream-only sub-keys are
// added while the hotel's own sub-keys stay untouched,
// - keys that do not exist in the live config yet are ADDED from the example,
// - a source object NEVER replaces an existing scalar/array (would corrupt).
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (isPlainObject(source[key])) {
if (isPlainObject(result[key])) {
result[key] = deepMerge(result[key], source[key]);
} else if (!(key in result)) {
result[key] = deepMerge({}, source[key]);
if (
source[key] !== null &&
typeof source[key] === "object" &&
!Array.isArray(source[key])
) {
result[key] = deepMerge(target[key] || {}, source[key]);
} else {
if (!(key in result)) {
result[key] = source[key];
}
// Else: live value is a scalar/array/null -> keep it untouched.
} else if (!(key in result)) {
result[key] = source[key];
}
}
return result;
}
function parseJsonc(file) {
if (!fs.existsSync(file)) {
return undefined;
}
const content = fs.readFileSync(file, "utf-8");
const errors = [];
const value = parse(content, errors, {
allowTrailingComma: true,
allowEmptyContent: true,
});
if (errors.length > 0) {
console.error(
`[merge-config] parse error in ${file}: ${errors
.map((e) => printParseErrorCode(e.error))
.join(", ")}`,
);
return undefined;
}
return value;
}
let example;
try {
example = parseJsonc(exampleFile);
const content = fs.readFileSync(exampleFile, "utf-8");
example = JSON5.parse(content);
} catch {
process.exit(1);
}
if (example === undefined) {
process.exit(1);
}
let current = {};
try {
const parsed = parseJsonc(targetFile);
if (parsed !== undefined && parsed !== null) {
current = parsed;
if (fs.existsSync(targetFile)) {
const content = fs.readFileSync(targetFile, "utf-8");
current = JSON5.parse(content);
}
} catch {
current = {};
@@ -81,31 +46,8 @@ try {
const merged = deepMerge(current, example);
// Sanity: the merged result must stay an object and the written file must be
// re-parseable. If anything goes wrong we keep the live file untouched rather
// than shipping a corrupted config to the client.
if (merged === undefined || merged === null || typeof merged !== "object") {
process.stderr.write(
`[merge-config] refused to write non-object result for ${targetFile}\n`,
);
process.exit(1);
}
// Verify the result parses round-trip before overwriting the live config.
const serialized = `${JSON.stringify(merged, null, 4)}\n`;
let check;
try {
check = parse(serialized, [], {
allowTrailingComma: true,
allowEmptyContent: true,
});
} catch {
check = undefined;
}
if (!check || typeof check !== "object" || Array.isArray(check)) {
process.stderr.write(
`[merge-config] refused to write unparseable result for ${targetFile}\n`,
);
process.exit(1);
}
fs.writeFileSync(targetFile, serialized);
const isJson5 = targetFile.endsWith(".json5");
const output = isJson5
? JSON5.stringify(merged, null, 4)
: JSON.stringify(merged, null, 4);
fs.writeFileSync(targetFile, `${output}\n`);
+1
View File
@@ -8,6 +8,7 @@
* Usage:
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
*/
import "dotenv/config";
import {
createCipheriv,
createDecipheriv,
-5
View File
@@ -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();
}
-503
View File
@@ -1,503 +0,0 @@
<?php
/**
* sync-furnidata-ids.php
* --------------------------------------------------------------------------
* Foolproof synchronisation of the emulator furniture tables (`items_base`
* and `catalog_items`) against FurnitureData.json.
*
* FurnitureData.json is treated as the SINGLE SOURCE OF TRUTH for the sprite
* id (`id`) and the class name (`classname` -> `items_base.item_name`).
*
* What this script guarantees:
* 1. Every furniture entry in furnidata is matched to an `items_base` row by
* `item_name`. Missing rows are INSERTed with the exact furnidata id.
* 2. Existing rows whose `id` differs from furnidata are moved to the
* furnidata id. Because `items_base.id` is referenced by ~22 other
* tables, the move is implemented as a multi-pass PRIMARY KEY swap that
* rewrites EVERY referencing column (int FKs and the `item_ids` string
* lists) so no foreign key / shop reference is ever broken.
* 3. `sprite_id` is kept equal to `id` and `catalog_items.catalog_name`
* is normalised to the base `item_name` after the move.
*
* SAFETY:
* - The entire DML workload runs inside ONE InnoDB transaction. On any
* exception it is rolled back completely (zero corruption).
* - Uses TEMPORARY tables only, so no DDL implicit-commit escapes the txn.
* - `--dry-run` (default) only reports; pass `--apply` to write.
*
* USAGE:
* php sync-furnidata-ids.php # dry run, prints plan
* php sync-furnidata-ids.php --apply # execute
*
* ADAPTATION:
* This uses raw PDO. In a Laravel command swap the PDO bootstrap for the
* `DB::` facade and replace `$pdo->prepare()/execute()` with `DB::...`; the
* transaction calls map 1:1: `DB::beginTransaction()`, `DB::commit()`,
* `DB::rollBack()`. The SQL is identical.
*/
declare(strict_types=1);
/* ───────────────────────────── CONFIG ───────────────────────────── */
// Either hard-code here or pull from environment / .env.
$DB_HOST = getenv('DB_HOST') ?: '127.0.0.1';
$DB_PORT = getenv('DB_PORT') ?: '3306';
$DB_NAME = getenv('DB_DATABASE') ?: getenv('DB_NAME') ?: 'retro';
$DB_USER = getenv('DB_USERNAME') ?: getenv('DB_USER') ?: 'root';
$DB_PASS = getenv('DB_PASSWORD') ?: getenv('DB_PASS') ?: '';
// Absolute path to FurnitureData.json.
$FURNIDATA_PATH = getenv('FURNIDATA_PATH')
?: '/var/www/atom-nexst/public/gamedata/config/FurnitureData.json';
$APPLY = in_array('--apply', $argv, true);
$DRY = !$APPLY;
/* Referencing integer columns that store a base id (item_id / sprite_id). */
$INT_COLS = [
['items', 'item_id'],
['room_templates_items', 'item_id'],
['catalog_items_limited', 'item_id'],
['crafting_recipes_ingredients', 'item_id'],
['items_crackable', 'item_id'],
['gift_wrappers', 'sprite_id'],
['gift_wrappers', 'item_id'],
['trax_playlist', 'item_id'],
['pet_drinks', 'item_id'],
['pet_foods', 'item_id'],
['pet_items', 'item_id'],
['marketplace_items', 'item_id'],
['calendar_rewards', 'item_id'],
['builders_club_items', 'item_id'],
['youtube_playlists', 'item_id'],
['room_trax_playlist', 'item_id'],
['recycler_prizes', 'item_id'],
['website_event_prizes', 'item_id'],
['website_rare_values', 'item_id'],
['catalog_products', 'item_id'],
['room_trade_log_items', 'item_id'],
['logs_economy', 'item_id'],
];
/* Referencing string columns that hold a single numeric base id.
NOTE: this DB uses the American spelling `catalog_items` (not `catalogue_items`). */
$STR_COLS = [
['catalog_items', 'item_ids'],
['catalog_items_bc', 'item_ids'],
['logs_shop_purchases', 'item_ids'],
['catalog_version_offers', 'item_ids'],
];
/* ──────────────────────────── HELPERS ──────────────────────────── */
function log_line(string $msg): void
{
fwrite(STDOUT, $msg . PHP_EOL);
}
function pdo(): PDO
{
static $p;
if ($p) {
return $p;
}
global $DB_HOST, $DB_PORT, $DB_NAME, $DB_USER, $DB_PASS;
$p = new PDO(
"mysql:host={$DB_HOST};port={$DB_PORT};dbname={$DB_NAME};charset=utf8mb4",
$DB_USER,
$DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
return $p;
}
function q(string $sql, array $params = []): array
{
$stmt = pdo()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
function exec_sql(string $sql, array $params = []): int
{
$stmt = pdo()->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
/* Convert a numeric expression to a CHAR in the target column's own charset/
collation so a JOIN/comparison never hits "Illegal mix of collations". */
function numToStr(string $table, string $col, string $expr): string
{
static $cache = [];
$key = "{$table}.{$col}";
if (!isset($cache[$key])) {
$r = pdo()
->query(
"SELECT CHARACTER_SET_NAME, COLLATION_NAME FROM information_schema.COLUMNS "
. "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = '{$col}'"
)
->fetch();
$cache[$key] = ($r && $r['CHARACTER_SET_NAME']) ? [$r['CHARACTER_SET_NAME'], $r['COLLATION_NAME']] : null;
}
$info = $cache[$key];
if (!$info) {
return "CAST({$expr} AS CHAR)";
}
return "CONVERT({$expr}, CHAR CHARACTER SET {$info[0]}) COLLATE {$info[1]}";
}
/* ──────────────────────────── STEP 1 ──────────────────────────── */
/* READ + PARSE FURNIDATA */
log_line('[1/6] Reading FurnitureData.json ...');
if (!is_readable($FURNIDATA_PATH)) {
throw new RuntimeException("Cannot read furnidata: {$FURNIDATA_PATH}");
}
$furniRaw = json_decode((string) file_get_contents($FURNIDATA_PATH), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($furniRaw)) {
throw new RuntimeException('furnidata.json did not decode to an array');
}
$allEntries = [];
foreach (['roomitemtypes', 'wallitemtypes'] as $section) {
$list = $furniRaw[$section]['furnitype'] ?? [];
if (!is_array($list)) {
continue;
}
foreach ($list as $e) {
if (
isset($e['classname']) && is_string($e['classname'])
&& isset($e['id']) && is_numeric($e['id'])
) {
$id = (int) $e['id'];
if ($id > 0) {
$allEntries[] = ['section' => $section, 'classname' => $e['classname'], 'id' => $id];
}
}
}
}
log_line(' furnidata entries parsed: ' . count($allEntries));
/* ── global iterative duplicate-id resolution ──────────────────── */
/* If two classnames claim the same id, the one already occupying that id in
the DB keeps it; the loser is patched back to its own DB id (and we simply
drop its claim so it is not forced onto a contested id). */
$desired = []; // classname => furnidata id
foreach ($allEntries as $e) {
$cn = $e['classname'];
if ($cn === '' || isset($desired[$cn])) {
continue;
}
$desired[$cn] = $e['id'];
}
$rows = q('SELECT id, item_name FROM items_base');
$nameById = [];
$idByName = [];
$maxId = 0;
foreach ($rows as $r) {
$id = (int) $r['id'];
$nameById[$id] = $r['item_name'];
$idByName[$r['item_name']] = $id;
$maxId = max($maxId, $id);
}
for (;;) {
$claimsById = [];
foreach ($desired as $cn => $id) {
$claimsById[$id][] = $cn;
}
$found = false;
ksort($claimsById);
foreach ($claimsById as $id => $claimants) {
if (count($claimants) < 2) {
continue;
}
$live = array_values(array_filter($claimants, fn ($cn) => ($desired[$cn] ?? null) === $id));
if (count($live) < 2) {
continue;
}
$found = true;
$dbHolder = $nameById[$id] ?? null;
$winner = ($dbHolder !== null && in_array($dbHolder, $live, true)) ? $dbHolder : $live[0];
foreach ($live as $cn) {
if ($cn === $winner) {
continue;
}
unset($desired[$cn]); // loser loses its contested claim
}
}
if (!$found) {
break;
}
}
log_line(' furnidata classnames after dup-resolution: ' . count($desired));
/* ──────────────────────────── STEP 2 ──────────────────────────── */
/* COMPUTE ID MOVES (existing rows -> furnidata id) */
$fresh = max($maxId, max(0, ...array_values($desired))) + 1000;
$moves = []; // old_id => new_id
foreach ($rows as $r) {
$cn = $r['item_name'];
$target = $desired[$cn] ?? null;
if ($target !== null && $target !== (int) $r['id']) {
$moves[(int) $r['id']] = $target;
}
}
/* Squatter displacement: a row sitting on a contested id with no furnidata
entry of its own must make room for the rightful claimant. */
$occupied = array_fill_keys(array_keys($nameById), true);
for (;;) {
$destCount = [];
foreach ($moves as $t) {
$destCount[$t] = ($destCount[$t] ?? 0) + 1;
}
$changed = false;
foreach ($moves as $oldId => $target) {
if (!isset($occupied[$target])) {
continue; // target already free
}
if (isset($moves[$target])) {
continue; // target itself is being vacated
}
$holder = $nameById[$target] ?? null;
if ($holder !== null && ($desired[$holder] ?? null) === $target) {
continue; // legit stayer
}
$moves[$target] = $fresh++;
$changed = true;
break;
}
if (!$changed) {
break;
}
}
/* Drop moves that land on an id a legit stayer keeps forever. */
for (;;) {
$changed = false;
foreach ($moves as $oldId => $target) {
if (!isset($occupied[$target]) || isset($moves[$target])) {
continue;
}
$holder = $nameById[$target] ?? null;
if ($holder !== null && ($desired[$holder] ?? null) === $target) {
unset($moves[$oldId]);
$changed = true;
break;
}
}
if (!$changed) {
break;
}
}
log_line(' planned id moves: ' . count($moves));
if ($DRY) {
$i = 0;
foreach ($moves as $o => $t) {
if ($i++ >= 10) {
break;
}
log_line(" '{$nameById[$o]}' : {$o} -> {$t}");
}
}
if ($DRY) {
log_line('[DRY RUN] No changes written. Re-run with --apply to execute.');
exit(0);
}
/* ──────────────────────────── STEP 3 ──────────────────────────── */
/* TRANSACTION-SAFE APPLICATION */
log_line('[3/6] Applying inside a single transaction ...');
try {
pdo()->beginTransaction();
// TEMPORARY table => no implicit commit, lives only for this transaction.
exec_sql('DROP TEMPORARY TABLE IF EXISTS _id_map_pass');
exec_sql(
'CREATE TEMPORARY TABLE _id_map_pass (
old_id INT PRIMARY KEY,
new_id INT NOT NULL,
UNIQUE KEY uniq_new (new_id)
) ENGINE=InnoDB'
);
exec_sql('SET FOREIGN_KEY_CHECKS = 0');
$occupiedIds = array_fill_keys(array_keys($nameById), true);
$pending = [];
foreach ($moves as $oldId => $target) {
$pending[] = [$oldId, $target];
}
$scratch = $fresh;
$passes = 0;
$applyPass = function (array $pairs) use (&$occupiedIds, $INT_COLS, $STR_COLS): void {
if ($pairs === []) {
return;
}
// (Re)load the pass map.
exec_sql('TRUNCATE TABLE _id_map_pass');
foreach (array_chunk($pairs, 500) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '(?,?)'));
$flat = [];
foreach ($chunk as [$o, $nw]) {
$flat[] = $o;
$flat[] = $nw;
}
exec_sql("INSERT INTO _id_map_pass (old_id, new_id) VALUES {$ph}", $flat);
}
// Move the PK on items_base itself.
exec_sql(
'UPDATE items_base b JOIN _id_map_pass m ON b.id = m.old_id SET b.id = m.new_id'
);
// Cascade to every integer foreign-key column (skip tables that don't exist).
static $intCache = null;
if ($intCache === null) {
$intCache = [];
foreach ($INT_COLS as [$table, $col]) {
$exists = (int) pdo()
->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}'")
->fetchColumn();
if ($exists) {
$intCache[] = [$table, $col];
} else {
log_line(" [warn] skipping missing table `{$table}`");
}
}
}
foreach ($intCache as [$table, $col]) {
exec_sql(
"UPDATE `{$table}` t JOIN _id_map_pass m ON t.`{$col}` = m.old_id SET t.`{$col}` = m.new_id"
);
}
// Cascade to every `item_ids` string column (single numeric id).
static $strCache = null;
if ($strCache === null) {
$strCache = [];
foreach ($STR_COLS as [$table, $col]) {
$exists = (int) pdo()
->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}'")
->fetchColumn();
if ($exists) {
$strCache[] = [$table, $col];
} else {
log_line(" [warn] skipping missing table `{$table}`");
}
}
}
foreach ($strCache as [$table, $col]) {
exec_sql(
"UPDATE `{$table}` t JOIN _id_map_pass m ON t.`{$col}` = " . numToStr($table, $col, 'm.old_id')
. " SET t.`{$col}` = CAST(m.new_id AS CHAR)"
);
}
foreach ($pairs as [$o, $nw]) {
unset($occupiedIds[$o]);
$occupiedIds[$nw] = true;
}
};
// Multi-pass until every move is applied (handles dependency cycles).
// $pending / $safe are LISTS of [old_id, new_id] pairs.
while ($pending !== []) {
$passes++;
$safe = [];
foreach ($pending as [$oldId, $target]) {
if (!isset($occupiedIds[$target])) {
$safe[] = [$oldId, $target];
}
}
if ($safe === []) {
// Cycle: park the smallest row on a scratch id to break it.
$oldest = min(array_column($pending, 0));
$pair = null;
foreach ($pending as $p) {
if ($p[0] === $oldest) {
$pair = $p;
break;
}
}
$target = $pair[1];
$pending = array_filter($pending, fn ($p) => $p[0] !== $oldest);
$applyPass([[$oldest, $scratch]]);
$pending[] = [$scratch, $target];
$scratch++;
continue;
}
$applyPass($safe);
$done = [];
foreach ($safe as [$oldId]) {
$done[] = $oldId;
}
$pending = array_filter($pending, fn ($p) => !in_array($p[0], $done, true));
log_line(" pass {$passes}: moved " . count($safe) . ' ids (' . count($pending) . ' left)');
}
exec_sql('SET FOREIGN_KEY_CHECKS = 1');
exec_sql('DROP TEMPORARY TABLE IF EXISTS _id_map_pass');
/* ───────────────────────── STEP 4: SPRITE_ID + CATALOG ───────────────────────── */
$affected = exec_sql('UPDATE items_base SET sprite_id = id WHERE sprite_id <> id');
log_line(" sprite_id normalised rows: {$affected}");
$cat = exec_sql(
"UPDATE catalog_items ci JOIN items_base ib ON ci.item_ids = " . numToStr('catalog_items', 'item_ids', 'ib.id')
. " SET ci.catalog_name = ib.item_name WHERE ci.catalog_name <> ib.item_name"
);
log_line(" catalog_items.catalog_name normalised rows: {$cat}");
/* ───────────────────────── STEP 5: INSERT MISSING ───────────────────────── */
$inserted = 0;
$skipped = [];
$currentIds = array_fill_keys(array_column(q('SELECT id FROM items_base'), 'id'), true);
foreach ($desired as $cn => $id) {
if (isset($idByName[$cn])) {
continue; // already exists (and now correctly id'd)
}
if (isset($currentIds[$id])) {
$skipped[] = "{$cn} (id {$id} still occupied)";
continue;
}
exec_sql(
'INSERT INTO items_base (id, item_name, sprite_id) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE item_name = VALUES(item_name), sprite_id = VALUES(sprite_id)',
[$id, $cn, $id]
);
$currentIds[$id] = true;
$inserted++;
}
log_line(" inserted missing items: {$inserted}");
if ($skipped !== []) {
log_line(' SKIPPED (id occupied, manual review): ' . implode('; ', $skipped));
}
/* ───────────────────────── STEP 6: AUTO_INCREMENT + COMPOUND ───────────────────────── */
$next = (int) (q('SELECT COALESCE(MAX(id),0) + 1 AS nxt FROM items_base')[0]['nxt'] ?? 1);
exec_sql("ALTER TABLE items_base AUTO_INCREMENT = {$next}");
// Rewrite compound "a;b" lists that may contain moved ids.
$compound = q("SELECT id, item_ids FROM catalog_items WHERE item_ids LIKE '%;%'");
foreach ($compound as $row) {
$mapped = implode(';', array_map(
static fn ($p) => trim($p),
explode(';', (string) $row['item_ids'])
));
exec_sql('UPDATE catalog_items SET item_ids = ? WHERE id = ?', [$mapped, $row['id']]);
}
log_line(' compound item_ids lists checked: ' . count($compound));
pdo()->commit();
log_line('[DONE] Synchronisation committed successfully.');
} catch (Throwable $e) {
if (pdo()->inTransaction()) {
pdo()->rollBack();
}
log_line('[FATAL] Rolled back. Error: ' . $e->getMessage());
exit(1);
}
-136
View File
@@ -1,136 +0,0 @@
// Syncs the Nitro/Octane runtime config URLs from environment variables into the
// renderer-config.json / renderer-config.jsonc / ui-config.json files.
//
// Uses jsonc-parser so JSONC (with // and /* */ comments) is handled safely —
// unlike a naive JSON.parse/json.load this never corrupts URLs that contain
// "https://". After this runs the files are written back as strict JSON (which
// is still valid JSONC), so downstream validation passes.
//
// Env:
// NITRO_SRC_DIR e.g. /var/www/Octane/public/configuration
// NITRO_DIST_CONFIG_DIR e.g. /var/www/Octane/dist/configuration
// NITRO_GAMEDATA_CONF_DIR e.g. /var/www/Gamedata/config
// NITRO_IMAGE_LIBRARY_URL, NITRO_HOF_FURNITURE_URL, NITRO_API_URL,
// NITRO_SOCKET_URL, NITRO_GAMEDATA_URL, NITRO_ASSET_URL,
// NITRO_FURNI_ASSET_ICON_URL
// NITRO_URL_FORCE=1 (optional) overwrite existing values too
const fs = require("node:fs");
const path = require("node:path");
const { parse } = require("jsonc-parser");
const FILES = [
"renderer-config.json",
"renderer-config.jsonc",
"ui-config.json",
];
const FORCE = process.env.NITRO_URL_FORCE === "1";
// Values that must never survive into a production config: placeholder or
// loopback hosts that originate from the upstream example files. When a key
// still holds one of these, it has been newly added by the merge step and has
// to be filled from the environment. A real, already-configured URL (the
// hotel's own domain) is NEVER rewritten, so hotel links stay exactly as the
// hotel owner configured them.
const PLACEHOLDER_RE =
/(localhost|127\.0\.0\.1|0\.0\.0\.0|::1|\.example\.com|nitro\.example\.|hotel\.example\.|example\.org|example\.net|:5173|:2096|\$\{)/i;
function isPlaceholder(value) {
if (!value) return true;
return PLACEHOLDER_RE.test(value);
}
function env(name) {
return process.env[name]?.length ? process.env[name] : null;
}
function applyOverrides(data) {
const image = env("NITRO_IMAGE_LIBRARY_URL");
const hof = env("NITRO_HOF_FURNITURE_URL");
const api = env("NITRO_API_URL");
const socket = env("NITRO_SOCKET_URL");
const gamedata = env("NITRO_GAMEDATA_URL");
const asset = env("NITRO_ASSET_URL");
const icon = env("NITRO_FURNI_ASSET_ICON_URL");
// Add-only, placeholder-aware URL fill-in. Existing real values are kept,
// crypto.* keys are never touched, and only missing/placeholder keys are
// filled from the environment. NITRO_URL_FORCE=1 opts back into overwriting.
const set = (key, value) => {
if (!value || key.startsWith("crypto.")) return;
if (FORCE || !(key in data) || isPlaceholder(data[key])) {
data[key] = value;
}
};
set("image.library.url", image);
set("hof.furni.url", hof);
set("api.url", api);
set("socket.url", socket);
set("gamedata.url", gamedata);
set("asset.url", asset);
set("furni.asset.icon.url", icon);
if (
gamedata &&
(!("radio.url" in data) || isPlaceholder(data["radio.url"]))
) {
data["radio.url"] = `${gamedata}/config/radio-stations.jsonc?t=%timestamp%`;
}
if (
gamedata &&
(!("soundboard.url" in data) || isPlaceholder(data["soundboard.url"]))
) {
data["soundboard.url"] =
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
}
// Never force ads on/off over a hotel's own choice; only default to "off"
// when the key does not exist yet (prevents external ad scripts from 404ing).
if (!("show.google.ads" in data)) data["show.google.ads"] = false;
return data;
}
const dirs = [
env("NITRO_SRC_DIR"),
env("NITRO_DIST_CONFIG_DIR"),
env("NITRO_GAMEDATA_CONF_DIR"),
].filter(Boolean);
let changed = 0;
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
for (const file of FILES) {
const full = path.join(dir, file);
if (!fs.existsSync(full)) continue;
let content;
try {
content = fs.readFileSync(full, "utf-8");
} catch {
continue;
}
let data;
try {
data = parse(content, [], {
allowTrailingComma: true,
allowEmptyContent: true,
});
} catch (e) {
console.error(`[sync-nitro-urls] parse error in ${full}: ${e}`);
continue;
}
if (!data || typeof data !== "object") continue;
const before = JSON.stringify(data);
const updated = applyOverrides(data);
if (JSON.stringify(updated) === before) continue;
try {
fs.writeFileSync(full, `${JSON.stringify(updated, null, 4)}\n`);
changed++;
console.log(` [OK] Synced URLs: ${file} (${dir})`);
} catch (e) {
console.error(`[sync-nitro-urls] write error ${full}: ${e}`);
}
}
}
console.log(` [OK] URL sync complete (${changed} file(s) updated)`);
process.exit(0);
-30
View File
@@ -1,30 +0,0 @@
import "./load-env";
import { buildLocalizedFurniDataFiles } from "../src/lib/services/furni-data-i18n";
async function main() {
const args = process.argv.slice(2);
const maxPerLang = args.includes("--full")
? undefined
: args.includes("--limit")
? parseInt(args[args.indexOf("--limit") + 1] || "2000", 10)
: 1500;
const full = args.includes("--full");
console.log(
`[translate-furnidata] Starting ${full ? "FULL" : `limited ${maxPerLang} per lang`} build...`,
);
const start = Date.now();
try {
const results = await buildLocalizedFurniDataFiles();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`[translate-furnidata] Done in ${elapsed}s`);
for (const r of results) {
console.log(
` ${r.lang} (${r.hotel}): ${r.translatedRoom + r.translatedWall}/${r.total} translated -> ${r.file} ${r.ok ? "OK" : `FAIL ${r.error}`}`,
);
}
} catch (e) {
console.error("Failed", e);
process.exit(1);
}
}
main();
-18
View File
@@ -1,18 +0,0 @@
import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
if (issues.length > 0) {
for (const issue of issues) console.error(issue);
process.exitCode = 1;
} else {
console.log(
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
);
}
+26
View File
@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteAds } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
@@ -116,3 +117,28 @@ export const deleteAd = adminAction(
return actionOk();
},
);
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
export async function deleteAdForm(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
} catch (err) {
logger.error("Action failed: deleteAdForm", {
action: "deleteAdForm",
id: Number(id),
error: err instanceof Error ? err.message : "DB error",
});
}
redirect("/admin/ads");
}
+8 -40
View File
@@ -2,55 +2,23 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
async function removeApplication(id: bigint): Promise<boolean> {
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
return true;
} catch (error) {
logServerError("applications.delete_failed", error, { id: String(id) });
return false;
} catch {
// already gone / no DB — nothing to do
}
}
export async function dismissApplication(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await removeApplication(id);
await logStaffActivity({
staffId: staff.id,
action: "application_dismiss",
description: `Dismissed staff application #${id}`,
targetType: "staff_application",
targetId: Number(id),
});
revalidatePath("/admin/applications");
}
export async function approveApplication(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await removeApplication(id);
await logStaffActivity({
staffId: staff.id,
action: "application_approve",
description: `Approved staff application #${id}`,
targetType: "staff_application",
targetId: Number(id),
});
revalidatePath("/admin/applications");
}
-116
View File
@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [] as unknown[][],
insert: vi.fn(),
update: vi.fn(),
invalidate: vi.fn(),
log: vi.fn(() => "news-error-1"),
notify: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: async () => ({ id: 1, username: "staff" }),
}));
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
vi.mock("@/lib/services/news-cache", () => ({
invalidateNewsCache: state.invalidate,
}));
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
vi.mock("next-intl/server", () => ({
getTranslations: async () => (key: string, args?: { reference: string }) =>
key + (args?.reference ?? ""),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({
redirect: (url: string) => {
throw Error(`redirect ${url}`);
},
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: {
select: () => ({
from: () => ({
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
}),
}),
insert: () => ({ values: state.insert }),
update: () => ({
set: (value: unknown) => {
state.update(value);
return { where: async () => [{ affectedRows: 1 }] };
},
}),
},
}));
import { createArticle, updateArticle } from "./admin-articles";
function form(extra: Record<string, string> = {}) {
const f = new FormData();
for (const [k, v] of Object.entries({
title: "Test news",
slug: "test-news",
shortStory: "Summary",
fullStory: "Body",
status: "published",
...extra,
}))
f.set(k, v);
return f;
}
describe("news saves", () => {
beforeEach(() => {
vi.clearAllMocks();
state.rows = [];
state.insert.mockResolvedValue([{ insertId: 1 }]);
});
it("preserves a recoverable error and logs its reference", async () => {
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
const result = await createArticle(form());
expect(result).toEqual({
ok: false,
error: "saveFailedReferencenews-error-1",
});
expect(state.invalidate).not.toHaveBeenCalled();
expect(state.log).toHaveBeenCalled();
});
it("saves drafts without publication notifications", async () => {
expect((await createArticle(form({ status: "draft" }))).ok).toBe(true);
expect(state.insert).toHaveBeenCalledWith(
expect.objectContaining({
status: "draft",
publishAt: null,
publishedAt: null,
}),
);
expect(state.notify).not.toHaveBeenCalled();
expect(state.invalidate).toHaveBeenCalledOnce();
});
it("preserves the slug and clears old scheduling for immediate publication", async () => {
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
expect(
(
await updateArticle(
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
)
).ok,
).toBe(true);
expect(state.update).toHaveBeenCalledWith(
expect.objectContaining({
slug: "test-news",
publishAt: null,
status: "published",
}),
);
expect(state.invalidate).toHaveBeenCalledOnce();
});
it("rejects invalid scheduled dates before writing", async () => {
expect(
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
.ok,
).toBe(false);
expect(state.insert).not.toHaveBeenCalled();
});
});
+61 -128
View File
@@ -1,167 +1,108 @@
"use server";
import { and, eq, ne } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/lib/admin/guard";
import {
ArticleInputError,
type ArticleSaveResult,
readArticleInput,
} from "@/lib/article-input";
import {
db,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { slugify } from "@/lib/format";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { invalidateNewsCache } from "@/lib/services/news-cache";
import { notify } from "@/lib/services/webhook";
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
let slug = base;
for (let n = 2; ; n++) {
let n = 2;
for (;;) {
const [existing] = await db
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.slug, slug),
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
),
)
.where(eq(WebsiteArticles.slug, slug))
.limit(1);
if (!existing) return slug;
const suffix = `-${n}`;
slug = base.slice(0, 255 - suffix.length) + suffix;
slug = `${base}-${n++}`;
}
}
async function saveFailure(
error: unknown,
operation: string,
): Promise<ArticleSaveResult> {
const t = await getTranslations("pages.admin.articles.form");
if (error instanceof ArticleInputError)
return { ok: false, error: t(error.code) };
const reference = logger.error("News save failed", {
module: "news",
operation,
error,
});
return { ok: false, error: t("saveFailedReference", { reference }) };
}
async function refreshNews() {
await invalidateNewsCache();
revalidatePath("/admin/articles");
revalidatePath("/news", "layout");
revalidatePath("/me");
revalidatePath("/");
}
export async function createArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
export async function createArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
let input: ReturnType<typeof readArticleInput>;
let slug: string;
const title = String(formData.get("title") ?? "")
.normalize("NFC")
.trim();
const shortStory = String(formData.get("shortStory") ?? "")
.normalize("NFC")
.trim();
const fullStory = String(formData.get("fullStory") ?? "")
.normalize("NFC")
.trim();
const image = String(formData.get("image") ?? "")
.normalize("NFC")
.trim();
const rawSlug = String(formData.get("slug") ?? "").trim();
if (!title) return;
try {
input = readArticleInput(formData);
const { rawSlug, ...fields } = input;
slug = await uniqueSlug(rawSlug || fields.title);
const now = new Date();
await db.insert(WebsiteArticles).values({
...fields,
slug,
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
publishedAt: fields.status === "published" ? now : null,
});
} catch (error) {
return saveFailure(error, "create");
} catch {
// Database error — re-render unchanged with error.
redirect(
"/admin/articles/new?error=Database error while creating article. Please try again.",
);
}
// Auxiliary services must not turn a committed insert into an apparent failure.
if (input.status === "published")
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
return { ok: true, data: { redirectTo: "/admin/articles" } };
redirect("/admin/articles");
}
export async function updateArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const t = await getTranslations("pages.admin.articles.form");
const id = formPositiveBigInt(formData, "id");
if (!id) return { ok: false, error: t("articleNotFound") };
let input: ReturnType<typeof readArticleInput>;
let becamePublished = false;
let slug: string;
export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim();
try {
input = readArticleInput(formData);
const [existing] = await db
.select({
slug: WebsiteArticles.slug,
status: WebsiteArticles.status,
publishedAt: WebsiteArticles.publishedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
if (!existing) return { ok: false, error: t("articleNotFound") };
const { rawSlug, ...fields } = input;
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
slug =
requestedSlug === existing.slug
? existing.slug
: await uniqueSlug(requestedSlug, id);
becamePublished =
fields.status === "published" && existing.status !== "published";
await db
.update(WebsiteArticles)
.set({
...fields,
slug,
publishedAt:
fields.status === "published"
? (existing.publishedAt ?? new Date())
: null,
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
shortStory: String(formData.get("shortStory") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
fullStory: String(formData.get("fullStory") ?? "")
.normalize("NFC")
.trim(),
image: String(formData.get("image") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
} catch (error) {
return saveFailure(error, "update");
} catch {
redirect("/admin/articles?error=Update failed");
}
if (becamePublished)
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
revalidatePath(`/admin/articles/${id}`);
return { ok: true, data: { redirectTo: "/admin/articles" } };
redirect("/admin/articles");
}
export async function deleteArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) redirect("/admin/articles?error=Missing article id");
const [article] = await db
.select({ title: WebsiteArticles.title })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
try {
await db.transaction(async (tx) => {
await tx
@@ -172,16 +113,8 @@ export async function deleteArticle(formData: FormData): Promise<void> {
.where(eq(WebsiteArticleComments.articleId, id));
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
});
notify({
action: "news_delete",
actor: staff.username,
target: article?.title ?? String(id),
});
} catch (error) {
logger.error("News deletion failed", { module: "news", error });
} catch {
redirect("/admin/articles?error=Delete failed");
}
await refreshNews();
redirect("/admin/articles");
}
-89
View File
@@ -16,33 +16,6 @@ import {
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
const GUILD_STATES = [0, 1, 2] as const;
const GUILD_FORUM = ["0", "1"] as const;
const GUILD_FORUM_ACCESS = [
"EVERYONE",
"OWNER",
"ADMIN",
"MEMBER",
"NONE",
] as const;
const GUILD_MOD_ACCESS = ["ADMINS", "OWNER", "MEMBER", "NONE"] as const;
function parseGuildState(raw: unknown): number | null {
const value = Number(raw);
return (GUILD_STATES as readonly number[]).includes(value) ? value : null;
}
function parseEnum<T extends string>(
raw: unknown,
allowed: readonly T[],
fallback: T,
): T {
const value = String(raw ?? fallback).trim();
return (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
@@ -90,65 +63,3 @@ export async function disbandGuild(formData: FormData): Promise<void> {
});
revalidatePath("/admin/guilds");
}
export async function updateGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 50);
if (!name) return;
const description = String(formData.get("description") ?? "")
.trim()
.slice(0, 250);
const state = parseGuildState(formData.get("state"));
if (state === null) return;
const forum = parseEnum(formData.get("forum"), GUILD_FORUM, "0");
const readForum = parseEnum(
formData.get("readForum"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const postMessages = parseEnum(
formData.get("postMessages"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const postThreads = parseEnum(
formData.get("postThreads"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const modForum = parseEnum(
formData.get("modForum"),
GUILD_MOD_ACCESS,
"ADMINS",
);
await db
.update(Guilds)
.set({
name,
description,
state,
forum,
readForum,
postMessages,
postThreads,
modForum,
})
.where(eq(Guilds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "guild_update",
description: `Updated guild #${id}`,
targetType: "guild",
targetId: id,
});
revalidatePath("/admin/guilds");
revalidatePath(`/admin/guilds/${id}`);
}
+41
View File
@@ -3,8 +3,35 @@
import { asc } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
import { redirectSafe } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
/**
* Housekeeping table writes are retired. Runtime access uses ACL only.
* Redirect editors to live Permissions.
*/
async function rejectLegacyHousekeepingWrite(): Promise<never> {
await requirePermission(PERMS.SETTINGS_EDIT);
return redirectSafe(
"/admin/permissions?from=housekeeping",
"/admin/permissions",
);
}
export async function upsertPermission(_formData: FormData): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
export async function deletePermission(_formData: FormData): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
export async function bulkImportPermissions(
_formData: FormData,
): Promise<{ count: number; errors: string[] }> {
return rejectLegacyHousekeepingWrite();
}
export async function exportPermissions(): Promise<string> {
await requirePermission(PERMS.SETTINGS_VIEW);
@@ -24,3 +51,17 @@ export async function exportPermissions(): Promise<string> {
return JSON.stringify(perms, null, 2);
}
export async function applyPreset(_formData: FormData): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
export async function clearAllPermissions(): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
export async function bulkDeletePermissions(
_ids: bigint[],
): Promise<{ count: number }> {
return rejectLegacyHousekeepingWrite();
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
ADMIN_NAV_CONFIG_KEY,
type AdminNavConfig,
serializeAdminNavConfig,
} from "@/lib/admin-nav-config";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
const schema = z.object({
groupOrder: z.array(z.string()),
hiddenGroups: z.array(z.string()),
hiddenItems: z.array(z.string()),
itemOrder: z.record(z.string(), z.array(z.string())),
});
export const saveAdminNavConfig = adminAction(
{
permission: PERMS.SETTINGS_EDIT,
schema,
rateLimitKey: "admin-nav-config-save",
rateLimitMax: 30,
},
async (ctx) => {
const config: AdminNavConfig = {
groupOrder: ctx.data.groupOrder,
hiddenGroups: ctx.data.hiddenGroups,
hiddenItems: ctx.data.hiddenItems,
itemOrder: ctx.data.itemOrder,
};
await siteSettings.update(
ADMIN_NAV_CONFIG_KEY,
serializeAdminNavConfig(config),
);
revalidatePath("/admin", "layout");
revalidatePath("/admin/menu");
return actionOk({ saved: true });
},
);
+35 -1
View File
@@ -1,6 +1,6 @@
"use server";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
@@ -34,3 +34,37 @@ export async function deletePhoto(formData: FormData): Promise<void> {
revalidatePath("/admin/photos");
revalidatePath("/photos");
}
export async function bulkDeletePhotos(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const raw = String(formData.get("ids") ?? "");
const ids = raw
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n > 0);
if (ids.length === 0) return;
const rows = await db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.where(inArray(CameraWeb.id, ids));
if (rows.length > 0) {
await db.delete(CameraWeb).where(
inArray(
CameraWeb.id,
rows.map((r) => r.id),
),
);
await Promise.all(rows.map((r) => tryRemoveLocalPhotoFile(r.url)));
await logStaffActivity({
staffId: staff.id,
action: "photo_bulk_delete",
description: `Deleted ${rows.length} camera photo(s)`,
targetType: "camera_web",
});
}
revalidatePath("/admin/photos");
revalidatePath("/photos");
}
+16 -17
View File
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
@@ -51,8 +50,8 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
siteSettings.reload();
} catch (err) {
logger.error("Failed to save radio setting", { err, key });
} catch {
// DB unavailable — fail soft so the action does not throw.
}
revalidatePath("/admin/radio/settings");
}
@@ -82,8 +81,8 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
}),
);
siteSettings.reload();
} catch (err) {
logger.error("Failed to bulk-save radio settings", { err, keys });
} catch {
// Fail soft.
}
revalidatePath("/admin/radio/settings");
}
@@ -115,8 +114,8 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
createdAt: now,
updatedAt: now,
});
} catch (err) {
logger.error("Failed to create radio banner", { err, imagePath });
} catch {
// Fail soft.
}
revalidatePath("/admin/radio/banners");
}
@@ -148,8 +147,8 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
updatedAt: new Date(),
})
.where(eq(RadioBanners.id, id));
} catch (err) {
logger.error("Failed to update radio banner", { err, id: String(id) });
} catch {
// Row may be gone; ignore.
}
revalidatePath("/admin/radio/banners");
}
@@ -160,8 +159,8 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
if (id === null) return;
try {
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
} catch (err) {
logger.error("Failed to delete radio banner", { err, id: String(id) });
} catch {
// Already deleted; ignore.
}
revalidatePath("/admin/radio/banners");
}
@@ -187,8 +186,8 @@ export async function createRadioRank(formData: FormData): Promise<void> {
createdAt: now,
updatedAt: now,
});
} catch (err) {
logger.error("Failed to create radio rank", { err, name });
} catch {
// Fail soft.
}
revalidatePath("/admin/radio/ranks");
}
@@ -215,8 +214,8 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
updatedAt: new Date(),
})
.where(eq(RadioRanks.id, id));
} catch (err) {
logger.error("Failed to update radio rank", { err, id: String(id) });
} catch {
// Row may be gone; ignore.
}
revalidatePath("/admin/radio/ranks");
}
@@ -227,8 +226,8 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
if (id === null) return;
try {
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
} catch (err) {
logger.error("Failed to delete radio rank", { err, id: String(id) });
} catch {
// Already deleted; ignore.
}
revalidatePath("/admin/radio/ranks");
}
-85
View File
@@ -115,88 +115,3 @@ export async function deleteValue(formData: FormData): Promise<void> {
}
revalidatePath("/admin/rare-values");
}
export async function updateCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const priorityRaw = Number(formData.get("priority"));
const priority =
Number.isFinite(priorityRaw) && priorityRaw > 0
? Math.floor(priorityRaw)
: 1;
if (!name || !badge) return;
try {
await db
.update(WebsiteRareValueCategories)
.set({ name, badge, priority })
.where(eq(WebsiteRareValueCategories.id, id));
} catch {
// Unique name collision or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function updateValue(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const categoryId = formPositiveBigInt(formData, "categoryId");
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId"));
const itemId =
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const currencyType =
String(formData.get("currencyType") ?? "diamonds")
.trim()
.slice(0, 255) || "diamonds";
try {
const sets: Record<string, unknown> = {
name,
itemId,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
};
if (categoryId) sets.categoryId = categoryId;
await db
.update(WebsiteRareValues)
.set(sets)
.where(eq(WebsiteRareValues.id, id));
} catch {
// DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
+3 -3
View File
@@ -77,9 +77,9 @@ export async function updateSetting(formData: FormData): Promise<void> {
);
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
.update(WebsiteSetting)
.set({ value })
.where(eq(WebsiteSetting.key, key));
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
-55
View File
@@ -83,58 +83,3 @@ export async function deleteVoucher(input: {
return actionError("Could not delete voucher");
}
}
export async function updateVoucher(input: {
id: string;
code: string;
amount: number;
maxUses: number;
expiresAt?: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.SHOP_EDIT);
const id = positiveBigInt(String(input.id ?? "").trim());
if (!id) return actionError("Missing voucher id");
const code = String(input.code ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const amount = Number(input.amount);
const maxUsesRaw = Number(input.maxUses);
const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) {
return actionError("Code and a positive amount are required");
}
let expiresAt: Date | null = null;
const expiresRaw = String(input.expiresAt ?? "")
.normalize("NFC")
.trim();
if (expiresRaw) {
const parsed = new Date(expiresRaw);
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
}
try {
await db
.update(WebsiteShopVouchers)
.set({
code,
amount: Math.floor(amount),
maxUses,
expiresAt,
updatedAt: new Date(),
})
.where(eq(WebsiteShopVouchers.id, id));
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
logServerError("admin.voucher_update_failed", error, {
voucherId: String(id),
});
return actionError("Could not update voucher (code may already exist)");
}
}
+62 -34
View File
@@ -1,24 +1,36 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { checkLogin } from "@/lib/auth/password";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
import { precheckLogin } from "./auth-precheck";
const core = vi.hoisted(() => ({
getLoginUser: vi.fn(),
verifyLoginPassword: vi.fn(),
isEmailUnverified: vi.fn(),
runDummyHashCheck: vi.fn(),
normalizeLoginInput: (username: unknown, password: unknown) => ({
username: String(username ?? "")
.normalize("NFC")
.trim(),
password: String(password ?? "").normalize("NFC"),
}),
}));
const { selectLimit } = vi.hoisted(() => {
const selectLimit = vi.fn().mockResolvedValue([]);
return { selectLimit };
});
vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
vi.mock("@/lib/auth/login-core", () => core);
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
},
User: {
password: "password",
twoFactorConfirmedAt: "twoFactorConfirmedAt",
mail: "mail",
mailVerified: "mailVerified",
username: "username",
},
}));
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: vi.fn(),
@@ -28,35 +40,37 @@ vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { getBool: vi.fn() },
}));
const user = (overrides = {}) => ({
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
core.getLoginUser.mockResolvedValue(null);
core.verifyLoginPassword.mockResolvedValue({ valid: true });
core.isEmailUnverified.mockResolvedValue(false);
core.runDummyHashCheck.mockResolvedValue(undefined);
selectLimit.mockResolvedValue([]);
});
describe("precheckLogin", () => {
it("returns ok for valid login without 2FA", async () => {
core.getLoginUser.mockResolvedValue(user());
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
},
]);
expect(await precheckLogin("user", "pass")).toBe("ok");
});
it("returns twofactor when 2FA is set up", async () => {
core.getLoginUser.mockResolvedValue(
user({ twoFactorConfirmedAt: new Date() }),
);
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: new Date(),
mail: null,
mailVerified: "0",
},
]);
expect(await precheckLogin("user", "pass")).toBe("twofactor");
});
@@ -69,20 +83,34 @@ describe("precheckLogin", () => {
provider: "hcaptcha",
} as never);
vi.mocked(verifyCaptcha).mockResolvedValue(false);
core.getLoginUser.mockResolvedValue(user());
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
},
]);
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
});
it("returns invalid when user not found (dummy hash check)", async () => {
core.getLoginUser.mockResolvedValue(null);
selectLimit.mockResolvedValue([]);
const result = await precheckLogin("nonexistent", "pass");
expect(result).toBe("invalid");
expect(core.runDummyHashCheck).toHaveBeenCalled();
expect(checkLogin).toHaveBeenCalled();
});
it("returns unverified when email verification required", async () => {
core.getLoginUser.mockResolvedValue(user({ mail: "[email protected]" }));
core.isEmailUnverified.mockResolvedValue(true);
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: null,
mail: "[email protected]",
mailVerified: "0",
},
]);
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("unverified");
});
});
+45 -12
View File
@@ -1,14 +1,12 @@
"use server";
import {
getLoginUser,
isEmailUnverified,
normalizeLoginInput,
runDummyHashCheck,
verifyLoginPassword,
} from "@/lib/auth/login-core";
import { eq } from "drizzle-orm";
import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password";
import { db, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export type PrecheckResult =
| "ok"
@@ -27,7 +25,10 @@ export async function precheckLogin(
password: string,
captchaToken?: string | null,
): Promise<PrecheckResult> {
const { username: u, password: p } = normalizeLoginInput(username, password);
const u = String(username ?? "")
.normalize("NFC")
.trim();
const p = String(password ?? "");
if (!u || !p) return "invalid";
const ip = await clientIp();
@@ -38,17 +39,49 @@ export async function precheckLogin(
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
}
const user = await getLoginUser(u);
let user: {
password: string;
twoFactorConfirmedAt: Date | null;
mail: string | null;
mailVerified: string;
} | null;
try {
const [row] = await db
.select({
password: User.password,
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
mail: User.mail,
mailVerified: User.mailVerified,
})
.from(User)
.where(eq(User.username, u))
.limit(1);
user = row ?? null;
} catch {
return "invalid";
}
if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check.
await runDummyHashCheck(p);
await checkLogin(
p,
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
{
convertPasswords: false,
},
);
return "invalid";
}
const res = await verifyLoginPassword(user, p);
const res = await checkLogin(p, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
});
if (!res.valid) return "invalid";
if (await isEmailUnverified(user)) {
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return "unverified";
}
+185 -146
View File
@@ -1,24 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
createBcOfferCommand,
updateBcOfferCommand,
} from "@/features/catalog/server/offer-commands";
import {
createPageCommand,
deletePageCommand,
reorderPagesCommand,
togglePageCommand,
updatePageCommand,
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, db } from "@/lib/db";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BC_PAGE_FIELDS = [
@@ -62,51 +49,42 @@ function pickAllowed(
export async function updateBcPage({
id,
expected,
...fields
}: { id: number; expected?: Record<string, unknown> } & Record<
string,
unknown
>) {
}: { id: number } & Record<string, unknown>) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const data = pickAllowed(fields, BC_PAGE_FIELDS);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
try {
await updatePageCommand("bc", id, data, expected);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_update",
description: `Updated BC catalog page #${id}`,
targetType: "catalog_page_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
const data = pickAllowed(fields, BC_PAGE_FIELDS);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogPagesBc)
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_update",
description: `Updated BC catalog page #${id}`,
targetType: "catalog_page_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
export async function deleteBcItem({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_delete",
description: `Deleted BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_delete",
description: `Deleted BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
export async function updateBcItem({
@@ -120,27 +98,24 @@ export async function updateBcItem({
extradata?: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
try {
await updateBcOfferCommand(id, safe);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_update",
description: `Updated BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogItemsBc)
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
.where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_update",
description: `Updated BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
export async function createBcItem({
@@ -154,19 +129,18 @@ export async function createBcItem({
extradata: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const createdId = await createBcOfferCommand({ pageId, ...data });
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
description: `Created BC catalog item #${createdId}`,
targetType: "catalog_item_bc",
targetId: createdId,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
description: `Created BC catalog item #${createdId}`,
targetType: "catalog_item_bc",
targetId: createdId,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
}
export async function toggleBcPage({
@@ -177,12 +151,22 @@ export async function toggleBcPage({
field: "enabled" | "visible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await togglePageCommand("bc", id, field);
await sendCatalogUpdate();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
await db
.update(CatalogPagesBc)
.set({ [field]: page[field] === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
export async function createBcPage(input: {
@@ -196,31 +180,52 @@ export async function createBcPage(input: {
orderNum?: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const createdId = await createPageCommand("bc", {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
});
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc",
targetId: createdId,
});
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
const [result] = await db.insert(CatalogPagesBc).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc",
targetId: createdId,
});
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
}
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const [parent] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await db
.update(CatalogPagesBc)
.set({ parentId: newParentId })
.where(eq(CatalogPagesBc.id, pageId));
}
export async function reorderBcTreePage(input: {
@@ -229,16 +234,24 @@ export async function reorderBcTreePage(input: {
newOrderNum: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await updatePageCommand("bc", input.pageId, {
parentId: input.newParentId,
orderNum: input.newOrderNum,
});
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
});
if (input.newParentId !== undefined) {
try {
await moveBcPage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await db
.update(CatalogPagesBc)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPagesBc.id, input.pageId));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
}
export async function deleteBcTreePage(input: {
@@ -246,26 +259,52 @@ export async function deleteBcTreePage(input: {
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePageCommand("bc", input.pageId, input.mode);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
});
}
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
export async function reorderBcCatalogPages(input: {
parentId: number;
ids: number[];
expectedIds: number[];
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
await reorderPagesCommand("bc", input);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
});
if (input.mode === "reparent") {
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, input.pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, input.pageId));
await tx
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId));
});
} else {
const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
}
-68
View File
@@ -1,68 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
import {
applyBulkOffersCommand,
listBulkOfferDestinationsCommand,
previewBulkOffersCommand,
} from "@/features/catalog/server/bulk-offers";
import { catalogFailure } from "@/features/catalog/server/errors";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
export async function previewBulkOffers(input: BulkOfferInput) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return { ok: true as const, data: await previewBulkOffersCommand(input) };
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function applyBulkOffers(
input: BulkOfferInput,
fingerprint: string,
) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
try {
return await withCatalogExport(async () => {
const data = await applyBulkOffersCommand(input, fingerprint);
if (data.changedCount > 0) {
await logAudit({
userId: staff.id,
action: "catalog_offers_bulk_update",
target: "catalog",
after: {
ids: input.ids,
changes: input.changes,
changedCount: data.changedCount,
},
}).catch((error) =>
logger.error("Catalog bulk update committed; audit write failed", {
module: "catalog",
error,
}),
);
await sendCatalogUpdate();
revalidatePath("/admin/catalog", "layout");
}
return { ok: true as const, data };
});
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function getBulkOfferDestinations() {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await listBulkOfferDestinationsCommand(),
};
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
-66
View File
@@ -1,66 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import type {
DuplicateInput,
DuplicatePreview,
} from "@/features/catalog/domain/duplicate";
import {
duplicateCategoryCommand,
duplicateDestinationsCommand,
previewDuplicateCommand,
} from "@/features/catalog/server/duplicate-commands";
import { catalogFailure } from "@/features/catalog/server/errors";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function previewCatalogDuplicate(input: DuplicateInput) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return { ok: true as const, data: await previewDuplicateCommand(input) };
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function applyCatalogDuplicate(input: DuplicatePreview) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
let data: Awaited<ReturnType<typeof duplicateCategoryCommand>>;
try {
data = await duplicateCategoryCommand(input);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
const hotel = await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Duplicated ${input.kind} category #${input.sourceId} to #${data.id}: ${data.pages} categories, ${data.offers} offers`,
targetType: "catalog_page",
targetId: data.id,
}).catch((error) => {
logger.error("Category duplicated but audit logging failed", {
module: "catalog",
error,
});
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { ...data, hotel } };
});
}
export async function catalogDuplicateDestinations(
kind: DuplicateInput["kind"],
) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await duplicateDestinationsCommand(kind),
};
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
+329 -267
View File
@@ -2,23 +2,71 @@
import { eq, inArray, like, or, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { offerPatchSchema } from "@/features/catalog/domain/offer-input";
import {
createOfferCommand,
moveOffersCommand,
reorderOffersCommand,
updateOfferCommand,
} from "@/features/catalog/server/offer-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { translateItemsSchema } from "@/lib/validators/catalog";
const CATALOG_ITEM_FIELDS = [
"pageId",
"itemIds",
"catalogName",
"costCredits",
"costPoints",
"pointsType",
"amount",
"orderNumber",
"offerId",
"songId",
"limitedSells",
"limitedStack",
"extradata",
"haveOffer",
"clubOnly",
] as const;
const ITEMS_BASE_FIELDS = [
"publicName",
"itemName",
"type",
"width",
"length",
"stackHeight",
"allowStack",
"allowSit",
"allowLay",
"allowWalk",
"allowGift",
"allowTrade",
"allowRecycle",
"allowMarketplaceSell",
"allowInventoryStack",
"interactionType",
"interactionModesCount",
"vendingIds",
"customparams",
"effectIdMale",
"effectIdFemale",
"clothingOnWalk",
] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
async function insertCatalogItemRow(data: {
pageId: number;
@@ -38,10 +86,8 @@ async function insertCatalogItemRow(data: {
clubOnly: string;
}): Promise<number> {
const pageIdStr = String(data.pageId);
offerPatchSchema.required().parse(data);
return allocateCatalogItemId((nextId) =>
createOfferCommand("normal", data, async (tx) => {
await tx.execute(sql`
return allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
@@ -55,9 +101,8 @@ async function insertCatalogItemRow(data: {
${data.haveOffer}, ${data.clubOnly}
)
`);
return nextId;
}),
);
return nextId;
});
}
export async function createCatalogItem(data: {
@@ -78,34 +123,32 @@ export async function createCatalogItem(data: {
clubOnly: "0" | "1";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
let catalogName = data.catalogName.trim();
if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
if (firstId > 0) {
const [base] = await db
.select({
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, firstId))
.limit(1);
catalogName = base?.publicName || base?.itemName || String(firstId);
}
let catalogName = data.catalogName.trim();
if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
if (firstId > 0) {
const [base] = await db
.select({
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, firstId))
.limit(1);
catalogName = base?.publicName || base?.itemName || String(firstId);
}
const id = await insertCatalogItemRow({ ...data, catalogName });
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_create",
description: `Created catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id } };
}
const id = await insertCatalogItemRow({ ...data, catalogName });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_create",
description: `Created catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id } };
}
/** Bulk create with one RCON refresh at the end. */
@@ -122,88 +165,84 @@ export async function bulkCreateCatalogItems({
}>;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (rows.length === 0) {
return { ok: true as const, data: { created: 0, failed: 0 } };
if (rows.length === 0) {
return { ok: true as const, data: { created: 0, failed: 0 } };
}
if (rows.length > 500) {
return { ok: false as const, error: "Max 500 items per bulk import" };
}
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
let created = 0;
let failed = 0;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) {
failed++;
continue;
}
if (rows.length > 500) {
return { ok: false as const, error: "Max 500 items per bulk import" };
}
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
let created = 0;
let failed = 0;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) {
failed++;
continue;
}
try {
await insertCatalogItemRow({
pageId,
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
created++;
} catch {
failed++;
}
}
if (created > 0) {
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
targetType: "catalog_page",
targetId: pageId,
try {
await insertCatalogItemRow({
pageId,
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
revalidatePath("/admin/catalog");
created++;
} catch {
failed++;
}
}
return { ok: true as const, data: { created, failed } };
});
if (created > 0) {
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
targetType: "catalog_page",
targetId: pageId,
});
revalidatePath("/admin/catalog");
}
return { ok: true as const, data: { created, failed } };
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
description: `Deleted catalog items: ${ids.join(", ")}`,
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
description: `Deleted catalog items: ${ids.join(", ")}`,
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function moveCatalogItems({
@@ -214,15 +253,21 @@ export async function moveCatalogItems({
targetPageId: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (ids.length === 0) {
return { ok: true as const, data: {} };
}
await moveOffersCommand(ids, targetPageId);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
if (ids.length === 0) {
return { ok: true as const, data: {} };
});
}
const pageIdStr = String(targetPageId);
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageIdStr}
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function reorderCatalogItems({
@@ -231,12 +276,15 @@ export async function reorderCatalogItems({
orders: Array<{ id: number; orderNumber: number }>;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await reorderOffersCommand(orders);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
for (const { id, orderNumber } of orders) {
await db
.update(CatalogItems)
.set({ orderNumber })
.where(eq(CatalogItems.id, id));
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function updateCatalogItem({
@@ -249,29 +297,46 @@ export async function updateCatalogItem({
baseItem?: { id: number; fields: Record<string, unknown> };
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
try {
await updateOfferCommand({ id, catalogFields, baseItem });
} catch (error) {
return {
ok: false as const,
error:
error instanceof Error
? error.message
: "Unable to update catalog item",
};
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
return { ok: false as const, error: "No valid fields to update" };
}
// page_id is often VARCHAR — update it via raw SQL when present.
const pageIdRaw = safeCatalog.pageId;
if (pageIdRaw !== undefined) {
const pageIdStr = String(pageIdRaw);
await db.execute(sql`
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
`);
delete safeCatalog.pageId;
}
if (Object.keys(safeCatalog).length > 0) {
await db
.update(CatalogItems)
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
.where(eq(CatalogItems.id, id));
}
if (baseItem) {
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
if (Object.keys(safeBase).length > 0) {
await db
.update(ItemsBase)
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, baseItem.id));
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_update",
description: `Updated catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_update",
description: `Updated catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function translateCatalogItems(input: {
@@ -279,124 +344,121 @@ export async function translateCatalogItems(input: {
items: Array<{ id: number; publicName: string; description?: string }>;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const parsed = translateItemsSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false as const,
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
};
}
const { items } = parsed.data;
const { invalidateFurniDataCache } = await import(
"@/lib/services/catalog-items-loader"
);
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
let namesUpdated = 0;
let descriptionsUpdated = 0;
const furniPatches: Array<{
classname: string;
itemType: string;
name?: string;
description?: string;
spriteId?: number;
createIfMissing?: boolean;
}> = [];
for (const item of items) {
const [base] = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, item.id))
.limit(1);
if (!base) continue;
const nextName = item.publicName?.trim() ?? "";
const nextDesc = item.description ?? "";
const nameChanged =
nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, base.id));
const idStr = String(base.id);
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) {
if (row.catalogName !== nextName) {
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
}
}
namesUpdated++;
}
if (nextDesc !== "" || nameChanged) {
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
furniPatches.push({
classname: base.itemName,
itemType: base.type || "s",
name: nextName || base.publicName || base.itemName,
description: nextDesc,
spriteId: base.spriteId,
createIfMissing: true,
});
}
}
const furniResult =
furniPatches.length > 0
? await patchFurniEntryNames(furniPatches)
: { updated: 0, inserted: 0 };
if (furniResult.updated > 0 || furniResult.inserted > 0) {
invalidateFurniDataCache();
}
await sendCatalogUpdate();
await logAudit({
userId: staff.id,
action: "items_base_translate",
target: "ItemsBase",
after: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated: furniResult.updated > 0,
furniDataInserted: furniResult.inserted,
},
});
revalidatePath("/admin/catalog");
const parsed = translateItemsSchema.safeParse(input);
if (!parsed.success) {
return {
ok: true as const,
data: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated: furniResult.updated,
furniDataInserted: furniResult.inserted,
updated: items.length,
},
ok: false as const,
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
};
}
const { items } = parsed.data;
const { invalidateFurniDataCache } = await import(
"@/lib/services/catalog-items-loader"
);
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
let namesUpdated = 0;
let descriptionsUpdated = 0;
const furniPatches: Array<{
classname: string;
itemType: string;
name?: string;
description?: string;
spriteId?: number;
createIfMissing?: boolean;
}> = [];
for (const item of items) {
const [base] = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, item.id))
.limit(1);
if (!base) continue;
const nextName = item.publicName?.trim() ?? "";
const nextDesc = item.description ?? "";
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, base.id));
const idStr = String(base.id);
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) {
if (row.catalogName !== nextName) {
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
}
}
namesUpdated++;
}
if (nextDesc !== "" || nameChanged) {
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
furniPatches.push({
classname: base.itemName,
itemType: base.type || "s",
name: nextName || base.publicName || base.itemName,
description: nextDesc,
spriteId: base.spriteId,
createIfMissing: true,
});
}
}
const furniResult =
furniPatches.length > 0
? await patchFurniEntryNames(furniPatches)
: { updated: 0, inserted: 0 };
if (furniResult.updated > 0 || furniResult.inserted > 0) {
invalidateFurniDataCache();
}
await rcon.updateCatalog();
await logAudit({
userId: staff.id,
action: "items_base_translate",
target: "ItemsBase",
after: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated: furniResult.updated > 0,
furniDataInserted: furniResult.inserted,
},
});
revalidatePath("/admin/catalog");
return {
ok: true as const,
data: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated: furniResult.updated,
furniDataInserted: furniResult.inserted,
updated: items.length,
},
};
}
+101 -117
View File
@@ -1,19 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
createPageCommand,
deletePageCommand,
reorderPagesCommand,
togglePageCommand,
updatePageCommand,
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const CATALOG_PAGE_FIELDS = [
@@ -51,54 +45,45 @@ function pickPageFields(fields: Record<string, unknown>) {
export async function updateCatalogPage({
id,
expected,
...fields
}: { id: number; expected?: Record<string, unknown> } & Record<
string,
unknown
>): Promise<ActionResult> {
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const data = pickPageFields(fields);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
try {
await updatePageCommand("normal", id, data, expected);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_update",
description: `Updated catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
const data = pickPageFields(fields);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
await db
.update(CatalogPages)
.set(data as Partial<typeof CatalogPages.$inferInsert>)
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_update",
description: `Updated catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteCatalogPage({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePageCommand("normal", id, "reparent");
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_delete",
description: `Deleted catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
await deletePage(id, "reparent");
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_delete",
description: `Deleted catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function toggleCatalogPage({
@@ -109,16 +94,24 @@ export async function toggleCatalogPage({
action: "toggleEnabled" | "toggleVisible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await togglePageCommand(
"normal",
id,
action === "toggleEnabled" ? "enabled" : "visible",
);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
const [page] = await db
.select({
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function createCatalogPage(input: {
@@ -133,35 +126,34 @@ export async function createCatalogPage(input: {
orderNum?: number;
}): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const createdId = await createPageCommand("normal", {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
});
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
targetId: createdId,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: createdId } };
const [result] = await db.insert(CatalogPages).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
targetId: createdId,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: createdId } };
}
export async function reorderTreePage(input: {
@@ -170,15 +162,23 @@ export async function reorderTreePage(input: {
newOrderNum: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await updatePageCommand("normal", input.pageId, {
parentId: input.newParentId,
orderNum: input.newOrderNum,
});
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
if (input.newParentId !== undefined) {
try {
await movePage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await db
.update(CatalogPages)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPages.id, input.pageId));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteTreePage(input: {
@@ -186,24 +186,8 @@ export async function deleteTreePage(input: {
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePageCommand("normal", input.pageId, input.mode);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function reorderCatalogPages(input: {
parentId: number;
ids: number[];
expectedIds: number[];
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
await reorderPagesCommand("normal", input);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
await deletePage(input.pageId, input.mode);
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
+1 -7
View File
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
@@ -128,12 +127,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
}
// Grant the badge live so it appears immediately for online users.
await rcon.giveBadge(userId, code).catch((error) =>
logServerError("drawbadge.give_failed", error, {
userId,
code,
}),
);
await rcon.giveBadge(userId, code).catch(() => {});
outcome = "bought";
boughtCode = code;
-11
View File
@@ -15,7 +15,6 @@ import { PERMS } from "@/lib/permissions";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook";
import {
createEventSchema,
eventPrizeSchema,
@@ -121,11 +120,6 @@ export const createEvent = adminAction(
targetId: eventId,
after: { title: ctx.data.title },
});
notify({
action: "event_create",
actor: ctx.session.user.username,
target: ctx.data.title,
});
return actionOk({ id: eventId });
},
);
@@ -161,11 +155,6 @@ export const updateEvent = adminAction(
before: { title: existing.title, status: existing.status },
after: data,
});
notify({
action: "event_update",
actor: ctx.session.user.username,
target: data.title ?? existing.title,
});
return actionOk({ id });
},
);
-124
View File
@@ -1,124 +0,0 @@
"use server";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionOk,
handleActionError,
} from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import type {
AlignResult,
DedupResult,
FixOfferResult,
FixSpriteResult,
FurniHealth,
ReconcileResult,
} from "@/lib/services/furni-maintenance";
import * as maintenance from "@/lib/services/furni-maintenance";
async function guard() {
await requirePermission(PERMS.CATALOG_EDIT);
}
export async function getFurniHealthAction(): Promise<
ActionResult<{ health: FurniHealth }>
> {
try {
await guard();
const health = await maintenance.getFurniHealth();
return actionOk({ health });
} catch (e) {
return handleActionError(e);
}
}
export async function fixSpriteIdsAction(): Promise<
ActionResult<FixSpriteResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixSpriteIds());
});
} catch (e) {
return handleActionError(e);
}
}
export async function fixCatalogOffersAction(): Promise<
ActionResult<FixOfferResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixCatalogOffers());
});
} catch (e) {
return handleActionError(e);
}
}
export async function reconcileIdsAction(): Promise<
ActionResult<ReconcileResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.reconcileIds());
});
} catch (e) {
return handleActionError(e);
}
}
export async function removeDuplicateItemsBaseAction(): Promise<
ActionResult<DedupResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.removeDuplicates());
});
} catch (e) {
return handleActionError(e);
}
}
export async function previewAlignIdsAction(): Promise<
ActionResult<AlignResult>
> {
try {
await guard();
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(false));
} catch (e) {
return handleActionError(e);
}
}
export async function applyAlignIdsAction(): Promise<
ActionResult<AlignResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
});
} catch (e) {
return handleActionError(e);
}
}
export async function fixEverythingAction(input?: {
dedupePages?: boolean;
}): Promise<ActionResult<maintenance.FixAllResult>> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixEverything(input ?? {}));
});
} catch (e) {
return handleActionError(e);
}
}
-10
View File
@@ -15,7 +15,6 @@ import {
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
import { notify } from "@/lib/services/webhook";
const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
@@ -161,15 +160,6 @@ export async function createTicket(formData: FormData): Promise<void> {
updatedAt: now,
});
outcome = "created";
const sessionUser = session?.user;
if (sessionUser?.name) {
notify({
action: "ticket_create",
actor: sessionUser.name,
target: ticketTitle,
});
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
"use server";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { importBadgeSynced } from "@/lib/services/import-badge";
export async function importBadgeFromRemote({
code,
name,
description,
}: {
code: string;
name: string;
description: string;
}) {
await requirePermission(PERMS.ASSETS_IMPORT);
const result = await importBadgeSynced({ code, name, description });
if (!result.ok) {
return { ok: false as const, error: result.error };
}
return { ok: true as const };
}
+16 -23
View File
@@ -1,13 +1,21 @@
"use server";
import { z } from "zod";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { deleteImportedItem } from "@/lib/services/furni-import";
import { siteSettings } from "@/lib/services/site-settings";
import {
cleanupConvertedSwfs,
deleteImportedItem,
} from "@/lib/services/furni-import";
export const cleanSwfFiles = adminAction(
{ permission: PERMS.ASSETS_IMPORT },
async () => {
const result = await cleanupConvertedSwfs();
return actionOk(result as unknown as Record<string, unknown>);
},
);
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
@@ -15,24 +23,9 @@ export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) =>
actionOk(
(await withCatalogExport(() =>
deleteImportedItem(ctx.data.classname),
)) as unknown as Record<string, unknown>,
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
),
);
const translateToggleSchema = z.object({ enabled: z.boolean() });
/** Persist the global "translate furniture names" setting from the studio. */
export const setFurnidataTranslateEnabled = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: translateToggleSchema },
async (ctx) => {
const value = ctx.data.enabled ? "1" : "0";
await db
.insert(WebsiteSetting)
.values({ key: "furnidata_translate_enabled", value })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
return actionOk({ enabled: ctx.data.enabled });
},
);
+2 -10
View File
@@ -7,7 +7,6 @@ import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { db, PasswordReset, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email";
@@ -91,10 +90,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!error) {
try {
const [row] = await db
.select({
token: PasswordReset.token,
createdAt: PasswordReset.createdAt,
})
.select()
.from(PasswordReset)
.where(eq(PasswordReset.email, email))
.limit(1);
@@ -124,11 +120,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
await db
.delete(PasswordReset)
.where(eq(PasswordReset.email, email))
.catch((error) =>
logServerError("password.reset_delete_tokens_failed", error, {
email,
}),
);
.catch(() => {});
}
}
} catch {
+2 -18
View File
@@ -13,7 +13,6 @@ import { PERMS } from "@/lib/permissions";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook";
import {
createPollSchema,
pollQuestionSchema,
@@ -39,11 +38,6 @@ export const createPoll = adminAction(
targetId: pollId,
after: { title: ctx.data.title },
});
notify({
action: "poll_create",
actor: ctx.session.user.username,
target: ctx.data.title,
});
return actionOk({ id: pollId });
},
);
@@ -172,12 +166,7 @@ export const voteOnPoll = authAction(
}
const [poll] = await db
.select({
id: WebsitePoll.id,
status: WebsitePoll.status,
startsAt: WebsitePoll.startsAt,
endsAt: WebsitePoll.endsAt,
})
.select()
.from(WebsitePoll)
.where(eq(WebsitePoll.id, ctx.data.pollId))
.limit(1);
@@ -195,12 +184,7 @@ export const voteOnPoll = authAction(
}
const questions = await db
.select({
id: WebsitePollQuestion.id,
pollId: WebsitePollQuestion.pollId,
type: WebsitePollQuestion.type,
options: WebsitePollQuestion.options,
})
.select()
.from(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.pollId, poll.id));
+17 -60
View File
@@ -1,11 +1,10 @@
"use server";
import { count, eq } from "drizzle-orm";
import { after } from "next/server";
import { redirect } from "next/navigation";
import { z } from "zod";
import { sendVerification } from "@/actions/email-verify";
import { hashPassword } from "@/lib/auth/password";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
@@ -30,23 +29,16 @@ const registerSchema = z.object({
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one digit"),
passwordConfirmation: z.string(),
look: z.string().optional(),
});
// A valid starter Habbo figure so the avatar renders in-client immediately.
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export interface RegisterState {
error: string | null;
ok: boolean;
}
export async function register(
_prevState: RegisterState,
_prevState: string | null,
formData: FormData,
): Promise<RegisterState> {
const fail = (error: string): RegisterState => ({ error, ok: false });
): Promise<string | null> {
const raw = {
username: String(formData.get("username") ?? "")
.normalize("NFC")
@@ -56,23 +48,15 @@ export async function register(
.trim()
.toLowerCase(),
password: String(formData.get("password") ?? "").normalize("NFC"),
passwordConfirmation: String(
formData.get("password_confirmation") ?? "",
).normalize("NFC"),
look:
String(formData.get("look") ?? "")
.normalize("NFC")
.trim() || DEFAULT_LOOK,
termsAccepted: formData.get("terms") === "on",
};
const parsed = registerSchema.safeParse(raw);
if (!parsed.success) {
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
}
if (parsed.data.password !== parsed.data.passwordConfirmation) {
return fail("Passwords do not match");
return parsed.error.issues[0]?.message ?? "Invalid input";
}
const { username, mail, password, look } = parsed.data;
@@ -81,9 +65,7 @@ export async function register(
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
return fail(
"Too many sign-up attempts. Please wait a few minutes and try again.",
);
return "Too many sign-up attempts. Please wait a few minutes and try again.";
}
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
@@ -91,18 +73,14 @@ export async function register(
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip)))
return fail("Captcha verification failed. Please try again.");
return "Captcha verification failed. Please try again.";
}
// Terms acceptance check.
if (!raw.termsAccepted)
return fail("You must accept the terms and conditions to register.");
// VPN/proxy block (only when enabled in /admin/vpn).
if ((await checkVpn(ip)).blocked) {
return fail(
return (
(await siteSettings.get("vpn_block_message", "")) ||
"Registrations from VPN/proxy connections are not allowed.",
"Registrations from VPN/proxy connections are not allowed."
);
}
@@ -115,9 +93,7 @@ export async function register(
.where(eq(User.ipRegister, ip))
.catch(() => [{ total: 0 }]);
if (Number(row?.total ?? 0) >= max)
return fail(
"You have reached the maximum number of accounts for your connection.",
);
return "You have reached the maximum number of accounts for your connection.";
}
// Uniqueness check.
@@ -127,10 +103,10 @@ export async function register(
.from(User)
.where(eq(User.username, username))
.limit(1);
if (existing) return fail("That username is already taken");
if (existing) return "That username is already taken";
} catch {
logger.warn("Username uniqueness check failed during registration");
return fail("Registration is temporarily unavailable");
return "Registration is temporarily unavailable";
}
const now = Math.floor(Date.now() / 1000);
@@ -143,38 +119,19 @@ export async function register(
ipRegister: ip,
ipCurrent: ip,
look,
termsAccepted: raw.termsAccepted,
});
} catch (err) {
const code = (err as { cause?: { code?: string } }).cause?.code;
if (code === "ER_DUP_ENTRY") {
return fail("That username is already taken");
}
logger.error("Account creation failed", {
code,
message: err instanceof Error ? err.message : String(err),
});
return fail(
"Could not create the account. Please try again or contact staff.",
);
}
// The login lookup is cached for 15s — drop any stale entry so the
// immediate auto sign-in sees the fresh row.
await invalidateKey(`login:user:${username}`);
// Verification email must never block the sign-up response — it is sent
// after the response is flushed (no-op when mail is unconfigured).
if (hasEmail) {
after(async () => {
if (hasEmail) {
try {
await sendVerification(mail);
} catch {
logger.warn("Failed to send verification email after registration");
}
});
}
} catch {
logger.warn("Account creation failed");
return "Could not create the account (is the username unique?)";
}
// Client auto signs in with these credentials and navigates to /me.
return { error: null, ok: true };
redirect("/login?registered=1");
}
+1 -18
View File
@@ -7,7 +7,6 @@ import { db, Items, Rooms } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { notify } from "@/lib/services/webhook";
export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
@@ -76,17 +75,11 @@ export async function roomRconAction({
roomId: number;
action: string;
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await requirePermission(PERMS.ROOMS_EDIT);
if (action === "reload") {
await rcon.send("reloadroom", { room_id: roomId });
} else if (action === "kick") {
await rcon.send("kickall", { room_id: roomId });
notify({
action: "kick",
actor: staff.username,
target: String(roomId),
details: action,
});
} else if (action === "lock") {
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
} else if (action === "unlock") {
@@ -96,11 +89,6 @@ export async function roomRconAction({
export async function deleteRoom({ id }: { id: number }) {
const staff = await requirePermission(PERMS.ROOMS_DELETE);
const [room] = await db
.select({ name: Rooms.name })
.from(Rooms)
.where(eq(Rooms.id, id))
.limit(1);
await db.delete(Rooms).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
@@ -109,11 +97,6 @@ export async function deleteRoom({ id }: { id: number }) {
targetType: "room",
targetId: id,
});
notify({
action: "room_delete",
actor: staff.username,
target: room?.name ?? `#${id}`,
});
revalidatePath("/admin/rooms");
}
+1 -26
View File
@@ -9,8 +9,7 @@ import { logger } from "@/lib/logger";
/**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
* revoke personal access tokens, revoke the game SSO ticket, then end the
* current browser session too.
* revoke personal access tokens, then end the current browser session too.
*/
export async function signOutEverywhere(): Promise<void> {
const session = await auth();
@@ -25,7 +24,6 @@ export async function signOutEverywhere(): Promise<void> {
.update(User)
.set({
websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`,
authTicket: "",
})
.where(eq(User.id, userId));
await invalidateJwtVersionCache(userId);
@@ -59,26 +57,3 @@ export async function signOutEverywhere(): Promise<void> {
await signOut({ redirectTo: "/login?signedOutAll=1" });
}
/**
* Log the current user out of the website AND revoke their game SSO ticket.
*
* Without revoking it, a ticket leaked via logs/history/referrers stays valid
* for the emulator after logout. Clearing the ticket makes any future client
* connection with it invalid.
*/
export async function signOutAndRevokeTicket(): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (Number.isInteger(userId) && userId > 0) {
try {
await db.update(User).set({ authTicket: "" }).where(eq(User.id, userId));
} catch (err) {
logger.warn("Failed to revoke SSO ticket during sign out", {
userId,
error: err instanceof Error ? err.message : "Unknown",
});
}
}
await signOut({ redirectTo: "/" });
}
+1 -7
View File
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { creditsPerUnit } from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon";
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
@@ -184,12 +183,7 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
);
for (const code of badgeCodes) {
await rcon.giveBadge(userId, code).catch((error) =>
logServerError("shop.give_badge_failed", error, {
userId,
code,
}),
);
await rcon.giveBadge(userId, code).catch(() => {});
}
outcome = "bought";
-474
View File
@@ -1,474 +0,0 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
BLOCK_KEY_PREFIX,
EFFECT_KEYS,
LAYOUT_KEYS,
MEDIA_KEYS,
THEME_BLOCKS,
} from "@/lib/theme-blocks";
import { allModuleIds } from "@/lib/theme-modules";
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { getAllScopes, getScopeValues } from "@/lib/theme-resolver";
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
// ─── Scope CRUD ──────────────────────────────────────────────────────────────
export async function createScope(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("name") ?? "").trim();
const type = String(formData.get("type") ?? "") as
| "global"
| "site"
| "module"
| "route";
const parentId = formData.get("parent_id")
? Number(formData.get("parent_id"))
: null;
const siteDomain = String(formData.get("site_domain") ?? "").trim() || null;
const routePath = String(formData.get("route_path") ?? "").trim() || null;
const moduleId = String(formData.get("module_id") ?? "").trim() || null;
if (!name || !type) redirect("/admin/theme-builder");
if (type === "module" && !moduleId) redirect("/admin/theme-builder");
if (type === "route" && !routePath) redirect("/admin/theme-builder");
if (type === "site" && !siteDomain) redirect("/admin/theme-builder");
const [maxRow] = await db
.select({ maxSort: sql<number>`COALESCE(MAX(${ThemeScope.sortOrder}), 0)` })
.from(ThemeScope);
try {
await db.insert(ThemeScope).values({
name,
type,
parentId: parentId ? BigInt(parentId) : null,
siteDomain,
routePath,
moduleId,
isActive: true,
sortOrder: (maxRow?.maxSort ?? 0) + 1,
createdAt: new Date(),
updatedAt: new Date(),
});
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
await logStaffActivity({
staffId: staff.id,
action: "theme_scope_create",
description: `Created theme scope "${name}" (${type})`,
});
revalidatePath("/admin/theme-builder");
} catch {
// ignore
}
redirect("/admin/theme-builder?created=1");
}
export async function updateScope(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const id = Number(formData.get("id"));
const name = String(formData.get("name") ?? "").trim();
const isActive = formData.get("is_active") === "on";
if (!id) redirect("/admin/theme-builder");
try {
await db
.update(ThemeScope)
.set({ name, isActive, updatedAt: new Date() })
.where(sql`${ThemeScope.id} = ${id}`);
revalidatePath("/admin/theme-builder");
} catch {
// ignore
}
redirect("/admin/theme-builder?updated=1");
}
export async function deleteScope(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const id = Number(formData.get("id"));
if (!id) redirect("/admin/theme-builder");
try {
await db
.delete(ThemeScopeValue)
.where(sql`${ThemeScopeValue.scopeId} = ${id}`);
await db.delete(ThemeScope).where(sql`${ThemeScope.id} = ${id}`);
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
await logStaffActivity({
staffId: staff.id,
action: "theme_scope_delete",
description: `Deleted theme scope #${id}`,
});
revalidatePath("/admin/theme-builder");
} catch {
// ignore
}
redirect("/admin/theme-builder?deleted=1");
}
export async function reorderScopes(orderedIds: number[]): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
for (let i = 0; i < orderedIds.length; i++) {
await db
.update(ThemeScope)
.set({ sortOrder: i, updatedAt: new Date() })
.where(sql`${ThemeScope.id} = ${orderedIds[i]}`);
}
revalidatePath("/admin/theme-builder");
} catch {
// ignore
}
}
// ─── Theme Values CRUD ───────────────────────────────────────────────────────
export async function saveScopeValues(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const scopeId = Number(formData.get("scope_id"));
if (!scopeId) redirect("/admin/theme-builder");
try {
// Save color values (light + dark)
for (const mode of ["light", "dark"] as const) {
for (const key of THEME_COLOR_KEYS) {
const fieldKey = `${key}_${mode}`;
const raw = String(formData.get(fieldKey) ?? "").trim();
if (!raw || !COLOR_RE.test(raw)) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode,
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
}
// Save block visibility
for (const block of THEME_BLOCKS) {
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
const val = formData.get(key);
const visible = val === "on" || val === "true" ? "true" : "false";
// Only save if different from default
if (visible !== String(block.defaultVisible)) {
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: visible,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: visible, updatedAt: new Date() },
});
} else {
// Remove if set back to default
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, key),
),
);
}
}
// Save layout settings
for (const key of LAYOUT_KEYS) {
const raw = String(formData.get(key) ?? "").trim();
if (!raw) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
// Save effect settings
for (const key of EFFECT_KEYS) {
const raw = String(formData.get(key) ?? "").trim();
if (!raw) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
// Save media settings
for (const key of MEDIA_KEYS) {
const raw = String(formData.get(key) ?? "").trim();
if (!raw) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
// Save custom CSS
const customCss = String(formData.get("custom:css") ?? "").trim();
if (customCss) {
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: "custom:css",
settingVal: customCss,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: customCss, updatedAt: new Date() },
});
} else {
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, "custom:css"),
),
);
}
// Save custom HTML
const customHtml = String(formData.get("custom:html") ?? "").trim();
if (customHtml) {
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: "custom:html",
settingVal: customHtml,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: customHtml, updatedAt: new Date() },
});
} else {
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, "custom:html"),
),
);
}
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
await logStaffActivity({
staffId: staff.id,
action: "theme_scope_values",
description: `Updated theme values for scope #${scopeId}`,
});
revalidatePath("/admin/theme-builder");
revalidatePath("/", "layout");
} catch {
// ignore
}
redirect("/admin/theme-builder?saved=1");
}
export async function resetScopeValues(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const scopeId = Number(formData.get("scope_id"));
const key = String(formData.get("key") ?? "");
const mode = String(formData.get("mode") ?? "") as "light" | "dark";
if (!scopeId || !key) redirect("/admin/theme-builder");
try {
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, key),
eq(ThemeScopeValue.mode, mode),
),
);
revalidatePath("/admin/theme-builder");
} catch {
// ignore
}
redirect("/admin/theme-builder?reset=1");
}
// ─── Duplicate scope ─────────────────────────────────────────────────────────
export async function duplicateScope(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const sourceId = Number(formData.get("id"));
if (!sourceId) redirect("/admin/theme-builder");
try {
const scopes = await getAllScopes();
const source = scopes.find((s) => s.id === sourceId);
if (!source) redirect("/admin/theme-builder");
const [maxRow] = await db
.select({
maxSort: sql<number>`COALESCE(MAX(${ThemeScope.sortOrder}), 0)`,
})
.from(ThemeScope);
await db.insert(ThemeScope).values({
name: `${source.name} (copy)`,
type: source.type,
parentId: source.parentId ? BigInt(source.parentId) : null,
siteDomain: source.siteDomain,
routePath: source.routePath,
moduleId: source.moduleId,
isActive: false,
sortOrder: (maxRow?.maxSort ?? 0) + 1,
createdAt: new Date(),
updatedAt: new Date(),
});
const sourceValues = await getScopeValues(sourceId);
const allScopes = await getAllScopes();
const newScope = allScopes.find((s) => s.name === `${source.name} (copy)`);
if (newScope) {
for (const [key, val] of Object.entries(sourceValues)) {
if (val.light) {
await db.insert(ThemeScopeValue).values({
scopeId: BigInt(newScope.id),
settingKey: key,
settingVal: val.light,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
});
}
if (val.dark) {
await db.insert(ThemeScopeValue).values({
scopeId: BigInt(newScope.id),
settingKey: key,
settingVal: val.dark,
mode: "dark",
createdAt: new Date(),
updatedAt: new Date(),
});
}
}
}
revalidatePath("/admin/theme-builder");
} catch {
// ignore
}
redirect("/admin/theme-builder?duplicated=1");
}
// ─── Data helpers for the admin page ─────────────────────────────────────────
export async function getThemeBuilderData() {
const scopes = await getAllScopes();
const moduleIds = allModuleIds();
const tree = buildTree(scopes);
return { scopes, tree, moduleIds };
}
type ScopeNode = Awaited<ReturnType<typeof getAllScopes>>[number] & {
children: ScopeNode[];
};
function buildTree(
scopes: Awaited<ReturnType<typeof getAllScopes>>,
): ScopeNode[] {
const nodeMap = new Map<number, ScopeNode>();
const roots: ScopeNode[] = [];
for (const scope of scopes) {
nodeMap.set(scope.id, { ...scope, children: [] });
}
for (const scope of scopes) {
const node = nodeMap.get(scope.id);
if (!node) continue;
if (scope.parentId) {
const parent = nodeMap.get(scope.parentId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
} else {
roots.push(node);
}
}
return roots;
}
Loaded 100 of 935 files, more files were not shown because too many files have changed in this diff. Show more