Compare commits
1
Commits
v1.0.3
..
c9130ecd2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9130ecd2d |
No files matched your search
Whitespace-only changes.
@@ -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
@@ -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=
|
||||
@@ -1,2 +0,0 @@
|
||||
.husky/* text eol=lf
|
||||
*.sh text eol=lf
|
||||
+511
-66
@@ -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
|
||||
@@ -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
@@ -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,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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,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);
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: { baseURL, trace: "on-first-retry" },
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm dev --port 3000",
|
||||
url: "http://127.0.0.1:3000/api/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
Generated
+2857
-1170
File diff suppressed because it is too large.
Load diff
+24
-6
@@ -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
@@ -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
|
||||
]
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
@@ -8,6 +8,7 @@
|
||||
* Usage:
|
||||
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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: {} };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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: {} };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
Reference in new issue
Block a user