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:
1 parent
5c638cd6bc
commit
c5db7f5156
17 files changed
+1175
-449
No files matched your search
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
Generated
+27
@@ -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]: {}
|
||||||
|
|||||||
@@ -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" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
[H[2J[3J[1;36m
|
||||||
|
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ ███████╗███╗ ███╗ █████╗ ██████╗ ████████╗ ║
|
||||||
|
║ ██╔════╝████╗ ████║██╔══██╗██╔══██╗╚══██╔══╝ ║
|
||||||
|
║ █████╗ ██╔████╔██║███████║██████╔╝ ██║ ║
|
||||||
|
║ ██╔══╝ ██║╚██╔╝██║██╔══██║██╔══██╗ ██║ ║
|
||||||
|
║ ███████╗██║ ╚═╝ ██║██║ ██║██║ ██║ ██║ ║
|
||||||
|
║ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ [38;5;220m[1m Emulator / Nitro-V3 / Nitro-V3-Render[1;36m ║
|
||||||
|
║ [1;37m[1m Updater by Remco — epicnabbo.nl[1;36m ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ [2mBranch: [1;36mmain[2m (2 detected) │ 11:27[1;36m ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||||
|
[0m
|
||||||
|
[42m[1;37m[1m UPDATE [0m
|
||||||
|
[1;32m[1m1)[0m 🚀 Quick Update — branch: [1;36mmain[0m
|
||||||
|
[1;32m[1m2)[0m ⚡ Full Update (choose branch)
|
||||||
|
[1;32m[1m3)[0m ⚙ Emulator Only
|
||||||
|
[1;32m[1m4)[0m ⚙ Nitro-V3 Client Only
|
||||||
|
[1;32m[1m5)[0m ⚙ Renderer Only
|
||||||
|
[1;32m[1m6)[0m ⚙ Configs Only
|
||||||
|
|
||||||
|
[45m[1;37m[1m TOOLS [0m
|
||||||
|
[1;35m[1m7)[0m 🔀 Switch Branch
|
||||||
|
[1;33m[1m8)[0m 🗄 Database
|
||||||
|
[1;33m[1m9)[0m 🛡 Backup / Restore
|
||||||
|
[1;33m[1m10)[0m 🔧 Services
|
||||||
|
[1;33m[1m11)[0m 📊 Status
|
||||||
|
[1;33m[1m12)[0m ♥ Health Check
|
||||||
|
[1;33m[1m13)[0m 🧹 Clean
|
||||||
|
[1;33m[1m14)[0m 👁 Logs
|
||||||
|
[1;33m[1m15)[0m ⚙ Config
|
||||||
|
|
||||||
|
[44m[1;37m[1m ADVANCED [0m
|
||||||
|
[1;34m[1m16)[0m 🖥 Git Log
|
||||||
|
[1;34m[1m17)[0m 🌐 Compare Branches
|
||||||
|
[1;34m[1m18)[0m ⏱ Cron Scheduler
|
||||||
|
|
||||||
|
[1;31m[1m0)[0m Exit
|
||||||
|
|
||||||
|
[1;36m[1mSelect [0-18][0m:
|
||||||
|
[1m[1;36mUpdate: [1;32mFull[0m
|
||||||
|
|
||||||
|
[1;32m[1m1)[0m [1;36mDev[0m
|
||||||
|
[1;32m[1m2)[0m [1;36mmain[0m [1;32m(current)[0m
|
||||||
|
[1;32m[1m3)[0m Custom
|
||||||
|
|
||||||
|
Branch [1-3]:
|
||||||
|
Branch: [1;36m[1mmain[0m Mode: [1;32mFull[0m
|
||||||
|
[1;33m➜[0m [1mStart update?[0m [Y/n]
|
||||||
|
[1;34m➜[0m Site: https://epicnabbo.nl | Branch: main | Mode: full
|
||||||
|
[2026-07-08 11:27:55] [INFO] Site: https://epicnabbo.nl | Branch: main | Mode: full
|
||||||
|
[1;34m➜[0m 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
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 1/8 — System Diagnostics [0m
|
||||||
|
|
||||||
|
[1;32m✔[0m All commands available
|
||||||
|
[2026-07-08 11:27:55] [OK] All commands available
|
||||||
|
[1;32m✔[0m All directories exist
|
||||||
|
[2026-07-08 11:27:55] [OK] All directories exist
|
||||||
|
[1;32m✔[0m 128GB disk available
|
||||||
|
[2026-07-08 11:27:55] [OK] 128GB disk available
|
||||||
|
[1;32m✔[0m Database connected
|
||||||
|
[2026-07-08 11:27:55] [OK] Database connected
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 2/8 — Update & Build Emulator [0m
|
||||||
|
|
||||||
|
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␍ [1;36m⠋[0m Backing up database... ␍ [1;36m⠙[0m Backing up database... ␍ [1;36m⠹[0m Backing up database... ␍ [1;36m⠸[0m Backing up database... ␍ [1;36m⠼[0m Backing up database... ␍ [1;36m⠴[0m Backing up database... ␍ [1;36m⠦[0m Backing up database... ␍ [1;36m⠧[0m Backing up database... ␍ [1;36m⠇[0m Backing up database... ␍ [1;36m⠏[0m Backing up database... ␍ [1;36m⠋[0m Backing up database... ␍ [1;36m⠙[0m Backing up database... ␍[K[?12l[?25h␍ [1;32m✔[0m Done
|
||||||
|
[1;32m✔[0m Backup: 35M
|
||||||
|
[2026-07-08 11:28:29] [OK] Backup: 35M
|
||||||
|
[?25l
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m Building emulator...
[1;36m⠇[0m Building emulator...
[1;36m⠏[0m Building emulator...
[1;36m⠋[0m Building emulator...
[1;36m⠙[0m Building emulator...
[1;36m⠹[0m Building emulator...
[1;36m⠸[0m Building emulator...
[1;36m⠼[0m Building emulator...
[1;36m⠴[0m Building emulator...
[1;36m⠦[0m Building emulator...
[1;36m⠧[0m 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
|
||||||
|
␍[K[?12l[?25h␍ [1;32m✔[0m Done
|
||||||
|
[1;32m✔[0m 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
|
||||||
|
[1;32m✔[0m Launch script updated
|
||||||
|
[2026-07-08 11:28:51] [OK] Launch script updated
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 3/8 — Update Nitro_Render_V3 [0m
|
||||||
|
|
||||||
|
No local changes to save
|
||||||
|
Your branch is up to date with 'origin/main'.
|
||||||
|
Already up to date.
|
||||||
|
[1;34m➜[0m Renderer: already up to date
|
||||||
|
[2026-07-08 11:29:11] [INFO] Renderer: already up to date
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 4/8 — Update & Build Nitro-V3 [0m
|
||||||
|
|
||||||
|
No local changes to save
|
||||||
|
Your branch is up to date with 'origin/main'.
|
||||||
|
Already up to date.
|
||||||
|
[1;34m➜[0m Nitro-V3: already up to date
|
||||||
|
[2026-07-08 11:29:32] [INFO] Nitro-V3: already up to date
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 5/8 — Sync Configurations [0m
|
||||||
|
|
||||||
|
[1;32m✔[0m 18 config file(s) synced
|
||||||
|
[2026-07-08 11:29:33] [OK] 18 config file(s) synced
|
||||||
|
[1;34m➜[0m 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
|
||||||
|
[1;32m✔[0m All config URLs synced
|
||||||
|
[2026-07-08 11:29:33] [OK] All config URLs synced
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 6/8 — Cleanup [0m
|
||||||
|
|
||||||
|
yarn cache v1.22.22
|
||||||
|
success Cleared cache.
|
||||||
|
Done in 0.05s.
|
||||||
|
[1;32m✔[0m Cleanup done (0 logs removed)
|
||||||
|
[2026-07-08 11:29:33] [OK] Cleanup done (0 logs removed)
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 7/8 — Set Permissions [0m
|
||||||
|
|
||||||
|
[1;32m✔[0m Permissions set
|
||||||
|
[2026-07-08 11:29:35] [OK] Permissions set
|
||||||
|
|
||||||
|
[46m[1;37m[1m Step 8/8 — Restart Services [0m
|
||||||
|
|
||||||
|
[1;32m✔[0m emulator restarted
|
||||||
|
[2026-07-08 11:29:35] [OK] emulator restarted
|
||||||
|
[1;32m✔[0m Nginx reloaded
|
||||||
|
[2026-07-08 11:29:36] [OK] Nginx reloaded
|
||||||
|
OK
|
||||||
|
[1;32m✔[0m Redis flushed
|
||||||
|
[2026-07-08 11:29:36] [OK] Redis flushed
|
||||||
|
|
||||||
|
[1;36m╔══════════════════════════════════════════════════════════════════╗[0m
|
||||||
|
[1;36m║[0m [1;32m[1m✔ UPDATE COMPLETED SUCCESSFULLY[0m [1;36m║[0m
|
||||||
|
[1;36m╠══════════════════════════════════════════════════════════════════╣[0m
|
||||||
|
[1;36m║[0m Duration: 2m 1s [1;36m║[0m
|
||||||
|
[1;36m║[0m Updates: Yes [1;36m║[0m
|
||||||
|
[1;36m║[0m Nitro build: No [1;36m║[0m
|
||||||
|
[1;36m║[0m Warnings: 0 [1;36m║[0m
|
||||||
|
[1;36m║[0m Repos: Emulator [1;36m║[0m
|
||||||
|
[1;36m╚══════════════════════════════════════════════════════════════════╝[0m
|
||||||
|
|
||||||
|
|
||||||
|
[2mPress Enter to continue...[0m./update-Nitrov3.sh: line 929: read: 0: read error: Input/output error
|
||||||
Reference in new issue
Block a user