openhands 121dda7249
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s
fix: clone sources blocked by content-type check and TLS fingerprint
- fetchSourceFurnidata now parses JSON regardless of content-type,
  so GitHub raw mirrors (Kyzegs, sphynxkitten) that serve JSON as
  text/plain work again instead of forcing a FlareSolverr round-trip.
- Add curl subprocess fallback (curl-fetch.ts) for CDNs that block
  Node's fetch by TLS fingerprint (Leet.city) — used for furnidata,
  nitro bundle and icon downloads before giving up.
- Merge verified default clone source presets with stored user sources
  so the admin always offers many working sources.
2026-08-19 19:39:32 +02:00
2026-07-30 17:51:24 +02:00
2026-07-13 21:57:41 +02:00
2026-08-12 14:34:29 +02:00

EpicNext-CMS v1.0.1

A modern, high-performance content management system for Habbo hotel emulators, built on Next.js 16 (App Router) with Drizzle ORM and React 19. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.

Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.


System Requirements

Component Version Notes
Node.js >= 22 Required by Next.js 16
pnpm >= 10.33.4 Package manager (npm/yarn not supported)
MySQL / MariaDB 8.0+ / 10.6+ Shared with the emulator
DragonflyDB 1.x+ Optional — caching, rate limiting, SSE (Redis-protocol compatible)
Java 17+ Required only if building the emulator
Maven 3.9+ Required only if building the emulator

Quick Start

1. Clone & Install

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

2. Database Setup

The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:

CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

The CMS reads emulator-owned tables (users, items, rooms, bans, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in src/db/schema.ts is generated from the existing database structure and does not modify it.

Note: The CMS does not own the emulator schema — it maps to those tables via Drizzle. Never run drizzle-kit push / migrate against the shared DB. CMS-owned tables (website_*, radio_*, etc.) are created via idempotent SQL in drizzle/migrations/ (pnpm db:migrate).

3. Configure Environment

cp .env.example .env

Edit .env with at minimum:

DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
AUTH_SECRET=<random 32+ character string>
HOTEL_NAME=YourHotel
APP_URL=http://localhost:3000

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

4. ORM Setup & Type Generation

Drizzle ORM (Primary Data Layer)

Drizzle ORM is the runtime data layer. The connection is a singleton in src/lib/db.ts:

import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm/expressions";

const found = await db.select()
  .from(users)
  .where(eq(users.username, "hello"));

Drizzle CLI (drizzle-kit) is used for local development tasks — it is a devDependency and is never bundled in production.

Command What it does
pnpm db:generate Draft SQL from Drizzle schema into drizzle/drafts/ (review + copy into drizzle/migrations/)
pnpm db:studio Open Drizzle Studio (dev only)
pnpm db:introspect Reverse-engineer an existing DB into a Drizzle schema draft
pnpm db:schema:generate Regen committed src/db/schema.ts from previous schema names + live DB

The CMS does not use drizzle-kit 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

pnpm db:migrate

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

Check migration status:

pnpm db:migrate:status

6. Build & Start

# Development (hot reload)
pnpm dev

# Production
pnpm build && pnpm start

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

7. First Login

  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 is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via REDIS_URL using the ioredis client, so no application code changes are needed — every Redis command (PING, GET, SETEX, DEL, INCR, PEXPIRE, PTTL) works unchanged. It is optional: without it the CMS falls back to in-process memory.

Install (Ubuntu/Debian)

curl -fsSL -o /tmp/dragonfly_amd64.deb \
  https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
apt-get install -y /tmp/dragonfly_amd64.deb
systemctl enable --now dragonfly

This installs a dragonfly systemd service and a config file at /etc/dragonfly/dragonfly.conf.

Configure

--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false
  • --bind=127.0.0.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

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

Useful commands

redis-cli PING                 # → PONG
redis-cli FLUSHALL             # clear the cache
systemctl status dragonfly     # service health

Verify everything is wired up via the health endpoint: /api/health should report "redis": true. The ioredis client automatically reconnects after a DragonflyDB restart.

Note: if an old Redis install still occupies port 6379, stop it first: systemctl disable --now redis-server.

Nginx Configuration

The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.

Prerequisites

  • SSL certificates in /etc/ssl/cert.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

Reference Configuration

Create a file in /etc/nginx/sites-available/epicnext and symlink it to sites-enabled:

# ==========================================
# GLOBAL SETTINGS
# ==========================================
server_tokens off;
gzip on;
gzip_vary on;
gzip_proxied off;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css text/javascript application/json
           application/javascript application/xml application/xml+rss
           image/svg+xml font/opentype font/ttf font/woff font/woff2;

# ==========================================
# REDIRECT HTTP → HTTPS
# ==========================================
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/epicnext/public;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# ==========================================
# MAIN HTTPS SERVER
# ==========================================
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name yourdomain.com www.yourdomain.com;

    root /var/www/epicnext/public;
    index index.html;

    # SSL Certificates
    ssl_certificate /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 20m;
    client_body_timeout 30s;
    client_header_timeout 10s;
    keepalive_timeout 15s;
    send_timeout 10s;

    # Shared Proxy Settings
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffers 16 16k;
    proxy_buffer_size 32k;

    # ------------------------------------------
    # Static Files
    # ------------------------------------------
    location ^~ /nitro-client/ {
        alias /var/www/Nitro-V3/dist/;
        expires 7d;
        add_header Cache-Control "public";
        access_log off;
    }

    location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
    location = /robots.txt  { expires 1d; access_log off; log_not_found off; try_files $uri =404; }

    # ------------------------------------------
    # Next.js Assets (immutable, long cache)
    # ------------------------------------------
    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location /_next/data/ {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "public, max-age=0, must-revalidate";
    }

    # ------------------------------------------
    # API Routes (never cached)
    # ------------------------------------------
    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # ------------------------------------------
    # Habbo Imager (optional)
    # ------------------------------------------
    # Proxies to a Docker container that renders Habbo avatars.
    # The imager caches renders to disk, so a long s-maxage is safe.
    location /imaging {
        proxy_pass http://127.0.0.1:3030;
        add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
    }

    # ------------------------------------------
    # WebSocket (Radio / SSE)
    # ------------------------------------------
    location /ws {
        proxy_pass http://127.0.0.1:3030;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400;
    }

    # ------------------------------------------
    # Main Page Proxy (with HTML caching)
    # ------------------------------------------
    # The CMS middleware sets:
    #   Cache-Control: public, s-maxage=300, stale-while-revalidate=300  (anonymous)
    #   Cache-Control: private, no-store                                  (authenticated)
    #
    # nginx caches anonymous responses and serves them directly, bypassing
    # the Node.js process entirely. Authenticated responses are never cached.
    #
    # proxy_cache_valid: cache 200 responses for 60 seconds
    # proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
    #   next-router-*, Accept-Encoding) that would fragment the cache key.
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
        proxy_http_version 1.1;
        proxy_buffering on;

        proxy_cache html_cache;
        proxy_cache_valid 200 60s;
        proxy_cache_key "$host$request_uri";
        proxy_ignore_headers Vary;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;
        proxy_cache_revalidate on;
        add_header X-Cache-Status $upstream_cache_status always;
    }

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

    # Block hidden files
    location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
        deny all;
        access_log off;
        log_not_found off;
    }
}

HTML Caching

The CMS uses an origin-level proxy cache for anonymous HTML pages. This means:

  • Anonymous visitors receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
  • Authenticated visitors always hit Node.js (personalized content).
  • The cache is auto-invalidated after 60 seconds and revalidates in the background.

The proxy cache zone is defined in the http block (above any server block):

proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;

Verify caching works by checking the X-Cache-Status response header:

# First request (MISS = fetched from Node.js, now cached)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: MISS

# Second request (HIT = served from nginx cache)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: HIT

Key Points

Setting Value Why
proxy_http_version 1.1 HTTP/1.1 to upstream Required for keep-alive and chunked transfer
proxy_buffering on Buffer upstream response Required for proxy_cache to work with chunked responses
proxy_ignore_headers Vary Ignore upstream Vary Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache
proxy_cache_valid 200 60s Cache 200s for 60s Balances freshness with performance
proxy_cache_use_stale Serve stale on error Keeps the site available during brief upstream outages

Production Deployment (PM2)

pnpm build
pm2 start pnpm --name "next" -- start
pm2 save

Restart after updates:

git pull
pnpm install
pnpm build
pm2 restart next

The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via /api/media/* and survive rebuilds.


Scripts

Command Description
pnpm dev Start development server (hot reload)
pnpm build Production build
pnpm start Start production server
pnpm typecheck Run TypeScript type checking
pnpm test Run all tests (Vitest)
pnpm db:migrate Apply pending SQL migrations
pnpm db:migrate:status Show migration status
pnpm db:schema:generate Regen src/db/schema.ts from prior schema + live DB
pnpm db:generate Draft SQL via drizzle-kit → drizzle/drafts/
pnpm db:studio Drizzle Studio (dev)
pnpm db:introspect drizzle-kit introspect (draft)
pnpm analyze Build + open bundle analyzer
pnpm jobs:worker Start background task worker (systemd / PM2)
pnpm biome:check Lint and format code

Drizzle Kit notes: db:generate / db:introspect write drafts only. Reviewed SQL must be copied into drizzle/migrations/ as a new numbered file, then applied with pnpm db:migrate. Never run drizzle-kit push or drizzle-kit migrate against production.


Performance Features

Feature Description
React Compiler Automatic memoization — reduces unnecessary re-renders
View Transitions API Native browser transitions between page navigations
Lenis Smooth Scroll Fluid, customizable scrolling (respects prefers-reduced-motion)
Server-Sent Events Real-time radio now-playing & listeners via SSE (no polling)
DragonflyDB Caching Caches API responses (home, radio config) up to 30s in DragonflyDB
Bundle Analyzer Run pnpm analyze to visualize and optimize bundle sizes
RCON (TCP Socket) Live commands to the emulator (credits, badges, kick, ban)
Streaming & Suspense Next.js App Router streaming for fast page loads
Automatic Image Optimization next/image with Sharp for resizing and WebP/AVIF
CSS-based Animations input-glow, btn-shine, Reveal scroll animations, floating orbs
Compression Gzip compression enabled on all responses
Stale Times Optimized router cache (30s dynamic, 180s static)
PWA Service worker with skip-waiting, stale CSS chunk recovery
Biome Fast linting and formatting (replaces ESLint + Prettier)

Architecture

├── drizzle/
│   ├── migrations/            # CMS SQL migrations (idempotent, tracked in cms_migrations)
│   └── drafts/                # drizzle-kit generate output (never auto-applied)
├── scripts/
│   ├── apply-migrations.ts    # SQL migration runner (apply + status)
│   ├── jobs-worker.ts         # Background task scheduler
│   ├── merge-config.cjs       # Utility: merge split config files
│   └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
├── src/
│   ├── db/
│   │   ├── schema.ts          # Drizzle ORM schema (committed — runtime data layer)
│   │   └── relations.ts       # Drizzle relations
│   ├── app/                   # Next.js App Router (pages & API routes)
│   ├── actions/               # Server Actions
│   ├── components/            # UI components
│   ├── lib/
│   │   ├── auth/              # NextAuth, password hashing, 2FA, SSO tickets
│   │   ├── services/          # RCON, email, currency, PayPal, alerts
│   │   ├── db.ts              # Drizzle connection singleton (runtime)
│   │   ├── cached-db.ts       # DragonflyDB-backed query cache helpers
│   │   ├── redis.ts           # Cache client (ioredis → DragonflyDB)
│   │   ├── redis-cache.ts     # Caching utility for API routes (uses DragonflyDB)
│   │   ├── cache.ts           # In-memory cache fallback
│   │   ├── motion.ts          # Framer Motion animation variants
│   │   └── use-event-source.ts # React hook for SSE subscriptions
│   ├── messages/              # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│   └── env.ts                 # Zod-validated environment schema
├── public/
│   ├── assets/                # Images, icons, fonts
│   └── scripts/               # Client-side scripts (theme-init.js)
├── update-Nitrov3.sh          # Emulator & Nitro updater utility
├── next.config.ts             # Next.js configuration
└── .env.example               # Environment template with all options

Database Ownership

Component Type Migrations
Emulator tables (users, items, rooms, etc.) Existing Polaris schema None — CMS reads/writes only
CMS tables (website_*, radio_*, etc.) CMS-owned drizzle/migrations/*.sql
Migration tracking cms_migrations table Auto-created by migration runner

ORM Architecture

  • Runtime (Drizzle ORM): @/lib/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

Emulator — RCON

For live actions (credits, badges, rank changes, kick, ban, hotel alerts). The emulator must be running with RCON enabled at RCON_HOST:RCON_PORT.

Client — Nitro

The /client page loads the Nitro client. Configure the client URL via Admin → CMS Settings (nitro_client_url). See update-Nitrov3.sh for deployment.

Radio

Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings:

  • radio_now_playing_api_url
  • radio_listeners_api_url

The radio player uses SSE for real-time updates (10s interval, no polling).

Background Jobs

Run as a persistent process:

pnpm jobs:worker

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

AI Content Moderation

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

FlareSolverr (Cloudflare Bypass)

Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically.

Setup:

docker compose up -d flaresolverr

Set the URL in .env:

FLARESOLVERR_URL=http://localhost:8191

When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning.

See docker-compose.yml for the FlareSolverr service definition.


Development

pnpm dev          # Start with hot reload
pnpm typecheck    # Type check all files
pnpm test         # Run test suite
pnpm analyze      # Build and analyze bundle sizes
pnpm biome:check  # Lint and format

Contributing

  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
  6. Avoid any — use eslint-disable or biome-ignore comments only when unavoidable

License

CC BY-NC-SA 4.0. See LICENSE for details.

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