- Update all DragonflyDB references to Valkey in README and docker-compose.yml - Update install instructions to use Valkey package repository and .deb download - Update configuration paths from /etc/dragonfly/ to /etc/valkey/ - Update version requirement to Valkey 8.x+ (successor to Redis OSS)
931 lines
45 KiB
Markdown
931 lines
45 KiB
Markdown
# 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:[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, 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. If publication for the new commit is still running,
|
||
it stops before replacing the current container; run the same command after CI
|
||
succeeds. 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.
|
||
|
||
To publish from Gitea:
|
||
|
||
Gitea packages belong to an account or organization, independently of repository
|
||
permissions. Publication defaults to the lowercase `CONTAINER_REGISTRY_USER`
|
||
namespace, so a Simo token publishes `simo/epicnext-cms` even though the Git
|
||
repository belongs to remco. Set the Actions variable
|
||
`CONTAINER_REGISTRY_NAMESPACE` only to override this (for example, an organization
|
||
where the token account has package write access). Keep the login username and
|
||
token from the same account. Changing namespace also changes the image URL used
|
||
by installations; existing remco image tags are not moved automatically.
|
||
|
||
1. In the repository's Actions secrets, configure `CONTAINER_REGISTRY_USER` and
|
||
`CONTAINER_REGISTRY_TOKEN`. Use a Gitea access token with package read/write
|
||
permission belonging to the login account. For the Simo token, set
|
||
`CONTAINER_REGISTRY_USER=Simo`; the default package namespace will be `simo`.
|
||
2. Every push to `main` or `master` automatically builds and publishes the images
|
||
after the CI checks and production deployment succeed. Pull requests do not
|
||
publish images. The publication job builds from committed source only and checks
|
||
the same application image with two runtime configurations before pushing.
|
||
Missing registry secrets fail the publication job explicitly; they do not undo
|
||
an already successful production deployment. No `latest` tag is moved.
|
||
**Publish portable container** remains available for manual retries on the
|
||
commit/branch to distribute, without redeploying production.
|
||
3. The images are `<gitea-host>/<owner>/<repository-lowercase>:<full-commit>` and
|
||
`:<full-commit>-migrations`. Only the application image runs the website; the
|
||
migrations image is used temporarily for the matching database migrations.
|
||
|
||
Publication uses checksum-pinned regctl v0.11.6 with 8 MiB blob requests to
|
||
avoid monolithic layer uploads exceeding reverse-proxy limits. Both images are
|
||
exported and uploaded sequentially; temporary archives and credentials are removed
|
||
on exit. The runner needs curl, sha256sum and temporary disk space for one Docker
|
||
image archive plus its extracted OCI layout. The remote image config digest is checked against the locally normalized archive
|
||
after each upload. A proxy must still allow the OCI registry PATCH/PUT endpoints.
|
||
|
||
For this repository the image base is
|
||
`gitlab.epicnabbo.nl/simo/epicnext-cms`. Package access is controlled by Gitea.
|
||
For private packages, run `docker login gitlab.epicnabbo.nl` on the installation
|
||
with a token that can read packages. Then update with:
|
||
|
||
```bash
|
||
CMS_IMAGE_REPOSITORY=gitlab.epicnabbo.nl/simo/epicnext-cms \
|
||
CMS_PUBLIC_URL=https://your-hotel.example \
|
||
bash scripts/docker-update.sh
|
||
```
|
||
|
||
The updater pulls the configured Git upstream and requires both images for that
|
||
exact commit. A missing image or failed login stops before replacing the running
|
||
CMS. Local builds remain the default when `CMS_IMAGE_REPOSITORY` is unset. Both
|
||
paths retain the existing image/HTTP checks and automatic application rollback.
|
||
Migration secrets are mounted read-only for the temporary migration container;
|
||
they are never copied into its image. Registry images currently target the Linux
|
||
architecture of the self-hosted build runner; this is not a multi-architecture release.
|
||
|
||
The portability gate checks release identity, runtime avatar/badge routing and
|
||
absence of installation environment files in the application image. It uses an
|
||
unreachable fixture database and does not replace a full live database/site smoke
|
||
test. See `scripts/verify-portable-image.mjs`. Production deployment remains verified
|
||
separately by the existing CI workflow.
|
||
|
||
References: [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
||
and [Next.js runtime environment variables](https://nextjs.org/docs/app/guides/self-hosting).
|
||
|
||
### 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=<path>` | — (required)| JSON file (habbo furnidata_json or flat array) |
|
||
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
|
||
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
|
||
| `--category=<x>` | `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://<hotel>/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://<hotel>
|
||
AVATAR_IMAGING_HOTEL_NAME=<hotel>
|
||
```
|
||
|
||
### 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 takes 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`).
|
||
|
||
### 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 `<Gamedata>/config` and `<Gamedata>/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.
|