From c5db7f51566484f7c4e9987ace66226e3c7d2d49 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 8 Jul 2026 13:06:02 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20production=20hardening=20=E2=80=94=20mig?= =?UTF-8?q?ration=20script,=20security=20fixes,=20structured=20logging,=20?= =?UTF-8?q?API=20docs,=20component=20splitting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create apply-migrations.ts and jobs-worker.ts scripts (package.json references) - Convert badge leaderboard from $queryRawUnsafe to $queryRaw with Prisma.sql templates - Fix OAuth email binding: add oauth_require_link site setting, skip 2FA-protected accounts - Add per-user 2FA rate limiting (5/30s) to prevent TOTP brute-force - Add structured JSON logger with levels (debug/info/warn/error) - Split 341-line HomePage into GuestView + UserView components - Add OpenAPI v3.1 spec at /api/openapi.json - Add LOG_LEVEL env var, regenerate Prisma client - Add mysql2 dependency for migration scripts - All 58 tests pass, typecheck clean --- .env.example | 4 + package.json | 1 + pnpm-lock.yaml | 27 ++ public/api/openapi.json | 112 ++++++ scripts/apply-migrations.ts | 136 +++++++ scripts/jobs-worker.ts | 84 +++++ scripts/tsconfig.json | 9 + src/app/api/badges/leaderboard/route.ts | 200 ++++++----- src/app/page.tsx | 336 +----------------- src/components/home/guest-view.tsx | 183 ++++++++++ src/components/home/user-view.tsx | 192 ++++++++++ src/env.ts | 2 + src/lib/auth.ts | 79 ++-- src/lib/logger.test.ts | 16 + src/lib/logger.ts | 71 ++++ .../logs/email-2026-07-07T19-45-53-334Z.html | 13 + storage/logs/update-20260708-112735.log | 159 +++++++++ 17 files changed, 1175 insertions(+), 449 deletions(-) create mode 100644 public/api/openapi.json create mode 100644 scripts/apply-migrations.ts create mode 100644 scripts/jobs-worker.ts create mode 100644 scripts/tsconfig.json create mode 100644 src/components/home/guest-view.tsx create mode 100644 src/components/home/user-view.tsx create mode 100644 src/lib/logger.test.ts create mode 100644 src/lib/logger.ts create mode 100644 storage/logs/email-2026-07-07T19-45-53-334Z.html create mode 100644 storage/logs/update-20260708-112735.log diff --git a/.env.example b/.env.example index f49f47b6..d08f2c8f 100644 --- a/.env.example +++ b/.env.example @@ -69,3 +69,7 @@ PAYPAL_API=https://api-m.sandbox.paypal.com # allowing horizontal scaling across multiple instances. Falls back to in-process # Maps when unset. REDIS_URL=redis://127.0.0.1:6379 + +# Logging level (debug | info | warn | error). Defaults to 'info' in production, +# 'debug' in development. +LOG_LEVEL=info diff --git a/package.json b/package.json index 69c61974..a62db571 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "ioredis": "^5.11.1", "jszip": "^3.10.1", "lucide-react": "^1.23.0", + "mysql2": "^3.22.6", "next": "^16.2.10", "next-auth": "5.0.0-beta.31", "next-intl": "^4.13.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46387047..47f271ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: lucide-react: specifier: ^1.23.0 version: 1.23.0(react@19.2.7) + mysql2: + specifier: ^3.22.6 + version: 3.22.6(@types/node@22.20.0) next: specifier: ^16.2.10 version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2201,6 +2204,12 @@ packages: peerDependencies: '@types/node': '>= 8' + mysql2@3.22.6: + resolution: {integrity: sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} @@ -2488,6 +2497,10 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sql-escaper@1.4.0: + resolution: {integrity: sha512-Ti/Zx9J3aITMYUMFhCKbkplQjyi7Kk2SyYXp+rzrkyKZetIy19XbQtVZ+0ZR5aVm/178tIJ6+aVvBqXkH+Xs7w==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sqlstring@2.3.3: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} @@ -4195,6 +4208,18 @@ snapshots: named-placeholders: 1.1.6 sql-escaper: 1.3.3 + mysql2@3.22.6(@types/node@22.20.0): + dependencies: + '@types/node': 22.20.0 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.4.0 + named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 @@ -4507,6 +4532,8 @@ snapshots: sql-escaper@1.3.3: {} + sql-escaper@1.4.0: {} + sqlstring@2.3.3: {} stackback@0.0.2: {} diff --git a/public/api/openapi.json b/public/api/openapi.json new file mode 100644 index 00000000..4e378449 --- /dev/null +++ b/public/api/openapi.json @@ -0,0 +1,112 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "AtomCMS-Next API", + "version": "1.0.0", + "description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS." + }, + "servers": [ + { "url": "/api", "description": "Local API" } + ], + "security": [ + { "bearerAuth": [], "sessionAuth": [] } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Laravel Sanctum-compatible Bearer token from /api/tokens" + }, + "sessionAuth": { + "type": "apiKey", + "in": "cookie", + "name": "next-auth.session-token", + "description": "NextAuth session cookie (auto-sent by browser)" + } + } + }, + "paths": { + "/health": { + "get": { + "summary": "Health check", + "responses": { "200": { "description": "OK" } } + } + }, + "/articles": { + "get": { + "summary": "List published articles", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }, + { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } } + ], + "responses": { + "200": { + "description": "Article list", + "content": { "application/json": { "schema": { "type": "object" } } } + } + } + } + }, + "/online": { + "get": { + "summary": "Currently online users count", + "responses": { + "200": { + "description": "Online count", + "content": { "application/json": { "schema": { "type": "object", "properties": { "count": { "type": "integer" } } } } } + } + } + } + }, + "/leaderboard": { + "get": { + "summary": "User leaderboard (credits, achievement score, etc.)", + "responses": { "200": { "description": "Leaderboard data" } } + } + }, + "/client/sso": { + "get": { + "summary": "Generate SSO ticket for the game client (requires auth)", + "security": [{ "sessionAuth": [] }], + "responses": { + "200": { + "description": "SSO ticket + hotel name + client URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticket": { "type": "string" }, + "hotelName": { "type": "string" }, + "clientUrl": { "type": "string" } + } + } + } + } + }, + "401": { "description": "Unauthorized" } + } + } + }, + "/me": { + "get": { + "summary": "Current user profile (requires auth)", + "security": [{ "sessionAuth": [] }], + "responses": { "200": { "description": "User profile" } } + } + }, + "/settings": { + "get": { + "summary": "Public site settings (non-sensitive keys only)", + "responses": { "200": { "description": "Settings object" } } + } + }, + "/shop/packages": { + "get": { + "summary": "Available shop packages", + "responses": { "200": { "description": "Package list" } } + } + } + } +} diff --git a/scripts/apply-migrations.ts b/scripts/apply-migrations.ts new file mode 100644 index 00000000..0eba6202 --- /dev/null +++ b/scripts/apply-migrations.ts @@ -0,0 +1,136 @@ +import { createConnection } from "node:net"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations"); +const TRACKING_TABLE = "cms_migrations"; + +interface MigrationFile { + id: string; + name: string; + sql: string; +} + +function getDbConfig(): { url: string; database: string } { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is required"); + const dbName = url.split("/").pop()?.split("?")[0] ?? "atomcms"; + return { url, database: dbName }; +} + +async function ensureConnection(): Promise { + const { database } = getDbConfig(); + const mysql = await import("mysql2/promise"); + const conn = await mysql.createConnection(process.env.DATABASE_URL!); + try { + await conn.execute( + `CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + ); + } finally { + await conn.end(); + } +} + +async function getApplied(): Promise> { + const mysql = await import("mysql2/promise"); + const conn = await mysql.createConnection(process.env.DATABASE_URL!); + try { + const [rows] = await conn.execute( + `SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`, + ); + return new Set((rows as { migration: string }[]).map((r) => r.migration)); + } catch { + return new Set(); + } finally { + await conn.end(); + } +} + +function loadMigrations(): MigrationFile[] { + const entries = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }); + const files = entries + .filter((e) => e.isFile() && e.name.endsWith(".sql")) + .sort((a, b) => a.name.localeCompare(b.name)); + + return files.map((f) => { + const id = f.name.replace(/\.sql$/, ""); + const sql = readFileSync(resolve(MIGRATIONS_DIR, f.name), "utf-8"); + return { id, name: f.name, sql }; + }); +} + +async function apply(migration: MigrationFile): Promise { + const mysql = await import("mysql2/promise"); + const conn = await mysql.createConnection(process.env.DATABASE_URL!); + try { + const statements = migration.sql + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0 && !s.startsWith("--")); + + for (const stmt of statements) { + await conn.execute(stmt); + } + + await conn.execute( + `INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`, + [migration.id], + ); + console.log(`[migrate] Applied: ${migration.name}`); + } finally { + await conn.end(); + } +} + +async function main() { + const flag = process.argv[2]; + + if (flag === "--status") { + await ensureConnection(); + const applied = await getApplied(); + const all = loadMigrations(); + + console.log("\nMigration status:\n"); + for (const m of all) { + const done = applied.has(m.id); + console.log(` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`); + } + + const pending = all.filter((m) => !applied.has(m.id)); + const total = all.length; + const done = total - pending.length; + console.log(`\n${done}/${total} applied, ${pending.length} pending\n`); + return; + } + + await ensureConnection(); + const applied = await getApplied(); + const pending = loadMigrations().filter((m) => !applied.has(m.id)); + + if (pending.length === 0) { + console.log("[migrate] All migrations already applied."); + return; + } + + console.log(`[migrate] Applying ${pending.length} migration(s)...\n`); + for (const m of pending) { + try { + await apply(m); + } catch (err) { + console.error(`[migrate] FAILED: ${m.name}`, err); + process.exit(1); + } + } + console.log("\n[migrate] Done."); +} + +main().catch((err) => { + console.error("[migrate] Fatal:", err); + process.exit(1); +}); diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts new file mode 100644 index 00000000..1bd80092 --- /dev/null +++ b/scripts/jobs-worker.ts @@ -0,0 +1,84 @@ +import { Cron } from "croner"; +import { env } from "../src/env"; +import { prisma } from "../src/lib/prisma"; + +async function backupEmulatorJar(): Promise { + if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return; + + const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + + const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-"); + const backupFile = resolve(env.EMULATOR_BACKUP_DIR, `emulator-${timestamp}.jar`); + + if (!existsSync(env.EMULATOR_BACKUP_DIR)) { + mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true }); + } + + try { + copyFileSync(env.EMULATOR_JAR_PATH, backupFile); + console.log(`[jobs] Backed up emulator JAR to ${backupFile}`); + + // Rotate: keep only the N newest + const keep = env.EMULATOR_BACKUP_KEEP ?? 7; + const files = readdirSync(env.EMULATOR_BACKUP_DIR) + .filter((f) => f.startsWith("emulator-") && f.endsWith(".jar")) + .sort() + .reverse(); + + for (let i = keep; i < files.length; i++) { + unlinkSync(resolve(env.EMULATOR_BACKUP_DIR, files[i])); + console.log(`[jobs] Rotated out old backup: ${files[i]}`); + } + } catch (err) { + console.error("[jobs] JAR backup failed:", err); + } +} + +async function cleanupOldLogs(): Promise { + try { + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await prisma.websiteLoginLogs.deleteMany({ where: { createdAt: { lt: cutoff } } }); + console.log("[jobs] Cleaned up login logs older than 30 days"); + } catch (err) { + console.error("[jobs] Log cleanup failed:", err); + } +} + +async function cleanupOldSessions(): Promise { + try { + const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + await prisma.passwordReset.deleteMany({ where: { createdAt: { lt: cutoff } } }); + console.log("[jobs] Cleaned up expired password reset tokens"); + } catch (err) { + console.error("[jobs] Session cleanup failed:", err); + } +} + +async function main() { + console.log("[jobs] Worker started"); + + // JAR backup — daily at 03:00 + if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) { + new Cron("0 3 * * *", () => { + backupEmulatorJar().catch((e) => console.error("[jobs] Backup error:", e)); + }); + console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)"); + } + + // Log cleanup — daily at 04:00 + new Cron("0 4 * * *", () => { + Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) => + console.error("[jobs] Cleanup error:", e), + ); + }); + console.log("[jobs] Scheduled: old data cleanup (daily 04:00)"); + + // Run once on startup + await Promise.all([backupEmulatorJar(), cleanupOldLogs(), cleanupOldSessions()]); +} + +main().catch((err) => { + console.error("[jobs] Fatal:", err); + process.exit(1); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 00000000..0ca54c0c --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true + }, + "include": ["./**/*.ts"] +} diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts index ab7c470d..71d9d9d5 100644 --- a/src/app/api/badges/leaderboard/route.ts +++ b/src/app/api/badges/leaderboard/route.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@/generated/prisma/client"; import { apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; import { auth } from "@/lib/auth"; @@ -52,13 +53,15 @@ function rankEntries(entries: BadgeLeaderboardEntry[]): BadgeLeaderboardEntry[] } async function loadTotalBadgesBoard(userId: number | null): Promise { - const rows = await prisma.$queryRawUnsafe>( - `SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt - FROM users_badges ub - JOIN users u ON u.id = ub.user_id - GROUP BY ub.user_id - ORDER BY cnt DESC - LIMIT 20` + const rows = await prisma.$queryRaw>( + Prisma.sql` + SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt + FROM users_badges ub + JOIN users u ON u.id = ub.user_id + GROUP BY ub.user_id + ORDER BY cnt DESC + LIMIT 20 + `, ); const entries = rankEntries( @@ -68,39 +71,41 @@ async function loadTotalBadgesBoard(userId: number | null): Promise 0 - ? Number( - ( - await prisma.$queryRawUnsafe>( - `SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges` - ) - )[0]?.cnt ?? 0 - ) - : 0; + const totalPlayers = + entries.length > 0 + ? Number( + ( + await prisma.$queryRaw>( + Prisma.sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`, + ) + )[0]?.cnt ?? 0, + ) + : 0; let viewerEntry: Partial | undefined; if (userId) { - const viewerRow = await prisma.$queryRawUnsafe>( - `SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ?`, - userId + const viewerRow = await prisma.$queryRaw>( + Prisma.sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`, ); const viewerScore = Number(viewerRow[0]?.cnt ?? 0); if (viewerScore > 0) { - const viewerRank = entries.length > 0 - ? Number( - ( - await prisma.$queryRawUnsafe>( - `SELECT COUNT(*) + 1 AS cnt - FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t - WHERE t.total > ?`, - viewerScore - ) - )[0]?.cnt ?? entries.length + 1 - ) - : 1; + const viewerRank = + entries.length > 0 + ? Number( + ( + await prisma.$queryRaw>( + Prisma.sql` + SELECT COUNT(*) + 1 AS cnt + FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t + WHERE t.total > ${viewerScore} + `, + ) + )[0]?.cnt ?? entries.length + 1, + ) + : 1; const viewerUser = await prisma.user.findUnique({ where: { id: userId }, select: { username: true, look: true }, @@ -121,12 +126,14 @@ async function loadTotalBadgesBoard(userId: number | null): Promise { - const rows = await prisma.$queryRawUnsafe>( - `SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score - FROM users_settings us - JOIN users u ON u.id = us.user_id - ORDER BY us.achievement_score DESC - LIMIT 20` + const rows = await prisma.$queryRaw>( + Prisma.sql` + SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score + FROM users_settings us + JOIN users u ON u.id = us.user_id + ORDER BY us.achievement_score DESC + LIMIT 20 + `, ); const entries = rankEntries( @@ -136,18 +143,19 @@ async function loadAchievementBoard(userId: number | null): Promise 0 - ? Number( - ( - await prisma.$queryRawUnsafe>( - `SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0` - ) - )[0]?.cnt ?? 0 - ) - : 0; + const totalPlayers = + entries.length > 0 + ? Number( + ( + await prisma.$queryRaw>( + Prisma.sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`, + ) + )[0]?.cnt ?? 0, + ) + : 0; let viewerEntry: Partial | undefined; if (userId) { @@ -163,11 +171,12 @@ async function loadAchievementBoard(userId: number | null): Promise 0) { const viewerRank = Number( ( - await prisma.$queryRawUnsafe>( - `SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ?`, - viewerSettings.achievementScore + await prisma.$queryRaw>( + Prisma.sql` + SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore} + `, ) - )[0]?.cnt ?? entries.length + 1 + )[0]?.cnt ?? entries.length + 1, ); viewerEntry = { userId, @@ -186,20 +195,22 @@ async function loadAchievementBoard(userId: number | null): Promise { if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 }; - const placeholders = badgeCodes.map(() => "?").join(","); - const rows = await prisma.$queryRawUnsafe>( - `SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt - FROM users_badges ub - JOIN users u ON u.id = ub.user_id - WHERE ub.badge_code IN (${placeholders}) - GROUP BY ub.user_id - ORDER BY cnt DESC - LIMIT 20`, - ...badgeCodes + const codes = Prisma.join(badgeCodes); + + const rows = await prisma.$queryRaw>( + Prisma.sql` + SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt + FROM users_badges ub + JOIN users u ON u.id = ub.user_id + WHERE ub.badge_code IN (${codes}) + GROUP BY ub.user_id + ORDER BY cnt DESC + LIMIT 20 + `, ); const entries = rankEntries( @@ -209,26 +220,29 @@ async function loadRarityBoard( figure: r.look, score: Number(r.cnt), rank: 0, - })) + })), ); - const totalPlayers = rows.length > 0 - ? Number( - ( - await prisma.$queryRawUnsafe>( - `SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${placeholders})`, - ...badgeCodes - ) - )[0]?.cnt ?? 0 - ) - : 0; + const totalPlayers = + rows.length > 0 + ? Number( + ( + await prisma.$queryRaw>( + Prisma.sql` + SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${codes}) + `, + ) + )[0]?.cnt ?? 0, + ) + : 0; let viewerEntry: Partial | undefined; if (userId) { - const viewerRow = await prisma.$queryRawUnsafe>( - `SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ? AND badge_code IN (${placeholders})`, - userId, - ...badgeCodes + const viewerRow = await prisma.$queryRaw>( + Prisma.sql` + SELECT COUNT(*) AS cnt FROM users_badges + WHERE user_id = ${userId} AND badge_code IN (${codes}) + `, ); const viewerScore = Number(viewerRow[0]?.cnt ?? 0); if (viewerScore > 0) { @@ -239,14 +253,14 @@ async function loadRarityBoard( if (viewerUser) { const viewerRank = Number( ( - await prisma.$queryRawUnsafe>( - `SELECT COUNT(*) + 1 AS cnt - FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${placeholders}) GROUP BY user_id) t - WHERE t.total > ?`, - ...badgeCodes, - viewerScore + await prisma.$queryRaw>( + Prisma.sql` + SELECT COUNT(*) + 1 AS cnt + FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t + WHERE t.total > ${viewerScore} + `, ) - )[0]?.cnt ?? entries.length + 1 + )[0]?.cnt ?? entries.length + 1, ); viewerEntry = { userId, @@ -270,13 +284,12 @@ export async function GET(req: Request) { userId = session?.user?.id ? Number(session.user.id) : null; } - // Gather badge ownership stats - const badgeStatsRaw = await prisma.$queryRawUnsafe< - Array<{ badgeCode: string; ownerCount: bigint }> - >( - `SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount - FROM users_badges - GROUP BY badge_code` + const badgeStatsRaw = await prisma.$queryRaw>( + Prisma.sql` + SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount + FROM users_badges + GROUP BY badge_code + `, ); const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ @@ -285,7 +298,6 @@ export async function GET(req: Request) { rarity: assignRarity(Number(r.ownerCount)), })); - // Group badge codes by rarity const badgesByRarity = new Map(); for (const stat of badgeStats) { const list = badgesByRarity.get(stat.rarity) ?? []; @@ -302,7 +314,7 @@ export async function GET(req: Request) { ]); const rarity = Object.fromEntries( - rarityKeys.map((rk, i) => [rk, rarityBoards[i]]) + rarityKeys.map((rk, i) => [rk, rarityBoards[i]]), ) as Record; return apiJson({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 1f471742..8f274e26 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,341 +1,27 @@ -import { getTranslations } from "next-intl/server"; -import Link from "next/link"; import { auth } from "@/lib/auth"; -import { avatarImageUrl, excerpt } from "@/lib/format"; import { prisma } from "@/lib/prisma"; -import { siteSettings } from "@/lib/services/site-settings"; -import { HomeLoginForm } from "@/components/auth/home-login-form"; +import GuestView from "@/components/home/guest-view"; +import UserView from "@/components/home/user-view"; export const dynamic = "force-dynamic"; export default async function HomePage() { - const t = await getTranslations("pages.home"); const session = await auth(); - const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; - const imager = - (await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? ""; if (!session?.user) { - let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = []; - try { - articles = await prisma.websiteArticles.findMany({ - orderBy: { createdAt: "desc" }, - take: 8, - select: { slug: true, title: true, shortStory: true, image: true }, - }); - } catch {} - - let recentUsers: Array<{ username: string; look: string }> = []; - try { - recentUsers = await prisma.user.findMany({ - orderBy: { lastOnline: "desc" }, - take: 12, - select: { username: true, look: true }, - }); - } catch {} - - return ( -
- {/* LEFT COLUMN — 1/3 */} -
- {/* Login Card */} -
-
-
-
-
-
-
- -
-
- - {/* Register Button */} - -
- Register -
- - REGISTER NOW - -
- - {/* Recent Users */} - {recentUsers.length > 0 && ( -
-
-

Recent Users

-
-
- {recentUsers.map((u) => ( -
- {u.username} -
- ))} -
-
- )} -
- - {/* RIGHT COLUMN — 2/3 */} -
- {/* News */} -
-
-

Latest News

-
-
- {articles.length === 0 ? ( -

No articles available

- ) : ( -
- {articles.map((a) => ( - -
- {a.title} -
-

{a.title}

- {a.shortStory && ( -

- {excerpt(a.shortStory, 80)} -

- )} -
-
- - ))} -
- )} -
-
-
-
- ); + return ; } - const dbUser = await prisma.user.findUnique({ - where: { id: Number(session.user.id) }, - select: { username: true, look: true }, - }).catch(() => null); + const dbUser = await prisma.user + .findUnique({ + where: { id: Number(session.user.id) }, + select: { username: true, look: true }, + }) + .catch(() => null); + if (!dbUser) { return

User not found.

; } - const userId = Number(session.user.id); - let onlineFriends: Array<{ username: string; look: string }> = []; - try { - const friendships = await prisma.messengerFriendships.findMany({ - where: { - OR: [ - { userOneId: userId }, - { userTwoId: userId }, - ], - }, - select: { userOneId: true, userTwoId: true }, - }); - const friendIds = friendships.map((f) => - f.userOneId === userId ? f.userTwoId : f.userOneId, - ); - if (friendIds.length > 0) { - onlineFriends = await prisma.user.findMany({ - where: { id: { in: friendIds }, online: "1" }, - take: 10, - select: { username: true, look: true }, - }); - } - } catch {} - - const [latestArticle] = await prisma.websiteArticles.findMany({ - orderBy: { createdAt: "desc" }, - take: 1, - select: { slug: true, title: true, shortStory: true, image: true }, - }).catch(() => []); - - return ( -
- {/* Left column — 9/12 */} -
- {/* User Hero */} -
-
-
-
-
-
- - {dbUser.username} - -
- - - -
-
-
- - {/* Online Friends */} -
-
- {t("onlineFriends")} -
-
- {onlineFriends.length === 0 ? ( -

- {t("noFriendsOnline")} -

- ) : ( - onlineFriends.map((f) => ( - - {f.username} - - )) - )} -
-
-
- - {/* Right column — 3/12 */} -
- {latestArticle ? ( -
-
-

{t("latestArticle")}

-
- -
- {latestArticle.title} -
-
-

{latestArticle.title}

- {latestArticle.shortStory && ( -

- {excerpt(latestArticle.shortStory, 100)} -

- )} -
- -
- ) : null} -
-
- ); + return ; } diff --git a/src/components/home/guest-view.tsx b/src/components/home/guest-view.tsx new file mode 100644 index 00000000..a1ccea92 --- /dev/null +++ b/src/components/home/guest-view.tsx @@ -0,0 +1,183 @@ +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { avatarImageUrl, excerpt } from "@/lib/format"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; +import { HomeLoginForm } from "@/components/auth/home-login-form"; + +export default async function GuestView() { + const t = await getTranslations("pages.home"); + const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; + const imager = + (await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? ""; + + let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = []; + try { + articles = await prisma.websiteArticles.findMany({ + orderBy: { createdAt: "desc" }, + take: 8, + select: { slug: true, title: true, shortStory: true, image: true }, + }); + } catch {} + + let recentUsers: Array<{ username: string; look: string }> = []; + try { + recentUsers = await prisma.user.findMany({ + orderBy: { lastOnline: "desc" }, + take: 12, + select: { username: true, look: true }, + }); + } catch {} + + return ( +
+
+
+
+
+
+
+
+
+ +
+
+ + +
+ Register +
+ + REGISTER NOW + +
+ + {recentUsers.length > 0 && ( +
+
+

Recent Users

+
+
+ {recentUsers.map((u) => ( +
+ {u.username} +
+ ))} +
+
+ )} +
+ +
+
+
+

{t("latestNews") || "Latest News"}

+
+
+ {articles.length === 0 ? ( +

No articles available

+ ) : ( +
+ {articles.map((a) => ( + +
+ {a.title} +
+

{a.title}

+ {a.shortStory && ( +

+ {excerpt(a.shortStory, 80)} +

+ )} +
+
+ + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/home/user-view.tsx b/src/components/home/user-view.tsx new file mode 100644 index 00000000..01a57e7b --- /dev/null +++ b/src/components/home/user-view.tsx @@ -0,0 +1,192 @@ +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { avatarImageUrl, excerpt } from "@/lib/format"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +interface UserViewProps { + userId: number; + username: string; + look: string; +} + +export default async function UserView({ userId, username, look }: UserViewProps) { + const t = await getTranslations("pages.home"); + const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; + const imager = + (await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? ""; + + let onlineFriends: Array<{ username: string; look: string }> = []; + try { + const friendships = await prisma.messengerFriendships.findMany({ + where: { OR: [{ userOneId: userId }, { userTwoId: userId }] }, + select: { userOneId: true, userTwoId: true }, + }); + const friendIds = friendships.map((f) => + f.userOneId === userId ? f.userTwoId : f.userOneId, + ); + if (friendIds.length > 0) { + onlineFriends = await prisma.user.findMany({ + where: { id: { in: friendIds }, online: "1" }, + take: 10, + select: { username: true, look: true }, + }); + } + } catch {} + + const [latestArticle] = await prisma.websiteArticles + .findMany({ + orderBy: { createdAt: "desc" }, + take: 1, + select: { slug: true, title: true, shortStory: true, image: true }, + }) + .catch(() => []); + + return ( +
+
+
+
+
+
+
+
+ + {username} + +
+ + + +
+
+
+ +
+
+ {t("onlineFriends")} +
+
+ {onlineFriends.length === 0 ? ( +

+ {t("noFriendsOnline")} +

+ ) : ( + onlineFriends.map((f) => ( + + {f.username} + + )) + )} +
+
+
+ +
+ {latestArticle ? ( +
+
+

{t("latestArticle")}

+
+ +
+ {latestArticle.title} +
+
+

{latestArticle.title}

+ {latestArticle.shortStory && ( +

+ {excerpt(latestArticle.shortStory, 100)} +

+ )} +
+ +
+ ) : null} +
+
+ ); +} diff --git a/src/env.ts b/src/env.ts index 7447b34b..22d2a9b7 100644 --- a/src/env.ts +++ b/src/env.ts @@ -63,6 +63,8 @@ const schema = z.object({ PAYPAL_API: z.string().url().optional(), // Optional Redis — enables shared caching for rate limiting and site settings. REDIS_URL: z.string().optional(), + // Logging level. + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(), }); type Env = z.infer; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 547d6b92..dee19ce8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -7,6 +7,7 @@ import { checkLogin } from "@/lib/auth/password"; import { verifyTotp } from "@/lib/auth/totp"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { siteSettings } from "@/lib/services/site-settings"; import { env } from "@/env"; async function verify2faCode(userId: number, code: string): Promise { @@ -89,6 +90,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (user.twoFactorConfirmedAt && user.twoFactorSecret) { const code = String(credentials?.code ?? "").trim(); if (!code || !env.APP_KEY) return null; + + // Per-user 2FA rate limit (5 attempts per 30s) — prevents TOTP brute-force + // even when the attacker rotates IPs or knows the password. + if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null; + if (!(await verify2faCode(user.id, code))) return null; } @@ -118,20 +124,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ callbacks: { async signIn({ user, account }) { if (account?.provider === "credentials") return true; - // OAuth: try to match by email first. - const email = user.email; - if (email) { - try { - const dbUser = await prisma.user.findFirst({ - where: { mail: email }, - select: { id: true }, - }); - if (dbUser) return true; - } catch { - return "/login?error=Unavailable"; - } - } - // If email didn't match, try Discord ID via SocialAccounts. + + const requireLink = await siteSettings.getBool("oauth_require_link", false); + + // Always allow explicitly linked accounts. if (account?.provider === "discord" && account.providerAccountId) { try { const linked = await prisma.socialAccounts.findUnique({ @@ -143,28 +139,32 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ return "/login?error=Unavailable"; } } + + // Email-based binding: only allowed when oauth_require_link is disabled + // AND the matched account does NOT have 2FA enabled (account takeover guard). + if (!requireLink && user.email) { + try { + const dbUser = await prisma.user.findFirst({ + where: { mail: user.email, twoFactorConfirmedAt: null }, + select: { id: true }, + }); + if (dbUser) return true; + } catch { + return "/login?error=Unavailable"; + } + } + return "/login?error=NoAccount"; }, async jwt({ token, user, account }) { if (user && account?.provider === "credentials") { token.rank = (user as { rank?: number }).rank; - } else if (user?.email) { - // OAuth with email: bind to matching hotel account. - try { - const dbUser = await prisma.user.findFirst({ - where: { mail: user.email }, - select: { id: true, rank: true, username: true }, - }); - if (dbUser) { - token.sub = String(dbUser.id); - token.rank = dbUser.rank; - token.name = dbUser.username; - } - } catch { - // leave token as-is on lookup failure - } + return token; } - // OAuth without email match: try Discord ID via SocialAccounts. + + const requireLink = await siteSettings.getBool("oauth_require_link", false); + + // Try Discord ID via SocialAccounts (always allowed, even when requireLink is true). if (!token.sub && account?.provider === "discord" && account.providerAccountId) { try { const linked = await prisma.socialAccounts.findUnique({ @@ -179,12 +179,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ token.sub = String(dbUser.id); token.rank = dbUser.rank; token.name = dbUser.username; + return token; } } } catch { // leave token as-is on lookup failure } } + + // Email-based binding: only when requireLink is off AND account has no 2FA. + if (!requireLink && user?.email && !token.sub) { + try { + const dbUser = await prisma.user.findFirst({ + where: { mail: user.email, twoFactorConfirmedAt: null }, + select: { id: true, rank: true, username: true }, + }); + if (dbUser) { + token.sub = String(dbUser.id); + token.rank = dbUser.rank; + token.name = dbUser.username; + } + } catch { + // leave token as-is on lookup failure + } + } + return token; }, session({ session, token }) { diff --git a/src/lib/logger.test.ts b/src/lib/logger.test.ts new file mode 100644 index 00000000..4a8a7a7f --- /dev/null +++ b/src/lib/logger.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { generateRequestId } from "./logger"; + +describe("generateRequestId", () => { + it("produces a non-empty string", () => { + const id = generateRequestId(); + expect(id).toBeTruthy(); + expect(typeof id).toBe("string"); + }); + + it("produces unique values on successive calls", () => { + const a = generateRequestId(); + const b = generateRequestId(); + expect(a).not.toBe(b); + }); +}); diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 00000000..4f7d44f7 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,71 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface LogEntry { + level: LogLevel; + message: string; + timestamp: string; + requestId?: string; + module?: string; + [key: string]: unknown; +} + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const currentLevel: LogLevel = + (process.env.LOG_LEVEL as LogLevel) ?? (process.env.NODE_ENV === "production" ? "info" : "debug"); + +let requestIdCounter = 0; + +export function generateRequestId(): string { + requestIdCounter += 1; + return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`; +} + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel]; +} + +function formatLog(entry: LogEntry): string { + return JSON.stringify(entry); +} + +function writeLog(entry: LogEntry): void { + if (!shouldLog(entry.level)) return; + + const formatted = formatLog(entry); + + switch (entry.level) { + case "error": + console.error(formatted); + break; + case "warn": + console.warn(formatted); + break; + default: + console.log(formatted); + break; + } +} + +export const logger = { + debug(message: string, meta: Record = {}): void { + writeLog({ level: "debug", message, timestamp: new Date().toISOString(), ...meta }); + }, + + info(message: string, meta: Record = {}): void { + writeLog({ level: "info", message, timestamp: new Date().toISOString(), ...meta }); + }, + + warn(message: string, meta: Record = {}): void { + writeLog({ level: "warn", message, timestamp: new Date().toISOString(), ...meta }); + }, + + error(message: string, meta: Record = {}): void { + writeLog({ level: "error", message, timestamp: new Date().toISOString(), ...meta }); + }, +}; diff --git a/storage/logs/email-2026-07-07T19-45-53-334Z.html b/storage/logs/email-2026-07-07T19-45-53-334Z.html new file mode 100644 index 00000000..8fd70ceb --- /dev/null +++ b/storage/logs/email-2026-07-07T19-45-53-334Z.html @@ -0,0 +1,13 @@ + +
+

Verify your email

+

Welcome to Epicnabbo! Confirm this email address to finish setting up your account.

+

+ + Verify email + +

+

If the button doesn't work, paste this link into your browser:

+

https://epicnabbo.nl/verify?token=5e21ef6e09952e9bb7257d54c1fe896713c0f05e6d92a1f8669869d875ab2693&email=juanedavid%40hotmail.com

+
\ No newline at end of file diff --git a/storage/logs/update-20260708-112735.log b/storage/logs/update-20260708-112735.log new file mode 100644 index 00000000..2d02ed39 --- /dev/null +++ b/storage/logs/update-20260708-112735.log @@ -0,0 +1,159 @@ + + + ╔═══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ ███████╗███╗ ███╗ █████╗ ██████╗ ████████╗ ║ + ║ ██╔════╝████╗ ████║██╔══██╗██╔══██╗╚══██╔══╝ ║ + ║ █████╗ ██╔████╔██║███████║██████╔╝ ██║ ║ + ║ ██╔══╝ ██║╚██╔╝██║██╔══██║██╔══██╗ ██║ ║ + ║ ███████╗██║ ╚═╝ ██║██║ ██║██║ ██║ ██║ ║ + ║ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ║ + ║ ║ + ╠═══════════════════════════════════════════════════════════════════════════╣ + ║  Emulator / Nitro-V3 / Nitro-V3-Render ║ + ║  Updater by Remco — epicnabbo.nl ║ + ╠═══════════════════════════════════════════════════════════════════════════╣ + ║ Branch: main (2 detected) │ 11:27 ║ + ╚═══════════════════════════════════════════════════════════════════════════╝ + +  UPDATE  + 1) 🚀 Quick Update — branch: main + 2) ⚡ Full Update (choose branch) + 3) ⚙ Emulator Only + 4) ⚙ Nitro-V3 Client Only + 5) ⚙ Renderer Only + 6) ⚙ Configs Only + +  TOOLS  + 7) 🔀 Switch Branch + 8) 🗄 Database + 9) 🛡 Backup / Restore + 10) 🔧 Services + 11) 📊 Status + 12) ♥ Health Check + 13) 🧹 Clean + 14) 👁 Logs + 15) ⚙ Config + +  ADVANCED  + 16) 🖥 Git Log + 17) 🌐 Compare Branches + 18) ⏱ Cron Scheduler + + 0) Exit + + Select [0-18]: + Update: Full + + 1) Dev + 2) main (current) + 3) Custom + + Branch [1-3]: + Branch: main Mode: Full + ➜ Start update? [Y/n] + ➜ Site: https://epicnabbo.nl | Branch: main | Mode: full +[2026-07-08 11:27:55] [INFO] Site: https://epicnabbo.nl | Branch: main | Mode: full + ➜ Log: /var/www/atom-nexst/storage/logs/update-20260708-112735.log +[2026-07-08 11:27:55] [INFO] Log: /var/www/atom-nexst/storage/logs/update-20260708-112735.log + +  Step 1/8 — System Diagnostics  + + ✔ All commands available +[2026-07-08 11:27:55] [OK] All commands available + ✔ All directories exist +[2026-07-08 11:27:55] [OK] All directories exist + ✔ 128GB disk available +[2026-07-08 11:27:55] [OK] 128GB disk available + ✔ Database connected +[2026-07-08 11:27:55] [OK] Database connected + +  Step 2/8 — Update & Build Emulator  + +Saved working directory and index state WIP on main: 1a05d0ed 🆙 Bump version to 4.2.49 [skip ci] +Your branch is up to date with 'origin/main'. +Updating 1a05d0ed..5c2c8013 +Fast-forward + Emulator/pom.xml | 4 +- + .../habbo/habbohotel/rooms/RoomCycleManager.java | 123 ++------- + .../com/eu/habbo/habbohotel/rooms/RoomUnit.java | 20 +- + .../eu/habbo/habbohotel/rooms/RoomUnitManager.java | 280 +-------------------- + 4 files changed, 39 insertions(+), 388 deletions(-) +[?25l ⠋ Backing up database... ⠙ Backing up database... ⠹ Backing up database... ⠸ Backing up database... ⠼ Backing up database... ⠴ Backing up database... ⠦ Backing up database... ⠧ Backing up database... ⠇ Backing up database... ⠏ Backing up database... ⠋ Backing up database... ⠙ Backing up database... [?12l[?25h ✔ Done + ✔ Backup: 35M +[2026-07-08 11:28:29] [OK] Backup: 35M +[?25l ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... ⠙ Building emulator... ⠹ Building emulator... ⠸ Building emulator... ⠼ Building emulator... ⠴ Building emulator... ⠦ Building emulator... ⠧ Building emulator... ⠇ Building emulator... ⠏ Building emulator... ⠋ Building emulator... WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release +11:28:42.663 WARN [main ] FurnidataReader | FurnidataReader: ignoring out-of-base file /tmp/junit-2680494782301105986/furnidata/core/../../secret.json +11:28:42.669 WARN [main ] FurnidataReader | FurnidataReader: /tmp/junit-4684459296101932112/FurnitureData.json is 292 bytes, over cap 8 — refusing +11:28:42.679 WARN [main ] FurnidataReader | FurnidataReader: /tmp/junit-4000087741262526872/furnidata/manifest.json is 1850 bytes, over cap 8 — refusing +11:28:43.087 INFO [main ] Version | HV000001: Hibernate Validator 9.1.2.Final + [?12l[?25h ✔ Done + ✔ Jar: Habbo-4.2.50-jar-with-dependencies.jar +[2026-07-08 11:28:51] [OK] Jar: Habbo-4.2.50-jar-with-dependencies.jar + ✔ Launch script updated +[2026-07-08 11:28:51] [OK] Launch script updated + +  Step 3/8 — Update Nitro_Render_V3  + +No local changes to save +Your branch is up to date with 'origin/main'. +Already up to date. + ➜ Renderer: already up to date +[2026-07-08 11:29:11] [INFO] Renderer: already up to date + +  Step 4/8 — Update & Build Nitro-V3  + +No local changes to save +Your branch is up to date with 'origin/main'. +Already up to date. + ➜ Nitro-V3: already up to date +[2026-07-08 11:29:32] [INFO] Nitro-V3: already up to date + +  Step 5/8 — Sync Configurations  + + ✔ 18 config file(s) synced +[2026-07-08 11:29:33] [OK] 18 config file(s) synced + ➜ Applying critical config URLs... +[2026-07-08 11:29:33] [INFO] Applying critical config URLs... + [OK] Fixed: renderer-config.json + [OK] Fixed: renderer-config.json + [OK] Fixed: ui-config.json + [OK] Fixed: ui-config.json + ✔ All config URLs synced +[2026-07-08 11:29:33] [OK] All config URLs synced + +  Step 6/8 — Cleanup  + +yarn cache v1.22.22 +success Cleared cache. +Done in 0.05s. + ✔ Cleanup done (0 logs removed) +[2026-07-08 11:29:33] [OK] Cleanup done (0 logs removed) + +  Step 7/8 — Set Permissions  + + ✔ Permissions set +[2026-07-08 11:29:35] [OK] Permissions set + +  Step 8/8 — Restart Services  + + ✔ emulator restarted +[2026-07-08 11:29:35] [OK] emulator restarted + ✔ Nginx reloaded +[2026-07-08 11:29:36] [OK] Nginx reloaded +OK + ✔ Redis flushed +[2026-07-08 11:29:36] [OK] Redis flushed + + ╔══════════════════════════════════════════════════════════════════╗ + ║ ✔ UPDATE COMPLETED SUCCESSFULLY ║ + ╠══════════════════════════════════════════════════════════════════╣ + ║ Duration: 2m 1s ║ + ║ Updates: Yes ║ + ║ Nitro build: No ║ + ║ Warnings: 0 ║ + ║ Repos: Emulator ║ + ╚══════════════════════════════════════════════════════════════════╝ + + + Press Enter to continue..../update-Nitrov3.sh: line 929: read: 0: read error: Input/output error