fix: production hardening — migration script, security fixes, structured logging, API docs, component splitting

- 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
This commit is contained in:
openhands committed 2026-07-08 13:06:02 +02:00
1 parent 5c638cd6bc
commit c5db7f5156
17 files changed
+1175 -449

No files matched your search

+4
View File
@@ -69,3 +69,7 @@ PAYPAL_API=https://api-m.sandbox.paypal.com
# allowing horizontal scaling across multiple instances. Falls back to in-process # allowing horizontal scaling across multiple instances. Falls back to in-process
# Maps when unset. # Maps when unset.
REDIS_URL=redis://127.0.0.1:6379 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
+1
View File
@@ -26,6 +26,7 @@
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"mysql2": "^3.22.6",
"next": "^16.2.10", "next": "^16.2.10",
"next-auth": "5.0.0-beta.31", "next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.1", "next-intl": "^4.13.1",
+27
View File
@@ -32,6 +32,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.23.0 specifier: ^1.23.0
version: 1.23.0([email protected]) version: 1.23.0([email protected])
mysql2:
specifier: ^3.22.6
version: 3.22.6(@types/[email protected])
next: next:
specifier: ^16.2.10 specifier: ^16.2.10
version: 16.2.10([email protected]([email protected]))([email protected]) version: 16.2.10([email protected]([email protected]))([email protected])
@@ -2201,6 +2204,12 @@ packages:
peerDependencies: peerDependencies:
'@types/node': '>= 8' '@types/node': '>= 8'
[email protected]:
resolution: {integrity: sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==}
engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
[email protected]: [email protected]:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -2488,6 +2497,10 @@ packages:
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-Ti/Zx9J3aITMYUMFhCKbkplQjyi7Kk2SyYXp+rzrkyKZetIy19XbQtVZ+0ZR5aVm/178tIJ6+aVvBqXkH+Xs7w==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -4195,6 +4208,18 @@ snapshots:
named-placeholders: 1.1.6 named-placeholders: 1.1.6
sql-escaper: 1.3.3 sql-escaper: 1.3.3
[email protected](@types/[email protected]):
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
[email protected]: [email protected]:
dependencies: dependencies:
lru.min: 1.1.4 lru.min: 1.1.4
@@ -4507,6 +4532,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+112
View File
@@ -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" } }
}
}
}
}
+136
View File
@@ -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<void> {
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<Set<string>> {
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<void> {
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);
});
+84
View File
@@ -0,0 +1,84 @@
import { Cron } from "croner";
import { env } from "../src/env";
import { prisma } from "../src/lib/prisma";
async function backupEmulatorJar(): Promise<void> {
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<void> {
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<void> {
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);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"noEmit": true
},
"include": ["./**/*.ts"]
}
+106 -94
View File
@@ -1,3 +1,4 @@
import { Prisma } from "@/generated/prisma/client";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth"; import { bearerUserId } from "@/lib/api-auth";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -52,13 +53,15 @@ function rankEntries(entries: BadgeLeaderboardEntry[]): BadgeLeaderboardEntry[]
} }
async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderboardBoard> { async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderboardBoard> {
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; cnt: bigint }>>( const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; cnt: bigint }>>(
`SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt Prisma.sql`
FROM users_badges ub SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
JOIN users u ON u.id = ub.user_id FROM users_badges ub
GROUP BY ub.user_id JOIN users u ON u.id = ub.user_id
ORDER BY cnt DESC GROUP BY ub.user_id
LIMIT 20` ORDER BY cnt DESC
LIMIT 20
`,
); );
const entries = rankEntries( const entries = rankEntries(
@@ -68,39 +71,41 @@ async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderb
figure: r.look, figure: r.look,
score: Number(r.cnt), score: Number(r.cnt),
rank: 0, rank: 0,
})) })),
); );
const totalPlayers = entries.length > 0 const totalPlayers =
? Number( entries.length > 0
( ? Number(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( (
`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges` await prisma.$queryRaw<Array<{ cnt: bigint }>>(
) Prisma.sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`,
)[0]?.cnt ?? 0 )
) )[0]?.cnt ?? 0,
: 0; )
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined; let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) { if (userId) {
const viewerRow = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ?`, Prisma.sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
userId
); );
const viewerScore = Number(viewerRow[0]?.cnt ?? 0); const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore > 0) { if (viewerScore > 0) {
const viewerRank = entries.length > 0 const viewerRank =
? Number( entries.length > 0
( ? Number(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( (
`SELECT COUNT(*) + 1 AS cnt await prisma.$queryRaw<Array<{ cnt: bigint }>>(
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t Prisma.sql`
WHERE t.total > ?`, SELECT COUNT(*) + 1 AS cnt
viewerScore 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; )[0]?.cnt ?? entries.length + 1,
)
: 1;
const viewerUser = await prisma.user.findUnique({ const viewerUser = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { username: true, look: true }, select: { username: true, look: true },
@@ -121,12 +126,14 @@ async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderb
} }
async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderboardBoard> { async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderboardBoard> {
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; score: number }>>( const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; score: number }>>(
`SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score Prisma.sql`
FROM users_settings us SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score
JOIN users u ON u.id = us.user_id FROM users_settings us
ORDER BY us.achievement_score DESC JOIN users u ON u.id = us.user_id
LIMIT 20` ORDER BY us.achievement_score DESC
LIMIT 20
`,
); );
const entries = rankEntries( const entries = rankEntries(
@@ -136,18 +143,19 @@ async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderb
figure: r.look, figure: r.look,
score: r.score, score: r.score,
rank: 0, rank: 0,
})) })),
); );
const totalPlayers = entries.length > 0 const totalPlayers =
? Number( entries.length > 0
( ? Number(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( (
`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0` await prisma.$queryRaw<Array<{ cnt: bigint }>>(
) Prisma.sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`,
)[0]?.cnt ?? 0 )
) )[0]?.cnt ?? 0,
: 0; )
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined; let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) { if (userId) {
@@ -163,11 +171,12 @@ async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderb
if (viewerSettings && viewerSettings.achievementScore > 0) { if (viewerSettings && viewerSettings.achievementScore > 0) {
const viewerRank = Number( const viewerRank = Number(
( (
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( await prisma.$queryRaw<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ?`, Prisma.sql`
viewerSettings.achievementScore SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
`,
) )
)[0]?.cnt ?? entries.length + 1 )[0]?.cnt ?? entries.length + 1,
); );
viewerEntry = { viewerEntry = {
userId, userId,
@@ -186,20 +195,22 @@ async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderb
async function loadRarityBoard( async function loadRarityBoard(
rarity: BadgeRarityKey, rarity: BadgeRarityKey,
badgeCodes: string[], badgeCodes: string[],
userId: number | null userId: number | null,
): Promise<BadgeLeaderboardBoard> { ): Promise<BadgeLeaderboardBoard> {
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 }; if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
const placeholders = badgeCodes.map(() => "?").join(","); const codes = Prisma.join(badgeCodes);
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; cnt: bigint }>>(
`SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; cnt: bigint }>>(
FROM users_badges ub Prisma.sql`
JOIN users u ON u.id = ub.user_id SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
WHERE ub.badge_code IN (${placeholders}) FROM users_badges ub
GROUP BY ub.user_id JOIN users u ON u.id = ub.user_id
ORDER BY cnt DESC WHERE ub.badge_code IN (${codes})
LIMIT 20`, GROUP BY ub.user_id
...badgeCodes ORDER BY cnt DESC
LIMIT 20
`,
); );
const entries = rankEntries( const entries = rankEntries(
@@ -209,26 +220,29 @@ async function loadRarityBoard(
figure: r.look, figure: r.look,
score: Number(r.cnt), score: Number(r.cnt),
rank: 0, rank: 0,
})) })),
); );
const totalPlayers = rows.length > 0 const totalPlayers =
? Number( rows.length > 0
( ? Number(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( (
`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${placeholders})`, await prisma.$queryRaw<Array<{ cnt: bigint }>>(
...badgeCodes Prisma.sql`
) SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${codes})
)[0]?.cnt ?? 0 `,
) )
: 0; )[0]?.cnt ?? 0,
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined; let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) { if (userId) {
const viewerRow = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ? AND badge_code IN (${placeholders})`, Prisma.sql`
userId, SELECT COUNT(*) AS cnt FROM users_badges
...badgeCodes WHERE user_id = ${userId} AND badge_code IN (${codes})
`,
); );
const viewerScore = Number(viewerRow[0]?.cnt ?? 0); const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore > 0) { if (viewerScore > 0) {
@@ -239,14 +253,14 @@ async function loadRarityBoard(
if (viewerUser) { if (viewerUser) {
const viewerRank = Number( const viewerRank = Number(
( (
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>( await prisma.$queryRaw<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) + 1 AS cnt Prisma.sql`
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${placeholders}) GROUP BY user_id) t SELECT COUNT(*) + 1 AS cnt
WHERE t.total > ?`, FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
...badgeCodes, WHERE t.total > ${viewerScore}
viewerScore `,
) )
)[0]?.cnt ?? entries.length + 1 )[0]?.cnt ?? entries.length + 1,
); );
viewerEntry = { viewerEntry = {
userId, userId,
@@ -270,13 +284,12 @@ export async function GET(req: Request) {
userId = session?.user?.id ? Number(session.user.id) : null; userId = session?.user?.id ? Number(session.user.id) : null;
} }
// Gather badge ownership stats const badgeStatsRaw = await prisma.$queryRaw<Array<{ badgeCode: string; ownerCount: bigint }>>(
const badgeStatsRaw = await prisma.$queryRawUnsafe< Prisma.sql`
Array<{ badgeCode: string; ownerCount: bigint }> SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
>( FROM users_badges
`SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount GROUP BY badge_code
FROM users_badges `,
GROUP BY badge_code`
); );
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
@@ -285,7 +298,6 @@ export async function GET(req: Request) {
rarity: assignRarity(Number(r.ownerCount)), rarity: assignRarity(Number(r.ownerCount)),
})); }));
// Group badge codes by rarity
const badgesByRarity = new Map<BadgeRarityKey, string[]>(); const badgesByRarity = new Map<BadgeRarityKey, string[]>();
for (const stat of badgeStats) { for (const stat of badgeStats) {
const list = badgesByRarity.get(stat.rarity) ?? []; const list = badgesByRarity.get(stat.rarity) ?? [];
@@ -302,7 +314,7 @@ export async function GET(req: Request) {
]); ]);
const rarity = Object.fromEntries( const rarity = Object.fromEntries(
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]) rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>; ) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
return apiJson({ return apiJson({
+11 -325
View File
@@ -1,341 +1,27 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import GuestView from "@/components/home/guest-view";
import { HomeLoginForm } from "@/components/auth/home-login-form"; import UserView from "@/components/home/user-view";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
const t = await getTranslations("pages.home");
const session = await auth(); 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) { if (!session?.user) {
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = []; return <GuestView />;
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 (
<div className="flex flex-col gap-4 md:flex-row">
{/* LEFT COLUMN — 1/3 */}
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
{/* Login Card */}
<div className="overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
<div className="absolute inset-0" style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}} />
<div className="absolute inset-0" style={{
background: "linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
}} />
<div className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]" style={{ background: "var(--color-primary)" }} />
</div>
<div className="p-3" style={{ backgroundColor: "var(--color-surface)" }}>
<HomeLoginForm />
</div>
</div>
{/* Register Button */}
<a href="/register" className="relative block">
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
</div>
<span
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white"
style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginLeft: "15px" }}
>
REGISTER NOW
</span>
</a>
{/* Recent Users */}
{recentUsers.length > 0 && (
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<div
className="flex h-12 w-full items-center justify-center bg-repeat"
style={{
background: "linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
}}
>
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
</div>
<div className="grid grid-cols-6 gap-4 p-4">
{recentUsers.map((u) => (
<div
key={u.username}
className="relative overflow-visible rounded-md bg-white p-0 shadow-lg dark:bg-gray-800"
style={{ width: "50px", height: "50px", justifySelf: "center" }}
>
<img
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
alt={u.username}
loading="lazy"
decoding="async"
style={{
position: "absolute",
top: "-12px",
left: "50%",
transform: "translateX(-50%)",
width: "auto",
height: "auto",
maxWidth: "none",
maxHeight: "none",
}}
/>
</div>
))}
</div>
</div>
)}
</div>
{/* RIGHT COLUMN — 2/3 */}
<div className="flex w-full flex-col gap-4 md:w-2/3">
{/* News */}
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<div
className="flex h-12 w-full items-center justify-center"
style={{
background: "linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-xl font-bold text-white">Latest News</h2>
</div>
<div className="p-4">
{articles.length === 0 ? (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">No articles available</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{articles.map((a) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
style={{
height: "215px",
backgroundColor: "var(--color-surface)",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-lg">
<img
src={a.image}
alt={a.title}
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
loading="lazy"
/>
<div
className="absolute left-0 w-full p-2"
style={{ bottom: "-2px", background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)" }}
>
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">{a.title}</h3>
{a.shortStory && (
<p className="truncate text-xs font-normal text-white text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
} }
const dbUser = await prisma.user.findUnique({ const dbUser = await prisma.user
where: { id: Number(session.user.id) }, .findUnique({
select: { username: true, look: true }, where: { id: Number(session.user.id) },
}).catch(() => null); select: { username: true, look: true },
})
.catch(() => null);
if (!dbUser) { if (!dbUser) {
return <p className="py-8 text-center text-gray-500">User not found.</p>; return <p className="py-8 text-center text-gray-500">User not found.</p>;
} }
const userId = Number(session.user.id); return <UserView userId={Number(session.user.id)} username={dbUser.username} look={dbUser.look} />;
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 (
<div className="flex flex-col gap-6 md:flex-row">
{/* Left column — 9/12 */}
<div className="flex w-full flex-col gap-6 md:w-3/4">
{/* User Hero */}
<div className="relative flex h-[200px] items-end overflow-hidden rounded-lg">
<div className="absolute inset-0" style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}} />
<div className="absolute inset-0" style={{
background: "linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
}} />
<div className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]" style={{ background: "var(--color-primary)" }} />
<div className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]" style={{ background: "var(--color-primary)" }} />
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
<Link href={`/u/${dbUser.username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
<img
src={avatarImageUrl(imager, dbUser.look, { size: "l", direction: 2 })}
alt={dbUser.username}
className="drop-shadow-2xl"
style={{ imageRendering: "pixelated" }}
/>
</Link>
<div className="flex items-center mb-4">
<Link href="/client" target="_blank" rel="noopener noreferrer">
<button
type="button"
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
style={{
background: "linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
color: "var(--button-text-color)",
border: "1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
{t("enterHotel", { hotelName })}
<svg className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" /></svg>
</button>
</Link>
</div>
</div>
</div>
{/* Online Friends */}
<div
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
style={{ backgroundColor: "#e9b124" }}
>
<span className="relative text-white">{t("onlineFriends")}</span>
</div>
<div className="flex items-center gap-3 px-3">
{onlineFriends.length === 0 ? (
<p className="mb-3 block w-full text-center text-xs font-medium text-gray-500 dark:text-white md:mb-0 md:text-left">
{t("noFriendsOnline")}
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="transition-all duration-200 hover:scale-110"
>
<img
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
className="h-10 w-10"
loading="lazy"
title={f.username}
/>
</Link>
))
)}
</div>
</div>
</div>
{/* Right column — 3/12 */}
<div className="flex w-full flex-col gap-6 md:w-1/4">
{latestArticle ? (
<div
className="overflow-hidden rounded-lg border"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="flex h-12 items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-lg font-bold text-white">{t("latestArticle")}</h2>
</div>
<Link
href={`/news/${latestArticle.slug}`}
className="group block"
>
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={latestArticle.image}
alt={latestArticle.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
</div>
</Link>
</div>
) : null}
</div>
</div>
);
} }
+183
View File
@@ -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 (
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
>
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
}}
/>
<div
className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
</div>
<div className="p-3" style={{ backgroundColor: "var(--color-surface)" }}>
<HomeLoginForm />
</div>
</div>
<a href="/register" className="relative block">
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
</div>
<span
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white"
style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginLeft: "15px" }}
>
REGISTER NOW
</span>
</a>
{recentUsers.length > 0 && (
<div
className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
>
<div
className="flex h-12 w-full items-center justify-center bg-repeat"
style={{
background:
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
}}
>
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
</div>
<div className="grid grid-cols-6 gap-4 p-4">
{recentUsers.map((u) => (
<div
key={u.username}
className="relative overflow-visible rounded-md bg-white p-0 shadow-lg dark:bg-gray-800"
style={{ width: "50px", height: "50px", justifySelf: "center" }}
>
<img
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
alt={u.username}
loading="lazy"
decoding="async"
style={{
position: "absolute",
top: "-12px",
left: "50%",
transform: "translateX(-50%)",
width: "auto",
height: "auto",
maxWidth: "none",
maxHeight: "none",
}}
/>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex w-full flex-col gap-4 md:w-2/3">
<div
className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
>
<div
className="flex h-12 w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-xl font-bold text-white">{t("latestNews") || "Latest News"}</h2>
</div>
<div className="p-4">
{articles.length === 0 ? (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">No articles available</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{articles.map((a) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
style={{
height: "215px",
backgroundColor: "var(--color-surface)",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-lg">
<img
src={a.image}
alt={a.title}
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
loading="lazy"
/>
<div
className="absolute left-0 w-full p-2"
style={{
bottom: "-2px",
background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
}}
>
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">{a.title}</h3>
{a.shortStory && (
<p className="truncate text-xs font-normal text-white text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+192
View File
@@ -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 (
<div className="flex flex-col gap-6 md:flex-row">
<div className="flex w-full flex-col gap-6 md:w-3/4">
<div className="relative flex h-[200px] items-end overflow-hidden rounded-lg">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
}}
/>
<div
className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
<Link href={`/u/${username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
<img
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
alt={username}
className="drop-shadow-2xl"
style={{ imageRendering: "pixelated" }}
/>
</Link>
<div className="flex items-center mb-4">
<Link href="/client" target="_blank" rel="noopener noreferrer">
<button
type="button"
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
color: "var(--button-text-color)",
border:
"1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
{t("enterHotel", { hotelName })}
<svg
className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</button>
</Link>
</div>
</div>
</div>
<div
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
style={{ backgroundColor: "#e9b124" }}
>
<span className="relative text-white">{t("onlineFriends")}</span>
</div>
<div className="flex items-center gap-3 px-3">
{onlineFriends.length === 0 ? (
<p className="mb-3 block w-full text-center text-xs font-medium text-gray-500 dark:text-white md:mb-0 md:text-left">
{t("noFriendsOnline")}
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="transition-all duration-200 hover:scale-110"
>
<img
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
className="h-10 w-10"
loading="lazy"
title={f.username}
/>
</Link>
))
)}
</div>
</div>
</div>
<div className="flex w-full flex-col gap-6 md:w-1/4">
{latestArticle ? (
<div
className="overflow-hidden rounded-lg border"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="flex h-12 items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-lg font-bold text-white">{t("latestArticle")}</h2>
</div>
<Link href={`/news/${latestArticle.slug}`} className="group block">
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={latestArticle.image}
alt={latestArticle.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
</div>
</Link>
</div>
) : null}
</div>
</div>
);
}
+2
View File
@@ -63,6 +63,8 @@ const schema = z.object({
PAYPAL_API: z.string().url().optional(), PAYPAL_API: z.string().url().optional(),
// Optional Redis — enables shared caching for rate limiting and site settings. // Optional Redis — enables shared caching for rate limiting and site settings.
REDIS_URL: z.string().optional(), REDIS_URL: z.string().optional(),
// Logging level.
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
}); });
type Env = z.infer<typeof schema>; type Env = z.infer<typeof schema>;
+49 -30
View File
@@ -7,6 +7,7 @@ import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp"; import { verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { siteSettings } from "@/lib/services/site-settings";
import { env } from "@/env"; import { env } from "@/env";
async function verify2faCode(userId: number, code: string): Promise<boolean> { async function verify2faCode(userId: number, code: string): Promise<boolean> {
@@ -89,6 +90,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (user.twoFactorConfirmedAt && user.twoFactorSecret) { if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
const code = String(credentials?.code ?? "").trim(); const code = String(credentials?.code ?? "").trim();
if (!code || !env.APP_KEY) return null; 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; if (!(await verify2faCode(user.id, code))) return null;
} }
@@ -118,20 +124,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
callbacks: { callbacks: {
async signIn({ user, account }) { async signIn({ user, account }) {
if (account?.provider === "credentials") return true; if (account?.provider === "credentials") return true;
// OAuth: try to match by email first.
const email = user.email; const requireLink = await siteSettings.getBool("oauth_require_link", false);
if (email) {
try { // Always allow explicitly linked accounts.
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.
if (account?.provider === "discord" && account.providerAccountId) { if (account?.provider === "discord" && account.providerAccountId) {
try { try {
const linked = await prisma.socialAccounts.findUnique({ const linked = await prisma.socialAccounts.findUnique({
@@ -143,28 +139,32 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return "/login?error=Unavailable"; 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"; return "/login?error=NoAccount";
}, },
async jwt({ token, user, account }) { async jwt({ token, user, account }) {
if (user && account?.provider === "credentials") { if (user && account?.provider === "credentials") {
token.rank = (user as { rank?: number }).rank; token.rank = (user as { rank?: number }).rank;
} else if (user?.email) { return token;
// 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
}
} }
// 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) { if (!token.sub && account?.provider === "discord" && account.providerAccountId) {
try { try {
const linked = await prisma.socialAccounts.findUnique({ const linked = await prisma.socialAccounts.findUnique({
@@ -179,12 +179,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
token.sub = String(dbUser.id); token.sub = String(dbUser.id);
token.rank = dbUser.rank; token.rank = dbUser.rank;
token.name = dbUser.username; token.name = dbUser.username;
return token;
} }
} }
} catch { } catch {
// leave token as-is on lookup failure // 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; return token;
}, },
session({ session, token }) { session({ session, token }) {
+16
View File
@@ -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);
});
});
+71
View File
@@ -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<LogLevel, number> = {
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<string, unknown> = {}): void {
writeLog({ level: "debug", message, timestamp: new Date().toISOString(), ...meta });
},
info(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "info", message, timestamp: new Date().toISOString(), ...meta });
},
warn(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "warn", message, timestamp: new Date().toISOString(), ...meta });
},
error(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "error", message, timestamp: new Date().toISOString(), ...meta });
},
};
@@ -0,0 +1,13 @@
<!-- To: [email protected] | From: | Subject: Verify your email · Epicnabbo -->
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
<p>Welcome to Epicnabbo! Confirm this email address to finish setting up your account.</p>
<p style="margin:1.25rem 0">
<a href="https://epicnabbo.nl/verify?token=5e21ef6e09952e9bb7257d54c1fe896713c0f05e6d92a1f8669869d875ab2693&email=juanedavid%40hotmail.com"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
Verify email
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">https://epicnabbo.nl/verify?token=5e21ef6e09952e9bb7257d54c1fe896713c0f05e6d92a1f8669869d875ab2693&email=juanedavid%40hotmail.com</p>
</div>
+159
View File
@@ -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... Line truncated
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