Docker: full Dockerized deployment (volumes, host networking, multi-package-manager build)
- docker-compose.yml: network_mode host so 127.0.0.1 refs (.env) keep working; mounts /var/www/Gamedata + write volumes; /api/health healthcheck; mem_limit - Dockerfile: package-manager detection (pnpm/yarn/npm) + PACKAGE_MANAGER arg; runs as www-data (UID/GID 33) so gamedata is writable; .env loaded only for the build (no secrets baked in); build runs on the host network - .dockerignore: .env stays in the build context (needed for NEXT_PUBLIC_*) - README: Docker deployment section (paths, volumes, chown, migrations on host)
This commit is contained in:
1 parent
58c35a2920
commit
cb927db78d
4 files changed
+377
-411
No files matched your search
+6
-2
@@ -7,8 +7,12 @@ storage
|
|||||||
prod.log
|
prod.log
|
||||||
update.log
|
update.log
|
||||||
.pm2
|
.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
|
*.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/nitro-assets
|
||||||
public/swf
|
public/swf
|
||||||
+65
-24
@@ -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 ---
|
# --- Builder stage ---
|
||||||
FROM node:26.8.1-bookworm-slim AS builder
|
FROM node:26.8.1-bookworm-slim AS builder
|
||||||
|
|
||||||
# pnpm is required and pinned in package.json (packageManager: [email protected]).
|
# Install all three package managers so the build can pick whichever lockfile exists.
|
||||||
# Node 26 does not bundle corepack anymore, so install pnpm via npm.
|
RUN npm install -g pnpm@11.25.0 yarn
|
||||||
RUN npm install -g [email protected]
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# First copy only the manifests so dependency layers are cached.
|
# First copy only the manifests so dependency layers are cached when using pnpm.
|
||||||
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc ./
|
# For npm/yarn the full context is copied below before install.
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Build the production bundle.
|
# --- Detect package manager & install dependencies ---
|
||||||
ENV NODE_ENV=production
|
# Priority: pnpm > yarn > npm
|
||||||
RUN pnpm build
|
# 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 if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \
|
||||||
RUN pnpm prune --prod
|
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 ---
|
# --- Runtime stage ---
|
||||||
FROM node:26.8.1-bookworm-slim AS runner
|
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 NODE_ENV=production
|
||||||
ENV PORT=3002
|
ENV PORT=3002
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Occupied base port on the host; keep PM2-style default.
|
|
||||||
EXPOSE 3002
|
EXPOSE 3002
|
||||||
|
|
||||||
# Non-root user for security.
|
|
||||||
RUN groupadd --system --gid 1001 nodejs \
|
|
||||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Storage directory for runtime uploaded media (persistent volume).
|
# 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 \
|
RUN mkdir -p /app/storage \
|
||||||
/app/public/nitro-assets \
|
/app/public/nitro-assets \
|
||||||
/app/public/swf \
|
/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 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 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 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).
|
# Client assets + runtime uploads live outside the image (mounted volumes).
|
||||||
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
||||||
|
|
||||||
USER nextjs
|
USER ${RUN_USER}
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -10,12 +10,13 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
|
|
||||||
| Component | Version | Notes |
|
| Component | Version | Notes |
|
||||||
| --------------- | -------------- | ---------------------------------------- |
|
| --------------- | -------------- | ---------------------------------------- |
|
||||||
| Node.js | 26.7.0 | Current release pinned in `.nvmrc` |
|
| Node.js | 26.8.1 | Current release pinned in `.nvmrc` |
|
||||||
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
|
| 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 |
|
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||||
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
|
| Docker | 24+ | Optional — for containerized deployment |
|
||||||
| Java | 17+ | Required only if building the emulator |
|
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
|
||||||
| Maven | 3.9+ | Required only if building the emulator |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,12 +24,28 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
|
|
||||||
### 1. Clone & Install
|
### 1. Clone & Install
|
||||||
|
|
||||||
|
Choose your preferred package manager:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
||||||
cd EpicNext-Cms
|
cd EpicNext-Cms
|
||||||
|
```
|
||||||
|
|
||||||
|
**pnpm (recommended):**
|
||||||
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**npm:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**yarn:**
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
### 2. Database Setup
|
### 2. Database Setup
|
||||||
|
|
||||||
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
|
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.
|
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
|
### 3. Configure Environment
|
||||||
|
|
||||||
@@ -53,14 +70,168 @@ Edit `.env` with at minimum:
|
|||||||
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
||||||
AUTH_SECRET=<random 32+ character string>
|
AUTH_SECRET=<random 32+ character string>
|
||||||
HOTEL_NAME=YourHotel
|
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.).
|
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`:
|
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.
|
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| ------- | ------------ |
|
| ---------------------- | ---------------------------------------------------------------------- |
|
||||||
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
|
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
|
||||||
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||||
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
|
| `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 |
|
| `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`.
|
> 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
|
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.
|
||||||
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)
|
### Install (Ubuntu/Debian)
|
||||||
|
|
||||||
@@ -137,10 +271,10 @@ apt-get install -y /tmp/dragonfly_amd64.deb
|
|||||||
systemctl enable --now dragonfly
|
systemctl enable --now dragonfly
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`.
|
|
||||||
|
|
||||||
### Configure
|
### Configure
|
||||||
|
|
||||||
|
Edit `/etc/dragonfly/dragonfly.conf`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
--bind=127.0.0.1
|
--bind=127.0.0.1
|
||||||
--port=6379
|
--port=6379
|
||||||
@@ -148,260 +282,91 @@ This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly
|
|||||||
--version_check=false
|
--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
|
### Point the CMS at it
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
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
|
## 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
|
### Prerequisites
|
||||||
|
|
||||||
- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt)
|
- 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
|
- CMS running on `127.0.0.1:3002`
|
||||||
- Habbo imager (optional) running on `127.0.0.1:3030`
|
|
||||||
|
|
||||||
### Reference Configuration
|
### Reference Configuration
|
||||||
|
|
||||||
Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`:
|
|
||||||
|
|
||||||
```nginx
|
```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;
|
||||||
# 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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
server_name yourdomain.com;
|
||||||
server_name yourdomain.com www.yourdomain.com;
|
return 301 https://$host$request_uri;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/epicnext/public;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# MAIN HTTPS SERVER
|
|
||||||
# ==========================================
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
listen [::]:443 ssl;
|
|
||||||
http2 on;
|
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 /etc/ssl/cert.pem;
|
||||||
ssl_certificate_key /etc/ssl/key.pem;
|
ssl_certificate_key /etc/ssl/key.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
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-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" 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;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
client_max_body_size 20m;
|
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_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
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 Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "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 html_cache;
|
||||||
proxy_cache_valid 200 60s;
|
proxy_cache_valid 200 60s;
|
||||||
proxy_cache_key "$host$request_uri";
|
|
||||||
proxy_ignore_headers Vary;
|
proxy_ignore_headers Vary;
|
||||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
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;
|
add_header X-Cache-Status $upstream_cache_status always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------
|
location /api/ {
|
||||||
# Health Check
|
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 {
|
location /health {
|
||||||
access_log off;
|
access_log off;
|
||||||
return 200 "OK";
|
return 200 "OK";
|
||||||
add_header Content-Type text/plain;
|
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)
|
## Production Deployment (PM2)
|
||||||
@@ -421,30 +386,28 @@ pnpm build
|
|||||||
pm2 restart next
|
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
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------------- | -------------------------------------------------- |
|
| ------------------------- | -------------------------------------------------- |
|
||||||
| `pnpm dev` | Start development server (hot reload) |
|
| `pnpm dev` | Start development server (hot reload) |
|
||||||
| `pnpm build` | Production build |
|
| `pnpm build` | Production build |
|
||||||
| `pnpm start` | Start production server |
|
| `pnpm start` | Start production server |
|
||||||
| `pnpm typecheck` | Run TypeScript type checking |
|
| `pnpm typecheck` | Run TypeScript type checking |
|
||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `pnpm db:migrate:status` | Show migration status |
|
||||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
| `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:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
|
||||||
| `pnpm db:studio` | Drizzle Studio (dev) |
|
| `pnpm db:studio` | Drizzle Studio (dev) |
|
||||||
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
||||||
| `pnpm analyze` | Build + open bundle analyzer |
|
| `pnpm analyze` | Build + open bundle analyzer |
|
||||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
| `pnpm jobs:worker` | Start background task worker |
|
||||||
| `pnpm biome:check` | Lint and format code |
|
| `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 |
|
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
|
||||||
| **View Transitions API** | Native browser transitions between page navigations |
|
| **View Transitions API** | Native browser transitions between page navigations |
|
||||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
|
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
||||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
||||||
| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB |
|
| **DragonflyDB Caching** | Caches API responses 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) |
|
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
| **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 |
|
| **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 |
|
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
|
||||||
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
|
| **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/
|
├── scripts/
|
||||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||||
│ ├── jobs-worker.ts # Background task scheduler
|
│ ├── 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
|
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── db/
|
│ ├── 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
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||||
│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
|
|
||||||
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
||||||
│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
|
│ │ └── motion.ts # Framer Motion animation variants
|
||||||
│ │ ├── 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)
|
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
|
||||||
│ └── env.ts # Zod-validated environment schema
|
│ └── env.ts # Zod-validated environment schema
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── assets/ # Images, icons, fonts
|
│ ├── assets/ # Images, icons, fonts
|
||||||
│ └── scripts/ # Client-side scripts (theme-init.js)
|
│ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker)
|
||||||
├── update-Nitrov3.sh # Emulator & Nitro updater utility
|
│ └── 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
|
├── next.config.ts # Next.js configuration
|
||||||
└── .env.example # Environment template with all options
|
└── .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
|
## Optional Integrations
|
||||||
@@ -549,97 +490,35 @@ The radio player uses SSE for real-time updates (10s interval, no polling).
|
|||||||
Run as a persistent process:
|
Run as a persistent process:
|
||||||
|
|
||||||
```bash
|
```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
|
### AI Content Moderation
|
||||||
|
|
||||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||||
|
|
||||||
### FlareSolverr (Cloudflare Bypass)
|
### 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.
|
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d flaresolverr
|
docker compose up -d flaresolverr
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the URL in `.env`:
|
```dotenv
|
||||||
|
|
||||||
```
|
|
||||||
FLARESOLVERR_URL=http://localhost:8191
|
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
|
### Contributing
|
||||||
|
|
||||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
2. Follow existing code conventions (Server Components where possible)
|
||||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
3. SQL migrations in `drizzle/migrations/` must be idempotent
|
||||||
4. SQL migrations in `drizzle/migrations/` must be idempotent
|
4. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`)
|
||||||
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
|
5. Avoid `any` — use `biome-ignore` comments only when unavoidable
|
||||||
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+52
-9
@@ -1,20 +1,63 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cms:
|
cms:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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
|
container_name: epicnext-cms
|
||||||
ports:
|
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
|
||||||
# Host port -> container port (container always listens on 3002)
|
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
|
||||||
- "3002:3002"
|
# 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
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
# ~3GB client assets live on the host; mount them read-only into the container
|
# ── Write targets (runtime imports/uploads, persistent on the host) ──
|
||||||
- ./public/nitro-assets:/app/public/nitro-assets:ro
|
# The CMS writes imported furni/figures/pets/effects here (see
|
||||||
- ./public/swf:/app/public/swf:ro
|
# src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by
|
||||||
# Runtime uploaded media (persistent on the host)
|
# 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
|
- ./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
|
||||||
Reference in new issue
Block a user