Simo ce4a3ba32c
CI / check (pull_request) Successful in 2m11s
CI / deploy (pull_request) Skipped
CI / e2e (pull_request) Skipped
Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise
2026-09-04 20:08:49 +02:00
2026-07-13 21:57:41 +02:00
2026-09-03 16:58:50 +02:00

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.8.1 Current release pinned in .nvmrc
pnpm >= 11.25.0 Recommended package manager
npm >= 11.x Supported alternative
yarn >= 4.x Supported alternative
MySQL / MariaDB 8.0+ / 10.6+ Shared with the emulator
Docker 24+ Optional — for containerized deployment
DragonflyDB 1.x+ Optional — caching, rate limiting, SSE

Quick Start

1. Clone & Install

Choose your preferred package manager:

git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms

pnpm (recommended):

pnpm install

npm:

npm install

yarn:

yarn 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 / migrate against the shared DB. CMS-owned tables (website_*, radio_*, etc.) are created via idempotent SQL in drizzle/migrations/.

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:3002

See .env.example for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).

4. Run CMS Migrations

# pnpm
pnpm db:migrate

# npm
npm run db:migrate

# yarn
yarn db:migrate

Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log, etc.) via idempotent SQL files in drizzle/migrations/. Emulator tables are never touched.

5. Build & Start

# Development (hot reload)
pnpm dev      # or: npm run dev / yarn dev

# Production
pnpm build && pnpm start       # or: npm run build && npm start

Open http://localhost:3002 in your browser.

6. First Login

  1. Register an account at /register, or log in with an existing emulator account.
  2. Grant yourself admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';
  3. Visit /admin and configure your hotel via Admin → CMS Settings.

Docker Deployment

The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. Only the CMS runs in Docker; the Nitro/Octane client and its renderer stay outside and are served by nginx on the host.

Prerequisites

  • Docker 24+ and Docker Compose v2
  • .env file configured (see step 3 above)
  • A MySQL/MariaDB database reachable from the container (DATABASE_URL host should point to the DB server, not 127.0.0.1 unless it's reachable from inside the container)
  • (Optional) A shared Gamedata directory at /var/www/Gamedata with write access (see Volumes below)

Quick Start with Docker

# 1. Clone and configure
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
cp .env.example .env
# Edit .env with your database credentials and settings.
# The container runs on the host network, so all 127.0.0.1 references
# (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is.

# 2. Stop any existing host-side CMS that occupies port 3002 (if present).
pm2 stop next 2>/dev/null || true

# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
#    container user can write imports/uploads to them.
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage

# 4. Build and start
docker compose up -d --build

# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
#    on the HOST (against the same database) before/after starting the container.
pnpm db:migrate          # or: npm run db:migrate / yarn db:migrate

# 6. Check logs
docker compose logs -f cms

The CMS will be available at http://localhost:3002.

Reverse proxy: This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (/client/, /nitro-client/) and /gamedata/ directly from /var/www/Octane/dist and /var/www/Gamedata, and proxies / to the CMS on 127.0.0.1:3002. Point APP_URL/NEXT_PUBLIC_APP_URL at the public site URL.

Automatic Updates

Updating is fully scripted — no need to touch Docker or nginx by hand. The script scripts/docker-update.sh pulls the latest main, runs CMS migrations on the host, rebuilds the image, recreates the container and waits for a healthy status. It also makes sure a stale host-side PM2 CMS (pm2 stop next) stays stopped so it can't clash on port 3002.

Automatically (recommended): a nightly cron job is already configured on a production server that installed this setup. It runs the script every night at 03:30 and appends to logs/docker-update.cron.log:

crontab -e
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1

Manually — to update right now (same steps as the cron runs):

cd /var/www/atom-nexst
./scripts/docker-update.sh
# log: logs/docker-update.log

The script aborts safely (exit 1) if the working tree has uncommitted changes so a git pull can never clobber local edits, and leaves the container running if health fails so you can debug it (exit 3). Failed runs are reported in the log; an exit of 0 means the CMS is healthy on the new commit.

Docker Commands

Command Description
docker compose up -d --build Build and start in background
docker compose down Stop and remove containers
docker compose logs -f cms Follow CMS logs
docker compose exec cms sh Open a shell in the CMS container
docker compose restart cms Restart the CMS container
docker compose pull && docker compose up -d --build Update and redeploy
pnpm db:migrate (on the host) Run database migrations (slim image has no source)
./scripts/docker-update.sh Full automated update (manual or cron)

How Package Manager Detection Works

The Dockerfile checks for lockfiles in this order:

  1. pnpm-lock.yaml → uses pnpm (fastest, recommended)
  2. yarn.lock → uses yarn
  3. package-lock.json → uses npm
  4. No lockfile → falls back to npm install

This means you can use any package manager on your host machine — the Docker build will automatically match.

Override the detection explicitly with docker compose build --build-arg PACKAGE_MANAGER=pnpm (or npm / yarn).

Volumes

Only the CMS runs in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:

Container Path Host Path Mode Purpose
/app/public/nitro-assets ./public/nitro-assets rw Imported furni/pet/effect assets
/app/public/swf ./public/swf rw SWF costumes / icons (imports)
/app/storage ./storage rw Uploaded media (persistent)
/var/www/Gamedata /var/www/Gamedata rw Shared gamedata root (hardcoded path)

About the hardcoded /var/www/Gamedata path: src/lib/services/furni-asset-dirs.ts defines DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host /var/www/Gamedata at the identical path inside the container so existsSync('/var/www/Gamedata') succeeds and nginx keeps serving /gamedata/ from the same directory.

The Nitro/Octane client and renderer are NOT mounted — nginx on the host serves them directly:

Component Host path How it's served
Nitro/Octane client /var/www/Octane/dist nginx location ^~ /client/ and /nitro-client/ alias
Octane-Renderer /var/www/Octane-Renderer Optional. Run separately (or as the commented-out imager service) proxied by nginx /imaging
Camera uploads /var/www/Camera nginx /camera/ alias

Container user & write permissions: the CMS container runs as www-data (UID/GID 33) by default to match the host owner of /var/www/Gamedata. Ensure the other write volumes (./public/nitro-assets, ./public/swf, ./storage) are also owned by www-data on the host:

sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage

If your host user UID differs, override the run user at build time (defaults to www-data, which already exists in the base image with UID/GID 33):

docker compose build --build-arg RUN_USER=1000

Networking

The CMS container runs with network_mode: host. This lets the existing 127.0.0.1 references in .env keep working against host services — MariaDB (3306), DragonflyDB/Redis (6379), the emulator RCON/API (3003/3001) and the avatar imager — without re-writing any environment variables. The container consequently listens directly on the host's port 3002, so stop any previous host-side CMS (e.g. pm2 stop next) that occupies that port before starting it.

The build also runs on the host network (build.network: host) because this host disables Docker iptables (/etc/docker/daemon.json: "iptables": false), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.

Health Check

The container includes a health check that hits /api/health on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:

docker inspect --format='{{.State.Health.Status}}' epicnext-cms

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)
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 + 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.


DragonflyDB (Caching, Rate Limiting, SSE)

DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via REDIS_URL using the ioredis client. It is optional: without it the CMS falls back to in-process memory.

Install (Ubuntu/Debian)

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

Configure

Edit /etc/dragonfly/dragonfly.conf:

--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false

Point the CMS at it

REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2

Verify via /api/health — it should report "redis": true.


Nginx Configuration

The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration.

Prerequisites

  • SSL certificates in /etc/ssl/cert.pem and /etc/ssl/key.pem (or use Let's Encrypt)
  • CMS running on 127.0.0.1:3002

Reference Configuration

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;

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name yourdomain.com;

    ssl_certificate /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 20m;

    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;

    location / {
        proxy_pass http://127.0.0.1:3002;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_cache html_cache;
        proxy_cache_valid 200 60s;
        proxy_ignore_headers Vary;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        add_header X-Cache-Status $upstream_cache_status always;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:3002;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    location /_next/static/ {
        proxy_pass http://127.0.0.1:3002;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location /imaging {
        proxy_pass http://127.0.0.1:3030;
        add_header Cache-Control "public, max-age=3600, s-maxage=86400";
    }

    location /health {
        access_log off;
        return 200 "OK";
        add_header Content-Type text/plain;
    }
}

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

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
pnpm biome:check Lint and format code

Replace pnpm with npm run or yarn for other package managers.


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
Server-Sent Events Real-time radio now-playing & listeners via SSE
DragonflyDB Caching Caches API responses up to 30s in DragonflyDB
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
Compression Gzip compression enabled on all responses
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
│   └── 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)
│   │   ├── redis.ts           # Cache client (ioredis → DragonflyDB)
│   │   └── motion.ts          # Framer Motion animation variants
│   ├── messages/              # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│   └── env.ts                 # Zod-validated environment schema
├── public/
│   ├── assets/                # Images, icons, fonts
│   ├── nitro-assets/          # Nitro client assets (~3GB, volume-mounted in Docker)
│   └── swf/                   # SWF client files (volume-mounted in Docker)
├── docker-compose.yml         # Docker Compose configuration
├── Dockerfile                 # Multi-stage, multi-package-manager Docker build
├── next.config.ts             # Next.js configuration
└── .env.example               # Environment template with all options

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_url
  • radio_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    # or: npm run jobs:worker / yarn jobs:worker

AI Content Moderation

Optional OpenAI-powered moderation for comments and guestbook posts. Set OPENAI_API_KEY.

FlareSolverr (Cloudflare Bypass)

Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.

docker compose up -d flaresolverr
FLARESOLVERR_URL=http://localhost:8191

Furniture Import with Auto-Translation

The CMS automatically translates furniture names and descriptions to 13 languages on every import:

  • Native languages (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
  • Additional languages (via LibreTranslate Docker at 127.0.0.1:5000): Portuguese, Finnish, Polish, Russian, Arabic, Japanese

Theming

12 preset themes with fully customizable colors via Admin → Theme (/admin/theme).


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

  1. Ensure typecheck and tests pass: pnpm typecheck && pnpm test
  2. Follow existing code conventions (Server Components where possible)
  3. SQL migrations in drizzle/migrations/ must be idempotent
  4. For new database code, use the Drizzle runtime directly (import { db } from "@/lib/db")
  5. Avoid any — use biome-ignore comments only when unavoidable

License

CC BY-NC-SA 4.0. See LICENSE for details. Test aanpassing voor Gitea Actions

S
Description
No description provided
Readme CC-BY-SA-4.0
172 MiB
1 Stars 1 Watchers 0 Forks
Languages
TypeScript 95.9%
JavaScript 1.4%
Shell 1.1%
CSS 1.1%
PHP 0.3%
Other 0.1%