1 Commits
Author SHA1 Message Date
remco 178e6d3d2c chore(deps): update All dependencies
CI / check (pull_request) Failing after 21s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-11 21:00:31 +00:00
6 changed files with 34 additions and 95 deletions

No files matched your search

+6 -60
View File
@@ -13,7 +13,7 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Node.js | >= 22 | Required by Next.js 16 |
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
| Redis | 7.x+ | Optional — caching, rate limiting, SSE |
| Java | 17+ | Required only if building the emulator |
| Maven | 3.9+ | Required only if building the emulator |
@@ -56,7 +56,7 @@ HOTEL_NAME=YourHotel
APP_URL=http://localhost:3000
```
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
### 4. ORM Setup & Type Generation
@@ -121,60 +121,6 @@ Open `http://localhost:3000` in your browser.
---
## DragonflyDB (caching, rate limiting, SSE)
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it
via `REDIS_URL` using the `ioredis` client, so no application code changes are needed —
every Redis command (`PING`, `GET`, `SETEX`, `DEL`, `INCR`, `PEXPIRE`, `PTTL`) works
unchanged. It is optional: without it the CMS falls back to in-process memory.
### Install (Ubuntu/Debian)
```bash
curl -fsSL -o /tmp/dragonfly_amd64.deb \
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
apt-get install -y /tmp/dragonfly_amd64.deb
systemctl enable --now dragonfly
```
This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`.
### Configure
```ini
--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false
```
- `--bind=127.0.0.1` keeps it private on the machine (matches `REDIS_URL=redis://127.0.0.1:6379`).
- `--port=6379` is the default Redis port, so `.env` stays unchanged.
- `--maxmemory` must be at least `0.25GiB` per CPU thread (e.g. `2gb` on a 6-thread server).
- `--version_check=false` disables the periodic outbound update check.
- Snapshots are written to `--dir` (`/var/lib/dragonfly/dump-*.dfs`).
### Point the CMS at it
```dotenv
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
```
### Useful commands
```bash
redis-cli PING # → PONG
redis-cli FLUSHALL # clear the cache
systemctl status dragonfly # service health
```
Verify everything is wired up via the health endpoint:
`/api/health` should report `"redis": true`. The ioredis client automatically reconnects
after a DragonflyDB restart.
> **Note:** if an old Redis install still occupies port 6379, stop it first:
> `systemctl disable --now redis-server`.
## Nginx Configuration
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
@@ -456,7 +402,7 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
| **View Transitions API** | Native browser transitions between page navigations |
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB |
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
@@ -491,9 +437,9 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── db.ts # Drizzle connection singleton (runtime)
│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
│ │ ├── cached-db.ts # Redis-backed query cache helpers
│ │ ├── redis.ts # Redis client (ioredis)
│ │ ├── redis-cache.ts # Redis caching utility for API routes
│ │ ├── cache.ts # In-memory cache fallback
│ │ ├── motion.ts # Framer Motion animation variants
│ │ └── use-event-source.ts # React hook for SSE subscriptions
+1 -1
View File
@@ -58,7 +58,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.85.0",
"resend": "6.20.0",
"resend": "6.19.0",
"server-only": "0.0.1",
"sharp": "0.35.3",
"sonner": "2.0.8",
+5 -5
View File
@@ -119,8 +119,8 @@ importers:
specifier: 7.85.0
version: 7.85.0([email protected])
resend:
specifier: 6.20.0
version: 6.20.0
specifier: 6.19.0
version: 6.19.0
server-only:
specifier: 0.0.1
version: 0.0.1
@@ -2764,8 +2764,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
resend@6.20.0:
resolution: {integrity: sha512-fXDFt7jVMuka6ruS79DzaQFKPyxOAUtoYUGSl3dTUyOnFK9klmUULxADQRzFFtHfHRipYyy62jYcm4cMKqvtjw==}
resend@6.19.0:
resolution: {integrity: sha512-JnEdYnd9WyBDIzunsEZtUF8n3cBKM9SlmySaos/7wwi4sEXKG1Zz4M64VFAyk+278erX01Fq2wHQ3p7OIdPriA==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
@@ -5010,7 +5010,7 @@ snapshots:
[email protected]: {}
resend@6.20.0:
resend@6.19.0:
dependencies:
postal-mime: 2.7.5
standardwebhooks: 1.0.0
+2 -1
View File
@@ -24,7 +24,8 @@ function createRedis(): Redis | null {
const client = new Redis(url, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
if (times > 3) return null;
return Math.min(times * 200, 2000);
},
lazyConnect: true,
});
+16 -21
View File
@@ -30,13 +30,6 @@ function escSqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
const VALID_IDENTIFIER = /^[a-zA-Z0-9_]+$/;
function quoteIdentifier(identifier: string): string {
if (!VALID_IDENTIFIER.test(identifier)) throw new Error("Invalid identifier");
return `\`${identifier}\``;
}
/**
* Mapping of event prefix substrings (lowercase, no leading underscore) to
* their human-readable English page labels. Covers every seasonal/holiday/event
@@ -531,7 +524,7 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
];
for (const { table, idCol } of listTables) {
const [rows] = (await db.execute(
sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`,
sql.raw(`SELECT ${idCol} AS pk, item_ids AS v FROM ${table}`),
)) as unknown as [Array<{ pk: number; v: string | null }>, unknown];
for (const row of rows) {
if (!row.v) continue;
@@ -552,7 +545,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
}
if (!changed) continue;
await db.execute(
sql`UPDATE ${sql.raw(quoteIdentifier(table))} SET item_ids = ${out.join(";")} WHERE ${sql.raw(quoteIdentifier(idCol))} = ${row.pk}`,
sql.raw(
`UPDATE ${table} SET item_ids = '${out.join(";")}' WHERE ${idCol} = ${row.pk}`,
),
);
remapped++;
}
@@ -592,16 +587,16 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
for (const table of singleTables) {
for (let i = 0; i < entries.length; i += CHUNK) {
const chunk = entries.slice(i, i + CHUNK);
const cases = chunk.map(
([dup, canonical]) => sql`WHEN ${dup} THEN ${canonical}`,
);
const dupList = chunk.map(([dup]) => dup);
const cases = chunk
.map(([dup, canonical]) => `WHEN ${dup} THEN ${canonical}`)
.join(" ");
const dupList = chunk.map(([dup]) => `${dup}`).join(",");
try {
const [result] = (await db.execute(sql`
UPDATE ${sql.raw(quoteIdentifier(table))}
SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END
WHERE item_id IN (${sql.join(dupList, sql`, `)})
`)) as unknown as [Record<string, unknown>, unknown];
const [result] = (await db.execute(
sql.raw(
`UPDATE ${table} SET item_id = CASE item_id ${cases} ELSE item_id END WHERE item_id IN (${dupList})`,
),
)) as unknown as [Record<string, unknown>, unknown];
remapped += Number(result.affectedRows ?? 0);
} catch {
// Table may not exist on some hotel schemas — skip it.
@@ -610,9 +605,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
}
const dupIds = entries.map(([dup]) => dup);
await db.execute(sql`
DELETE FROM items_base WHERE id IN (${sql.join(dupIds, sql`, `)})
`);
await db.execute(
sql.raw(`DELETE FROM items_base WHERE id IN (${dupIds.join(",")})`),
);
return { merged, rowsRemoved: dupIds.length, remapped };
}
+4 -7
View File
@@ -12,9 +12,7 @@ function toInt(value: unknown, fallback = 0): number {
/**
* Count catalog_items per page via raw SQL.
* Real Habbo DBs often store page_id as VARCHAR. String literals match both
* INT and VARCHAR columns while keeping the page_id index usable (CAST(... AS CHAR)
* would force a full index scan).
* Real Habbo DBs often store page_id as VARCHAR; numeric groupBy fails or returns 0.
*/
export async function getCatalogItemCounts(
pageIds?: number[],
@@ -28,7 +26,7 @@ export async function getCatalogItemCounts(
const [rows] = (await db.execute(sql`
SELECT page_id, COUNT(*) as cnt
FROM catalog_items
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
GROUP BY page_id
`)) as unknown as [
{ page_id: string | number; cnt: number | bigint }[],
@@ -218,15 +216,14 @@ export async function movePage(
/**
* Delete catalog_items for the given page ids.
* Habbo DBs often store page_id as VARCHAR; text delete on INT or VARCHAR
* columns keeps the page_id index usable (CAST would force a full scan).
* Habbo DBs often store page_id as VARCHAR; typed Int deletes can miss rows.
*/
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
if (pageIds.length === 0) return;
const idStrs = pageIds.map(String);
await db.execute(sql`
DELETE FROM catalog_items
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
`);
}