precheckLogin already normalized the username with NFC, but the NextAuth credentials authorize handler only trimmed it. This caused a mismatch for accounts with accented/non-ASCII usernames: the precheck passed while the actual sign-in lookup found no user and returned 'invalid username or password'. Also normalize the password to NFC in both the precheck and the authorize handler to match how register.ts hashes it.
EpicNext-CMS v1.0.1
A modern, high-performance content management system for Habbo hotel emulators, built on Next.js 16 (App Router) with Drizzle ORM and React 19. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
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.
System Requirements
| Component | Version | Notes |
|---|---|---|
| Node.js | 26.7.0 | Current release pinned in .nvmrc |
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
| Java | 17+ | Required only if building the emulator |
| Maven | 3.9+ | Required only if building the emulator |
Quick Start
1. Clone & Install
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
pnpm install
2. Database Setup
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
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 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/migrateagainst the shared DB. CMS-owned tables (website_*,radio_*, etc.) are created via idempotent SQL indrizzle/migrations/(pnpm db:migrate).
3. Configure Environment
cp .env.example .env
Edit .env with at minimum:
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
AUTH_SECRET=<random 32+ character string>
HOTEL_NAME=YourHotel
APP_URL=http://localhost:3000
See .env.example for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
4. ORM Setup & Type Generation
Drizzle ORM (Primary Data Layer)
Drizzle ORM is the runtime data layer. The connection is a singleton in src/lib/db.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"));
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 pushordrizzle-kit migrate— the database is shared with the emulator. Apply CMS DDL only viapnpm 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
pnpm db:migrate
Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log, etc.) via idempotent SQL files in drizzle/migrations/. Emulator tables are never touched.
Check migration status:
pnpm db:migrate:status
6. Build & Start
# Development (hot reload)
pnpm dev
# Production
pnpm build && pnpm start
Open http://localhost:3000 in your browser.
7. First Login
- Register an account at
/register, or log in with an existing emulator account. - Grant yourself admin access:
UPDATE users SET rank = 7 WHERE username = 'yourname'; - Visit
/adminand configure your hotel via Admin → CMS Settings.
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)
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
--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false
--bind=127.0.0.1keeps it private on the machine (matchesREDIS_URL=redis://127.0.0.1:6379).--port=6379is the default Redis port, so.envstays unchanged.--maxmemorymust be at least0.25GiBper CPU thread (e.g.2gbon a 6-thread server).--version_check=falsedisables the periodic outbound update check.- Snapshots are written to
--dir(/var/lib/dragonfly/dump-*.dfs).
Point the CMS at it
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
Useful commands
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.pemand/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:
# ==========================================
# 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):
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:
# 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)
pnpm build
pm2 start pnpm --name "next" -- start
pm2 save
Restart after updates:
git pull
pnpm install
pnpm build
pm2 restart next
The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via /api/media/* and survive rebuilds.
Scripts
| Command | Description |
|---|---|
pnpm dev |
Start development server (hot reload) |
pnpm build |
Production build |
pnpm start |
Start production server |
pnpm typecheck |
Run TypeScript type checking |
pnpm test |
Run all tests (Vitest) |
pnpm db:migrate |
Apply pending SQL migrations |
pnpm db:migrate:status |
Show migration status |
pnpm db:schema:generate |
Regen src/db/schema.ts from prior schema + live DB |
pnpm db:generate |
Draft SQL via drizzle-kit → drizzle/drafts/ |
pnpm db:studio |
Drizzle Studio (dev) |
pnpm db:introspect |
drizzle-kit introspect (draft) |
pnpm analyze |
Build + open bundle analyzer |
pnpm jobs:worker |
Start background task worker (systemd / PM2) |
pnpm biome:check |
Lint and format code |
Drizzle Kit notes: db:generate / db:introspect write drafts only. Reviewed SQL must be copied into drizzle/migrations/ as a new numbered file, then applied with pnpm db:migrate. Never run drizzle-kit push or drizzle-kit migrate against production.
Performance Features
| Feature | Description |
|---|---|
| React Compiler | Automatic memoization — reduces unnecessary re-renders |
| View Transitions API | Native browser transitions between page navigations |
| Lenis Smooth Scroll | Fluid, customizable scrolling (respects prefers-reduced-motion) |
| Server-Sent Events | Real-time radio now-playing & listeners via SSE (no polling) |
| DragonflyDB Caching | Caches API responses (home, radio config) up to 30s in DragonflyDB |
| Bundle Analyzer | Run pnpm analyze to visualize and optimize bundle sizes |
| RCON (TCP Socket) | Live commands to the emulator (credits, badges, kick, ban) |
| Streaming & Suspense | Next.js App Router streaming for fast page loads |
| Automatic Image Optimization | next/image with Sharp for resizing and WebP/AVIF |
| CSS-based Animations | input-glow, btn-shine, Reveal scroll animations, floating orbs |
| Compression | Gzip compression enabled on all responses |
| Stale Times | Optimized router cache (30s dynamic, 180s static) |
| PWA | Service worker with skip-waiting, stale CSS chunk recovery |
| Biome | Fast linting and formatting (replaces ESLint + Prettier) |
Architecture
├── drizzle/
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── jobs-worker.ts # Background task scheduler
│ ├── merge-config.cjs # Utility: merge split config files
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ └── relations.ts # Drizzle relations
│ ├── app/ # Next.js App Router (pages & API routes)
│ ├── actions/ # Server Actions
│ ├── components/ # UI components
│ ├── lib/
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── db.ts # Drizzle connection singleton (runtime)
│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
│ │ ├── cache.ts # In-memory cache fallback
│ │ ├── motion.ts # Framer Motion animation variants
│ │ └── use-event-source.ts # React hook for SSE subscriptions
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
│ └── scripts/ # Client-side scripts (theme-init.js)
├── update-Nitrov3.sh # Emulator & Nitro updater utility
├── next.config.ts # Next.js configuration
└── .env.example # Environment template with all options
Database Ownership
| Component | Type | Migrations |
|---|---|---|
Emulator tables (users, items, rooms, etc.) |
Existing Polaris schema | None — CMS reads/writes only |
CMS tables (website_*, radio_*, etc.) |
CMS-owned | drizzle/migrations/*.sql |
| Migration tracking | cms_migrations table |
Auto-created by migration runner |
ORM Architecture
- Runtime (Drizzle ORM):
@/lib/dbexposes a Drizzle singleton. Schema lives insrc/db/schema.ts. - Schema regeneration:
pnpm db:schema:generatereuses field/table names from the previoussrc/db/schema.tsand 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
Emulator — RCON
For live actions (credits, badges, rank changes, kick, ban, hotel alerts). The emulator must be running with RCON enabled at RCON_HOST:RCON_PORT.
Client — Nitro
The /client page loads the Nitro client. Configure the client URL via Admin → CMS Settings (nitro_client_url). See update-Nitrov3.sh for deployment.
Radio
Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings:
radio_now_playing_api_urlradio_listeners_api_url
The radio player uses SSE for real-time updates (10s interval, no polling).
Background Jobs
Run as a persistent process:
pnpm jobs:worker
Scheduled tasks:
- Daily 03:00 — Emulator JAR backup (requires
EMULATOR_JAR_PATH+EMULATOR_BACKUP_DIR) - Daily 04:00 — Cleanup login logs (>30 days) and expired password reset tokens (>7 days)
CAPTCHA
Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
Theming
12 preset themes with fully customizable colors via Admin → Theme (/admin/theme).
Furniture Import with Auto-Translation
The CMS now automatically translates furniture names and descriptions to 13 languages on every import:
- Native languages (via official Habbo gamedata): Dutch (
nl), English (en), German (de), French (fr), Spanish (es), Turkish (tr), Italian (it) - Additional languages (via LibreTranslate self-hosted Docker at
127.0.0.1:5000): Portuguese (pt), Finnish (fi), Polish (pl), Russian (ru), Arabic (ar), Japanese (ja)
How it works
-
Per-import translation — Every import route (
/admin/import/furni, batch, batch-regen, clone) triggers translation automatically after the furniture data is imported. -
Translation flow:
- The original (usually English)
classnameanddescriptionare sent to LibreTranslate - Official Habbo translations take priority for the 7 supported languages
- Custom/new meubels fall back to LibreTranslate
- Post-processing rules force Habbo‑style terminology (e.g.
bank→Bank,tafel→Tafel,stoel→Stoel)
- The original (usually English)
-
No manual steps needed — The translation happens as part of the import API calls. New custom furniture added via import immediately appears with translated names in the catalog for all 13 languages.
-
CLI scripts (optional):
pnpm translate:full— translate all furniture data fully (uses--fullflag)pnpm translate:limited [--limit N] [--concurrency N]— translate limited amount per languagepnpm build:languages [--full] [--limit N] [--concurrency N]— build the full localized furnidata JSON files
Requirements
- LibreTranslate Docker container running at
http://127.0.0.1:5000(or configure a different URL in the environment) - The
LIBRETRANSLATE_URLenvironment variable can override the default if needed - For the 7 official languages, no external service is needed — they use the built‑in Habbo gamedata
Environment variables
# LibreTranslate endpoint (default: http://127.0.0.1:5000)
LIBRETRANSLATE_URL=http://127.0.0.1:5000
Example import flow
# Single furniture import (triggers translation)
POST /admin/import/furni
# Batch import (triggers translation)
POST /admin/import/furni/batch
# Regenerate localized files
POST /admin/import/furni/batch-regen
# Clone import (triggers translation)
POST /admin/import/clone/batch
AI Content Moderation
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:
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
pnpm dev # Start with hot reload
pnpm typecheck # Type check all files
pnpm test # Run test suite
pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format
Contributing
- Ensure typecheck and tests pass:
pnpm typecheck && pnpm test - Follow existing code conventions (Server Components where possible, minimal client boundaries)
- Use the
src/lib/motion.tsanimation variants for consistent animations - SQL migrations in
drizzle/migrations/must be idempotent - For new database code, use the Drizzle runtime directly (
import { db } from "@/lib/db") — see ORM Setup - Avoid
any— useeslint-disableorbiome-ignorecomments only when unavoidable
License
CC BY-NC-SA 4.0. See LICENSE for details.