Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4869bb84 |
@@ -8,7 +8,6 @@
|
||||
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||
# DATABASE_URL supports both MySQL/MariaDB ("mysql://...") and PostgreSQL ("postgres://...").
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=25
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
.husky/* text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
*.nitro binary
|
||||
*.hab binary
|
||||
*.swf binary
|
||||
|
||||
@@ -21,11 +21,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
@@ -35,9 +30,6 @@ jobs:
|
||||
- name: Deploy port-selection tests
|
||||
run: bash scripts/ci-deploy-ports.test.sh
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -63,14 +55,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -99,14 +83,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -123,14 +99,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -142,7 +110,7 @@ jobs:
|
||||
|
||||
- name: Upload UI results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: ui-results
|
||||
path: |
|
||||
@@ -168,14 +136,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -205,14 +165,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -227,7 +179,7 @@ jobs:
|
||||
|
||||
- name: Upload quality results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: quality-results
|
||||
path: |
|
||||
@@ -247,11 +199,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
@@ -261,7 +208,7 @@ jobs:
|
||||
|
||||
- name: Upload preflight news browser results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: preflight-news-browser-results
|
||||
path: |
|
||||
@@ -281,11 +228,6 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Build, deploy and smoke test
|
||||
shell: bash
|
||||
env:
|
||||
@@ -294,7 +236,7 @@ jobs:
|
||||
|
||||
- name: Upload isolated news browser results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: news-browser-results
|
||||
path: |
|
||||
@@ -305,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Upload JavaScript size report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: javascript-size-report
|
||||
path: build-reports/
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
--mount=type=cache,target=/root/.npm \
|
||||
apk add --no-cache git bash util-linux \
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g [email protected] \
|
||||
&& corepack enable pnpm
|
||||
|
||||
@@ -84,8 +84,8 @@ ENV NODE_ENV=production \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN apk add --no-cache tini curl \
|
||||
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
|
||||
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/gamedata \
|
||||
&& chown -R 33:33 /app/storage /app/public /var/www/gamedata
|
||||
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
|
||||
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||
@@ -95,6 +95,6 @@ COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||
USER nextjs
|
||||
EXPOSE 3002
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/cms_api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "docker-start.mjs"]
|
||||
@@ -8,15 +8,15 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
|
||||
## System Requirements
|
||||
|
||||
| Component | Version | Notes |
|
||||
| ---------------------------- | ------------------ | --------------------------------------- |
|
||||
| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) |
|
||||
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||
| npm | >= 11.x | Supported alternative |
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB / PostgreSQL | 8.0+ / 10.6+ / 15+ | Supported databases |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||
| Component | Version | Notes |
|
||||
| --------------- | ------------ | --------------------------------------- |
|
||||
| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) |
|
||||
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||
| npm | >= 11.x | Supported alternative |
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||
|
||||
Core stack: **Next.js 16.4.2** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.5** · **Biome 2.6.2** · **Playwright 1.64.1**.
|
||||
|
||||
@@ -55,59 +55,32 @@ yarn install
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
The CMS supports both **MySQL / MariaDB** and **PostgreSQL**. The database type is automatically detected from your `DATABASE_URL` protocol (`mysql://` or `postgres://`).
|
||||
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
|
||||
|
||||
#### Option A: MySQL / MariaDB
|
||||
|
||||
Create your database:
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
#### Option B: PostgreSQL
|
||||
|
||||
Create your database:
|
||||
```sql
|
||||
CREATE DATABASE epicnext_cms;
|
||||
```
|
||||
|
||||
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` maps to the existing database structure.
|
||||
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/`.
|
||||
|
||||
#### Multi-Engine Architecture & Dialect Routing
|
||||
|
||||
EpicNext-CMS features a robust, multi-dialect database abstraction layer powered by **Drizzle ORM**. Depending on the connection string protocol supplied in `DATABASE_URL`:
|
||||
- **MySQL / MariaDB (`mysql://`)**: Utilizes `mysql2/promise` connection pooling with optimized keep-alive, named placeholders, and strict statement hardening.
|
||||
- **PostgreSQL (`postgres://` / `postgresql://`)**: Utilizes `node-postgres` (`pg.Pool`) with high-performance async client handling.
|
||||
|
||||
The query builder and helper functions (`queryRows`, `execResult`, `affectedRows`) automatically normalize driver-specific responses (`ResultSetHeader` vs PostgreSQL result objects), ensuring seamless compatibility across database engines without application-layer changes.
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Copy the example environment file and edit it with your specific settings:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` to include at minimum:
|
||||
Edit `.env` with at minimum:
|
||||
|
||||
```dotenv
|
||||
# Choose your database type and configure DATABASE_URL:
|
||||
# For MySQL / MariaDB:
|
||||
# DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
||||
|
||||
# For PostgreSQL:
|
||||
# DATABASE_URL=postgres://user:[email protected]:5432/epicnext_cms
|
||||
|
||||
# Ensure these are set:
|
||||
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
||||
AUTH_SECRET=<random 32+ character string>
|
||||
HOTEL_NAME=YourHotel
|
||||
APP_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
Refer to `.env.example` for a comprehensive list of all optional configuration variables, including RCON, email services, Valkey, OAuth providers, and PayPal integration.
|
||||
See `.env.example` for all optional variables (RCON, email, Valkey, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. Run CMS Migrations
|
||||
|
||||
@@ -157,7 +130,7 @@ The CMS ships with a multi-stage Dockerfile that automatically detects your pack
|
||||
- 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)
|
||||
- (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below)
|
||||
|
||||
### Quick Start with Docker
|
||||
|
||||
@@ -175,8 +148,8 @@ pm2 stop next 2>/dev/null || true
|
||||
|
||||
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
|
||||
# container user can write imports/uploads to them.
|
||||
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/gamedata
|
||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/gamedata
|
||||
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||
|
||||
# 4. Build, migrate and start a release tied to the Git commit.
|
||||
# Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm.
|
||||
@@ -208,7 +181,7 @@ upstream to your fork/branch if that is where you receive updates.
|
||||
It builds a commit-tagged image and runs migrations in the matching builder
|
||||
container, using the clone's `.env`; no host dependency installation is needed.
|
||||
Only after build and migrations succeed does it recreate the CMS service. It
|
||||
compares the running image ID, image revision and `/cms_api/health` release with the
|
||||
compares the running image ID, image revision and `/api/health` release with the
|
||||
expected Git commit. With `CMS_PUBLIC_URL`, it also checks the domain through
|
||||
your proxy/CDN. A healthy response from another release is an error.
|
||||
|
||||
@@ -252,7 +225,7 @@ the badge directory URL (or a local path). The legacy `NEXT_PUBLIC_IMAGER_URL` a
|
||||
`NEXT_PUBLIC_BADGE_URL` names remain supported at runtime. If the imager points back
|
||||
to the CMS `/imaging` proxy, set `IMAGING_UPSTREAM_URL` to the actual renderer to avoid
|
||||
a loop. With no imager configured, the CMS uses Habbo's public renderer. Client
|
||||
requests use the existing `/cms_api/imaging/avatar` proxy. Admin badges preserve their
|
||||
requests use the existing `/api/imaging/avatar` proxy. Admin badges preserve their
|
||||
local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `APP_URL`.
|
||||
After changing `.env`, recreate the container; an image rebuild is not required.
|
||||
`NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image.
|
||||
@@ -307,8 +280,8 @@ Container publication is disabled. CI builds, checks and deploys the CMS, but it
|
||||
git rev-parse HEAD
|
||||
docker inspect --format '{{.Image}}' epicnext-cms
|
||||
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms
|
||||
curl -fsS http://127.0.0.1:3002/cms_api/health
|
||||
curl -fsS https://your-hotel.example/cms_api/health
|
||||
curl -fsS http://127.0.0.1:3002/api/health
|
||||
curl -fsS https://your-hotel.example/api/health
|
||||
```
|
||||
|
||||
Both HTTP responses must report the expected `release`. `unknown` means the image
|
||||
@@ -382,9 +355,9 @@ Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#av
|
||||
| `/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) |
|
||||
| `/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.
|
||||
**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:
|
||||
|
||||
@@ -394,11 +367,11 @@ Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#av
|
||||
| Avatar imager | `/docker/Polaris-imager` | Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) |
|
||||
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
|
||||
|
||||
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
|
||||
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/gamedata
|
||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/gamedata
|
||||
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
|
||||
```
|
||||
|
||||
@@ -422,7 +395,7 @@ provides.
|
||||
|
||||
### Health Check
|
||||
|
||||
The container includes a health check that hits `/cms_api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:
|
||||
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
|
||||
@@ -455,7 +428,7 @@ pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules
|
||||
|
||||
```bash
|
||||
# Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes)
|
||||
pnpm db:bulk --file=/var/www/gamedata/config/FurnitureData.json
|
||||
pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json
|
||||
|
||||
# Flat array of documents → generic JSON store
|
||||
pnpm db:bulk --file=/data/products.json --table=docs --category=furni
|
||||
@@ -562,7 +535,7 @@ maxmemory-policy allkeys-lru
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
```
|
||||
|
||||
Verify via `/cms_api/health` — it should report `"redis": true`.
|
||||
Verify via `/api/health` — it should report `"redis": true`.
|
||||
|
||||
---
|
||||
|
||||
@@ -618,7 +591,7 @@ server {
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
}
|
||||
|
||||
location /cms_api/ {
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
@@ -704,7 +677,7 @@ A production issue that this section documents: **the `avatar-imaging-pixinode`
|
||||
|
||||
### Automatic upstream fallback
|
||||
|
||||
Since then the CMS-side avatar proxies (`/cms_api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
|
||||
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
|
||||
|
||||
### Figure string validation
|
||||
|
||||
@@ -719,7 +692,7 @@ reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a
|
||||
path-traversal attempt, an over-long value — is replaced with
|
||||
`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken
|
||||
avatars without weakening the route, which still answers `400` to a malformed
|
||||
figure (`src/app/cms_api/imaging/avatar/route.ts`).
|
||||
figure (`src/app/api/imaging/avatar/route.ts`).
|
||||
|
||||
---
|
||||
|
||||
@@ -791,11 +764,11 @@ cache them on their behalf.
|
||||
|
||||
| Class | Endpoints | TTL |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| 1 | `/cms_api/online`, `/cms_api/online/count` | 10s |
|
||||
| 2 | `/cms_api/photos`, `/cms_api/leaderboard`, `/cms_api/radio/current-dj`, `/cms_api/radio/points/leaderboard` | 60s |
|
||||
| 3 | `/cms_api/staff`, `/cms_api/teams`, `/cms_api/guilds`, `/cms_api/shop`, `/cms_api/shop/categories`, `/cms_api/values`, `/cms_api/values/categories`, `/cms_api/values/[0-9]+` | 300s |
|
||||
| 1 | `/api/online`, `/api/online/count` | 10s |
|
||||
| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s |
|
||||
| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s |
|
||||
|
||||
`/cms_api/radio/shouts` is intentionally excluded: it is live chat.
|
||||
`/api/radio/shouts` is intentionally excluded: it is live chat.
|
||||
|
||||
Three details are easy to get wrong and are worth keeping intact:
|
||||
- **`proxy_ignore_headers "Cache-Control" "Vary";` is what makes the cache work.**
|
||||
@@ -823,10 +796,10 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce.
|
||||
| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache |
|
||||
| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` |
|
||||
| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover |
|
||||
| `src/app/cms_api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/cms_api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/cms_api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/cms_api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state |
|
||||
| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` |
|
||||
| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed |
|
||||
@@ -847,7 +820,7 @@ repeat-offender attacks:
|
||||
1. **App-layer gate** (production only) — per-IP request buckets per category
|
||||
(pages / API / auth), a whole-site safety valve, and escalation tiers that
|
||||
temporarily hard-block offending IPs (`src/lib/ddos-guard.ts`).
|
||||
`/cms_api/health` is always exempt so the Docker liveness probe never trips it.
|
||||
`/api/health` is always exempt so the Docker liveness probe never trips it.
|
||||
2. **Automatic Cloudflare edge blocks** (optional, works on every Cloudflare
|
||||
plan including Free) — when the gate hard-blocks an IP, the CMS mirrors the
|
||||
block to the zone's **IP Access Rules** (`src/lib/cloudflare-api.ts`), so a
|
||||
@@ -1395,22 +1368,6 @@ It is run against the public site as a `continue-on-error` CI job, so it still
|
||||
surfaces a real content problem without turning the pipeline red. Point either
|
||||
suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
|
||||
|
||||
### Testing & Quality Assurance
|
||||
|
||||
EpicNext-CMS maintains strict engineering standards enforced through automated testing pipelines:
|
||||
1. **Unit & Integration Testing (Vitest)**:
|
||||
- Run tests: `pnpm test`
|
||||
- Run integration tests: `pnpm test:integration`
|
||||
- Covers database helpers, caching strategies, background workers, and housekeeping matrix logic.
|
||||
|
||||
2. **End-to-End Testing (Playwright)**:
|
||||
- Blocking deploy gate (`pnpm test:e2e`): validates critical user journeys (authentication, registration, captcha DOM contracts, security headers).
|
||||
- Quality advisory suite (`pnpm test:e2e:quality`): non-blocking inspection of live content assets, page latency, and responsive layouts.
|
||||
|
||||
3. **Linting & Type Safety (Biome & TypeScript)**:
|
||||
- Lint check: `pnpm lint` (or `pnpm biome:check`)
|
||||
- Type check: `pnpm typecheck` (strictly validates TypeScript across App Router, Server Actions, and Drizzle models).
|
||||
|
||||
### Contributing
|
||||
|
||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||
@@ -1421,21 +1378,7 @@ EpicNext-CMS maintains strict engineering standards enforced through automated t
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment & Zero-Downtime Releases
|
||||
|
||||
EpicNext-CMS is engineered for enterprise-grade production environments with zero-downtime deployment capabilities:
|
||||
1. **Blue/Green Slot Deployments**:
|
||||
- Updates run through containerized slot orchestration (`scripts/docker-update.sh`), ensuring seamless transitions between release versions without dropping requests.
|
||||
- Automatic health check verification (`/cms_api/health`) validates database connectivity, Redis/Valkey status, and RCON liveness before routing production traffic.
|
||||
|
||||
2. **Automated Rollback Protection**:
|
||||
- If a post-cutover health check or startup sequence fails, the system automatically triggers a rollback to the previous stable container image and logs the incident.
|
||||
|
||||
3. **High-Performance Caching & Rate Limiting**:
|
||||
- Integrates with Redis / Valkey for distributed session storage, global rate limiting, and ephemeral response caching.
|
||||
- Multi-tier anti-DDoS proxy filtering defends against volumetric application-layer abuse with automatic Cloudflare edge-block mirroring.
|
||||
|
||||
---
|
||||
## License
|
||||
|
||||
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
||||
test line
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
[
|
||||
{
|
||||
"id": 132,
|
||||
"sprite_id": 132,
|
||||
"item_name": "floortile",
|
||||
"public_name": "Floor Tile",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 0,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 1,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 420,
|
||||
"sprite_id": 420,
|
||||
"item_name": "soft_jaggara_norja",
|
||||
"public_name": "Norja-pehmojakkara",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 3,
|
||||
"stack_height": 1.7,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 1,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 1,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1001,
|
||||
"sprite_id": 1001,
|
||||
"item_name": "Chess",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1011,
|
||||
"sprite_id": 1011,
|
||||
"item_name": "TicTacToe",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1021,
|
||||
"sprite_id": 1021,
|
||||
"item_name": "BattleShip",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1659,
|
||||
"sprite_id": 1659,
|
||||
"item_name": "ticket",
|
||||
"public_name": "Big Ticket Bundle",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 10056,
|
||||
"sprite_id": 10056,
|
||||
"item_name": "Vacuum",
|
||||
"public_name": "laundry_r18_vacuum",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 0,
|
||||
"allow_stack": 0,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "9966",
|
||||
"rare": "0"
|
||||
}
|
||||
]
|
||||
@@ -11,5 +11,5 @@ services:
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3002/cms_api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
|
||||
@@ -62,11 +62,11 @@ map "$cms_sess_cookie$cms_authz_header" $cms_skip_cache {
|
||||
map $uri $cms_cc_class {
|
||||
default 0;
|
||||
# online count wordt door elke pagina en de SSE-stream gepolld
|
||||
~^/cms_api/online(/count)?$ 1;
|
||||
~^/api/online(/count)?$ 1;
|
||||
# snel verouderende, maar publieke lijsten
|
||||
~^/cms_api/(photos|leaderboard|radio/current-dj|radio/points/leaderboard)$ 2;
|
||||
~^/api/(photos|leaderboard|radio/current-dj|radio/points/leaderboard)$ 2;
|
||||
# stabiele catalogus- en rosterdata
|
||||
~^/cms_api/(staff|teams|guilds|shop|values)(/categories|/[0-9]+)?$ 3;
|
||||
~^/api/(staff|teams|guilds|shop|values)(/categories|/[0-9]+)?$ 3;
|
||||
}
|
||||
|
||||
# Eén bron van waarheid: klasse + al dan niet ingelogd. De `|`-scheiding is
|
||||
@@ -253,36 +253,6 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Oude, nog gecachte Nitro-configs vroegen SoundData/layout/sounds op paden
|
||||
# die niet bestaan. Serveer de bronbestanden daar ook, zodat een verouderde
|
||||
# browsercache geen 404's meer veroorzaakt.
|
||||
location = /nitro-client/nitro-assets/gamedata/SoundData.json {
|
||||
alias /var/www/gamedata/config/SoundData.json;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
access_log off;
|
||||
}
|
||||
location = /nitro-client/configuration/soundboard-layout.jsonc {
|
||||
alias /var/www/gamedata/config/soundboard-layout.jsonc;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
access_log off;
|
||||
}
|
||||
location = /nitro-client/configuration/soundboard-sounds.jsonc {
|
||||
alias /var/www/gamedata/config/soundboard-sounds.jsonc;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Clientconfiguratie (renderer-config, ui-config, SoundData-URL's) moet niet
|
||||
# 30 dagen in de browser blijven hangen: een gewijzigde gamedata-URL zou
|
||||
# anders pas na cache-expiry effect hebben.
|
||||
location ^~ /nitro-client/configuration/ {
|
||||
alias /var/www/Octane/dist/configuration/;
|
||||
try_files $uri =404;
|
||||
limit_req zone=cms_static_per_ip burst=1000 nodelay;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header Cache-Tag "cms-client";
|
||||
}
|
||||
|
||||
location ^~ /nitro-client/ {
|
||||
alias /var/www/Octane/dist/;
|
||||
try_files $uri $uri/ =404;
|
||||
@@ -331,7 +301,7 @@ server {
|
||||
# 4. alles wat overblijft (c_images, album*, clothes, …) — content-addressed
|
||||
# of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang.
|
||||
location ^~ /gamedata/config/ {
|
||||
alias /var/www/gamedata/config/;
|
||||
alias /var/www/Gamedata/config/;
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate";
|
||||
access_log off;
|
||||
add_header Cache-Tag "cms-gamedata";
|
||||
@@ -347,7 +317,7 @@ server {
|
||||
}
|
||||
|
||||
location ^~ /gamedata/bundled/ {
|
||||
alias /var/www/gamedata/bundled/;
|
||||
alias /var/www/Gamedata/bundled/;
|
||||
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||
access_log off;
|
||||
add_header Cache-Tag "cms-gamedata";
|
||||
@@ -363,7 +333,7 @@ server {
|
||||
}
|
||||
|
||||
location ^~ /gamedata/icons/ {
|
||||
alias /var/www/gamedata/icons/;
|
||||
alias /var/www/Gamedata/icons/;
|
||||
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||
access_log off;
|
||||
add_header Cache-Tag "cms-gamedata";
|
||||
@@ -383,7 +353,7 @@ server {
|
||||
}
|
||||
|
||||
location /gamedata/ {
|
||||
alias /var/www/gamedata/;
|
||||
alias /var/www/Gamedata/;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
access_log off;
|
||||
add_header Cache-Tag "cms-gamedata";
|
||||
@@ -426,7 +396,7 @@ server {
|
||||
# ─── Furni-icons: gamedata-boom eerst, CMS-boom als valrimpeltje ───
|
||||
#
|
||||
# De CMS bouwde zijn icon-URL's altijd vanaf /swf/dcr/hof_furni/icons/,
|
||||
# maar de catalogus komt uit /var/www/gamedata/icons:
|
||||
# maar de catalogus komt uit /var/www/Gamedata/icons:
|
||||
# - van de 16.263 classnames in items_base staat er 15.338 hier onder de
|
||||
# "veilige" naam (`highscore_perteam_1_icon.png`), tegen 11.267 in
|
||||
# public/swf/dcr/hof_furni/icons;
|
||||
@@ -447,7 +417,7 @@ server {
|
||||
# hieronder neemt Cloudflare de zone-TTL (1 jaar) over. Zelfde les als bij
|
||||
# @gamedata_missing hierboven.
|
||||
location ^~ /swf/dcr/hof_furni/icons/ {
|
||||
alias /var/www/gamedata/icons/;
|
||||
alias /var/www/Gamedata/icons/;
|
||||
error_page 404 = @furni_icon_from_cms;
|
||||
# 403 = het pad valt op een map, dus `alias` eindigt op een directory.
|
||||
# Dat is geen icon, dus dezelfde route als een miss.
|
||||
@@ -552,7 +522,7 @@ server {
|
||||
}
|
||||
|
||||
# ─── API Proxy's ───
|
||||
location /cms_api/auth/ {
|
||||
location /api/auth/ {
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -575,7 +545,7 @@ server {
|
||||
# body zelf wordt NIET tussen-gecachet (geen proxy_cache_*): stampede-
|
||||
# bescherming doet src/lib/cache.ts (in-process single-flight + Redis).
|
||||
# De header zet de TTL voor browser + CDN (10/60/300s + SWR).
|
||||
location ~ ^/cms_api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ {
|
||||
location ~ ^/api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ {
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -603,7 +573,7 @@ server {
|
||||
# 3. send_timeout — de server-level 10s meet de pauze tussen twee writes.
|
||||
# Een stream die 25s pingt, of een batch die minuten niets doet, wordt
|
||||
# daar dus losgekapt. Daarom hier een eigen, ruime waarde.
|
||||
location ~ ^/cms_api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ {
|
||||
location ~ ^/api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ {
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -627,7 +597,7 @@ server {
|
||||
add_header X-Accel-Buffering "no" always;
|
||||
}
|
||||
|
||||
location /cms_api/badges/custom {
|
||||
location /api/badges/custom {
|
||||
proxy_pass http://127.0.0.1:2096;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -645,7 +615,7 @@ server {
|
||||
# ─── Imaging & media: de app levert de eigen Cache-Control ───
|
||||
# De catch-all hieronder forceert no-store; avatars en uploads zijn
|
||||
# onveranderlijk per sleutel en moeten door de browser gecachet worden.
|
||||
location /cms_api/imaging/ {
|
||||
location /api/imaging/ {
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -657,7 +627,7 @@ server {
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location /cms_api/media/ {
|
||||
location /api/media/ {
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+3
-3
@@ -52,7 +52,7 @@ x-cms: &cms
|
||||
- ./public/nitro-assets:/app/public/nitro-assets
|
||||
- ./public/swf:/app/public/swf
|
||||
- ./storage:/app/storage
|
||||
- /var/www/gamedata:/var/www/gamedata
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
mem_limit: 4g
|
||||
@@ -64,7 +64,7 @@ x-cms: &cms
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/cms_api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))",
|
||||
"fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
- PORT=3002
|
||||
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:main
|
||||
image: ghcr.io/thephaseless/byparr:v3.0.4
|
||||
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
|
||||
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
|
||||
# versie die ooit werd binnengehaald.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Refactor del catalogo HK — analisi e programma
|
||||
|
||||
Data: 6 settembre 2026. Base verificata: main, commit 9b0ea2fb. Documento di proposta, non implementazione approvata. Il sito /admin/catalog reindirizza al login senza sessione staff: nessuna prova delle mutazioni sul database di produzione. Le criticità indicate sono percorsi verificati nel codice; gli effetti concorrenti richiedono riproduzione controllata.
|
||||
|
||||
## Obiettivo e perimetro
|
||||
|
||||
Un catalogo HK con un solo ambiente di lavoro, regole coerenti e operazioni recuperabili. Conservare stile HK, icone reali, salvataggio diretto, catalogo normale e Builder Club. Nessun ritorno dei Preferiti.
|
||||
|
||||
Inclusi: albero, categorie, offerte, prezzi, bundle, disponibilità, proprietà condivise dei furni, traduzioni, ricerca, anteprima, operazioni massive, manutenzione, collegamenti a Catalog Studio e sincronizzazione Git/hotel.
|
||||
|
||||
Catalog Studio conserva la responsabilità di importare, convertire e riparare .nitro, icone e furnidata. Il refactor collega questi strumenti alla selezione del catalogo; non comporta riscrivere il convertitore, cambiare protocollo dell'emulatore o sostituire il sistema Git/Gitea già esistente.
|
||||
|
||||
## Inventario verificato
|
||||
|
||||
Sono già presenti virtualizzazione dell'albero, trascinamento, multiselezione, griglia/tabella, editor dei prezzi, anteprima negozio, import massivo, traduzioni, manutenzione e coda di export Git. Vanno riutilizzati.
|
||||
|
||||
| File | Righe attuali | Responsabilità da separare |
|
||||
|---|---:|---|
|
||||
| src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx | 2342 | Rendering, selezione, editor offerta/furno, prezzi massivi, drag and drop, dialoghi |
|
||||
| src/components/admin/catalog-manager/sortable-tree.tsx | 1135 | Lettura albero, mutazioni, ricerca, trascinamento, comandi |
|
||||
| src/components/admin/catalog-manager/inline-editor.tsx | 775 | Fetch, stato modifiche, schede, salvataggio, anteprima |
|
||||
| src/app/admin/catalog/[id]/catalog-page-form.tsx | 738 | Campi, layout, media, salvataggio |
|
||||
| src/app/admin/catalog/[id]/catalog-translate-tab.tsx | 617 | Selezione, proposta traduzioni, applicazione |
|
||||
| src/app/admin/catalog/builder-club/bc-manager.tsx | 608 | Gestione parallela BC |
|
||||
| src/app/admin/catalog/page.tsx | 526 | Query, conteggi, varianti normale/BC, composizione UI |
|
||||
|
||||
Le dimensioni aiutano a trovare i punti di intervento: l'obiettivo non è un limite arbitrario di righe, ma responsabilità verificabili e riutilizzabili.
|
||||
|
||||
## Problemi e interventi
|
||||
|
||||
| Priorità | Evidenza | Intervento |
|
||||
|---|---|---|
|
||||
| P0 | sortable-tree.tsx invia il riordino dei fratelli con Promise.allSettled, una action per riga; catalog.ts aggiorna RCON per ciascuna | Un comando batch con lista completa, validazione, transazione e un solo evento di aggiornamento |
|
||||
| P0 | catalog-items.ts riordina le offerte con update sequenziali fuori transazione | Stesso contratto atomico per l'ordine delle offerte |
|
||||
| P0 | updateCatalogPage accetta parentId direttamente; il controllo cicli è separato in movePage | Validazione comune per creazione, form, spostamento e API; controllare destinazioni inesistenti e concorrenza |
|
||||
| P0 | deletePage normale sposta figli, elimina offerte e pagina separatamente | Transazione, analisi dell'impatto e snapshot ripristinabile |
|
||||
| P0 | updateCatalogItem modifica pagina, offerta e items_base con scritture separate | Transazione e verifica che il furno appartenga all'offerta; scope distinto per proprietà condivise |
|
||||
| P1 | cascadeDelete non mantiene un insieme di nodi visitati; il calcolo profondità BC è ricorsivo senza guardia ai cicli | Lettura tollerante di dati incoerenti, diagnostica e arresto sicuro delle traversate |
|
||||
| P1 | handleEditTab modifica lo stato prima della conferma; chiusura X bypassa la protezione | Un unico controllo delle modifiche per cambio pagina, offerta, scheda, uscita e navigazione |
|
||||
| P1 | loadPage/loadItemsData non annullano o identificano la richiesta precedente | AbortController e identità della selezione; solo la risposta corrente può aggiornare l'editor |
|
||||
| P1 | Il salvataggio ignora il booleano restituito da RCON; Git opera in coda | Distinguere DB salvato, invio hotel riuscito/fallito e stato Git; retry senza risalvare i dati |
|
||||
| P1 | loadCatalogItemsData usa Number(value) || fallback per order_number, offer_id e amount | Definire semantica di zero/null per campo e testare il round trip prima di cambiare i fallback |
|
||||
| P2 | Tutte le offerte e metadati sono caricati insieme; filtro con CAST(page_id AS CHAR) | Misurare query/payload, separare elenco e dettagli, paginazione e adapter compatibile INT/VARCHAR |
|
||||
| P2 | Editor normale/BC e form condividono solo parte delle regole; testi anche letterali | Contratti comuni, differenze BC esplicite, traduzioni e permessi coerenti |
|
||||
|
||||
Riferimenti principali: src/actions/catalog.ts, src/actions/catalog-items.ts, src/actions/catalog-bc.ts, src/lib/services/catalog-tree.ts, src/lib/services/catalog-items-loader.ts, src/app/api/admin/catalog/tree/route.ts, src/components/admin/catalog-manager/catalog-manager-dialog.tsx, src/components/admin/catalog-manager/inline-editor.tsx.
|
||||
|
||||
## Alternative
|
||||
|
||||
1. **Pulizia dei file mantenendo tutti gli editor:** rischio iniziale basso, ma conserva duplicazioni e differenze operative. Utile solo come passaggio iniziale.
|
||||
2. **Refactor progressivo con un editor principale — consigliato:** servizi comuni prima, poi promozione del Visual Manager a pagina. Permette piccoli rilasci e confronti tra vecchio e nuovo percorso.
|
||||
3. **Riscrittura completa:** libertà maggiore, ma più rischio di perdere casi speciali, compatibilità DB e funzioni già presenti. Non giustificata dall'inventario attuale.
|
||||
|
||||
## Architettura proposta
|
||||
|
||||
Modulo src/features/catalog con confini chiari:
|
||||
|
||||
- domain/: tipi Page, Offer, FurnitureReference, CatalogKind; validazione gerarchie, prezzi, bundle e disponibilità; nessuna dipendenza React/DB.
|
||||
- server/queries/: letture albero, elenco offerte, dettaglio e ricerca; output serializzabile esplicito.
|
||||
- server/commands/: create/update/move/reorder/delete; autorizzazione, validazione, transazioni, controllo revisione e audit.
|
||||
- server/repositories/: accesso Drizzle e compatibilità delle colonne; adapter normale/BC senza fingere che tutti i campi coincidano.
|
||||
- client/: stato selezione, modifiche locali, operazioni in corso, caricamento e gestione conflitti.
|
||||
- components/: albero, elenco offerte, editor categoria, editor offerta, dettagli furno, diagnostica, stato sincronizzazione.
|
||||
|
||||
Le route e le action attuali rimangono inizialmente adapter sottili. Un unico risultato di operazione include ID operazione, revisione, elementi modificati, eventuali errori di campo e stato sincronizzazione. Non introdurre nuove librerie prima di verificare i limiti degli strumenti già installati.
|
||||
|
||||
Flusso di scrittura: permesso → validazione → verifica revisione → transazione DB con audit → risposta di salvataggio → aggiornamento hotel/export Git. La durabilità del passaggio DB→coda va garantita con un evento persistito nella transazione o meccanismo equivalente verificato. Un fallimento Git/RCON non deve far ripetere una creazione già committata. Riutilizzare il worker e la coda esistenti, aggiungendo idempotenza dove manca.
|
||||
|
||||
## UX proposta
|
||||
|
||||
Pagina /admin/catalog con barra: Normale/BC, ricerca, nuova categoria, aggiungi furni, stato operazioni. Sotto: categorie a sinistra, offerte al centro, dettagli a destra. Il pannello dettagli si richiude; su schermi piccoli diventa una vista dedicata. Un solo scorrimento per ciascuna area, azioni di salvataggio sempre raggiungibili.
|
||||
|
||||
La URL conserva catalogo, categoria, offerta, vista e ricerca; i campi non salvati restano nello stato locale. Indietro/avanti e ricaricamento devono riaprire il contesto corretto. I vecchi URL dei dettagli continuano a funzionare.
|
||||
|
||||
Tre oggetti riconoscibili:
|
||||
|
||||
- Categoria: percorso, titolo, icona, layout, visibilità e requisiti.
|
||||
- Offerta: prezzo, valuta, quantità, componenti bundle, disponibilità e ordine.
|
||||
- Furno condiviso: classname, sprite, dimensioni e interazioni; mostrare quante offerte lo referenziano prima di una modifica globale.
|
||||
|
||||
Idee operative:
|
||||
|
||||
- Ricerca trasversale per nome, classname, ID pagina/offerta/furno e sprite ID, con percorso nei risultati.
|
||||
- Selettore visuale di categoria e layout; proprietà tecniche nelle Avanzate.
|
||||
- Prezzi con icone reali delle valute; mostrare il prima/dopo delle operazioni massive, arrotondamenti ed elementi esclusi.
|
||||
- Multiselezione con riepilogo di spostamento/eliminazione; dopo un errore mantenere selezionati i falliti.
|
||||
- Anteprima del negozio già esistente integrata nel contesto; non presentarla come prova completa del comportamento del client hotel.
|
||||
- Diagnostica su richiesta: offerta, SQL, furnidata, Nitro e icona separati. Collegamento a Catalog Studio sul furno esatto; nessuna scansione pesante a ogni apertura.
|
||||
- Storico di chi/cosa/quando con differenze e ripristino. Il ripristino controlla revisioni successive: non sovrascrive in silenzio modifiche di altri operatori e non annulla acquisti già avvenuti.
|
||||
- Riepilogo visibile: salvato, invio hotel, Git. Gli errori hanno riferimento al monitor CMS.
|
||||
- Stati vuoti, errori, caricamento e sola lettura distinti; traduzioni complete e uso da tastiera.
|
||||
|
||||
## Programma di lavoro e criteri di uscita
|
||||
|
||||
| Lotto | Consegna | Criterio per proseguire |
|
||||
|---|---|---|
|
||||
| 1. Baseline | Matrice funzioni/route/permessi normale e BC; fixture con bundle, LTD, offerte speciali, zeri/null, alberi incoerenti; misure query e rete | Tutti i flussi esistenti hanno una destinazione nel piano, senza omissioni |
|
||||
| 2. Integrità | Validatori, transazioni di riordino/spostamento/eliminazione, gerarchie sicure, revisioni | Un fallimento intermedio non lascia dati parziali; due operatori non si sovrascrivono |
|
||||
| 3. Servizi condivisi | Query/command/repository e risultato comune; vecchie route come adapter | Vecchie UI superano le stesse prove con il nuovo backend |
|
||||
| 4. Stato editor | Unica gestione delle modifiche, richieste annullabili, risposta coerente con selezione | Annullare l'uscita conserva tutto; cambi rapidi mostrano sempre l'ultima selezione |
|
||||
| 5. Pagina unificata | Visual Manager nella pagina, griglia/tabella condivise, URL, layout adattivo | Parità normale/BC e vecchi link conservati; niente perdita di scroll o azioni nascoste |
|
||||
| 6. Operazioni avanzate | Ricerca, editor bundle, prezzi massivi con differenze, storico e diagnosi contestuale | Gli effetti sono spiegati prima dell'applicazione; retry applica solo ciò che manca |
|
||||
| 7. Sincronizzazione | Stato DB/hotel/Git, operazioni persistenti, retry/idempotenza | Guasto dopo commit e riavvio worker non duplicano né perdono l'operazione |
|
||||
| 8. Prestazioni e rimozione duplicati | Paginazione, caricamento progressivo, accessibilità, eliminazione vecchi componenti | Confronto misurato e prove finali; nessuna route o funzione rimasta senza equivalente |
|
||||
|
||||
I lotti 2 e 7 condividono il contratto delle operazioni: progettare subito evento persistente e idempotenza, anche se la UI di stato arriva dopo. Nessuna stima in giorni finché non sono note dimensioni reali del catalogo, varianti DB e casi speciali attivi. Ogni lotto può richiedere più PR piccole; niente sostituzione monolitica.
|
||||
|
||||
## Verifica e rilascio
|
||||
|
||||
Test unitari delle regole; integrazione su MariaDB per rollback, concorrenza e varianti INT/VARCHAR; browser con permessi lettura/modifica, desktop e schermo ridotto. Simulare doppio submit, timeout, risposta fuori ordine, fallimento RCON, Git non raggiungibile, riavvio dopo commit. Conservare test e componenti esistenti finché la parità non è dimostrata.
|
||||
|
||||
Registrare baseline e risultati per categorie grandi/piccole: richieste per riordino, tempo DB, payload, tempo fino a editor utilizzabile, risposte fallite. Non promettere percentuali senza dati.
|
||||
|
||||
Rilascio progressivo con selezione reversibile del nuovo editor. Il ritorno alla UI precedente deve usare gli stessi servizi corretti. Migrazioni additive e compatibili; il rollback dell'app non deve richiedere la cancellazione di dati. Eliminare le vecchie UI solo dopo parità verificata. Confermare CI, deploy, health e prove staff prima di dichiarare risolto il flusso live.
|
||||
|
||||
## Primo passo consigliato
|
||||
|
||||
Lotti 1 e 2: inventario di compatibilità e correzione delle operazioni a rischio, mantenendo inizialmente l'aspetto corrente. Poi estrarre i servizi e unificare l'editor. È la sequenza che permette di migliorare UX senza portare avanti gli stessi difetti dentro una nuova schermata.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Backup and isolated restore drill
|
||||
|
||||
This opt-in operator tool creates one MariaDB logical dump and copies explicitly selected persistent files. It never runs during install, update or CI deployment. The only restore operation is a **disposable drill**: there is no production restore command, database target, destination directory or overwrite option.
|
||||
|
||||
## Scope and prerequisites
|
||||
|
||||
Use the existing project Node toolchain and Docker CLI/Engine on a Linux host. Creation uses a short-lived `mariadb:11.4.5` client on the Docker host network, so `127.0.0.1` means that host. Use a local Docker Engine/context with the same filesystem; remote Docker daemons and Docker Desktop are not supported for creation. The image must already be available or downloadable through the operator's normal image policy. No packages are installed by this tool.
|
||||
|
||||
Choose the single application database explicitly. Its tables must use InnoDB; empty databases, system schemas and unsupported engines are rejected. The backup account needs access to every application table, view, trigger, routine and event being exported. Account/grant provisioning belongs to the operator; the tool does not change privileges. Restore compatibility is checked with the pinned MariaDB image, not guaranteed across arbitrary server versions, plugins, collations or external schema dependencies.
|
||||
|
||||
Before starting, pause **every database/file writer** and schema changer for the whole creation command: CMS requests that write, workers, schedulers, emulator processes, MariaDB events, import jobs and other tools using these resources. Keep them paused until the command exits. `--writers-quiesced` records your acknowledgement; it does not stop services or prove that they are stopped. No DDL may run while dumping. InnoDB's transaction snapshot alone cannot make independently copied files consistent with rows or coordinate other services. The before/after table inventory and second source-file hash pass detect many concurrent changes, but cannot replace quiescing.
|
||||
|
||||
The dump explicitly uses `--single-transaction --quick --skip-lock-tables --routines --events --triggers --hex-blob --tz-utc`. It retains schema/data and named SQL objects while streaming rows. See [MariaDB dump snapshot and object options](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump). This is a logical application backup, not point-in-time recovery: binlogs, server accounts/grants, server configuration, Redis state and unrelated databases are outside its scope.
|
||||
|
||||
## Private configuration
|
||||
|
||||
Create a private JSON file **outside the clone and every selected source directory**, for example `/etc/epicnext/backup.json`. Use a directory accessible only to the operator and file mode `0600`. Edit it with the host's normal private configuration workflow; do not put a password in a shell command or commit the file.
|
||||
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "REPLACE_WITH_BACKUP_ACCOUNT",
|
||||
"password": "REPLACE_PRIVATELY",
|
||||
"database": "REPLACE_WITH_APPLICATION_DATABASE"
|
||||
},
|
||||
"roots": {
|
||||
"storage": "/srv/epicnext/storage",
|
||||
"nitro": "/srv/epicnext/public/nitro-assets",
|
||||
"swf": "/srv/epicnext/public/swf",
|
||||
"gamedata": "/var/www/Gamedata"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace the example clone path and database settings. `storage`, `nitro` and `swf` are required existing directories, including when empty. `gamedata` is optional; omit its key only when those files are independently backed up or not used. All paths must be absolute, distinct, non-overlapping and free of `..` and symlink/junction components. Links, hardlinked files, special files and secret configuration filenames such as `.env`, `.docker-install` and `persistent.path` inside a selected root cause rejection. The configuration itself may not be inside any source root. Keep writers and directory ownership controlled for the duration; this is not a filesystem snapshot resistant to hostile concurrent renames.
|
||||
|
||||
The tool reads only this explicit JSON. It does not source `.env`, inspect a running application's environment or inherit its secrets into Docker. The MariaDB password is written to a random private temporary directory/file (`0700`/`0600`) and read through a read-only container mount with `--defaults-file` as the first client option. It is absent from process arguments and tool logs; source configuration and absolute source paths are absent from the manifest. Temporary credentials are removed on ordinary success/failure. See [MariaDB option-file handling](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump#defaults-file-name).
|
||||
|
||||
Only Docker connection/runtime environment variables are passed to child processes. Do not point `DOCKER_HOST`/`DOCKER_CONTEXT` at another host or enable shell tracing around private configuration work.
|
||||
|
||||
## Create and verify
|
||||
|
||||
Provision a private backup parent directory, with adequate free space, outside all source roots. Choose a **new** absolute artifact directory for each run. After pausing the writers described above, run from the clone root:
|
||||
|
||||
```sh
|
||||
node scripts/backup/cli.mjs create \
|
||||
--config /etc/epicnext/backup.json \
|
||||
--output /srv/epicnext-backups/2026-09-13T200000Z \
|
||||
--writers-quiesced
|
||||
```
|
||||
|
||||
The date is an example; use a new name for the actual run. Existing directories are refused, including earlier incomplete attempts. A successful artifact contains:
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
database.sql
|
||||
files/storage/...
|
||||
files/nitro/...
|
||||
files/swf/...
|
||||
files/gamedata/... (only when selected)
|
||||
```
|
||||
|
||||
`manifest.json` is written last and marks the format complete. It records each relative file path, byte count and SHA-256, empty directories, selected logical roots, creation time and database inventory. The inventory includes table row counts and MariaDB extended table checksums plus names/types of views, triggers, routines and events. These checksums validate restored table contents; they are not cryptographic signatures or a substitute for application-level checks. See [MariaDB CHECKSUM TABLE semantics and version limits](https://mariadb.com/docs/server/reference/sql-statements/table-statements/checksum-table).
|
||||
|
||||
On a caught failure the newly created artifact is removed; an interrupted process can leave an incomplete directory, which verification refuses. Source roots and existing backup directories are never overwritten. Files are copied and hashed as streams, then source hashes are checked again across the dump interval. This performs multiple full reads of the assets and table data; allow sufficient time and disk capacity during the maintenance window.
|
||||
|
||||
After creation you may resume writers. Copy the completed artifact to the designated protected backup location according to the operator's retention/encryption policy. SQL and uploaded files contain application data and may themselves contain sensitive values. Hashes detect corruption against the manifest, not an attacker who can replace both. Keep the manifest and artifact under trusted access control. Secrets, TLS material and deployment configuration excluded from this artifact need their separate recovery procedure.
|
||||
|
||||
## Isolated restore drill
|
||||
|
||||
Run the drill against a trusted completed artifact:
|
||||
|
||||
```sh
|
||||
node scripts/backup/cli.mjs drill \
|
||||
--artifact /srv/epicnext-backups/2026-09-13T200000Z
|
||||
```
|
||||
|
||||
The drill first rejects missing, extra, changed or unsafe paths/files. It copies the persistent files into a new private temporary directory and verifies their contents. It creates a randomly named MariaDB container with **no network and no published ports**, a fresh password supplied by file, and a disposable database volume. SQL is imported through the container's standard input; there is no connection to the source database. The event scheduler stays off. The resulting table counts/checksums and object inventory must match the manifest. This proves dump importability and the recorded contents, not a full CMS/emulator startup or external-service recovery.
|
||||
|
||||
On ordinary completion/failure it removes the container, its anonymous volume and temporary files. Cleanup failure makes the drill fail. A host crash or forced process termination can interrupt cleanup; inspect only resources labeled `cms.backup-drill=true` and private `cms-backup-private-*` temporary directories from that run, and review their ownership before manual removal. Never substitute an existing database/container into this procedure.
|
||||
|
||||
A real production recovery remains a separate, reviewed procedure with its own deployment configuration, credentials, downtime and application checks. This tool deliberately cannot perform it.
|
||||
|
||||
## Repository verification
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --coverage.enabled=false scripts/backup
|
||||
pnpm exec vitest run --config vitest.integration.config.ts integration/backup.test.ts
|
||||
```
|
||||
|
||||
The local suite exercises real file copies, empty directories, SQL/file tampering, manifest traversal, links, secret-file rejection, overwrites, cleanup and changes during backup. The integration suite requires Docker: failure to start MariaDB fails the suite. It uses a real database with Unicode text, large unsigned identifiers, a foreign key, view, trigger, procedure and event; it creates an artifact, restores it to a second disposable server and checks both byte corruption and a SQL content change with a recomputed file hash. A passing local file suite alone is not evidence that the MariaDB drill ran.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Docker and news checks before merging
|
||||
|
||||
Push work to a `codex/**` branch and open a pull request targeting `main` or `master`. CI runs the existing `check` job first. After it passes, the new `preflight` job builds the production Dockerfile and runs the isolated news browser suite against that exact image. Review both results before merging; repository branch protection can require `check` and `preflight` for pull requests.
|
||||
|
||||
Each execution uses `epicnext-cms:preflight-<commit>-<random suffix>`, including retries and separate push/PR runs of the same commit. The full checked-out commit is passed as `NEXT_DEPLOYMENT_ID` and `NEWS_E2E_RELEASE`; `NEWS_E2E_IMAGE` identifies that execution's image. Failures in dependency/browser setup, Docker build, news tests or cleanup fail the job. News browser artifacts are uploaded even when the gate fails.
|
||||
|
||||
The script installs dependencies with the frozen lockfile and installs Chromium on the CI runner. The real Docker build uses the existing Dockerfile's fixture build settings. It never copies or sources a deployment `.env`, connects to a VPS, runs live migrations, updates live containers, publishes a registry image or changes release tags. The existing isolated news runner owns its disposable MariaDB, Redis and application containers. Cleanup removes only the preflight tag and its empty private temporary directory; it does not prune Docker resources.
|
||||
|
||||
The `deploy` and `publish-container` conditions remain restricted to pushes on `main`/`master`. Deployment still runs its own news gate before live migrations/cutover. A successful branch preflight supplies earlier evidence; the deployed commit is independently checked again.
|
||||
|
||||
On a Linux development or CI host with the project toolchain, Docker Engine and normal browser prerequisites, the same gate can be run from a clean checkout:
|
||||
|
||||
```sh
|
||||
bash scripts/ci-preflight.sh
|
||||
```
|
||||
|
||||
Shell orchestration is covered by `pnpm exec vitest run --coverage.enabled=false src/lib/ci-preflight.test.ts`. Those tests execute the real shell script with external command boundaries simulated; they prove ordering, failure propagation, unique tags and cleanup scope. They do not build an image or run the news browser suite. The branch/PR CI job provides that Docker/browser evidence.
|
||||
@@ -0,0 +1,94 @@
|
||||
# CMS error center and dependency maintenance
|
||||
|
||||
Open **HK > DevOps > CMS error center** (`/admin/devops/cms-errors`).
|
||||
The previous `/admin/devops/errors` page still displays emulator errors.
|
||||
|
||||
## Access and workflow
|
||||
|
||||
- Read: existing `admin.devops.view` permission, checked on the server.
|
||||
- Mark resolved: `admin.devops.edit`, checked again in the server action; recorded in the staff audit log.
|
||||
- Search by event reference, Next.js digest, route, message or deployment version.
|
||||
- Expand a group for its latest stack, sanitized context and occurrence references.
|
||||
- Marking a group resolved does not delete evidence. A later occurrence reopens it.
|
||||
- Refresh is manual so inspecting an expanded error is not interrupted by polling.
|
||||
|
||||
## What is collected
|
||||
|
||||
Pino `logger.error`, `logServerError`, unexpected action errors, Next.js request
|
||||
failures, React error boundaries, uncaught browser errors and unhandled browser
|
||||
promise rejections. API wrapper failures return an `errorId`; Next.js boundary
|
||||
errors can be correlated by their digest. Release identifiers come from the build.
|
||||
Browser reports retain their own client release separately from the receiving server.
|
||||
|
||||
Reports are stored in `storage/cms-errors`, using the existing persistent storage
|
||||
mount. No third-party service, token or new database table is required.
|
||||
Storage does not depend on database availability; viewing the HK and its permission
|
||||
checks still require authentication/database availability. Server console logs
|
||||
remain the fallback when the CMS itself cannot serve requests.
|
||||
|
||||
Retention: seven days; approximately 10 MB/day, 100 queued server writes, and the
|
||||
latest 1,000 events in the viewer. Limits are per CMS process/storage volume;
|
||||
this is intended for the existing single-instance deployment. Daily expiry runs
|
||||
on the next write. The browser endpoint is same-origin, body-size bounded and
|
||||
rate-limited. Browser reports are untrusted observations and cannot grant access.
|
||||
Known credential patterns and URL parameters are redacted; arbitrary object
|
||||
metadata and request bodies are excluded. Avoid putting personal data in error
|
||||
messages: this is pattern-based redaction, not a universal data-loss filter.
|
||||
|
||||
This does not collect historical console logs, process crashes before framework
|
||||
startup, nginx failures, all `console.error` calls, or every handled business
|
||||
validation error. A browser stack may point at minified chunks; private source-map
|
||||
symbolication and distributed traces are not part of this local viewer. A recorded
|
||||
stack is diagnostic evidence, not an automatic root-cause determination.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`,
|
||||
`pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
||||
`pnpm analyze --output` writes a Next.js bundle analysis (not an application build).
|
||||
|
||||
TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and
|
||||
license notices to ignored `public/vendor/tinymce`; dev/build run this first.
|
||||
The Docker build includes the generated assets. The editor retains its existing
|
||||
HTML fields and toolbar; validate saved content and preview when upgrading it.
|
||||
|
||||
Lenis has been removed; the public site now uses native scrolling. Other used
|
||||
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
||||
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||
|
||||
Dependency updates run through self-hosted Renovate: `renovate.json` plus
|
||||
`.gitea/workflows/renovate.yaml` run the pinned `renovate/renovate` image on the
|
||||
host runner every weekday at 04:17 (and via `workflow_dispatch`). Minor and patch
|
||||
updates are grouped into one PR and automerged only after CI is green
|
||||
(`platformAutomerge: false`, so Renovate waits on the checks itself); major
|
||||
updates require approval on the dependency dashboard. The job authenticates with
|
||||
the `renovate-bot` account token stored as the repo Actions secret
|
||||
`RENOVATE_TOKEN`; rotation only requires overwriting that secret (the bot
|
||||
password lives in `/root/.gitea-renovate-bot.env` on the host). `minimumReleaseAge`
|
||||
is 3 days in `renovate.json` and 60 hours in `pnpm-workspace.yaml`, so neither the
|
||||
installer nor the bot picks up a fresh release on day zero. Node/pnpm upgrades
|
||||
remain coordinated with Docker and the runner toolchain.
|
||||
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||
Kit; replacing the ORM is not warranted for this tooling issue.
|
||||
|
||||
## HK request performance
|
||||
|
||||
Open **DevOps → Request performance** (`/admin/devops/performance`). Access requires `DEVOPS_VIEW`; collection only retains requests that passed authentication and permission checks through `withAdmin`.
|
||||
|
||||
The view shows recent requests, their server duration, completed database query count/time/errors, monitored curl download count/time/errors, HTTP status and the existing operation ID. Search by route or operation ID, sort by duration or recency, and filter requests taking at least one second.
|
||||
|
||||
### Measurement boundaries
|
||||
|
||||
- Duration ends when the handler returns its response. Streaming completion, background jobs, browser rendering and public pages are not measured.
|
||||
- Database spans wrap the shared mysql2 promise pool and transaction connection query/execute calls. Query parameters and SQL are never collected. Explicit transaction connection acquisition and transaction control methods are not separate spans.
|
||||
- External spans currently cover `curlFetchText` and `curlDownload`; ordinary fetch calls and other integrations are not covered.
|
||||
- Concurrent dependency durations can exceed wall-clock request duration. Do not subtract the sums to infer application CPU time.
|
||||
- Dynamic route values and query strings are removed. No request bodies, headers, usernames, download URLs or SQL text are stored.
|
||||
|
||||
### Storage and operation
|
||||
|
||||
No new environment variables or packages are required. Redis stores at most 200 recent samples under `cms:performance:v1`, with one-hour retention. Writes are best effort, restricted to an already-ready connection and at most four pending batches; the request never waits for persistence. The view reads at most 200 entries and falls back after one second if shared storage is unavailable.
|
||||
|
||||
An in-process buffer preserves up to 200 samples during outages. The page explicitly labels this local mode; it is per instance and disappears on restart. Filtering applies to the retained samples, not complete traffic history. Under load or during storage outages, some shared samples may be omitted.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Install a clone and choose an update
|
||||
|
||||
## Requirements and first installation
|
||||
|
||||
Use a Linux host with Docker Engine, the Compose plugin, Git and `flock`. This Compose file uses host networking and port 3002. Provide an existing compatible Habbo MariaDB database, reachable Redis, persistent storage and an HTTP(S) reverse proxy. The installer does not provision the emulator, a database, TLS, or a registry account.
|
||||
|
||||
Clone this repository from the Gitea URL supplied by your administrator, enter the clone, then run:
|
||||
|
||||
```sh
|
||||
bash cms install
|
||||
```
|
||||
|
||||
The wizard asks for the public URL and delivery mode. **Source** (the default for a new installation) builds the locked source locally and only needs repository access plus access to public build dependencies. **Prebuilt** downloads the application and migrations from the repository's `docker-image.txt`; a private package needs a separate registry login with package-read permission. Git access alone may not grant package access. Existing saved mode and `.env` are preserved. `bash cms install --configure-only` saves configuration without starting containers.
|
||||
|
||||
Credentials are entered on the host, never in the dashboard. Do not paste `.env` into support tickets. Installation prepares `public/nitro-assets`, `public/swf`, `storage`, and `/var/www/Gamedata` for UID/GID 33 without recursively taking ownership of existing files. Existing nested assets may still need operator permission repair. The updater tests access to those mounts as the candidate container user before migrations; this checks directory access, not every nested file or filesystem capacity.
|
||||
|
||||
After startup, visit `/admin/devops/installation` with the appropriate permission. Verify database, Redis, storage and migration status. Worker heartbeat is a separate runtime signal: a healthy HTTP endpoint does not prove an import worker is processing jobs. Check the worker status and investigate a missing/stale heartbeat before scheduling imports. The dashboard is read-only and cannot start Docker, upgrade the host or grant registry access.
|
||||
|
||||
## Client IP trust at the reverse proxy
|
||||
|
||||
The application validates and normalizes client addresses from `cf-connecting-ip`, the first `x-forwarded-for` entry, then `x-real-ip`. It never accepts `x-real-client-ip`; that legacy derived header is also stripped by the Next.js proxy. Missing or invalid addresses resolve to `0.0.0.0` for rate limits and audit records. API routes use the same resolver even though they do not run through the Next.js proxy.
|
||||
|
||||
These headers are trustworthy only when the ingress sanitizes them. Configure the reverse proxy to discard client-supplied forwarding/derived headers and replace the accepted address from a verified connection or a specifically trusted upstream proxy. Do not append an untrusted incoming `x-forwarded-for` chain and then treat its first entry as authoritative. Forward `cf-connecting-ip` only after verifying that it came through your trusted CDN path; otherwise remove it.
|
||||
|
||||
Restrict direct access to the application port so requests must pass through that ingress. The provided Compose file uses host networking with `HOSTNAME=0.0.0.0`; it does not enforce this restriction or provision nginx/Traefik trust rules. Verify the host firewall and actual reverse-proxy configuration before relying on client IPs for blocking, auditing or abuse limits. Repository tests prove rejection of the derived-header bypass and malformed addresses; they do not certify the deployed forwarding trust chain.
|
||||
|
||||
## Opt-in profile: Nginx on the same host
|
||||
|
||||
Use this profile for a new Linux Compose clone whose public HTTPS endpoint is Nginx on that same host. It leaves `docker-compose.yml` and CI-managed production deployments unchanged. Nginx must include `ngx_http_realip_module`; check `nginx -V` before using the templates. The CMS remains on the existing host network for database/Redis connectivity, but its HTTP process listens on `127.0.0.1:3002`. Host networking shares the host network namespace; a `ports:` mapping would not provide the restriction. See [Docker host networking](https://docs.docker.com/engine/network/drivers/host/).
|
||||
|
||||
1. Run `bash cms install --configure-only` and choose the intended public HTTPS URL. Obtain a valid certificate for that hostname using the host's existing certificate-management process. The templates do not issue certificates or configure renewal.
|
||||
2. In the clone's existing `.env`, add or replace this single setting, preserving all other values:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:deployment/proxy/compose.loopback.yml
|
||||
```
|
||||
|
||||
Keep `APP_URL`, `AUTH_URL` and the saved installer public URL on the same canonical `https://` hostname. The profile pins the existing port 3002 as well as the loopback address. Do not place `COMPOSE_FILE` in `.docker-install`; that file accepts only `MODE` and `PUBLIC_URL`.
|
||||
3. Copy [nginx-direct.example.conf](../../deployment/proxy/nginx-direct.example.conf) into the host's Nginx configuration directory, outside this Git clone. Replace **every** `hotel.example` and both certificate paths. Load it at `http` scope, for example through `/etc/nginx/conf.d/cms.conf`. Review any existing virtual host for that hostname to avoid two competing configurations. The example handles only CMS HTTP traffic; emulator WebSocket and other hotel services need their own reviewed ingress.
|
||||
4. Validate the effective Nginx configuration with `nginx -t`, then enable/reload it through the host's normal service-management procedure. Prepare this endpoint before starting the installer, because installation verifies the saved public URL. It can return an upstream-unavailable response until the CMS starts.
|
||||
5. From the clone root, remove conflicting Compose overrides from the deployment shell and validate the model:
|
||||
|
||||
```sh
|
||||
unset COMPOSE_FILE COMPOSE_PATH_SEPARATOR COMPOSE_ENV_FILES COMPOSE_DISABLE_ENV_FILE
|
||||
docker compose config --quiet
|
||||
bash cms install
|
||||
```
|
||||
|
||||
These `unset` commands remove shell overrides; they do not remove the `COMPOSE_FILE` line in `.env`. Do not set `COMPOSE_DISABLE_ENV_FILE=1`, use an alternate `--env-file`, or supply a competing shell `COMPOSE_FILE` for this workflow. Environment values can override `.env` selection. Do not print or paste the full rendered Compose configuration, because it contains runtime credentials. See [Compose predefined variables and precedence](https://docs.docker.com/compose/how-tos/environment-variables/envvars/).
|
||||
6. Confirm the running configuration without dumping the environment:
|
||||
|
||||
```sh
|
||||
docker compose exec -T cms node -e 'if(process.env.HOSTNAME!=="127.0.0.1"||process.env.PORT!=="3002")process.exit(1);console.log("CMS configured for 127.0.0.1:3002")'
|
||||
ss -lnt '( sport = :3002 )'
|
||||
curl --fail --silent --show-error https://hotel.example/api/health
|
||||
```
|
||||
|
||||
The listener must be `127.0.0.1:3002`, not `0.0.0.0:3002` or `[::]:3002`. From another machine, the host's public IP on port 3002 must be unreachable. Check both address families when the host has IPv6. Loopback isolation covers the CMS process only: other containers and host services, including Byparr, retain their existing bindings.
|
||||
|
||||
The direct template uses the original socket peer (`$realip_remote_addr`), overwrites the two accepted forwarding headers, and removes incoming `CF-Connecting-IP`, `X-Real-Client-IP` and `Forwarded`. It fixes forwarded host/protocol to the configured HTTPS origin. An unrelated inherited real-IP rule cannot turn a caller-supplied header into the forwarded client address in this mode. See [Nginx original-peer variables](https://nginx.org/en/docs/http/ngx_http_realip_module.html) and [header replacement/removal](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header).
|
||||
|
||||
Response buffering is disabled for progress streams, and this example adds no proxy response cache or CORS policy. Its 64 MiB ingress body cap accommodates the existing 52 MiB Studio attachment limit; route and Server Action limits remain authoritative and may be lower. TLS 1.2/1.3 are configured explicitly. See [Nginx buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) and [TLS protocols](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_protocols).
|
||||
|
||||
### Why the profile survives an update
|
||||
|
||||
The installer preserves an existing `.env`. The installer invokes `docker-update.sh`, and the updater's config, build, candidate run, cutover and rollback paths all call **bare `docker compose` from the clone root**, without `-f`. Compose therefore reads the saved file list on each invocation. The base file appears first so its relative mount/build paths remain rooted in the clone; the later profile overrides only the CMS environment and healthcheck. No installer/updater patch is required for this selection. See [Compose merge order and relative paths](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/).
|
||||
|
||||
Keep the profile selected for every routine `bash cms update` and selected-release update. A one-off `docker compose -f ... up` does not persist selection for later installer/updater commands. Do not add an untracked `docker-compose.override.yml`: it makes the clone dirty unless separately excluded and is bypassed when `COMPOSE_FILE` selects an explicit list. Before selecting an older release, verify that `deployment/proxy/compose.loopback.yml` exists in that release; a missing selected file fails configuration rather than silently using the public bind.
|
||||
|
||||
This profile does not change `scripts/ci-deploy.sh`, which owns a separate `docker run` deployment and currently sets its own public binding. Do not use the clone installer to take over a host managed by CI. Restricting that deployment's listener requires a separate compatibility review and rollout.
|
||||
|
||||
### Separate mode: verified remote edge or Cloudflare
|
||||
|
||||
A remote proxy cannot connect to the CMS loopback listener directly. The supported topology here is **remote edge → TLS → Nginx on the CMS host → loopback CMS**, retaining the same Compose profile. Use [nginx-trusted-proxy.example.conf](../../deployment/proxy/nginx-trusted-proxy.example.conf) instead of the direct template. Do not enable both templates for one hostname.
|
||||
|
||||
For your own remote edge, replace `203.0.113.10/32` in both the `geo` peer allowlist and `set_real_ip_from` with the exact approved connection source addresses. The reserved example address deliberately permits no real edge. Require the edge to overwrite `X-Forwarded-For` with one verified client IP, use the configured hostname, enforce public HTTPS, and validate this origin's TLS certificate. The origin checks the original socket peer before accepting the rewritten address. Never use `0.0.0.0/0`, `::/0` or arbitrary client networks as trusted proxies. See [Nginx real-IP trust configuration](https://nginx.org/en/docs/http/ngx_http_realip_module.html).
|
||||
|
||||
For Cloudflare, use that same restricted-edge mode and replace both peer lists with the **current verified IPv4 and IPv6 Cloudflare ranges**, maintained by the operator; use `real_ip_header CF-Connecting-IP` instead of `X-Forwarded-For`. Configure Full (strict) TLS and review authenticated origin pulls. Obtain ranges from [Cloudflare's official IP list](https://www.cloudflare.com/ips/) and follow its [visitor-IP restoration guidance](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/). Do not copy a historical range list from a support ticket or trust `CF-Connecting-IP` merely because it is present. This setup still removes the CF header before the CMS and forwards only Nginx's normalized result in XFF/X-Real-IP.
|
||||
|
||||
The direct template intentionally records the CDN/edge socket address when placed behind an unconfigured CDN; it does not silently trust an upstream header. After configuring the restricted-edge mode, verify with requests from an allowed edge and a disallowed direct client, including forged CF/XFF headers, and check the recorded client address. Neither the repository nor the installer changes the host firewall or certifies another proxy's header behavior.
|
||||
|
||||
### Local verification and deployment boundary
|
||||
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
|
||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||
|
||||
## Routine and selected-release updates
|
||||
|
||||
```sh
|
||||
bash cms update
|
||||
```
|
||||
|
||||
This requires a clean clone and fast-forwards its configured Git upstream, then builds/pulls artifacts for that exact commit. It validates the application and migration revision labels, validates runtime configuration and storage, runs migrations and verifies the recreated CMS locally and through the saved public URL.
|
||||
|
||||
To install a specific published version, fetch it and select its commit on the host first:
|
||||
|
||||
```sh
|
||||
git fetch origin
|
||||
git switch --detach <reviewed-commit-or-tag>
|
||||
bash cms update --skip-pull
|
||||
```
|
||||
|
||||
`--skip-pull` deliberately uses the checked-out commit, including detached HEAD. For routine updates again, switch back to your tracked deployment branch. The script does not invent version-to-schema compatibility or automatically change branches.
|
||||
|
||||
In prebuilt mode you can additionally require immutable artifacts from the configured repository:
|
||||
|
||||
```sh
|
||||
bash cms update --skip-pull --app-digest sha256:<64-lowercase-hex> --migrations-digest sha256:<64-lowercase-hex>
|
||||
```
|
||||
|
||||
Replace both placeholders with publisher-provided digests. Both are mandatory together. Revision labels must match the checked-out commit; a digest alone does not establish schema compatibility. Older migration images without a revision label must be republished from matching source, or the same checkout can be installed in source mode. No migration runs when the artifact or candidate validation fails.
|
||||
|
||||
## Compatibility and recovery
|
||||
|
||||
Before upgrading, read the selected release's migration changes and take a database backup with a tested restore procedure. Preserve `.env`, persistent assets and the previous release identifier. The current tooling does not provide a validated matrix of supported source/target database versions. Pinning an old commit is therefore not a supported database downgrade procedure.
|
||||
|
||||
On a failure after container replacement, the updater attempts to restore the previous image and checks it locally. **Image rollback does not reverse database migrations.** A migration may partially apply or make the old application incompatible, including when migration fails before container replacement. Recover the database only through the reviewed backup/restore procedure and coordinate downtime; do not assume restarting the old image recovers it. First installation has no prior image to restore. Host logs remain in `logs/docker-update.log` and may contain application/database diagnostics; restrict access.
|
||||
|
||||
Local Git Bash tests cover selection validation and mocked failure paths. They do not establish Linux container startup, runtime filesystem permissions, registry availability or real MariaDB upgrade compatibility.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Personal API token scopes
|
||||
|
||||
Public API bearer authentication accepts only tokens owned by the exact `App\Models\User` model. The owner ID must be a positive, safely representable user ID, and the token must satisfy its existing expiration check. Both plaintext tokens and the existing `{id}|{plaintext}` request format remain supported; only the SHA-256 hash is looked up in the database.
|
||||
|
||||
The `abilities` column must contain a non-empty JSON array of non-empty strings. Null, malformed JSON, non-array JSON, empty arrays, non-string entries, and entries with surrounding whitespace are rejected. A valid `"*"` entry grants access to all existing bearer-protected endpoints. Other permissions match exactly: there is no `tickets:*` expansion, implicit read/write inheritance, or fallback to unrestricted access.
|
||||
|
||||
| Ability | Endpoint access |
|
||||
| --- | --- |
|
||||
| `tickets:read` | `GET /api/tickets`, `GET /api/tickets/{id}` |
|
||||
| `tickets:write` | `POST /api/tickets`, `POST /api/tickets/{id}/reply` |
|
||||
| `articles:write` | `POST /api/articles/{slug}/comment` |
|
||||
| `radio:read` | `GET /api/radio/points` |
|
||||
| `radio:write` | `POST /api/radio/shouts` |
|
||||
| `badges:read` | Personal viewer data in `GET /api/badges/leaderboard` |
|
||||
|
||||
For example, `["tickets:read","radio:read"]` allows reading the owner's tickets and radio points. It cannot create tickets, send replies, post article comments, or send radio shouts. Endpoint ownership checks and rate limits still apply after scope authorization.
|
||||
|
||||
Required-token endpoints return the existing generic `401 Unauthorized` response when authorization fails. The badge leaderboard remains public: a denied bearer token receives the anonymous view, without personal viewer data. When an Authorization header is present, this endpoint does not use a session cookie to bypass a denied token. Session-only requests continue to personalize the leaderboard normally.
|
||||
|
||||
## Compatibility and maintenance
|
||||
|
||||
Existing valid wildcard tokens remain compatible. The existing session-authenticated `POST /api/tokens` endpoint continues issuing `["*"]`; this change does not add token-creation options or alter stored tokens. Legacy null, malformed, empty, differently cased model names, and unrelated model tokens are intentionally denied. Review and replace affected tokens with explicit intended scopes, or reissue through the existing token endpoint when full access is appropriate.
|
||||
|
||||
Every new bearer-authenticated endpoint must pass its required abilities to `bearerUserId`. Multiple required abilities use AND semantics. Omitting the requirements, or passing an empty list, requires a wildcard token rather than granting arbitrary scoped tokens access.
|
||||
|
||||
No plaintext token or stored hash is added to error responses or logs by these checks. The existing issuance endpoint returns plaintext once by design.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Security report verification — 2026-09-13
|
||||
|
||||
The supplied review describes commit `baeb54ae` plus a separate port for another hotel. Its “Fixed” labels were not evidence that the changes existed in EpicNext-Cms. This verification inspected canonical `main` at `52f6d149` and the corrective changes prepared here. No exploit or authenticated mutation was performed against production.
|
||||
|
||||
| Supplied finding | Verified state in baseline | Correction / remaining boundary |
|
||||
| --- | --- | --- |
|
||||
| 1. Logo authorization/upload | Confirmed missing action permission and per-file validation | Require settings edit before input or storage access; bounded decoded raster uploads |
|
||||
| 2. Favicon authorization/delete | Confirmed missing action permission; SVG accepted | Same permission boundary for create/delete, bounded raster/ICO validation |
|
||||
| 3. Active uploaded SVG | Confirmed SVG served inline without route CSP | Route CSP sandbox and nosniff on success/errors; existing SVG served as attachment |
|
||||
| 4. Client IP spoofing | Confirmed direct trust in caller-controlled `x-real-client-ip` | Shared validated resolver ignores that header. Forwarded headers still require trusted ingress that overwrites them and prevents direct public origin access |
|
||||
| 5. Email token action exports | Confirmed token helpers in a `use server` module | Move token creation/validation and delivery to a server-only module. Registration and verification call it internally |
|
||||
| 6. Locale cookie | Confirmed missing allowlist | Supported locales only, validate before reading/writing cookies |
|
||||
| 7. Email header injection | Confirmed unsanitized values in sendmail headers | Reject control characters before any mail transport or file fallback; includes configured sender |
|
||||
| 8. Gateway CORS | Supplied gateway path is outside this repository | Read-only GET to our `/api/health` with an unrelated Origin returned a fixed `https://epicnabbo.nl` allow-origin and no allow-credentials. This does not reproduce the report on that route, nor certify every host/route |
|
||||
| 9. Token abilities | Confirmed abilities and owner type not checked by bearer authentication | Enforce User owner type and explicit endpoint abilities; existing wildcard user tokens remain supported |
|
||||
| 10. Broad script CDN | Confirmed unrestricted jsDelivr script source, without a source-code consumer | Remove the broad script source; retain required captcha/analytics sources and nonce |
|
||||
|
||||
The additional `withNitroStaff` code and its tests mentioned in the supplied port do not exist in this checkout; they were not assumed to have been reviewed or imported.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Regression tests exercise authorization before I/O, actual file decoding, SVG/error response headers, token-boundary exports, token abilities, forged derived-IP headers across consumers, locale values, and mail header control characters.
|
||||
- An updated `pnpm audit --json` reported zero known advisories. This is a dependency database result, not proof that application code has no vulnerabilities.
|
||||
- Next.js treats exported Server Actions as public endpoints; unused actions can also be removed by the compiler. The email refactor removes the action boundary entirely instead of relying on whether a specific build exports an unused helper. See [Next.js data security](https://nextjs.org/docs/app/guides/data-security).
|
||||
- The framework also has its own Server Action body limit. The logo defect was absence of application-level file validation, not evidence of literally unlimited bytes through every deployment layer.
|
||||
- No live database, user accounts, uploaded files, or gateway configuration were modified during verification. These changes do not constitute a penetration test or an audit of the emulator, host, or all CMS endpoints.
|
||||
- No nginx/Traefik ingress configuration is versioned here. The deployment guide records the forwarding-header trust requirement. That external boundary remains unverified.
|
||||
|
||||
## Follow-up identified during verification
|
||||
|
||||
The separate comment review is now implemented: both the website form and REST API use one submission service, require a published article whose publication time is due, apply the same moderation, and share a five-attempt/30-second per-user quota. The publication check locks the current article in the insertion transaction. Regression tests cover both entrypoints; real MariaDB/Redis coverage includes publication eligibility, word filtering and alternating submissions. Moderation retains its existing fail-open behavior on service outages. Form input beyond 255 characters is now rejected instead of truncated, and temporary API storage failures return 503. Real integration execution remains a required CI check.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Furni Import Hotel Source Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the furni import visibly and consistently use the CMS `habbo_gamedata_hotel` setting without Italy-specific names or messages.
|
||||
|
||||
**Architecture:** Keep locale selection centralized in `getHabboGamedataHotel()` and expose normalized display metadata to the server-rendered import page. Rename the furnidata cache contract to generic official-Habbo terminology, and keep asset downloads on the global `images.habbo.com` CDN.
|
||||
|
||||
**Tech Stack:** TypeScript 7, React 19, Next.js 16, Vitest 4.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `habbo_gamedata_hotel` remains the single source of truth.
|
||||
- Furnidata and external texts use `www.habbo.<hotel>`.
|
||||
- SWF and icon downloads remain on `images.habbo.com`.
|
||||
- Cache entries must never leak across two configured hotels.
|
||||
- Existing import behavior and permissions remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generic official-Habbo furnidata contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
- Modify: `src/types/furni.ts`
|
||||
- Modify: `src/lib/services/habbo-furnidata-cache.ts`
|
||||
- Modify: `src/lib/services/habbofurni.ts`
|
||||
- Modify: `src/lib/services/furni-data.ts`
|
||||
- Modify: `src/lib/services/furni-import.ts`
|
||||
- Modify: `src/actions/admin-settings.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/route.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/resync/route.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/batch-regen/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `OfficialHabboFurniEntry`.
|
||||
- Produces: `getOfficialHabboFurnidata()`, `lookupOfficialHabboFurni()`, and `clearOfficialHabboFurnidataCache()`.
|
||||
- Consumes: `getHabboGamedataHotel()` and `habboFurnidataUrl(hotel)`.
|
||||
|
||||
- [ ] **Step 1: Write a failing cache-isolation test**
|
||||
|
||||
Mock the selected hotel as `it` for the first request and `nl` for the second. Return distinct fixtures from `fetch` and assert that the second call returns the Dutch fixture and requests `https://www.habbo.nl/gamedata/furnidata_json/1`.
|
||||
|
||||
- [ ] **Step 2: Run the cache test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
|
||||
Expected: FAIL because the generic official-Habbo API is not exported yet.
|
||||
|
||||
- [ ] **Step 3: Rename the cache contract and consumers**
|
||||
|
||||
Rename the Italy-specific type and functions throughout the import pipeline. Keep the existing hotel-keyed cache behavior and update comments to say “configured official Habbo hotel”.
|
||||
|
||||
- [ ] **Step 4: Run the cache test and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
|
||||
Expected: PASS with separate `it` and `nl` fetches.
|
||||
|
||||
### Task 2: Display and report the configured source
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/admin/import/furni/import-source.test.ts`
|
||||
- Create: `src/app/admin/import/furni/import-source.ts`
|
||||
- Modify: `src/app/admin/import/furni/page.tsx`
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Modify: `src/lib/services/furni-import.ts`
|
||||
- Modify: `src/lib/services/furni-import.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `FurniImportSource { hotel, label, host, furnidataUrl }`.
|
||||
- Produces: `getFurniImportSource()` for the server page.
|
||||
- Produces: `formatOfficialHabboEnrichmentWarning(hotel, name)`.
|
||||
|
||||
- [ ] **Step 1: Write failing source and message tests**
|
||||
|
||||
Assert that an `nl` setting becomes `{ hotel: "nl", label: "Netherlands (habbo.nl)", host: "habbo.nl", furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1" }` and that enrichment reports `Enriched from habbo.nl`.
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||
|
||||
Expected: FAIL because source metadata and the dynamic warning formatter do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement source metadata and UI**
|
||||
|
||||
Read the normalized hotel on the server page, pass source metadata into `ImportFurniClient`, and render a compact source badge/link above the furni controls. Replace literal `habbo.it` enrichment wording with the resolved host.
|
||||
|
||||
- [ ] **Step 4: Run focused verification**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run repository verification**
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all commands exit with status 0 using the same non-secret production validation environment as CI where required.
|
||||
|
||||
- [ ] **Step 6: Commit the implementation**
|
||||
|
||||
```text
|
||||
fix: use configured Habbo hotel in furni import
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Manual Recent Furni Resync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a guarded Tools command to Admin Import Furni that resyncs furniture imported during the last seven days and displays the operation result.
|
||||
|
||||
**Architecture:** Keep the existing permission-protected `/api/admin/import/furni/resync` route unchanged. Add a client-safe request/normalization helper with an injected fetcher so its exact URL, HTTP method, success payload, partial failures, and transport failures are testable without rendering the large Import Furni client. The existing client component owns the confirmation, loading, and result UI state.
|
||||
|
||||
**Tech Stack:** TypeScript, React 19, Next.js 16 App Router, Vitest, shadcn/Radix UI, Sonner, Biome.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The operation targets only `furni_import` audit entries from the last seven days.
|
||||
- Use the existing `ASSETS_IMPORT`-protected endpoint and `adminFetch` CSRF flow.
|
||||
- Do not expose `all=1`, delete database rows, or regenerate `.nitro`, SWF, or icon files.
|
||||
- Display examined, resynced, failed, RCON status, and returned per-item errors.
|
||||
- Prevent duplicate submissions while a request is active.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Tested recent-resync client contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/admin/recent-furni-resync.ts`
|
||||
- Test: `src/lib/admin/recent-furni-resync.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a fetcher matching `(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>`.
|
||||
- Produces: `RECENT_FURNI_RESYNC_URL`, `RecentFurniResyncError`, `RecentFurniResyncResult`, and `requestRecentFurniResync(fetcher): Promise<RecentFurniResyncResult>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing request-contract tests**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RECENT_FURNI_RESYNC_URL,
|
||||
requestRecentFurniResync,
|
||||
} from "./recent-furni-resync";
|
||||
|
||||
describe("requestRecentFurniResync", () => {
|
||||
it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
mode: "days",
|
||||
days: 7,
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
});
|
||||
expect(RECENT_FURNI_RESYNC_URL).toBe(
|
||||
"/api/admin/import/furni/resync?days=7",
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the API error when the request fails", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ error: "Forbidden" }, { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `recent-furni-resync.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal typed request helper**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.ts`:
|
||||
|
||||
```ts
|
||||
export const RECENT_FURNI_RESYNC_URL =
|
||||
"/api/admin/import/furni/resync?days=7";
|
||||
|
||||
export interface RecentFurniResyncError {
|
||||
classname: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecentFurniResyncResult {
|
||||
examined: number;
|
||||
resynced: number;
|
||||
failed: number;
|
||||
rconOk: boolean;
|
||||
errors: RecentFurniResyncError[];
|
||||
}
|
||||
|
||||
type AdminFetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export async function requestRecentFurniResync(
|
||||
fetcher: AdminFetcher,
|
||||
): Promise<RecentFurniResyncResult> {
|
||||
const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" });
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof payload.error === "string" ? payload.error : "Furni resync failed",
|
||||
);
|
||||
}
|
||||
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors.filter(
|
||||
(value): value is RecentFurniResyncError =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as RecentFurniResyncError).classname === "string" &&
|
||||
typeof (value as RecentFurniResyncError).message === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
examined: Number(payload.examined) || 0,
|
||||
resynced: Number(payload.resynced) || 0,
|
||||
failed: Number(payload.failed) || 0,
|
||||
rconOk: payload.rconOk === true,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec biome check --write src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: 2 tests pass and Biome reports no remaining errors.
|
||||
|
||||
- [ ] **Step 5: Commit the tested contract**
|
||||
|
||||
```powershell
|
||||
git add -- src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
git commit -m "feat: add recent furni resync client contract"
|
||||
```
|
||||
|
||||
### Task 2: Import Furni Tools action and result UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Consume: `src/lib/admin/recent-furni-resync.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `requestRecentFurniResync(adminFetch): Promise<RecentFurniResyncResult>`.
|
||||
- Produces: a `Tools → Update imported furni` action, confirmation dialog, loading state, and dismissible result panel.
|
||||
|
||||
- [ ] **Step 1: Add state and the guarded request handler**
|
||||
|
||||
Import `DatabaseZap`, `RecentFurniResyncResult`, and
|
||||
`requestRecentFurniResync`. Add state alongside the existing reorganization
|
||||
state:
|
||||
|
||||
```ts
|
||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||
const [recentResyncResult, setRecentResyncResult] =
|
||||
useState<RecentFurniResyncResult | null>(null);
|
||||
```
|
||||
|
||||
Add the handler near `startReorganize`:
|
||||
|
||||
```ts
|
||||
async function resyncRecentImports() {
|
||||
setConfirmRecentResync(false);
|
||||
setRecentResyncing(true);
|
||||
setRecentResyncResult(null);
|
||||
try {
|
||||
const result = await requestRecentFurniResync(adminFetch);
|
||||
setRecentResyncResult(result);
|
||||
if (result.examined === 0) {
|
||||
toast.info("No imported furni found in the last 7 days");
|
||||
} else if (result.failed > 0 || !result.rconOk) {
|
||||
toast.warning("Furni resync completed with warnings");
|
||||
} else {
|
||||
toast.success(`Updated ${result.resynced} imported furni`);
|
||||
}
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Furni resync failed",
|
||||
);
|
||||
} finally {
|
||||
setRecentResyncing(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Tools entry and duplicate-submit guard**
|
||||
|
||||
Insert before the Tools separator:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmRecentResync(true)}
|
||||
disabled={recentResyncing}
|
||||
>
|
||||
{recentResyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DatabaseZap className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{recentResyncing ? "Updating imported furni..." : "Update imported furni"}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the confirmation dialog**
|
||||
|
||||
Add a controlled dialog beside the existing batch confirmation dialogs:
|
||||
|
||||
```tsx
|
||||
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update imported furni?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This updates FurnitureData for furni imported during the last 7 days,
|
||||
then refreshes the emulator catalog and item caches. No database rows or
|
||||
asset files are deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRecentResync(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
|
||||
Update last 7 days
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render a dismissible result panel**
|
||||
|
||||
Place the panel after the existing error banner and before batch summaries:
|
||||
|
||||
```tsx
|
||||
{recentResyncResult && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecentResyncResult(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>{recentResyncResult.examined} examined</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{recentResyncResult.resynced} updated
|
||||
</span>
|
||||
<span className={recentResyncResult.failed ? "text-destructive" : ""}>
|
||||
{recentResyncResult.failed} failed
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
recentResyncResult.rconOk
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
|
||||
</span>
|
||||
</div>
|
||||
{recentResyncResult.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
View errors
|
||||
</summary>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{recentResyncResult.errors.map((error) => (
|
||||
<p key={`${error.classname}:${error.message}`}>
|
||||
<span className="font-mono">{error.classname}</span>: {error.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused and static verification**
|
||||
|
||||
```powershell
|
||||
pnpm exec biome check --write src/app/admin/import/furni/import-furni-client.tsx src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts src/lib/services/furni-data-paths.test.ts
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
Expected: Biome clean, focused tests pass, and TypeScript exits 0.
|
||||
|
||||
- [ ] **Step 6: Run complete regression verification**
|
||||
|
||||
```powershell
|
||||
pnpm test
|
||||
$env:NODE_ENV='production'
|
||||
$env:DATABASE_URL='mysql://test:test@localhost:3306/test?charset=utf8mb4'
|
||||
$env:AUTH_SECRET='ci-test-secret-key-that-is-long-enough'
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all tests pass and the production build exits 0. Redis/database
|
||||
availability warnings during static generation are acceptable in the local test
|
||||
environment; compilation or route-generation errors are not.
|
||||
|
||||
- [ ] **Step 7: Commit the UI integration**
|
||||
|
||||
```powershell
|
||||
git add -- src/app/admin/import/furni/import-furni-client.tsx
|
||||
git commit -m "feat: add manual recent furni resync action"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify the final repository state**
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git log --oneline origin/main..HEAD
|
||||
```
|
||||
|
||||
Expected: clean worktree and the design, plan, tested helper, and UI commits are
|
||||
ahead of `origin/main`, ready for an explicit push request.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Production Furni Assets Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make every furni import write the icon, Nitro bundle, and FurnitureData entry into the directories served by the production client under `/var/www/Gamedata`.
|
||||
|
||||
**Architecture:** Extend the central furni asset resolver with a separate Gamedata layout while preserving CMS-local and Nitro-Files targets. Both import paths consume the same resolved targets; FurnitureData resolves the same Gamedata root independently so metadata and binary assets stay aligned.
|
||||
|
||||
**Tech Stack:** TypeScript 7, Node.js filesystem APIs, Vitest 4, Next.js 16.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all existing `nitro_files_root`, `furni_*_dir`, and `furni_data_mirror_path` behavior.
|
||||
- Auto-detect `/var/www/Gamedata` only when the directory exists; allow `gamedata_root` to override it.
|
||||
- Do not copy source SWFs into the Gamedata tree.
|
||||
- Report live mirror failures in the import warnings.
|
||||
- Use test-first development and run the production build before completion.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Resolve the production Gamedata layout
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/furni-asset-dirs.test.ts`
|
||||
- Modify: `src/lib/services/furni-asset-dirs.ts`
|
||||
- Modify: `src/lib/services/furni-data.ts`
|
||||
- Modify: `src/app/admin/settings/cms-settings-config.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `getGamedataRoot(): Promise<string>`.
|
||||
- Produces: Gamedata mirror entries from `getFurniAssetWriteTargets()` with `<root>/icons` and `<root>/bundled/furniture`.
|
||||
- Consumes: `siteSettings.get("gamedata_root", "")` and `existsSync(DEFAULT_GAMEDATA_ROOT)`.
|
||||
|
||||
- [ ] **Step 1: Write failing resolver tests**
|
||||
|
||||
Mock `siteSettings.get` and `existsSync`, then assert that a configured Gamedata root produces:
|
||||
|
||||
```ts
|
||||
expect(targets.mirrorDirs).toContainEqual({
|
||||
swfDir: targets.swfDir,
|
||||
iconDir: path.join(gamedataRoot, "icons"),
|
||||
nitroDir: path.join(gamedataRoot, "bundled/furniture"),
|
||||
});
|
||||
```
|
||||
|
||||
Also assert that an absent unconfigured root adds no Gamedata mirror and that a duplicate primary destination is de-duplicated.
|
||||
|
||||
- [ ] **Step 2: Run the resolver test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
|
||||
|
||||
Expected: FAIL because `gamedata_root` is not read and the Gamedata mirror is absent.
|
||||
|
||||
- [ ] **Step 3: Implement the Gamedata resolver**
|
||||
|
||||
Add `DEFAULT_GAMEDATA_ROOT`, `getGamedataRoot()`, and a pure Gamedata mapping that uses the primary `swfDir` while mapping icons and Nitro bundles to the live locations. Merge this candidate with the existing Nitro-Files candidate and remove identical directory triples.
|
||||
|
||||
- [ ] **Step 4: Extend FurnitureData and the settings form**
|
||||
|
||||
Make `getFurnitureDataWritePaths()` include `<gamedata_root>/config/FurnitureData.json`, after any explicit `furni_data_mirror_path`. Add a documented `gamedata_root` text setting with `/var/www/Gamedata` as the placeholder.
|
||||
|
||||
- [ ] **Step 5: Run resolver tests and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 1**
|
||||
|
||||
```text
|
||||
fix: map furni imports to production gamedata
|
||||
```
|
||||
|
||||
### Task 2: Mirror manual Nitro uploads
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/upload-import.test.ts`
|
||||
- Modify: `src/lib/services/upload-import.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets>`.
|
||||
- Produces: manual upload copies at every unique `mirrorDirs[].iconDir` and `mirrorDirs[].nitroDir`.
|
||||
|
||||
- [ ] **Step 1: Write a failing manual-upload test**
|
||||
|
||||
Mock the database and metadata dependencies, provide temporary primary and Gamedata directories, call `uploadSingleFurni`, and assert:
|
||||
|
||||
```ts
|
||||
await expect(fs.readFile(path.join(liveNitroDir, "chair.nitro"))).resolves.toEqual(nitroBuffer);
|
||||
await expect(fs.readFile(path.join(liveIconDir, "chair_icon.png"))).resolves.toEqual(iconBuffer);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the upload test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
|
||||
|
||||
Expected: FAIL because manual uploads currently write only to the primary CMS directories.
|
||||
|
||||
- [ ] **Step 3: Implement manual mirroring**
|
||||
|
||||
Resolve all write targets, create their directories through the existing `ensureDirectories()`, and copy the successfully written primary Nitro and optional icon files to each unique mirror. Catch each copy error separately and append a warning containing the destination path.
|
||||
|
||||
- [ ] **Step 4: Run the upload test and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run focused and full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: every command exits with status 0.
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
```text
|
||||
fix: mirror manual furni uploads to live assets
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
# Catalog operations upgrade plan
|
||||
|
||||
User approved bulk editing and complete category duplication, keeping Visual Manager in its button-opened modal.
|
||||
|
||||
Use subagent-driven-development for the independent duplication task; root owns bulk operations and final review.
|
||||
|
||||
- [x] Duplicate category subtree and offers atomically for normal/BC; preview counts, destination/name, fresh IDs, preserve furniture references; fail on stale source, invalid destination, cycles and insert failure. Shared permissions, single export/RCON outcome. UI in Visual Manager.
|
||||
- [x] Extend selected-offer operations to preview and atomically apply prices, currency, and destination. Visibility belongs to categories, so expose it separately with exact affected categories; no fictitious per-offer flag. Preserve unselected and unrelated fields; conflicts prevent overwriting concurrent changes.
|
||||
- [x] Meaningful domain/transaction/action tests, browser fixtures for modal preview/confirm, typecheck/Biome/i18n/Knip; review limits and commit exact scope.
|
||||
|
||||
No production data mutations, added dependencies or schema migrations. Full category duplication shares existing items_base definitions and asset files. Repeated confirmations must be disabled while saving. Preview should be revalidated under locks before write. Existing direct save and permissions remain.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Bulk editing now works from selected normal-catalog offers and from selection across global searches. Only explicitly chosen prices/currency/destination fields change. Prices support set/add/percentage with integer rounding and range validation; 500 selected offers per transaction. Preview binds rows, changes and category names; concurrent changes reject confirmation. Category visibility remains on the existing category controls, not a fabricated offer property. BC has no offer pricing and does not expose that editor.
|
||||
|
||||
Duplication copies up to 500 categories and 5000 offers, preserving bundle and asset references. New root starts disabled and hidden. Includes and internal offer IDs are remapped. Explicit normal offer IDs use the existing allocator and do not require AUTO_INCREMENT; database collisions roll back the whole copy. Preview binds destination siblings as well as source data so confirmed preview replay is rejected. Dirty editor state blocks copying saved data until drafts are saved/reset.
|
||||
|
||||
No database migration or dependency added. Database transaction tests use mocks and do not prove live MariaDB locking behavior. Browser fixtures use real components with mocked actions, not production writes. Existing allocator is process-local; competing external imports may cause a safe rollback requiring a fresh preview. No automatic retry of uncertain copy commits.
|
||||
|
||||
Verification complete: 1339 unit tests passed, 5 skipped. Typecheck/Knip/i18n and changed-file Biome checked. Real-component browser fixtures passed bulk selection, preview, conflicts, pending guards, mobile bounds and table refresh without phantom drafts; duplication nested inside actual manager verified menus, picker, preview and dirty-state guards. Fixed manager portal layering and invalid menu-label nesting uncovered by those tests. Browser actions are mocked; no live data written or deployment claimed.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Catalog refactor implementation plan
|
||||
|
||||
> For agentic workers: use superpowers:subagent-driven-development for independent changes and review each deliverable.
|
||||
|
||||
Goal: deliver the approved progressive catalog refactor while preserving current features.
|
||||
Architecture: shared domain validation and transactional commands behind existing action contracts; unified editor reuses current views and tools.
|
||||
Stack: Next, React, Drizzle/MariaDB, Zod, Vitest, Playwright; no added dependencies.
|
||||
Spec: docs/CATALOG_REFACTOR_PLAN.md
|
||||
Constraints: preserve normal/BC differences, direct save, real icons, permissions, existing routes, no favorites. No production data mutations for testing.
|
||||
|
||||
- [x] Characterize compatibility and command rules with tests; capture baseline source map.
|
||||
- [x] Domain hierarchy/reorder validation and transactional page commands (normal/BC), deterministic locking, common updates/deletion.
|
||||
- [x] Offer update/reorder atomicity and safe numeric normalization, shared mutation contracts.
|
||||
- [x] Editor cancellation/dirty state protection (agent catalog_editor_state), review and browser verification.
|
||||
- [x] Promote editor as an in-page view with reversible classic view, URL context, responsive panels and coherent tool access.
|
||||
- [ ] Sync outcomes/diagnostics/history integration; safe retry and explicit limits.
|
||||
- [ ] Verify unit tests, database integration if runtime available, browser, typecheck, lint/i18n, full suite; review final scope and remaining environment-only checks.
|
||||
|
||||
Execution notes: changes are progressive, no destructive migration. Existing UI adapters stay until functional parity is tested. Whole-program completion must not be claimed from the first deliverable.
|
||||
|
||||
## First implementation delivery (2026-09-06)
|
||||
|
||||
Completed: shared normal/BC page and offer commands; transactional page reorder/delete/move and offer create/update/reorder; hierarchy validation and optimistic page-save checks; embedded default manager with classic views retained; request cancellation, editor unsaved-change guards and URL selection; bounded global category/offer/furniture search; separate hotel/Git status and hotel retry; export-finalization failures no longer mask committed server actions. Existing permissions and direct-save behavior retained. No added dependency or DB migration.
|
||||
|
||||
Verification: full unit suite 1308 passed / 5 skipped before final retry-classification regression; final focused catalog suite 96 passed. TypeScript, i18n static validation, Knip and Biome on all 54 changed source files pass. Browser fixtures cover editor loading races, retry failures, unsaved changes, URL history, read-only, normal/BC, search and 375px layout. Database calls and mutations are mocked in those fixtures. Full-repository formatter check reports pre-existing Windows CRLF formatting differences; unrelated files were not reformatted.
|
||||
|
||||
Remaining roadmap: per-operation durable dispatch/outbox, category/offer snapshot history and conflict-aware undo, contextual maintenance diagnosis, pagination/performance measurement against representative real data, further decomposition of retained legacy views. Existing coarse audit/export infrastructure remains; the new status file is not a durable outbox and RCON socket success does not prove client application. External furni importer mutation internals remain outside shared editor commands.
|
||||
|
||||
Environment checks still required: real MariaDB locking/rollback integration, staff-authenticated end-to-end smoke test and pipeline/deployment health. No production mutations performed; no production deployment claimed.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Security and operational reliability implementation plan
|
||||
|
||||
Goal: finish the five approved follow-ups with independently verified commits.
|
||||
Architecture: share comment policy between session and bearer entrypoints; opt-in same-host proxy configuration; run real news browser checks against the already-built candidate in disposable services; correlate existing diagnostics with deliveries; verify database and persistent-file backup restoration in isolation.
|
||||
Stack: existing Next, MariaDB, Redis, Playwright, Testcontainers and Docker; no new dependencies.
|
||||
Design: user-approved numbered proposal in this task, 2026-09-13.
|
||||
|
||||
Global constraints: preserve current public/HK UX and ACL; no production test content or proxy/firewall changes; no credentials in output; root owns Git on canonical main. Complete each block's checks before an exact-file commit and push. Confirm final CI, container publication and live release.
|
||||
|
||||
1. Comments — src/actions/article-comments.ts, API comment route and shared policy/tests. Add regression cases for hidden/future articles, moderation, cross-channel limit and safe failures; reproduce them, implement, run focused and integration checks. Publicly available article predicate is checked on both entrypoints.
|
||||
2. Proxy — deployment/proxy templates and installation guide/tests. Override must survive installer/update/rollback, force loopback and replace incoming identity headers. Validate the merged Compose config and Nginx syntax; do not apply to the host.
|
||||
3. Real news — e2e/news-real runner/fixture plus ci-deploy gate and harness tests. Start only disposable MariaDB/Redis and the local candidate image; real staff login, draft, preview, publish and anonymous read. Fail before live migration/cutover on any error, clean all fixture resources. Require successful CI execution.
|
||||
4. Diagnostics — carry persisted operation/delivery identifiers into error records; link filtered deliveries and diagnostics with permission checks. Preserve request correlation separately. Tests cover exact matching, hostile IDs, permissions and retry outcomes.
|
||||
5. Recovery — backup creation and isolated restore drill for database plus explicit persistent directories. Keep credentials off argv/logs, reject unsafe paths and incomplete/tampered artifacts. Test real database restore and file checksums with disposable data, record limits for cross-service consistency. Never overwrite production during a drill.
|
||||
|
||||
Status: all five blocks implemented and locally checked. Required final gates: CI real database/proxy/backup suites, candidate news browser journey, deployment/container completion and live release verification. Extra scheduler deadlock discovered in the real concurrency test is fixed with bounded transaction retries. Evidence and boundaries accompany each delivered block.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Furni import hotel source
|
||||
|
||||
## Problem
|
||||
|
||||
The furni import already fetches furnidata through the CMS setting
|
||||
`habbo_gamedata_hotel`, but its public and internal language still refers to
|
||||
Habbo Italy. This makes the active source unclear and gives the impression
|
||||
that the importer is hardcoded to `habbo.it`.
|
||||
|
||||
## Design
|
||||
|
||||
The import page will read `habbo_gamedata_hotel` on the server and pass the
|
||||
normalized hotel value and label to the client. The page will display the
|
||||
active official furnidata source near the import controls, including the
|
||||
resolved `habbo.<hotel>` host.
|
||||
|
||||
Import enrichment warnings will use the resolved hotel label rather than the
|
||||
literal `habbo.it`. Internal furnidata cache APIs and types will be renamed
|
||||
from Italy-specific names to generic official-Habbo names while retaining
|
||||
temporary aliases only where needed to avoid an unsafe all-at-once migration.
|
||||
|
||||
Furniture SWF and icon downloads remain on `images.habbo.com`. That host is
|
||||
Habbo's global asset CDN and must not be derived from the gamedata locale.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Admin settings stores `habbo_gamedata_hotel`.
|
||||
2. Server-side import code normalizes the value through
|
||||
`getHabboGamedataHotel()`.
|
||||
3. Furnidata and external texts use `www.habbo.<hotel>`.
|
||||
4. The import UI and enrichment messages display the same resolved hotel.
|
||||
5. The in-memory cache remains keyed by hotel, so changing the setting loads
|
||||
the selected locale rather than reusing another locale's data.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests will cover normalized locale URL generation, cache separation after a
|
||||
hotel change, dynamic enrichment text, and the source information passed to
|
||||
the import UI. Existing furni import tests, typecheck, full tests, and the
|
||||
production build must pass.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Manual Recent Furni Resync Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a safe manual command to the existing Admin Import Furni screen for
|
||||
refreshing furniture imported during the last seven days. The command must
|
||||
update the live FurnitureData metadata and emulator caches without deleting
|
||||
database rows or regenerating existing asset bundles.
|
||||
|
||||
## User interface
|
||||
|
||||
Add `Update imported furni` to the existing `Tools` dropdown on
|
||||
`/admin/import/furni`.
|
||||
|
||||
Selecting it opens a confirmation dialog that states the fixed scope: furniture
|
||||
recorded by the admin audit log as imported during the last seven days. While
|
||||
the operation is running, the action and confirmation button are disabled and a
|
||||
loading state is shown to prevent duplicate requests.
|
||||
|
||||
After completion, show a result panel containing:
|
||||
|
||||
- examined item count;
|
||||
- successfully resynced item count;
|
||||
- failed item count;
|
||||
- emulator RCON refresh status;
|
||||
- returned per-item errors, when present.
|
||||
|
||||
## Data flow
|
||||
|
||||
The client sends an authenticated, CSRF-protected `POST` request through
|
||||
`adminFetch` to the existing endpoint:
|
||||
|
||||
`/api/admin/import/furni/resync?days=7`
|
||||
|
||||
The endpoint remains authoritative for selecting the targets. It reads only
|
||||
`ItemsBase` IDs referenced by `admin_audit_log` entries whose action is
|
||||
`furni_import`, target is `ItemsBase`, and timestamp falls within the last seven
|
||||
days.
|
||||
|
||||
For each target, the existing resync logic rebuilds its FurnitureData entry,
|
||||
enriches it from the configured `habbo_gamedata_hotel`, and upserts it into the
|
||||
live `FurnitureData.json`. It then requests `updateCatalog` and `updateItems`
|
||||
through RCON.
|
||||
|
||||
## Safety and permissions
|
||||
|
||||
- Reuse the endpoint's `ASSETS_IMPORT` permission check and admin CSRF guard.
|
||||
- Do not expose a UI option for `all=1`.
|
||||
- Do not delete or recreate `items_base` or catalog rows.
|
||||
- Do not regenerate `.nitro`, SWF, or icon files.
|
||||
- Keep returned errors visible without treating an RCON failure as a successful
|
||||
refresh.
|
||||
|
||||
## Error handling
|
||||
|
||||
Network or non-JSON failures produce an error toast and leave the command
|
||||
available for retry. A successful API response is rendered even when individual
|
||||
items failed, so the administrator can distinguish partial completion from a
|
||||
request failure.
|
||||
|
||||
The result panel is dismissible. Running the command again replaces the prior
|
||||
result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit-test the result normalization used by the UI, including partial failures
|
||||
and RCON status.
|
||||
- Verify that the client calls exactly `resync?days=7` through `adminFetch`.
|
||||
- Run focused tests, Biome, TypeScript, the full test suite, and a production
|
||||
build.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Production furni asset destinations
|
||||
|
||||
## Problem
|
||||
|
||||
The production Nitro client loads furniture icons from `/gamedata/icons`,
|
||||
furniture bundles from `/gamedata/bundled/furniture`, and furniture metadata
|
||||
from `/gamedata/config/FurnitureData.json`. The importer currently derives its
|
||||
mirror paths using the development `Nitro-Files` layout. On the production
|
||||
server this creates paths such as `swf/dcr/hof_furni/icons` and
|
||||
`nitro-assets/bundled/furniture` below the configured root, which are not the
|
||||
directories served by the live client. Manual `.nitro` uploads do not mirror
|
||||
assets at all.
|
||||
|
||||
Files written only below the CMS `public` directory are also not durable: the
|
||||
deployment workflow cleans untracked files from the CMS checkout.
|
||||
|
||||
## Design
|
||||
|
||||
Keep the existing CMS-local and `Nitro-Files` destinations for compatibility,
|
||||
and add the deployed Gamedata tree as a distinct asset layout. In production,
|
||||
`/var/www/Gamedata` is detected automatically when it exists. A configurable
|
||||
`gamedata_root` setting can override that location for other installations.
|
||||
|
||||
The Gamedata layout maps assets as follows:
|
||||
|
||||
- icons: `<gamedata_root>/icons`
|
||||
- Nitro furniture: `<gamedata_root>/bundled/furniture`
|
||||
- FurnitureData: `<gamedata_root>/config/FurnitureData.json`
|
||||
- source SWFs: not copied into Gamedata because the Nitro client does not load
|
||||
them; existing CMS-local and `Nitro-Files` SWF handling remains unchanged
|
||||
|
||||
Both the normal Habbo importer and manual `.nitro` uploader use the same write
|
||||
target resolver. Duplicate destinations are removed before writing.
|
||||
|
||||
## Error handling
|
||||
|
||||
The CMS-local write remains the primary operation. Every configured or
|
||||
auto-detected live destination is treated as an expected mirror. Directory or
|
||||
copy failures are returned as import warnings containing the failed target,
|
||||
instead of being silently ignored. A successful import therefore cannot hide
|
||||
that the live client asset copy failed.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing `nitro_files_root`, `furni_swf_dir`, `furni_icon_dir`,
|
||||
`furni_nitro_dir`, and `furni_data_mirror_path` behavior is preserved. The new
|
||||
Gamedata destination is additive, so Windows development using the
|
||||
`Nitro-Files` layout continues to work.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests will cover Gamedata path derivation, automatic production-root
|
||||
detection through an injected root, destination de-duplication, and manual
|
||||
upload mirroring. Existing importer tests, type checks, and the production
|
||||
build must remain green.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Public Avatar Thumbnail and Currency Icon Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make avatar and currency presentation consistent across the public site:
|
||||
|
||||
- user thumbnails in lists and compact cards show only the avatar head at a fixed 40 x 40 pixel size;
|
||||
- full-body avatars remain available on profile pages and deliberately large previews;
|
||||
- currency amounts use the existing graphical currency icons instead of placeholder letters such as `c`, `cr`, `du`, or `di`.
|
||||
|
||||
## Scope
|
||||
|
||||
The change covers public-facing pages and shared public components. It includes rankings, leaderboards, shop and badge-purchase currency rows, plus every other compact user list or card that currently renders an avatar directly.
|
||||
|
||||
Admin-only screens are outside this visual cleanup unless they reuse a shared public component changed by this work. Profile hero avatars, the main current-user avatar, registration/login previews, and other intentionally large previews retain their full-avatar presentation.
|
||||
|
||||
## Avatar Design
|
||||
|
||||
Compact user representations will use one shared semantic thumbnail path rather than choosing imager options independently in each page.
|
||||
|
||||
The thumbnail contract is:
|
||||
|
||||
- request `headOnly: true` from the avatar imager;
|
||||
- render at 40 x 40 CSS and image dimensions;
|
||||
- preserve pixel-art rendering and contain the image without stretching;
|
||||
- prevent the thumbnail container from shrinking into adjacent text;
|
||||
- use the user's actual figure and the existing avatar URL fallback behavior;
|
||||
- keep useful alternative text based on the displayed username where that context is available.
|
||||
|
||||
The existing shared avatar component will be extended with an explicit compact/head-thumbnail variant, or a narrowly focused wrapper will be added if that keeps call sites clearer. Public list and compact-card call sites will migrate to this shared contract. Large/profile call sites will remain explicit so they cannot be accidentally cropped by a global CSS rule.
|
||||
|
||||
## Currency Design
|
||||
|
||||
All public currency amount rows will use the existing `CurrencyIcon` component and the existing assets under `public/assets/images/icons/currency`.
|
||||
|
||||
The mapping is:
|
||||
|
||||
- credits: `credits.png`;
|
||||
- duckets: `duckets.png`;
|
||||
- diamonds/crystals: `diamonds.png`.
|
||||
|
||||
Icons will be decorative when the surrounding UI already names the currency, using an empty alternative text to avoid repeated screen-reader announcements. The numeric amount and existing pill/layout styling remain unchanged. Icon size will be fixed consistently for compact amount rows, with no textual placeholder left visible.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Add the shared compact avatar contract and focused tests.
|
||||
2. Inventory public avatar call sites and classify each as compact thumbnail or large/profile preview.
|
||||
3. Migrate every compact call site to the shared head-only 40 x 40 rendering.
|
||||
4. Replace textual and empty CSS currency markers in public amount rows with `CurrencyIcon` and the correct currency kind.
|
||||
5. Remove CSS rules that exist only to draw obsolete letter-based or background-only markers, while retaining layout classes still used by the amount pills.
|
||||
|
||||
This semantic migration is preferred over a global CSS crop because it sends the correct head-only request to the imager and does not risk changing profile avatars.
|
||||
|
||||
## Verification
|
||||
|
||||
Verification will include:
|
||||
|
||||
- automated tests for the compact avatar contract (`headOnly`, fixed dimensions, actual figure propagation);
|
||||
- source/component checks ensuring public currency rows use `CurrencyIcon` with the correct mapping and no placeholder letters remain;
|
||||
- the existing test, type-check, and build commands relevant to the changed files;
|
||||
- visual checks at desktop and narrow widths for rankings, leaderboard, shop, badge purchase, and representative user lists/cards;
|
||||
- explicit checks that profile pages and large avatar previews still render full avatars.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- changing balances or currency business logic;
|
||||
- changing the avatar imager service itself;
|
||||
- redesigning profile hero sections;
|
||||
- modifying admin-only layouts that do not share the affected public components.
|
||||
@@ -0,0 +1,438 @@
|
||||
# Housekeeping modernization design
|
||||
|
||||
Date: 2026-08-24
|
||||
Status: approved in design review; awaiting review of this written specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Replace the current administration experience with one coherent, role-adaptive Housekeeping (HK) at `/admin`.
|
||||
|
||||
The new HK is a modular part of the existing Next.js application. It is built in parallel, validated against the current system, and exposed with one atomic cutover. It unifies the current `/admin` and `/mod` surfaces, removes duplicated workflows, and preserves reliable domain services without automatically preserving their current pages.
|
||||
|
||||
This document is the master architecture for the program. It is deliberately not one giant implementation plan. Delivery is split into independently specified and verified subprojects, beginning with **Inventory & Foundation**.
|
||||
|
||||
## Current-state findings
|
||||
|
||||
- The repository currently contains 124 `page.tsx` files below `src/app/admin` and 13 below `src/app/mod`: 137 administration pages in total.
|
||||
- `src/lib/admin-nav.ts` currently exposes nine navigation groups and seven hub definitions.
|
||||
- `/admin` and `/mod` provide overlapping moderation, ticket, ban, team, and user workflows with separate shells.
|
||||
- `/admin/housekeeping` is a legacy permission archive/comparison/export surface, while `/admin/permissions` is the live permission-management surface.
|
||||
- The current dashboard reports useful counts but is not an operational work queue.
|
||||
- Page composition, localization, ACL checks, filtering, error handling, and action feedback are not yet uniform across the administration surface.
|
||||
|
||||
The migration must therefore classify every current page. A visual refresh without workflow and boundary changes is insufficient.
|
||||
|
||||
## Approved decisions
|
||||
|
||||
| Area | Decision |
|
||||
| --- | --- |
|
||||
| Audience | One role-adaptive HK. Effective capabilities, not rank names alone, determine what an operator sees and can do. |
|
||||
| Entry point | `/admin` is the only administration entry point after cutover. `/mod` is removed. |
|
||||
| Layout | Command Deck: compact domain rail, contextual navigation, global command palette, operational workspace. |
|
||||
| Personalization | Hybrid: the system supplies mandatory capability-derived content; the operator may pin and reorder allowed shortcuts and optional widgets. |
|
||||
| Compatibility | Clean break. Old subroute compatibility and legacy UX are not preserved through redirects. |
|
||||
| Build strategy | Build the new HK in parallel, keep it unavailable to normal production operators, then switch atomically. |
|
||||
| Work queue | “Da fare ora” is derived from existing sources. It is not a second task database and never owns workflow state. |
|
||||
| Command palette | It navigates, searches entities, and executes only safe commands. Sensitive actions open a dedicated contextual flow. |
|
||||
| Architecture | Modular hybrid replacement inside the current application: reuse sound services, rebuild weak UI/workflows, merge duplicates, and remove obsolete surfaces. |
|
||||
|
||||
## Goals
|
||||
|
||||
1. Give each operator one clear, capability-appropriate place to work.
|
||||
2. Replace feature sprawl with six stable domains and consistent page contracts.
|
||||
3. Make urgent work visible without copying or diverging from source workflow state.
|
||||
4. Enforce authorization, validation, transaction boundaries, error semantics, and audit behavior server-side.
|
||||
5. Remove `/mod`, the legacy HK archive page, duplicate hubs, and manual navigation concepts that the new foundation owns.
|
||||
6. Reach explicit functional, authorization, audit, localization, accessibility, and data-parity gates before cutover.
|
||||
7. Keep rollback practical without exposing a mixed legacy/new experience.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Creating a separate HK application, microservice, or deployment.
|
||||
- Creating a new assignment/task system for the operational inbox.
|
||||
- Preserving every current page, route, component, or interaction.
|
||||
- Adding backward-compatible redirects for removed administration subroutes.
|
||||
- Providing full sensitive-workflow parity on phones. The target is desktop-first with usable tablet layouts.
|
||||
- Redesigning public CMS or game-client experiences as part of this program.
|
||||
- Replacing sound domain logic solely for architectural uniformity.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Modular monolith
|
||||
|
||||
The HK remains inside EpicNext CMS and uses the application's existing authentication, database, service, localization, and deployment infrastructure.
|
||||
|
||||
The target source organization separates composition from behavior:
|
||||
|
||||
```text
|
||||
src/app/admin/ route composition only
|
||||
src/features/housekeeping/
|
||||
foundation/ shell, registry, ACL context, preferences
|
||||
domains/
|
||||
operations/ derived inbox, global search, recent work
|
||||
people/
|
||||
content/
|
||||
economy/
|
||||
hotel/
|
||||
system/
|
||||
src/lib/services/ existing and extracted domain services
|
||||
```
|
||||
|
||||
The exact filenames are an implementation-plan concern, but the boundaries are mandatory:
|
||||
|
||||
- App Router files compose pages and bind route parameters; they do not own business rules.
|
||||
- The foundation owns cross-cutting HK behavior and does not mutate domain data.
|
||||
- Each domain owns its queries, commands, search providers, inbox providers, widgets, and page composition.
|
||||
- Domains do not import another domain's UI internals. Cross-domain interaction uses registered contracts or links to the owning route.
|
||||
- Existing reliable services are adapted behind domain contracts rather than copied into the new UI.
|
||||
|
||||
### Module manifest and registry
|
||||
|
||||
Every domain exports a manifest with stable identifiers for:
|
||||
|
||||
- domain metadata and localized labels;
|
||||
- routes and contextual navigation;
|
||||
- required capabilities;
|
||||
- command-palette entries;
|
||||
- entity-search providers;
|
||||
- derived-inbox sources;
|
||||
- mandatory and optional dashboard widgets.
|
||||
|
||||
The foundation composes these manifests into the rail, contextual navigation, palette, dashboard, and route metadata. Contract tests reject duplicate IDs, duplicate routes, missing localization keys, unknown capability slugs, and commands without an owner.
|
||||
|
||||
The manifest registry replaces hand-maintained duplication between the sidebar, hubs, search, and dashboards. It is code-owned and reviewable. Operator preferences can alter presentation only within what the registry and capability context permit.
|
||||
|
||||
### Capability context
|
||||
|
||||
The server creates one request-scoped capability context from the authenticated operator and the existing ACL source.
|
||||
|
||||
- Capability checks are based on effective permission slugs.
|
||||
- Super-administrator behavior remains explicit and testable.
|
||||
- Rank may help choose default presentation, but never grants access by itself.
|
||||
- Navigation filtering is a usability feature, not an authorization boundary.
|
||||
- Every query and command rechecks its capability on the server and defaults to deny.
|
||||
|
||||
## Functional domains
|
||||
|
||||
| Domain | Owns | Representative current areas |
|
||||
| --- | --- | --- |
|
||||
| Da fare & operations | Derived inbox, global search, recent work, favorites, operational summaries | Dashboard, selected alerts and cross-domain counts; projections only |
|
||||
| People & community | Users, online state, accounts, guilds, applications, staff directory, moderation, support | Users, multi-accounts, guilds, applications, CFH, moderation actions, bans, IP/VPN, word filter, tickets, help tickets, `/mod/*` |
|
||||
| Content & engagement | Public/editorial content and engagement workflows | Articles, photos, media, banners, ads, events, polls, help content, tags, prefixes, writable boxes, email content, branding/localization surfaces |
|
||||
| Economy & catalog | Products, value, commercial assets, and economic history | Catalog, items, import/maintenance, shop, marketplace, transactions, vouchers, subscriptions, rare values, badges, achievements, sounds |
|
||||
| Hotel & world | Live hotel surfaces and world-management tools | Rooms, navigator, radio, studio/runtime asset tools, contextual hotel actions |
|
||||
| System, access & observability | Configuration, authorization, diagnostics, and privileged operations | Permissions, access audit, settings, maintenance, emulator, command center, logs, analytics, alerts, DevOps |
|
||||
|
||||
Where an existing feature spans two domains, responsibility follows the action rather than the old route. For example, the staff directory belongs to People, while the policy granting staff capabilities belongs to System and Access.
|
||||
|
||||
Domain landing pages summarize their own workflows. They do not recreate the global dashboard or become a second source of state.
|
||||
|
||||
## Operator experience
|
||||
|
||||
### Command Deck shell
|
||||
|
||||
The shared shell contains:
|
||||
|
||||
1. A compact rail for the six domains.
|
||||
2. Contextual navigation generated from the active domain manifest.
|
||||
3. A global command/search field available by keyboard.
|
||||
4. A main workspace using consistent title, context, primary action, filters, content, and feedback regions.
|
||||
5. Operator identity, effective-capability context, notifications, and session controls.
|
||||
|
||||
The shell is desktop-first, fully keyboard operable, and responsive for tablets. Phone layouts may support inspection and low-risk triage, but sensitive multi-step operations are not optimized for phone use.
|
||||
|
||||
### Adaptive dashboard
|
||||
|
||||
ACL and capability data determine:
|
||||
|
||||
- visible domains and routes;
|
||||
- mandatory queues and warnings;
|
||||
- permitted metrics and widgets;
|
||||
- available commands and search providers.
|
||||
|
||||
The operator may:
|
||||
|
||||
- pin allowed routes and safe commands;
|
||||
- reorder shortcuts and optional widgets;
|
||||
- add or remove optional allowed widgets;
|
||||
- persist preferred filters and presentation density where supported.
|
||||
|
||||
The operator may not hide mandatory warnings, reveal unauthorized data, or preserve a shortcut after its required capability is lost.
|
||||
|
||||
Preferences are server-persisted, user-scoped, schema-versioned, and non-authoritative. If no suitable existing preference store exists, the foundation adds one additive `housekeeping_user_preferences` store containing presentation state only. It never stores task status or authorization decisions. Every preference is reconciled with the current manifest and capability context when read.
|
||||
|
||||
### Standard page contract
|
||||
|
||||
Every target page follows the same structural contract:
|
||||
|
||||
- localized title, description, breadcrumb/context, and one clear primary action;
|
||||
- capability-derived actions with server authorization;
|
||||
- shared filtering, pagination, empty, loading, partial, and error states;
|
||||
- explicit unsaved-change behavior for editable forms;
|
||||
- consistent confirmation and outcome feedback;
|
||||
- stable deep links to owned entities and workflows;
|
||||
- responsive table-to-detail behavior without hiding critical fields;
|
||||
- audit context for mutations.
|
||||
|
||||
## Operational inbox
|
||||
|
||||
The inbox is a read model over domain-owned sources such as tickets, CFH reports, alerts, emulator errors, and detected anomalies.
|
||||
|
||||
Each source emits normalized work items containing at least:
|
||||
|
||||
- stable source and item IDs;
|
||||
- domain and required capability;
|
||||
- severity and source timestamp;
|
||||
- localized summary and optional context;
|
||||
- stable destination route and entity target;
|
||||
- deduplication key;
|
||||
- freshness/availability metadata.
|
||||
|
||||
The aggregator:
|
||||
|
||||
1. Requests sources independently with bounded timeouts.
|
||||
2. Filters every result against the operator's capability context.
|
||||
3. Deduplicates by stable source identity.
|
||||
4. Orders by severity, age, and domain policy.
|
||||
5. Returns both items and per-source availability.
|
||||
|
||||
The aggregator never creates, assigns, dismisses, or completes work. Selecting an item opens the owning workflow. If that workflow supports assignment or resolution, those state changes occur there.
|
||||
|
||||
A failed or timed-out source does not erase successful sources. The UI labels the missing source and the freshness of remaining data instead of presenting the whole system as healthy.
|
||||
|
||||
## Global search and command palette
|
||||
|
||||
The palette has three provider types:
|
||||
|
||||
1. **Navigation providers** for permitted routes and favorites.
|
||||
2. **Entity providers** for capability-filtered entities such as users, rooms, tickets, articles, or catalog entries.
|
||||
3. **Safe command providers** for narrowly scoped, validated, idempotent or reversible actions.
|
||||
|
||||
A mutation may run directly from the palette only when it is single-target, low impact, reviewable in the palette, protected by a specific capability, and safe against duplicate submission. It still uses the normal server command and audit path.
|
||||
|
||||
Destructive, economic, moderation, permission, bulk, or otherwise sensitive actions return a navigation intent. The target page receives validated context and shows impact, current state, required reason, confirmation, and final outcome.
|
||||
|
||||
## Data and command flow
|
||||
|
||||
### Queries
|
||||
|
||||
```text
|
||||
page or shell
|
||||
-> request-scoped capability context
|
||||
-> typed domain query
|
||||
-> existing API/repository through an adapter
|
||||
-> sanitized response
|
||||
```
|
||||
|
||||
The UI does not query arbitrary tables or reproduce sensitive filter rules. Authorization-sensitive results are filtered at the query boundary. Short-lived caching may be used for operational counts, but authorization is applied after cache lookup and sensitive per-user results are not shared across capability contexts.
|
||||
|
||||
### Commands
|
||||
|
||||
```text
|
||||
intent
|
||||
-> server capability check
|
||||
-> schema validation
|
||||
-> current-state/concurrency check
|
||||
-> domain transaction or controlled external call
|
||||
-> audit outcome
|
||||
-> typed result and cache invalidation
|
||||
```
|
||||
|
||||
Every command receives a server-issued action ID used as an idempotency key. Duplicate submissions return the original known outcome rather than repeating the mutation.
|
||||
|
||||
For records with a revision or update timestamp, edits use optimistic concurrency. A stale edit returns a conflict result and current-state reference; it is not silently overwritten. Where a source cannot expose a revision, the command performs the strongest available transactional re-read before mutation.
|
||||
|
||||
## Security and audit
|
||||
|
||||
- Default-deny server checks protect every query and command.
|
||||
- Sensitive actions require a dedicated flow, an explicit target, an impact summary, confirmation, and a non-empty operator reason.
|
||||
- Domain validation occurs after authorization and before mutation.
|
||||
- Audit is append-only from the HK application: no HK route can edit or delete audit events.
|
||||
- Audit records include actor, target, command, reason, sanitized before/after details where appropriate, outcome, timestamp, action ID, and correlation ID.
|
||||
- Secrets, credentials, tokens, and unnecessary personal data are excluded from audit payloads.
|
||||
- When data and audit share a transactional store, a privileged mutation and its audit record commit together.
|
||||
- For external operations, an intent/pending audit record is written before dispatch and completed with success or failure afterward.
|
||||
- A privileged mutation fails closed if its required audit trail cannot be established.
|
||||
|
||||
## Error model
|
||||
|
||||
Domain boundaries return typed outcomes rather than leaking raw infrastructure errors:
|
||||
|
||||
- validation failure;
|
||||
- authentication required;
|
||||
- capability denied;
|
||||
- not found;
|
||||
- stale/conflicting state;
|
||||
- dependency unavailable;
|
||||
- partial aggregate result;
|
||||
- unexpected internal failure.
|
||||
|
||||
Expected outcomes have localized, actionable messages. Unexpected failures expose a correlation ID to the operator and retain technical detail only in server logs. Forms preserve safe input after recoverable failures. Lists and the operational dashboard distinguish empty results from unavailable data.
|
||||
|
||||
## Migration inventory
|
||||
|
||||
The first subproject creates a committed migration matrix covering all 137 current pages. Each row contains:
|
||||
|
||||
- legacy path and source surface (`admin` or `mod`);
|
||||
- target domain and owning workflow;
|
||||
- target path;
|
||||
- decision: `REHOST`, `REBUILD`, `MERGE`, or `REMOVE`;
|
||||
- required read and mutation capabilities;
|
||||
- source queries and mutations;
|
||||
- audit requirement;
|
||||
- localization and accessibility status;
|
||||
- required unit, integration, and E2E coverage;
|
||||
- parity evidence and migration status.
|
||||
|
||||
Decision meanings:
|
||||
|
||||
- **REHOST**: the current UI and service are sound enough to enter the new shell after contract and ACL adaptation.
|
||||
- **REBUILD**: preserve the workflow and sound service logic, but reconstruct its interaction and page composition.
|
||||
- **MERGE**: combine duplicated routes or variants into one owning workflow with contextual views.
|
||||
- **REMOVE**: eliminate obsolete or foundation-owned behavior at cutover.
|
||||
|
||||
Mandatory consolidations:
|
||||
|
||||
- All 13 `/mod` pages merge into People and Community workflows. `/mod` does not redirect after cutover.
|
||||
- `/admin/housekeeping` ceases to exist as a named feature. Useful comparison/export history moves into System, Access, and Audit.
|
||||
- `/admin/permissions` remains the live policy editor under System and Access.
|
||||
- Legacy dashboard, hub, and manual HK-navigation concepts are removed when their responsibilities are supplied by the registry and Command Deck.
|
||||
|
||||
No page is considered migrated merely because it renders in the new shell. Its matrix row closes only after data, actions, capability behavior, audit, localization, accessibility, and required tests pass.
|
||||
|
||||
## Delivery decomposition
|
||||
|
||||
This master design controls the program. For delivery purposes it is also the approved design specification for subproject 01. Subprojects 02 through 06 require their own scoped design specifications before their implementation plans. Subprojects are delivered in this order:
|
||||
|
||||
### 01. Inventory & Foundation
|
||||
|
||||
This is the first and only scope of the initial implementation plan.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- the complete 137-page migration matrix;
|
||||
- HK manifest contracts and registry validation;
|
||||
- request-scoped capability context and server guard interfaces;
|
||||
- domain query, command, search, inbox, and widget contracts;
|
||||
- the Command Deck shell primitives and standard page-state contract;
|
||||
- six domain manifests with no migrated business workflow yet;
|
||||
- a non-production/test-only entry mechanism that cannot expose a mixed HK to normal production operators;
|
||||
- contract, capability, localization-key, accessibility-smoke, and shell tests.
|
||||
|
||||
Explicit exclusions:
|
||||
|
||||
- no current `/admin` or `/mod` route changes;
|
||||
- no production operator exposure;
|
||||
- no operational inbox aggregation;
|
||||
- no entity search implementation;
|
||||
- no domain mutation migration;
|
||||
- no legacy deletion.
|
||||
|
||||
### 02. Access, audit & system core
|
||||
|
||||
Implement the capability enforcement adapters, audit command path, error taxonomy, correlation IDs, and core observability used by every later vertical.
|
||||
|
||||
### 03. People, moderation & support
|
||||
|
||||
Deliver the first complete vertical and unify user, ticket, CFH, moderation-action, and ban workflows. This vertical proves the future removal of `/mod` without exposing a partial cutover.
|
||||
|
||||
### 04. Command Deck operations
|
||||
|
||||
Implement global search, safe commands, favorites, preferences, and the derived inbox against the sources available from completed verticals.
|
||||
|
||||
### 05. Remaining domain verticals
|
||||
|
||||
Deliver separate scoped specifications and plans for:
|
||||
|
||||
1. Content and Engagement;
|
||||
2. Hotel and World;
|
||||
3. Economy and Catalog;
|
||||
4. remaining System, Access, and Observability pages.
|
||||
|
||||
Economy and permission-affecting mutations receive the strictest confirmation, concurrency, and audit coverage.
|
||||
|
||||
### 06. Parity, cutover & cleanup
|
||||
|
||||
Close the migration matrix, run cross-role journeys and data comparisons, switch `/admin`, make `/mod` unreachable, observe the release, then delete unreachable legacy code and later remove obsolete schema safely.
|
||||
|
||||
Subproject 01 uses this specification; every later subproject has its own spec, implementation plan, tests, review, and completion gate. A later subproject may not silently expand an earlier approved scope.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
Every subproject runs proportionate checks from these layers:
|
||||
|
||||
1. **Unit tests** for manifest parsing, normalizers, policy functions, reducers, and domain services.
|
||||
2. **Contract tests** for unique IDs/routes, capability declarations, localization keys, command ownership, and provider behavior.
|
||||
3. **Integration tests** against representative repository/API implementations, including transactions, external failures, idempotency, and conflicts.
|
||||
4. **ACL matrix tests** covering permitted, denied, capability-revoked, and super-administrator cases at both render and server boundaries.
|
||||
5. **E2E journeys** for moderation, support, editorial, economy, hotel operations, and administration roles defined by capabilities rather than rank labels.
|
||||
6. **Audit assertions** after every tested mutation.
|
||||
7. **Accessibility checks** for keyboard use, focus order, names, contrast, live feedback, dialogs, and table/detail transitions.
|
||||
8. **Localization checks** rejecting new hard-coded operator copy and missing translation keys.
|
||||
9. **Visual regression checks** for the shared shell and high-risk standard states.
|
||||
10. **Performance comparison** against a recorded legacy baseline using the same environment and dataset. Comparable new flows may not regress median or p95 response time by more than 10% without an explicit reviewed exception. Performance improvements are reported only from measurements.
|
||||
|
||||
## Cutover gate
|
||||
|
||||
The atomic switch is permitted only when all of the following are true:
|
||||
|
||||
- all 137 migration rows are closed with evidence;
|
||||
- every exposed query and command has a declared and tested capability;
|
||||
- every mutation has validation and required audit coverage;
|
||||
- no blocking or critical defect remains open;
|
||||
- equivalent legacy/new counts and records have been compared for migrated read workflows;
|
||||
- role journeys for moderator, support operator, editor, economy operator, hotel operator, and administrator pass;
|
||||
- localization, accessibility, build, type, lint, test, and visual checks pass;
|
||||
- production-like smoke tests, backup verification, rollback procedure, and health checks have been rehearsed;
|
||||
- the new HK is not dependent on legacy UI routes;
|
||||
- communication and operator runbooks are ready for the clean break.
|
||||
|
||||
## Cutover and rollback
|
||||
|
||||
Before cutover, the new HK is exercised through test/staging or an explicit non-production mechanism. Read-only shadow comparisons may run against representative data. There is no production dual-write.
|
||||
|
||||
At cutover:
|
||||
|
||||
1. `/admin` changes to the new route composition in one release/flag transition.
|
||||
2. `/mod` and removed legacy subroutes become unreachable without compatibility redirects.
|
||||
3. Smoke tests verify authentication, capability filtering, representative reads, one controlled mutation per risk class, audit, and health signals.
|
||||
|
||||
Database changes required before cutover are additive and backward-compatible for the emergency rollback window. A flag or previous release can temporarily restore the legacy application if the cutover fails. During normal operation, only one HK is exposed.
|
||||
|
||||
After the agreed stability window, unreachable legacy code and flags are removed. Destructive schema cleanup is a later migration and is not coupled to the cutover release.
|
||||
|
||||
## Success criteria
|
||||
|
||||
The program is complete when:
|
||||
|
||||
- `/admin` is the single role-adaptive administration surface;
|
||||
- `/mod` and the legacy Housekeeping archive surface are gone;
|
||||
- all 137 legacy pages have an evidenced migration decision;
|
||||
- all exposed data, navigation, commands, widgets, and inbox items are capability-correct;
|
||||
- the operational inbox derives live work without owning duplicate workflow state;
|
||||
- all mutations use the domain command, validation, concurrency, idempotency, and audit path appropriate to their risk;
|
||||
- no mixed legacy/new production experience exists;
|
||||
- measured performance meets the approved comparison gate;
|
||||
- rollback and eventual legacy cleanup are complete.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
### Full greenfield rewrite
|
||||
|
||||
Rejected because it would discard reliable existing services and maximize parity, timing, and regression risk across 137 pages.
|
||||
|
||||
### Cosmetic refactor of the existing HK
|
||||
|
||||
Rejected because it would preserve duplicated `/admin` and `/mod` workflows, inconsistent page boundaries, and manual navigation debt.
|
||||
|
||||
### Separate HK service/application
|
||||
|
||||
Rejected because the current requirement does not justify another deployment, authentication boundary, or distributed consistency problem.
|
||||
|
||||
### Persistent cross-domain task database
|
||||
|
||||
Rejected because it would duplicate ticket, moderation, alert, and anomaly state and create reconciliation failure modes.
|
||||
|
||||
## Final design invariant
|
||||
|
||||
The migration may be incremental internally, but the operator-facing product is not. Until the cutover gate passes, the current HK remains the only normal production surface. After cutover, the new HK is the only surface.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Real database integration tests
|
||||
|
||||
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
||||
|
||||
Sixteen database tests exercise the production database commands, news actions, public article query and delivery worker against MariaDB 11.4.5 and Redis 7.4.2:
|
||||
|
||||
- Migration CLI replay/status, committed catalog bulk edits and undo history, complete rollback after an audit insert fails, and competing catalog previews.
|
||||
- Real Redis expiry metadata and cache-key isolation, concurrent request idempotency, operation/outbox rollback, and exclusive delivery claims.
|
||||
- Concurrent duplicate draft creation and publication produce one article, one revision, one audit update and one effect per operation. The public query changes from cached absence to the full published content, including Unicode and a body larger than a TEXT column.
|
||||
- A database trigger rejects the publication effect after the article, revision and audit writes. The transaction restores all preceding state; the identical request can then retry successfully without duplicate history.
|
||||
- A trigger rejects the second scheduled-publication effect. Both article updates and both operations roll back, including the first queued effect.
|
||||
- Competing scheduler ticks publish each due article once, preserve future articles and drafts, retain an unsigned bigint ID beyond JavaScript's safe integer range, and attribute a legacy authorless article to the system actor.
|
||||
- A real Redis client disconnect leaves a publication committed and readable directly from MariaDB. The worker records a pending failed attempt. Reconnecting retains the stale cached absence until a successful outbox retry rotates the cache revision; the public query then returns the published article. The test advances only the queued retry timestamp to avoid sleeping.
|
||||
|
||||
Each execution starts disposable containers with random exposed ports and generated passwords. No production URLs, volumes or credentials are used. The real migration CLI is copied beneath a temporary isolated fixture root so its environment loader cannot read the checkout environment file. Cleanup attempts all connections and containers even if a previous cleanup fails. The fixture inspection connection reads timestamps as UTC; the application keeps its production connection settings and host timezone. Each test receives a fresh news-cache revision, and the disconnect test reconnects its client in a `finally` block.
|
||||
|
||||
Only authorization/session lookup, translation lookup, Next.js revalidation/redirects, and the external publication webhook are mocked for the news actions. MariaDB, Drizzle, transactions, article revisions, history, operation deduplication, outbox claims/retries, Redis caching, the publication scheduler, public article lookup and the news delivery handler use their production implementations.
|
||||
|
||||
The fixture models the emulator's catalog/audit baseline plus the legacy article columns. Real migrations 0025-0029 and 0031 supply publication, editorial recovery, catalog packages, history and operations tables. This does not certify every historical emulator schema or migration.
|
||||
|
||||
These are application-service integration tests, not browser or HTTP end-to-end tests: they do not start a Next.js server, render the news page, verify login/ACL behavior, or send external webhooks. The cache outage test closes and restores the actual application Redis connection; it does not stop the Redis server or model a multi-host network partition. Passing TypeScript or unit tests without Docker is not a passing result for this suite.
|
||||
|
||||
Public comment pagination and reaction aggregation are covered by unit tests using the production Drizzle query builder with a substituted database transport, plus server-rendered page tests with substituted service results. This integration fixture does not create users or article-reactions tables and does not execute the public pagination and aggregation queries against MariaDB; its article-comments table is used for submission tests. Comment submission now additionally uses real MariaDB and Redis to verify publication eligibility, filtering and the shared quota across the actual form/API handlers, with authentication replaced at the boundary. The article-cache upgrade test verifies that legacy cached absence is ignored under the versioned key; it is a unit test, separate from the real Redis publication and delivery checks above.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Real news browser gate
|
||||
|
||||
Run `pnpm test:news:real` with `NEWS_E2E_IMAGE=epicnext-cms:<candidate-tag>`. Docker CLI, a working Docker daemon, the OpenSSL CLI with `-addext` support, installed project dependencies and Playwright Chromium are required. The runner deliberately fails when the image or Docker is unavailable. It never silently skips the browser journey.
|
||||
|
||||
`NEWS_E2E_RELEASE=<full-commit-sha>` additionally verifies the image revision label. The runner resolves the local image to its immutable ID before starting it. It does not build or pull the application image. MariaDB 11.4.5 and Redis 7.4.2 Alpine are disposable Testcontainers dependencies and may be pulled when absent.
|
||||
|
||||
The deployment script runs this gate after building the candidate and extracting its performance report, before live database migrations and container cutover. The general Playwright suite excludes `e2e/news-real`; this suite has its own configuration and requires the runner.
|
||||
|
||||
## What the browser proves
|
||||
|
||||
One Chromium journey exercises the candidate's normal Docker entrypoint and standalone Next server:
|
||||
|
||||
1. An anonymous request to the staff editor reaches the login page.
|
||||
2. The browser signs in through the actual login form, credential precheck and Auth.js handler. The test verifies the real Secure/HttpOnly session cookie and database login record.
|
||||
3. A rank 7 editor, below an occupied rank 9 owner, accesses news through three explicit ACL grants: `admin.dashboard`, `admin.news.view` and `admin.news.edit`.
|
||||
4. The browser types Unicode content into the bundled TinyMCE editor and saves a draft through the real server action. The persisted HTML must equal what the form submitted.
|
||||
5. An independent anonymous browser sees the not-found screen; the public articles API returns an empty list. Redis contains the cached null article. Next can stream a not-found screen with HTTP 200, so this check verifies the rendered 404 screen as well as absence from the API.
|
||||
6. The editor reopens and previews the saved draft in the real preview iframe. Its title, summary and rendered body match; it remains a draft with no article revision created by previewing.
|
||||
7. Publishing through the editor produces one article, a publication timestamp, one previous-draft revision, a before/after audit entry, two completed operation results and two news-refresh outbox entries.
|
||||
8. The anonymous page and API immediately show the article, using a new shared Redis cache revision. The browser remains signed out.
|
||||
|
||||
No authentication, application HTTP responses, mutations, database calls or cache calls are mocked. The browser blocks resources outside the local fixture origin, such as external avatars. This does not replace any application response. A local HTTPS edge passes requests to Next and sets trusted forwarding headers, allowing the production Secure-cookie behavior to run normally.
|
||||
|
||||
The fresh browser contexts retain normal application service-worker registration. The application worker does not cache article or authentication responses. Playwright's worker-blocking injection is avoided because it throws inside the sandboxed preview; browser errors are still checked without filtering. The preview is closed through its Close button, since keyboard events inside its sandbox do not reach the parent dialog.
|
||||
|
||||
## Isolation and cleanup
|
||||
|
||||
- Each run creates a random Docker network and fresh MariaDB, Redis and candidate containers. All mapped ports are allocated dynamically. The HTTPS listener binds only to `127.0.0.1`.
|
||||
- No production container, database, volume or credentials are reused. Container configuration is explicit. Child processes receive a small environment whitelist; checkout `.env` and installation service credentials are not forwarded.
|
||||
- The database uses the current ORM column definitions for 29 tables needed by login, site/admin layouts and news. Unique constraints and composite primary keys are preserved. The emulator-owned `permission_ranks` fixture provides the rank authority columns this flow queries. Real CMS migrations 0026 and 0031 create the recovery/revision and operation/outbox tables. This is a focused fixture, not a replacement for the emulator's complete schema or migration coverage.
|
||||
- Passwords, Redis credentials and the Auth.js secret are generated per run. The owner has an unknown random password and is never used to bypass ACL checks. Email verification is enabled with a verified fixture staff account. CAPTCHA and forced staff 2FA use their ordinary disabled installation settings; their challenges are outside this flow.
|
||||
- OpenSSL generates a fresh localhost certificate and private key in the OS temporary directory for each run. The certificate lasts one day and covers `127.0.0.1` and `localhost`. Playwright trusts this self-signed endpoint only in this suite. No certificate or key is committed or copied into build artifacts; cleanup removes both with the temporary credentials.
|
||||
- Credentials used by the test worker are stored in an OS temporary directory with a mode-0600 file, then removed. Cleanup stops only containers and the network created by this runner; Testcontainers also registers them with its resource reaper.
|
||||
- Failure artifacts are under `test-results/news-real` and `playwright-report/news-real`. Server logs redact generated passwords/secrets. Playwright traces can contain the short-lived fixture login/session data; all associated services are destroyed after the run.
|
||||
|
||||
This browser gate checks the synchronous editor/publication path and durable delivery intent. Scheduler concurrency, rollback, duplicate requests, worker delivery and Redis outage/recovery remain covered by `pnpm test:integration`. It does not claim to test emulator connectivity, external notifications, CAPTCHA/2FA challenges or production data.
|
||||
|
||||
The local workstation currently has no working Docker daemon. Type checks, lint and fixture/bootstrap checks can run there; a passing real browser result must come from the Docker-capable CI gate.
|
||||
+2
-6
@@ -6,16 +6,12 @@ import { defineConfig } from "drizzle-kit";
|
||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
||||
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
||||
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
||||
const dbUrl = process.env.DATABASE_URL ?? "";
|
||||
const isPostgres =
|
||||
dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://");
|
||||
|
||||
export default defineConfig({
|
||||
dialect: isPostgres ? "postgresql" : "mysql",
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/drafts",
|
||||
dbCredentials: {
|
||||
url: dbUrl,
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
-- 0036_used_vouchers_unique.sql
|
||||
-- One redemption per user and voucher, enforced by the database.
|
||||
--
|
||||
-- The redeem action checked for an earlier use and then inserted, so parallel
|
||||
-- requests could each pass the check and each be credited. With this key the
|
||||
-- second insert fails and its transaction (which also claims the use) rolls back.
|
||||
--
|
||||
-- Rows from such races already in the table would stop the key from being added:
|
||||
-- keep the oldest row of each user/voucher pair.
|
||||
DELETE newer FROM `website_used_shop_vouchers` newer
|
||||
JOIN `website_used_shop_vouchers` older
|
||||
ON older.user_id = newer.user_id
|
||||
AND older.voucher_id = newer.voucher_id
|
||||
AND older.id < newer.id;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `website_used_shop_vouchers_user_voucher_uk`
|
||||
ON `website_used_shop_vouchers` (`user_id`, `voucher_id`);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 0037_users_password_length.sql
|
||||
-- Room for Argon2id password hashes in the emulator's users table.
|
||||
--
|
||||
-- New passwords are stored as encoded Argon2id hashes (about 97 characters,
|
||||
-- `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>`), and legacy hashes are
|
||||
-- upgraded to that format on login. Polaris and Arcturus create
|
||||
-- `users.password` as VARCHAR(64), so under MariaDB's strict mode every
|
||||
-- registration failed with "Data too long for column 'password'" (and a
|
||||
-- legacy-hash upgrade would fail the same way). The emulator itself never
|
||||
-- reads the password (players log in to the hotel with an SSO ticket), so a
|
||||
-- wider column changes nothing for it. AtomCMS' bcrypt hashes (60 characters)
|
||||
-- still fit as they are.
|
||||
ALTER TABLE `users` MODIFY `password` VARCHAR(255) NOT NULL;
|
||||
@@ -140,7 +140,7 @@ test.describe("rejects malformed figures", () => {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
`/cms_api/imaging/avatar${query}`,
|
||||
`/api/imaging/avatar${query}`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
@@ -171,14 +171,14 @@ test("collapses a burst of concurrent requests for one figure", async ({
|
||||
test("the badge endpoint serves an image and revalidates", async ({
|
||||
request,
|
||||
}) => {
|
||||
const first = await getThroughEdge(request, "/cms_api/imaging/badge?code=ADM");
|
||||
const first = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(first.status()).toBe(200);
|
||||
expect(first.headers()["content-type"]).toContain("image/");
|
||||
expect(first.headers()["access-control-allow-origin"]).toBe("*");
|
||||
const etag = first.headers().etag;
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const second = await getThroughEdge(request, "/cms_api/imaging/badge?code=ADM", {
|
||||
const second = await getThroughEdge(request, "/api/imaging/badge?code=ADM", {
|
||||
headers: { "if-none-match": etag as string },
|
||||
});
|
||||
expect(second.status()).toBe(304);
|
||||
@@ -189,7 +189,7 @@ test("the badge endpoint can resolve from a local source", async ({
|
||||
}) => {
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
"/cms_api/imaging/badge?code=ADM&source=local",
|
||||
"/api/imaging/badge?code=ADM&source=local",
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
@@ -208,7 +208,7 @@ test.describe("rejects malformed badge codes", () => {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
`/cms_api/imaging/badge${query}`,
|
||||
`/api/imaging/badge${query}`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
@@ -218,7 +218,7 @@ test.describe("rejects malformed badge codes", () => {
|
||||
test("the badge preflight advertises GET", async ({ request }) => {
|
||||
const response = await fetchThroughEdge(
|
||||
request,
|
||||
"/cms_api/imaging/badge?code=ADM",
|
||||
"/api/imaging/badge?code=ADM",
|
||||
{
|
||||
method: "OPTIONS",
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ export const FIGURE =
|
||||
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
|
||||
|
||||
export const avatarPath = (params: Record<string, string> = {}) =>
|
||||
`/cms_api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||
|
||||
/** Public imager endpoint served through the edge proxy (imager container). */
|
||||
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./imaging-target";
|
||||
|
||||
// Regression guard for the public topology: the edge proxy must front the
|
||||
// imager container at `/imaging/*` while `/cms_api/imaging/*` reaches the CMS.
|
||||
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
|
||||
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
|
||||
// Only meaningful against the public origin, so it is skipped when the CMS is
|
||||
// reached directly (set `IMAGING_EDGE=1` for the public origin).
|
||||
@@ -40,7 +40,7 @@ test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||
});
|
||||
|
||||
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
|
||||
const response = await getThroughEdge(request, "/cms_api/imaging/badge?code=ADM");
|
||||
const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
|
||||
// navigates there explicitly anyway; this asserts the redirect target.
|
||||
await expect(page).toHaveURL(/\/admin\/articles\/new(?:\?|$)/);
|
||||
const session = await context.request
|
||||
.get("/cms_api/auth/session")
|
||||
.get("/api/auth/session")
|
||||
.then((response) => response.json());
|
||||
expect(session.user).toMatchObject({
|
||||
id: String(fixture.userId),
|
||||
@@ -168,7 +168,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
|
||||
await expect(reader.locator(".error-screen-code")).toHaveText("404");
|
||||
await expect(publicTitle).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/cms_api/articles")
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toEqual([]);
|
||||
expect(listing.meta.total).toBe(0);
|
||||
@@ -266,7 +266,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
|
||||
await expect(reader.locator(".article-body")).toContainText(body);
|
||||
await expect(reader.locator(".error-screen-code")).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/cms_api/articles")
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toHaveLength(1);
|
||||
expect(listing.data[0]).toMatchObject({
|
||||
|
||||
@@ -253,7 +253,7 @@ try {
|
||||
LOG_LEVEL: "warn",
|
||||
})
|
||||
.withExposedPorts(3002)
|
||||
.withWaitStrategy(Wait.forHttp("/cms_api/health", 3002).forStatusCode(200))
|
||||
.withWaitStrategy(Wait.forHttp("/api/health", 3002).forStatusCode(200))
|
||||
.withStartupTimeout(120_000)
|
||||
.withLogConsumer((stream) =>
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
@@ -263,7 +263,7 @@ try {
|
||||
.start();
|
||||
upstream = new URL(`http://${app.getHost()}:${app.getMappedPort(3002)}`);
|
||||
abort.signal.throwIfAborted();
|
||||
const health = (await fetch(new URL("/cms_api/health", upstream), {
|
||||
const health = (await fetch(new URL("/api/health", upstream), {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).then((response) => response.json())) as {
|
||||
status?: string;
|
||||
|
||||
@@ -18,7 +18,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
|
||||
page,
|
||||
}) => {
|
||||
let retries = 0;
|
||||
await page.route("**/cms_api/admin/studio/import-attachment", async (route) => {
|
||||
await page.route("**/api/admin/studio/import-attachment", async (route) => {
|
||||
expect(route.request().method()).toBe("POST");
|
||||
expect(route.request().postData()).toContain('name="classname"');
|
||||
expect(route.request().postData()).toContain("fixture_chair");
|
||||
@@ -27,7 +27,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
|
||||
);
|
||||
await route.fulfill({ json: { ok: true, attachmentId } });
|
||||
});
|
||||
await page.route("**/cms_api/admin/studio/import-jobs", async (route) => {
|
||||
await page.route("**/api/admin/studio/import-jobs", async (route) => {
|
||||
retries++;
|
||||
expect(route.request().method()).toBe("PATCH");
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
@@ -82,11 +82,11 @@ for (const scenario of [
|
||||
page,
|
||||
}) => {
|
||||
let retries = 0;
|
||||
await page.route("**/cms_api/admin/studio/import-jobs", async (route) => {
|
||||
await page.route("**/api/admin/studio/import-jobs", async (route) => {
|
||||
retries++;
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
await page.route("**/cms_api/admin/studio/import-attachment", (route) =>
|
||||
await page.route("**/api/admin/studio/import-attachment", (route) =>
|
||||
scenario.status
|
||||
? route.fulfill({
|
||||
status: scenario.status,
|
||||
|
||||
@@ -33,12 +33,12 @@ const catalog = {
|
||||
};
|
||||
|
||||
async function mockStudio(page: Page) {
|
||||
await page.route("**/cms_api/**", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/cms_api/admin/catalog/revision")
|
||||
if (url.pathname === "/api/admin/catalog/revision")
|
||||
return route.fulfill({ json: { ok: true, revision: catalog.revision } });
|
||||
if (
|
||||
url.pathname === "/cms_api/admin/catalog/tree" &&
|
||||
url.pathname === "/api/admin/catalog/tree" &&
|
||||
url.searchParams.get("mode") === "full"
|
||||
)
|
||||
return route.fulfill({
|
||||
|
||||
@@ -241,7 +241,7 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/studio/import-jobs") {
|
||||
if (url.pathname === "/api/admin/studio/import-jobs") {
|
||||
if (req.method === "PATCH") {
|
||||
const job = jobs.get(body.id);
|
||||
if (job) job.state = "cancelled";
|
||||
@@ -253,7 +253,7 @@ const server = createServer(async (req, res) => {
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/import/official/sync-all") {
|
||||
if (url.pathname === "/api/admin/import/official/sync-all") {
|
||||
if (!jobs.has(body.id))
|
||||
jobs.set(body.id, {
|
||||
id: body.id,
|
||||
@@ -279,7 +279,7 @@ const server = createServer(async (req, res) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return json(res, { ok: true, job: jobs.get(body.id) });
|
||||
}
|
||||
if (url.pathname === "/cms_api/media") return json(res, { files: [] });
|
||||
if (url.pathname === "/api/media") return json(res, { files: [] });
|
||||
if (url.pathname.startsWith("/fixture/actions/")) {
|
||||
const name = url.pathname.split("/").at(-1);
|
||||
if (name === "load-recovery")
|
||||
|
||||
@@ -9,7 +9,7 @@ test("history hook deduplicates refreshes, pages and reports completion once", a
|
||||
}) => {
|
||||
let requests = 0;
|
||||
let completed = false;
|
||||
await page.route("**/cms_api/admin/studio/import-jobs?*", async (route) => {
|
||||
await page.route("**/api/admin/studio/import-jobs?*", async (route) => {
|
||||
requests++;
|
||||
const secondPage = new URL(route.request().url()).searchParams.has(
|
||||
"before",
|
||||
@@ -68,7 +68,7 @@ test("history polling pauses while hidden and refreshes on visibility return", a
|
||||
}) => {
|
||||
let requests = 0;
|
||||
await page.clock.install();
|
||||
await page.route("**/cms_api/admin/studio/import-jobs?*", (route) => {
|
||||
await page.route("**/api/admin/studio/import-jobs?*", (route) => {
|
||||
requests++;
|
||||
return route.fulfill({
|
||||
json: { ok: true, jobs: [furnitureJobFixture], nextCursor: null },
|
||||
@@ -102,7 +102,7 @@ test("history polling pauses while hidden and refreshes on visibility return", a
|
||||
test("history shows the current phase and validated recovery source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/cms_api/admin/studio/import-jobs?*", (route) =>
|
||||
await page.route("**/api/admin/studio/import-jobs?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
ok: true,
|
||||
@@ -148,10 +148,10 @@ test("uncertain import submissions keep their identity after a page reload", asy
|
||||
page,
|
||||
}) => {
|
||||
const ids: string[] = [];
|
||||
await page.route("**/cms_api/admin/csrf", (route) =>
|
||||
await page.route("**/api/admin/csrf", (route) =>
|
||||
route.fulfill({ json: { token: "a".repeat(64) } }),
|
||||
);
|
||||
await page.route("**/cms_api/admin/studio/import-jobs**", async (route) => {
|
||||
await page.route("**/api/admin/studio/import-jobs**", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,7 @@ test("notification reads persist, new replies are unread and failed preferences
|
||||
let key = "support:42";
|
||||
const readKeys = new Set<string>();
|
||||
let fail = false;
|
||||
await page.route("**/cms_api/notifications**", async (route) => {
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
if (fail)
|
||||
return route.fulfill({ status: 503, json: { error: "Unavailable" } });
|
||||
@@ -83,7 +83,7 @@ test("notification service failure offers a retry and recovers to empty state",
|
||||
page,
|
||||
}) => {
|
||||
let failing = true;
|
||||
await page.route("**/cms_api/notifications**", (route) =>
|
||||
await page.route("**/api/notifications**", (route) =>
|
||||
route.fulfill(
|
||||
failing
|
||||
? { status: 503, json: { error: "Unavailable" } }
|
||||
@@ -113,7 +113,7 @@ test("category changes persist and pagination recovers when filtering shrinks th
|
||||
page,
|
||||
}) => {
|
||||
let enabled = true;
|
||||
await page.route("**/cms_api/notifications**", async (route) => {
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
enabled = route.request().postDataJSON().preferences.support;
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
|
||||
@@ -49,11 +49,11 @@ test("the furniture pane is painted, not merely present in the DOM", async ({
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.route("**/cms_api/**", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (url.pathname === "/cms_api/admin/import/furni") {
|
||||
if (url.pathname === "/api/admin/import/furni") {
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.searchParams.get("action") === "stats"
|
||||
@@ -64,11 +64,11 @@ test("the furniture pane is painted, not merely present in the DOM", async ({
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/import/clone")
|
||||
if (url.pathname === "/api/admin/import/clone")
|
||||
return route.fulfill({ json: { sources: [] } });
|
||||
if (url.pathname === "/cms_api/admin/studio/import-jobs")
|
||||
if (url.pathname === "/api/admin/studio/import-jobs")
|
||||
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||
if (url.pathname === "/cms_api/admin/studio/nitro-quality")
|
||||
if (url.pathname === "/api/admin/studio/nitro-quality")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
report: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const items = Array.from({ length: 60 }, (_, index) => ({
|
||||
iconUrl: "/fixture/cover.svg",
|
||||
}));
|
||||
async function mockStudio(page: Page) {
|
||||
await page.route("**/cms_api/**", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/inspect"))
|
||||
return route.fulfill({
|
||||
@@ -147,7 +147,7 @@ test("an older search response cannot replace newer furniture", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockStudio(page);
|
||||
await page.route("**/cms_api/admin/import/furni?*", async (route) => {
|
||||
await page.route("**/api/admin/import/furni?*", async (route) => {
|
||||
const search = new URL(route.request().url()).searchParams.get("search");
|
||||
if (!search) return route.fallback();
|
||||
if (search === "old")
|
||||
|
||||
+13
-13
@@ -35,10 +35,10 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
let metadataRequests = 0;
|
||||
let qualityRequests = 0;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/cms_api/**", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === "/cms_api/admin/studio/nitro-quality") {
|
||||
if (url.pathname === "/api/admin/studio/nitro-quality") {
|
||||
qualityRequests++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
@@ -55,7 +55,7 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/studio/inspect") {
|
||||
if (url.pathname === "/api/admin/studio/inspect") {
|
||||
const { classnames } = request.postDataJSON() as { classnames: string[] };
|
||||
return route.fulfill({
|
||||
json: {
|
||||
@@ -71,7 +71,7 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/studio/source-assets") {
|
||||
if (url.pathname === "/api/admin/studio/source-assets") {
|
||||
const body = request.postDataJSON() as {
|
||||
items: Array<{ classname: string }>;
|
||||
};
|
||||
@@ -107,7 +107,7 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/import/furni") {
|
||||
if (url.pathname === "/api/admin/import/furni") {
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.searchParams.get("action") === "stats"
|
||||
@@ -118,9 +118,9 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/import/clone")
|
||||
if (url.pathname === "/api/admin/import/clone")
|
||||
return route.fulfill({ json: { sources: [] } });
|
||||
if (url.pathname === "/cms_api/admin/studio/import-jobs")
|
||||
if (url.pathname === "/api/admin/studio/import-jobs")
|
||||
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
@@ -220,7 +220,7 @@ test("Studio loads organize imports on demand and preserves the dialog on reopen
|
||||
json: { error: "No mutations expected" },
|
||||
});
|
||||
});
|
||||
await page.route("**/cms_api/**", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== "GET") {
|
||||
@@ -230,7 +230,7 @@ test("Studio loads organize imports on demand and preserves the dialog on reopen
|
||||
json: { error: "No mutations expected" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/import/organize") {
|
||||
if (url.pathname === "/api/admin/import/organize") {
|
||||
organizeRequests++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
@@ -263,9 +263,9 @@ test("Studio loads organize imports on demand and preserves the dialog on reopen
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/cms_api/admin/catalog/tree")
|
||||
if (url.pathname === "/api/admin/catalog/tree")
|
||||
return route.fulfill({ json: { pages: [] } });
|
||||
if (url.pathname === "/cms_api/admin/import/furni")
|
||||
if (url.pathname === "/api/admin/import/furni")
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.searchParams.get("action") === "stats"
|
||||
@@ -275,9 +275,9 @@ test("Studio loads organize imports on demand and preserves the dialog on reopen
|
||||
meta: { currentPage: 1, lastPage: 1, total: 0, perPage: 20 },
|
||||
},
|
||||
});
|
||||
if (url.pathname === "/cms_api/admin/import/clone")
|
||||
if (url.pathname === "/api/admin/import/clone")
|
||||
return route.fulfill({ json: { sources: [] } });
|
||||
if (url.pathname === "/cms_api/admin/studio/import-jobs")
|
||||
if (url.pathname === "/api/admin/studio/import-jobs")
|
||||
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
|
||||
@@ -74,7 +74,7 @@ let publicNews: typeof import("@/lib/services/news-detail");
|
||||
let commentSubmission: typeof import("@/lib/services/article-comment-submission");
|
||||
let commentModeration: typeof import("@/lib/services/moderation");
|
||||
let commentAction: typeof import("@/actions/article-comments");
|
||||
let commentApi: typeof import("@/app/cms_api/articles/[slug]/comment/route");
|
||||
let commentApi: typeof import("@/app/api/articles/[slug]/comment/route");
|
||||
let scheduler: typeof import("@/lib/services/news-scheduler");
|
||||
let worker: typeof import("@/features/operations/worker");
|
||||
let migrationRoot: string | undefined;
|
||||
@@ -217,7 +217,7 @@ beforeAll(async () => {
|
||||
commentSubmission = await import("@/lib/services/article-comment-submission");
|
||||
commentModeration = await import("@/lib/services/moderation");
|
||||
commentAction = await import("@/actions/article-comments");
|
||||
commentApi = await import("@/app/cms_api/articles/[slug]/comment/route");
|
||||
commentApi = await import("@/app/api/articles/[slug]/comment/route");
|
||||
scheduler = await import("@/lib/services/news-scheduler");
|
||||
worker = await import("@/features/operations/worker");
|
||||
});
|
||||
@@ -972,7 +972,7 @@ describe("real comment publication, moderation and shared quota", () => {
|
||||
form.set("userId", "999");
|
||||
const api = () =>
|
||||
commentApi.POST(
|
||||
new Request("https://hotel.test/cms_api/articles/comment-public/comment", {
|
||||
new Request("https://hotel.test/api/articles/comment-public/comment", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ comment: "Shared quota", userId: 999 }),
|
||||
|
||||
@@ -74,12 +74,6 @@ const nextConfig: NextConfig = {
|
||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
reactStrictMode: true,
|
||||
// The All-in-1 Installer builds published commits, which were type-checked before they were
|
||||
// pushed: it sets CMS_SKIP_BUILD_TYPECHECK=1 to save the build's own check. Unset, the build
|
||||
// checks types as always.
|
||||
typescript: {
|
||||
ignoreBuildErrors: process.env.CMS_SKIP_BUILD_TYPECHECK === "1",
|
||||
},
|
||||
compress: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
// `isomorphic-dompurify` builds a DOM through jsdom on the server. Bundled,
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
"pg": "^8.23.1",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
@@ -101,7 +100,6 @@
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.6.5",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
|
||||
Generated
+2453
-4664
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,14 @@
|
||||
"version": "1.0.0",
|
||||
"description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS."
|
||||
},
|
||||
"servers": [{ "url": "/cms_api", "description": "Local API" }],
|
||||
"servers": [{ "url": "/api", "description": "Local API" }],
|
||||
"security": [{ "bearerAuth": [], "sessionAuth": [] }],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"description": "Laravel Sanctum-compatible Bearer token from /cms_api/tokens"
|
||||
"description": "Laravel Sanctum-compatible Bearer token from /api/tokens"
|
||||
},
|
||||
"sessionAuth": {
|
||||
"type": "apiKey",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,402 @@
|
||||
{
|
||||
"badge_name_Z75": "Training camp!",
|
||||
"badge_desc_Z75": "Per esser diventato un guerriero DOC!",
|
||||
"badge_name_Z67": "Coniglio Scaccia-Mito",
|
||||
"badge_desc_Z67": "La Sicurezza in Habbo non è una leggenda!",
|
||||
"badge_name_Z64": "Topo da Laboratorio",
|
||||
"badge_desc_Z64": "Beta Tester II",
|
||||
"badge_name_Z63": "Topo da Laboratorio",
|
||||
"badge_desc_Z63": "Beta Tester I",
|
||||
"badge_name_Z39": "Hotel Bau",
|
||||
"badge_desc_Z39": "Tutti a casa finalmente!",
|
||||
"badge_name_Z38": "Direttore Hotel Bau",
|
||||
"badge_desc_Z38": "Hotel a ***3 stelle",
|
||||
"badge_name_Z37": "Direttore Hotel Bau",
|
||||
"badge_desc_Z37": "Hotel a **2 stelle",
|
||||
"badge_name_Z36": "Direttore Hotel Bau",
|
||||
"badge_desc_Z36": "Hotel a *1 stella",
|
||||
"badge_name_Z35": "Hotel Bau",
|
||||
"badge_desc_Z35": "Per aver ritrovato i cuccioli",
|
||||
"badge_name_Z26": "Futuro Re",
|
||||
"badge_desc_Z26": "Sarai tu il prossimo Re?",
|
||||
"badge_name_Z16": "Campagna di Sicurezza Gennaio 2017",
|
||||
"badge_desc_Z16": "Per essere un esperto nel proteggere il mio account!",
|
||||
"badge_name_Z09": "In Campeggio",
|
||||
"badge_desc_Z09": "Campagna Campeggio con i Lupetti - 2009",
|
||||
"badge_name_Z05": "Ex Habbo eXpert",
|
||||
"badge_desc_Z05": "Gli Habbo X erano guide volontarie scelte dello Staff tra il 2006 e il 2008.",
|
||||
"badge_name_Z02": "W la Terra",
|
||||
"badge_desc_Z02": "Per i veri amanti della natura!",
|
||||
"badge_name_Z01": "Wow or not?",
|
||||
"badge_desc_Z01": "Hai impressionato la giuria con la tua sfilata!",
|
||||
"badge_name_YAK": "x",
|
||||
"badge_desc_YAK": "x",
|
||||
"badge_name_XXX": "x",
|
||||
"badge_desc_XXX": "x",
|
||||
"badge_name_XRLTD": "Distintivo Palla di Vetro con Neve",
|
||||
"badge_desc_XRLTD": "Per avere acquistato questo Raro LTD di Natale",
|
||||
"badge_name_XMSR1": "Macchina Magica",
|
||||
"badge_desc_XMSR1": "Per comprare una Macchina Rara Magica",
|
||||
"badge_name_XMS14": "Gnomi all'avventura!",
|
||||
"badge_desc_XMS14": "Per aver vinto la Competizione Video!",
|
||||
"badge_name_XMS13": "Cin cin!",
|
||||
"badge_desc_XMS13": "Attento a non versartelo addosso",
|
||||
"badge_name_XMS12": "Organizzatore di Feste",
|
||||
"badge_desc_XMS12": "... da urlo!",
|
||||
"badge_name_XMS10": "Postazione di Controllo",
|
||||
"badge_desc_XMS10": "Per comprare una Postazione di Controllo Rara",
|
||||
"badge_name_XMS07": "Babbo Natale e i 3 Re Magi",
|
||||
"badge_desc_XMS07": "Babbo Natale ha molte conoscenze...",
|
||||
"badge_name_XMS06": "Natale in Europa",
|
||||
"badge_desc_XMS06": "Un Natale dei più tipici!",
|
||||
"badge_name_XMS05": "Natale sulla Spiaggia",
|
||||
"badge_desc_XMS05": "Festeggia come un Brasiliano!",
|
||||
"badge_name_XMS04": "Natale senza Natale",
|
||||
"badge_desc_XMS04": "Festeggia come in Turchia",
|
||||
"badge_name_XMS03": "Decoratore di Alberi Natalizi",
|
||||
"badge_desc_XMS03": "Per avere decorato l'Albero di Natale Habbo 2013",
|
||||
"badge_name_XMS01": "Bottega di Babbo Natale",
|
||||
"badge_desc_XMS01": "Per avere acquistato l'Affare Stanza natalizio",
|
||||
"badge_name_XMH20": "Pupazzo Gatto Scheletrico",
|
||||
"badge_desc_XMH20": "Per avere acquistato il Pupazzo Gatto Scheletrico",
|
||||
"badge_name_XMH19": "Pupazzo Tristo Mietitore",
|
||||
"badge_desc_XMH19": "Per avere acquistato il Pupazzo Tristo Mietitore",
|
||||
"badge_name_XMH18": "Pupazzo Punk Rock",
|
||||
"badge_desc_XMH18": "Per avere acquistato il Pupazzo Punk Rock",
|
||||
"badge_name_XMH17": "Pupazzo Ragazza Habbo",
|
||||
"badge_desc_XMH17": "Per avere acquistato il Pupazzo Ragazza Habbo",
|
||||
"badge_name_XMH16": "Pupazzo Lucha Libre",
|
||||
"badge_desc_XMH16": "Per avere acquistato il Pupazzo Lucha Libre",
|
||||
"badge_name_XMH15": "Pupazzo Scheletro Tatuato",
|
||||
"badge_desc_XMH15": "Per avere acquistato il Pupazzo Scheletro Tatuato",
|
||||
"badge_name_XMH14": "Pupazzo Ragazzo Habbo",
|
||||
"badge_desc_XMH14": "Per avere acquistato il Pupazzo Ragazzo Habbo",
|
||||
"badge_name_XMH13": "Pupazzo in Giacca e Cravatta",
|
||||
"badge_desc_XMH13": "Per avere acquistato il Pupazzo in Giacca e Cravatta",
|
||||
"badge_name_XMH12": "Pupazzo Catrina",
|
||||
"badge_desc_XMH12": "Per avere acquistato il Pupazzo Catrina",
|
||||
"badge_name_XMH11": "Pupazzo Mariachi",
|
||||
"badge_desc_XMH11": "Per avere acquistato il Pupazzo Mariachi",
|
||||
"badge_name_XMB": "Mr.Pupazzo",
|
||||
"badge_desc_XMB": "Con cilindro e carota è il signore delle nevi!",
|
||||
"badge_name_XmasRoom_Top100": "Top 100 Castello di Natale",
|
||||
"badge_desc_XmasRoom_Top100": "Per essere rientrat@ nella top 100 della Competizione Stanza Castello di Natale",
|
||||
"badge_name_XmasRoom_Top10": "Top 10 Castello di Natale",
|
||||
"badge_desc_XmasRoom_Top10": "Per essere rientrat@ nella top 10 della Competizione Stanza Castello di Natale",
|
||||
"badge_name_XmasRoom": "Partecipante Castello di Natale",
|
||||
"badge_desc_XmasRoom": "Per aver partecipato alla Competizione Stanza Castello di Natale",
|
||||
"badge_name_XMAS2": "Ninnolo",
|
||||
"badge_desc_XMAS2": "Livello III",
|
||||
"badge_name_XMAS1": "Ninnolo",
|
||||
"badge_desc_XMAS1": "Livello II",
|
||||
"badge_name_XMAS0": "Ninnolo",
|
||||
"badge_desc_XMAS0": "Livello I",
|
||||
"badge_name_XMA": "Smile Congelato",
|
||||
"badge_desc_XMA": "Il suo sorriso ti scalda il cuore",
|
||||
"badge_name_XM9": "Palla di Neve",
|
||||
"badge_desc_XM9": "Deve ancora fare parecchia strada per diventare un pupazzo!",
|
||||
"badge_name_XM8": "Boccale di Birra",
|
||||
"badge_desc_XM8": "Habbo Natale 2007",
|
||||
"badge_name_XM7": "Moneta",
|
||||
"badge_desc_XM7": "Habbo Natale 2007",
|
||||
"badge_name_XM6": "Robot di Santa 3000",
|
||||
"badge_desc_XM6": "Habbo Natale 2007",
|
||||
"badge_name_XM5": "Fiocco di Neve",
|
||||
"badge_desc_XM5": "Habbo Natale 2007",
|
||||
"badge_name_XM4": "Natale 2006",
|
||||
"badge_desc_XM4": "Ero su Habbo a Natale del 2006!",
|
||||
"badge_name_XM3": "Renna",
|
||||
"badge_desc_XM3": "Habbo Natale 2005",
|
||||
"badge_name_XM2": "DJ-Bling",
|
||||
"badge_desc_XM2": "Habbo Natale 2005",
|
||||
"badge_name_XM10E": "FREEZE!",
|
||||
"badge_desc_XM10E": "Hai costruito un'Arena Fantastica!",
|
||||
"badge_name_XM10D": "Campione Wired-Freeze",
|
||||
"badge_desc_XM10D": "L'ennesima potenza del divertimento!",
|
||||
"badge_name_XM10C": "Regalo Natalizio Fuori Stagione",
|
||||
"badge_desc_XM10C": "Saluti dai Caraibi!",
|
||||
"badge_name_XM10B": "Pattino d'Oro",
|
||||
"badge_desc_XM10B": "Il Miglior Palazzetto del Ghiaccio",
|
||||
"badge_name_XM10A": "Città Natalizia!",
|
||||
"badge_desc_XM10A": "Per gli addobbi Natalizi più Originali!",
|
||||
"badge_name_XM1": "Rasta Santa",
|
||||
"badge_desc_XM1": "Habbo Natale 2005 e 2006",
|
||||
"badge_name_XGH1": "Il Fantasma del Natale",
|
||||
"badge_desc_XGH1": "Ho trovato il Fantasma del Natale",
|
||||
"badge_name_X535": "Conduttore di slitta - Sig.ra Claus",
|
||||
"badge_desc_X535": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
|
||||
"badge_name_X534": "Conduttore di slitta - Habbo Natale",
|
||||
"badge_desc_X534": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
|
||||
"badge_name_X533": "Pranzo di Natale - Sig.ra Claus",
|
||||
"badge_desc_X533": "Non c'è di niente di meglio di un bel Pranzo di Natale",
|
||||
"badge_name_X532": "Pranzo di Natale - Habbo Natale",
|
||||
"badge_desc_X532": "Non c'è di niente di meglio di un bel Pranzo di Natale",
|
||||
"badge_name_X531": "Silent night - Sig.ra Claus",
|
||||
"badge_desc_X531": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come la Sig.ra Claus",
|
||||
"badge_name_X530": "Silent night - Habbo Natale",
|
||||
"badge_desc_X530": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come Habbo Natale",
|
||||
"badge_name_X529": "Preparazione Regali di Natale - Sig.ra Claus",
|
||||
"badge_desc_X529": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
|
||||
"badge_name_X528": "Preparazione Regali di Natale - Habbo Natale",
|
||||
"badge_desc_X528": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
|
||||
"badge_name_X527": "Mercato di Natale - Sig.ra Claus",
|
||||
"badge_desc_X527": "Per aver completato il labirinto del mercato di Natale",
|
||||
"badge_name_X526": "Mercato di Natale - Habbo Natale",
|
||||
"badge_desc_X526": "Per aver completato il labirinto del mercato di Natale",
|
||||
"badge_name_X525": "Proprietario di Renna - Sig.ra Claus",
|
||||
"badge_desc_X525": "Vixen, la renna della Sig.ra Claus, è stata riportata sana e salva nella stalla",
|
||||
"badge_name_X524": "Proprietario di Renna - Habbo Natale",
|
||||
"badge_desc_X524": "Dasher, la renna preferita di Habbo Natale, è stata riportata sana e salva nella stalla",
|
||||
"badge_name_X523": "Film di Natale - Sig.ra Claus",
|
||||
"badge_desc_X523": "Sei un vero Fan dei film di Natale",
|
||||
"badge_name_X522": "Film di Natale - Habbo Natale",
|
||||
"badge_desc_X522": "Sei un vero Fan dei film di Natale",
|
||||
"badge_name_X521": "Meraviglioso Albero di Natale - Sig.ra Natale",
|
||||
"badge_desc_X521": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
|
||||
"badge_name_X520": "Meraviglioso Albero di Natale - Habbo Natale",
|
||||
"badge_desc_X520": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
|
||||
"badge_name_X519": "Canti Natalizi - Sig.ra Claus",
|
||||
"badge_desc_X519": "Jingle bell, jingle bell, jingle bell rock",
|
||||
"badge_name_X518": "Canti Natalizi - Habbo Natale",
|
||||
"badge_desc_X518": "Haaaabbo Natale sta arrivando in città!",
|
||||
"badge_name_X2535": "L'Alimentatore Preferito di Esophagor",
|
||||
"badge_desc_X2535": "",
|
||||
"badge_name_X2534": "Stazione dei Treni",
|
||||
"badge_desc_X2534": "",
|
||||
"badge_name_X2533": "Collezione Paese delle Meraviglie di Cacao",
|
||||
"badge_desc_X2533": "",
|
||||
"badge_name_X2532": "Affare Stanza Ufficio di Habbo Natale",
|
||||
"badge_desc_X2532": "",
|
||||
"badge_name_X2531": "Affare Stanza Natale Accogliente di Habbo",
|
||||
"badge_desc_X2531": "",
|
||||
"badge_name_X2530": "Uovo Glassato LTD",
|
||||
"badge_desc_X2530": "",
|
||||
"badge_name_X2529": "Borsa Kitty Rara",
|
||||
"badge_desc_X2529": "",
|
||||
"badge_name_X2528": "Cuscino di Ghiaccio Raro",
|
||||
"badge_desc_X2528": "",
|
||||
"badge_name_X2527": "Habbo Night Hotel Raro",
|
||||
"badge_desc_X2527": "",
|
||||
"badge_name_X2526": "Coda di cavallo Ghiacciata Scintillante Rara",
|
||||
"badge_desc_X2526": "",
|
||||
"badge_name_X2525": "Buon Natale 2025!",
|
||||
"badge_desc_X2525": "",
|
||||
"badge_name_X2521": "A caccia di vacanze!",
|
||||
"badge_desc_X2521": "",
|
||||
"badge_name_X2520": "Alimentatore di Esophagor",
|
||||
"badge_desc_X2520": "",
|
||||
"badge_name_X2518": "Battaglia con Palle di neve!",
|
||||
"badge_desc_X2518": "",
|
||||
"badge_name_X2516": "Spirito delle Feste",
|
||||
"badge_desc_X2516": "",
|
||||
"badge_name_X2515": "Straordinario Creatore delle Feste",
|
||||
"badge_desc_X2515": "",
|
||||
"badge_name_X2514": "Felice & Moderno",
|
||||
"badge_desc_X2514": "",
|
||||
"badge_name_X2513": "Eroe delle Tradizioni Natalizie",
|
||||
"badge_desc_X2513": "",
|
||||
"badge_name_X2512": "Corona dello Scontro di Natale",
|
||||
"badge_desc_X2512": "",
|
||||
"badge_name_X2511": "Habubu Glam",
|
||||
"badge_desc_X2511": "",
|
||||
"badge_name_X2510": "Habubu Cozy",
|
||||
"badge_desc_X2510": "",
|
||||
"badge_name_X2509": "Habubu Dream",
|
||||
"badge_desc_X2509": "",
|
||||
"badge_name_X2508": "Habubu Calm",
|
||||
"badge_desc_X2508": "",
|
||||
"badge_name_X2507": "Habubu Luck",
|
||||
"badge_desc_X2507": "",
|
||||
"badge_name_X2506": "Habubu Hope",
|
||||
"badge_desc_X2506": "",
|
||||
"badge_name_X2505": "Habubu Happy",
|
||||
"badge_desc_X2505": "",
|
||||
"badge_name_X2504": "Habubu Fun",
|
||||
"badge_desc_X2504": "",
|
||||
"badge_name_X2503": "Habubu Love",
|
||||
"badge_desc_X2503": "",
|
||||
"badge_name_X2502": "Squadra - DJ Bling",
|
||||
"badge_desc_X2502": "",
|
||||
"badge_name_X2501": "Squadra - Rasta Santa",
|
||||
"badge_desc_X2501": "",
|
||||
"badge_name_X2330": "Competizione Anatroccolo",
|
||||
"badge_desc_X2330": "",
|
||||
"badge_name_X2329": "Competizione Stanza Sweet Suite NL, TR",
|
||||
"badge_desc_X2329": "",
|
||||
"badge_name_X2328": "Offerta Crafting",
|
||||
"badge_desc_X2328": "",
|
||||
"badge_name_X2327": "Trono Bianco",
|
||||
"badge_desc_X2327": "",
|
||||
"badge_name_X2326": "Buon 2024!",
|
||||
"badge_desc_X2326": "",
|
||||
"badge_name_X2325": "Buon Natale!",
|
||||
"badge_desc_X2325": "",
|
||||
"badge_name_X2324": "Goditi il Natale Habbo",
|
||||
"badge_desc_X2324": "",
|
||||
"badge_name_X2323": "Aiuta Frank Wobbah a cucinare",
|
||||
"badge_desc_X2323": "",
|
||||
"badge_name_X2322": "Salvataggio delle ricette di Frank Wobbah",
|
||||
"badge_desc_X2322": "",
|
||||
"badge_name_X2321": "Missione Babbo Natale per un giorno",
|
||||
"badge_desc_X2321": "",
|
||||
"badge_name_X2320": "Missione Brilla come un Biglietto d'oro",
|
||||
"badge_desc_X2320": "",
|
||||
"badge_name_X2319": "Missione Delizie Arcobaleno",
|
||||
"badge_desc_X2319": "",
|
||||
"badge_name_X2318": "Missione Frutti di Bosco",
|
||||
"badge_desc_X2318": "",
|
||||
"badge_name_X2317": "Missione Mentine Rinfrescanti",
|
||||
"badge_desc_X2317": "",
|
||||
"badge_name_X2316": "Pazzo per la Missione del Cioccolato",
|
||||
"badge_desc_X2316": "",
|
||||
"badge_name_X2315": "Torta Trono Rara",
|
||||
"badge_desc_X2315": "",
|
||||
"badge_name_X2314": "Offerta di Capodanno 2023",
|
||||
"badge_desc_X2314": "",
|
||||
"badge_name_X2313": "Offerta Indumenti Natale 2023",
|
||||
"badge_desc_X2313": "",
|
||||
"badge_name_X2312": "Carosello di Cioccolato Artigianale LTD",
|
||||
"badge_desc_X2312": "",
|
||||
"badge_name_X2311": "Corona d'Oro 24K LTD",
|
||||
"badge_desc_X2311": "",
|
||||
"badge_name_X2310": "Ali sulla Testa Mitiche Rare",
|
||||
"badge_desc_X2310": "",
|
||||
"badge_name_X2309": "Seduta Aerea Rara",
|
||||
"badge_desc_X2309": "",
|
||||
"badge_name_X2308": "Nastro Scartami RARO",
|
||||
"badge_desc_X2308": "",
|
||||
"badge_name_X2307": "Albero di Zucchero Filato RARO",
|
||||
"badge_desc_X2307": "",
|
||||
"badge_name_X2306": "Affare Stanza di Capodanno",
|
||||
"badge_desc_X2306": "",
|
||||
"badge_name_X2305": "Affare Stanza Pista di Pattinaggio sul Ghiaccio",
|
||||
"badge_desc_X2305": "",
|
||||
"badge_name_X2304": "Affare Stanza Ritrovo dell'Elfo",
|
||||
"badge_desc_X2304": "",
|
||||
"badge_name_X2303": "Bottega Moderna di Babbo Natale",
|
||||
"badge_desc_X2303": "",
|
||||
"badge_name_X2302": "Emporio del Cioccolato Magico",
|
||||
"badge_desc_X2302": "",
|
||||
"badge_name_X2301": "Collezione Paese delle Meraviglie di Cacao",
|
||||
"badge_desc_X2301": "",
|
||||
"badge_name_X2234": "Sanrio Christmas Room Competition",
|
||||
"badge_desc_X2234": "",
|
||||
"badge_name_X2232": "Affare Stanza Salone da Pranzo",
|
||||
"badge_desc_X2232": "",
|
||||
"badge_name_X2231": "Offerta Bevande per soldi Reali",
|
||||
"badge_desc_X2231": "",
|
||||
"badge_name_X2230": "Renna Abile",
|
||||
"badge_desc_X2230": "",
|
||||
"badge_name_X2229": "Pinguino Abile",
|
||||
"badge_desc_X2229": "",
|
||||
"badge_name_X2228": "Labirinto dei Pinguini: Livello Facile - Completato",
|
||||
"badge_desc_X2228": "",
|
||||
"badge_name_X2227": "Labirinto dei Pinguini: Livello Medio - Completato",
|
||||
"badge_desc_X2227": "",
|
||||
"badge_name_X2226": "Labirinto dei Pinguini: Livello Folle - Completato",
|
||||
"badge_desc_X2226": "",
|
||||
"badge_name_X2224": "Natale Habbo 2022 - Pinguino di Tokyo",
|
||||
"badge_desc_X2224": "",
|
||||
"badge_name_X2223": "Natale Habbo 2022 - Pinguino Timido",
|
||||
"badge_desc_X2223": "",
|
||||
"badge_name_X2222": "Natale Habbo 2022 - Pinguino Accademico",
|
||||
"badge_desc_X2222": "",
|
||||
"badge_name_X2220": "Natale Habbo 2022 - Pinguino Unicorno",
|
||||
"badge_desc_X2220": "",
|
||||
"badge_name_X2219": "Natale Habbo 2022 - Pinguino Albino",
|
||||
"badge_desc_X2219": "",
|
||||
"badge_name_X2218": "Natale Habbo 2022 - Pinguino Fantasma",
|
||||
"badge_desc_X2218": "",
|
||||
"badge_name_X2217": "Natale Habbo 2022 - Pinguino Egizio",
|
||||
"badge_desc_X2217": "",
|
||||
"badge_name_X2216": "Natale Habbo 2022 - Pinguino Artista",
|
||||
"badge_desc_X2216": "",
|
||||
"badge_name_X2215": "Natale Habbo 2022 - Pinguino Addormentato",
|
||||
"badge_desc_X2215": "",
|
||||
"badge_name_X2214": "Natale Habbo 2022 - Pinguino Testa a Molla",
|
||||
"badge_desc_X2214": "",
|
||||
"badge_name_X2213": "Offerta Cibo Soldi Veri",
|
||||
"badge_desc_X2213": "",
|
||||
"badge_name_X2212": "Uovo Habberge Palla di Neve LTD",
|
||||
"badge_desc_X2212": "",
|
||||
"badge_name_X2211": "Ali d'Angelo LTD",
|
||||
"badge_desc_X2211": "",
|
||||
"badge_name_X2210": "RARO Husky Addestrato",
|
||||
"badge_desc_X2210": "",
|
||||
"badge_name_X2209": "Raro Sauna Nordica",
|
||||
"badge_desc_X2209": "",
|
||||
"badge_name_X2208": "RARO Capelli con Treccine",
|
||||
"badge_desc_X2208": "",
|
||||
"badge_name_X2207": "Raro Look da Albero Festivo",
|
||||
"badge_desc_X2207": "",
|
||||
"badge_name_X2206": "Affare Stanza Sauna Finlandese",
|
||||
"badge_desc_X2206": "",
|
||||
"badge_name_X2205": "Affare Stanza Snowboard sulle Alpi in Inverno",
|
||||
"badge_desc_X2205": "",
|
||||
"badge_name_X2204": "Affare Stanza Natale Bavarese",
|
||||
"badge_desc_X2204": "",
|
||||
"badge_name_X2203": "Affare Stanza Inverno ad Habbo Stonehenge",
|
||||
"badge_desc_X2203": "",
|
||||
"badge_name_X2202": "Affare Stanza Baita del Resort Invernale",
|
||||
"badge_desc_X2202": "",
|
||||
"badge_name_X2201": "Affare Stanza Resort degli Sport Invernali",
|
||||
"badge_desc_X2201": "",
|
||||
"badge_name_X2139": "Buon Natale!",
|
||||
"badge_desc_X2139": "",
|
||||
"badge_name_X2138": "Sulla lista dei buoni di Babbo Natale!",
|
||||
"badge_desc_X2138": "",
|
||||
"badge_name_X2137": "La Squadra di Babbo Natale",
|
||||
"badge_desc_X2137": "",
|
||||
"badge_name_X2136": "Chi è il vero Babbo Natale?",
|
||||
"badge_desc_X2136": "",
|
||||
"badge_name_X2135": "Il Coro Natalizio di Habbo",
|
||||
"badge_desc_X2135": "",
|
||||
"badge_name_X2134": "Battaglia di Palle di Neve!",
|
||||
"badge_desc_X2134": "",
|
||||
"badge_name_X2133": "Oh Albero di Natale",
|
||||
"badge_desc_X2133": "",
|
||||
"badge_name_X2132": "Si prepara una Tempesta!",
|
||||
"badge_desc_X2132": "",
|
||||
"badge_name_X2131": "Pattinando sul Ghiaccio Sottile",
|
||||
"badge_desc_X2131": "",
|
||||
"badge_name_X2130": "Piante Malridotte dall'Inverno",
|
||||
"badge_desc_X2130": "",
|
||||
"badge_name_X2129": "Nobile Uccello d'Oro",
|
||||
"badge_desc_X2129": "",
|
||||
"badge_name_X2128": "Caso Risolto!",
|
||||
"badge_desc_X2128": "",
|
||||
"badge_name_X2127": "Passi nella neve",
|
||||
"badge_desc_X2127": "",
|
||||
"badge_name_X2126": "La Grande Fuga",
|
||||
"badge_desc_X2126": "",
|
||||
"badge_name_X2125": "Il Sospetto Sbagliato",
|
||||
"badge_desc_X2125": "",
|
||||
"badge_name_X2124": "Scappato appena in tempo!",
|
||||
"badge_desc_X2124": "",
|
||||
"badge_name_X2123": "Identità Segreta",
|
||||
"badge_desc_X2123": "",
|
||||
"badge_name_X2122": "Una Mappa Segreta",
|
||||
"badge_desc_X2122": "",
|
||||
"badge_name_X2121": "Zzzz..Zzzz..",
|
||||
"badge_desc_X2121": "",
|
||||
"badge_name_X2120": "Si è intrufolato nella stanza del commesso viaggiatore",
|
||||
"badge_desc_X2120": "",
|
||||
"badge_name_X2119": "Un Look strepitoso!",
|
||||
"badge_desc_X2119": "",
|
||||
"badge_name_X2118": "Chiavi prestate",
|
||||
"badge_desc_X2118": "",
|
||||
"badge_name_X2117": "Il Treno sta arrivando",
|
||||
"badge_desc_X2117": "",
|
||||
"badge_name_X2116": "Bandito del Treno",
|
||||
"badge_desc_X2116": "",
|
||||
"badge_name_X2115": "Il Treno Habbo Express",
|
||||
"badge_desc_X2115": "",
|
||||
"badge_name_X2114": "Uovo Habberge Art Deco LTD",
|
||||
"badge_desc_X2114": "",
|
||||
"badge_name_X2113": "Locomotiva a Vapore Rara",
|
||||
"badge_desc_X2113": "",
|
||||
"badge_name_X2112": "Costume da Treno Raro",
|
||||
"badge_desc_X2112": "",
|
||||
"badge_name_X2111": "Macchina della Cioccolata Calda di Lusso Rara",
|
||||
"badge_desc_X2111": ""
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import urllib.request
|
||||
import sys
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────────
|
||||
MASTER_PATH = "/var/www/gamedata/config/FurnitureData.json"
|
||||
OUT_DIR = "/var/www/gamedata/config"
|
||||
CACHE_PATH = "/var/www/gamedata/config/.translations_libre_cache.json"
|
||||
MASTER_PATH = "/var/www/Gamedata/config/FurnitureData.json"
|
||||
OUT_DIR = "/var/www/Gamedata/config"
|
||||
CACHE_PATH = "/var/www/Gamedata/config/.translations_libre_cache.json"
|
||||
LIBRE_URL = "http://127.0.0.1:5000/translate"
|
||||
CONCURRENCY = 6
|
||||
LANGUAGES = [
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//
|
||||
// Usage (via pnpm, as required):
|
||||
// pnpm exec tsx scripts/bulk-import-json.ts \
|
||||
// --file=/var/www/gamedata/.../FurnitureData.json [--chunk-size=2000]
|
||||
// --file=/var/www/Gamedata/.../FurnitureData.json [--chunk-size=2000]
|
||||
//
|
||||
// Input shapes supported automatically:
|
||||
// A) habbo furnidata_json → { roomitemtypes:{furnitype:[…]}, wallitemtypes:{…} }
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
|
||||
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
|
||||
# respectievelijk `cms-gamedata`, en `/cms_api/imaging/` dat zijn eigen s-maxage
|
||||
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
|
||||
# stuurt).
|
||||
#
|
||||
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
||||
# by default; `/cms_api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||
# though their `Cache-Control: s-maxage` says they are cacheable. A Cache Rule
|
||||
# with "Cache Everything" turns those the other way.
|
||||
#
|
||||
@@ -43,12 +43,12 @@ DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-publi
|
||||
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||
# `starts_with()`.
|
||||
#
|
||||
# `/cms_api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
|
||||
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
|
||||
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
|
||||
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
|
||||
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
|
||||
# tussen gebruikers.
|
||||
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/cms_api/staff" "/cms_api/teams" "/cms_api/guilds" "/cms_api/photos" "/cms_api/leaderboard" "/cms_api/online" "/cms_api/online/count" "/cms_api/shop" "/cms_api/shop/categories" "/cms_api/values" "/cms_api/values/categories" "/cms_api/radio/current-dj" "/cms_api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/cms_api/values/") or starts_with(http.request.uri.path, "/cms_api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
||||
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
||||
|
||||
load_env() {
|
||||
local name="$1"
|
||||
@@ -138,4 +138,4 @@ if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("succes
|
||||
echo "error: could not save ruleset: $resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "cache rule live. Verify: curl -s https://epicnabbo.nl/cms_api/shop -o /dev/null -D - | grep -i cf-cache-status"
|
||||
echo "cache rule live. Verify: curl -s https://epicnabbo.nl/api/shop -o /dev/null -D - | grep -i cf-cache-status"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
|
||||
#
|
||||
# Background: on a host where both blue/green slots answer /cms_api/health, the
|
||||
# Background: on a host where both blue/green slots answer /api/health, the
|
||||
# original read_active_port() counted healthy slots and only consulted the nginx
|
||||
# upstream when the count was not exactly 1. With two healthy slots it fell back
|
||||
# to the upstream file, but an operator `docker compose up` can leave an extra
|
||||
|
||||
@@ -67,7 +67,7 @@ healthy() {
|
||||
local port="${1:-$slot_a_port}"
|
||||
local attempt
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -sf --max-time 5 "http://127.0.0.1:$port/cms_api/health" | grep -q '"database":true'; then return 0; fi
|
||||
if curl -sf --max-time 5 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then return 0; fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
@@ -77,7 +77,7 @@ healthy() {
|
||||
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
|
||||
# een al draaiend proces dat nu of nooit antwoordt.
|
||||
answers_health() {
|
||||
curl -sf --max-time 5 "http://127.0.0.1:$1/cms_api/health" | grep -q '"database":true'
|
||||
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
|
||||
}
|
||||
|
||||
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
|
||||
@@ -301,7 +301,7 @@ start_candidate() {
|
||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||
-v "$deploy_dir/storage:/app/storage" \
|
||||
-v /var/www/gamedata:/var/www/gamedata \
|
||||
-v /var/www/Gamedata:/var/www/Gamedata \
|
||||
"$image"
|
||||
)
|
||||
}
|
||||
@@ -512,7 +512,7 @@ fi
|
||||
# (each avatar becomes a fresh live render) and the catalog export reports
|
||||
# "delivery failed" while the emulator never receives the update. The old code
|
||||
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
|
||||
# coming back root:root and /cms_api/admin/catalog/status kept throwing EACCES.
|
||||
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
|
||||
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
|
||||
nitro-cleanup config-backups nitro-scale32-backups; do
|
||||
target="$deploy_dir/storage/$owned_dir"
|
||||
@@ -549,7 +549,7 @@ if [ "$blue_green" -eq 1 ]; then
|
||||
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
|
||||
# cutover de productie breken in plaats van ervoor.
|
||||
healthy "$new_port"
|
||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/cms_api/health" "$sha"
|
||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||
|
||||
# 4b. Cross-browser imaging gate. The chromium suite above proves the
|
||||
@@ -595,7 +595,7 @@ else
|
||||
candidate_attempted=1
|
||||
start_candidate 3002 epicnext-cms-app
|
||||
healthy 3002
|
||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/cms_api/health "$sha"
|
||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||
|
||||
@@ -94,7 +94,7 @@ mv -f -- "$profile_tmp" "$DIR/.docker-install"; profile_tmp=""
|
||||
printf '%s\n' 'Configuration saved. Credentials remain in .env; installation settings are excluded from Git.'
|
||||
if [[ "$configure_only" = 1 ]]; then printf '%s\n' 'No container was started. Next: bash cms install'; exit 0; fi
|
||||
as_root() { if [[ "$EUID" = 0 ]]; then "$@"; else command -v sudo >/dev/null || fail "sudo is required to prepare runtime directories."; sudo "$@"; fi; }
|
||||
for path in "$DIR/public/nitro-assets" "$DIR/public/swf" "$DIR/storage" /var/www/gamedata; do
|
||||
for path in "$DIR/public/nitro-assets" "$DIR/public/swf" "$DIR/storage" /var/www/Gamedata; do
|
||||
[[ ! -L "$path" ]] || fail "Runtime directory is a symlink: $path. Configure its permissions manually."
|
||||
if [[ ! -d "$path" ]]; then as_root install -d -o 33 -g 33 -m 0755 "$path"; fi
|
||||
# Do not recursively change ownership of existing assets.
|
||||
|
||||
@@ -41,7 +41,7 @@ RW_DIRS=(
|
||||
"$DIR/public/nitro-assets"
|
||||
"$DIR/public/swf"
|
||||
"$DIR/storage"
|
||||
"/var/www/gamedata"
|
||||
"/var/www/Gamedata"
|
||||
)
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
|
||||
@@ -24,7 +24,7 @@ probe='const r=await fetch(process.argv[1],{cache:"no-store",signal:AbortSignal.
|
||||
verify_container() {
|
||||
local target="$1" release="$2" attempt
|
||||
for attempt in $(seq 1 30); do
|
||||
if docker exec "$target" node --input-type=module -e "$probe" "http://127.0.0.1:3002/cms_api/health" "$release" >>"$LOG_FILE" 2>&1; then return 0; fi
|
||||
if docker exec "$target" node --input-type=module -e "$probe" "http://127.0.0.1:3002/api/health" "$release" >>"$LOG_FILE" 2>&1; then return 0; fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
@@ -137,7 +137,7 @@ revision="$(docker image inspect --format '{{index .Config.Labels "org.openconta
|
||||
migration_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$migration_image")"
|
||||
[[ "$migration_revision" = "$CMS_RELEASE" ]] || die "Migrations image has no matching release label. Rebuild/publish both artifacts from this checkout; the database was not changed."
|
||||
# Compose uses the candidate image's USER and exact mounts/env without starting the server.
|
||||
docker compose run --rm --no-deps --entrypoint node cms --input-type=module -e 'import {access,constants} from "node:fs/promises";import {validateRuntime} from "./docker-start.mjs";validateRuntime(process.env);for(const p of ["/app/storage","/app/public/nitro-assets","/app/public/swf","/var/www/gamedata"]){try{await access(p,constants.R_OK|constants.W_OK|constants.X_OK)}catch{console.error("Runtime storage access denied: "+p);process.exit(1)}}' >>"$LOG_FILE" 2>&1 || die "Candidate configuration/storage validation failed; database unchanged. Check directory access for UID/GID 33."
|
||||
docker compose run --rm --no-deps --entrypoint node cms --input-type=module -e 'import {access,constants} from "node:fs/promises";import {validateRuntime} from "./docker-start.mjs";validateRuntime(process.env);for(const p of ["/app/storage","/app/public/nitro-assets","/app/public/swf","/var/www/Gamedata"]){try{await access(p,constants.R_OK|constants.W_OK|constants.X_OK)}catch{console.error("Runtime storage access denied: "+p);process.exit(1)}}' >>"$LOG_FILE" 2>&1 || die "Candidate configuration/storage validation failed; database unchanged. Check directory access for UID/GID 33."
|
||||
log "Before migration: ensure a verified database backup is available. Image rollback does not undo database migrations."
|
||||
migration_mounts=(--mount "type=bind,source=$DIR/.env,target=/app/.env,readonly")
|
||||
if [[ -f "$DIR/.env.local" ]]; then migration_mounts+=(--mount "type=bind,source=$DIR/.env.local,target=/app/.env.local,readonly"); fi
|
||||
@@ -152,7 +152,7 @@ actual_image="$(docker inspect --format '{{.Image}}' "$container")"
|
||||
verify_container "$container" "$CMS_RELEASE" || die "HTTP health/release verification failed."
|
||||
if [[ -n "${CMS_PUBLIC_URL:-}" ]]; then
|
||||
[[ "$CMS_PUBLIC_URL" = https://* || "$CMS_PUBLIC_URL" = http://* ]] || die "CMS_PUBLIC_URL must be an HTTP(S) URL."
|
||||
docker exec "$container" node --input-type=module -e "$probe" "${CMS_PUBLIC_URL%/}/cms_api/health?release=$CMS_RELEASE" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Public domain serves another release or is unhealthy. Check reverse proxy/CDN destination."
|
||||
docker exec "$container" node --input-type=module -e "$probe" "${CMS_PUBLIC_URL%/}/api/health?release=$CMS_RELEASE" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Public domain serves another release or is unhealthy. Check reverse proxy/CDN destination."
|
||||
log "Public URL verified: $CMS_PUBLIC_URL"
|
||||
else
|
||||
log "Public domain was not checked. Set CMS_PUBLIC_URL to verify reverse proxy/CDN routing as well."
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ echo "Checking .env..."
|
||||
if [ -f .env ]; then echo ".env exists."; else echo "Error: .env missing."; fi
|
||||
|
||||
echo "Checking health..."
|
||||
if curl -s http://localhost:3002/cms_api/health > /dev/null; then echo "Health endpoint OK."; else echo "Health endpoint failing."; fi
|
||||
if curl -s http://localhost:3002/api/health > /dev/null; then echo "Health endpoint OK."; else echo "Health endpoint failing."; fi
|
||||
|
||||
echo "Disk usage:"
|
||||
df -h / | tail -n 1
|
||||
|
||||
@@ -29,8 +29,8 @@ import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
* pnpm exec tsx scripts/generate-stack-height-sql.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --dir=<path> bundle directory (default: /var/www/gamedata/bundled/furniture)
|
||||
* --furnidata=<path> local FurnitureData.json (default: /var/www/gamedata/config/FurnitureData.json)
|
||||
* --dir=<path> bundle directory (default: /var/www/Gamedata/bundled/furniture)
|
||||
* --furnidata=<path> local FurnitureData.json (default: /var/www/Gamedata/config/FurnitureData.json)
|
||||
* --out=<path> SQL output file (default: stack-heights.sql)
|
||||
* --types=<list> items_base types (default: s,i → floor + wall furniture)
|
||||
* --all emit every matched row instead of only changed rows
|
||||
@@ -46,9 +46,9 @@ for (const raw of process.argv.slice(2)) {
|
||||
else args.set(raw.slice(2, eq), raw.slice(eq + 1));
|
||||
}
|
||||
|
||||
const DIR = String(args.get("dir") ?? "/var/www/gamedata/bundled/furniture");
|
||||
const DIR = String(args.get("dir") ?? "/var/www/Gamedata/bundled/furniture");
|
||||
const FURNIDATA = String(
|
||||
args.get("furnidata") ?? "/var/www/gamedata/config/FurnitureData.json",
|
||||
args.get("furnidata") ?? "/var/www/Gamedata/config/FurnitureData.json",
|
||||
);
|
||||
const OUT = String(args.get("out") ?? "stack-heights.sql");
|
||||
const TYPES = String(args.get("types") ?? "s,i")
|
||||
|
||||
@@ -305,15 +305,12 @@ async function backupDatabase(): Promise<void> {
|
||||
`--databases`,
|
||||
dbName,
|
||||
];
|
||||
// The password goes in the environment (MYSQL_PWD), not on the command line, where every
|
||||
// user of the server could read it in the process list.
|
||||
const childEnv = parsed.password
|
||||
? { ...process.env, MYSQL_PWD: decodeURIComponent(parsed.password) }
|
||||
: process.env;
|
||||
if (parsed.password) {
|
||||
args.splice(3, 0, `-p${decodeURIComponent(parsed.password)}`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
const child = spawn("mysqldump", args, {
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const out = createWriteStream(outFile);
|
||||
|
||||
@@ -90,7 +90,7 @@ async function resolveDirs(skipGeneric: boolean): Promise<string[]> {
|
||||
push(
|
||||
getRuntimePath(process.cwd(), "public/nitro-assets/bundled/furniture"),
|
||||
);
|
||||
push("/var/www/gamedata/bundled/furniture");
|
||||
push("/var/www/Gamedata/bundled/furniture");
|
||||
}
|
||||
|
||||
let gamedataRoot = "";
|
||||
|
||||
@@ -136,5 +136,5 @@ else
|
||||
fi
|
||||
|
||||
echo "--- health check ---"
|
||||
curl -sf "http://127.0.0.1:3002/cms_api/health" > /dev/null && echo "OK: CMS reachable"
|
||||
curl -sf "http://127.0.0.1:3002/api/health" > /dev/null && echo "OK: CMS reachable"
|
||||
curl -skf -o /dev/null -H "Host: epicnabbo.nl" "https://127.0.0.1:9443/health" && echo "OK: nginx :9443 /health"
|
||||
@@ -70,13 +70,13 @@ it.skipIf(!hasCompose)(
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3002/cms_api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
]);
|
||||
expect(cms.volumes.map((volume) => volume.target).sort()).toEqual([
|
||||
"/app/public/nitro-assets",
|
||||
"/app/public/swf",
|
||||
"/app/storage",
|
||||
"/var/www/gamedata",
|
||||
"/var/www/Gamedata",
|
||||
]);
|
||||
expect(cms.environment.DATABASE_URL).toBe(
|
||||
"mysql://fixture:[email protected]/fixture",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// Env:
|
||||
// NITRO_SRC_DIR e.g. /var/www/Octane/public/configuration
|
||||
// NITRO_DIST_CONFIG_DIR e.g. /var/www/Octane/dist/configuration
|
||||
// NITRO_GAMEDATA_CONF_DIR e.g. /var/www/gamedata/config
|
||||
// NITRO_GAMEDATA_CONF_DIR e.g. /var/www/Gamedata/config
|
||||
// NITRO_IMAGE_LIBRARY_URL, NITRO_HOF_FURNITURE_URL, NITRO_API_URL,
|
||||
// NITRO_SOCKET_URL, NITRO_GAMEDATA_URL, NITRO_ASSET_URL,
|
||||
// NITRO_FURNI_ASSET_ICON_URL
|
||||
@@ -84,22 +84,6 @@ function applyOverrides(data) {
|
||||
data["soundboard.url"] =
|
||||
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
|
||||
}
|
||||
if (gamedata) {
|
||||
const soundboardDefaults = {
|
||||
"soundboard.manifest.url": `${gamedata}/config/SoundData.json?t=%timestamp%`,
|
||||
"soundboard.sounds.url": `${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`,
|
||||
"soundboard.layout.url": `${gamedata}/config/soundboard-layout.jsonc?t=%timestamp%`,
|
||||
};
|
||||
for (const [key, value] of Object.entries(soundboardDefaults)) {
|
||||
if (
|
||||
!(key in data) ||
|
||||
isPlaceholder(data[key]) ||
|
||||
data[key] === "configuration/soundboard-layout.jsonc"
|
||||
) {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never force ads on/off over a hotel's own choice; only default to "off"
|
||||
// when the key does not exist yet (prevents external ad scripts from 404ing).
|
||||
if (!("show.google.ads" in data)) data["show.google.ads"] = false;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Verify if CMS is reachable
|
||||
cd "$(dirname "$0")/.."
|
||||
echo "Checking health..."
|
||||
if curl -f http://localhost:3002/cms_api/health > /dev/null 2>&1; then
|
||||
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
|
||||
echo "Success: Service is reachable."
|
||||
else
|
||||
echo "Error: Service not reachable."
|
||||
|
||||
@@ -85,7 +85,7 @@ try {
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
const r = await fetch(`${base}/cms_api/health`, {
|
||||
const r = await fetch(`${base}/api/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if ((await r.json()).release === release) {
|
||||
@@ -98,7 +98,7 @@ try {
|
||||
if (!ready) throw new Error(`${hotel}: expected HTTP release not served`);
|
||||
await verifyRuntimeMetadata(base, hotel);
|
||||
const avatar = await fetch(
|
||||
`${base}/cms_api/imaging/avatar?figure=hd-180-1&img_format=png`,
|
||||
`${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`,
|
||||
{ signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
if (
|
||||
@@ -106,7 +106,7 @@ try {
|
||||
!requests.some((url) => url.startsWith(`/${hotel}/avatar?`))
|
||||
)
|
||||
throw new Error(`${hotel}: wrong avatar upstream`);
|
||||
const badge = await fetch(`${base}/cms_api/imaging/badge?code=ADM`, {
|
||||
const badge = await fetch(`${base}/api/imaging/badge?code=ADM`, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
@@ -193,11 +193,7 @@ run_ulimit() {
|
||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||
return 1
|
||||
}
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
run_refuse() {
|
||||
@@ -208,11 +204,7 @@ run_refuse() {
|
||||
|
||||
run_opted_out() {
|
||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
case "$backend" in
|
||||
|
||||
@@ -5,7 +5,6 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { tryGuardRank } from "@/lib/admin/rank-guard";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import { Ban, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
@@ -50,8 +49,6 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
cfhTopic: -1,
|
||||
});
|
||||
|
||||
// The ban also ends the CMS sessions, API tokens and game SSO ticket the user has.
|
||||
await revokeUserCredentials(userId);
|
||||
if (guard.ok) await rcon.disconnectUser(userId, guard.target.username);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("uploadMediaAndReturn", () => {
|
||||
describe("deleteMedia", () => {
|
||||
it("deletes media file and revalidates", async () => {
|
||||
await deleteMedia("photo.png");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/cms_api/media");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||
});
|
||||
|
||||
it("skips deletion when path is outside media root", async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function uploadMedia(
|
||||
const stored = await storeUploadedMedia(file);
|
||||
if (!stored.ok) return { ok: false, error: stored.error };
|
||||
|
||||
revalidatePath("/cms_api/media");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export async function deleteMedia(name: string): Promise<void> {
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
revalidatePath("/cms_api/media");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function uploadMediaAndReturn(
|
||||
const stored = await storeUploadedMedia(file);
|
||||
if (!stored.ok) return "";
|
||||
|
||||
revalidatePath("/cms_api/media");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return `/cms_api/media/${stored.name}`;
|
||||
return `/api/media/${stored.name}`;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
|
||||
import { isSafeOutboundUrl } from "@/lib/import-url-safety";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -44,17 +43,7 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
const key = str(formData.get("key")).trim().slice(0, 255);
|
||||
const value = str(formData.get("value"));
|
||||
const comment = str(formData.get("comment")).trim().slice(0, 255);
|
||||
// Radio rights cover radio settings only, not any website setting (staff 2FA, ranks, secrets).
|
||||
if (!key || !(key.startsWith("radio_") || key.startsWith("auto_dj_"))) return;
|
||||
// The server fetches the provider API URLs: refuse ones that reach this server or the local network.
|
||||
if (
|
||||
key.endsWith("_api_url") &&
|
||||
value &&
|
||||
!(await isSafeOutboundUrl(value, { allowHttp: true }))
|
||||
) {
|
||||
logger.warn("Radio API URL refused: private or local address", { key });
|
||||
return;
|
||||
}
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
@@ -82,35 +71,15 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
|
||||
if (keys.length === 0) return;
|
||||
|
||||
// The server fetches the provider API URLs: refuse ones that reach this server or the local network.
|
||||
const unsafe = new Set<string>();
|
||||
for (const key of keys) {
|
||||
const value = str(formData.get(key));
|
||||
if (
|
||||
key.endsWith("_api_url") &&
|
||||
value &&
|
||||
!(await isSafeOutboundUrl(value, { allowHttp: true }))
|
||||
) {
|
||||
unsafe.add(key);
|
||||
}
|
||||
}
|
||||
if (unsafe.size > 0) {
|
||||
logger.warn("Radio API URL refused: private or local address", {
|
||||
keys: [...unsafe],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => !unsafe.has(key))
|
||||
.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}),
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch (err) {
|
||||
|
||||
@@ -75,22 +75,13 @@ export const saveManagedSettings = adminAction(
|
||||
},
|
||||
);
|
||||
|
||||
/** Lockout/security settings: only `saveManagedSettings` changes them, never the free-form forms. */
|
||||
function isLockedRawKey(key: string): boolean {
|
||||
return (
|
||||
key === "force_staff_2fa" ||
|
||||
key === "min_staff_rank" ||
|
||||
key === "maintenance_enabled"
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSetting(formData: FormData): Promise<void> {
|
||||
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const raw = String(formData.get("value") ?? "").normalize("NFC");
|
||||
if (!key || !RAW_SETTING_KEY_RE.test(key) || isLockedRawKey(key)) return;
|
||||
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
|
||||
// Blank on a secret means "keep what is stored", so the UI can render a
|
||||
// placeholder without the risk of wiping the credential.
|
||||
if (isSecretSettingKey(key) && raw === SECRET_PLACEHOLDER) return;
|
||||
@@ -121,7 +112,14 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
// Managed keys go through `saveManagedSettings`; anything else must be a
|
||||
// clearly namespaced custom key, and lockout/security settings are never
|
||||
// writable through the free-form form.
|
||||
if (!key || !RAW_SETTING_KEY_RE.test(key) || isLockedRawKey(key)) return;
|
||||
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
|
||||
if (
|
||||
key === "force_staff_2fa" ||
|
||||
key === "min_staff_rank" ||
|
||||
key === "maintenance_enabled"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
@@ -136,8 +134,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
// Deleting a lockout setting or a secret would switch that protection off (or drop the credential).
|
||||
if (!key || isLockedRawKey(key) || isSecretSettingKey(key)) return;
|
||||
if (!key) return;
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { db, WebsiteShopArticles } from "@/lib/db";
|
||||
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
@@ -41,9 +40,6 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
// A package never hands out a staff rank: buying it would make the buyer staff (or owner).
|
||||
const giveRank = optUInt(formData, "giveRank");
|
||||
if (giveRank != null && giveRank >= (await getMinStaffRank())) return;
|
||||
|
||||
const now = new Date();
|
||||
const costs = reqUInt(formData, "costs");
|
||||
@@ -63,7 +59,7 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs,
|
||||
giveRank,
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
@@ -106,9 +102,6 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
// A package never hands out a staff rank: buying it would make the buyer staff (or owner).
|
||||
const giveRank = optUInt(formData, "giveRank");
|
||||
if (giveRank != null && giveRank >= (await getMinStaffRank())) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
@@ -128,7 +121,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank,
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
@@ -72,7 +71,7 @@ export async function bulkUnban({
|
||||
}: {
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_BAN);
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const ids = parseUserIds(userIds);
|
||||
await guardBulkTargets(staff, ids);
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
|
||||
@@ -95,7 +94,7 @@ export async function bulkBan({
|
||||
reason: string;
|
||||
duration: number;
|
||||
}): Promise<ActionResult<{ banned: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_BAN);
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const ids = parseUserIds(userIds);
|
||||
const seconds = parseDuration(duration);
|
||||
const reasonText = typeof reason === "string" ? reason.slice(0, 255) : "";
|
||||
@@ -116,8 +115,6 @@ export async function bulkBan({
|
||||
type: "account",
|
||||
});
|
||||
banned++;
|
||||
// The ban also ends the CMS sessions, API tokens and game SSO ticket the user has.
|
||||
await revokeUserCredentials(userId);
|
||||
} catch {
|
||||
// skip duplicates
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ export async function insertCatalogItemRow(data: {
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
}): Promise<number> {
|
||||
// Exported from a "use server" file, so it is a public endpoint too: it checks the permission itself.
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const pageIdStr = String(data.pageId);
|
||||
offerPatchSchema.required().parse(data);
|
||||
return allocateCatalogItemId((nextId) =>
|
||||
@@ -146,8 +144,7 @@ export async function createCatalogItem(
|
||||
*/
|
||||
export async function bulkInsertCatalogItems(data: {
|
||||
pageId: number;
|
||||
/** Ignored: the signed-in staff member is logged. */
|
||||
staffId?: number;
|
||||
staffId: number;
|
||||
rows: Array<{
|
||||
baseId: number;
|
||||
credits?: number;
|
||||
@@ -156,10 +153,7 @@ export async function bulkInsertCatalogItems(data: {
|
||||
amount?: number;
|
||||
}>;
|
||||
}): Promise<{ created: number; failed: number }> {
|
||||
// Exported from a "use server" file, so it is a public endpoint too: it checks the permission itself,
|
||||
// and the audit log names the signed-in staff member, not a caller-supplied id.
|
||||
const staffId = (await requirePermission(PERMS.CATALOG_EDIT)).id;
|
||||
const { pageId, rows } = data;
|
||||
const { pageId, staffId, rows } = data;
|
||||
if (rows.length === 0) return { created: 0, failed: 0 };
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
|
||||
@@ -21,7 +21,6 @@ import { PERMS } from "@/lib/permissions";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { sqlStringLiteral } from "@/lib/sql-literal";
|
||||
|
||||
const CATALOG_PAGE_FIELDS = [
|
||||
"caption",
|
||||
@@ -518,7 +517,10 @@ export async function organizeImportFurni(input: {
|
||||
`WHEN ${Number(row.catalogItemId)} THEN ${nextOrder}`,
|
||||
);
|
||||
moveCaseName.push(
|
||||
`WHEN ${Number(row.catalogItemId)} THEN ${sqlStringLiteral(catalogName)}`,
|
||||
`WHEN ${Number(row.catalogItemId)} THEN '${catalogName.replace(
|
||||
/['\\]/g,
|
||||
"\\$&",
|
||||
)}'`,
|
||||
);
|
||||
moveIds.push(Number(row.catalogItemId));
|
||||
const hostPageId = hostPageByItemId.get(Number(row.catalogItemId));
|
||||
|
||||
@@ -22,12 +22,6 @@ vi.mock("@/lib/permissions", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// The rank check reads the target from the database; here it only answers.
|
||||
const mockGuardRank = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ username: "bob", rank: 1 })),
|
||||
);
|
||||
vi.mock("@/lib/admin/rank-guard", () => ({ guardRank: mockGuardRank }));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||
logAuthorizationEvent: vi.fn(),
|
||||
@@ -256,21 +250,6 @@ describe("disconnectUser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rank check on user-targeted actions", () => {
|
||||
it("refuses to run a command as a user of the same or a higher rank", async () => {
|
||||
const { ActionError } = await import("@/lib/safe-action-shared");
|
||||
mockGuardRank.mockRejectedValueOnce(
|
||||
new ActionError("Cannot modify user with equal or higher rank"),
|
||||
);
|
||||
const result = await executeCommand({
|
||||
userId: 1,
|
||||
command: ":giverank me 7",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("alertUser", () => {
|
||||
it("alerts a specific user", async () => {
|
||||
await expect(alertUser({ userId: 3, message: "m" })).resolves.toEqual({
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { guardRank } from "@/lib/admin/rank-guard";
|
||||
import { db, queryRows, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
@@ -95,11 +94,8 @@ const disconnectSchema = z.object({
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
await requireRconOk(
|
||||
await rcon.disconnectUser(ctx.data.userId, target.username),
|
||||
);
|
||||
const username = ctx.data.username.normalize("NFC");
|
||||
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
@@ -114,8 +110,6 @@ const alertUserSchema = z.object({
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const message = ctx.data.message.normalize("NFC");
|
||||
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
|
||||
revalidatePath(PATH);
|
||||
@@ -132,8 +126,6 @@ const forwardUserSchema = z.object({
|
||||
export const forwardUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
await requireRconOk(
|
||||
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
|
||||
);
|
||||
@@ -218,8 +210,6 @@ const giveBadgeSchema = z.object({
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const badge = ctx.data.badge.normalize("NFC");
|
||||
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
|
||||
revalidatePath(PATH);
|
||||
@@ -236,8 +226,6 @@ const setMottoSchema = z.object({
|
||||
export const setMotto = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const motto = ctx.data.motto.normalize("NFC");
|
||||
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
|
||||
revalidatePath(PATH);
|
||||
@@ -303,8 +291,6 @@ const executeCommandSchema = z.object({
|
||||
export const executeCommand = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const command = ctx.data.command.normalize("NFC");
|
||||
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
|
||||
revalidatePath(PATH);
|
||||
|
||||
+13
-29
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, gte, max, sql } from "drizzle-orm";
|
||||
import { and, eq, max, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -95,23 +95,12 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
} else {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
let paid = true;
|
||||
if (price > 0) {
|
||||
paid = await db.transaction(async (tx) => {
|
||||
// Only while the credits are still there (parallel purchases cannot all pass the check
|
||||
// above), and not while the player is in the hotel: the emulator writes its in-memory
|
||||
// credits back on save or logout, which would undo the deduction.
|
||||
const [result] = await tx
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} - ${price}` })
|
||||
.where(
|
||||
and(
|
||||
eq(User.id, userId),
|
||||
gte(User.credits, price),
|
||||
eq(User.online, "0"),
|
||||
),
|
||||
);
|
||||
if (Number(result.affectedRows ?? 0) !== 1) return false;
|
||||
.where(eq(User.id, userId));
|
||||
|
||||
const [existing] = await tx
|
||||
.select({ id: UsersBadges.id })
|
||||
@@ -135,24 +124,19 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
badgeCode: code,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (!paid) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("drawbadge.give_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("drawbadge.give_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
}
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ const state = vi.hoisted(() => ({
|
||||
set: null as any,
|
||||
}));
|
||||
|
||||
// The rank check reads the target from the database; here it only answers.
|
||||
const mockGuardRank = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ username: "bob", rank: 1 })),
|
||||
);
|
||||
vi.mock("@/lib/admin/rank-guard", () => ({ guardRank: mockGuardRank }));
|
||||
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { createFakeDb } = await import("@/test/fake-db");
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { guardRank } from "@/lib/admin/rank-guard";
|
||||
import { db, SupportTickets } from "@/lib/db";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
@@ -105,8 +104,6 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||
export const quickKick = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
await rcon.disconnectUser(ctx.data.userId);
|
||||
|
||||
logAudit({
|
||||
@@ -128,8 +125,6 @@ const muteSchema = z.object({
|
||||
export const quickMute = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
|
||||
logAudit({
|
||||
@@ -147,8 +142,6 @@ export const quickMute = adminAction(
|
||||
export const quickUnmute = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
await rcon.unmuteUser(ctx.data.userId);
|
||||
|
||||
logAudit({
|
||||
@@ -170,8 +163,6 @@ const alertSchema = z.object({
|
||||
export const quickAlert = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||
async (ctx) => {
|
||||
// Never on a user of the same or a higher rank (nor on yourself).
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
|
||||
logAudit({
|
||||
|
||||
@@ -80,8 +80,8 @@ beforeEach(() => {
|
||||
|
||||
describe("requestReset", () => {
|
||||
it("sends a reset email when the user exists", async () => {
|
||||
selectLimit.mockResolvedValue([{ id: 1, mail: "[email protected]" }]);
|
||||
selectWhere.mockResolvedValue([{ id: 1, mail: "[email protected]" }]);
|
||||
selectLimit.mockResolvedValue([{ id: 1 }]);
|
||||
selectWhere.mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
@@ -108,19 +108,6 @@ describe("requestReset", () => {
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send a link to a look-alike address the collation matches", async () => {
|
||||
// utf8mb4_unicode_ci treats "exámple" and "example" as equal.
|
||||
selectLimit.mockResolvedValue([{ id: 1, mail: "[email protected]" }]);
|
||||
selectWhere.mockResolvedValue([{ id: 1, mail: "[email protected]" }]);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("email", "user@exámple.com");
|
||||
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
expect(insertOnDup).not.toHaveBeenCalled();
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate limits and does not throw on email without @", async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("email", "not-an-email");
|
||||
|
||||
@@ -55,16 +55,11 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
// Always respond the same way so we don't reveal which emails exist.
|
||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
try {
|
||||
// The column's collation is accent-insensitive, so `victim@gmaíl.com` would match
|
||||
// `[email protected]` and the link would go to the attacker's look-alike domain:
|
||||
// only exact addresses count.
|
||||
const matches = (
|
||||
await db
|
||||
.select({ id: User.id, mail: User.mail })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.orderBy(asc(User.id))
|
||||
).filter((m) => sameAddress(m.mail, email));
|
||||
const matches = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.orderBy(asc(User.id));
|
||||
if (matches.length > 1) {
|
||||
logger.warn("Password reset address is not unique", {
|
||||
email,
|
||||
@@ -142,13 +137,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
if (!row || !fresh || !match) {
|
||||
error = "invalid";
|
||||
} else {
|
||||
const matches = (
|
||||
await db
|
||||
.select({ id: User.id, mail: User.mail, username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.orderBy(asc(User.id))
|
||||
).filter((m) => sameAddress(m.mail, email));
|
||||
const matches = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.orderBy(asc(User.id));
|
||||
const user = matches[0];
|
||||
if (!user) {
|
||||
error = "invalid";
|
||||
@@ -164,8 +157,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
.where(eq(User.id, user.id)),
|
||||
revokeUserCredentials(user.id),
|
||||
]);
|
||||
// The login cache is keyed by username: the old hash must not keep working.
|
||||
await invalidateLoginCache(user.username);
|
||||
await invalidateLoginCache(email);
|
||||
await db
|
||||
.delete(PasswordReset)
|
||||
.where(eq(PasswordReset.email, email))
|
||||
@@ -186,11 +178,3 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
redirect("/login?reset=1");
|
||||
}
|
||||
|
||||
/** The exact address (ignoring only case), not what the database collation considers equal. */
|
||||
function sameAddress(
|
||||
stored: string | null | undefined,
|
||||
typed: string,
|
||||
): boolean {
|
||||
return (stored ?? "").normalize("NFC").trim().toLowerCase() === typed;
|
||||
}
|
||||
|
||||
+35
-44
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -130,58 +130,49 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
// Only while the balance still covers it: parallel claims cannot all spend it.
|
||||
const [spent] = await db
|
||||
await db
|
||||
.update(UserReferrals)
|
||||
.set({
|
||||
referralsTotal: sql`${UserReferrals.referralsTotal} - ${needed}`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(UserReferrals.id, referrals.id),
|
||||
gte(UserReferrals.referralsTotal, BigInt(needed)),
|
||||
),
|
||||
);
|
||||
if (Number(spent.affectedRows ?? 0) !== 1) {
|
||||
outcome = "not_enough";
|
||||
} else {
|
||||
try {
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
currency,
|
||||
amount,
|
||||
);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
try {
|
||||
await db
|
||||
.update(UserReferrals)
|
||||
.set({
|
||||
referralsTotal: sql`${UserReferrals.referralsTotal} + ${needed}`,
|
||||
})
|
||||
.where(eq(UserReferrals.id, referrals.id));
|
||||
} catch {
|
||||
/* best-effort rollback */
|
||||
}
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
.where(eq(UserReferrals.id, referrals.id));
|
||||
|
||||
try {
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
currency,
|
||||
amount,
|
||||
);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
try {
|
||||
await db.insert(ClaimedReferralLogs).values({
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db
|
||||
.update(UserReferrals)
|
||||
.set({
|
||||
referralsTotal: sql`${UserReferrals.referralsTotal} + ${needed}`,
|
||||
})
|
||||
.where(eq(UserReferrals.id, referrals.id));
|
||||
} catch {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
/* best-effort rollback */
|
||||
}
|
||||
outcome = "claimed";
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(ClaimedReferralLogs).values({
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
}
|
||||
outcome = "claimed";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -224,17 +224,6 @@ export async function register(
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
const hasEmail = !!mail;
|
||||
// Without an address there is nothing to verify: login only checks verification for
|
||||
// accounts that have one, so a blank email would skip it.
|
||||
if (
|
||||
!hasEmail &&
|
||||
(await siteSettings.getBool("require_email_verification", false))
|
||||
) {
|
||||
return fail(
|
||||
"Enter your email address: it has to be verified before you can log in.",
|
||||
"invalidInput",
|
||||
);
|
||||
}
|
||||
const ip = await clientIp();
|
||||
|
||||
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
|
||||
|
||||
@@ -34,12 +34,12 @@ export async function saveFavicon(
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/cms_api/media/favicon/${filename}`;
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
|
||||
// Remove old favicon file if it exists
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl?.startsWith("/cms_api/media/favicon/")) {
|
||||
const oldName = oldUrl.replace("/cms_api/media/favicon/", "");
|
||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
@@ -75,9 +75,9 @@ export async function deleteFavicon(): Promise<{
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
try {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl?.startsWith("/cms_api/media/favicon/")) {
|
||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
||||
const baseDir = resolveMediaPath("favicon");
|
||||
const oldName = oldUrl.replace("/cms_api/media/favicon/", "");
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function saveLogo(
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/cms_api/media/logo/${filename}`;
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
|
||||
@@ -30,9 +30,6 @@ vi.mock("next/navigation", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||
vi.mock("@/lib/admin/min-staff-rank", () => ({
|
||||
getMinStaffRank: async () => 7,
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: state.rateLimit,
|
||||
clientIp: state.clientIp,
|
||||
@@ -179,23 +176,11 @@ describe("buyShopArticle", () => {
|
||||
});
|
||||
|
||||
it("raises the buyer rank when the package grants a higher rank", async () => {
|
||||
state.articleRows = [{ ...ARTICLE, giveRank: 5 }];
|
||||
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||
"/shop?category=1&bought=1",
|
||||
);
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
User,
|
||||
expect.objectContaining({ rank: 5 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never grants a staff rank, even when a package was saved with one", async () => {
|
||||
state.articleRows = [{ ...ARTICLE, giveRank: 7 }];
|
||||
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||
"/shop?category=1&bought=1",
|
||||
);
|
||||
expect(state.update).not.toHaveBeenCalledWith(
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
User,
|
||||
expect.objectContaining({ rank: 7 }),
|
||||
expect.anything(),
|
||||
|
||||
+34
-64
@@ -1,9 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, gte, max, sql } from "drizzle-orm";
|
||||
import { and, eq, max, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -30,13 +29,7 @@ function parseBadgeCodes(raw: string | null | undefined): string[] {
|
||||
.filter((s) => s.length > 0 && s.length <= 32);
|
||||
}
|
||||
|
||||
type BuyOutcome =
|
||||
| "bought"
|
||||
| "invalid"
|
||||
| "credits"
|
||||
| "online"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error";
|
||||
|
||||
function shopRedirect(
|
||||
categoryId: string,
|
||||
@@ -117,46 +110,27 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
const price = creditPriceFromCosts(article.costs);
|
||||
|
||||
const [buyer] = await db
|
||||
.select({
|
||||
credits: User.credits,
|
||||
rank: User.rank,
|
||||
online: User.online,
|
||||
})
|
||||
.select({ credits: User.credits, rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else if (price > 0 && buyer.online === "1") {
|
||||
// The emulator keeps an online player's credits in memory and writes them back on save or
|
||||
// logout, which would undo this deduction: the package would be free.
|
||||
outcome = "online";
|
||||
} else {
|
||||
const badgeCodes = parseBadgeCodes(article.badges);
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
|
||||
const paid = await db.transaction(async (tx) => {
|
||||
await db.transaction(async (tx) => {
|
||||
if (price > 0) {
|
||||
// Only when the credits are still there: parallel purchases cannot all pass the check above.
|
||||
const [result] = await tx
|
||||
await tx
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} - ${price}` })
|
||||
.where(
|
||||
and(
|
||||
eq(User.id, userId),
|
||||
gte(User.credits, price),
|
||||
eq(User.online, "0"),
|
||||
),
|
||||
);
|
||||
if (Number(result.affectedRows ?? 0) !== 1) return false;
|
||||
.where(eq(User.id, userId));
|
||||
}
|
||||
|
||||
// Never a staff rank, also for packages saved before that was refused.
|
||||
if (
|
||||
article.giveRank != null &&
|
||||
article.giveRank > 0 &&
|
||||
article.giveRank > buyer.rank &&
|
||||
article.giveRank < minStaffRank
|
||||
article.giveRank > buyer.rank
|
||||
) {
|
||||
await tx
|
||||
.update(User)
|
||||
@@ -188,41 +162,37 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!paid) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
"credits",
|
||||
article.credits,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
"duckets",
|
||||
article.duckets,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
"diamonds",
|
||||
article.diamonds,
|
||||
);
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("shop.give_badge_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
"credits",
|
||||
article.credits,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
"duckets",
|
||||
article.duckets,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
"diamonds",
|
||||
article.diamonds,
|
||||
);
|
||||
|
||||
outcome = "bought";
|
||||
for (const code of badgeCodes) {
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("shop.give_badge_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
outcome = "bought";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { invalidateKey } from "@/lib/cached-db";
|
||||
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
import { allModuleIds } from "@/lib/theme-modules";
|
||||
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
import { getAllScopes, getScopeValues } from "@/lib/theme-resolver";
|
||||
import { SCOPED_THEMES_CACHE_KEY } from "@/lib/theme-scope-cache";
|
||||
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
|
||||
@@ -73,7 +71,6 @@ export async function createScope(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -96,7 +93,6 @@ export async function updateScope(formData: FormData): Promise<void> {
|
||||
.where(sql`${ThemeScope.id} = ${id}`);
|
||||
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -123,7 +119,6 @@ export async function deleteScope(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -141,7 +136,6 @@ export async function reorderScopes(orderedIds: number[]): Promise<void> {
|
||||
.where(sql`${ThemeScope.id} = ${orderedIds[i]}`);
|
||||
}
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -335,7 +329,6 @@ export async function saveScopeValues(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -364,7 +357,6 @@ export async function resetScopeValues(formData: FormData): Promise<void> {
|
||||
);
|
||||
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -433,7 +425,6 @@ export async function duplicateScope(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
revalidatePath("/admin/theme-builder");
|
||||
void invalidateKey(SCOPED_THEMES_CACHE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -443,8 +434,6 @@ export async function duplicateScope(formData: FormData): Promise<void> {
|
||||
// ─── Data helpers for the admin page ─────────────────────────────────────────
|
||||
|
||||
export async function getThemeBuilderData() {
|
||||
// Exported from a "use server" file, so it is a public endpoint too (the page needs SETTINGS_VIEW).
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const scopes = await getAllScopes();
|
||||
const moduleIds = allModuleIds();
|
||||
const tree = buildTree(scopes);
|
||||
|
||||
@@ -285,7 +285,7 @@ export async function uploadBackground(formData: FormData): Promise<void> {
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
await writeSetting("background_image", `/cms_api/media/${name}`);
|
||||
await writeSetting("background_image", `/api/media/${name}`);
|
||||
await writeSetting(
|
||||
"background_position",
|
||||
BACKGROUND_DEFAULTS.background_position,
|
||||
@@ -301,7 +301,7 @@ export async function uploadBackground(formData: FormData): Promise<void> {
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath(MANAGER_PATH);
|
||||
revalidatePath("/cms_api/media");
|
||||
revalidatePath("/api/media");
|
||||
uploaded = true;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -145,7 +145,7 @@ for (const [name, save] of [
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.url).toMatch(
|
||||
new RegExp(
|
||||
`^/cms_api/media/${name}/[^/]+\\.${format === "jpeg" ? "jpg" : format}$`,
|
||||
`^/api/media/${name}/[^/]+\\.${format === "jpeg" ? "jpg" : format}$`,
|
||||
),
|
||||
);
|
||||
expect(writeFile).toHaveBeenCalledWith(expect.any(String), image);
|
||||
|
||||
@@ -222,8 +222,6 @@ export const banUser = adminAction(
|
||||
machineId: "",
|
||||
});
|
||||
|
||||
// The ban also ends the CMS sessions, API tokens and game SSO ticket the user has.
|
||||
await revokeUserCredentials(userId);
|
||||
await rcon.disconnectUser(userId);
|
||||
|
||||
logAudit({
|
||||
|
||||
@@ -42,20 +42,13 @@ const hoistedUpdateSetObj = vi.hoisted(() => ({
|
||||
set: vi.fn(() => ({ where: hoistedUpdateWhere })),
|
||||
}));
|
||||
|
||||
const hoistedDb = vi.hoisted(() => {
|
||||
const db = {
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({ from: hoistedSelectFrom })),
|
||||
insert: vi.fn(() => ({ values: hoistedInsertValues })),
|
||||
update: vi.fn(() => hoistedUpdateSetObj),
|
||||
delete: vi.fn(() => ({ where: hoistedDeleteWhere })),
|
||||
// The claim and the redemption row share one transaction.
|
||||
transaction: vi.fn(async (fn) => fn(db)),
|
||||
};
|
||||
return db;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: hoistedDb,
|
||||
},
|
||||
WebsiteShopVouchers: {
|
||||
id: "voucher.id",
|
||||
code: "voucher.code",
|
||||
@@ -152,7 +145,6 @@ describe("redeem action", () => {
|
||||
.mockResolvedValueOnce([]) // not used yet
|
||||
.mockResolvedValueOnce([{ maxUses: 10, useCount: 3 }]); // updated count
|
||||
|
||||
hoistedUpdateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const result = await redeem(null, fakeForm({ code: "VALIDCODE" }));
|
||||
expect(result?.ok).toBe(true);
|
||||
expect(result?.message).toContain("500 credits");
|
||||
@@ -164,34 +156,4 @@ describe("redeem action", () => {
|
||||
);
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pays nothing when the voucher has no uses left", async () => {
|
||||
hoistedSelectLimit
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 10n, amount: 500, maxUses: 1, useCount: 0, expiresAt: null },
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
// Another redemption took the last use between the checks and the claim.
|
||||
hoistedUpdateWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
||||
const result = await redeem(null, fakeForm({ code: "VALIDCODE" }));
|
||||
expect(result?.ok).toBe(false);
|
||||
expect(mockSendCurrency).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pays nothing for a parallel second redemption by the same user", async () => {
|
||||
hoistedSelectLimit
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 10n, amount: 500, maxUses: 10, useCount: 2, expiresAt: null },
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
hoistedUpdateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
// The unique (user_id, voucher_id) key refuses the second row.
|
||||
hoistedInsertValues.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Duplicate entry"), { code: "ER_DUP_ENTRY" }),
|
||||
);
|
||||
const result = await redeem(null, fakeForm({ code: "VALIDCODE" }));
|
||||
expect(result?.ok).toBe(false);
|
||||
expect(result?.message).toContain("only use each shop voucher once");
|
||||
expect(mockSendCurrency).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user