diff --git a/.dockerignore b/.dockerignore index aff52080..e38d7e3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,8 +7,12 @@ storage prod.log update.log .pm2 -.env +# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside +# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values. +# It is not copied into the runtime image (the runner stage copies only +# .next/standalone, public/, and .next/static). +# .env *.tsbuildinfo -# ~3GB client assets; mounted as a volume at runtime (see docker-compose.yml) +# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml) public/nitro-assets public/swf diff --git a/Dockerfile b/Dockerfile index 32b7ad5d..41aca3f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,63 +1,104 @@ # ============================================================================== -# EpicNext-CMS — Docker image (Node 26.8.1, pnpm 11.24.0, Next.js standalone) +# EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone) +# ============================================================================== +# Supports pnpm (default), npm, and yarn. The build stage detects which package +# manager lockfile is present and uses it automatically. # ============================================================================== # --- Builder stage --- FROM node:26.8.1-bookworm-slim AS builder -# pnpm is required and pinned in package.json (packageManager: pnpm@11.24.0). -# Node 26 does not bundle corepack anymore, so install pnpm via npm. -RUN npm install -g pnpm@11.24.0 +# Install all three package managers so the build can pick whichever lockfile exists. +RUN npm install -g pnpm@11.25.0 yarn WORKDIR /app -# First copy only the manifests so dependency layers are cached. -COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc ./ -RUN pnpm install --frozen-lockfile --ignore-scripts +# First copy only the manifests so dependency layers are cached when using pnpm. +# For npm/yarn the full context is copied below before install. +COPY package.json pnpm-workspace.yaml .npmrc ./ -# Copy the rest of the source. +# Copy the rest of the source (brings in whichever lockfile your project uses). COPY . . -# Build the production bundle. -ENV NODE_ENV=production -RUN pnpm build +# --- Detect package manager & install dependencies --- +# Priority: pnpm > yarn > npm +# Build arg lets the user force a manager; otherwise it is auto-detected. +ARG PACKAGE_MANAGER= -# Copy runtime dependencies (node_modules) needed by standalone output. -RUN pnpm prune --prod +RUN if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \ + echo ">> Using pnpm" && \ + pnpm install --frozen-lockfile --ignore-scripts; \ + elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ + echo ">> Using yarn" && \ + yarn install --frozen-lockfile --ignore-scripts; \ + elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ + echo ">> Using npm" && \ + npm ci --ignore-scripts; \ + else \ + echo "!! No lockfile found — falling back to npm install" && \ + npm install --ignore-scripts; \ + fi + +# Build the production bundle. +# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no +# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time +# values (HOTEL_NAME, DATABASE_URL, AUTH_SECRET, ...) at build time. +ENV NODE_ENV=production +RUN if [ -f .env ]; then set -a && . ./.env && set +a; fi && \ + if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ + yarn build; \ + elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ + npm run build; \ + else \ + pnpm build; \ + fi + +# Prune dev dependencies for the runtime image. +RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ + yarn install --production --ignore-scripts && rm -rf node_modules/.cache; \ + elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ + npm prune --production; \ + else \ + pnpm prune --prod; \ + fi # --- Runtime stage --- FROM node:26.8.1-bookworm-slim AS runner +# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by +# the host's www-data user, UID/GID 33). The node base image already ships a +# www-data user with UID/GID 33, which matches that ownership — so we run as +# www-data and can write to the shared gamedata directory. If your host owner +# differs, override via --build-arg RUN_USER (e.g. --build-arg RUN_USER=1000). +ARG RUN_USER=www-data + ENV NODE_ENV=production ENV PORT=3002 ENV HOSTNAME=0.0.0.0 -# Occupied base port on the host; keep PM2-style default. EXPOSE 3002 -# Non-root user for security. -RUN groupadd --system --gid 1001 nodejs \ - && useradd --system --uid 1001 --gid nodejs nextjs - WORKDIR /app # Storage directory for runtime uploaded media (persistent volume). -# nitro/swf client assets (~3GB) are mounted here as volumes at runtime. +# Also create the hardcoded gamedata mount point (/var/www/Gamedata is +# bind-mounted at runtime so the CMS can read + write imported assets there). RUN mkdir -p /app/storage \ /app/public/nitro-assets \ /app/public/swf \ - && chown -R nextjs:nodejs /app + /var/www/Gamedata \ + && chown -R ${RUN_USER} /app /var/www/Gamedata # Copy standalone Next.js output (includes a minimal node_modules). -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=${RUN_USER} /app/.next/standalone ./ # Copy static assets (public files served directly). -COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=${RUN_USER} /app/public ./public # Copy the server-side static build output. -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=${RUN_USER} /app/.next/static ./.next/static # Client assets + runtime uploads live outside the image (mounted volumes). VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"] -USER nextjs +USER ${RUN_USER} CMD ["node", "server.js"] diff --git a/README.md b/README.md index 45462106..789d71f6 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou | Component | Version | Notes | | --------------- | -------------- | ---------------------------------------- | -| Node.js | 26.7.0 | Current release pinned in `.nvmrc` | -| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) | +| 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 | -| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) | -| Java | 17+ | Required only if building the emulator | -| Maven | 3.9+ | Required only if building the emulator | +| Docker | 24+ | Optional — for containerized deployment | +| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE | --- @@ -23,12 +24,28 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou ### 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: @@ -39,7 +56,7 @@ CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4 The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it. -> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/` (`pnpm db:migrate`). +> **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 @@ -53,14 +70,168 @@ Edit `.env` with at minimum: DATABASE_URL=mysql://user:password@127.0.0.1:3306/epicnext_cms AUTH_SECRET= HOTEL_NAME=YourHotel -APP_URL=http://localhost:3000 +APP_URL=http://localhost:3002 ``` See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.). -### 4. ORM Setup & Type Generation +### 4. Run CMS Migrations -#### Drizzle ORM (Primary Data Layer) +```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. 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](#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 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. + +### 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) | + +### 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: + +```bash +sudo chown -R 33:33 ./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`), 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: + +```bash +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`: @@ -76,57 +247,20 @@ const found = await db.select() **Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production. -| Command | What it does | -| ------- | ------------ | -| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) | -| `pnpm db:studio` | Open Drizzle Studio (dev only) | -| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft | -| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema names + live DB | +| 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`. -Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.ts` for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time. - -### 5. Run CMS Migrations - -```bash -pnpm db:migrate -``` - -Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched. - -Check migration status: - -```bash -pnpm db:migrate:status -``` - -### 6. Build & Start - -```bash -# Development (hot reload) -pnpm dev - -# Production -pnpm build && pnpm start -``` - -Open `http://localhost:3000` in your browser. - -### 7. 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**. - --- -## DragonflyDB (caching, rate limiting, SSE) +## DragonflyDB (Caching, Rate Limiting, SSE) -DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it -via `REDIS_URL` using the `ioredis` client, so no application code changes are needed — -every Redis command (`PING`, `GET`, `SETEX`, `DEL`, `INCR`, `PEXPIRE`, `PTTL`) works -unchanged. It is optional: without it the CMS falls back to in-process memory. +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) @@ -137,10 +271,10 @@ apt-get install -y /tmp/dragonfly_amd64.deb systemctl enable --now dragonfly ``` -This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`. - ### Configure +Edit `/etc/dragonfly/dragonfly.conf`: + ```ini --bind=127.0.0.1 --port=6379 @@ -148,260 +282,91 @@ This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly --version_check=false ``` -- `--bind=127.0.0.1` keeps it private on the machine (matches `REDIS_URL=redis://127.0.0.1:6379`). -- `--port=6379` is the default Redis port, so `.env` stays unchanged. -- `--maxmemory` must be at least `0.25GiB` per CPU thread (e.g. `2gb` on a 6-thread server). -- `--version_check=false` disables the periodic outbound update check. -- Snapshots are written to `--dir` (`/var/lib/dragonfly/dump-*.dfs`). - ### Point the CMS at it ```dotenv REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2 ``` -### Useful commands +Verify via `/api/health` — it should report `"redis": true`. -```bash -redis-cli PING # → PONG -redis-cli FLUSHALL # clear the cache -systemctl status dragonfly # service health -``` - -Verify everything is wired up via the health endpoint: -`/api/health` should report `"redis": true`. The ioredis client automatically reconnects -after a DragonflyDB restart. - -> **Note:** if an old Redis install still occupies port 6379, stop it first: -> `systemctl disable --now redis-server`. +--- ## Nginx Configuration -The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration. +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) -- Next.js running on `127.0.0.1:3000` (default) or your configured port -- Habbo imager (optional) running on `127.0.0.1:3030` +- CMS running on `127.0.0.1:3002` ### Reference Configuration -Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`: - ```nginx -# ========================================== -# GLOBAL SETTINGS -# ========================================== -server_tokens off; -gzip on; -gzip_vary on; -gzip_proxied off; -gzip_comp_level 6; -gzip_min_length 256; -gzip_types text/plain text/css text/javascript application/json - application/javascript application/xml application/xml+rss - image/svg+xml font/opentype font/ttf font/woff font/woff2; +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; -# ========================================== -# REDIRECT HTTP → HTTPS -# ========================================== server { listen 80; - listen [::]:80; - server_name yourdomain.com www.yourdomain.com; - - location /.well-known/acme-challenge/ { - root /var/www/epicnext/public; - } - - location / { - return 301 https://$host$request_uri; - } + server_name yourdomain.com; + return 301 https://$host$request_uri; } -# ========================================== -# MAIN HTTPS SERVER -# ========================================== server { listen 443 ssl; - listen [::]:443 ssl; http2 on; - server_name yourdomain.com www.yourdomain.com; + server_name yourdomain.com; - root /var/www/epicnext/public; - index index.html; - - # SSL Certificates ssl_certificate /etc/ssl/cert.pem; ssl_certificate_key /etc/ssl/key.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers off; - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 1d; - ssl_session_tickets off; - # Security Headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; client_max_body_size 20m; - client_body_timeout 30s; - client_header_timeout 10s; - keepalive_timeout 15s; - send_timeout 10s; - # Shared Proxy Settings proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffers 16 16k; - proxy_buffer_size 32k; - # ------------------------------------------ - # Static Files - # ------------------------------------------ - location ^~ /nitro-client/ { - alias /var/www/Nitro-V3/dist/; - expires 7d; - add_header Cache-Control "public"; - access_log off; - } - - location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; } - location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; } - - # ------------------------------------------ - # Next.js Assets (immutable, long cache) - # ------------------------------------------ - location /_next/static/ { - proxy_pass http://127.0.0.1:3000; - add_header Cache-Control "public, max-age=31536000, immutable"; - } - - location /_next/data/ { - proxy_pass http://127.0.0.1:3000; - add_header Cache-Control "public, max-age=0, must-revalidate"; - } - - # ------------------------------------------ - # API Routes (never cached) - # ------------------------------------------ - location /api/ { - proxy_pass http://127.0.0.1:3000; - add_header Cache-Control "no-cache, no-store, must-revalidate"; - } - - # ------------------------------------------ - # Habbo Imager (optional) - # ------------------------------------------ - # Proxies to a Docker container that renders Habbo avatars. - # The imager caches renders to disk, so a long s-maxage is safe. - location /imaging { - proxy_pass http://127.0.0.1:3030; - add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always; - } - - # ------------------------------------------ - # WebSocket (Radio / SSE) - # ------------------------------------------ - location /ws { - proxy_pass http://127.0.0.1:3030; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400; - } - - # ------------------------------------------ - # Main Page Proxy (with HTML caching) - # ------------------------------------------ - # The CMS middleware sets: - # Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous) - # Cache-Control: private, no-store (authenticated) - # - # nginx caches anonymous responses and serves them directly, bypassing - # the Node.js process entirely. Authenticated responses are never cached. - # - # proxy_cache_valid: cache 200 responses for 60 seconds - # proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc, - # next-router-*, Accept-Encoding) that would fragment the cache key. location / { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3002; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; - proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; - proxy_http_version 1.1; - proxy_buffering on; - proxy_cache html_cache; proxy_cache_valid 200 60s; - proxy_cache_key "$host$request_uri"; proxy_ignore_headers Vary; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; - proxy_cache_background_update on; - proxy_cache_revalidate on; add_header X-Cache-Status $upstream_cache_status always; } - # ------------------------------------------ - # Health Check - # ------------------------------------------ + location /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; } - - # Block hidden files - location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) { - deny all; - access_log off; - log_not_found off; - } } ``` -### HTML Caching - -The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means: - -- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely. -- **Authenticated visitors** always hit Node.js (personalized content). -- The cache is **auto-invalidated** after 60 seconds and revalidates in the background. - -The proxy cache zone is defined in the `http` block (above any `server` block): - -```nginx -proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off; -``` - -Verify caching works by checking the `X-Cache-Status` response header: - -```bash -# First request (MISS = fetched from Node.js, now cached) -curl -sI https://yourdomain.com/ | grep X-Cache-Status -# → X-Cache-Status: MISS - -# Second request (HIT = served from nginx cache) -curl -sI https://yourdomain.com/ | grep X-Cache-Status -# → X-Cache-Status: HIT -``` - -### Key Points - -| Setting | Value | Why | -| ------- | ----- | --- | -| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer | -| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses | -| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache | -| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance | -| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages | - --- ## Production Deployment (PM2) @@ -421,30 +386,28 @@ pnpm build pm2 restart next ``` -The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via `/api/media/*` and survive rebuilds. - --- ## Scripts -| Command | Description | -| ---------------------- | -------------------------------------------------- | -| `pnpm dev` | Start development server (hot reload) | -| `pnpm build` | Production build | -| `pnpm start` | Start production server | -| `pnpm typecheck` | Run TypeScript type checking | -| `pnpm test` | Run all tests (Vitest) | -| `pnpm db:migrate` | Apply pending SQL migrations | -| `pnpm db:migrate:status` | Show migration status | +| Command | Description | +| ------------------------- | -------------------------------------------------- | +| `pnpm dev` | Start development server (hot reload) | +| `pnpm build` | Production build | +| `pnpm start` | Start production server | +| `pnpm typecheck` | Run TypeScript type checking | +| `pnpm test` | Run all tests (Vitest) | +| `pnpm db:migrate` | Apply pending SQL migrations | +| `pnpm db:migrate:status` | Show migration status | | `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB | -| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` | -| `pnpm db:studio` | Drizzle Studio (dev) | -| `pnpm db:introspect` | drizzle-kit introspect (draft) | -| `pnpm analyze` | Build + open bundle analyzer | -| `pnpm jobs:worker` | Start background task worker (systemd / PM2) | -| `pnpm biome:check` | Lint and format code | +| `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 | -**Drizzle Kit notes:** `db:generate` / `db:introspect` write drafts only. Reviewed SQL must be copied into `drizzle/migrations/` as a new numbered file, then applied with `pnpm db:migrate`. Never run `drizzle-kit push` or `drizzle-kit migrate` against production. +> Replace `pnpm` with `npm run` or `yarn` for other package managers. --- @@ -454,16 +417,13 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse | ------------------------------ | -------------------------------------------------------------- | | **React Compiler** | Automatic memoization — reduces unnecessary re-renders | | **View Transitions API** | Native browser transitions between page navigations | -| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) | -| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) | -| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB | -| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes | +| **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 | -| **CSS-based Animations** | `input-glow`, `btn-shine`, `Reveal` scroll animations, floating orbs | | **Compression** | Gzip compression enabled on all responses | -| **Stale Times** | Optimized router cache (30s dynamic, 180s static) | | **PWA** | Service worker with skip-waiting, stale CSS chunk recovery | | **Biome** | Fast linting and formatting (replaces ESLint + Prettier) | @@ -478,7 +438,6 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse ├── scripts/ │ ├── apply-migrations.ts # SQL migration runner (apply + status) │ ├── jobs-worker.ts # Background task scheduler -│ ├── merge-config.cjs # Utility: merge split config files │ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB ├── src/ │ ├── db/ @@ -491,38 +450,20 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse │ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets │ │ ├── services/ # RCON, email, currency, PayPal, alerts │ │ ├── db.ts # Drizzle connection singleton (runtime) -│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers │ │ ├── redis.ts # Cache client (ioredis → DragonflyDB) -│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB) -│ │ ├── cache.ts # In-memory cache fallback -│ │ ├── motion.ts # Framer Motion animation variants -│ │ └── use-event-source.ts # React hook for SSE subscriptions +│ │ └── 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 -│ └── scripts/ # Client-side scripts (theme-init.js) -├── update-Nitrov3.sh # Emulator & Nitro updater utility +│ ├── 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 ``` -### Database Ownership - -| Component | Type | Migrations | -| ------------------------------------------------- | ----------------------- | ----------------------------------- | -| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only | -| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` | -| Migration tracking | `cms_migrations` table | Auto-created by migration runner | - -### ORM Architecture - -- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. Schema lives in `src/db/schema.ts`. -- **Schema regeneration**: `pnpm db:schema:generate` reuses field/table names from the previous `src/db/schema.ts` and refreshes column types from the live DB. -- **Drizzle Kit**: studio / generate / introspect for local tooling; CMS apply path remains `pnpm db:migrate`. - -Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`. - --- ## Optional Integrations @@ -549,97 +490,35 @@ The radio player uses SSE for real-time updates (10s interval, no polling). Run as a persistent process: ```bash -pnpm jobs:worker +pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker ``` -Scheduled tasks: -- **Daily 03:00** — Emulator JAR backup (requires `EMULATOR_JAR_PATH` + `EMULATOR_BACKUP_DIR`) -- **Daily 04:00** — Cleanup login logs (>30 days) and expired password reset tokens (>7 days) - -### CAPTCHA - -Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings. - -### Theming - -12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`). - - -## Furniture Import with Auto-Translation - -The CMS now automatically translates furniture names and descriptions to **13 languages** on every import: - -- **Native languages** (via official Habbo gamedata): Dutch (`nl`), English (`en`), German (`de`), French (`fr`), Spanish (`es`), Turkish (`tr`), Italian (`it`) -- **Additional languages** (via LibreTranslate self-hosted Docker at `127.0.0.1:5000`): Portuguese (`pt`), Finnish (`fi`), Polish (`pl`), Russian (`ru`), Arabic (`ar`), Japanese (`ja`) - -### How it works - -1. **Per-import translation** — Every import route (`/admin/import/furni`, batch, batch-regen, clone) triggers translation automatically after the furniture data is imported. - -2. **Translation flow**: - - The original (usually English) `classname` and `description` are sent to LibreTranslate - - Official Habbo translations take priority for the 7 supported languages - - Custom/new meubels fall back to LibreTranslate - - Post-processing rules force Habbo‑style terminology (e.g. `bank` → `Bank`, `tafel` → `Tafel`, `stoel` → `Stoel`) - -3. **No manual steps needed** — The translation happens as part of the import API calls. New custom furniture added via import immediately appears with translated names in the catalog for all 13 languages. - -4. **CLI scripts** (optional): - - `pnpm translate:full` — translate all furniture data fully (uses `--full` flag) - - `pnpm translate:limited [--limit N] [--concurrency N]` — translate limited amount per language - - `pnpm build:languages [--full] [--limit N] [--concurrency N]` — build the full localized furnidata JSON files - -### Requirements - -- LibreTranslate Docker container running at `http://127.0.0.1:5000` (or configure a different URL in the environment) -- The `LIBRETRANSLATE_URL` environment variable can override the default if needed -- For the 7 official languages, no external service is needed — they use the built‑in Habbo gamedata - -### Environment variables - -```dotenv -# LibreTranslate endpoint (default: http://127.0.0.1:5000) -LIBRETRANSLATE_URL=http://127.0.0.1:5000 -``` - -### Example import flow - -```bash -# Single furniture import (triggers translation) -POST /admin/import/furni - -# Batch import (triggers translation) -POST /admin/import/furni/batch - -# Regenerate localized files -POST /admin/import/furni/batch-regen - -# Clone import (triggers translation) -POST /admin/import/clone/batch -``` ### AI Content Moderation Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`. ### FlareSolverr (Cloudflare Bypass) -Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically. - -**Setup:** +Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically. ```bash docker compose up -d flaresolverr ``` -Set the URL in `.env`: - -``` +```dotenv FLARESOLVERR_URL=http://localhost:8191 ``` -When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning. +### Furniture Import with Auto-Translation -See `docker-compose.yml` for the FlareSolverr service definition. +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`). --- @@ -656,11 +535,10 @@ 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, minimal client boundaries) -3. Use the `src/lib/motion.ts` animation variants for consistent animations -4. SQL migrations in `drizzle/migrations/` must be idempotent -5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation) -6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable +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 --- diff --git a/docker-compose.yml b/docker-compose.yml index 80010c5b..fc560f1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,63 @@ -version: "3.8" - services: cms: build: context: . dockerfile: Dockerfile + # The host disables Docker iptables (daemon.json: "iptables": false), so + # build containers on the bridge network have no outbound NAT/DNS. Build on + # the host network instead so pnpm/npm/yarn can reach the registry. + network: host + args: + # Run as the host owner (www-data = UID/GID 33, already present in the + # node base image) of /var/www/Gamedata so the container can read + write + # the shared gamedata directory. + RUN_USER: "www-data" container_name: epicnext-cms - ports: - # Host port -> container port (container always listens on 3002) - - "3002:3002" + # Runs on the host network so existing 127.0.0.1 refs in .env keep working: + # MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001), + # and the imaging renderer (8082). The container then listens directly on the + # host's 3002 (the same port the current host-side CMS uses). + # NOTE: stop the host CMS (next-server on 3002) first, otherwise the port is taken. + network_mode: host restart: unless-stopped env_file: - .env volumes: - # ~3GB client assets live on the host; mount them read-only into the container - - ./public/nitro-assets:/app/public/nitro-assets:ro - - ./public/swf:/app/public/swf:ro - # Runtime uploaded media (persistent on the host) + # ── Write targets (runtime imports/uploads, persistent on the host) ── + # The CMS writes imported furni/figures/pets/effects here (see + # src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by + # UID/GID 33 (www-data) on the host so the container user can write to them: + # sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage + - ./public/nitro-assets:/app/public/nitro-assets + - ./public/swf:/app/public/swf + # Runtime uploaded media (persistent on the host). - ./storage:/app/storage + + # ── Shared gamedata (absolute path the CMS hardcodes & writes to) ── + # src/lib/services/furni-asset-dirs.ts: `DEFAULT_GAMEDATA_ROOT = + # /var/www/Gamedata`. nginx on the host also serves /gamedata/ from this + # same directory, so mount it into the container at the same absolute path. + # Must be RW so furniture/badge imports can write mirrors to it. + - /var/www/Gamedata:/var/www/Gamedata + + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + mem_limit: 2g + + # ── Opt-in: Octane-Renderer (Habbo avatar imager) ── + # Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders + # avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access. + # Disabled by default — uncomment to run the renderer as a container. + # imager: + # build: + # context: /var/www/Octane-Renderer + # container_name: epicnext-octane-renderer + # ports: + # - "3030:3030" + # restart: unless-stopped + # volumes: + # - /var/www/Gamedata:/var/www/Gamedata