Compare commits
301
Commits
v1.0.2
...
8a75e7e5b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a75e7e5b6 | ||
|
|
635abfbc9f | ||
|
|
b4968a9967 | ||
|
|
b4021f6b42 | ||
|
|
bcd24bfca4 | ||
|
|
99e77d9872 | ||
|
|
18825115a4 | ||
|
|
c95ad4a37f | ||
|
|
305a0c42ff | ||
|
|
7257a7b0f4 | ||
|
|
8a6c596727 | ||
|
|
f18735cf3e | ||
|
|
7c9602c5cd | ||
|
|
3b8bf74e35 | ||
|
|
f285a7cd98 | ||
|
|
54f2bc5e0c | ||
|
|
5b09179404 | ||
|
|
e31f6d985c | ||
|
|
7ef5038a9e | ||
|
|
f89b8a6adf | ||
|
|
3b6ebe7bdc | ||
|
|
a810fba0ae | ||
|
|
27a3652a79 | ||
|
|
3d89e108f3 | ||
|
|
0f35bc8529 | ||
|
|
1bca9657fa | ||
|
|
361ff56d65 | ||
|
|
e76c530e4f | ||
|
|
e5ec3c1f06 | ||
|
|
dc9e5a567c | ||
|
|
65e3915a5f | ||
|
|
9463c8d4da | ||
|
|
578a6e943a | ||
|
|
ade0f286d3 | ||
|
|
9e453666e5 | ||
|
|
abc06e438c | ||
|
|
0c39405dab | ||
|
|
c808c59fce | ||
|
|
e867b675fc | ||
|
|
0bd2ac6707 | ||
|
|
06cd0cfe42 | ||
|
|
adc201bfb6 | ||
|
|
bf24d9575a | ||
|
|
9702ab304c | ||
|
|
4a6fcd050e | ||
|
|
49185dd7a9 | ||
|
|
0aef27efc4 | ||
|
|
3213126a12 | ||
|
|
2f708bcf11 | ||
|
|
7a2c0fd4d4 | ||
|
|
fc7e6ca248 | ||
|
|
703c29bc83 | ||
|
|
b1ac2e1331 | ||
|
|
de28f26e0e | ||
|
|
ca49c6b5a8 | ||
|
|
6549ae1959 | ||
|
|
4993b75608 | ||
|
|
ae1393d3e3 | ||
|
|
76730b84cf | ||
|
|
5c035dc7f5 | ||
|
|
304cfb5be7 | ||
|
|
02abf8f88c | ||
|
|
ebd2ff131c | ||
|
|
6a67fb6e83 | ||
|
|
24bf8eabb9 | ||
|
|
510d070dc5 | ||
|
|
82e8448f26 | ||
|
|
11e8b06bf8 | ||
|
|
51ef7602ca | ||
|
|
b25e7b00dd | ||
|
|
94ccd098d5 | ||
|
|
f792c276b7 | ||
|
|
aee099339c | ||
|
|
a1775fbf1e | ||
|
|
4c93dc38c6 | ||
|
|
7149fb146b | ||
|
|
cfcb245c40 | ||
|
|
c2e48a8a0e | ||
|
|
1b817fe434 | ||
|
|
6f53ca514d | ||
|
|
8b7298657d | ||
|
|
366fb7e538 | ||
|
|
c505841990 | ||
|
|
af8aaaa512 | ||
|
|
00b5a6f5c3 | ||
|
|
02ad9c21f2 | ||
|
|
101c73df1b | ||
|
|
d1dafba2c9 | ||
|
|
936053cca6 | ||
|
|
79b82e0a31 | ||
|
|
4d4cbd2211 | ||
|
|
1851653afb | ||
|
|
172941c542 | ||
|
|
742536820a | ||
|
|
e8209df294 | ||
|
|
4816d3eebf | ||
|
|
bdcf1451f8 | ||
|
|
b7f14ff5e6 | ||
|
|
dd708a64fb | ||
|
|
694a24c791 | ||
|
|
dc8fb8a6ed | ||
|
|
ac9b3e3cb5 | ||
|
|
b892786ff8 | ||
|
|
83884ef2e3 | ||
|
|
b06f27ef89 | ||
|
|
a2b0752076 | ||
|
|
1fd6f7146b | ||
|
|
0abcead359 | ||
|
|
3edc987281 | ||
|
|
2e87e5bf09 | ||
|
|
b87c9a5b8d | ||
|
|
5c60ce364c | ||
|
|
cef068ff3c | ||
|
|
7137b046d7 | ||
|
|
c565351c2f | ||
|
|
847febbe64 | ||
|
|
0bf6133775 | ||
|
|
af8b59e024 | ||
|
|
a338f9cb72 | ||
|
|
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
+51
-70
@@ -1,95 +1,76 @@
|
|||||||
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
# ==============================================================================
|
||||||
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||||
# it does NOT own or migrate the emulator tables. Format:
|
# ==============================================================================
|
||||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
|
||||||
|
|
||||||
# Optional pool tuning (defaults shown)
|
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||||
DATABASE_POOL_SIZE=10
|
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
DATABASE_POOL_SIZE=150
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
|
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||||
# REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||||
|
REDIS_CACHE_TTL_DEFAULT=7200
|
||||||
|
|
||||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
HOTEL_NAME=Atom
|
NODE_ENV=production
|
||||||
APP_URL=http://localhost:3000
|
PORT=3002
|
||||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
AUTH_URL=http://localhost:3000
|
UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
# --- HOTEL & URLS ---
|
||||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
HOTEL_NAME=EPIC WEB CONTROL
|
||||||
AUTH_SECRET=
|
APP_URL=http://localhost:3002
|
||||||
APP_KEY=
|
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||||
CONVERT_PASSWORDS=false
|
AUTH_URL=http://localhost:3002
|
||||||
|
|
||||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
# --- IMAGER ---
|
||||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||||
# column). Existing accounts in either format still verify on login.
|
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||||
PASSWORD_HASH=bcrypt
|
|
||||||
|
|
||||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
# --- SECURITY & HASHING ---
|
||||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
# Leave unset to disable badge uploads.
|
APP_KEY=base64:your-app-key-here=
|
||||||
BADGE_UPLOAD_DIR=
|
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
|
# --- PATHS ---
|
||||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
EMULATOR_BACKUP_DIR=
|
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||||
EMULATOR_BACKUP_KEEP=7
|
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_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.
|
# --- EMAIL & NOTIFICATIONS ---
|
||||||
# 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)
|
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=[email protected]
|
||||||
|
|
||||||
# Optional alerting (jobs worker / alert service)
|
# --- ALERTING & MONITORING ---
|
||||||
DISCORD_WEBHOOK_URL=
|
DISCORD_WEBHOOK_URL=
|
||||||
ALERT_EMAIL=
|
ALERT_EMAIL=
|
||||||
|
|
||||||
# Optional AI content moderation (user comments / guestbook).
|
# --- MODERATION & PAYMENTS ---
|
||||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
|
||||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
# Optional PayPal top-up (sandbox by default)
|
|
||||||
PAYPAL_CLIENT_ID=
|
PAYPAL_CLIENT_ID=
|
||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||||
|
|
||||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
# --- LOGGING ---
|
||||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
LOG_LEVEL=error
|
||||||
# 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,
|
# --- FLARESOLVERR (Cloudflare bypass for clone sources) ---
|
||||||
# 'debug' in development. Production logs use structured JSON via pino.
|
FLARESOLVERR_URL=http://localhost:8191
|
||||||
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=
|
|
||||||
+488
-3
@@ -1,11 +1,18 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
if: startsWith(gitea.ref_name, 'v') == false
|
||||||
runs-on: shell
|
runs-on: shell
|
||||||
steps:
|
steps:
|
||||||
- name: Typecheck, lint, and test
|
- name: Typecheck, lint, and test
|
||||||
@@ -30,12 +37,490 @@ jobs:
|
|||||||
git checkout -f "${REF}"
|
git checkout -f "${REF}"
|
||||||
|
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
export ARGON2_MEMORY_SIZE=1024
|
export NODE_ENV=test
|
||||||
export ARGON2_ITERATIONS=1
|
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
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm prisma:generate
|
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
|
pnpm knip
|
||||||
echo "--- CI checks passed ---"
|
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 .next/cache
|
||||||
|
|
||||||
|
# No build-cache restore: every deploy is a fully clean, from-scratch
|
||||||
|
# build so the shipped output is 100% up to date with origin/main.
|
||||||
|
|
||||||
|
# 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 ---"
|
|
||||||
@@ -12,8 +12,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
# Ensure cache dir exists before Docker starts
|
||||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
|
||||||
mkdir -p /var/tmp/renovate-cache
|
mkdir -p /var/tmp/renovate-cache
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
|
|||||||
+6
-1
@@ -1,11 +1,11 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
node_modules.prev/
|
||||||
.turbo/
|
.turbo/
|
||||||
.next/
|
.next/
|
||||||
.next.prev/
|
.next.prev/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
# Prisma client is generated by `prisma generate`
|
|
||||||
src/generated/
|
src/generated/
|
||||||
# Runtime avatar/badge imaging disk cache
|
# Runtime avatar/badge imaging disk cache
|
||||||
public/cache/
|
public/cache/
|
||||||
@@ -25,5 +25,10 @@ gitea
|
|||||||
# Runtime uploaded media (persistent, outside public/)
|
# Runtime uploaded media (persistent, outside public/)
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
|
# Runtime downloaded furni assets (mirrored from gamedata / audit repair)
|
||||||
|
public/nitro-assets/bundled/furniture/
|
||||||
|
public/swf/dcr/
|
||||||
|
public/tmp/
|
||||||
|
|
||||||
# Test coverage reports
|
# Test coverage reports
|
||||||
coverage/
|
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
|
# 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
| Node.js | >= 22 | Required by Next.js 16 |
|
| Node.js | >= 22 | Required by Next.js 16 |
|
||||||
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
|
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
|
||||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||||
| Redis | 7.x+ | Optional — caching, rate limiting, SSE |
|
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
|
||||||
| Java | 17+ | Required only if building the emulator |
|
| Java | 17+ | Required only if building the emulator |
|
||||||
| Maven | 3.9+ | Required only if building the emulator |
|
| Maven | 3.9+ | Required only if building the emulator |
|
||||||
|
|
||||||
@@ -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;
|
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
|
### 3. Configure Environment
|
||||||
|
|
||||||
@@ -54,15 +56,36 @@ HOTEL_NAME=YourHotel
|
|||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
|
||||||
|
|
||||||
### 4. Generate Prisma Client
|
### 4. ORM Setup & Type Generation
|
||||||
|
|
||||||
```bash
|
#### Drizzle ORM (Primary Data Layer)
|
||||||
pnpm prisma:generate
|
|
||||||
|
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
|
### 5. Run CMS Migrations
|
||||||
|
|
||||||
@@ -70,7 +93,7 @@ Generates TypeScript types in `src/generated/prisma/`.
|
|||||||
pnpm db:migrate
|
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:
|
Check migration status:
|
||||||
|
|
||||||
@@ -98,6 +121,289 @@ Open `http://localhost:3000` in your browser.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## DragonflyDB (caching, rate limiting, SSE)
|
||||||
|
|
||||||
|
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it
|
||||||
|
via `REDIS_URL` using the `ioredis` client, so no application code changes are needed —
|
||||||
|
every Redis command (`PING`, `GET`, `SETEX`, `DEL`, `INCR`, `PEXPIRE`, `PTTL`) works
|
||||||
|
unchanged. It is optional: without it the CMS falls back to in-process memory.
|
||||||
|
|
||||||
|
### Install (Ubuntu/Debian)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o /tmp/dragonfly_amd64.deb \
|
||||||
|
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
|
||||||
|
apt-get install -y /tmp/dragonfly_amd64.deb
|
||||||
|
systemctl enable --now dragonfly
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`.
|
||||||
|
|
||||||
|
### Configure
|
||||||
|
|
||||||
|
```ini
|
||||||
|
--bind=127.0.0.1
|
||||||
|
--port=6379
|
||||||
|
--maxmemory=2gb
|
||||||
|
--version_check=false
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--bind=127.0.0.1` keeps it private on the machine (matches `REDIS_URL=redis://127.0.0.1:6379`).
|
||||||
|
- `--port=6379` is the default Redis port, so `.env` stays unchanged.
|
||||||
|
- `--maxmemory` must be at least `0.25GiB` per CPU thread (e.g. `2gb` on a 6-thread server).
|
||||||
|
- `--version_check=false` disables the periodic outbound update check.
|
||||||
|
- Snapshots are written to `--dir` (`/var/lib/dragonfly/dump-*.dfs`).
|
||||||
|
|
||||||
|
### Point the CMS at it
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli PING # → PONG
|
||||||
|
redis-cli FLUSHALL # clear the cache
|
||||||
|
systemctl status dragonfly # service health
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify everything is wired up via the health endpoint:
|
||||||
|
`/api/health` should report `"redis": true`. The ioredis client automatically reconnects
|
||||||
|
after a DragonflyDB restart.
|
||||||
|
|
||||||
|
> **Note:** if an old Redis install still occupies port 6379, stop it first:
|
||||||
|
> `systemctl disable --now redis-server`.
|
||||||
|
|
||||||
|
## 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)
|
## Production Deployment (PM2)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -130,10 +436,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `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 analyze` | Build + open bundle analyzer |
|
||||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||||
| `pnpm biome:check` | Lint and format code |
|
| `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
|
## Performance Features
|
||||||
@@ -144,7 +456,7 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| **View Transitions API** | Native browser transitions between page navigations |
|
| **View Transitions API** | Native browser transitions between page navigations |
|
||||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
|
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
|
||||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
||||||
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
|
| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB |
|
||||||
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
||||||
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||||
@@ -160,23 +472,28 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
├── prisma/
|
├── drizzle/
|
||||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
|
||||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── apply-migrations.ts # Custom migration runner
|
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||||
│ ├── jobs-worker.ts # Background task scheduler
|
│ ├── 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/
|
├── src/
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
|
||||||
|
│ │ └── relations.ts # Drizzle relations
|
||||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||||
│ ├── actions/ # Server Actions
|
│ ├── actions/ # Server Actions
|
||||||
│ ├── components/ # UI components
|
│ ├── components/ # UI components
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||||
│ │ ├── prisma.ts # Database connection singleton
|
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||||
│ │ ├── redis.ts # Redis client (ioredis)
|
│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
|
||||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
||||||
|
│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
|
||||||
│ │ ├── cache.ts # In-memory cache fallback
|
│ │ ├── cache.ts # In-memory cache fallback
|
||||||
│ │ ├── motion.ts # Framer Motion animation variants
|
│ │ ├── motion.ts # Framer Motion animation variants
|
||||||
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
||||||
@@ -195,9 +512,17 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| Component | Type | Migrations |
|
| Component | Type | Migrations |
|
||||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
| 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 |
|
| 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
|
## Optional Integrations
|
||||||
@@ -243,6 +568,26 @@ Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
|
|||||||
|
|
||||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||||
|
|
||||||
|
### FlareSolverr (Cloudflare Bypass)
|
||||||
|
|
||||||
|
Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d flaresolverr
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the URL in `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
FLARESOLVERR_URL=http://localhost:8191
|
||||||
|
```
|
||||||
|
|
||||||
|
When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning.
|
||||||
|
|
||||||
|
See `docker-compose.yml` for the FlareSolverr service definition.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -260,7 +605,9 @@ pnpm biome:check # Lint and format
|
|||||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
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,11 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
flaresolverr:
|
||||||
|
image: flaresolverr/flaresolverr:latest
|
||||||
|
container_name: flaresolverr
|
||||||
|
ports:
|
||||||
|
- "8191:8191"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=info
|
||||||
@@ -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,69 @@
|
|||||||
|
# Public Avatar Thumbnail and Currency Icon Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make avatar and currency presentation consistent across the public site:
|
||||||
|
|
||||||
|
- user thumbnails in lists and compact cards show only the avatar head at a fixed 40 x 40 pixel size;
|
||||||
|
- full-body avatars remain available on profile pages and deliberately large previews;
|
||||||
|
- currency amounts use the existing graphical currency icons instead of placeholder letters such as `c`, `cr`, `du`, or `di`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The change covers public-facing pages and shared public components. It includes rankings, leaderboards, shop and badge-purchase currency rows, plus every other compact user list or card that currently renders an avatar directly.
|
||||||
|
|
||||||
|
Admin-only screens are outside this visual cleanup unless they reuse a shared public component changed by this work. Profile hero avatars, the main current-user avatar, registration/login previews, and other intentionally large previews retain their full-avatar presentation.
|
||||||
|
|
||||||
|
## Avatar Design
|
||||||
|
|
||||||
|
Compact user representations will use one shared semantic thumbnail path rather than choosing imager options independently in each page.
|
||||||
|
|
||||||
|
The thumbnail contract is:
|
||||||
|
|
||||||
|
- request `headOnly: true` from the avatar imager;
|
||||||
|
- render at 40 x 40 CSS and image dimensions;
|
||||||
|
- preserve pixel-art rendering and contain the image without stretching;
|
||||||
|
- prevent the thumbnail container from shrinking into adjacent text;
|
||||||
|
- use the user's actual figure and the existing avatar URL fallback behavior;
|
||||||
|
- keep useful alternative text based on the displayed username where that context is available.
|
||||||
|
|
||||||
|
The existing shared avatar component will be extended with an explicit compact/head-thumbnail variant, or a narrowly focused wrapper will be added if that keeps call sites clearer. Public list and compact-card call sites will migrate to this shared contract. Large/profile call sites will remain explicit so they cannot be accidentally cropped by a global CSS rule.
|
||||||
|
|
||||||
|
## Currency Design
|
||||||
|
|
||||||
|
All public currency amount rows will use the existing `CurrencyIcon` component and the existing assets under `public/assets/images/icons/currency`.
|
||||||
|
|
||||||
|
The mapping is:
|
||||||
|
|
||||||
|
- credits: `credits.png`;
|
||||||
|
- duckets: `duckets.png`;
|
||||||
|
- diamonds/crystals: `diamonds.png`.
|
||||||
|
|
||||||
|
Icons will be decorative when the surrounding UI already names the currency, using an empty alternative text to avoid repeated screen-reader announcements. The numeric amount and existing pill/layout styling remain unchanged. Icon size will be fixed consistently for compact amount rows, with no textual placeholder left visible.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
1. Add the shared compact avatar contract and focused tests.
|
||||||
|
2. Inventory public avatar call sites and classify each as compact thumbnail or large/profile preview.
|
||||||
|
3. Migrate every compact call site to the shared head-only 40 x 40 rendering.
|
||||||
|
4. Replace textual and empty CSS currency markers in public amount rows with `CurrencyIcon` and the correct currency kind.
|
||||||
|
5. Remove CSS rules that exist only to draw obsolete letter-based or background-only markers, while retaining layout classes still used by the amount pills.
|
||||||
|
|
||||||
|
This semantic migration is preferred over a global CSS crop because it sends the correct head-only request to the imager and does not risk changing profile avatars.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Verification will include:
|
||||||
|
|
||||||
|
- automated tests for the compact avatar contract (`headOnly`, fixed dimensions, actual figure propagation);
|
||||||
|
- source/component checks ensuring public currency rows use `CurrencyIcon` with the correct mapping and no placeholder letters remain;
|
||||||
|
- the existing test, type-check, and build commands relevant to the changed files;
|
||||||
|
- visual checks at desktop and narrow widths for rankings, leaderboard, shop, badge purchase, and representative user lists/cards;
|
||||||
|
- explicit checks that profile pages and large avatar previews still render full avatars.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- changing balances or currency business logic;
|
||||||
|
- changing the avatar imager service itself;
|
||||||
|
- redesigning profile hero sections;
|
||||||
|
- modifying admin-only layouts that do not share the affected public components.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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.
@@ -0,0 +1,12 @@
|
|||||||
|
-- 0020_performance_indexes.sql
|
||||||
|
-- Adds indexes for the hot CMS read paths (shared DB with the Arcturus
|
||||||
|
-- emulator — additive only, no schema changes to emulator-owned columns).
|
||||||
|
--
|
||||||
|
-- users.credits → credits leaderboard (ORDER BY credits DESC LIMIT 20)
|
||||||
|
-- users_currency(type, amount) → duckets/diamonds leaderboard (WHERE type=? ORDER BY amount DESC LIMIT 20)
|
||||||
|
-- users_settings.respects_received → respects leaderboard (ORDER BY respects_received DESC LIMIT 20)
|
||||||
|
-- camera_web.timestamp → homepage recent photos (ORDER BY timestamp DESC LIMIT 4)
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_users_credits` ON `users` (`credits`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_users_currency_type_amount` ON `users_currency` (`type`, `amount`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_users_settings_respects_received` ON `users_settings` (`respects_received`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_camera_web_timestamp` ON `camera_web` (`timestamp`);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 0021_add_messenger_offline_user_id_index.sql
|
||||||
|
-- The /me dashboard counts unread offline messages with
|
||||||
|
-- `WHERE user_id = ?`; messenger_offline previously had no index there.
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_messenger_offline_user_id` ON `messenger_offline` (`user_id`);
|
||||||
@@ -1,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,17 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "epicnextcms",
|
||||||
|
script: "pnpm",
|
||||||
|
args: "start",
|
||||||
|
instances: "max",
|
||||||
|
exec_mode: "cluster",
|
||||||
|
node_args: "--v8-pool-size=4",
|
||||||
|
max_memory_restart: "1G",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
PORT: 3002
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { WebsiteSetting } from "@/db/schema";
|
||||||
|
import { db } from "./src/lib/db";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(WebsiteSetting)
|
||||||
|
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
|
||||||
|
console.log("Current:", result);
|
||||||
|
|
||||||
|
if (result[0]?.value !== "/imaging") {
|
||||||
|
await db
|
||||||
|
.update(WebsiteSetting)
|
||||||
|
.set({ value: "/imaging" })
|
||||||
|
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
|
||||||
|
console.log("Updated to /imaging");
|
||||||
|
} else {
|
||||||
|
console.log("Already correct");
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -5,15 +5,9 @@
|
|||||||
"src/app/**/layout.{ts,tsx}",
|
"src/app/**/layout.{ts,tsx}",
|
||||||
"src/app/**/route.{ts,tsx}",
|
"src/app/**/route.{ts,tsx}",
|
||||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||||
"sentry.{server,edge}.config.ts"
|
"scripts/migrate-aes-cbc-to-gcm.ts"
|
||||||
],
|
|
||||||
"project": ["src/**/*.{ts,tsx}"],
|
|
||||||
"ignoreDependencies": [
|
|
||||||
"tailwindcss-animate",
|
|
||||||
"@tailwindcss/forms",
|
|
||||||
"@tailwindcss/typography",
|
|
||||||
"pino-pretty",
|
|
||||||
"tailwindcss"
|
|
||||||
],
|
],
|
||||||
|
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
||||||
|
"ignoreDependencies": ["@sentry/nextjs", "pino-pretty", "husky"],
|
||||||
"ignoreBinaries": ["sendmail"]
|
"ignoreBinaries": ["sendmail"]
|
||||||
}
|
}
|
||||||
@@ -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 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+42
-35
@@ -1,8 +1,22 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import withBundleAnalyzer from "@next/bundle-analyzer";
|
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
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 = [
|
const securityHeaders = [
|
||||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||||
{
|
{
|
||||||
@@ -20,26 +34,39 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {},
|
cacheComponents: true,
|
||||||
serverExternalPackages: [
|
deploymentId: resolveDeploymentId(),
|
||||||
"@prisma/adapter-mariadb",
|
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
|
||||||
"mariadb",
|
|
||||||
"@prisma/client",
|
// Silence Turbopack warnings for the broad file patterns in src/lib.
|
||||||
"lzma",
|
turbopack: {
|
||||||
"sharp",
|
ignoreIssue: [
|
||||||
"pino",
|
{
|
||||||
"pino-pretty",
|
path: "**/src/lib/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Enable React Compiler for automatic memoization
|
typescript: {
|
||||||
reactCompiler: true,
|
ignoreBuildErrors: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Compress responses with gzip
|
// Compress responses with gzip/brotli.
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|
||||||
// Disable Next.js telemetry
|
// Disable Next.js telemetry and browser sourcemaps in production.
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
useTypeScriptCli: true,
|
||||||
|
hideLogsAfterAbort: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optimize images served through next/image with sharp → AVIF/WebP.
|
||||||
|
images: {
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
},
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
@@ -70,28 +97,8 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|||||||
|
|
||||||
const config = withNextIntl(nextConfig);
|
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({
|
const withBA = withBundleAnalyzer({
|
||||||
enabled: process.env.ANALYZE === "true",
|
enabled: process.env.ANALYZE === "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBA(
|
export default withBA(config);
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
+60
-92
@@ -3,119 +3,87 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=26"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.4",
|
"packageManager": "pnpm@11.20.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
"lint": "biome check .",
|
||||||
"typecheck": "tsc6 --noEmit --incremental false",
|
"biome:lint": "biome check .",
|
||||||
"biome:check": "biome check --write .",
|
"format": "biome format --write .",
|
||||||
"biome:lint": "biome lint .",
|
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||||
"biome:format": "biome format --write .",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
"knip": "knip",
|
"test": "DATABASE_URL=mysql://root:root@localhost:3306/test vitest run",
|
||||||
"analyze": "ANALYZE=true pnpm build",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"db:generate": "drizzle-kit generate",
|
||||||
"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": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
"db:studio": "drizzle-kit studio"
|
||||||
"prepare": "husky"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
|
||||||
"biome check --write",
|
|
||||||
"eslint --fix --max-warnings=50"
|
|
||||||
],
|
|
||||||
"*.{json,md,css,scss,html}": [
|
|
||||||
"biome format --write"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "1.7.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@prisma/adapter-mariadb": "^7.9.1",
|
"@next/bundle-analyzer": "16.3.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@tanstack/react-virtual": "3.14.10",
|
||||||
"@sentry/nextjs": "^10.68.0",
|
"class-variance-authority": "0.7.1",
|
||||||
"@tanstack/react-virtual": "^3.14.8",
|
"clsx": "2.1.1",
|
||||||
"bcrypt": "^6.0.0",
|
"cmdk": "1.1.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"croner": "10.0.1",
|
||||||
"clsx": "^2.1.1",
|
"dompurify": "^3.4.13",
|
||||||
"cmdk": "^1.1.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"croner": "^10.0.1",
|
"hash-wasm": "4.12.0",
|
||||||
"framer-motion": "^12.42.2",
|
"ioredis": "6.0.0",
|
||||||
"hash-wasm": "^4.12.0",
|
|
||||||
"ioredis": "^5.11.1",
|
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"jsonc-parser": "^3.3.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lenis": "^1.3.25",
|
"lenis": "1.3.26",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "1.32.0",
|
||||||
"lzma": "^2.3.2",
|
"lzma": "^2.3.2",
|
||||||
"music-metadata": "^11.14.0",
|
"motion": "13.1.0",
|
||||||
"mysql2": "^3.23.2",
|
"music-metadata": "11.15.0",
|
||||||
"next": "^16.2.12",
|
"mysql2": "3.23.3",
|
||||||
|
"next": "16.3.1",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "^4.13.4",
|
"next-intl": "4.13.7",
|
||||||
"next-view-transitions": "^0.3.5",
|
"otplib": "13.4.1",
|
||||||
"nodemailer": "^9.0.3",
|
"pino": "10.3.1",
|
||||||
"otplib": "^13.4.1",
|
"react": "19.2.8",
|
||||||
"pino": "^10.3.1",
|
"react-dom": "19.2.8",
|
||||||
"react": "^19.2.8",
|
"react-hook-form": "7.85.0",
|
||||||
"react-dom": "^19.2.8",
|
"resend": "6.20.0",
|
||||||
"react-hook-form": "^7.83.0",
|
"server-only": "0.0.1",
|
||||||
"resend": "^6.18.1",
|
"sharp": "0.35.3",
|
||||||
"sanitize-html": "^2.17.6",
|
"sonner": "2.0.8",
|
||||||
"server-only": "^0.0.1",
|
"tailwind-merge": "3.6.0",
|
||||||
"sharp": "^0.35.3",
|
"zod": "4.4.3"
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.9",
|
||||||
"@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",
|
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/node": "^26.2.0",
|
||||||
"@types/node": "^26.1.2",
|
"@types/react": "19.2.18",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/react-dom": "19.2.4",
|
||||||
"@types/react": "^19.2.17",
|
"@vitest/coverage-v8": "4.1.11",
|
||||||
"@types/react-dom": "^19.2.3",
|
"drizzle-kit": "^0.31.10",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
|
||||||
"@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",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.29.0",
|
"knip": "6.32.2",
|
||||||
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.24",
|
"postcss": "^8.5.26",
|
||||||
"prisma": "^7.9.1",
|
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"vite": "8.2.1",
|
||||||
"vite": "8.1.5",
|
"vitest": "4.1.11"
|
||||||
"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
+1279
-6905
File diff suppressed because it is too large.
Load diff
+59
-14
@@ -1,33 +1,78 @@
|
|||||||
# pnpm-workspace.yaml
|
# pnpm-workspace.yaml
|
||||||
|
|
||||||
# pnpm v11 vervanger voor onlyBuiltDependencies
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
|
||||||
"@prisma/client": true
|
|
||||||
"@prisma/engines": true
|
|
||||||
sharp: true
|
sharp: true
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
"@swc/core": true
|
"@swc/core": true
|
||||||
"@sentry/cli": true
|
|
||||||
bcrypt: true
|
bcrypt: true
|
||||||
|
|
||||||
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
|
minimumReleaseAgeExclude:
|
||||||
|
- "@base-ui/[email protected]"
|
||||||
|
- "@base-ui/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@biomejs/[email protected]"
|
||||||
|
- "@hookform/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "@next/[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
- "[email protected]"
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
glob: "^10.4.5"
|
glob: "^11.0.3"
|
||||||
inflight: "npm:lru-cache@^10.2.2"
|
rimraf: "^6.0.1"
|
||||||
rimraf: "^5.0.7"
|
uuid: "^11.1.0"
|
||||||
uuid: "^9.0.1"
|
|
||||||
fast-uri: "^3.1.3"
|
fast-uri: "^3.1.3"
|
||||||
"@hono/node-server": "^1.19.13"
|
"@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"
|
tmp: "^0.2.6"
|
||||||
# NIEUWE SECURITY PATCHES:
|
|
||||||
sharp: "^0.35.3"
|
sharp: "^0.35.3"
|
||||||
brace-expansion: "^5.0.8"
|
brace-expansion: "^5.0.8"
|
||||||
|
"@types/react": "19.2.18"
|
||||||
|
"@types/react-dom": "19.2.4"
|
||||||
|
# Tijdelijke mitigatie voor drizzle-kit audit
|
||||||
|
esbuild: "^0.25.9"
|
||||||
|
|
||||||
|
allowedDeprecatedVersions:
|
||||||
|
"@esbuild-kit/esm-loader": "*"
|
||||||
|
"@esbuild-kit/core-utils": "*"
|
||||||
|
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- "@prisma/engines"
|
||||||
|
- "prisma"
|
||||||
|
|
||||||
# Zorgt voor een schone terminal zonder de Next-Auth / Nodemailer waarschuwing
|
|
||||||
peerDependencyRules:
|
peerDependencyRules:
|
||||||
allowedVersions:
|
allowedVersions:
|
||||||
nodemailer: "9.0.3"
|
nodemailer: "9.0.3"
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,402 @@
|
|||||||
|
{
|
||||||
|
"badge_name_Z75": "Training camp!",
|
||||||
|
"badge_desc_Z75": "Per esser diventato un guerriero DOC!",
|
||||||
|
"badge_name_Z67": "Coniglio Scaccia-Mito",
|
||||||
|
"badge_desc_Z67": "La Sicurezza in Habbo non è una leggenda!",
|
||||||
|
"badge_name_Z64": "Topo da Laboratorio",
|
||||||
|
"badge_desc_Z64": "Beta Tester II",
|
||||||
|
"badge_name_Z63": "Topo da Laboratorio",
|
||||||
|
"badge_desc_Z63": "Beta Tester I",
|
||||||
|
"badge_name_Z39": "Hotel Bau",
|
||||||
|
"badge_desc_Z39": "Tutti a casa finalmente!",
|
||||||
|
"badge_name_Z38": "Direttore Hotel Bau",
|
||||||
|
"badge_desc_Z38": "Hotel a ***3 stelle",
|
||||||
|
"badge_name_Z37": "Direttore Hotel Bau",
|
||||||
|
"badge_desc_Z37": "Hotel a **2 stelle",
|
||||||
|
"badge_name_Z36": "Direttore Hotel Bau",
|
||||||
|
"badge_desc_Z36": "Hotel a *1 stella",
|
||||||
|
"badge_name_Z35": "Hotel Bau",
|
||||||
|
"badge_desc_Z35": "Per aver ritrovato i cuccioli",
|
||||||
|
"badge_name_Z26": "Futuro Re",
|
||||||
|
"badge_desc_Z26": "Sarai tu il prossimo Re?",
|
||||||
|
"badge_name_Z16": "Campagna di Sicurezza Gennaio 2017",
|
||||||
|
"badge_desc_Z16": "Per essere un esperto nel proteggere il mio account!",
|
||||||
|
"badge_name_Z09": "In Campeggio",
|
||||||
|
"badge_desc_Z09": "Campagna Campeggio con i Lupetti - 2009",
|
||||||
|
"badge_name_Z05": "Ex Habbo eXpert",
|
||||||
|
"badge_desc_Z05": "Gli Habbo X erano guide volontarie scelte dello Staff tra il 2006 e il 2008.",
|
||||||
|
"badge_name_Z02": "W la Terra",
|
||||||
|
"badge_desc_Z02": "Per i veri amanti della natura!",
|
||||||
|
"badge_name_Z01": "Wow or not?",
|
||||||
|
"badge_desc_Z01": "Hai impressionato la giuria con la tua sfilata!",
|
||||||
|
"badge_name_YAK": "x",
|
||||||
|
"badge_desc_YAK": "x",
|
||||||
|
"badge_name_XXX": "x",
|
||||||
|
"badge_desc_XXX": "x",
|
||||||
|
"badge_name_XRLTD": "Distintivo Palla di Vetro con Neve",
|
||||||
|
"badge_desc_XRLTD": "Per avere acquistato questo Raro LTD di Natale",
|
||||||
|
"badge_name_XMSR1": "Macchina Magica",
|
||||||
|
"badge_desc_XMSR1": "Per comprare una Macchina Rara Magica",
|
||||||
|
"badge_name_XMS14": "Gnomi all'avventura!",
|
||||||
|
"badge_desc_XMS14": "Per aver vinto la Competizione Video!",
|
||||||
|
"badge_name_XMS13": "Cin cin!",
|
||||||
|
"badge_desc_XMS13": "Attento a non versartelo addosso",
|
||||||
|
"badge_name_XMS12": "Organizzatore di Feste",
|
||||||
|
"badge_desc_XMS12": "... da urlo!",
|
||||||
|
"badge_name_XMS10": "Postazione di Controllo",
|
||||||
|
"badge_desc_XMS10": "Per comprare una Postazione di Controllo Rara",
|
||||||
|
"badge_name_XMS07": "Babbo Natale e i 3 Re Magi",
|
||||||
|
"badge_desc_XMS07": "Babbo Natale ha molte conoscenze...",
|
||||||
|
"badge_name_XMS06": "Natale in Europa",
|
||||||
|
"badge_desc_XMS06": "Un Natale dei più tipici!",
|
||||||
|
"badge_name_XMS05": "Natale sulla Spiaggia",
|
||||||
|
"badge_desc_XMS05": "Festeggia come un Brasiliano!",
|
||||||
|
"badge_name_XMS04": "Natale senza Natale",
|
||||||
|
"badge_desc_XMS04": "Festeggia come in Turchia",
|
||||||
|
"badge_name_XMS03": "Decoratore di Alberi Natalizi",
|
||||||
|
"badge_desc_XMS03": "Per avere decorato l'Albero di Natale Habbo 2013",
|
||||||
|
"badge_name_XMS01": "Bottega di Babbo Natale",
|
||||||
|
"badge_desc_XMS01": "Per avere acquistato l'Affare Stanza natalizio",
|
||||||
|
"badge_name_XMH20": "Pupazzo Gatto Scheletrico",
|
||||||
|
"badge_desc_XMH20": "Per avere acquistato il Pupazzo Gatto Scheletrico",
|
||||||
|
"badge_name_XMH19": "Pupazzo Tristo Mietitore",
|
||||||
|
"badge_desc_XMH19": "Per avere acquistato il Pupazzo Tristo Mietitore",
|
||||||
|
"badge_name_XMH18": "Pupazzo Punk Rock",
|
||||||
|
"badge_desc_XMH18": "Per avere acquistato il Pupazzo Punk Rock",
|
||||||
|
"badge_name_XMH17": "Pupazzo Ragazza Habbo",
|
||||||
|
"badge_desc_XMH17": "Per avere acquistato il Pupazzo Ragazza Habbo",
|
||||||
|
"badge_name_XMH16": "Pupazzo Lucha Libre",
|
||||||
|
"badge_desc_XMH16": "Per avere acquistato il Pupazzo Lucha Libre",
|
||||||
|
"badge_name_XMH15": "Pupazzo Scheletro Tatuato",
|
||||||
|
"badge_desc_XMH15": "Per avere acquistato il Pupazzo Scheletro Tatuato",
|
||||||
|
"badge_name_XMH14": "Pupazzo Ragazzo Habbo",
|
||||||
|
"badge_desc_XMH14": "Per avere acquistato il Pupazzo Ragazzo Habbo",
|
||||||
|
"badge_name_XMH13": "Pupazzo in Giacca e Cravatta",
|
||||||
|
"badge_desc_XMH13": "Per avere acquistato il Pupazzo in Giacca e Cravatta",
|
||||||
|
"badge_name_XMH12": "Pupazzo Catrina",
|
||||||
|
"badge_desc_XMH12": "Per avere acquistato il Pupazzo Catrina",
|
||||||
|
"badge_name_XMH11": "Pupazzo Mariachi",
|
||||||
|
"badge_desc_XMH11": "Per avere acquistato il Pupazzo Mariachi",
|
||||||
|
"badge_name_XMB": "Mr.Pupazzo",
|
||||||
|
"badge_desc_XMB": "Con cilindro e carota è il signore delle nevi!",
|
||||||
|
"badge_name_XmasRoom_Top100": "Top 100 Castello di Natale",
|
||||||
|
"badge_desc_XmasRoom_Top100": "Per essere rientrat@ nella top 100 della Competizione Stanza Castello di Natale",
|
||||||
|
"badge_name_XmasRoom_Top10": "Top 10 Castello di Natale",
|
||||||
|
"badge_desc_XmasRoom_Top10": "Per essere rientrat@ nella top 10 della Competizione Stanza Castello di Natale",
|
||||||
|
"badge_name_XmasRoom": "Partecipante Castello di Natale",
|
||||||
|
"badge_desc_XmasRoom": "Per aver partecipato alla Competizione Stanza Castello di Natale",
|
||||||
|
"badge_name_XMAS2": "Ninnolo",
|
||||||
|
"badge_desc_XMAS2": "Livello III",
|
||||||
|
"badge_name_XMAS1": "Ninnolo",
|
||||||
|
"badge_desc_XMAS1": "Livello II",
|
||||||
|
"badge_name_XMAS0": "Ninnolo",
|
||||||
|
"badge_desc_XMAS0": "Livello I",
|
||||||
|
"badge_name_XMA": "Smile Congelato",
|
||||||
|
"badge_desc_XMA": "Il suo sorriso ti scalda il cuore",
|
||||||
|
"badge_name_XM9": "Palla di Neve",
|
||||||
|
"badge_desc_XM9": "Deve ancora fare parecchia strada per diventare un pupazzo!",
|
||||||
|
"badge_name_XM8": "Boccale di Birra",
|
||||||
|
"badge_desc_XM8": "Habbo Natale 2007",
|
||||||
|
"badge_name_XM7": "Moneta",
|
||||||
|
"badge_desc_XM7": "Habbo Natale 2007",
|
||||||
|
"badge_name_XM6": "Robot di Santa 3000",
|
||||||
|
"badge_desc_XM6": "Habbo Natale 2007",
|
||||||
|
"badge_name_XM5": "Fiocco di Neve",
|
||||||
|
"badge_desc_XM5": "Habbo Natale 2007",
|
||||||
|
"badge_name_XM4": "Natale 2006",
|
||||||
|
"badge_desc_XM4": "Ero su Habbo a Natale del 2006!",
|
||||||
|
"badge_name_XM3": "Renna",
|
||||||
|
"badge_desc_XM3": "Habbo Natale 2005",
|
||||||
|
"badge_name_XM2": "DJ-Bling",
|
||||||
|
"badge_desc_XM2": "Habbo Natale 2005",
|
||||||
|
"badge_name_XM10E": "FREEZE!",
|
||||||
|
"badge_desc_XM10E": "Hai costruito un'Arena Fantastica!",
|
||||||
|
"badge_name_XM10D": "Campione Wired-Freeze",
|
||||||
|
"badge_desc_XM10D": "L'ennesima potenza del divertimento!",
|
||||||
|
"badge_name_XM10C": "Regalo Natalizio Fuori Stagione",
|
||||||
|
"badge_desc_XM10C": "Saluti dai Caraibi!",
|
||||||
|
"badge_name_XM10B": "Pattino d'Oro",
|
||||||
|
"badge_desc_XM10B": "Il Miglior Palazzetto del Ghiaccio",
|
||||||
|
"badge_name_XM10A": "Città Natalizia!",
|
||||||
|
"badge_desc_XM10A": "Per gli addobbi Natalizi più Originali!",
|
||||||
|
"badge_name_XM1": "Rasta Santa",
|
||||||
|
"badge_desc_XM1": "Habbo Natale 2005 e 2006",
|
||||||
|
"badge_name_XGH1": "Il Fantasma del Natale",
|
||||||
|
"badge_desc_XGH1": "Ho trovato il Fantasma del Natale",
|
||||||
|
"badge_name_X535": "Conduttore di slitta - Sig.ra Claus",
|
||||||
|
"badge_desc_X535": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
|
||||||
|
"badge_name_X534": "Conduttore di slitta - Habbo Natale",
|
||||||
|
"badge_desc_X534": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
|
||||||
|
"badge_name_X533": "Pranzo di Natale - Sig.ra Claus",
|
||||||
|
"badge_desc_X533": "Non c'è di niente di meglio di un bel Pranzo di Natale",
|
||||||
|
"badge_name_X532": "Pranzo di Natale - Habbo Natale",
|
||||||
|
"badge_desc_X532": "Non c'è di niente di meglio di un bel Pranzo di Natale",
|
||||||
|
"badge_name_X531": "Silent night - Sig.ra Claus",
|
||||||
|
"badge_desc_X531": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come la Sig.ra Claus",
|
||||||
|
"badge_name_X530": "Silent night - Habbo Natale",
|
||||||
|
"badge_desc_X530": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come Habbo Natale",
|
||||||
|
"badge_name_X529": "Preparazione Regali di Natale - Sig.ra Claus",
|
||||||
|
"badge_desc_X529": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
|
||||||
|
"badge_name_X528": "Preparazione Regali di Natale - Habbo Natale",
|
||||||
|
"badge_desc_X528": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
|
||||||
|
"badge_name_X527": "Mercato di Natale - Sig.ra Claus",
|
||||||
|
"badge_desc_X527": "Per aver completato il labirinto del mercato di Natale",
|
||||||
|
"badge_name_X526": "Mercato di Natale - Habbo Natale",
|
||||||
|
"badge_desc_X526": "Per aver completato il labirinto del mercato di Natale",
|
||||||
|
"badge_name_X525": "Proprietario di Renna - Sig.ra Claus",
|
||||||
|
"badge_desc_X525": "Vixen, la renna della Sig.ra Claus, è stata riportata sana e salva nella stalla",
|
||||||
|
"badge_name_X524": "Proprietario di Renna - Habbo Natale",
|
||||||
|
"badge_desc_X524": "Dasher, la renna preferita di Habbo Natale, è stata riportata sana e salva nella stalla",
|
||||||
|
"badge_name_X523": "Film di Natale - Sig.ra Claus",
|
||||||
|
"badge_desc_X523": "Sei un vero Fan dei film di Natale",
|
||||||
|
"badge_name_X522": "Film di Natale - Habbo Natale",
|
||||||
|
"badge_desc_X522": "Sei un vero Fan dei film di Natale",
|
||||||
|
"badge_name_X521": "Meraviglioso Albero di Natale - Sig.ra Natale",
|
||||||
|
"badge_desc_X521": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
|
||||||
|
"badge_name_X520": "Meraviglioso Albero di Natale - Habbo Natale",
|
||||||
|
"badge_desc_X520": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
|
||||||
|
"badge_name_X519": "Canti Natalizi - Sig.ra Claus",
|
||||||
|
"badge_desc_X519": "Jingle bell, jingle bell, jingle bell rock",
|
||||||
|
"badge_name_X518": "Canti Natalizi - Habbo Natale",
|
||||||
|
"badge_desc_X518": "Haaaabbo Natale sta arrivando in città!",
|
||||||
|
"badge_name_X2535": "L'Alimentatore Preferito di Esophagor",
|
||||||
|
"badge_desc_X2535": "",
|
||||||
|
"badge_name_X2534": "Stazione dei Treni",
|
||||||
|
"badge_desc_X2534": "",
|
||||||
|
"badge_name_X2533": "Collezione Paese delle Meraviglie di Cacao",
|
||||||
|
"badge_desc_X2533": "",
|
||||||
|
"badge_name_X2532": "Affare Stanza Ufficio di Habbo Natale",
|
||||||
|
"badge_desc_X2532": "",
|
||||||
|
"badge_name_X2531": "Affare Stanza Natale Accogliente di Habbo",
|
||||||
|
"badge_desc_X2531": "",
|
||||||
|
"badge_name_X2530": "Uovo Glassato LTD",
|
||||||
|
"badge_desc_X2530": "",
|
||||||
|
"badge_name_X2529": "Borsa Kitty Rara",
|
||||||
|
"badge_desc_X2529": "",
|
||||||
|
"badge_name_X2528": "Cuscino di Ghiaccio Raro",
|
||||||
|
"badge_desc_X2528": "",
|
||||||
|
"badge_name_X2527": "Habbo Night Hotel Raro",
|
||||||
|
"badge_desc_X2527": "",
|
||||||
|
"badge_name_X2526": "Coda di cavallo Ghiacciata Scintillante Rara",
|
||||||
|
"badge_desc_X2526": "",
|
||||||
|
"badge_name_X2525": "Buon Natale 2025!",
|
||||||
|
"badge_desc_X2525": "",
|
||||||
|
"badge_name_X2521": "A caccia di vacanze!",
|
||||||
|
"badge_desc_X2521": "",
|
||||||
|
"badge_name_X2520": "Alimentatore di Esophagor",
|
||||||
|
"badge_desc_X2520": "",
|
||||||
|
"badge_name_X2518": "Battaglia con Palle di neve!",
|
||||||
|
"badge_desc_X2518": "",
|
||||||
|
"badge_name_X2516": "Spirito delle Feste",
|
||||||
|
"badge_desc_X2516": "",
|
||||||
|
"badge_name_X2515": "Straordinario Creatore delle Feste",
|
||||||
|
"badge_desc_X2515": "",
|
||||||
|
"badge_name_X2514": "Felice & Moderno",
|
||||||
|
"badge_desc_X2514": "",
|
||||||
|
"badge_name_X2513": "Eroe delle Tradizioni Natalizie",
|
||||||
|
"badge_desc_X2513": "",
|
||||||
|
"badge_name_X2512": "Corona dello Scontro di Natale",
|
||||||
|
"badge_desc_X2512": "",
|
||||||
|
"badge_name_X2511": "Habubu Glam",
|
||||||
|
"badge_desc_X2511": "",
|
||||||
|
"badge_name_X2510": "Habubu Cozy",
|
||||||
|
"badge_desc_X2510": "",
|
||||||
|
"badge_name_X2509": "Habubu Dream",
|
||||||
|
"badge_desc_X2509": "",
|
||||||
|
"badge_name_X2508": "Habubu Calm",
|
||||||
|
"badge_desc_X2508": "",
|
||||||
|
"badge_name_X2507": "Habubu Luck",
|
||||||
|
"badge_desc_X2507": "",
|
||||||
|
"badge_name_X2506": "Habubu Hope",
|
||||||
|
"badge_desc_X2506": "",
|
||||||
|
"badge_name_X2505": "Habubu Happy",
|
||||||
|
"badge_desc_X2505": "",
|
||||||
|
"badge_name_X2504": "Habubu Fun",
|
||||||
|
"badge_desc_X2504": "",
|
||||||
|
"badge_name_X2503": "Habubu Love",
|
||||||
|
"badge_desc_X2503": "",
|
||||||
|
"badge_name_X2502": "Squadra - DJ Bling",
|
||||||
|
"badge_desc_X2502": "",
|
||||||
|
"badge_name_X2501": "Squadra - Rasta Santa",
|
||||||
|
"badge_desc_X2501": "",
|
||||||
|
"badge_name_X2330": "Competizione Anatroccolo",
|
||||||
|
"badge_desc_X2330": "",
|
||||||
|
"badge_name_X2329": "Competizione Stanza Sweet Suite NL, TR",
|
||||||
|
"badge_desc_X2329": "",
|
||||||
|
"badge_name_X2328": "Offerta Crafting",
|
||||||
|
"badge_desc_X2328": "",
|
||||||
|
"badge_name_X2327": "Trono Bianco",
|
||||||
|
"badge_desc_X2327": "",
|
||||||
|
"badge_name_X2326": "Buon 2024!",
|
||||||
|
"badge_desc_X2326": "",
|
||||||
|
"badge_name_X2325": "Buon Natale!",
|
||||||
|
"badge_desc_X2325": "",
|
||||||
|
"badge_name_X2324": "Goditi il Natale Habbo",
|
||||||
|
"badge_desc_X2324": "",
|
||||||
|
"badge_name_X2323": "Aiuta Frank Wobbah a cucinare",
|
||||||
|
"badge_desc_X2323": "",
|
||||||
|
"badge_name_X2322": "Salvataggio delle ricette di Frank Wobbah",
|
||||||
|
"badge_desc_X2322": "",
|
||||||
|
"badge_name_X2321": "Missione Babbo Natale per un giorno",
|
||||||
|
"badge_desc_X2321": "",
|
||||||
|
"badge_name_X2320": "Missione Brilla come un Biglietto d'oro",
|
||||||
|
"badge_desc_X2320": "",
|
||||||
|
"badge_name_X2319": "Missione Delizie Arcobaleno",
|
||||||
|
"badge_desc_X2319": "",
|
||||||
|
"badge_name_X2318": "Missione Frutti di Bosco",
|
||||||
|
"badge_desc_X2318": "",
|
||||||
|
"badge_name_X2317": "Missione Mentine Rinfrescanti",
|
||||||
|
"badge_desc_X2317": "",
|
||||||
|
"badge_name_X2316": "Pazzo per la Missione del Cioccolato",
|
||||||
|
"badge_desc_X2316": "",
|
||||||
|
"badge_name_X2315": "Torta Trono Rara",
|
||||||
|
"badge_desc_X2315": "",
|
||||||
|
"badge_name_X2314": "Offerta di Capodanno 2023",
|
||||||
|
"badge_desc_X2314": "",
|
||||||
|
"badge_name_X2313": "Offerta Indumenti Natale 2023",
|
||||||
|
"badge_desc_X2313": "",
|
||||||
|
"badge_name_X2312": "Carosello di Cioccolato Artigianale LTD",
|
||||||
|
"badge_desc_X2312": "",
|
||||||
|
"badge_name_X2311": "Corona d'Oro 24K LTD",
|
||||||
|
"badge_desc_X2311": "",
|
||||||
|
"badge_name_X2310": "Ali sulla Testa Mitiche Rare",
|
||||||
|
"badge_desc_X2310": "",
|
||||||
|
"badge_name_X2309": "Seduta Aerea Rara",
|
||||||
|
"badge_desc_X2309": "",
|
||||||
|
"badge_name_X2308": "Nastro Scartami RARO",
|
||||||
|
"badge_desc_X2308": "",
|
||||||
|
"badge_name_X2307": "Albero di Zucchero Filato RARO",
|
||||||
|
"badge_desc_X2307": "",
|
||||||
|
"badge_name_X2306": "Affare Stanza di Capodanno",
|
||||||
|
"badge_desc_X2306": "",
|
||||||
|
"badge_name_X2305": "Affare Stanza Pista di Pattinaggio sul Ghiaccio",
|
||||||
|
"badge_desc_X2305": "",
|
||||||
|
"badge_name_X2304": "Affare Stanza Ritrovo dell'Elfo",
|
||||||
|
"badge_desc_X2304": "",
|
||||||
|
"badge_name_X2303": "Bottega Moderna di Babbo Natale",
|
||||||
|
"badge_desc_X2303": "",
|
||||||
|
"badge_name_X2302": "Emporio del Cioccolato Magico",
|
||||||
|
"badge_desc_X2302": "",
|
||||||
|
"badge_name_X2301": "Collezione Paese delle Meraviglie di Cacao",
|
||||||
|
"badge_desc_X2301": "",
|
||||||
|
"badge_name_X2234": "Sanrio Christmas Room Competition",
|
||||||
|
"badge_desc_X2234": "",
|
||||||
|
"badge_name_X2232": "Affare Stanza Salone da Pranzo",
|
||||||
|
"badge_desc_X2232": "",
|
||||||
|
"badge_name_X2231": "Offerta Bevande per soldi Reali",
|
||||||
|
"badge_desc_X2231": "",
|
||||||
|
"badge_name_X2230": "Renna Abile",
|
||||||
|
"badge_desc_X2230": "",
|
||||||
|
"badge_name_X2229": "Pinguino Abile",
|
||||||
|
"badge_desc_X2229": "",
|
||||||
|
"badge_name_X2228": "Labirinto dei Pinguini: Livello Facile - Completato",
|
||||||
|
"badge_desc_X2228": "",
|
||||||
|
"badge_name_X2227": "Labirinto dei Pinguini: Livello Medio - Completato",
|
||||||
|
"badge_desc_X2227": "",
|
||||||
|
"badge_name_X2226": "Labirinto dei Pinguini: Livello Folle - Completato",
|
||||||
|
"badge_desc_X2226": "",
|
||||||
|
"badge_name_X2224": "Natale Habbo 2022 - Pinguino di Tokyo",
|
||||||
|
"badge_desc_X2224": "",
|
||||||
|
"badge_name_X2223": "Natale Habbo 2022 - Pinguino Timido",
|
||||||
|
"badge_desc_X2223": "",
|
||||||
|
"badge_name_X2222": "Natale Habbo 2022 - Pinguino Accademico",
|
||||||
|
"badge_desc_X2222": "",
|
||||||
|
"badge_name_X2220": "Natale Habbo 2022 - Pinguino Unicorno",
|
||||||
|
"badge_desc_X2220": "",
|
||||||
|
"badge_name_X2219": "Natale Habbo 2022 - Pinguino Albino",
|
||||||
|
"badge_desc_X2219": "",
|
||||||
|
"badge_name_X2218": "Natale Habbo 2022 - Pinguino Fantasma",
|
||||||
|
"badge_desc_X2218": "",
|
||||||
|
"badge_name_X2217": "Natale Habbo 2022 - Pinguino Egizio",
|
||||||
|
"badge_desc_X2217": "",
|
||||||
|
"badge_name_X2216": "Natale Habbo 2022 - Pinguino Artista",
|
||||||
|
"badge_desc_X2216": "",
|
||||||
|
"badge_name_X2215": "Natale Habbo 2022 - Pinguino Addormentato",
|
||||||
|
"badge_desc_X2215": "",
|
||||||
|
"badge_name_X2214": "Natale Habbo 2022 - Pinguino Testa a Molla",
|
||||||
|
"badge_desc_X2214": "",
|
||||||
|
"badge_name_X2213": "Offerta Cibo Soldi Veri",
|
||||||
|
"badge_desc_X2213": "",
|
||||||
|
"badge_name_X2212": "Uovo Habberge Palla di Neve LTD",
|
||||||
|
"badge_desc_X2212": "",
|
||||||
|
"badge_name_X2211": "Ali d'Angelo LTD",
|
||||||
|
"badge_desc_X2211": "",
|
||||||
|
"badge_name_X2210": "RARO Husky Addestrato",
|
||||||
|
"badge_desc_X2210": "",
|
||||||
|
"badge_name_X2209": "Raro Sauna Nordica",
|
||||||
|
"badge_desc_X2209": "",
|
||||||
|
"badge_name_X2208": "RARO Capelli con Treccine",
|
||||||
|
"badge_desc_X2208": "",
|
||||||
|
"badge_name_X2207": "Raro Look da Albero Festivo",
|
||||||
|
"badge_desc_X2207": "",
|
||||||
|
"badge_name_X2206": "Affare Stanza Sauna Finlandese",
|
||||||
|
"badge_desc_X2206": "",
|
||||||
|
"badge_name_X2205": "Affare Stanza Snowboard sulle Alpi in Inverno",
|
||||||
|
"badge_desc_X2205": "",
|
||||||
|
"badge_name_X2204": "Affare Stanza Natale Bavarese",
|
||||||
|
"badge_desc_X2204": "",
|
||||||
|
"badge_name_X2203": "Affare Stanza Inverno ad Habbo Stonehenge",
|
||||||
|
"badge_desc_X2203": "",
|
||||||
|
"badge_name_X2202": "Affare Stanza Baita del Resort Invernale",
|
||||||
|
"badge_desc_X2202": "",
|
||||||
|
"badge_name_X2201": "Affare Stanza Resort degli Sport Invernali",
|
||||||
|
"badge_desc_X2201": "",
|
||||||
|
"badge_name_X2139": "Buon Natale!",
|
||||||
|
"badge_desc_X2139": "",
|
||||||
|
"badge_name_X2138": "Sulla lista dei buoni di Babbo Natale!",
|
||||||
|
"badge_desc_X2138": "",
|
||||||
|
"badge_name_X2137": "La Squadra di Babbo Natale",
|
||||||
|
"badge_desc_X2137": "",
|
||||||
|
"badge_name_X2136": "Chi è il vero Babbo Natale?",
|
||||||
|
"badge_desc_X2136": "",
|
||||||
|
"badge_name_X2135": "Il Coro Natalizio di Habbo",
|
||||||
|
"badge_desc_X2135": "",
|
||||||
|
"badge_name_X2134": "Battaglia di Palle di Neve!",
|
||||||
|
"badge_desc_X2134": "",
|
||||||
|
"badge_name_X2133": "Oh Albero di Natale",
|
||||||
|
"badge_desc_X2133": "",
|
||||||
|
"badge_name_X2132": "Si prepara una Tempesta!",
|
||||||
|
"badge_desc_X2132": "",
|
||||||
|
"badge_name_X2131": "Pattinando sul Ghiaccio Sottile",
|
||||||
|
"badge_desc_X2131": "",
|
||||||
|
"badge_name_X2130": "Piante Malridotte dall'Inverno",
|
||||||
|
"badge_desc_X2130": "",
|
||||||
|
"badge_name_X2129": "Nobile Uccello d'Oro",
|
||||||
|
"badge_desc_X2129": "",
|
||||||
|
"badge_name_X2128": "Caso Risolto!",
|
||||||
|
"badge_desc_X2128": "",
|
||||||
|
"badge_name_X2127": "Passi nella neve",
|
||||||
|
"badge_desc_X2127": "",
|
||||||
|
"badge_name_X2126": "La Grande Fuga",
|
||||||
|
"badge_desc_X2126": "",
|
||||||
|
"badge_name_X2125": "Il Sospetto Sbagliato",
|
||||||
|
"badge_desc_X2125": "",
|
||||||
|
"badge_name_X2124": "Scappato appena in tempo!",
|
||||||
|
"badge_desc_X2124": "",
|
||||||
|
"badge_name_X2123": "Identità Segreta",
|
||||||
|
"badge_desc_X2123": "",
|
||||||
|
"badge_name_X2122": "Una Mappa Segreta",
|
||||||
|
"badge_desc_X2122": "",
|
||||||
|
"badge_name_X2121": "Zzzz..Zzzz..",
|
||||||
|
"badge_desc_X2121": "",
|
||||||
|
"badge_name_X2120": "Si è intrufolato nella stanza del commesso viaggiatore",
|
||||||
|
"badge_desc_X2120": "",
|
||||||
|
"badge_name_X2119": "Un Look strepitoso!",
|
||||||
|
"badge_desc_X2119": "",
|
||||||
|
"badge_name_X2118": "Chiavi prestate",
|
||||||
|
"badge_desc_X2118": "",
|
||||||
|
"badge_name_X2117": "Il Treno sta arrivando",
|
||||||
|
"badge_desc_X2117": "",
|
||||||
|
"badge_name_X2116": "Bandito del Treno",
|
||||||
|
"badge_desc_X2116": "",
|
||||||
|
"badge_name_X2115": "Il Treno Habbo Express",
|
||||||
|
"badge_desc_X2115": "",
|
||||||
|
"badge_name_X2114": "Uovo Habberge Art Deco LTD",
|
||||||
|
"badge_desc_X2114": "",
|
||||||
|
"badge_name_X2113": "Locomotiva a Vapore Rara",
|
||||||
|
"badge_desc_X2113": "",
|
||||||
|
"badge_name_X2112": "Costume da Treno Raro",
|
||||||
|
"badge_desc_X2112": "",
|
||||||
|
"badge_name_X2111": "Macchina della Cioccolata Calda di Lusso Rara",
|
||||||
|
"badge_desc_X2111": ""
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
import "dotenv/config";
|
import "./load-env";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -6,7 +6,7 @@ import { mysqlConnectionUrl } from "./db-url";
|
|||||||
import { splitSqlStatements } from "./sql-statements";
|
import { splitSqlStatements } from "./sql-statements";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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";
|
const TRACKING_TABLE = "cms_migrations";
|
||||||
|
|
||||||
interface MigrationFile {
|
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)`);
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
URL="${FLARESOLVERR_URL:-http://localhost:8191}"
|
||||||
|
MAX_RETRIES="${FLARESOLVERR_MAX_RETRIES:-30}"
|
||||||
|
RETRY_INTERVAL="${FLARESOLVERR_RETRY_INTERVAL:-2}"
|
||||||
|
|
||||||
|
echo "Waiting for FlareSolverr at ${URL}..."
|
||||||
|
|
||||||
|
for i in $(seq 1 "$MAX_RETRIES"); do
|
||||||
|
if curl -sf "${URL}/health" >/dev/null 2>&1; then
|
||||||
|
echo "FlareSolverr is healthy."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Attempt ${i}/${MAX_RETRIES} - FlareSolverr not ready, retrying in ${RETRY_INTERVAL}s..."
|
||||||
|
sleep "$RETRY_INTERVAL"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "ERROR: FlareSolverr did not become healthy after ${MAX_RETRIES} attempts."
|
||||||
|
exit 1
|
||||||
+167
-24
@@ -1,29 +1,82 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import "./load-env";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
|
import { lt, sql } from "drizzle-orm";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
|
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { redis } from "../src/lib/redis";
|
||||||
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
function initWorkerSentry(): void {
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureWorkerError(err: unknown, context: string): void {
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
logger.error(context, {
|
logger.error(context, {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
err: err instanceof Error ? err.message : String(err),
|
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 +122,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> {
|
async function cleanupOldLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.websiteLoginLogs.deleteMany({
|
await db
|
||||||
where: { createdAt: { lt: cutoff } },
|
.delete(WebsiteLoginLogs)
|
||||||
});
|
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
|
||||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
captureWorkerError(err, "Log cleanup failed");
|
captureWorkerError(err, "Log cleanup failed");
|
||||||
@@ -84,9 +215,7 @@ async function cleanupOldLogs(): Promise<void> {
|
|||||||
async function cleanupOldSessions(): Promise<void> {
|
async function cleanupOldSessions(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.passwordReset.deleteMany({
|
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
|
||||||
where: { createdAt: { lt: cutoff } },
|
|
||||||
});
|
|
||||||
logger.info("Cleaned up expired password reset tokens", {
|
logger.info("Cleaned up expired password reset tokens", {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
@@ -96,7 +225,6 @@ async function cleanupOldSessions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
initWorkerSentry();
|
|
||||||
logger.info("Worker started", { module: "jobs" });
|
logger.info("Worker started", { module: "jobs" });
|
||||||
|
|
||||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||||
@@ -108,6 +236,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 * * *", () => {
|
new Cron("0 4 * * *", () => {
|
||||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||||
captureWorkerError(e, "Cleanup error"),
|
captureWorkerError(e, "Cleanup error"),
|
||||||
@@ -115,10 +252,16 @@ async function main() {
|
|||||||
});
|
});
|
||||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
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([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
cleanupOldSessions(),
|
cleanupOldSessions(),
|
||||||
|
checkOpsHealth(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = resolve(__dirname, "..");
|
||||||
|
|
||||||
|
// Standalone scripts (tsx) don't auto-load .env like Next.js does, so the
|
||||||
|
// deploy step symlinks ${LIVE}/.env into the stage but nothing ever read it.
|
||||||
|
// Node's loadEnvFile() never overrides already-set variables, so real shell
|
||||||
|
// env wins; we load .env.local first so it takes precedence over .env.
|
||||||
|
function loadEnvFile(): void {
|
||||||
|
for (const name of [".env.local", ".env"]) {
|
||||||
|
const file = resolve(ROOT, name);
|
||||||
|
if (existsSync(file)) process.loadEnvFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFile();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const { parse, printParseErrorCode } = require("jsonc-parser");
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonc(file) {
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
|
const errors = [];
|
||||||
|
const value = parse(content, errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
allowEmptyContent: true,
|
||||||
|
});
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`[merge-config] parse error in ${file}: ${errors
|
||||||
|
.map((e) => printParseErrorCode(e.error))
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let example;
|
||||||
|
try {
|
||||||
|
example = parseJsonc(exampleFile);
|
||||||
|
} catch {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (example === undefined) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = {};
|
||||||
|
try {
|
||||||
|
const parsed = parseJsonc(targetFile);
|
||||||
|
if (parsed !== undefined && parsed !== null) {
|
||||||
|
current = parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
current = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = deepMerge(current, example);
|
||||||
|
fs.writeFileSync(targetFile, `${JSON.stringify(merged, null, 4)}\n`);
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
* Usage:
|
* Usage:
|
||||||
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
|
||||||
import {
|
import {
|
||||||
createCipheriv,
|
createCipheriv,
|
||||||
createDecipheriv,
|
createDecipheriv,
|
||||||
@@ -16,7 +15,8 @@ import {
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
timingSafeEqual,
|
timingSafeEqual,
|
||||||
} from "node:crypto";
|
} 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 {
|
function getKey(appKey: string): Buffer {
|
||||||
const raw = appKey.startsWith("base64:")
|
const raw = appKey.startsWith("base64:")
|
||||||
@@ -84,10 +84,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const key = getKey(appKey);
|
const key = getKey(appKey);
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await db
|
||||||
where: { twoFactorSecret: { not: null } },
|
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
|
||||||
select: { id: true, twoFactorSecret: true },
|
.from(User)
|
||||||
});
|
.where(isNotNull(User.twoFactorSecret));
|
||||||
|
|
||||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||||
|
|
||||||
@@ -107,10 +107,10 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||||
const reEncrypted = encryptGcm(plaintext, key);
|
const reEncrypted = encryptGcm(plaintext, key);
|
||||||
await prisma.user.update({
|
await db
|
||||||
where: { id: user.id },
|
.update(User)
|
||||||
data: { twoFactorSecret: reEncrypted },
|
.set({ twoFactorSecret: reEncrypted })
|
||||||
});
|
.where(eq(User.id, user.id));
|
||||||
console.log(` [OK] User ${user.id} — migrated`);
|
console.log(` [OK] User ${user.id} — migrated`);
|
||||||
migrated++;
|
migrated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -125,12 +125,10 @@ async function main() {
|
|||||||
if (errors > 0) process.exit(1);
|
if (errors > 0) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main().catch((err) => {
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
})
|
});
|
||||||
.finally(() => prisma.$disconnect());
|
|
||||||
|
|
||||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
describe("radio columns migration", () => {
|
describe("radio columns migration", () => {
|
||||||
it("adds every column idempotently for partially migrated databases", () => {
|
it("adds every column idempotently for partially migrated databases", () => {
|
||||||
const sql = readFileSync(
|
const sql = readFileSync(
|
||||||
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
|
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
-38
@@ -1,13 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { db, WebsiteAds } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -25,15 +26,17 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
const ad = await prisma.websiteAds.create({
|
const [result] = (await db.insert(WebsiteAds).values({
|
||||||
data: { image, createdAt: now, updatedAt: now },
|
image,
|
||||||
});
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ad_create",
|
action: "ad_create",
|
||||||
description: `Created advertisement #${ad.id} (${image})`,
|
description: `Created advertisement #${result.insertId} (${image})`,
|
||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(ad.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Action failed: createAd", {
|
logger.error("Action failed: createAd", {
|
||||||
@@ -58,10 +61,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
|||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteAds.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteAds)
|
||||||
data: { image, updatedAt: new Date() },
|
.set({ image, updatedAt: new Date() })
|
||||||
});
|
.where(eq(WebsiteAds.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ad_update",
|
action: "ad_update",
|
||||||
@@ -92,8 +95,14 @@ export const deleteAd = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = ctx.data.id;
|
const id = ctx.data.id;
|
||||||
try {
|
try {
|
||||||
await prisma.websiteAds.delete({ where: { id } });
|
const [result] = (await db
|
||||||
} catch {
|
.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");
|
throw new ActionError("Advertisement not found");
|
||||||
}
|
}
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -107,28 +116,3 @@ export const deleteAd = adminAction(
|
|||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
|
|
||||||
export async function deleteAdForm(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
|
||||||
const id = formPositiveBigInt(formData, "id");
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.websiteAds.delete({ where: { id } });
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "ad_delete",
|
|
||||||
description: `Deleted advertisement #${id}`,
|
|
||||||
targetType: "website_ad",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("Action failed: deleteAdForm", {
|
|
||||||
action: "deleteAdForm",
|
|
||||||
id: Number(id),
|
|
||||||
error: err instanceof Error ? err.message : "DB error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
redirect("/admin/ads");
|
|
||||||
}
|
|
||||||
@@ -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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { AlertLogs, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
@@ -29,3 +31,17 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/alerts");
|
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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
await requirePermission(PERMS.USERS_EDIT);
|
||||||
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
await db
|
||||||
|
.delete(WebsiteStaffApplications)
|
||||||
|
.where(eq(WebsiteStaffApplications.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// already gone / no DB — nothing to do
|
// already gone / no DB — nothing to do
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
WebsiteArticleComments,
|
||||||
|
WebsiteArticleReactions,
|
||||||
|
WebsiteArticles,
|
||||||
|
} from "@/lib/db";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let n = 2;
|
let n = 2;
|
||||||
while (
|
for (;;) {
|
||||||
await prisma.websiteArticles.findUnique({
|
const [existing] = await db
|
||||||
where: { slug },
|
.select({ id: WebsiteArticles.id })
|
||||||
select: { id: true },
|
.from(WebsiteArticles)
|
||||||
})
|
.where(eq(WebsiteArticles.slug, slug))
|
||||||
) {
|
.limit(1);
|
||||||
|
if (!existing) return slug;
|
||||||
slug = `${base}-${n++}`;
|
slug = `${base}-${n++}`;
|
||||||
}
|
}
|
||||||
return slug;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createArticle(formData: FormData): Promise<void> {
|
export async function createArticle(formData: FormData): Promise<void> {
|
||||||
@@ -41,8 +47,7 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteArticles.create({
|
await db.insert(WebsiteArticles).values({
|
||||||
data: {
|
|
||||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||||
title: title.slice(0, 255),
|
title: title.slice(0, 255),
|
||||||
shortStory: shortStory.slice(0, 255),
|
shortStory: shortStory.slice(0, 255),
|
||||||
@@ -51,7 +56,6 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
userId: staff.id,
|
userId: staff.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Database error — re-render unchanged with error.
|
// 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 id = BigInt(String(formData.get("id")));
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
try {
|
try {
|
||||||
await prisma.websiteArticles.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteArticles)
|
||||||
data: {
|
.set({
|
||||||
title: String(formData.get("title") ?? "")
|
title: String(formData.get("title") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -87,8 +91,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
})
|
||||||
});
|
.where(eq(WebsiteArticles.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
redirect("/admin/articles?error=Update failed");
|
redirect("/admin/articles?error=Update failed");
|
||||||
}
|
}
|
||||||
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.NEWS_EDIT);
|
await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = BigInt(String(formData.get("id")));
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
await tx
|
||||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
.delete(WebsiteArticleReactions)
|
||||||
prisma.websiteArticles.delete({ where: { id } }),
|
.where(eq(WebsiteArticleReactions.articleId, id));
|
||||||
]);
|
await tx
|
||||||
|
.delete(WebsiteArticleComments)
|
||||||
|
.where(eq(WebsiteArticleComments.articleId, id));
|
||||||
|
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
redirect("/admin/articles?error=Delete failed");
|
redirect("/admin/articles?error=Delete failed");
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-13
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, max } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, UsersBadges } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
export async function giveBadge(formData: FormData): Promise<void> {
|
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
|
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||||
// against duplicates and compute the next free slot ourselves.
|
// against duplicates and compute the next free slot ourselves.
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.usersBadges.findFirst({
|
const [existing] = await db
|
||||||
where: { userId, badgeCode: code },
|
.select({ id: UsersBadges.id })
|
||||||
select: { id: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(
|
||||||
|
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const max = await prisma.usersBadges.aggregate({
|
const [agg] = await db
|
||||||
where: { userId },
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||||
_max: { slotId: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(eq(UsersBadges.userId, userId));
|
||||||
const slotId = (max._max.slotId ?? 0) + 1;
|
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||||
await prisma.usersBadges.create({
|
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
||||||
data: { userId, slotId, badgeCode: code },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: the RCON grant already succeeded for online users.
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-12
@@ -1,14 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { $Enums } from "@/generated/prisma/client";
|
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { Ban, db, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
type BanType = $Enums.bans_type;
|
|
||||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||||
"account",
|
"account",
|
||||||
"ip",
|
"ip",
|
||||||
@@ -31,13 +30,13 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const [user] = await db
|
||||||
where: { id: userId },
|
.select({ username: User.username })
|
||||||
select: { username: true },
|
.from(User)
|
||||||
});
|
.where(eq(User.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
await prisma.ban.create({
|
await db.insert(Ban).values({
|
||||||
data: {
|
|
||||||
userId,
|
userId,
|
||||||
ip: "",
|
ip: "",
|
||||||
machineId: "",
|
machineId: "",
|
||||||
@@ -45,9 +44,8 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
banExpire,
|
banExpire,
|
||||||
banReason: reason,
|
banReason: reason,
|
||||||
type: type as BanType,
|
type: type as "account" | "ip" | "machine" | "super",
|
||||||
cfhTopic: -1,
|
cfhTopic: -1,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) await rcon.disconnectUser(userId, user.username);
|
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 staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (id > 0) {
|
if (id > 0) {
|
||||||
await prisma.ban.delete({ where: { id } });
|
await db.delete(Ban).where(eq(Ban.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ban_lift",
|
action: "ban_lift",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, EmailTemplates } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
@@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
const isActive = formData.get("isActive") != null;
|
const isActive = formData.get("isActive") != null;
|
||||||
if (!name || !subject || !body) return;
|
if (!name || !subject || !body) return;
|
||||||
|
|
||||||
await prisma.emailTemplates.create({
|
await db.insert(EmailTemplates).values({
|
||||||
data: {
|
|
||||||
name,
|
name,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
variables: variablesRaw || null,
|
variables: variablesRaw || null,
|
||||||
isActive,
|
isActive,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
@@ -56,15 +55,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
const isActive = formData.get("isActive") != null;
|
const isActive = formData.get("isActive") != null;
|
||||||
if (!subject || !body) return;
|
if (!subject || !body) return;
|
||||||
|
|
||||||
await prisma.emailTemplates.update({
|
await db
|
||||||
where: { id },
|
.update(EmailTemplates)
|
||||||
data: {
|
.set({
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
variables: variablesRaw || null,
|
variables: variablesRaw || null,
|
||||||
isActive,
|
isActive,
|
||||||
},
|
})
|
||||||
});
|
.where(eq(EmailTemplates.id, id));
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +71,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
await prisma.emailTemplates.delete({ where: { id } });
|
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
|
// 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).
|
// 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")
|
.normalize("NFC")
|
||||||
.slice(0, 512);
|
.slice(0, 512);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.emulatorSettings.upsert({
|
await db
|
||||||
where: { key },
|
.insert(EmulatorSettings)
|
||||||
update: { value },
|
.values({ key, value })
|
||||||
create: { key, value },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
revalidatePath("/admin/emulator");
|
revalidatePath("/admin/emulator");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +37,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.slice(0, 4096);
|
.slice(0, 4096);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.emulatorTexts.upsert({
|
await db
|
||||||
where: { key },
|
.insert(EmulatorTexts)
|
||||||
update: { value },
|
.values({ key, value })
|
||||||
create: { key, value },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
revalidatePath("/admin/emulator");
|
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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
GuildForumViews,
|
||||||
|
Guilds,
|
||||||
|
GuildsForumsComments,
|
||||||
|
GuildsForumsThreads,
|
||||||
|
GuildsMembers,
|
||||||
|
Items,
|
||||||
|
Rooms,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
/** Disband a guild and clean related membership/forum rows. */
|
/** 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"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
const guild = await prisma.guilds.findUnique({
|
const [guild] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { id: true, name: true, userId: true },
|
id: Guilds.id,
|
||||||
});
|
name: Guilds.name,
|
||||||
|
userId: Guilds.userId,
|
||||||
|
})
|
||||||
|
.from(Guilds)
|
||||||
|
.where(eq(Guilds.id, id))
|
||||||
|
.limit(1);
|
||||||
if (!guild) return;
|
if (!guild) return;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const threads = await tx.guildsForumsThreads.findMany({
|
const threads = await tx
|
||||||
where: { guildId: id },
|
.select({ id: GuildsForumsThreads.id })
|
||||||
select: { id: true },
|
.from(GuildsForumsThreads)
|
||||||
});
|
.where(eq(GuildsForumsThreads.guildId, id));
|
||||||
const threadIds = threads.map((t) => t.id);
|
const threadIds = threads.map((t) => t.id);
|
||||||
if (threadIds.length > 0) {
|
if (threadIds.length > 0) {
|
||||||
await tx.guildsForumsComments.deleteMany({
|
await tx
|
||||||
where: { threadId: { in: threadIds } },
|
.delete(GuildsForumsComments)
|
||||||
});
|
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||||
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
|
await tx
|
||||||
|
.delete(GuildsForumsThreads)
|
||||||
|
.where(eq(GuildsForumsThreads.guildId, id));
|
||||||
}
|
}
|
||||||
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
|
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
|
||||||
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
|
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
|
||||||
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
|
||||||
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
|
||||||
await tx.guilds.delete({ where: { id } });
|
await tx.delete(Guilds).where(eq(Guilds.id, id));
|
||||||
});
|
});
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
db,
|
||||||
|
WebsiteHelpCenterTicketReplies,
|
||||||
|
WebsiteHelpCenterTickets,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
@@ -25,39 +31,99 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
|||||||
const id = String(ticketId);
|
const id = String(ticketId);
|
||||||
revalidatePath("/admin/help-tickets");
|
revalidatePath("/admin/help-tickets");
|
||||||
revalidatePath(`/admin/help-tickets/${id}`);
|
revalidatePath(`/admin/help-tickets/${id}`);
|
||||||
|
revalidatePath("/mod/help-tickets");
|
||||||
|
revalidatePath(`/mod/help-tickets/${id}`);
|
||||||
revalidatePath("/help/tickets");
|
revalidatePath("/help/tickets");
|
||||||
revalidatePath(`/help/tickets/${id}`);
|
revalidatePath(`/help/tickets/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const replyHelpCenterTicket = adminAction(
|
export const liftBanFromHelpTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
{
|
||||||
|
permission: PERMS.USERS_BAN,
|
||||||
|
schema: helpCenterTicketIdSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, open: true },
|
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");
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const staffId = Number(ctx.session.user.id);
|
const staffId = Number(ctx.session.user.id);
|
||||||
|
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.websiteHelpCenterTicketReplies.create({
|
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
||||||
data: {
|
|
||||||
ticketId,
|
ticketId,
|
||||||
userId: staffId,
|
userId: staffId,
|
||||||
content: ctx.data.content.trim(),
|
content: ctx.data.content.trim(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
});
|
||||||
}),
|
await tx
|
||||||
prisma.websiteHelpCenterTickets.update({
|
.update(WebsiteHelpCenterTickets)
|
||||||
where: { id: ticketId },
|
.set({ updatedAt: now })
|
||||||
data: { updatedAt: now },
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
}),
|
});
|
||||||
]);
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: staffId,
|
userId: staffId,
|
||||||
@@ -72,22 +138,26 @@ export const replyHelpCenterTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeHelpCenterTicket = adminAction(
|
export const closeHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, open: true },
|
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) throw new ActionError("Ticket not found");
|
||||||
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.update({
|
await db
|
||||||
where: { id: ticketId },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { open: false, updatedAt: now },
|
.set({ open: false, updatedAt: now })
|
||||||
});
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: Number(ctx.session.user.id),
|
userId: Number(ctx.session.user.id),
|
||||||
@@ -104,22 +174,26 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const reopenHelpCenterTicket = adminAction(
|
export const reopenHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, open: true },
|
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) throw new ActionError("Ticket not found");
|
||||||
if (ticket.open) throw new ActionError("Ticket is already open");
|
if (ticket.open) throw new ActionError("Ticket is already open");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.update({
|
await db
|
||||||
where: { id: ticketId },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { open: true, updatedAt: now },
|
.set({ open: true, updatedAt: now })
|
||||||
});
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: Number(ctx.session.user.id),
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-13
@@ -1,12 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
||||||
@@ -32,8 +34,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
||||||
data: {
|
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
position: parsePosition(formData.get("position")),
|
position: parsePosition(formData.get("position")),
|
||||||
@@ -43,14 +44,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
buttonColor,
|
buttonColor,
|
||||||
buttonBorderColor,
|
buttonBorderColor,
|
||||||
smallBox: formData.get("smallBox") != null,
|
smallBox: formData.get("smallBox") != null,
|
||||||
},
|
})) as unknown as [ResultSetHeader];
|
||||||
});
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_create",
|
action: "help_create",
|
||||||
description: `Created help-center entry #${entry.id} (${name})`,
|
description: `Created help-center entry #${result.insertId} (${name})`,
|
||||||
targetType: "help_center_category",
|
targetType: "help_center_category",
|
||||||
targetId: Number(entry.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Unique name collision or DB error — re-render unchanged with error.
|
// 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";
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteHelpCenterCategories)
|
||||||
data: {
|
.set({
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
position: parsePosition(formData.get("position")),
|
position: parsePosition(formData.get("position")),
|
||||||
@@ -93,8 +93,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
buttonColor,
|
buttonColor,
|
||||||
buttonBorderColor,
|
buttonBorderColor,
|
||||||
smallBox: formData.get("smallBox") != null,
|
smallBox: formData.get("smallBox") != null,
|
||||||
},
|
})
|
||||||
});
|
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_update",
|
action: "help_update",
|
||||||
@@ -116,7 +116,9 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
await db
|
||||||
|
.delete(WebsiteHelpCenterCategories)
|
||||||
|
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_delete",
|
action: "help_delete",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { asc } from "drizzle-orm";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
|
||||||
import { redirectSafe } from "@/lib/foundation/security";
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||||
@@ -17,33 +18,22 @@ async function rejectLegacyHousekeepingWrite(): Promise<never> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertPermission(_formData: FormData): Promise<void> {
|
|
||||||
return rejectLegacyHousekeepingWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePermission(_formData: FormData): Promise<void> {
|
|
||||||
return rejectLegacyHousekeepingWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkImportPermissions(
|
|
||||||
_formData: FormData,
|
|
||||||
): Promise<{ count: number; errors: string[] }> {
|
|
||||||
return rejectLegacyHousekeepingWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportPermissions(): Promise<string> {
|
export async function exportPermissions(): Promise<string> {
|
||||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
|
||||||
const perms = await prisma.websiteHousekeepingPermissions.findMany({
|
const perms = await db
|
||||||
orderBy: [{ groupName: "asc" }, { permission: "asc" }],
|
.select({
|
||||||
select: {
|
permission: WebsiteHousekeepingPermissions.permission,
|
||||||
permission: true,
|
minRank: WebsiteHousekeepingPermissions.minRank,
|
||||||
minRank: true,
|
description: WebsiteHousekeepingPermissions.description,
|
||||||
description: true,
|
groupName: WebsiteHousekeepingPermissions.groupName,
|
||||||
groupName: true,
|
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
|
||||||
dependsOn: true,
|
})
|
||||||
},
|
.from(WebsiteHousekeepingPermissions)
|
||||||
});
|
.orderBy(
|
||||||
|
asc(WebsiteHousekeepingPermissions.groupName),
|
||||||
|
asc(WebsiteHousekeepingPermissions.permission),
|
||||||
|
);
|
||||||
|
|
||||||
return JSON.stringify(perms, null, 2);
|
return JSON.stringify(perms, null, 2);
|
||||||
}
|
}
|
||||||
@@ -51,13 +41,3 @@ export async function exportPermissions(): Promise<string> {
|
|||||||
export async function applyPreset(_formData: FormData): Promise<void> {
|
export async function applyPreset(_formData: FormData): Promise<void> {
|
||||||
return rejectLegacyHousekeepingWrite();
|
return rejectLegacyHousekeepingWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAllPermissions(): Promise<void> {
|
|
||||||
return rejectLegacyHousekeepingWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkDeletePermissions(
|
|
||||||
_ids: bigint[],
|
|
||||||
): Promise<{ count: number }> {
|
|
||||||
return rejectLegacyHousekeepingWrite();
|
|
||||||
}
|
|
||||||
@@ -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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
function parseIp(formData: FormData): string {
|
function parseIp(formData: FormData): string {
|
||||||
return String(formData.get("ipAddress") ?? "")
|
return String(formData.get("ipAddress") ?? "")
|
||||||
@@ -25,8 +26,10 @@ export async function addWhitelist(formData: FormData): Promise<void> {
|
|||||||
const ipAddress = parseIp(formData);
|
const ipAddress = parseIp(formData);
|
||||||
if (!ipAddress) return;
|
if (!ipAddress) return;
|
||||||
const asn = parseAsn(formData);
|
const asn = parseAsn(formData);
|
||||||
await prisma.websiteIpWhitelist.create({
|
await db.insert(WebsiteIpWhitelist).values({
|
||||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
ipAddress,
|
||||||
|
asn,
|
||||||
|
whitelistAsn: asn != null,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,9 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
await db
|
||||||
|
.delete(WebsiteIpWhitelist)
|
||||||
|
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +51,10 @@ export async function addBlacklist(formData: FormData): Promise<void> {
|
|||||||
const ipAddress = parseIp(formData);
|
const ipAddress = parseIp(formData);
|
||||||
if (!ipAddress) return;
|
if (!ipAddress) return;
|
||||||
const asn = parseAsn(formData);
|
const asn = parseAsn(formData);
|
||||||
await prisma.websiteIpBlacklist.create({
|
await db.insert(WebsiteIpBlacklist).values({
|
||||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
ipAddress,
|
||||||
|
asn,
|
||||||
|
blacklistAsn: asn != null,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -58,6 +65,8 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
await db
|
||||||
|
.delete(WebsiteIpBlacklist)
|
||||||
|
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
|
const {
|
||||||
vi.hoisted(() => ({
|
mockValues,
|
||||||
mockUpsert: vi.fn(),
|
mockOnDuplicateKeyUpdate,
|
||||||
|
mockRequirePermission,
|
||||||
|
mockReload,
|
||||||
|
mockRevalidatePath,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockValues = vi.fn(() => ({
|
||||||
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
mockValues,
|
||||||
|
mockOnDuplicateKeyUpdate,
|
||||||
mockRequirePermission: vi.fn(),
|
mockRequirePermission: vi.fn(),
|
||||||
mockReload: vi.fn(),
|
mockReload: vi.fn(),
|
||||||
mockRevalidatePath: vi.fn(),
|
mockRevalidatePath: vi.fn(),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: {
|
PERMS: {
|
||||||
@@ -16,10 +28,11 @@ vi.mock("@/lib/permissions", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
websiteSetting: { upsert: mockUpsert },
|
insert: vi.fn(() => ({ values: mockValues })),
|
||||||
},
|
},
|
||||||
|
WebsiteSetting: { key: "key", value: "value" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
@@ -38,6 +51,10 @@ import { saveMaintenance } from "./admin-maintenance";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
|
});
|
||||||
|
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveMaintenance", () => {
|
describe("saveMaintenance", () => {
|
||||||
@@ -57,37 +74,26 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
expect(mockRequirePermission).toHaveBeenCalled();
|
expect(mockRequirePermission).toHaveBeenCalled();
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledTimes(3);
|
expect(mockValues).toHaveBeenCalledTimes(3);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "maintenance_enabled" },
|
|
||||||
update: { value: "1" },
|
|
||||||
create: expect.objectContaining({
|
|
||||||
key: "maintenance_enabled",
|
key: "maintenance_enabled",
|
||||||
value: "1",
|
value: "1",
|
||||||
}),
|
}),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "maintenance_message" },
|
|
||||||
update: { value: "We will be back soon!" },
|
|
||||||
create: expect.objectContaining({
|
|
||||||
key: "maintenance_message",
|
key: "maintenance_message",
|
||||||
value: "We will be back soon!",
|
value: "We will be back soon!",
|
||||||
}),
|
}),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
|
||||||
update: { value: "3" },
|
|
||||||
create: expect.objectContaining({
|
|
||||||
key: "min_maintenance_login_rank",
|
key: "min_maintenance_login_rank",
|
||||||
value: "3",
|
value: "3",
|
||||||
}),
|
}),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
expect(mockReload).toHaveBeenCalledOnce();
|
expect(mockReload).toHaveBeenCalledOnce();
|
||||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||||
@@ -106,16 +112,16 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "maintenance_enabled" },
|
key: "maintenance_enabled",
|
||||||
update: { value: "0" },
|
value: "0",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "5" },
|
value: "5",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -134,10 +140,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "5" },
|
value: "5",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -156,10 +162,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "5" },
|
value: "5",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
|
// 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> {
|
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||||
await prisma.websiteSetting.upsert({
|
await db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
comment: COMMENTS[key] ?? null,
|
||||||
});
|
})
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMaintenance(formData: FormData): Promise<void> {
|
export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { db, MarketplaceItems } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
/** Cancel an active marketplace listing (state 1 → 0). */
|
/** Cancel an active marketplace listing (state 1 → 0). */
|
||||||
@@ -14,16 +15,23 @@ export async function cancelMarketplaceListing(
|
|||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
const listing = await prisma.marketplaceItems.findUnique({
|
const [listing] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { id: true, state: true, userId: true, itemId: true, price: true },
|
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;
|
if (listing?.state !== 1) return;
|
||||||
|
|
||||||
await prisma.marketplaceItems.update({
|
await db
|
||||||
where: { id },
|
.update(MarketplaceItems)
|
||||||
data: { state: 0 },
|
.set({ state: 0 })
|
||||||
});
|
.where(eq(MarketplaceItems.id, id));
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
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,36 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
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"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
try {
|
const [row] = await db
|
||||||
await prisma.cameraWeb.delete({ where: { id } });
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||||
} catch {
|
.from(CameraWeb)
|
||||||
// Record may have already been removed; ignore.
|
.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("/admin/photos");
|
||||||
|
revalidatePath("/photos");
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, RadioApiKeys } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
||||||
@@ -50,8 +51,7 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
const created = await prisma.radioApiKeys.create({
|
const [result] = await db.insert(RadioApiKeys).values({
|
||||||
data: {
|
|
||||||
name,
|
name,
|
||||||
key,
|
key,
|
||||||
allowedIps,
|
allowedIps,
|
||||||
@@ -59,14 +59,14 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const createdId = BigInt(result.insertId);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_api_key_create",
|
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",
|
targetType: "radio_api_key",
|
||||||
targetId: Number(created.id),
|
targetId: Number(createdId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
// 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;
|
if (id == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.radioApiKeys.findUnique({
|
const [existing] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { name: true, isActive: true },
|
name: RadioApiKeys.name,
|
||||||
});
|
isActive: RadioApiKeys.isActive,
|
||||||
|
})
|
||||||
|
.from(RadioApiKeys)
|
||||||
|
.where(eq(RadioApiKeys.id, id))
|
||||||
|
.limit(1);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
const next = !existing.isActive;
|
const next = !existing.isActive;
|
||||||
await prisma.radioApiKeys.update({
|
await db
|
||||||
where: { id },
|
.update(RadioApiKeys)
|
||||||
data: { isActive: next, updatedAt: new Date() },
|
.set({ isActive: next, updatedAt: new Date() })
|
||||||
});
|
.where(eq(RadioApiKeys.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_api_key_toggle",
|
action: "radio_api_key_toggle",
|
||||||
@@ -116,7 +120,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
|
|||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioApiKeys.delete({ where: { id } });
|
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_api_key_delete",
|
action: "radio_api_key_delete",
|
||||||
|
|||||||
Loaded 100 of 708 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user