Compare commits
183
Commits
v1.0.2
..
3f589c8b01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f589c8b01 | ||
|
|
624edf751a | ||
|
|
1258fd8e7b | ||
|
|
9fbfd2f51a | ||
|
|
fa7fc75c9a | ||
|
|
04b84e6055 | ||
|
|
2ee5ba5c4c | ||
|
|
d587749b50 | ||
|
|
b1a1e5125c | ||
|
|
750973de38 | ||
|
|
1167a48344 | ||
|
|
40da24bd8c | ||
|
|
e97213e5d1 | ||
|
|
86d59195ec | ||
|
|
1cbb33a4e2 | ||
|
|
40a21ba767 | ||
|
|
cf89681576 | ||
|
|
2fba923a3a | ||
|
|
080dc34e23 | ||
|
|
450e7e8d00 | ||
|
|
30ed2b8ce2 | ||
|
|
6fc14b9b84 | ||
|
|
bee55e1fd4 | ||
|
|
613b7502e1 | ||
|
|
44c34dbae6 | ||
|
|
5308ce6a12 | ||
|
|
0c8e62357f | ||
|
|
cde15ad022 | ||
|
|
1f9e8a0eec | ||
|
|
bac2f653af | ||
|
|
88ac5ac1ba | ||
|
|
cf563f3550 | ||
|
|
6b6fc5dc64 | ||
|
|
ab43f5d63c | ||
|
|
c78f8812ad | ||
|
|
aed70db81f | ||
|
|
1126a70d55 | ||
|
|
86cd43def9 | ||
|
|
01570874a8 | ||
|
|
a81af2d71a | ||
|
|
44b4b3b45c | ||
|
|
b3245a18ea | ||
|
|
a57c73055f | ||
|
|
bfc951cfd9 | ||
|
|
828fda63e7 | ||
|
|
1f4aadb3d7 | ||
|
|
c7fb37356e | ||
|
|
95b1955218 | ||
|
|
d173dd3194 | ||
|
|
0dc16e832d | ||
|
|
59f03827bc | ||
|
|
0d6032d444 | ||
|
|
ff1fa319a5 | ||
|
|
cc02851be3 | ||
|
|
7c1f8d709e | ||
|
|
2799b63943 | ||
|
|
fd8ab7db93 | ||
|
|
14a3de0f2a | ||
|
|
22d455da7a | ||
|
|
8275842e78 | ||
|
|
c601ffbb76 | ||
|
|
d39738eb0d | ||
|
|
d69e3f5da5 | ||
|
|
811cf5719b | ||
|
|
2194aa1239 | ||
|
|
16191cef14 | ||
|
|
9c4949186c | ||
|
|
db957d7fb1 | ||
|
|
725e1cb338 | ||
|
|
3bd712e744 | ||
|
|
ba82789166 | ||
|
|
d8199ea1e4 | ||
|
|
24d0b735c1 | ||
|
|
422567272c | ||
|
|
ca72966a37 | ||
|
|
30b54e99e7 | ||
|
|
9aa4f331bf | ||
|
|
580972c0a0 | ||
|
|
2cd0863cb8 | ||
|
|
7c39aef5d9 | ||
|
|
53b350057d | ||
|
|
65b2fbee6a | ||
|
|
22234fe102 | ||
|
|
096f55b394 | ||
|
|
ed9c23c702 | ||
|
|
9854719cfd | ||
|
|
67656a9aad | ||
|
|
20b85381fe | ||
|
|
e5ff7ec9e5 | ||
|
|
7f7971f578 | ||
|
|
d1807ca814 | ||
|
|
ef5e706ee1 | ||
|
|
c0bbcae5d6 | ||
|
|
2f030deb42 | ||
|
|
9a8905c726 | ||
|
|
beae86194d | ||
|
|
56061e41d4 | ||
|
|
9d1c71d926 | ||
|
|
744e224fd0 | ||
|
|
a2acac2c4c | ||
|
|
0224b34f15 | ||
|
|
1127807aaa | ||
|
|
3ac5d6f4f6 | ||
|
|
3e584aadaf | ||
|
|
bfbc02c75d | ||
|
|
98613af875 | ||
|
|
7138ae4445 | ||
|
|
3ad3f9512c | ||
|
|
fe46bd0544 | ||
|
|
822dfd6a1c | ||
|
|
525f58cd24 | ||
|
|
d805e54053 | ||
|
|
583d05eee9 | ||
|
|
58fae1f90f | ||
|
|
474de0717b | ||
|
|
ed5b9a6f7c | ||
|
|
6eab5e5343 | ||
|
|
686279eb25 | ||
|
|
c0a2c9db5e | ||
|
|
d8bb117114 | ||
|
|
63ad651b9b | ||
|
|
b03dbb295f | ||
|
|
4b2d893905 | ||
|
|
e07da3d052 | ||
|
|
10932a8799 | ||
|
|
4ec75c2c1d | ||
|
|
1e3b7bc31d | ||
|
|
ea7d861e69 | ||
|
|
c433e5a52f | ||
|
|
540bce911f | ||
|
|
749dc237f1 | ||
|
|
8c193936f6 | ||
|
|
d3068ce88b | ||
|
|
340ecb42c8 | ||
|
|
39b211084d | ||
|
|
22162fa8b9 | ||
|
|
ae2b9328b9 | ||
|
|
84f64b6615 | ||
|
|
91357aca3b | ||
|
|
cc95a0d690 | ||
|
|
da390e447f | ||
|
|
4bd717d627 | ||
|
|
1311d36933 | ||
|
|
ded8fa62af | ||
|
|
3bf0644a9a | ||
|
|
d87c4284fe | ||
|
|
9cdb0b85fb | ||
|
|
7cdb785218 | ||
|
|
7f58e428ed | ||
|
|
a8d86a10bc | ||
|
|
b926ffa93c | ||
|
|
65fae257ba | ||
|
|
22a9fc13f7 | ||
|
|
3b853efba0 | ||
|
|
72079050f4 | ||
|
|
e08e366266 | ||
|
|
1e661a2b41 | ||
|
|
a637d09ca5 | ||
|
|
15eeab8a59 | ||
|
|
54fa1aecdd | ||
|
|
5140784dc7 | ||
|
|
41e41f0d22 | ||
|
|
46db76219f | ||
|
|
5b9e2166df | ||
|
|
738d7b8223 | ||
|
|
ab63de000b | ||
|
|
3532972357 | ||
|
|
e644362d09 | ||
|
|
b6b8625246 | ||
|
|
01126207dc | ||
|
|
9177230dc2 | ||
|
|
db39fb335c | ||
|
|
9ea67ecf72 | ||
|
|
15a76ffe84 | ||
|
|
9c0b339736 | ||
|
|
7384041bb6 | ||
|
|
a72646c933 | ||
|
|
a513d9b7bd | ||
|
|
3827f3e686 | ||
|
|
e408fdd5e6 | ||
|
|
78fab3c9ac | ||
|
|
e69c2fbb04 | ||
|
|
2e2aba11af |
No files matched your search
+49
-71
@@ -1,95 +1,73 @@
|
||||
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
||||
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
||||
# it does NOT own or migrate the emulator tables. Format:
|
||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||
# ==============================================================================
|
||||
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Optional pool tuning (defaults shown)
|
||||
DATABASE_POOL_SIZE=10
|
||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
REDIS_CACHE_TTL_DEFAULT=7200
|
||||
|
||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||
HOTEL_NAME=Atom
|
||||
APP_URL=http://localhost:3000
|
||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
AUTH_URL=http://localhost:3000
|
||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||
NODE_ENV=production
|
||||
PORT=3002
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
UV_THREADPOOL_SIZE=16
|
||||
|
||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
||||
AUTH_SECRET=
|
||||
APP_KEY=
|
||||
CONVERT_PASSWORDS=false
|
||||
# --- HOTEL & URLS ---
|
||||
HOTEL_NAME=EPIC WEB CONTROL
|
||||
APP_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||
AUTH_URL=http://localhost:3002
|
||||
|
||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
||||
# column). Existing accounts in either format still verify on login.
|
||||
PASSWORD_HASH=bcrypt
|
||||
# --- IMAGER ---
|
||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||
|
||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
||||
# Leave unset to disable badge uploads.
|
||||
BADGE_UPLOAD_DIR=
|
||||
# --- SECURITY & HASHING ---
|
||||
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
|
||||
|
||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
||||
EMULATOR_JAR_PATH=
|
||||
EMULATOR_BACKUP_DIR=
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||
EMULATOR_BACKUP_KEEP=7
|
||||
# Optional mysqldump (jobs-worker daily 03:30). Requires mysqldump on PATH.
|
||||
DB_BACKUP_DIR=
|
||||
DB_BACKUP_KEEP=7
|
||||
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||
|
||||
# RCON link to the Arcturus emulator
|
||||
# --- RCON (Low Latency Loop) ---
|
||||
RCON_HOST=127.0.0.1
|
||||
RCON_PORT=3001
|
||||
RCON_PORT=3003
|
||||
EMU_PORT=3004
|
||||
RCON_TIMEOUT_MS=2000
|
||||
|
||||
# Public imager URL — overrides the default /imaging relative path.
|
||||
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
||||
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
||||
|
||||
# Preferred email provider (HTTP API). Used before SMTP when set.
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Optional SMTP fallback (password reset / alert emails)
|
||||
# --- EMAIL & NOTIFICATIONS ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
SMTP_FROM=[email protected]
|
||||
|
||||
# Optional alerting (jobs worker / alert service)
|
||||
# --- ALERTING & MONITORING ---
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
|
||||
# Optional AI content moderation (user comments / guestbook).
|
||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
||||
# --- MODERATION & PAYMENTS ---
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional PayPal top-up (sandbox by default)
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
|
||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
||||
# in-process memory: limits reset on restart and do not work across instances.
|
||||
# Deploy logs a loud warning when this is unset in production.
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
||||
# 'debug' in development. Production logs use structured JSON via pino.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional Sentry error monitoring (no-op when unset).
|
||||
# Server/edge use SENTRY_DSN; browser uses NEXT_PUBLIC_SENTRY_DSN.
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
# Optional source-map upload during CI builds (requires SENTRY_AUTH_TOKEN).
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Optional release tag shown in Sentry (e.g. git sha).
|
||||
# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha.
|
||||
APP_VERSION=
|
||||
NEXT_PUBLIC_APP_VERSION=
|
||||
# --- LOGGING ---
|
||||
LOG_LEVEL=error
|
||||
+491
-3
@@ -1,11 +1,18 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Typecheck, lint, and test
|
||||
@@ -30,12 +37,493 @@ jobs:
|
||||
git checkout -f "${REF}"
|
||||
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
export ARGON2_MEMORY_SIZE=1024
|
||||
export ARGON2_ITERATIONS=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
|
||||
pnpm prisma:generate
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
echo "--- CI checks passed ---"
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
if: gitea.event_name == 'push' && gitea.ref_name == 'main'
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
|
||||
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:
|
||||
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
|
||||
@@ -1,417 +0,0 @@
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
jobs:
|
||||
release:
|
||||
if: startsWith(gitea.ref_name, 'v')
|
||||
runs-on: shell
|
||||
steps:
|
||||
- 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 Prisma 7. 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 (argon2id/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. Generate Prisma Client"
|
||||
echo '```bash'
|
||||
echo "pnpm prisma:generate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 5. 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."
|
||||
echo ""
|
||||
echo "### 6. 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 "### 7. 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 "### 8. 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 "### 9. 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 "### 10. 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
|
||||
deploy:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
|
||||
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
|
||||
# Roll back the build artifact if cutover already moved .next into place.
|
||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -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 [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
fi
|
||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||
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)"
|
||||
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||
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
|
||||
# prisma generate does not need a live DB connection.
|
||||
pnpm prisma:generate
|
||||
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
# 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 "Cutover: stop service (free DB connections), migrate, swap .next..."
|
||||
CUTOVER_STARTED=1
|
||||
pm2 stop next --kill-timeout 10000 || true
|
||||
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
||||
sleep 10
|
||||
|
||||
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
||||
# the old process still holds DATABASE_POOL_SIZE connections.
|
||||
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, and deps we are about to replace from stage.
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache -e node_modules -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"
|
||||
|
||||
# Save current .next as backup before swapping (kept until health check passes).
|
||||
if [ -d .next ]; then
|
||||
mv .next .next.prev
|
||||
fi
|
||||
mv "${STAGE}/.next" .next
|
||||
|
||||
# Use the exact node_modules the stage build resolved against.
|
||||
rm -rf node_modules
|
||||
mv "${STAGE}/node_modules" node_modules
|
||||
|
||||
# Prisma client is gitignored — regenerate into live src/generated.
|
||||
pnpm prisma:generate
|
||||
|
||||
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
|
||||
|
||||
echo "Starting PM2 (zero-downtime reload)..."
|
||||
pm2 reload next --update-env || pm2 start next --update-env
|
||||
|
||||
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..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 15); 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}/15 failed, 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
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning stage worktree and previous .next backup..."
|
||||
rm -rf "${LIVE}/.next.prev"
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
STAGE=""
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
@@ -11,9 +11,8 @@ jobs:
|
||||
- name: Self-hosted Renovate
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
||||
|
||||
# Ensure cache dir exists before Docker starts
|
||||
mkdir -p /var/tmp/renovate-cache
|
||||
|
||||
docker run --rm \
|
||||
@@ -25,4 +24,4 @@ jobs:
|
||||
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||
-e LOG_LEVEL=info \
|
||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
+5
-1
@@ -5,7 +5,6 @@ node_modules/
|
||||
next-env.d.ts
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
# Prisma client is generated by `prisma generate`
|
||||
src/generated/
|
||||
# Runtime avatar/badge imaging disk cache
|
||||
public/cache/
|
||||
@@ -25,5 +24,10 @@ gitea
|
||||
# Runtime uploaded media (persistent, outside public/)
|
||||
storage/
|
||||
|
||||
# Runtime downloaded furni assets (mirrored from gamedata / audit repair)
|
||||
public/nitro-assets/bundled/furniture/
|
||||
public/swf/dcr/
|
||||
public/tmp/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
@@ -1,8 +1,8 @@
|
||||
# EpicNext-CMS v1.0.1
|
||||
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
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.
|
||||
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +37,9 @@ The CMS shares a database with the Polaris/Arcturus emulator. Use an existing da
|
||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them.
|
||||
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/` (`pnpm db:migrate`).
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
@@ -56,13 +58,34 @@ APP_URL=http://localhost:3000
|
||||
|
||||
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. Generate Prisma Client
|
||||
### 4. ORM Setup & Type Generation
|
||||
|
||||
```bash
|
||||
pnpm prisma:generate
|
||||
#### Drizzle ORM (Primary Data Layer)
|
||||
|
||||
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
|
||||
|
||||
```ts
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm/expressions";
|
||||
|
||||
const found = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "hello"));
|
||||
```
|
||||
|
||||
Generates TypeScript types in `src/generated/prisma/`.
|
||||
**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 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`.
|
||||
|
||||
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.
|
||||
|
||||
### 5. Run CMS Migrations
|
||||
|
||||
@@ -70,7 +93,7 @@ Generates TypeScript types in `src/generated/prisma/`.
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `prisma/migrations/`. Emulator tables are never touched.
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
|
||||
|
||||
Check migration status:
|
||||
|
||||
@@ -98,6 +121,235 @@ Open `http://localhost:3000` in your browser.
|
||||
|
||||
---
|
||||
|
||||
## Nginx 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)
|
||||
- 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
|
||||
|
||||
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;
|
||||
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 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;
|
||||
|
||||
# ------------------------------------------
|
||||
# Static Files
|
||||
# ------------------------------------------
|
||||
location ^~ /nitro-client/ {
|
||||
alias /var/www/Nitro-V3/dist/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
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:3000;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Caching
|
||||
|
||||
The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means:
|
||||
|
||||
- **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.
|
||||
|
||||
The proxy cache zone is defined in the `http` block (above any `server` block):
|
||||
|
||||
```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;
|
||||
```
|
||||
|
||||
Verify caching works by checking the `X-Cache-Status` response header:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment (PM2)
|
||||
|
||||
```bash
|
||||
@@ -130,10 +382,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| `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 analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Performance Features
|
||||
@@ -160,21 +418,26 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
## Architecture
|
||||
|
||||
```
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
||||
├── drizzle/
|
||||
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
|
||||
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
|
||||
├── scripts/
|
||||
│ ├── apply-migrations.ts # Custom migration runner
|
||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||
│ ├── jobs-worker.ts # Background task scheduler
|
||||
│ └── sql-statements.ts # SQL parsing utilities
|
||||
│ ├── 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)
|
||||
│ │ └── relations.ts # Drizzle relations
|
||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── components/ # UI components
|
||||
│ ├── lib/
|
||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||
│ │ ├── prisma.ts # Database connection singleton
|
||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||
│ │ ├── 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
|
||||
@@ -195,9 +458,17 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| Component | Type | Migrations |
|
||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
||||
| 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
|
||||
@@ -260,7 +531,9 @@ pnpm biome:check # Lint and format
|
||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||
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 `prisma/migrations/` must be idempotent
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Furni Import Hotel Source Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the furni import visibly and consistently use the CMS `habbo_gamedata_hotel` setting without Italy-specific names or messages.
|
||||
|
||||
**Architecture:** Keep locale selection centralized in `getHabboGamedataHotel()` and expose normalized display metadata to the server-rendered import page. Rename the furnidata cache contract to generic official-Habbo terminology, and keep asset downloads on the global `images.habbo.com` CDN.
|
||||
|
||||
**Tech Stack:** TypeScript 7, React 19, Next.js 16, Vitest 4.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `habbo_gamedata_hotel` remains the single source of truth.
|
||||
- Furnidata and external texts use `www.habbo.<hotel>`.
|
||||
- SWF and icon downloads remain on `images.habbo.com`.
|
||||
- Cache entries must never leak across two configured hotels.
|
||||
- Existing import behavior and permissions remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generic official-Habbo furnidata contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
- Modify: `src/types/furni.ts`
|
||||
- Modify: `src/lib/services/habbo-furnidata-cache.ts`
|
||||
- Modify: `src/lib/services/habbofurni.ts`
|
||||
- Modify: `src/lib/services/furni-data.ts`
|
||||
- Modify: `src/lib/services/furni-import.ts`
|
||||
- Modify: `src/actions/admin-settings.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/route.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/resync/route.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/batch-regen/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `OfficialHabboFurniEntry`.
|
||||
- Produces: `getOfficialHabboFurnidata()`, `lookupOfficialHabboFurni()`, and `clearOfficialHabboFurnidataCache()`.
|
||||
- Consumes: `getHabboGamedataHotel()` and `habboFurnidataUrl(hotel)`.
|
||||
|
||||
- [ ] **Step 1: Write a failing cache-isolation test**
|
||||
|
||||
Mock the selected hotel as `it` for the first request and `nl` for the second. Return distinct fixtures from `fetch` and assert that the second call returns the Dutch fixture and requests `https://www.habbo.nl/gamedata/furnidata_json/1`.
|
||||
|
||||
- [ ] **Step 2: Run the cache test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
|
||||
Expected: FAIL because the generic official-Habbo API is not exported yet.
|
||||
|
||||
- [ ] **Step 3: Rename the cache contract and consumers**
|
||||
|
||||
Rename the Italy-specific type and functions throughout the import pipeline. Keep the existing hotel-keyed cache behavior and update comments to say “configured official Habbo hotel”.
|
||||
|
||||
- [ ] **Step 4: Run the cache test and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
|
||||
Expected: PASS with separate `it` and `nl` fetches.
|
||||
|
||||
### Task 2: Display and report the configured source
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/admin/import/furni/import-source.test.ts`
|
||||
- Create: `src/app/admin/import/furni/import-source.ts`
|
||||
- Modify: `src/app/admin/import/furni/page.tsx`
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Modify: `src/lib/services/furni-import.ts`
|
||||
- Modify: `src/lib/services/furni-import.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `FurniImportSource { hotel, label, host, furnidataUrl }`.
|
||||
- Produces: `getFurniImportSource()` for the server page.
|
||||
- Produces: `formatOfficialHabboEnrichmentWarning(hotel, name)`.
|
||||
|
||||
- [ ] **Step 1: Write failing source and message tests**
|
||||
|
||||
Assert that an `nl` setting becomes `{ hotel: "nl", label: "Netherlands (habbo.nl)", host: "habbo.nl", furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1" }` and that enrichment reports `Enriched from habbo.nl`.
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||
|
||||
Expected: FAIL because source metadata and the dynamic warning formatter do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement source metadata and UI**
|
||||
|
||||
Read the normalized hotel on the server page, pass source metadata into `ImportFurniClient`, and render a compact source badge/link above the furni controls. Replace literal `habbo.it` enrichment wording with the resolved host.
|
||||
|
||||
- [ ] **Step 4: Run focused verification**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run repository verification**
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all commands exit with status 0 using the same non-secret production validation environment as CI where required.
|
||||
|
||||
- [ ] **Step 6: Commit the implementation**
|
||||
|
||||
```text
|
||||
fix: use configured Habbo hotel in furni import
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Manual Recent Furni Resync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a guarded Tools command to Admin Import Furni that resyncs furniture imported during the last seven days and displays the operation result.
|
||||
|
||||
**Architecture:** Keep the existing permission-protected `/api/admin/import/furni/resync` route unchanged. Add a client-safe request/normalization helper with an injected fetcher so its exact URL, HTTP method, success payload, partial failures, and transport failures are testable without rendering the large Import Furni client. The existing client component owns the confirmation, loading, and result UI state.
|
||||
|
||||
**Tech Stack:** TypeScript, React 19, Next.js 16 App Router, Vitest, shadcn/Radix UI, Sonner, Biome.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The operation targets only `furni_import` audit entries from the last seven days.
|
||||
- Use the existing `ASSETS_IMPORT`-protected endpoint and `adminFetch` CSRF flow.
|
||||
- Do not expose `all=1`, delete database rows, or regenerate `.nitro`, SWF, or icon files.
|
||||
- Display examined, resynced, failed, RCON status, and returned per-item errors.
|
||||
- Prevent duplicate submissions while a request is active.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Tested recent-resync client contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/admin/recent-furni-resync.ts`
|
||||
- Test: `src/lib/admin/recent-furni-resync.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a fetcher matching `(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>`.
|
||||
- Produces: `RECENT_FURNI_RESYNC_URL`, `RecentFurniResyncError`, `RecentFurniResyncResult`, and `requestRecentFurniResync(fetcher): Promise<RecentFurniResyncResult>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing request-contract tests**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RECENT_FURNI_RESYNC_URL,
|
||||
requestRecentFurniResync,
|
||||
} from "./recent-furni-resync";
|
||||
|
||||
describe("requestRecentFurniResync", () => {
|
||||
it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
mode: "days",
|
||||
days: 7,
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
});
|
||||
expect(RECENT_FURNI_RESYNC_URL).toBe(
|
||||
"/api/admin/import/furni/resync?days=7",
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the API error when the request fails", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ error: "Forbidden" }, { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `recent-furni-resync.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal typed request helper**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.ts`:
|
||||
|
||||
```ts
|
||||
export const RECENT_FURNI_RESYNC_URL =
|
||||
"/api/admin/import/furni/resync?days=7";
|
||||
|
||||
export interface RecentFurniResyncError {
|
||||
classname: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecentFurniResyncResult {
|
||||
examined: number;
|
||||
resynced: number;
|
||||
failed: number;
|
||||
rconOk: boolean;
|
||||
errors: RecentFurniResyncError[];
|
||||
}
|
||||
|
||||
type AdminFetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export async function requestRecentFurniResync(
|
||||
fetcher: AdminFetcher,
|
||||
): Promise<RecentFurniResyncResult> {
|
||||
const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" });
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof payload.error === "string" ? payload.error : "Furni resync failed",
|
||||
);
|
||||
}
|
||||
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors.filter(
|
||||
(value): value is RecentFurniResyncError =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as RecentFurniResyncError).classname === "string" &&
|
||||
typeof (value as RecentFurniResyncError).message === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
examined: Number(payload.examined) || 0,
|
||||
resynced: Number(payload.resynced) || 0,
|
||||
failed: Number(payload.failed) || 0,
|
||||
rconOk: payload.rconOk === true,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec biome check --write src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: 2 tests pass and Biome reports no remaining errors.
|
||||
|
||||
- [ ] **Step 5: Commit the tested contract**
|
||||
|
||||
```powershell
|
||||
git add -- src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
git commit -m "feat: add recent furni resync client contract"
|
||||
```
|
||||
|
||||
### Task 2: Import Furni Tools action and result UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Consume: `src/lib/admin/recent-furni-resync.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `requestRecentFurniResync(adminFetch): Promise<RecentFurniResyncResult>`.
|
||||
- Produces: a `Tools → Update imported furni` action, confirmation dialog, loading state, and dismissible result panel.
|
||||
|
||||
- [ ] **Step 1: Add state and the guarded request handler**
|
||||
|
||||
Import `DatabaseZap`, `RecentFurniResyncResult`, and
|
||||
`requestRecentFurniResync`. Add state alongside the existing reorganization
|
||||
state:
|
||||
|
||||
```ts
|
||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||
const [recentResyncResult, setRecentResyncResult] =
|
||||
useState<RecentFurniResyncResult | null>(null);
|
||||
```
|
||||
|
||||
Add the handler near `startReorganize`:
|
||||
|
||||
```ts
|
||||
async function resyncRecentImports() {
|
||||
setConfirmRecentResync(false);
|
||||
setRecentResyncing(true);
|
||||
setRecentResyncResult(null);
|
||||
try {
|
||||
const result = await requestRecentFurniResync(adminFetch);
|
||||
setRecentResyncResult(result);
|
||||
if (result.examined === 0) {
|
||||
toast.info("No imported furni found in the last 7 days");
|
||||
} else if (result.failed > 0 || !result.rconOk) {
|
||||
toast.warning("Furni resync completed with warnings");
|
||||
} else {
|
||||
toast.success(`Updated ${result.resynced} imported furni`);
|
||||
}
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Furni resync failed",
|
||||
);
|
||||
} finally {
|
||||
setRecentResyncing(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Tools entry and duplicate-submit guard**
|
||||
|
||||
Insert before the Tools separator:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmRecentResync(true)}
|
||||
disabled={recentResyncing}
|
||||
>
|
||||
{recentResyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DatabaseZap className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{recentResyncing ? "Updating imported furni..." : "Update imported furni"}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the confirmation dialog**
|
||||
|
||||
Add a controlled dialog beside the existing batch confirmation dialogs:
|
||||
|
||||
```tsx
|
||||
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update imported furni?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This updates FurnitureData for furni imported during the last 7 days,
|
||||
then refreshes the emulator catalog and item caches. No database rows or
|
||||
asset files are deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRecentResync(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
|
||||
Update last 7 days
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render a dismissible result panel**
|
||||
|
||||
Place the panel after the existing error banner and before batch summaries:
|
||||
|
||||
```tsx
|
||||
{recentResyncResult && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecentResyncResult(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>{recentResyncResult.examined} examined</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{recentResyncResult.resynced} updated
|
||||
</span>
|
||||
<span className={recentResyncResult.failed ? "text-destructive" : ""}>
|
||||
{recentResyncResult.failed} failed
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
recentResyncResult.rconOk
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
|
||||
</span>
|
||||
</div>
|
||||
{recentResyncResult.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
View errors
|
||||
</summary>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{recentResyncResult.errors.map((error) => (
|
||||
<p key={`${error.classname}:${error.message}`}>
|
||||
<span className="font-mono">{error.classname}</span>: {error.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused and static verification**
|
||||
|
||||
```powershell
|
||||
pnpm exec biome check --write src/app/admin/import/furni/import-furni-client.tsx src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts src/lib/services/furni-data-paths.test.ts
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
Expected: Biome clean, focused tests pass, and TypeScript exits 0.
|
||||
|
||||
- [ ] **Step 6: Run complete regression verification**
|
||||
|
||||
```powershell
|
||||
pnpm test
|
||||
$env:NODE_ENV='production'
|
||||
$env:DATABASE_URL='mysql://test:test@localhost:3306/test?charset=utf8mb4'
|
||||
$env:AUTH_SECRET='ci-test-secret-key-that-is-long-enough'
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all tests pass and the production build exits 0. Redis/database
|
||||
availability warnings during static generation are acceptable in the local test
|
||||
environment; compilation or route-generation errors are not.
|
||||
|
||||
- [ ] **Step 7: Commit the UI integration**
|
||||
|
||||
```powershell
|
||||
git add -- src/app/admin/import/furni/import-furni-client.tsx
|
||||
git commit -m "feat: add manual recent furni resync action"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify the final repository state**
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git log --oneline origin/main..HEAD
|
||||
```
|
||||
|
||||
Expected: clean worktree and the design, plan, tested helper, and UI commits are
|
||||
ahead of `origin/main`, ready for an explicit push request.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Production Furni Assets Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make every furni import write the icon, Nitro bundle, and FurnitureData entry into the directories served by the production client under `/var/www/Gamedata`.
|
||||
|
||||
**Architecture:** Extend the central furni asset resolver with a separate Gamedata layout while preserving CMS-local and Nitro-Files targets. Both import paths consume the same resolved targets; FurnitureData resolves the same Gamedata root independently so metadata and binary assets stay aligned.
|
||||
|
||||
**Tech Stack:** TypeScript 7, Node.js filesystem APIs, Vitest 4, Next.js 16.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all existing `nitro_files_root`, `furni_*_dir`, and `furni_data_mirror_path` behavior.
|
||||
- Auto-detect `/var/www/Gamedata` only when the directory exists; allow `gamedata_root` to override it.
|
||||
- Do not copy source SWFs into the Gamedata tree.
|
||||
- Report live mirror failures in the import warnings.
|
||||
- Use test-first development and run the production build before completion.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Resolve the production Gamedata layout
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/furni-asset-dirs.test.ts`
|
||||
- Modify: `src/lib/services/furni-asset-dirs.ts`
|
||||
- Modify: `src/lib/services/furni-data.ts`
|
||||
- Modify: `src/app/admin/settings/cms-settings-config.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `getGamedataRoot(): Promise<string>`.
|
||||
- Produces: Gamedata mirror entries from `getFurniAssetWriteTargets()` with `<root>/icons` and `<root>/bundled/furniture`.
|
||||
- Consumes: `siteSettings.get("gamedata_root", "")` and `existsSync(DEFAULT_GAMEDATA_ROOT)`.
|
||||
|
||||
- [ ] **Step 1: Write failing resolver tests**
|
||||
|
||||
Mock `siteSettings.get` and `existsSync`, then assert that a configured Gamedata root produces:
|
||||
|
||||
```ts
|
||||
expect(targets.mirrorDirs).toContainEqual({
|
||||
swfDir: targets.swfDir,
|
||||
iconDir: path.join(gamedataRoot, "icons"),
|
||||
nitroDir: path.join(gamedataRoot, "bundled/furniture"),
|
||||
});
|
||||
```
|
||||
|
||||
Also assert that an absent unconfigured root adds no Gamedata mirror and that a duplicate primary destination is de-duplicated.
|
||||
|
||||
- [ ] **Step 2: Run the resolver test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
|
||||
|
||||
Expected: FAIL because `gamedata_root` is not read and the Gamedata mirror is absent.
|
||||
|
||||
- [ ] **Step 3: Implement the Gamedata resolver**
|
||||
|
||||
Add `DEFAULT_GAMEDATA_ROOT`, `getGamedataRoot()`, and a pure Gamedata mapping that uses the primary `swfDir` while mapping icons and Nitro bundles to the live locations. Merge this candidate with the existing Nitro-Files candidate and remove identical directory triples.
|
||||
|
||||
- [ ] **Step 4: Extend FurnitureData and the settings form**
|
||||
|
||||
Make `getFurnitureDataWritePaths()` include `<gamedata_root>/config/FurnitureData.json`, after any explicit `furni_data_mirror_path`. Add a documented `gamedata_root` text setting with `/var/www/Gamedata` as the placeholder.
|
||||
|
||||
- [ ] **Step 5: Run resolver tests and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 1**
|
||||
|
||||
```text
|
||||
fix: map furni imports to production gamedata
|
||||
```
|
||||
|
||||
### Task 2: Mirror manual Nitro uploads
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/upload-import.test.ts`
|
||||
- Modify: `src/lib/services/upload-import.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets>`.
|
||||
- Produces: manual upload copies at every unique `mirrorDirs[].iconDir` and `mirrorDirs[].nitroDir`.
|
||||
|
||||
- [ ] **Step 1: Write a failing manual-upload test**
|
||||
|
||||
Mock the database and metadata dependencies, provide temporary primary and Gamedata directories, call `uploadSingleFurni`, and assert:
|
||||
|
||||
```ts
|
||||
await expect(fs.readFile(path.join(liveNitroDir, "chair.nitro"))).resolves.toEqual(nitroBuffer);
|
||||
await expect(fs.readFile(path.join(liveIconDir, "chair_icon.png"))).resolves.toEqual(iconBuffer);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the upload test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
|
||||
|
||||
Expected: FAIL because manual uploads currently write only to the primary CMS directories.
|
||||
|
||||
- [ ] **Step 3: Implement manual mirroring**
|
||||
|
||||
Resolve all write targets, create their directories through the existing `ensureDirectories()`, and copy the successfully written primary Nitro and optional icon files to each unique mirror. Catch each copy error separately and append a warning containing the destination path.
|
||||
|
||||
- [ ] **Step 4: Run the upload test and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run focused and full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: every command exits with status 0.
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
```text
|
||||
fix: mirror manual furni uploads to live assets
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# Furni import hotel source
|
||||
|
||||
## Problem
|
||||
|
||||
The furni import already fetches furnidata through the CMS setting
|
||||
`habbo_gamedata_hotel`, but its public and internal language still refers to
|
||||
Habbo Italy. This makes the active source unclear and gives the impression
|
||||
that the importer is hardcoded to `habbo.it`.
|
||||
|
||||
## Design
|
||||
|
||||
The import page will read `habbo_gamedata_hotel` on the server and pass the
|
||||
normalized hotel value and label to the client. The page will display the
|
||||
active official furnidata source near the import controls, including the
|
||||
resolved `habbo.<hotel>` host.
|
||||
|
||||
Import enrichment warnings will use the resolved hotel label rather than the
|
||||
literal `habbo.it`. Internal furnidata cache APIs and types will be renamed
|
||||
from Italy-specific names to generic official-Habbo names while retaining
|
||||
temporary aliases only where needed to avoid an unsafe all-at-once migration.
|
||||
|
||||
Furniture SWF and icon downloads remain on `images.habbo.com`. That host is
|
||||
Habbo's global asset CDN and must not be derived from the gamedata locale.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Admin settings stores `habbo_gamedata_hotel`.
|
||||
2. Server-side import code normalizes the value through
|
||||
`getHabboGamedataHotel()`.
|
||||
3. Furnidata and external texts use `www.habbo.<hotel>`.
|
||||
4. The import UI and enrichment messages display the same resolved hotel.
|
||||
5. The in-memory cache remains keyed by hotel, so changing the setting loads
|
||||
the selected locale rather than reusing another locale's data.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests will cover normalized locale URL generation, cache separation after a
|
||||
hotel change, dynamic enrichment text, and the source information passed to
|
||||
the import UI. Existing furni import tests, typecheck, full tests, and the
|
||||
production build must pass.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Manual Recent Furni Resync Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a safe manual command to the existing Admin Import Furni screen for
|
||||
refreshing furniture imported during the last seven days. The command must
|
||||
update the live FurnitureData metadata and emulator caches without deleting
|
||||
database rows or regenerating existing asset bundles.
|
||||
|
||||
## User interface
|
||||
|
||||
Add `Update imported furni` to the existing `Tools` dropdown on
|
||||
`/admin/import/furni`.
|
||||
|
||||
Selecting it opens a confirmation dialog that states the fixed scope: furniture
|
||||
recorded by the admin audit log as imported during the last seven days. While
|
||||
the operation is running, the action and confirmation button are disabled and a
|
||||
loading state is shown to prevent duplicate requests.
|
||||
|
||||
After completion, show a result panel containing:
|
||||
|
||||
- examined item count;
|
||||
- successfully resynced item count;
|
||||
- failed item count;
|
||||
- emulator RCON refresh status;
|
||||
- returned per-item errors, when present.
|
||||
|
||||
## Data flow
|
||||
|
||||
The client sends an authenticated, CSRF-protected `POST` request through
|
||||
`adminFetch` to the existing endpoint:
|
||||
|
||||
`/api/admin/import/furni/resync?days=7`
|
||||
|
||||
The endpoint remains authoritative for selecting the targets. It reads only
|
||||
`ItemsBase` IDs referenced by `admin_audit_log` entries whose action is
|
||||
`furni_import`, target is `ItemsBase`, and timestamp falls within the last seven
|
||||
days.
|
||||
|
||||
For each target, the existing resync logic rebuilds its FurnitureData entry,
|
||||
enriches it from the configured `habbo_gamedata_hotel`, and upserts it into the
|
||||
live `FurnitureData.json`. It then requests `updateCatalog` and `updateItems`
|
||||
through RCON.
|
||||
|
||||
## Safety and permissions
|
||||
|
||||
- Reuse the endpoint's `ASSETS_IMPORT` permission check and admin CSRF guard.
|
||||
- Do not expose a UI option for `all=1`.
|
||||
- Do not delete or recreate `items_base` or catalog rows.
|
||||
- Do not regenerate `.nitro`, SWF, or icon files.
|
||||
- Keep returned errors visible without treating an RCON failure as a successful
|
||||
refresh.
|
||||
|
||||
## Error handling
|
||||
|
||||
Network or non-JSON failures produce an error toast and leave the command
|
||||
available for retry. A successful API response is rendered even when individual
|
||||
items failed, so the administrator can distinguish partial completion from a
|
||||
request failure.
|
||||
|
||||
The result panel is dismissible. Running the command again replaces the prior
|
||||
result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit-test the result normalization used by the UI, including partial failures
|
||||
and RCON status.
|
||||
- Verify that the client calls exactly `resync?days=7` through `adminFetch`.
|
||||
- Run focused tests, Biome, TypeScript, the full test suite, and a production
|
||||
build.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Production furni asset destinations
|
||||
|
||||
## Problem
|
||||
|
||||
The production Nitro client loads furniture icons from `/gamedata/icons`,
|
||||
furniture bundles from `/gamedata/bundled/furniture`, and furniture metadata
|
||||
from `/gamedata/config/FurnitureData.json`. The importer currently derives its
|
||||
mirror paths using the development `Nitro-Files` layout. On the production
|
||||
server this creates paths such as `swf/dcr/hof_furni/icons` and
|
||||
`nitro-assets/bundled/furniture` below the configured root, which are not the
|
||||
directories served by the live client. Manual `.nitro` uploads do not mirror
|
||||
assets at all.
|
||||
|
||||
Files written only below the CMS `public` directory are also not durable: the
|
||||
deployment workflow cleans untracked files from the CMS checkout.
|
||||
|
||||
## Design
|
||||
|
||||
Keep the existing CMS-local and `Nitro-Files` destinations for compatibility,
|
||||
and add the deployed Gamedata tree as a distinct asset layout. In production,
|
||||
`/var/www/Gamedata` is detected automatically when it exists. A configurable
|
||||
`gamedata_root` setting can override that location for other installations.
|
||||
|
||||
The Gamedata layout maps assets as follows:
|
||||
|
||||
- icons: `<gamedata_root>/icons`
|
||||
- Nitro furniture: `<gamedata_root>/bundled/furniture`
|
||||
- FurnitureData: `<gamedata_root>/config/FurnitureData.json`
|
||||
- source SWFs: not copied into Gamedata because the Nitro client does not load
|
||||
them; existing CMS-local and `Nitro-Files` SWF handling remains unchanged
|
||||
|
||||
Both the normal Habbo importer and manual `.nitro` uploader use the same write
|
||||
target resolver. Duplicate destinations are removed before writing.
|
||||
|
||||
## Error handling
|
||||
|
||||
The CMS-local write remains the primary operation. Every configured or
|
||||
auto-detected live destination is treated as an expected mirror. Directory or
|
||||
copy failures are returned as import warnings containing the failed target,
|
||||
instead of being silently ignored. A successful import therefore cannot hide
|
||||
that the live client asset copy failed.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing `nitro_files_root`, `furni_swf_dir`, `furni_icon_dir`,
|
||||
`furni_nitro_dir`, and `furni_data_mirror_path` behavior is preserved. The new
|
||||
Gamedata destination is additive, so Windows development using the
|
||||
`Nitro-Files` layout continues to work.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests will cover Gamedata path derivation, automatic production-root
|
||||
detection through an injected root, destination de-duplication, and manual
|
||||
upload mirroring. Existing importer tests, type checks, and the production
|
||||
build must remain green.
|
||||
@@ -0,0 +1,19 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// Schema source of truth for the query builder: src/db/schema.ts
|
||||
// (regenerated via `pnpm db:schema:generate` from the previous schema + live DB).
|
||||
//
|
||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
||||
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
||||
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/drafts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,14 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Admin panel", () => {
|
||||
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
|
||||
test("admin page has login form", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication flows", () => {
|
||||
test("login form validates required fields", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("login with invalid credentials shows error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="username"]', "nonexistent");
|
||||
await page.fill('input[name="password"]', "wrongpassword");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("register page has password confirmation field", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("register form validates password match", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.fill('input[name="password"]', "Password123!");
|
||||
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("homepage has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/Magic Hotel|Atom/i);
|
||||
});
|
||||
|
||||
test("register page loads", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page).toHaveTitle(/registreer|register|konto/i);
|
||||
});
|
||||
|
||||
test("login page loads", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveTitle(/inloggen|login/i);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Public navigation", () => {
|
||||
test("homepage loads with expected elements", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("nav")).toBeVisible();
|
||||
await expect(page.locator("footer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("community page loads", async ({ page }) => {
|
||||
await page.goto("/community");
|
||||
await expect(page).toHaveTitle(/community/i);
|
||||
});
|
||||
|
||||
test("shop page loads", async ({ page }) => {
|
||||
await page.goto("/shop");
|
||||
await expect(page.locator("h1, h2").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("404 page for unknown routes", async ({ page }) => {
|
||||
const response = await page.goto("/this-page-does-not-exist");
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "next",
|
||||
script: ".next/standalone/server.js",
|
||||
exec_mode: "cluster",
|
||||
instances: 0,
|
||||
max_memory_restart: "512M",
|
||||
env: {
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
"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}",
|
||||
"sentry.{server,edge}.config.ts"
|
||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignoreDependencies": [
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
url: ["http://localhost:3000"],
|
||||
numberOfRuns: 3,
|
||||
},
|
||||
assert: {
|
||||
preset: "lighthouse:no-pwa",
|
||||
assertions: {
|
||||
"categories:performance": ["error", { minScore: 0.9 }],
|
||||
"categories:accessibility": ["error", { minScore: 0.9 }],
|
||||
"categories:best-practices": ["error", { minScore: 0.9 }],
|
||||
"categories:seo": ["error", { minScore: 0.8 }],
|
||||
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
|
||||
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
|
||||
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+24
-33
@@ -1,8 +1,22 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
function resolveDeploymentId(): string | undefined {
|
||||
const configuredId = process.env.NEXT_DEPLOYMENT_ID?.trim();
|
||||
if (configuredId) return configuredId;
|
||||
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return process.env.APP_VERSION?.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
{
|
||||
@@ -20,26 +34,23 @@ const securityHeaders = [
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
deploymentId: resolveDeploymentId(),
|
||||
turbopack: {},
|
||||
serverExternalPackages: [
|
||||
"@prisma/adapter-mariadb",
|
||||
"mariadb",
|
||||
"@prisma/client",
|
||||
"lzma",
|
||||
"sharp",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
],
|
||||
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
// Enable React Compiler for automatic memoization
|
||||
reactCompiler: true,
|
||||
|
||||
// Compress responses with gzip
|
||||
// Compress responses with gzip/brotli
|
||||
compress: true,
|
||||
|
||||
// Disable Next.js telemetry
|
||||
// Disable Next.js telemetry and browser sourcemaps in production
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
experimental: {
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
|
||||
// Add caching headers for static assets
|
||||
async headers() {
|
||||
return [
|
||||
@@ -70,28 +81,8 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const config = withNextIntl(nextConfig);
|
||||
|
||||
// Source-map upload + release creation need SENTRY_AUTH_TOKEN.
|
||||
// Without it, keep the SDK wrapper but skip remote Sentry build steps
|
||||
// so CI/prod compile stays quiet (runtime DSN still works independently).
|
||||
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
|
||||
const withBA = withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
|
||||
export default withBA(
|
||||
withSentryConfig(config, {
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
silent: !process.env.CI || !sentryAuthToken,
|
||||
widenClientFileUpload: true,
|
||||
sourcemaps: {
|
||||
disable: !sentryAuthToken,
|
||||
},
|
||||
release: {
|
||||
create: Boolean(sentryAuthToken),
|
||||
},
|
||||
telemetry: false,
|
||||
}),
|
||||
);
|
||||
export default withBA(config);
|
||||
+32
-49
@@ -10,72 +10,63 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"prisma:generate": "prisma generate",
|
||||
"typecheck": "tsc6 --noEmit --incremental false",
|
||||
"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",
|
||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
||||
"lhci:collect": "lhci collect",
|
||||
"lhci:assert": "lhci assert",
|
||||
"lhci:server": "lhci server",
|
||||
"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",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write",
|
||||
"eslint --fix --max-warnings=50"
|
||||
"biome check --write"
|
||||
],
|
||||
"*.{json,md,css,scss,html}": [
|
||||
"biome format --write"
|
||||
"biome format --write --no-errors-on-unmatched"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@sentry/nextjs": "^10.68.0",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"@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",
|
||||
"framer-motion": "^12.42.2",
|
||||
"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.27.0",
|
||||
"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": "^16.3.0",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "^4.13.4",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"next-intl": "^4.13.5",
|
||||
"otplib": "^13.4.1",
|
||||
"pino": "^10.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"resend": "^6.18.1",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -84,38 +75,30 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/eslint-plugin-next": "^16.2.12",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@biomejs/biome": "2.5.7",
|
||||
"@next/bundle-analyzer": "^16.3.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@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",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-security": "^4.0.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^6.29.0",
|
||||
"knip": "^6.31.0",
|
||||
"lint-staged": "^17.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.24",
|
||||
"prisma": "^7.9.1",
|
||||
"postcss": "^8.5.25",
|
||||
"puppeteer": "^25.5.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "8.2.0",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Generated
+2201
-4982
File diff suppressed because it is too large.
Load diff
+2
-6
@@ -3,13 +3,9 @@
|
||||
# pnpm v11 vervanger voor onlyBuiltDependencies
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
prisma: true
|
||||
"@prisma/client": true
|
||||
"@prisma/engines": true
|
||||
sharp: true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
"@sentry/cli": true
|
||||
bcrypt: true
|
||||
|
||||
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
|
||||
@@ -20,7 +16,7 @@ overrides:
|
||||
uuid: "^9.0.1"
|
||||
fast-uri: "^3.1.3"
|
||||
"@hono/node-server": "^1.19.13"
|
||||
postcss: "^8.5.19"
|
||||
postcss: "^8.5.25"
|
||||
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
||||
tmp: "^0.2.6"
|
||||
# NIEUWE SECURITY PATCHES:
|
||||
@@ -32,4 +28,4 @@ peerDependencyRules:
|
||||
allowedVersions:
|
||||
nodemailer: "9.0.3"
|
||||
ignoreMissing:
|
||||
- nodemailer
|
||||
- nodemailer
|
||||
@@ -1,16 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
// Prisma 7 config. The datasource URL lives here (not in schema.prisma).
|
||||
// We NEVER run `prisma migrate`/`db push` against this database — it is shared
|
||||
// live with the Arcturus emulator. CMS-only schema changes go in
|
||||
// prisma/migrations/*.sql (idempotent) applied via `pnpm db:migrate`.
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ import { mysqlConnectionUrl } from "./db-url";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../drizzle/migrations");
|
||||
const TRACKING_TABLE = "cms_migrations";
|
||||
|
||||
interface MigrationFile {
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/db/schema.ts from:
|
||||
// 1. existing src/db/schema.ts -> TS export names, camelCase fields, column maps, keys
|
||||
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
|
||||
//
|
||||
// The DB is owned by the Arcturus emulator; we never run drizzle-kit migrate/push.
|
||||
// CMS DDL lands in drizzle/migrations/*.sql via `pnpm db:migrate`.
|
||||
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
|
||||
//
|
||||
// Usage: pnpm db:schema:generate
|
||||
import "dotenv/config";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MYSQL2_UNSUPPORTED_OPTIONS = [
|
||||
"connection_limit",
|
||||
"pool_timeout",
|
||||
"connect_timeout",
|
||||
];
|
||||
|
||||
function mysqlConnectionUrl(value) {
|
||||
const url = new URL(value);
|
||||
for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
|
||||
url.searchParams.delete(option);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
||||
const OUT = SCHEMA_TS;
|
||||
|
||||
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
|
||||
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
|
||||
|
||||
/**
|
||||
* @returns {{ name: string, table: string, fields: object[], ids: string[]|null, uniques: string[][] }}
|
||||
*/
|
||||
function parseDrizzleTables(source) {
|
||||
const models = [];
|
||||
const re2 =
|
||||
/^export const (\w+) = mysqlTable\(\s*"([^"]+)"\s*,\s*\{([\s\S]*?)\n\}(?:,\s*\(t\)\s*=>\s*\[([\s\S]*?)\])?\s*\);/gm;
|
||||
|
||||
let match = re2.exec(source);
|
||||
const seen = new Set();
|
||||
while (match !== null) {
|
||||
const [, name, table, body, extras] = match;
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
models.push(parseTableBody(name, table, body, extras ?? ""));
|
||||
}
|
||||
match = re2.exec(source);
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error(
|
||||
`[schema-gen] Failed to parse any mysqlTable exports from ${SCHEMA_TS}`,
|
||||
);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function parseTableBody(name, table, body, extras) {
|
||||
const fields = [];
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\s*$/);
|
||||
if (!m) continue;
|
||||
const fieldName = m[1];
|
||||
const expr = m[2];
|
||||
const colMatch = expr.match(/\(\s*"([^"]+)"/);
|
||||
if (!colMatch) continue;
|
||||
const column = colMatch[1];
|
||||
|
||||
const isBoolean = /\bboolean\s*\(/.test(expr);
|
||||
const enumMatch = expr.match(/mysqlEnum\s*\(\s*"[^"]+"\s*,\s*(\[[^\]]*\])/);
|
||||
let enumValues = null;
|
||||
if (enumMatch) {
|
||||
try {
|
||||
enumValues = JSON.parse(enumMatch[1].replace(/'/g, '"'));
|
||||
} catch {
|
||||
enumValues = [...enumMatch[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
||||
}
|
||||
}
|
||||
|
||||
let defaultContent = null;
|
||||
const defIdx = expr.indexOf(".default(");
|
||||
if (defIdx >= 0) {
|
||||
const start = defIdx + ".default(".length;
|
||||
let depth = 0;
|
||||
for (let i = start; i < expr.length; i++) {
|
||||
const ch = expr[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") {
|
||||
if (depth === 0) {
|
||||
defaultContent = expr.slice(start, i).trim();
|
||||
break;
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.push({
|
||||
fieldName,
|
||||
column,
|
||||
optional: !/\.notNull\s*\(/.test(expr),
|
||||
isId: /\.primaryKey\s*\(/.test(expr),
|
||||
isUnique: /\.unique\s*\(/.test(expr),
|
||||
autoIncrement: /\.autoincrement\s*\(/.test(expr),
|
||||
isBoolean,
|
||||
enumValues,
|
||||
defaultContent,
|
||||
// legacy shape used by columnExpr / fallback
|
||||
prismaType: isBoolean
|
||||
? "Boolean"
|
||||
: enumValues
|
||||
? fieldName === "gender"
|
||||
? "users_gender"
|
||||
: fieldName === "type" && table === "bans"
|
||||
? "bans_type"
|
||||
: "String"
|
||||
: "String",
|
||||
attrs: defaultContent ? `@default(${defaultContent})` : "",
|
||||
dbHint: null,
|
||||
});
|
||||
}
|
||||
|
||||
let ids = null;
|
||||
const uniques = [];
|
||||
if (extras) {
|
||||
const pk = extras.match(
|
||||
/primaryKey\(\s*\{\s*columns:\s*\[([^\]]+)\]\s*\}\s*\)/,
|
||||
);
|
||||
if (pk) {
|
||||
ids = [...pk[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]);
|
||||
}
|
||||
for (const u of extras.matchAll(/uniqueIndex\([^)]*\)\.on\(([^)]+)\)/g)) {
|
||||
uniques.push([...u[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, table, fields, ids, uniques };
|
||||
}
|
||||
|
||||
const models = parseDrizzleTables(schemaSource);
|
||||
|
||||
// ---------- Introspect live MySQL ----------
|
||||
let url;
|
||||
try {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL is required");
|
||||
url = mysqlConnectionUrl(raw);
|
||||
} catch (err) {
|
||||
console.error("[schema-gen] No DATABASE_URL:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
|
||||
const [cols] = await conn.query(
|
||||
`SELECT table_name, column_name, data_type, column_type, is_nullable,
|
||||
column_key, column_default, extra
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()`,
|
||||
);
|
||||
await conn.end();
|
||||
|
||||
const colByTable = new Map();
|
||||
for (const c of cols) {
|
||||
const key = `${c.table_name}.${c.column_name}`;
|
||||
colByTable.set(key, c);
|
||||
}
|
||||
|
||||
// ---------- Build drizzle column builders ----------
|
||||
const used = new Set(["mysqlTable", "primaryKey", "uniqueIndex", "customType"]);
|
||||
let usedSql = false;
|
||||
|
||||
function use(name) {
|
||||
used.add(name);
|
||||
}
|
||||
|
||||
function markSqlUsed() {
|
||||
usedSql = true;
|
||||
}
|
||||
|
||||
function quote(v) {
|
||||
return `"${String(v).replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Map a DB column row to a drizzle column expression string. */
|
||||
function columnExpr(field, dbCol) {
|
||||
const col = field.column;
|
||||
let type = "";
|
||||
|
||||
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
||||
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
||||
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
||||
|
||||
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
||||
use("mysqlEnum");
|
||||
const values = enumMatch
|
||||
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
: (field.enumValues ??
|
||||
(field.prismaType === "users_gender"
|
||||
? ["M", "F"]
|
||||
: ["account", "ip", "machine", "super"]));
|
||||
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
||||
}
|
||||
|
||||
switch (dbCol?.data_type) {
|
||||
case "int":
|
||||
use("int");
|
||||
type = unsigned
|
||||
? `int(${quote(col)}, { unsigned: true })`
|
||||
: `int(${quote(col)})`;
|
||||
break;
|
||||
case "tinyint": {
|
||||
const isBool =
|
||||
dbCol.column_type === "tinyint(1)" &&
|
||||
(field.isBoolean || field.prismaType === "Boolean");
|
||||
if (isBool) {
|
||||
use("boolean");
|
||||
type = `boolean(${quote(col)})`;
|
||||
} else {
|
||||
use("tinyint");
|
||||
type = unsigned
|
||||
? `tinyint(${quote(col)}, { unsigned: true })`
|
||||
: `tinyint(${quote(col)})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "smallint":
|
||||
use("smallint");
|
||||
type = unsigned
|
||||
? `smallint(${quote(col)}, { unsigned: true })`
|
||||
: `smallint(${quote(col)})`;
|
||||
break;
|
||||
case "mediumint":
|
||||
use("mediumint");
|
||||
type = unsigned
|
||||
? `mediumint(${quote(col)}, { unsigned: true })`
|
||||
: `mediumint(${quote(col)})`;
|
||||
break;
|
||||
case "bigint":
|
||||
use("bigint");
|
||||
type = `bigint(${quote(col)}, { mode: "bigint", unsigned: ${unsigned} })`;
|
||||
break;
|
||||
case "varchar":
|
||||
case "enum": {
|
||||
use("varchar");
|
||||
const maxLen = enumMatch
|
||||
? Math.max(
|
||||
...[...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].length),
|
||||
1,
|
||||
)
|
||||
: Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 255);
|
||||
type = `varchar(${quote(col)}, { length: ${maxLen} })`;
|
||||
break;
|
||||
}
|
||||
case "char":
|
||||
use("char");
|
||||
type = `char(${quote(col)}, { length: ${Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 8)} })`;
|
||||
break;
|
||||
case "text":
|
||||
use("text");
|
||||
type = `text(${quote(col)})`;
|
||||
break;
|
||||
case "mediumtext":
|
||||
use("mediumtext");
|
||||
type = `mediumtext(${quote(col)})`;
|
||||
break;
|
||||
case "longtext":
|
||||
use("longtext");
|
||||
type = `longtext(${quote(col)})`;
|
||||
break;
|
||||
case "datetime": {
|
||||
use("datetime");
|
||||
const fsp = dbCol.column_type.match(/datetime\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `datetime(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `datetime(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "timestamp": {
|
||||
use("timestamp");
|
||||
const fsp = dbCol.column_type.match(/timestamp\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `timestamp(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `timestamp(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "double":
|
||||
use("double");
|
||||
type = `double(${quote(col)})`;
|
||||
break;
|
||||
case "float":
|
||||
use("float");
|
||||
type = `float(${quote(col)})`;
|
||||
break;
|
||||
case "decimal":
|
||||
use("decimal");
|
||||
type = `decimal(${quote(col)}, { precision: ${Number(decimalMatch?.[1] ?? 10)}, scale: ${Number(decimalMatch?.[2] ?? 0)}, mode: "number" })`;
|
||||
break;
|
||||
case "json":
|
||||
use("json");
|
||||
type = `json(${quote(col)})`;
|
||||
break;
|
||||
case "date":
|
||||
type = `dateAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "time":
|
||||
type = `timeAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "blob":
|
||||
case "tinyblob":
|
||||
case "mediumblob":
|
||||
case "longblob":
|
||||
use("binary");
|
||||
type = `binary(${quote(col)})`;
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col}`,
|
||||
);
|
||||
use("varchar");
|
||||
type = `varchar(${quote(col)}, { length: 255 })`;
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
function modifiers(field, dbCol) {
|
||||
let expr = "";
|
||||
if (dbCol?.extra?.includes("auto_increment") || field.autoIncrement) {
|
||||
expr += `.autoincrement()`;
|
||||
}
|
||||
if (field.isId) {
|
||||
expr += `.primaryKey()`;
|
||||
} else if (dbCol?.column_key === "UNI" && !field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
} else if (field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
}
|
||||
if (!field.optional) {
|
||||
expr += `.notNull()`;
|
||||
}
|
||||
expr += emitDefault(field, dbCol);
|
||||
return expr;
|
||||
}
|
||||
|
||||
function emitDefault(field, dbCol) {
|
||||
const content = field.defaultContent;
|
||||
if (!content) return "";
|
||||
if (
|
||||
content === "sql`CURRENT_TIMESTAMP`" ||
|
||||
content.includes("CURRENT_TIMESTAMP")
|
||||
) {
|
||||
markSqlUsed();
|
||||
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
||||
}
|
||||
if (content === "true" || content === "false") return `.default(${content})`;
|
||||
if (/^-?\d+n$/.test(content)) return `.default(${content})`;
|
||||
if (/^-?\d+$/.test(content)) {
|
||||
return dbCol?.data_type === "bigint"
|
||||
? `.default(${content}n)`
|
||||
: `.default(${content})`;
|
||||
}
|
||||
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
||||
if (
|
||||
(content.startsWith('"') && content.endsWith('"')) ||
|
||||
(content.startsWith("'") && content.endsWith("'"))
|
||||
) {
|
||||
return `.default(${JSON.stringify(content.slice(1, -1))})`;
|
||||
}
|
||||
return `.default(${content})`;
|
||||
}
|
||||
|
||||
function modelTable(model) {
|
||||
const rows = [];
|
||||
for (const f of model.fields) {
|
||||
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
||||
if (!dbCol) {
|
||||
const fallback = fallbackColumn(f) + modifiers(f, null);
|
||||
rows.push(`\t${f.fieldName}: ${fallback},`);
|
||||
console.warn(
|
||||
`[schema-gen] WARN ${model.table}.${f.column} (${f.fieldName}) missing in DB, used fallback`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rows.push(
|
||||
`\t${f.fieldName}: ${columnExpr(f, dbCol)}${modifiers(f, dbCol)},`,
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueRows = [];
|
||||
if (model.ids) {
|
||||
const idCols = model.ids
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (idCols.length)
|
||||
uniqueRows.push(`\tprimaryKey({ columns: [${idCols.join(", ")}] }),`);
|
||||
}
|
||||
for (const u of model.uniques) {
|
||||
const cols = u
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (cols.length)
|
||||
uniqueRows.push(
|
||||
`\tuniqueIndex("${model.table}_${u.join("_")}").on(${cols.join(", ")}),`,
|
||||
);
|
||||
}
|
||||
|
||||
if (uniqueRows.length) {
|
||||
return `export const ${model.name} = mysqlTable(
|
||||
"${model.table}",
|
||||
{
|
||||
${rows.join("\n")}
|
||||
},
|
||||
(t) => [
|
||||
${uniqueRows.join("\n")}
|
||||
],
|
||||
);`;
|
||||
}
|
||||
return `export const ${model.name} = mysqlTable("${model.table}", {
|
||||
${rows.join("\n")}
|
||||
});`;
|
||||
}
|
||||
|
||||
function fallbackColumn(field) {
|
||||
const name = field.column;
|
||||
if (field.enumValues) {
|
||||
use("mysqlEnum");
|
||||
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
|
||||
}
|
||||
if (field.isBoolean || field.prismaType === "Boolean") {
|
||||
use("boolean");
|
||||
return `boolean(${quote(name)})`;
|
||||
}
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
}
|
||||
|
||||
// ---------- Generate file ----------
|
||||
const body = models.map(modelTable).join("\n\n");
|
||||
|
||||
const IMPORTABLE = [
|
||||
"bigint",
|
||||
"binary",
|
||||
"boolean",
|
||||
"char",
|
||||
"customType",
|
||||
"date",
|
||||
"datetime",
|
||||
"decimal",
|
||||
"double",
|
||||
"float",
|
||||
"int",
|
||||
"json",
|
||||
"longtext",
|
||||
"mediumint",
|
||||
"mediumtext",
|
||||
"mysqlEnum",
|
||||
"mysqlTable",
|
||||
"primaryKey",
|
||||
"smallint",
|
||||
"text",
|
||||
"time",
|
||||
"timestamp",
|
||||
"tinyint",
|
||||
"uniqueIndex",
|
||||
"varchar",
|
||||
];
|
||||
const importList = IMPORTABLE.filter((b) => used.has(b));
|
||||
|
||||
const helpers = [
|
||||
"// MySQL TIME / DATE columns are hydrated as JS Date (epoch 1970-01-01",
|
||||
"// for TIME). Keep this so existing call sites stay unchanged.",
|
||||
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "time";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(11, 19);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
||||
" return new Date(`1970-01-01T${value}Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"const dateAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "date";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(0, 10);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
||||
" return new Date(`${value}T00:00:00Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const sqlImport = usedSql ? 'import { sql } from "drizzle-orm";\n' : "";
|
||||
|
||||
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
||||
// TS field names come from the previous src/db/schema.ts; column types from the
|
||||
// live MySQL DB. Run \`pnpm db:schema:generate\` after schema/map changes.
|
||||
${sqlImport}import {
|
||||
${importList.map((b) => `\t${b},`).join("\n")}
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
${helpers}${body}
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, out);
|
||||
console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`);
|
||||
+166
-24
@@ -1,29 +1,81 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Cron } from "croner";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
|
||||
function initWorkerSentry(): void {
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (!dsn || process.env.NODE_ENV !== "production") return;
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
tracesSampleRate: 0.05,
|
||||
});
|
||||
logger.info("Sentry initialized for jobs worker", { module: "jobs" });
|
||||
}
|
||||
import { redis } from "../src/lib/redis";
|
||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
logger.error(context, {
|
||||
module: "jobs",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
|
||||
/** In-process cooldown so a flapping probe does not spam Discord/email. */
|
||||
const alertCooldownMs = (env.HEALTH_ALERT_COOLDOWN_MIN ?? 15) * 60_000;
|
||||
const lastHealthAlertAt = new Map<string, number>();
|
||||
|
||||
function canAlert(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const prev = lastHealthAlertAt.get(key) ?? 0;
|
||||
if (now - prev < alertCooldownMs) return false;
|
||||
lastHealthAlertAt.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function probeHealth(): Promise<{
|
||||
database: boolean;
|
||||
redis: boolean | null;
|
||||
emulator: boolean;
|
||||
}> {
|
||||
const database = await db
|
||||
.execute(sql`SELECT 1`)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
let redisOk: boolean | null = null;
|
||||
if (env.REDIS_URL) {
|
||||
if (!redis) {
|
||||
redisOk = false;
|
||||
} else {
|
||||
try {
|
||||
redisOk = (await redis.ping()) === "PONG";
|
||||
} catch {
|
||||
redisOk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||
return {
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator: Boolean(emulator),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkOpsHealth(): Promise<void> {
|
||||
try {
|
||||
const health = await probeHealth();
|
||||
const degraded =
|
||||
!health.database || health.redis === false || !health.emulator;
|
||||
if (!degraded) return;
|
||||
|
||||
if (!health.emulator && health.database && health.redis !== false) {
|
||||
if (canAlert("emulator")) {
|
||||
await emulatorOffline("jobs-worker RCON ping failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (canAlert("health")) {
|
||||
await healthDegraded(health);
|
||||
}
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Health probe failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +121,90 @@ async function backupEmulatorJar(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional mysqldump when DB_BACKUP_DIR is set (host must have mysqldump on PATH). */
|
||||
async function backupDatabase(): Promise<void> {
|
||||
const backupDir = env.DB_BACKUP_DIR;
|
||||
if (!backupDir || !env.DATABASE_URL) return;
|
||||
|
||||
const { mkdirSync, readdirSync, unlinkSync, existsSync, createWriteStream } =
|
||||
await import("node:fs");
|
||||
const { resolve } = await import("node:path");
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(env.DATABASE_URL);
|
||||
} catch {
|
||||
logger.error("Invalid DATABASE_URL for DB backup", { module: "jobs" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(backupDir)) {
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||
const dbName =
|
||||
decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "cms";
|
||||
const outFile = resolve(backupDir, `db-${dbName}-${timestamp}.sql`);
|
||||
|
||||
const args = [
|
||||
`-h${parsed.hostname}`,
|
||||
`-P${parsed.port || "3306"}`,
|
||||
`-u${decodeURIComponent(parsed.username)}`,
|
||||
`--single-transaction`,
|
||||
`--routines`,
|
||||
`--databases`,
|
||||
dbName,
|
||||
];
|
||||
if (parsed.password) {
|
||||
args.splice(3, 0, `-p${decodeURIComponent(parsed.password)}`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
const child = spawn("mysqldump", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const out = createWriteStream(outFile);
|
||||
child.stdout.pipe(out);
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
captureWorkerError(err, "mysqldump spawn failed (is it on PATH?)");
|
||||
resolvePromise();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
out.end();
|
||||
if (code !== 0) {
|
||||
captureWorkerError(
|
||||
new Error(stderr || `mysqldump exit ${code}`),
|
||||
"DB backup failed",
|
||||
);
|
||||
} else {
|
||||
logger.info("Backed up database", { module: "jobs", outFile });
|
||||
const keep = env.DB_BACKUP_KEEP ?? 7;
|
||||
const files = readdirSync(backupDir)
|
||||
.filter((f) => f.startsWith("db-") && f.endsWith(".sql"))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (let i = keep; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file) unlinkSync(resolve(backupDir, file));
|
||||
}
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupOldLogs(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
await prisma.websiteLoginLogs.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
await db
|
||||
.delete(WebsiteLoginLogs)
|
||||
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
|
||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Log cleanup failed");
|
||||
@@ -84,9 +214,7 @@ async function cleanupOldLogs(): Promise<void> {
|
||||
async function cleanupOldSessions(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
await prisma.passwordReset.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
|
||||
logger.info("Cleaned up expired password reset tokens", {
|
||||
module: "jobs",
|
||||
});
|
||||
@@ -96,7 +224,6 @@ async function cleanupOldSessions(): Promise<void> {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
initWorkerSentry();
|
||||
logger.info("Worker started", { module: "jobs" });
|
||||
|
||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||
@@ -108,6 +235,15 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
if (env.DB_BACKUP_DIR) {
|
||||
new Cron("30 3 * * *", () => {
|
||||
backupDatabase().catch((e) => captureWorkerError(e, "DB backup error"));
|
||||
});
|
||||
logger.info("Scheduled: mysqldump DB backup (daily 03:30)", {
|
||||
module: "jobs",
|
||||
});
|
||||
}
|
||||
|
||||
new Cron("0 4 * * *", () => {
|
||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||
captureWorkerError(e, "Cleanup error"),
|
||||
@@ -115,10 +251,16 @@ async function main() {
|
||||
});
|
||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
||||
|
||||
new Cron("*/5 * * * *", () => {
|
||||
checkOpsHealth().catch((e) => captureWorkerError(e, "Health check error"));
|
||||
});
|
||||
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
||||
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
cleanupOldSessions(),
|
||||
checkOpsHealth(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const fs = require("node:fs");
|
||||
const JSON5 = require("json5");
|
||||
|
||||
const exampleFile = process.argv[2];
|
||||
const targetFile = process.argv[3];
|
||||
|
||||
if (!exampleFile || !targetFile) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function deepMerge(target, source) {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let example;
|
||||
try {
|
||||
const content = fs.readFileSync(exampleFile, "utf-8");
|
||||
example = JSON5.parse(content);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let current = {};
|
||||
try {
|
||||
if (fs.existsSync(targetFile)) {
|
||||
const content = fs.readFileSync(targetFile, "utf-8");
|
||||
current = JSON5.parse(content);
|
||||
}
|
||||
} catch {
|
||||
current = {};
|
||||
}
|
||||
|
||||
const merged = deepMerge(current, example);
|
||||
|
||||
const isJson5 = targetFile.endsWith(".json5");
|
||||
const output = isJson5
|
||||
? JSON5.stringify(merged, null, 4)
|
||||
: JSON.stringify(merged, null, 4);
|
||||
fs.writeFileSync(targetFile, `${output}\n`);
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
import { db, User } from "../src/lib/db";
|
||||
|
||||
function getKey(appKey: string): Buffer {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
@@ -84,10 +85,10 @@ async function main() {
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
const users = await db
|
||||
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
|
||||
.from(User)
|
||||
.where(isNotNull(User.twoFactorSecret));
|
||||
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
|
||||
@@ -107,10 +108,10 @@ async function main() {
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
await db
|
||||
.update(User)
|
||||
.set({ twoFactorSecret: reEncrypted })
|
||||
.where(eq(User.id, user.id));
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
@@ -125,12 +126,10 @@ async function main() {
|
||||
if (errors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
describe("radio columns migration", () => {
|
||||
it("adds every column idempotently for partially migrated databases", () => {
|
||||
const sql = readFileSync(
|
||||
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import puppeteer from "puppeteer-extra";
|
||||
import StealthPlugin from "puppeteer-extra-plugin-stealth";
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
const UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
interface CloneSource {
|
||||
id: string;
|
||||
name: string;
|
||||
furnidataUrl: string;
|
||||
nitroBaseUrl: string;
|
||||
iconBaseUrl: string;
|
||||
}
|
||||
|
||||
const sources: CloneSource[] = [
|
||||
{
|
||||
id: "default-habbo",
|
||||
name: "Habbo",
|
||||
furnidataUrl:
|
||||
"https://raw.githubusercontent.com/Izzxt/habbo-resource/master/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "",
|
||||
},
|
||||
{
|
||||
id: "default-wibbo",
|
||||
name: "Wibbo",
|
||||
furnidataUrl: "https://assets.wibbo.org/gamedata/FurnitureData.json",
|
||||
nitroBaseUrl: "https://assets.wibbo.org/bundled/furniture",
|
||||
iconBaseUrl: "https://assets.wibbo.org/icons",
|
||||
},
|
||||
{
|
||||
id: "default-hubba",
|
||||
name: "Hubba.cc",
|
||||
furnidataUrl: "https://nitro.hubba.cc/gamedata/FurnitureData.json",
|
||||
nitroBaseUrl: "https://nitro.hubba.cc/bundled/furniture",
|
||||
iconBaseUrl: "",
|
||||
},
|
||||
{
|
||||
id: "default-leet",
|
||||
name: "Leet",
|
||||
furnidataUrl: "https://www.leet.ws/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://www.leet.ws/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://www.leet.ws/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-hubbly",
|
||||
name: "Hubbly",
|
||||
furnidataUrl: "https://www.hubbly.pw/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://www.hubbly.pw/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://www.hubbly.pw/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habbaio",
|
||||
name: "Habba.io",
|
||||
furnidataUrl: "https://habba.io/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://habba.io/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://habba.io/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habheights",
|
||||
name: "Hab Heights",
|
||||
furnidataUrl: "https://habheights.online/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://habheights.online/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://habheights.online/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-sodaho",
|
||||
name: "Soda Ho",
|
||||
furnidataUrl: "https://assets.sodaho.tel/gamedata/FurnitureData.json",
|
||||
nitroBaseUrl: "https://sodaho.tel/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://sodaho.tel/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habstar",
|
||||
name: "Hab Star",
|
||||
furnidataUrl: "https://habstar.nu/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://habstar.nu/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://habstar.nu/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-freshhotel",
|
||||
name: "Fresh Hotel",
|
||||
furnidataUrl: "https://fresh-hotel.org/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://fresh-hotel.org/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://fresh-hotel.org/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habcrush",
|
||||
name: "Hab Crush",
|
||||
furnidataUrl: "https://habcrush.pw/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://habcrush.pw/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://habcrush.pw/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-zento",
|
||||
name: "Zento",
|
||||
furnidataUrl: "https://zento.wtf/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://zento.wtf/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://zento.wtf/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habhub",
|
||||
name: "Hab Hub",
|
||||
furnidataUrl: "https://habhub.life/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://habhub.life/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://habhub.life/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-fobba",
|
||||
name: "Fobba",
|
||||
furnidataUrl: "https://fobba.net/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://fobba.net/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://fobba.net/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-playaura",
|
||||
name: "Play Aura",
|
||||
furnidataUrl: "https://playaura.net/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://playaura.net/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://playaura.net/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-hablet",
|
||||
name: "Hablet",
|
||||
furnidataUrl: "https://hablet.pw/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://hablet.pw/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://hablet.pw/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-eclipsehotel",
|
||||
name: "Eclipse Hotel",
|
||||
furnidataUrl: "https://eclipsehotel.me/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://eclipsehotel.me/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://eclipsehotel.me/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habbletcity",
|
||||
name: "Habblet City",
|
||||
furnidataUrl: "https://www.habblet.city/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://www.habblet.city/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://www.habblet.city/nitro/icons",
|
||||
},
|
||||
{
|
||||
id: "default-habbocity",
|
||||
name: "Habbo City",
|
||||
furnidataUrl: "https://habbocity.fr/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://habbocity.fr/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://habbocity.fr/nitro/icons",
|
||||
},
|
||||
];
|
||||
|
||||
async function testUrl(
|
||||
page: any,
|
||||
url: string,
|
||||
): Promise<{ status: number; blocked: boolean }> {
|
||||
try {
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: "networkidle2",
|
||||
timeout: 15000,
|
||||
});
|
||||
const status = response?.status() ?? 0;
|
||||
const body = await page.content();
|
||||
|
||||
const blocked =
|
||||
body.includes("Attention Required") ||
|
||||
body.includes("Checking your browser") ||
|
||||
body.includes("Just a moment");
|
||||
return { status, blocked };
|
||||
} catch (e: any) {
|
||||
return { status: 0, blocked: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-web-security",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--user-agent=" + UA,
|
||||
],
|
||||
});
|
||||
|
||||
const results: Record<string, any> = {};
|
||||
|
||||
for (const src of sources) {
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent(UA);
|
||||
|
||||
const entry: any = { name: src.name };
|
||||
const tests: { url: string; status: number; blocked: boolean }[] = [];
|
||||
|
||||
const urls = [
|
||||
src.furnidataUrl,
|
||||
src.nitroBaseUrl ? `${src.nitroBaseUrl}` : null,
|
||||
src.iconBaseUrl ? `${src.iconBaseUrl}` : null,
|
||||
].filter((u): u is string => !!u && u.length > 0);
|
||||
|
||||
for (const url of urls) {
|
||||
if (!url) continue;
|
||||
const result = await testUrl(page, url);
|
||||
tests.push({ url, ...result });
|
||||
}
|
||||
|
||||
entry.tests = tests;
|
||||
entry.working = tests.some((t) => t.status === 200 && !t.blocked);
|
||||
results[src.id] = entry;
|
||||
|
||||
console.log(
|
||||
`${entry.working ? "✓" : "❌"} ${src.name}` +
|
||||
(tests.length > 0
|
||||
? `: ${tests.map((t) => `${t.status}${t.blocked ? "B" : ""}`).join(" ")}`
|
||||
: " (no URLs)"),
|
||||
);
|
||||
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
writeFileSync("/tmp/clone-test.json", JSON.stringify(results, null, 2));
|
||||
console.log("\nResults written to /tmp/clone-test.json");
|
||||
}
|
||||
|
||||
void main().catch(console.error);
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
ignoreErrors: ["AbortError", "NEXT_REDIRECT", "NEXT_NOT_FOUND"],
|
||||
beforeSend: redactSentryEvent,
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
ignoreErrors: [
|
||||
"Network request failed",
|
||||
"AbortError",
|
||||
"NEXT_REDIRECT",
|
||||
"NEXT_NOT_FOUND",
|
||||
],
|
||||
beforeSend: redactSentryEvent,
|
||||
});
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# ===========================================================================
|
||||
# Polaris Emulator — reference config.ini
|
||||
# Copy this to /var/www/emulator/Emulator/target/config.ini and fill in the
|
||||
# values marked CHANGE_ME. The emulator reads this file on startup.
|
||||
# Full guide: https://github.com/duckietm/Complete-Retro-on-Ubuntu
|
||||
# ===========================================================================
|
||||
|
||||
# ---- Database configuration ----
|
||||
db.hostname=127.0.0.1
|
||||
db.port=3306
|
||||
db.database=habbo # DB name (shared with the CMS)
|
||||
db.username=root # Username
|
||||
db.password=CHANGE_ME # Password
|
||||
db.params=?characterEncoding=utf8&useSSL=false&serverTimezone=Europe/Amsterdam
|
||||
db.pool.minsize=25
|
||||
db.pool.maxsize=100
|
||||
db.pool.connection_timeout_ms=10000
|
||||
db.pool.idle_timeout_ms=600000
|
||||
db.pool.max_lifetime_ms=1800000
|
||||
db.pool.validation_timeout_ms=5000
|
||||
db.pool.leak_detection_ms=20000 # set to 0 to disable leak detection
|
||||
|
||||
# ---- Game configuration ----
|
||||
# Host IP. Use 0.0.0.0 in most cases. Use 127.0.0.1 for LAN only.
|
||||
game.host=0.0.0.0
|
||||
game.port=3000
|
||||
|
||||
# ---- RCON configuration ----
|
||||
# Leave host at 127.0.0.1 if the CMS runs on the same server as the emulator.
|
||||
rcon.host=127.0.0.1
|
||||
rcon.port=3001
|
||||
rcon.allowed=127.0.0.1;127.0.0.2
|
||||
|
||||
# ---- Nitro secure runtime assets (disabled by default) ----
|
||||
nitro.secure.assets.enabled=false
|
||||
nitro.secure.api.enabled=false
|
||||
nitro.secure.session_ttl_sec=900
|
||||
nitro.secure.config.root=
|
||||
nitro.secure.gamedata.root=
|
||||
# Set a persistent secret when using Cloudflare or multiple backend requests.
|
||||
nitro.secure.master_key=change-me-to-a-long-random-secret
|
||||
|
||||
# ---- Login ----
|
||||
login.remember.enabled=true
|
||||
login.remember.duration.days=30
|
||||
# Optional: set a persistent remember-me JWT secret here.
|
||||
login.remember.jwt.secret=
|
||||
login.news.limit=5
|
||||
|
||||
# ---- Secure runtime ECDH session TTL in seconds ----
|
||||
# (kept for compatibility; unused when secure assets are disabled)
|
||||
|
||||
# ---- WebSockets (Nitro client) ----
|
||||
ws.enabled=true
|
||||
ws.host=0.0.0.0
|
||||
ws.port=2096
|
||||
# Header used to obtain the real client IP when behind a proxy
|
||||
# (usually X-Forwarded-For, or CF-Connecting-IP behind Cloudflare).
|
||||
ws.ip.header=X-Forwarded-For
|
||||
|
||||
# ---- Console / emulator_settings ----
|
||||
# Run these SQL statements ONCE, after importing the emulator database:
|
||||
# USE habbo;
|
||||
# UPDATE emulator_settings SET `value` = '0' WHERE `key` = 'console.mode';
|
||||
# UPDATE emulator_settings SET `value` = 'MY_DOMAIN.COM,*.MY_DOMAIN.COM,localhost,127.0.0.1' WHERE `key` = 'websockets.whitelist';
|
||||
# console.mode MUST be 0 and the whitelist MUST match your domain.
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Launcher for the Polaris emulator.
|
||||
# Place at /var/www/emulator/Emulator/target/emulator and chmod +x.
|
||||
# Update the JAR name to match the version built by `mvn clean package`.
|
||||
|
||||
file_name_emulator=emulator.log
|
||||
current_time=$(date "+%H%M_%d-%m-%Y")
|
||||
file_name=$file_name_emulator.$current_time
|
||||
|
||||
# Rotate the previous log
|
||||
mkdir -p /var/log/emu
|
||||
mv /var/log/emu/emulator.log /var/log/emu/$file_name 2>/dev/null || true
|
||||
|
||||
# -Xmx4096m = 4 GB. 1 GB=1024, 2 GB=2048, 3 GB=3072, 4 GB=4096
|
||||
java -Dfile.encoding=UTF8 -Xmx4096m \
|
||||
-jar /var/www/emulator/Emulator/target/Habbo-*-jar-with-dependencies.jar \
|
||||
>/var/log/emu/emulator.log
|
||||
@@ -1,25 +0,0 @@
|
||||
[Unit]
|
||||
Description=Habbo Emulator
|
||||
|
||||
# Make sure the database has started before the emulator can start
|
||||
After=mariadb.service
|
||||
Requires=mariadb.service
|
||||
|
||||
# If you want to run as a less privileged account, change User below.
|
||||
# Make sure that account has the right permissions on the working directory and target folders.
|
||||
[Service]
|
||||
User=root
|
||||
|
||||
# Working directory where config.ini and the JAR live
|
||||
WorkingDirectory=/var/www/emulator/Emulator/target
|
||||
|
||||
# The launcher script we created below. It is a shell wrapper that calls the JAR.
|
||||
ExecStart=/var/www/emulator/Emulator/target/emulator
|
||||
|
||||
SuccessExitStatus=143
|
||||
TimeoutStopSec=10
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"distObfuscationEnabled": false,
|
||||
"secureAssetsEnabled": false,
|
||||
"secureApiEnabled": false,
|
||||
"apiBaseUrl": "https://MY_DOMAIN:2096",
|
||||
"plainConfigBaseUrl": "https://MY_DOMAIN/configuration/",
|
||||
"plainGamedataBaseUrl": "https://MY_DOMAIN/gamedata/"
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"socket.url": "wss://MY_DOMAIN.COM:2096",
|
||||
"crypto.ws.enabled": false,
|
||||
"crypto.ws.signing.enabled": false,
|
||||
"crypto.ws.signing.public_key": "",
|
||||
"api.url": "https://MY_DOMAIN.COM:2096",
|
||||
"asset.url": "https://MY_DOMAIN.COM/gamedata",
|
||||
"image.library.url": "http://MY_DOMAIN.COM/gamedata/c_images/",
|
||||
"hof.furni.url": "https://MY_DOMAIN.COM",
|
||||
"images.url": "${asset.url}/images",
|
||||
"gamedata.url": "${asset.url}",
|
||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
||||
"external.texts.url": [
|
||||
"${gamedata.url}/config/ExternalTexts.json?v=1",
|
||||
"${gamedata.url}/config/UITexts.json?v=1"
|
||||
],
|
||||
"external.samples.url": "${gamedata.url}/sounds/sound_machine_sample_%sample%.mp3",
|
||||
"furnidata.url": "${gamedata.url}/config/FurnitureData.json?v=1",
|
||||
"productdata.url": "${gamedata.url}/config/ProductData.json?v=1",
|
||||
"avatar.actions.url": "${gamedata.url}/config/HabboAvatarActions.json?v=1",
|
||||
"avatar.figuredata.url": "${gamedata.url}/config/FigureData.json?v=1",
|
||||
"avatar.figuremap.url": "${gamedata.url}/config/FigureMap.json?v=1",
|
||||
"avatar.effectmap.url": "${gamedata.url}/config/EffectMap.json?v=1",
|
||||
"avatar.asset.url": "${asset.url}/clothes/%libname%.nitro",
|
||||
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
|
||||
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
|
||||
"furni.asset.icon.url": "http://MY_DOMAIN.COM/gamedata/icons/%libname%%param%_icon.png",
|
||||
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
|
||||
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
|
||||
"room.asset.url": "${asset.url}/room/%libname%/%libname%.json",
|
||||
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
||||
"badge.asset.group.url": "http://MY_DOMAIN.COM/habbo-imaging/badge/%badgedata%",
|
||||
"badge.asset.group.external.url": "",
|
||||
"badge.asset.grouparts.url": "https://MY_DOMAIN.COM/gamedata/badgeparts/badgepart_%part%.png",
|
||||
"furni.rotation.bounce.steps": 20,
|
||||
"furni.rotation.bounce.height": 0.0625,
|
||||
"room.color.skip.transition": false,
|
||||
"enable.avatar.arrow": false,
|
||||
"system.animation.fps": 60,
|
||||
"system.limits.fps": false,
|
||||
"system.dispatcher.log": false,
|
||||
"system.packet.log": false,
|
||||
"system.pong.manually": true,
|
||||
"system.pong.interval.ms": 20000,
|
||||
"room.color.skip.transition": true,
|
||||
"user.badges.group.slot.enabled": true,
|
||||
"timezone.settings": "Europe/Amsterdam",
|
||||
"login.screen.enabled": true,
|
||||
"login.endpoint": "${api.url}/api/auth/login",
|
||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
||||
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
||||
"login.health.endpoint": "${api.url}/api/auth/health",
|
||||
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
|
||||
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
|
||||
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
|
||||
"login.remember.endpoint": "${api.url}/api/auth/remember",
|
||||
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
||||
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
||||
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
||||
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
||||
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
||||
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
|
||||
"account.change-password.endpoint": "${api.url}/api/auth/change-password",
|
||||
"account.change-email.endpoint": "${api.url}/api/auth/change-email",
|
||||
"account.change-username.endpoint": "${api.url}/api/auth/change-username",
|
||||
"login.health.method": "GET",
|
||||
"login.news.url": "${asset.url}/news/news.json",
|
||||
"login.turnstile.enabled": false,
|
||||
"login.turnstile.sitekey": "",
|
||||
"avatar.mandatory.libraries": ["bd:1", "li:0"],
|
||||
"avatar.mandatory.effect.libraries": ["dance.1", "dance.2", "dance.3", "dance.4"]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
||||
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
||||
"camera.url": "https://MY_DOMAIN.COM/camera/photo",
|
||||
"thumbnails.url": "https://MY_DOMAIN.COM/camera/photo/thumb/%thumbnail%.png",
|
||||
"url.prefix": "",
|
||||
"habbopages.url": "/gamedata/habbopages/",
|
||||
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
|
||||
"guide.help.alpha.groupid": 0,
|
||||
"chat.viewer.height.percentage": 0.4,
|
||||
"widget.dimmer.colorwheel": false,
|
||||
"avatar.wardrobe.max.slots": 10,
|
||||
"user.badges.max.slots": 5,
|
||||
"camera.publish.disabled": false,
|
||||
"hc.disabled": false,
|
||||
"badge.descriptions.enabled": true,
|
||||
"motto.max.length": 38,
|
||||
"bot.name.max.length": 15,
|
||||
"wired.action.bot.talk.to.avatar.max.length": 64,
|
||||
"wired.action.bot.talk.max.length": 64,
|
||||
"wired.action.chat.max.length": 100,
|
||||
"wired.action.kick.from.room.max.length": 100,
|
||||
"wired.action.mute.user.max.length": 100,
|
||||
"game.center.enabled": false,
|
||||
"catalog.style.new": true,
|
||||
"show.google.ads": false,
|
||||
"loginview": {
|
||||
"images": {
|
||||
"background": "${asset.url}/c_images/reception/stretch_blue.png",
|
||||
"background.colour": "#6eadc8",
|
||||
"sun": "${asset.url}/c_images/reception/sun.png",
|
||||
"drape": "${asset.url}/c_images/reception/drape.png",
|
||||
"left": "${asset.url}/c_images/reception/ts.png",
|
||||
"right": "${asset.url}/c_images/reception/US_right.png",
|
||||
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ActionError } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createAd, deleteAd } from "./admin-ads";
|
||||
|
||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteAds: { id: "id" },
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class extends Error {},
|
||||
actionOk: vi.fn(() => "ok"),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (k: string) => data[k] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("createAd", () => {
|
||||
it("creates ad and redirects", async () => {
|
||||
await createAd(
|
||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
});
|
||||
|
||||
it("returns early when image empty", async () => {
|
||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error on db failure", async () => {
|
||||
insertValues.mockRejectedValue(new Error("db"));
|
||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAd", () => {
|
||||
it("deletes ad and returns ok", async () => {
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
expect(
|
||||
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("throws ActionError when not found", async () => {
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
await expect(
|
||||
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
||||
).rejects.toThrow(ActionError);
|
||||
});
|
||||
});
|
||||
+23
-13
@@ -1,13 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
@@ -25,15 +27,17 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const ad = await prisma.websiteAds.create({
|
||||
data: { image, createdAt: now, updatedAt: now },
|
||||
});
|
||||
const [result] = (await db.insert(WebsiteAds).values({
|
||||
image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${ad.id} (${image})`,
|
||||
description: `Created advertisement #${result.insertId} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: createAd", {
|
||||
@@ -58,10 +62,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.update({
|
||||
where: { id },
|
||||
data: { image, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteAds)
|
||||
.set({ image, updatedAt: new Date() })
|
||||
.where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
@@ -92,8 +96,14 @@ export const deleteAd = adminAction(
|
||||
async (ctx) => {
|
||||
const id = ctx.data.id;
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
} catch {
|
||||
const [result] = (await db
|
||||
.delete(WebsiteAds)
|
||||
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
|
||||
if (!result.affectedRows) {
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ActionError) throw err;
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
await logStaffActivity({
|
||||
@@ -115,7 +125,7 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendHotelAlert } from "./admin-alerts";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue([]) })),
|
||||
},
|
||||
AlertLogs: {},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("sendHotelAlert", () => {
|
||||
it("sends hotel alert and revalidates", async () => {
|
||||
await sendHotelAlert(
|
||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||
);
|
||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||
});
|
||||
|
||||
it("returns early when message is empty", async () => {
|
||||
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
||||
expect(rcon.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { AlertLogs, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -29,3 +31,17 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/alerts");
|
||||
}
|
||||
|
||||
/** Mark every unread ops alert as read. */
|
||||
export async function markAllAlertsRead(): Promise<void> {
|
||||
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
||||
try {
|
||||
await db
|
||||
.update(AlertLogs)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(eq(AlertLogs.isRead, false));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
revalidatePath("/admin/alerts");
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, id));
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticleComments,
|
||||
WebsiteArticleReactions,
|
||||
WebsiteArticles,
|
||||
} from "@/lib/db";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
while (
|
||||
await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
for (;;) {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
@@ -41,17 +47,15 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.websiteArticles.create({
|
||||
data: {
|
||||
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,
|
||||
},
|
||||
await db.insert(WebsiteArticles).values({
|
||||
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,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
@@ -67,9 +71,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
try {
|
||||
await prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -87,8 +91,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
}
|
||||
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticles.delete({ where: { id } }),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(WebsiteArticleReactions)
|
||||
.where(eq(WebsiteArticleReactions.articleId, id));
|
||||
await tx
|
||||
.delete(WebsiteArticleComments)
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, max } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, UsersBadges } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
@@ -23,19 +24,20 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
||||
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||
// against duplicates and compute the next free slot ourselves.
|
||||
try {
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const max = await prisma.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await prisma.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
const [agg] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the RCON grant already succeeded for online users.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { createBan, liftBan } from "./admin-bans";
|
||||
|
||||
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const selectLimit = vi.fn();
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { selectLimit, insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
Ban: { id: "id", userId: "userId" },
|
||||
User: { id: "id", username: "username" },
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
selectLimit.mockResolvedValue([{ username: "baduser" }]);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("createBan", () => {
|
||||
it("creates a ban for valid inputs", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "42",
|
||||
reason: "Spam",
|
||||
hours: "24",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, type: "account" }),
|
||||
);
|
||||
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
|
||||
it("returns early when userId is invalid", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "0",
|
||||
hours: "1",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liftBan", () => {
|
||||
it("deletes ban and revalidates", async () => {
|
||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
});
|
||||
+18
-20
@@ -1,14 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { Ban, db, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
type BanType = $Enums.bans_type;
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||
"account",
|
||||
"ip",
|
||||
@@ -31,23 +30,22 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true },
|
||||
});
|
||||
const [user] = await db
|
||||
.select({ username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as BanType,
|
||||
cfhTopic: -1,
|
||||
},
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as "account" | "ip" | "machine" | "super",
|
||||
cfhTopic: -1,
|
||||
});
|
||||
|
||||
if (user) await rcon.disconnectUser(userId, user.username);
|
||||
@@ -65,7 +63,7 @@ export async function liftBan(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||
const id = Number(formData.get("id"));
|
||||
if (id > 0) {
|
||||
await prisma.ban.delete({ where: { id } });
|
||||
await db.delete(Ban).where(eq(Ban.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, EmailTemplates } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
@@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!name || !subject || !body) return;
|
||||
|
||||
await prisma.emailTemplates.create({
|
||||
data: {
|
||||
name,
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
},
|
||||
await db.insert(EmailTemplates).values({
|
||||
name,
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
});
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -56,15 +55,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!subject || !body) return;
|
||||
|
||||
await prisma.emailTemplates.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(EmailTemplates)
|
||||
.set({
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(EmailTemplates.id, id));
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
@@ -72,6 +71,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await prisma.emailTemplates.delete({ where: { id } });
|
||||
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
|
||||
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
|
||||
@@ -20,11 +20,10 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.slice(0, 512);
|
||||
if (!key) return;
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
await db
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
|
||||
@@ -38,10 +37,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await prisma.emulatorTexts.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
await db
|
||||
.insert(EmulatorTexts)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { disbandGuild } from "./admin-guilds";
|
||||
|
||||
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
|
||||
() => {
|
||||
const selectLimit = vi.fn();
|
||||
const transactionFn = vi.fn();
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
|
||||
return { selectLimit, transactionFn, deleteWhere, updateSet };
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
transaction: transactionFn,
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: updateSet })),
|
||||
},
|
||||
Guilds: { id: "id", name: "name", userId: "userId" },
|
||||
GuildsForumsThreads: { id: "id", guildId: "guildId" },
|
||||
GuildsForumsComments: { threadId: "threadId" },
|
||||
GuildForumViews: { guildId: "guildId" },
|
||||
GuildsMembers: { guildId: "guildId" },
|
||||
Rooms: { guildId: "guildId" },
|
||||
Items: { guildId: "guildId" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("disbandGuild", () => {
|
||||
it("disbands guild and cleans related data", async () => {
|
||||
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
|
||||
transactionFn.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<void>) => {
|
||||
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: txSelectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||
})),
|
||||
};
|
||||
// For threads findMany (no limit) — make where resolve to array
|
||||
tx.select = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ id: 10 }]),
|
||||
})),
|
||||
}));
|
||||
await fn(tx);
|
||||
},
|
||||
);
|
||||
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
||||
expect(selectLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+36
-19
@@ -1,9 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import {
|
||||
db,
|
||||
GuildForumViews,
|
||||
Guilds,
|
||||
GuildsForumsComments,
|
||||
GuildsForumsThreads,
|
||||
GuildsMembers,
|
||||
Items,
|
||||
Rooms,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Disband a guild and clean related membership/forum rows. */
|
||||
@@ -12,29 +22,36 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, userId: true },
|
||||
});
|
||||
const [guild] = await db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
name: Guilds.name,
|
||||
userId: Guilds.userId,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, id))
|
||||
.limit(1);
|
||||
if (!guild) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const threads = await tx.guildsForumsThreads.findMany({
|
||||
where: { guildId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
await db.transaction(async (tx) => {
|
||||
const threads = await tx
|
||||
.select({ id: GuildsForumsThreads.id })
|
||||
.from(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, id));
|
||||
const threadIds = threads.map((t) => t.id);
|
||||
if (threadIds.length > 0) {
|
||||
await tx.guildsForumsComments.deleteMany({
|
||||
where: { threadId: { in: threadIds } },
|
||||
});
|
||||
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
|
||||
await tx
|
||||
.delete(GuildsForumsComments)
|
||||
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||
await tx
|
||||
.delete(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, id));
|
||||
}
|
||||
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
|
||||
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
|
||||
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
||||
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
||||
await tx.guilds.delete({ where: { id } });
|
||||
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
|
||||
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
|
||||
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
|
||||
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
|
||||
await tx.delete(Guilds).where(eq(Guilds.id, id));
|
||||
});
|
||||
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
WebsiteHelpCenterTicketReplies,
|
||||
WebsiteHelpCenterTickets,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
@@ -25,39 +31,99 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||
const id = String(ticketId);
|
||||
revalidatePath("/admin/help-tickets");
|
||||
revalidatePath(`/admin/help-tickets/${id}`);
|
||||
revalidatePath("/mod/help-tickets");
|
||||
revalidatePath(`/mod/help-tickets/${id}`);
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
|
||||
export const replyHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
export const liftBanFromHelpTicket = adminAction(
|
||||
{
|
||||
permission: PERMS.USERS_BAN,
|
||||
schema: helpCenterTicketIdSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
userId: WebsiteHelpCenterTickets.userId,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.userId == null) {
|
||||
throw new ActionError("Ticket has no requester to unban");
|
||||
}
|
||||
|
||||
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
||||
const removed = Number(
|
||||
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
if (ticket.open) {
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: false, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban_via_help_ticket",
|
||||
target: "User",
|
||||
targetId: ticket.userId,
|
||||
after: {
|
||||
ticketId: String(ticketId),
|
||||
removedBans: removed,
|
||||
title: ticket.title,
|
||||
},
|
||||
});
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
||||
return actionOk({ removed, userId: ticket.userId });
|
||||
},
|
||||
);
|
||||
|
||||
const HELP_TICKET_EDIT = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
|
||||
|
||||
export const replyHelpCenterTicket = adminAction(
|
||||
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
const now = new Date();
|
||||
const staffId = Number(ctx.session.user.id);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.websiteHelpCenterTicketReplies.create({
|
||||
data: {
|
||||
ticketId,
|
||||
userId: staffId,
|
||||
content: ctx.data.content.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
}),
|
||||
prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { updatedAt: now },
|
||||
}),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
||||
ticketId,
|
||||
userId: staffId,
|
||||
content: ctx.data.content.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await tx
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: staffId,
|
||||
@@ -72,22 +138,26 @@ export const replyHelpCenterTicket = adminAction(
|
||||
);
|
||||
|
||||
export const closeHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: false, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: Number(ctx.session.user.id),
|
||||
@@ -104,22 +174,26 @@ export const closeHelpCenterTicket = adminAction(
|
||||
);
|
||||
|
||||
export const reopenHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.open) throw new ActionError("Ticket is already open");
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: true, updatedAt: now },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: true, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: Number(ctx.session.user.id),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
createHelpQuestion,
|
||||
deleteHelpQuestion,
|
||||
updateHelpQuestion,
|
||||
} from "./admin-help";
|
||||
|
||||
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
|
||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, updateWhere, deleteWhere };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteHelpCenterCategories: { id: "id" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("createHelpQuestion", () => {
|
||||
it("creates a help question and redirects", async () => {
|
||||
await createHelpQuestion(
|
||||
fakeForm({
|
||||
name: "FAQ",
|
||||
content: "<p>Answer</p>",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateHelpQuestion", () => {
|
||||
it("updates and redirects", async () => {
|
||||
await updateHelpQuestion(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
content: "New",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteHelpQuestion", () => {
|
||||
it("deletes and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
+24
-22
@@ -1,12 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
||||
@@ -32,25 +34,23 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_create",
|
||||
description: `Created help-center entry #${entry.id} (${name})`,
|
||||
description: `Created help-center entry #${result.insertId} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(entry.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — re-render unchanged with error.
|
||||
@@ -81,9 +81,9 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteHelpCenterCategories)
|
||||
.set({
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
@@ -93,8 +93,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_update",
|
||||
@@ -116,7 +116,9 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteHelpCenterCategories)
|
||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_delete",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||
@@ -34,16 +35,19 @@ export async function bulkImportPermissions(
|
||||
export async function exportPermissions(): Promise<string> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
|
||||
const perms = await prisma.websiteHousekeepingPermissions.findMany({
|
||||
orderBy: [{ groupName: "asc" }, { permission: "asc" }],
|
||||
select: {
|
||||
permission: true,
|
||||
minRank: true,
|
||||
description: true,
|
||||
groupName: true,
|
||||
dependsOn: true,
|
||||
},
|
||||
});
|
||||
const perms = await db
|
||||
.select({
|
||||
permission: WebsiteHousekeepingPermissions.permission,
|
||||
minRank: WebsiteHousekeepingPermissions.minRank,
|
||||
description: WebsiteHousekeepingPermissions.description,
|
||||
groupName: WebsiteHousekeepingPermissions.groupName,
|
||||
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
|
||||
})
|
||||
.from(WebsiteHousekeepingPermissions)
|
||||
.orderBy(
|
||||
asc(WebsiteHousekeepingPermissions.groupName),
|
||||
asc(WebsiteHousekeepingPermissions.permission),
|
||||
);
|
||||
|
||||
return JSON.stringify(perms, null, 2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
addBlacklist,
|
||||
addWhitelist,
|
||||
deleteBlacklist,
|
||||
deleteWhitelist,
|
||||
} from "./admin-ip";
|
||||
|
||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteIpWhitelist: { id: "id" },
|
||||
WebsiteIpBlacklist: { id: "id" },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("addWhitelist", () => {
|
||||
it("creates whitelist entry", async () => {
|
||||
await addWhitelist(
|
||||
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
ipAddress: "192.168.1.1",
|
||||
asn: null,
|
||||
whitelistAsn: false,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
||||
});
|
||||
|
||||
it("returns early when ip is empty", async () => {
|
||||
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWhitelist", () => {
|
||||
it("deletes whitelist entry", async () => {
|
||||
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addBlacklist", () => {
|
||||
it("creates blacklist entry", async () => {
|
||||
await addBlacklist(
|
||||
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
ipAddress: "203.0.113.1",
|
||||
asn: null,
|
||||
blacklistAsn: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBlacklist", () => {
|
||||
it("deletes blacklist entry", async () => {
|
||||
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+16
-7
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseIp(formData: FormData): string {
|
||||
return String(formData.get("ipAddress") ?? "")
|
||||
@@ -25,8 +26,10 @@ export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpWhitelist.create({
|
||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
||||
await db.insert(WebsiteIpWhitelist).values({
|
||||
ipAddress,
|
||||
asn,
|
||||
whitelistAsn: asn != null,
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -37,7 +40,9 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
||||
await db
|
||||
.delete(WebsiteIpWhitelist)
|
||||
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
@@ -46,8 +51,10 @@ export async function addBlacklist(formData: FormData): Promise<void> {
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
||||
await db.insert(WebsiteIpBlacklist).values({
|
||||
ipAddress,
|
||||
asn,
|
||||
blacklistAsn: asn != null,
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -58,6 +65,8 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
||||
await db
|
||||
.delete(WebsiteIpBlacklist)
|
||||
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
|
||||
vi.hoisted(() => ({
|
||||
mockUpsert: vi.fn(),
|
||||
const {
|
||||
mockValues,
|
||||
mockOnDuplicateKeyUpdate,
|
||||
mockRequirePermission,
|
||||
mockReload,
|
||||
mockRevalidatePath,
|
||||
} = vi.hoisted(() => {
|
||||
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const mockValues = vi.fn(() => ({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
}));
|
||||
return {
|
||||
mockValues,
|
||||
mockOnDuplicateKeyUpdate,
|
||||
mockRequirePermission: vi.fn(),
|
||||
mockReload: vi.fn(),
|
||||
mockRevalidatePath: vi.fn(),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
@@ -16,10 +28,11 @@ vi.mock("@/lib/permissions", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
websiteSetting: { upsert: mockUpsert },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: mockValues })),
|
||||
},
|
||||
WebsiteSetting: { key: "key", value: "value" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
@@ -38,6 +51,10 @@ import { saveMaintenance } from "./admin-maintenance";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockValues.mockReturnValue({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
});
|
||||
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("saveMaintenance", () => {
|
||||
@@ -57,37 +74,26 @@ describe("saveMaintenance", () => {
|
||||
|
||||
expect(mockRequirePermission).toHaveBeenCalled();
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledTimes(3);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledTimes(3);
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "maintenance_enabled" },
|
||||
update: { value: "1" },
|
||||
create: expect.objectContaining({
|
||||
key: "maintenance_enabled",
|
||||
value: "1",
|
||||
}),
|
||||
key: "maintenance_enabled",
|
||||
value: "1",
|
||||
}),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "maintenance_message" },
|
||||
update: { value: "We will be back soon!" },
|
||||
create: expect.objectContaining({
|
||||
key: "maintenance_message",
|
||||
value: "We will be back soon!",
|
||||
}),
|
||||
key: "maintenance_message",
|
||||
value: "We will be back soon!",
|
||||
}),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "3" },
|
||||
create: expect.objectContaining({
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "3",
|
||||
}),
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "3",
|
||||
}),
|
||||
);
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(mockReload).toHaveBeenCalledOnce();
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||
@@ -106,16 +112,16 @@ describe("saveMaintenance", () => {
|
||||
|
||||
await saveMaintenance(fd);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "maintenance_enabled" },
|
||||
update: { value: "0" },
|
||||
key: "maintenance_enabled",
|
||||
value: "0",
|
||||
}),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "5" },
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -134,10 +140,10 @@ describe("saveMaintenance", () => {
|
||||
|
||||
await saveMaintenance(fd);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "5" },
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -156,10 +162,10 @@ describe("saveMaintenance", () => {
|
||||
|
||||
await saveMaintenance(fd);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "5" },
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
|
||||
@@ -27,12 +27,15 @@ const COMMENTS: Record<string, string> = {
|
||||
};
|
||||
|
||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
comment: COMMENTS[key] ?? null,
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { db, MarketplaceItems } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Cancel an active marketplace listing (state 1 → 0). */
|
||||
@@ -14,16 +15,23 @@ export async function cancelMarketplaceListing(
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const listing = await prisma.marketplaceItems.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, state: true, userId: true, itemId: true, price: true },
|
||||
});
|
||||
const [listing] = await db
|
||||
.select({
|
||||
id: MarketplaceItems.id,
|
||||
state: MarketplaceItems.state,
|
||||
userId: MarketplaceItems.userId,
|
||||
itemId: MarketplaceItems.itemId,
|
||||
price: MarketplaceItems.price,
|
||||
})
|
||||
.from(MarketplaceItems)
|
||||
.where(eq(MarketplaceItems.id, id))
|
||||
.limit(1);
|
||||
if (listing?.state !== 1) return;
|
||||
|
||||
await prisma.marketplaceItems.update({
|
||||
where: { id },
|
||||
data: { state: 0 },
|
||||
});
|
||||
await db
|
||||
.update(MarketplaceItems)
|
||||
.set({ state: 0 })
|
||||
.where(eq(MarketplaceItems.id, id));
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/media-storage", () => {
|
||||
const root = path.join("/tmp", "nexst-test-media");
|
||||
return {
|
||||
MEDIA_ROOT: root,
|
||||
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
||||
};
|
||||
});
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("uploadMedia", () => {
|
||||
it("returns error when no file provided", async () => {
|
||||
const result = await uploadMedia(new FormData());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("No file provided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadMediaAndReturn", () => {
|
||||
it("returns empty string when no file", async () => {
|
||||
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMedia", () => {
|
||||
it("deletes media file and revalidates", async () => {
|
||||
await deleteMedia("photo.png");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||
});
|
||||
|
||||
it("skips deletion when path is outside media root", async () => {
|
||||
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
||||
await deleteMedia("../../../etc/passwd");
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { deletePhoto } from "./admin-photos";
|
||||
|
||||
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
||||
const limit = vi.fn();
|
||||
const whereSelect = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where: whereSelect }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
const whereDelete = vi.fn();
|
||||
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
||||
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/admin/photo-files", () => ({
|
||||
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: (...args) => select(...args),
|
||||
delete: (...args) => deleteFn(...args),
|
||||
},
|
||||
CameraWeb: { id: "id", url: "url" },
|
||||
}));
|
||||
|
||||
const fakeForm = (data) => ({
|
||||
get: (key) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
||||
whereDelete.mockResolvedValue(undefined);
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePhoto", () => {
|
||||
it("deletes a photo and revalidates", async () => {
|
||||
await deletePhoto(fakeForm({ id: "42" }));
|
||||
expect(select).toHaveBeenCalled();
|
||||
expect(deleteFn).toHaveBeenCalled();
|
||||
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
||||
expect(logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "photo_delete",
|
||||
targetId: 42,
|
||||
}),
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await deletePhoto(fakeForm({ id: "0" }));
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
try {
|
||||
await prisma.cameraWeb.delete({ where: { id } });
|
||||
} catch {
|
||||
// Record may have already been removed; ignore.
|
||||
const [row] = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(eq(CameraWeb.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (row) {
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
await tryRemoveLocalPhotoFile(row.url);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "photo_delete",
|
||||
description: `Deleted camera photo #${id}`,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioApiKeys } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
||||
@@ -50,23 +51,22 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.radioApiKeys.create({
|
||||
data: {
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
const [result] = await db.insert(RadioApiKeys).values({
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const createdId = BigInt(result.insertId);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_create",
|
||||
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`,
|
||||
description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(created.id),
|
||||
targetId: Number(createdId),
|
||||
});
|
||||
} catch {
|
||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||
@@ -84,17 +84,21 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
const existing = await prisma.radioApiKeys.findUnique({
|
||||
where: { id },
|
||||
select: { name: true, isActive: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({
|
||||
name: RadioApiKeys.name,
|
||||
isActive: RadioApiKeys.isActive,
|
||||
})
|
||||
.from(RadioApiKeys)
|
||||
.where(eq(RadioApiKeys.id, id))
|
||||
.limit(1);
|
||||
if (!existing) return;
|
||||
|
||||
const next = !existing.isActive;
|
||||
await prisma.radioApiKeys.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(RadioApiKeys)
|
||||
.set({ isActive: next, updatedAt: new Date() })
|
||||
.where(eq(RadioApiKeys.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_toggle",
|
||||
@@ -116,7 +120,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioApiKeys.delete({ where: { id } });
|
||||
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_delete",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioAutoDjPlaylist } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
|
||||
@@ -65,25 +66,24 @@ export async function createTrack(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.radioAutoDjPlaylist.create({
|
||||
data: {
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: album || null,
|
||||
artworkUrl: artworkUrl || null,
|
||||
duration,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
const [result] = await db.insert(RadioAutoDjPlaylist).values({
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: album || null,
|
||||
artworkUrl: artworkUrl || null,
|
||||
duration,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_create",
|
||||
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(created.id),
|
||||
targetId: createdId,
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable; re-render without throwing.
|
||||
@@ -100,10 +100,10 @@ export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.update({
|
||||
where: { id },
|
||||
data: { isActive, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(RadioAutoDjPlaylist)
|
||||
.set({ isActive, updatedAt: new Date() })
|
||||
.where(eq(RadioAutoDjPlaylist.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_toggle",
|
||||
@@ -123,7 +123,7 @@ export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
|
||||
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_delete",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
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 { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -44,11 +45,10 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
@@ -71,14 +71,13 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
await Promise.all(
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: null },
|
||||
});
|
||||
return db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
@@ -105,17 +104,15 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.create({
|
||||
data: {
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioBanners).values({
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
@@ -139,17 +136,17 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
if (!imagePath) return;
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(RadioBanners)
|
||||
.set({
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(RadioBanners.id, id));
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
@@ -161,7 +158,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioBanners.delete({ where: { id } });
|
||||
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
@@ -181,15 +178,13 @@ export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioRanks).values({
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
@@ -209,16 +204,16 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(RadioRanks)
|
||||
.set({
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(RadioRanks.id, id));
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
@@ -230,7 +225,7 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioRanks.delete({ where: { id } });
|
||||
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioShouts } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
@@ -28,7 +29,7 @@ export async function deleteShout(formData: FormData): Promise<void> {
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio.shout.delete",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -67,15 +67,16 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
await Promise.all(
|
||||
POINTS_KEYS.map((key) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
update: { value: values[key] },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
create: { key, value: values[key], comment: "Radio points" },
|
||||
}),
|
||||
.values({ key, value: values[key], comment: "Radio points" })
|
||||
.onDuplicateKeyUpdate({
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
set: { value: values[key] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
@@ -24,8 +25,10 @@ export async function createCategory(formData: FormData): Promise<void> {
|
||||
if (!name || !badge) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValueCategories.create({
|
||||
data: { name, badge, priority },
|
||||
await db.insert(WebsiteRareValueCategories).values({
|
||||
name,
|
||||
badge,
|
||||
priority,
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
||||
@@ -40,8 +43,12 @@ export async function deleteCategory(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
// Remove the category's values first to avoid orphaned rows.
|
||||
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
|
||||
await prisma.websiteRareValueCategories.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteRareValues)
|
||||
.where(eq(WebsiteRareValues.categoryId, id));
|
||||
await db
|
||||
.delete(WebsiteRareValueCategories)
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
@@ -81,16 +88,14 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
.slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValues.create({
|
||||
data: {
|
||||
categoryId,
|
||||
itemId,
|
||||
name,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
},
|
||||
await db.insert(WebsiteRareValues).values({
|
||||
categoryId,
|
||||
itemId,
|
||||
name,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
});
|
||||
} catch {
|
||||
// DB error — ignore.
|
||||
@@ -104,7 +109,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValues.delete({ where: { id } });
|
||||
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import {
|
||||
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -26,7 +27,7 @@ function normalizeSettingValue(key: string, value: string): string {
|
||||
|
||||
function bustGamedataCachesIfNeeded(key: string): void {
|
||||
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
||||
clearHabboItCache();
|
||||
clearOfficialHabboFurnidataCache();
|
||||
clearBadgeCache();
|
||||
}
|
||||
}
|
||||
@@ -48,16 +49,15 @@ export const saveManagedSettings = adminAction(
|
||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||
await Promise.all(
|
||||
entries.map(([key, value]) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
}),
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } }),
|
||||
),
|
||||
);
|
||||
await siteSettings.reload();
|
||||
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
||||
clearHabboItCache();
|
||||
clearOfficialHabboFurnidataCache();
|
||||
clearBadgeCache();
|
||||
}
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -76,7 +76,10 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
await db
|
||||
.update(WebsiteSetting)
|
||||
.set({ value })
|
||||
.where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -97,11 +100,10 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -113,7 +115,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
|
||||
+38
-37
@@ -1,11 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteShopArticles } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -40,43 +42,42 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
const costs = reqUInt(formData, "costs");
|
||||
try {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
const [result] = (await db.insert(WebsiteShopArticles).values({
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs,
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${created.costs} costs)`,
|
||||
description: `Created shop package "${name}" (${costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(created.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, {
|
||||
@@ -103,9 +104,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteShopArticles)
|
||||
.set({
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -131,8 +132,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(WebsiteShopArticles.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_update",
|
||||
@@ -159,7 +160,7 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.delete({ where: { id } });
|
||||
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_delete",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||
|
||||
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
|
||||
() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const transaction = vi.fn(async (fn) =>
|
||||
fn({
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
}),
|
||||
);
|
||||
return { insertValues, updateWhere, deleteWhere, transaction };
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
transaction,
|
||||
},
|
||||
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
|
||||
Taggables: { tagId: "tagId" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
transaction.mockImplementation(async (fn) =>
|
||||
fn({
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("createTag", () => {
|
||||
it("creates a tag and revalidates", async () => {
|
||||
await createTag(
|
||||
fakeForm({
|
||||
name: "News",
|
||||
backgroundColor: "#ff0000",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "News" }),
|
||||
);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when name is empty", async () => {
|
||||
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses default color when not provided", async () => {
|
||||
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ backgroundColor: "#888888" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles db error gracefully", async () => {
|
||||
insertValues.mockRejectedValue(new Error("DB error"));
|
||||
|
||||
await expect(
|
||||
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||
).resolves.toBeUndefined();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTag", () => {
|
||||
it("updates a tag and revalidates", async () => {
|
||||
await updateTag(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
backgroundColor: "#00ff00",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when id is invalid", async () => {
|
||||
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
||||
expect(updateWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when name is empty after update", async () => {
|
||||
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||
expect(updateWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTag", () => {
|
||||
it("deletes a tag and its taggables", async () => {
|
||||
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
|
||||
expect(transaction).toHaveBeenCalled();
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when id is invalid", async () => {
|
||||
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 588 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user