# EpicNext-CMS v1.0.3 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 | | Valkey | 8.x+ | Optional — caching, rate limiting, SSE | --- ## Quick Start ### 1. Clone & Install Choose your preferred package manager: ```bash git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git cd EpicNext-Cms ``` **pnpm (recommended):** ```bash pnpm install ``` **npm:** ```bash npm install ``` **yarn:** ```bash yarn install ``` ### 2. Database Setup The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one: ```sql 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 ```bash cp .env.example .env ``` Edit `.env` with at minimum: ```dotenv DATABASE_URL=mysql://user:password@127.0.0.1:3306/epicnext_cms AUTH_SECRET= HOTEL_NAME=YourHotel APP_URL=http://localhost:3002 ``` See `.env.example` for all optional variables (RCON, email, Valkey, OAuth, PayPal, etc.). ### 4. Run CMS Migrations ```bash # pnpm pnpm db:migrate # npm npm run db:migrate # yarn yarn db:migrate ``` Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched. ### 5. Build & Start ```bash # Development (hot reload) pnpm dev # or: npm run dev / yarn dev # Production pnpm build && pnpm start # or: npm run build && npm start ``` Open `http://localhost:3002` in your browser. ### 6. First Login 1. Register an account at `/register`, or log in with an existing emulator account. 2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';` 3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**. --- ## Docker Deployment The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. The **CMS** runs in Docker; the Nitro/Octane client stays outside and is served by nginx on the host. The server-side **avatar imager** also runs in its own Docker container (`avatar-imaging-pixinode`, port 8082) — see [Avatar Imaging](#avatar-imaging). ### Prerequisites - Docker 24+ and Docker Compose v2 - `.env` file configured (see step 3 above) - A MySQL/MariaDB database reachable from the container (`DATABASE_URL` host should point to the DB server, not `127.0.0.1` unless it's reachable from inside the container) - (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below) ### Quick Start with Docker ```bash # 1. Clone and configure git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git cd EpicNext-Cms cp .env.example .env # Edit .env with your database credentials and settings. # The container runs on the host network, so all 127.0.0.1 references # (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is. # 2. Stop any existing host-side CMS that occupies port 3002 (if present). pm2 stop next 2>/dev/null || true # 3. Ensure the write directories are owned by www-data (UID/GID 33) so the # container user can write imports/uploads to them. sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata # 4. Build, migrate and start a release tied to the Git commit. # Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm. bash scripts/docker-update.sh # 5. Follow logs docker compose logs -f cms ``` The CMS listens on port 3002 using Linux host networking. Existing database, Redis, emulator and shared gamedata services must already be configured. Create the mounted directories before installation; do not mount the checkout, `.next` or `node_modules` over `/app` inside the production container. ### Updating a Docker clone From your clone, run: ```bash # Also verifies that your public domain serves the expected commit: CMS_PUBLIC_URL=https://your-hotel.example bash scripts/docker-update.sh ``` The script follows the **current branch's configured Git upstream**. It refuses local uncommitted/untracked work, pulls fast-forward only and restarts itself if the updater changed. It does not reset or delete local work. Configure the upstream to your fork/branch if that is where you receive updates. It builds a commit-tagged image and runs migrations in the matching builder container, using the clone's `.env`; no host dependency installation is needed. Only after build and migrations succeed does it recreate the CMS service. It compares the running image ID, image revision and `/api/health` release with the expected Git commit. With `CMS_PUBLIC_URL`, it also checks the domain through your proxy/CDN. A healthy response from another release is an error. `git pull` alone updates source files, not an existing container. `docker compose restart` restarts the same image. `docker compose pull` downloads registry images; it does not fetch changes to this Git-built CMS. Use the updater for releases. A clone does **not** install an automatic scheduler. If desired, explicitly add a cron entry with the absolute path of **your** checkout; keep logs in its `logs` directory. Do not configure both CI and Compose updates for the same instance. The updater refuses an active `epicnext-cms-app` CI-managed container. Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check failure automatically recreates the CMS with its previous image and verifies its local HTTP release and database health. The command still returns nonzero: a rollback is not a successful update. Recovery uses the current Compose configuration and `.env`; it restores application code, not configuration edits or database migrations. The previous image must carry a valid release label. First installations have no previous release: a failed candidate remains available for diagnosis. A failed rollback is explicitly logged with a retained `epicnext-cms:rollback-...` recovery tag. Public routing must be checked separately after rollback. SIGKILL or host power loss cannot run the rollback handler. After success, the updater keeps the two most recent distinct releases tracked in `logs/docker-release-history.log`. Cleanup only removes older recorded CMS image tags, without force; images referenced by other containers (including stopped ones) are preserved and retried on later updates. Untracked historical images, build cache, other services and persistent volumes are not pruned. Retain the history file between updates. Before production migrations, retain your normal database backup. ### Portable images on the Gitea registry Docker builds no longer load your installation's `.env`. The builder uses disposable fixture values; the runtime reads `HOTEL_NAME`, `APP_URL`, `AUTH_SECRET`, database and asset settings when the container starts. Missing/invalid required runtime values stop startup with the setting names, without printing credentials. Configure `IMAGER_URL` with the actual avatar renderer endpoint and `BADGE_URL` with the badge directory URL (or a local path). The legacy `NEXT_PUBLIC_IMAGER_URL` and `NEXT_PUBLIC_BADGE_URL` names remain supported at runtime. If the imager points back to the CMS `/imaging` proxy, set `IMAGING_UPSTREAM_URL` to the actual renderer to avoid a loop. With no imager configured, the CMS uses Habbo's public renderer. Client requests use the existing `/api/imaging/avatar` proxy. Admin badges preserve their local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `APP_URL`. After changing `.env`, recreate the container; an image rebuild is not required. `NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image. ### Guided installation from this repository On a Linux host with Docker Engine, the Compose plugin, Git and `flock` available: ```bash git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git cd EpicNext-Cms bash cms install ``` The wizard asks for your public URL, delivery method, hotel name, existing database connection, Redis and avatar imager. It generates an authentication secret and creates `.env` with private permissions. An existing `.env` is preserved; review its `APP_URL`, `AUTH_URL`, RCON and optional original Laravel `APP_KEY` when moving an existing hotel. This installs the CMS, not the Habbo database, emulator, Redis or reverse proxy. Point HTTPS at port 3002 before running the final public check. The wizard may request sudo to prepare persistent directories for UID/GID 33; it does not recursively change ownership of existing assets. Choose **prebuilt** to download the images specified by this repository in `docker-image.txt`, without entering a registry namespace or commit tag. Choose **source** when building your own fork on the installation host. Private packages still require `docker login` on that host. Maintainers must update `docker-image.txt` if the publication registry or namespace changes. After installation, update with: ```bash bash cms update ``` The updater pulls the configured Git upstream, loads `.docker-install`, uses the matching application/migration images and verifies the running release locally and at the saved public URL. Existing application rollback remains available on a failed cutover; database migrations are not reversed. `bash cms install --configure-only` saves configuration without preparing runtime directories or starting containers. Neither `.env` nor `.docker-install` is committed or sent in Docker build contexts. Existing users of `bash scripts/docker-update.sh` retain the previous behavior when no wizard profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work. Container publication is disabled. CI builds, checks and deploys the CMS, but it does not log in to a registry or upload container images. ### Diagnose an update that is not visible ```bash git rev-parse HEAD docker inspect --format '{{.Image}}' epicnext-cms docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms curl -fsS http://127.0.0.1:3002/api/health curl -fsS https://your-hotel.example/api/health ``` Both HTTP responses must report the expected `release`. `unknown` means the image was built without a commit ID. If the local endpoint is current but the public one is old, check nginx's upstream, other CMS processes and proxy/CDN caching. Health responses must not be cached. The release is compiled into Next.js and cannot be changed simply by injecting a new variable into an old container. ### Docker commands | Command | Purpose | | --- | --- | | `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify | | `docker compose logs -f cms` | View CMS logs | | `docker compose exec cms sh` | Open a shell in the running CMS | | `docker compose restart cms` | Restart the existing release | | `docker compose down` | Stop services; does not update code | | `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service | For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before `docker compose build cms`; deployment and migration verification remain your responsibility. Docker uses the committed pnpm lockfile and pinned Node version. The build cache can stay enabled: copying changed source invalidates the application build layer. Deleting all Docker cache is not an update mechanism. ### Volumes Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist: | Container Path | Host Path | Mode | Purpose | | -------------------------- | -------------------------- | ---- | ------------------------------------ | | `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets | | `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) | | `/app/storage` | `./storage` | rw | Uploaded media (persistent) | | `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)| **About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory. **The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly: | Component | Host path | How it's served | | -------------------- | ----------------------- | ---------------------------------------------------------------------- | | Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias | | Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) | | Camera uploads | `/var/www/Camera` | nginx `/camera/` alias | **Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host: ```bash sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage ``` If your host user UID differs, override the run user at build time (defaults to `www-data`, which already exists in the base image with UID/GID 33): ```bash docker compose build --build-arg RUN_USER=1000 ``` ### Networking The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), Valkey/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it. The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages. ### Health Check The container includes a health check that hits `/api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with: ```bash docker inspect --format='{{.State.Health.Status}}' epicnext-cms ``` ### MariaDB Turbo (bulk loads) `docker-compose.yml` ships an opt-in **`mariadb-turbo`** service — a dedicated MariaDB 11 container tuned for loading >50 MB JSON dumps (furnidata, external texts, etc.). It configures itself via mysqld flags on the `command:`, so no external `.cnf` file has to be mounted or kept in sync: ```bash pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo) ``` Key settings (see the `command:` block in `docker-compose.yml`): - `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling. - `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes. - `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`. - `net_read_timeout` / `net_write_timeout = 600`, `wait_timeout = 3600` — fixes the `drizzle-kit` **"Pulling schema from database..."** hang by never starving introspection/DDL sessions behind a long import. - `performance_schema = OFF` — saves ~1-2 GB RAM. The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bind-mount: shared-filesystem sync trashes InnoDB files the same way it corrupts pnpm's `node_modules`. Named volumes stay inside the container filesystem, so 50 MB of JSON writes never cross a host-sync boundary. > **Port note:** the service uses `network_mode: host` and binds `127.0.0.1:3306` — stop the host MariaDB first, otherwise the port collides with the standalone `.env` `DATABASE_URL` (`localhost:3306`). #### node_modules & host-sync corruption pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind. #### Loading a 50 MB JSON dump ```bash # Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes) pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json # Flat array of documents → generic JSON store pnpm db:bulk --file=/data/products.json --table=docs --category=furni # Key/value texts pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default ``` The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design. --- ## ORM Setup & Type Generation ### Drizzle ORM (Primary Data Layer) Drizzle ORM 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")); ``` **Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production. | Command | What it does | | ---------------------- | ---------------------------------------------------------------------- | | `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) | | `pnpm db:studio` | Open Drizzle Studio (dev only) | | `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft | | `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` | | `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB | > The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`. #### Bulk JSON storage (MariaDB) Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern: - The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`). - The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed. - Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer. Three tables are exported: | Table | Purpose | Unique key | | -------------------- | ---------------------------------------------- | --------------------------- | | `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` | | `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` | | `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` | The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags: | Flag | Default | Description | | -------------------- | ----------- | ------------------------------------------------- | | `--file=` | — (required)| JSON file (habbo furnidata_json or flat array) | | `--table=` | `furnidata` | One of `furnidata` \| `docs` \| `texts` | | `--source=` | `habbo` | `source` value for `furnidata` | | `--category=` | `default` | `category` value for `docs` / `texts` | | `--chunk-size=N` | `2000` | Rows per multi-row INSERT | | `--limit=N` | `0` | Stop after N rows (dry-test) | | `--truncate` | off | DELETE rows for this source/category first | The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending. --- ## Valkey (Caching, Rate Limiting, SSE) Valkey is a drop-in, Redis-compatible open-source in-memory datastore (successor to Redis OSS). The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-memory memory. ### Install (Ubuntu/Debian) ```bash # Option 1: Package repository (recommended) curl -fsSL https://packages.valkey.io/valkey-pgp-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/valkey-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/valkey-archive-keyring.gpg] https://packages.valkey.io/apt $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/valkey.list apt-get update && apt-get install -y valkey # Option 2: Direct .deb download curl -fsSL -o /tmp/valkey_amd64.deb \ https://github.com/valkey-io/valkey/releases/download/8.1.1/valkey_8.1.1-1_amd64.deb apt-get install -y /tmp/valkey_amd64.deb systemctl enable --now valkey ``` ### Configure Edit `/etc/valkey/valkey.conf`: ```ini bind 127.0.0.1 port 6379 maxmemory 2gb maxmemory-policy allkeys-lru ``` ### Point the CMS at it ```dotenv 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 ```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; 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:8082/; proxy_set_header Connection ""; proxy_http_version 1.1; add_header Cache-Control "public, max-age=300"; } location /health { access_log off; return 200 "OK"; add_header Content-Type text/plain; } } ``` > **`/imaging` trailing-slash gotcha:** `location /imaging` (no trailing slash) combined with `proxy_pass http://…8082/;` rewrites `/imaging/avatarimage` into `//avatarimage` and 404s. Always use `location /imaging/` + a trailing-slash `proxy_pass` so `/imaging/avatarimage` reaches the imager's `/avatarimage` route. --- ## Avatar Imaging Avatar images (profile figures, chat badges, forum avatars, admin previews) are rendered server-side by **Polaris-imager** ([duckietm/Polaris-imager](https://github.com/duckietm/Polaris-imager)) — a headless Nitro/Octane renderer that runs `@pixi/node` (real WebGL via `gl` + Xvfb) inside a Docker container named **`avatar-imaging-pixinode`**. It serves `GET /avatarimage?figure=…` on port **8082** and can emit PNG/APNG/GIF, gestures, actions, scenes and more. ### Request flow ``` browser → Cloudflare → (page rule /origin for /imaging/*) → host:8082 browser → nginx origin → location /imaging/ → 127.0.0.1:8082/avatarimage ``` URLs look like `https:///imaging/avatarimage?figure=hd-180-1.ch-210-66&img_format=png&size=l&direction=4`. The public `NEXT_PUBLIC_IMAGER_URL` points at that endpoint (see `src/lib/imager.ts`). Responses carry `Cache-Control: public, max-age=300`, so browsers and Cloudflare reuse a render for 5 minutes. ### Requirements (host) - Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables). - An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (`.nitro` assets) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`. ### Configuration — `/docker/Polaris-imager/.env` ```dotenv NITRO_GAMEDATA_URL=http://host.docker.internal:8081/gamedata/config NITRO_ASSET_URL=http://host.docker.internal:8081/gamedata/bundled AVATAR_IMAGING_FPS=12 AVATAR_IMAGING_MAX_FRAMES=60 AVATAR_IMAGING_HOST=0.0.0.0 AVATAR_IMAGING_PORT=8082 AVATAR_IMAGING_RATELIMIT_MAX=600 AVATAR_IMAGING_SCENE=1 AVATAR_IMAGING_WARDROBE=1 AVATAR_IMAGING_HABBO_FONTS=1 AVATAR_IMAGING_CHAT_BUBBLES=1 AVATAR_IMAGING_HOTEL_URL=https:// AVATAR_IMAGING_HOTEL_NAME= ``` ### Build & start The image is self-contained — the `Dockerfile` clones the pinned Nitro/Octane renderer and runs the full `yarn`/`vite` bundle at build time, then produces a slim Debian runtime with Xvfb + Mesa software rendering: ```bash cd /docker/Polaris-imager docker compose build docker compose up -d # container: avatar-imaging-pixinode docker logs -f avatar-imaging-pixinode # expect: "listening on http://0.0.0.0:8082" ``` The container runs `restart: unless-stopped` and ships a healthcheck that probes `/health` (reports `{ "ready": true }` once the headless renderer has booted). Verify a render: ```bash curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l' ``` A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which used to take the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`). ### Automatic upstream fallback Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph. ### Figure string validation (`src/app/api/imaging/avatar/route.ts`) The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens. --- ## Performance Tuning (production) The CMS shares its MySQL/MariaDB database with the emulator, so the hot wins come from caching, asset serving and database knob-tuning — not from a storage-engine migration. ### 1. gzip_static pre-compression of gamedata JSON The client downloads `FurnitureData.json` (~48 MB, one file per supported language) plus other large JSON from `/gamedata/`. nginx has `gzip` and `gzip_static on` (see `/etc/nginx/nginx.conf`), but with no pre-built `.gz` file it re-compressed the 48 MB **on every request** (~0.25–0.30 s of CPU per miss). `scripts/compress-gamedata.mjs` pre-compresses every JSON >1 MB in `/config` and `/bundled/config` once (gzip level 9): `FurnitureData*.json` go from ~48 MB to ~2.4 MB (~95 % smaller). It is idempotent — a file is only re-compressed when the source mtime is newer than its `.gz` (e.g. after a Studio catalog export). ```bash pnpm gamedata:compress # manual run ``` Once the `.gz` exists, nginx `gzip_static` serves it straight from disk with `Content-Encoding: gzip` and effectively zero CPU. **Measured result on the production origin: 0.25–0.30 s → ~0.005 s per file.** The production host runs the script on a cron so newly exported catalogs are compressed shortly after they land: ```bash crontab -e # */15 * * * * node /var/www/atom-nexst/scripts/compress-gamedata.mjs >> /var/www/atom-nexst/logs/gamedata-compress.log 2>&1 ``` ### 2. MariaDB tuning The whole `habbo` datadir is roughly 0.5 GB; the default InnoDB buffer pool (128 MB) pushed index/row reads to disk. The pool is raised to **4 GB** and the slow-query log is enabled (threshold 2 s), both live (`SET GLOBAL …`) and persisted for boot in `/etc/mysql/mariadb.conf.d/zz-cms-performance.cnf`: ```ini [mysqld] innodb_buffer_pool_size = 4G slow_query_log = 1 long_query_time = 2 slow_query_log_file = /var/log/mysql/mariadb-slow.log ``` The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, `connection_limit` in `DATABASE_URL`). For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-turbo`** variant is available — see [MariaDB Turbo](#mariadb-turbo-bulk-loads). ### 3. Asset caching headers | Path | Cache-Control | | ------------------------------ | -------------------------------------------------------------- | | `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` | | `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) | | `/imaging/*` | `public, max-age=300` | | `/camera/**` | `public, max-age=31536000, immutable` | | `/_next/static/**` | `public, max-age=31536000, immutable` | ### 4. Cache layers (Redis + CDN) The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (Valkey-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further. --- ## Production Deployment (PM2) ```bash pnpm build pm2 start pnpm --name "next" -- start pm2 save ``` Restart after updates: ```bash 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 db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` | | `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container | | `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) | | `pnpm analyze` | Build + open bundle analyzer | | `pnpm jobs:worker` | Start background task worker | | `pnpm biome:check` | Lint and format code | > 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 | | **Valkey Caching** | Caches API responses up to 30s in Valkey | | **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 | | **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU | | **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) │ ├── bulk-import-json.ts # 50 MB+ JSON importer (2000-row chunks, UPSERT) │ ├── jobs-worker.ts # Background task scheduler │ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB │ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static) ├── src/ │ ├── db/ │ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer) │ │ ├── schema-gamedata.ts # Large-JSON storage schema (furnidata/docs/texts) │ │ └── relations.ts # Drizzle relations │ ├── app/ # Next.js App Router (pages & API routes) │ ├── actions/ # Server Actions │ ├── 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 → Valkey) │ │ └── 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: ```bash 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`. ### Byparr (Cloudflare Bypass) Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically. ```bash docker compose up -d byparr ``` ```dotenv BYPARR_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 ```bash 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.