Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084e090ba3 | ||
|
|
b1a1e5125c | ||
|
|
750973de38 | ||
|
|
1167a48344 | ||
|
|
40da24bd8c | ||
|
|
e97213e5d1 | ||
|
|
86d59195ec | ||
|
|
1cbb33a4e2 | ||
|
|
40a21ba767 | ||
|
|
cf89681576 | ||
|
|
2fba923a3a | ||
|
|
080dc34e23 | ||
|
|
450e7e8d00 | ||
|
|
30ed2b8ce2 | ||
|
|
6fc14b9b84 | ||
|
|
bee55e1fd4 | ||
|
|
613b7502e1 |
No files matched your search
+2
-3
@@ -32,9 +32,8 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
CONVERT_PASSWORDS=true
|
||||
ARGON2_MEMORY_KB=65536
|
||||
ARGON2_ITERATIONS=4
|
||||
ARGON2_PARALLELISM=1
|
||||
# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
|
||||
BCRYPT_COST=12
|
||||
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@
|
||||
"motion": "^12.43.0",
|
||||
"music-metadata": "^11.14.0",
|
||||
"mysql2": "^3.23.2",
|
||||
"next": "^16.2.12",
|
||||
"next": "^16.3.0",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "^4.13.4",
|
||||
"otplib": "^13.4.1",
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/bundle-analyzer": "^16.3.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
|
||||
Generated
+52
-52
@@ -90,14 +90,14 @@ importers:
|
||||
specifier: ^3.23.2
|
||||
version: 3.23.2(@types/[email protected])
|
||||
next:
|
||||
specifier: ^16.2.12
|
||||
version: 16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.32
|
||||
version: 5.0.0-beta.32(next@16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
version: 5.0.0-beta.32(next@16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
next-intl:
|
||||
specifier: ^4.13.4
|
||||
version: 4.13.4(next@16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
version: 4.13.4(next@16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
otplib:
|
||||
specifier: ^13.4.1
|
||||
version: 13.4.1
|
||||
@@ -139,8 +139,8 @@ importers:
|
||||
specifier: 2.5.6
|
||||
version: 2.5.6
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^16.2.12
|
||||
version: 16.2.12
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0
|
||||
'@tailwindcss/forms':
|
||||
specifier: ^0.5.11
|
||||
version: 0.5.11([email protected])
|
||||
@@ -1203,60 +1203,60 @@ packages:
|
||||
'@emnapi/core': ^2.0.0-alpha.3
|
||||
'@emnapi/runtime': ^2.0.0-alpha.3
|
||||
|
||||
'@next/bundle-analyzer@16.2.12':
|
||||
resolution: {integrity: sha512-0dYhmCYsTMFYUFaoEUx+VKw/oYP6b3XiGgq47EujXTE2UGgwVWAaur2tbko2pxfUka3WRZ9YWxa3PlSv3luWNQ==}
|
||||
'@next/bundle-analyzer@16.3.0':
|
||||
resolution: {integrity: sha512-o8OiXKIATcSC6kHm5BIEmaDEDTFUDhpy9POQIbzAQTlb8NWwFhEQheTLFSbyQXxH2ywhjZ/e8EUlbOjj2k22Yg==}
|
||||
|
||||
'@next/env@16.2.12':
|
||||
resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==}
|
||||
'@next/env@16.3.0':
|
||||
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.12':
|
||||
resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==}
|
||||
'@next/swc-darwin-arm64@16.3.0':
|
||||
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.2.12':
|
||||
resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==}
|
||||
'@next/swc-darwin-x64@16.3.0':
|
||||
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.2.12':
|
||||
resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==}
|
||||
'@next/swc-linux-arm64-gnu@16.3.0':
|
||||
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.12':
|
||||
resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==}
|
||||
'@next/swc-linux-arm64-musl@16.3.0':
|
||||
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.12':
|
||||
resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==}
|
||||
'@next/swc-linux-x64-gnu@16.3.0':
|
||||
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.12':
|
||||
resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==}
|
||||
'@next/swc-linux-x64-musl@16.3.0':
|
||||
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.12':
|
||||
resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==}
|
||||
'@next/swc-win32-arm64-msvc@16.3.0':
|
||||
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.2.12':
|
||||
resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==}
|
||||
'@next/swc-win32-x64-msvc@16.3.0':
|
||||
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -3430,8 +3430,8 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
next@16.2.12:
|
||||
resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==}
|
||||
next@16.3.0:
|
||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -4784,37 +4784,37 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/bundle-analyzer@16.2.12':
|
||||
'@next/bundle-analyzer@16.3.0':
|
||||
dependencies:
|
||||
webpack-bundle-analyzer: 4.10.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@next/env@16.2.12': {}
|
||||
'@next/env@16.3.0': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.12':
|
||||
'@next/swc-darwin-arm64@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.2.12':
|
||||
'@next/swc-darwin-x64@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.2.12':
|
||||
'@next/swc-linux-arm64-gnu@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.12':
|
||||
'@next/swc-linux-arm64-musl@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.12':
|
||||
'@next/swc-linux-x64-gnu@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.12':
|
||||
'@next/swc-linux-x64-musl@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.12':
|
||||
'@next/swc-win32-arm64-msvc@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.2.12':
|
||||
'@next/swc-win32-x64-msvc@16.3.0':
|
||||
optional: true
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
@@ -6697,22 +6697,22 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](next@16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
[email protected](next@16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.3
|
||||
next: 16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
next: 16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](next@16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
[email protected](next@16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
'@parcel/watcher': 2.6.0
|
||||
'@swc/core': 1.15.46
|
||||
icu-minify: 4.13.4
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
next: 16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
next-intl-swc-plugin-extractor: 4.13.4
|
||||
po-parser: 2.1.1
|
||||
react: 19.2.8
|
||||
@@ -6722,9 +6722,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
next@16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]):
|
||||
next@16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@next/env': 16.2.12
|
||||
'@next/env': 16.3.0
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.11.6
|
||||
caniuse-lite: 1.0.30001806
|
||||
@@ -6733,14 +6733,14 @@ snapshots:
|
||||
react-dom: 19.2.8([email protected])
|
||||
styled-jsx: 5.1.6([email protected])
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.2.12
|
||||
'@next/swc-darwin-x64': 16.2.12
|
||||
'@next/swc-linux-arm64-gnu': 16.2.12
|
||||
'@next/swc-linux-arm64-musl': 16.2.12
|
||||
'@next/swc-linux-x64-gnu': 16.2.12
|
||||
'@next/swc-linux-x64-musl': 16.2.12
|
||||
'@next/swc-win32-arm64-msvc': 16.2.12
|
||||
'@next/swc-win32-x64-msvc': 16.2.12
|
||||
'@next/swc-darwin-arm64': 16.3.0
|
||||
'@next/swc-darwin-x64': 16.3.0
|
||||
'@next/swc-linux-arm64-gnu': 16.3.0
|
||||
'@next/swc-linux-arm64-musl': 16.3.0
|
||||
'@next/swc-linux-x64-gnu': 16.3.0
|
||||
'@next/swc-linux-x64-musl': 16.3.0
|
||||
'@next/swc-win32-arm64-msvc': 16.3.0
|
||||
'@next/swc-win32-x64-msvc': 16.3.0
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.62.0
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SearchX,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -48,6 +49,11 @@ interface AuditSummary {
|
||||
catalogSqlApplied: number;
|
||||
furniDataAdded: number;
|
||||
furniDataDuplicatesRemoved: number;
|
||||
orphanedRemoved: number;
|
||||
orphanedCleaned: number;
|
||||
duplicatesMerged: number;
|
||||
duplicateRowsRemoved: number;
|
||||
remappedReferences: number;
|
||||
}
|
||||
|
||||
interface RepairStats {
|
||||
@@ -73,6 +79,13 @@ interface AuditEvent {
|
||||
applied?: number;
|
||||
sqlFailed?: number;
|
||||
furniData?: { added: number; removedDuplicates: number };
|
||||
structure?: {
|
||||
orphanedRemoved: number;
|
||||
orphanedCleaned: number;
|
||||
duplicatesMerged: number;
|
||||
duplicateRowsRemoved: number;
|
||||
remappedReferences: number;
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -131,6 +144,7 @@ export function AuditClient() {
|
||||
const [generateSql, setGenerateSql] = useState(false);
|
||||
const [applySql, setApplySql] = useState(false);
|
||||
const [repairFurniData, setRepairFurniData] = useState(false);
|
||||
const [repairStructure, setRepairStructure] = useState(false);
|
||||
|
||||
const [issues, setIssues] = useState<AuditIssue[]>([]);
|
||||
const [missingFromSources, setMissingFromSources] = useState<
|
||||
@@ -150,6 +164,13 @@ export function AuditClient() {
|
||||
added: number;
|
||||
removedDuplicates: number;
|
||||
} | null>(null);
|
||||
const [structureResult, setStructureResult] = useState<{
|
||||
orphanedRemoved: number;
|
||||
orphanedCleaned: number;
|
||||
duplicatesMerged: number;
|
||||
duplicateRowsRemoved: number;
|
||||
remappedReferences: number;
|
||||
} | null>(null);
|
||||
|
||||
const [checkProgress, setCheckProgress] = useState<{
|
||||
section: string;
|
||||
@@ -170,6 +191,7 @@ export function AuditClient() {
|
||||
setSqlText(null);
|
||||
setSqlApplied(null);
|
||||
setFurniDataResult(null);
|
||||
setStructureResult(null);
|
||||
setCheckProgress(null);
|
||||
setActiveTab("issues");
|
||||
|
||||
@@ -187,6 +209,7 @@ export function AuditClient() {
|
||||
sql: generateSql || applySql,
|
||||
applySql,
|
||||
repairFurniData,
|
||||
repairStructure,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -224,76 +247,82 @@ export function AuditClient() {
|
||||
try {
|
||||
const evt = JSON.parse(part.slice(6)) as AuditEvent;
|
||||
|
||||
if (evt.type === "progress") {
|
||||
setProgress(evt.message ?? "");
|
||||
}
|
||||
flushSync(() => {
|
||||
if (evt.type === "progress") {
|
||||
setProgress(evt.message ?? "");
|
||||
}
|
||||
|
||||
if (evt.type === "items_loaded") {
|
||||
setProgress(`Loaded ${evt.total} items_base entries`);
|
||||
}
|
||||
if (evt.type === "items_loaded") {
|
||||
setProgress(`Loaded ${evt.total} items_base entries`);
|
||||
}
|
||||
|
||||
if (evt.type === "catalog_loaded") {
|
||||
setProgress(`Loaded ${evt.total} catalog_items entries`);
|
||||
}
|
||||
if (evt.type === "catalog_loaded") {
|
||||
setProgress(`Loaded ${evt.total} catalog_items entries`);
|
||||
}
|
||||
|
||||
if (evt.type === "checking") {
|
||||
setProgress(`Checking ${evt.section}…`);
|
||||
if (evt.total) {
|
||||
setCheckProgress({
|
||||
section: evt.section ?? "",
|
||||
current: evt.current ?? 0,
|
||||
total: evt.total,
|
||||
if (evt.type === "checking") {
|
||||
setProgress(`Checking ${evt.section}…`);
|
||||
if (evt.total) {
|
||||
setCheckProgress({
|
||||
section: evt.section ?? "",
|
||||
current: evt.current ?? 0,
|
||||
total: evt.total,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === "repair_complete") {
|
||||
if (evt.repair) setRepairStats(evt.repair);
|
||||
if (evt.repairNitro) setNitroRepairStats(evt.repairNitro);
|
||||
}
|
||||
|
||||
if (evt.type === "sql_ready") {
|
||||
setSqlText(evt.sql ?? null);
|
||||
}
|
||||
|
||||
if (evt.type === "sql_applied") {
|
||||
setSqlApplied({
|
||||
applied: evt.applied ?? 0,
|
||||
failed: evt.sqlFailed ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === "repair_complete") {
|
||||
if (evt.repair) setRepairStats(evt.repair);
|
||||
if (evt.repairNitro) setNitroRepairStats(evt.repairNitro);
|
||||
}
|
||||
|
||||
if (evt.type === "sql_ready") {
|
||||
setSqlText(evt.sql ?? null);
|
||||
}
|
||||
|
||||
if (evt.type === "sql_applied") {
|
||||
setSqlApplied({
|
||||
applied: evt.applied ?? 0,
|
||||
failed: evt.sqlFailed ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "furnidata_repair_complete") {
|
||||
if (evt.furniData) setFurniDataResult(evt.furniData);
|
||||
}
|
||||
|
||||
if (evt.type === "batch_complete") {
|
||||
setIssues(evt.issues ?? []);
|
||||
setMissingFromSources(evt.missingFromSources ?? []);
|
||||
if (evt.summary) setSummary(evt.summary);
|
||||
setCheckProgress(null);
|
||||
setProgress("Done");
|
||||
if (
|
||||
evt.issues &&
|
||||
evt.issues.filter((i) => i.severity === "error").length > 0
|
||||
) {
|
||||
toast.error(
|
||||
`${evt.issues.filter((i) => i.severity === "error").length} error(s) found`,
|
||||
);
|
||||
} else if (
|
||||
evt.issues &&
|
||||
evt.issues.filter((i) => i.severity === "warning").length > 0
|
||||
) {
|
||||
toast.warning("Warnings found");
|
||||
} else {
|
||||
toast.success("No issues found");
|
||||
if (evt.type === "furnidata_repair_complete") {
|
||||
if (evt.furniData) setFurniDataResult(evt.furniData);
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === "error") {
|
||||
setProgress(`ERROR: ${evt.message ?? ""}`);
|
||||
toast.error(evt.message ?? "Audit failed");
|
||||
}
|
||||
if (evt.type === "structure_repair_complete") {
|
||||
if (evt.structure) setStructureResult(evt.structure);
|
||||
}
|
||||
|
||||
if (evt.type === "batch_complete") {
|
||||
setIssues(evt.issues ?? []);
|
||||
setMissingFromSources(evt.missingFromSources ?? []);
|
||||
if (evt.summary) setSummary(evt.summary);
|
||||
setCheckProgress(null);
|
||||
setProgress("Done");
|
||||
if (
|
||||
evt.issues &&
|
||||
evt.issues.filter((i) => i.severity === "error").length > 0
|
||||
) {
|
||||
toast.error(
|
||||
`${evt.issues.filter((i) => i.severity === "error").length} error(s) found`,
|
||||
);
|
||||
} else if (
|
||||
evt.issues &&
|
||||
evt.issues.filter((i) => i.severity === "warning").length > 0
|
||||
) {
|
||||
toast.warning("Warnings found");
|
||||
} else {
|
||||
toast.success("No issues found");
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === "error") {
|
||||
setProgress(`ERROR: ${evt.message ?? ""}`);
|
||||
toast.error(evt.message ?? "Audit failed");
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// skip parse errors
|
||||
}
|
||||
@@ -309,7 +338,14 @@ export function AuditClient() {
|
||||
setLoading(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [repairEnabled, repairNitros, generateSql, applySql, repairFurniData]);
|
||||
}, [
|
||||
repairEnabled,
|
||||
repairNitros,
|
||||
generateSql,
|
||||
applySql,
|
||||
repairFurniData,
|
||||
repairStructure,
|
||||
]);
|
||||
|
||||
const errors = issues.filter((i) => i.severity === "error");
|
||||
const warnings = issues.filter((i) => i.severity === "warning");
|
||||
@@ -372,6 +408,13 @@ export function AuditClient() {
|
||||
>
|
||||
Repair FurnitureData.json (add missing + dedupe)
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={repairStructure}
|
||||
disabled={loading}
|
||||
onChange={setRepairStructure}
|
||||
>
|
||||
Repair structural issues (orphaned refs + duplicates)
|
||||
</AuditCheckbox>
|
||||
{loading && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -601,6 +644,49 @@ export function AuditClient() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{structureResult && (
|
||||
<div className="flex items-center gap-2 col-span-full">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Structural repair:{" "}
|
||||
{structureResult.orphanedRemoved > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
{structureResult.orphanedRemoved} orphaned rows removed
|
||||
</span>
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
{structureResult.orphanedCleaned > 0 && (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{structureResult.orphanedCleaned} cleaned
|
||||
</span>
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
{structureResult.duplicatesMerged > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
{structureResult.duplicatesMerged} classname(s) merged
|
||||
</span>
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
{structureResult.duplicateRowsRemoved > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--admin-warning)] font-medium">
|
||||
{structureResult.duplicateRowsRemoved} duplicate rows
|
||||
removed
|
||||
</span>
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{structureResult.remappedReferences} reference(s) remapped
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -733,6 +819,7 @@ export function AuditClient() {
|
||||
setSqlText(null);
|
||||
setSqlApplied(null);
|
||||
setFurniDataResult(null);
|
||||
setStructureResult(null);
|
||||
setProgress("");
|
||||
setCheckProgress(null);
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const POST = withAdmin(
|
||||
let sql = false;
|
||||
let applySql = false;
|
||||
let repairFurniData = false;
|
||||
let repairStructure = false;
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
repair?: unknown;
|
||||
@@ -23,12 +24,14 @@ export const POST = withAdmin(
|
||||
sql?: unknown;
|
||||
applySql?: unknown;
|
||||
repairFurniData?: unknown;
|
||||
repairStructure?: unknown;
|
||||
};
|
||||
repair = body.repair === true;
|
||||
repairNitros = body.repairNitros === true;
|
||||
sql = body.sql === true;
|
||||
applySql = body.applySql === true;
|
||||
repairFurniData = body.repairFurniData === true;
|
||||
repairStructure = body.repairStructure === true;
|
||||
} catch {
|
||||
/* no body */
|
||||
}
|
||||
@@ -50,6 +53,7 @@ export const POST = withAdmin(
|
||||
sql,
|
||||
applySql,
|
||||
repairFurniData,
|
||||
repairStructure,
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export default async function RootLayout({
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<html lang={locale} className="app">
|
||||
<html lang={locale} className="app" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-default-dark" content={String(defaultDark)} />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
+3
-5
@@ -51,15 +51,13 @@ const schema = z
|
||||
APP_KEY: z.string().optional(),
|
||||
|
||||
// Mirrors Laravel config('habbo.site.convert_passwords') — enables
|
||||
// legacy md5/bcrypt hashes to be upgraded to argon2id on login.
|
||||
// legacy md5/argon2id hashes to be upgraded to bcrypt on login.
|
||||
CONVERT_PASSWORDS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true" || v === "1"),
|
||||
// Argon2id parameters — defaults match the old AtomCMS (Laravel) setup.
|
||||
ARGON2_MEMORY_KB: z.coerce.number().int().positive().default(65_536),
|
||||
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
|
||||
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
|
||||
// bcrypt cost factor used for new password hashes.
|
||||
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
|
||||
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
||||
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||
// when unset.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
ARGON2_MEMORY_KB: 65_536,
|
||||
ARGON2_ITERATIONS: 4,
|
||||
ARGON2_PARALLELISM: 1,
|
||||
BCRYPT_COST: 12,
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
@@ -28,16 +26,16 @@ describe("md5Hex", () => {
|
||||
});
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("emits an argon2id hash and round-trips", async () => {
|
||||
it("emits a bcrypt hash and round-trips", async () => {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$/);
|
||||
expect(h).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isArgon2idOf", () => {
|
||||
it("verifies an argon2id hash (AtomCMS/Laravel)", async () => {
|
||||
it("verifies a legacy argon2id hash (pre-migration accounts)", async () => {
|
||||
const stored =
|
||||
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
||||
expect(await isArgon2idOf("test-password-123", stored)).toBe(true);
|
||||
@@ -47,7 +45,7 @@ describe("isArgon2idOf", () => {
|
||||
});
|
||||
|
||||
describe("isBcryptOf", () => {
|
||||
it("verifies a legacy bcrypt hash", async () => {
|
||||
it("verifies a bcrypt hash", async () => {
|
||||
const { bcrypt } = await import("hash-wasm");
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
const stored = await bcrypt({
|
||||
@@ -71,20 +69,20 @@ describe("isMd5Of", () => {
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("verifies argon2id hashes", async () => {
|
||||
it("verifies bcrypt hashes", async () => {
|
||||
const h = await hashPassword("hunter2");
|
||||
expect(h).toMatch(/^\$argon2id\$/);
|
||||
expect(h).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => {
|
||||
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -99,37 +97,25 @@ describe("checkLogin", () => {
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an argon2id hash with no rehash", async () => {
|
||||
it("migrates a legacy argon2id hash to bcrypt", async () => {
|
||||
const stored =
|
||||
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
||||
const res = await checkLogin("test-password-123", stored, {
|
||||
convertPasswords: true,
|
||||
});
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
});
|
||||
|
||||
it("upgrades a legacy bcrypt hash to argon2id when conversion is enabled", async () => {
|
||||
// Generate a real bcrypt hash via hash-wasm and verify the upgrade path.
|
||||
it("accepts an existing bcrypt hash with no rehash", async () => {
|
||||
const { bcrypt } = await import("hash-wasm");
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
const stored = await bcrypt({
|
||||
password: "oldbcrypt",
|
||||
salt: await import("node:crypto").then((c) => c.randomBytes(16)),
|
||||
password: "modern",
|
||||
salt: randomBytes(16),
|
||||
costFactor: 10,
|
||||
outputType: "encoded",
|
||||
});
|
||||
const res = await checkLogin("oldbcrypt", stored, {
|
||||
convertPasswords: true,
|
||||
});
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||
expect(await verifyPassword("oldbcrypt", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
|
||||
+22
-16
@@ -1,16 +1,13 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { argon2id, argon2Verify, bcryptVerify, md5 } from "hash-wasm";
|
||||
import { argon2Verify, bcrypt, bcryptVerify, md5 } from "hash-wasm";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await argon2id({
|
||||
return await bcrypt({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
parallelism: env.ARGON2_PARALLELISM,
|
||||
iterations: env.ARGON2_ITERATIONS,
|
||||
memorySize: env.ARGON2_MEMORY_KB,
|
||||
hashLength: 32,
|
||||
costFactor: env.BCRYPT_COST,
|
||||
outputType: "encoded",
|
||||
});
|
||||
}
|
||||
@@ -29,6 +26,11 @@ export async function isMd5Of(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy argon2id verification — kept ONLY so accounts hashed before the
|
||||
* bcrypt switch can still sign in once and be migrated to bcrypt. No new
|
||||
* argon2 hashes are ever produced.
|
||||
*/
|
||||
export async function isArgon2idOf(
|
||||
password: string,
|
||||
stored: string,
|
||||
@@ -41,7 +43,6 @@ export async function isArgon2idOf(
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy bcrypt support — only kept to verify & auto-upgrade old accounts. */
|
||||
export async function isBcryptOf(
|
||||
password: string,
|
||||
stored: string,
|
||||
@@ -58,9 +59,6 @@ export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (/^\$argon2id\$/.test(stored)) {
|
||||
return isArgon2idOf(password, stored);
|
||||
}
|
||||
return isBcryptOf(password, stored);
|
||||
}
|
||||
|
||||
@@ -74,14 +72,22 @@ export async function checkLogin(
|
||||
stored: string,
|
||||
opts: { convertPasswords: boolean },
|
||||
): Promise<LoginCheck> {
|
||||
// Legacy argon2id — verify so existing users can sign in, then immediately
|
||||
// rehash to bcrypt so the hash format converges on bcrypt.
|
||||
if (/^\$argon2id\$/.test(stored)) {
|
||||
if (await isArgon2idOf(password, stored)) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
if (/^\$2[aby]\$/.test(stored)) {
|
||||
return { valid: await isBcryptOf(password, stored) };
|
||||
}
|
||||
|
||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
if (opts.convertPasswords && (await isArgon2idOf(password, stored))) {
|
||||
return { valid: true };
|
||||
}
|
||||
if (opts.convertPasswords && (await isBcryptOf(password, stored))) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { fetchSourceFurnidata } from "@/lib/services/clone-import";
|
||||
import { listSources } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import {
|
||||
getFurniAssetDirs,
|
||||
getGamedataRoot,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import {
|
||||
applyCatalogSql,
|
||||
generateCatalogSql,
|
||||
type MissingCatalogEntry,
|
||||
repairDuplicateClassnames,
|
||||
repairFurniData,
|
||||
repairOrphanedCatalog,
|
||||
} from "./catalog-repair";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
import { repairMissingIcons } from "./repair-icons";
|
||||
@@ -25,6 +30,7 @@ export interface AuditEvent {
|
||||
| "sql_ready"
|
||||
| "sql_applied"
|
||||
| "furnidata_repair_complete"
|
||||
| "structure_repair_complete"
|
||||
| "batch_complete"
|
||||
| "error";
|
||||
message?: string;
|
||||
@@ -41,6 +47,13 @@ export interface AuditEvent {
|
||||
applied?: number;
|
||||
sqlFailed?: number;
|
||||
furniData?: { added: number; removedDuplicates: number };
|
||||
structure?: {
|
||||
orphanedRemoved: number;
|
||||
orphanedCleaned: number;
|
||||
duplicatesMerged: number;
|
||||
duplicateRowsRemoved: number;
|
||||
remappedReferences: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RepairStats {
|
||||
@@ -84,6 +97,11 @@ export interface AuditSummary {
|
||||
catalogSqlApplied: number;
|
||||
furniDataAdded: number;
|
||||
furniDataDuplicatesRemoved: number;
|
||||
orphanedRemoved: number;
|
||||
orphanedCleaned: number;
|
||||
duplicatesMerged: number;
|
||||
duplicateRowsRemoved: number;
|
||||
remappedReferences: number;
|
||||
}
|
||||
|
||||
export interface CatalogAuditOptions {
|
||||
@@ -92,6 +110,7 @@ export interface CatalogAuditOptions {
|
||||
sql?: boolean;
|
||||
applySql?: boolean;
|
||||
repairFurniData?: boolean;
|
||||
repairStructure?: boolean;
|
||||
}
|
||||
|
||||
export async function runCatalogAudit(
|
||||
@@ -125,6 +144,27 @@ export async function runCatalogAudit(
|
||||
return;
|
||||
}
|
||||
|
||||
const [iconFiles, nitroFiles, badgeGifFiles] = await Promise.all([
|
||||
readdir(iconDir).catch(() => [] as string[]),
|
||||
readdir(nitroDir).catch(() => [] as string[]),
|
||||
(async () => {
|
||||
try {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) return [] as string[];
|
||||
return await readdir(path.join(gamedataRoot, "album1584")).catch(
|
||||
() => [] as string[],
|
||||
);
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
const iconSet = new Set(iconFiles);
|
||||
const nitroSet = new Set(nitroFiles);
|
||||
const badgeSet = new Set(
|
||||
badgeGifFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||
);
|
||||
|
||||
onEvent?.({ type: "progress", message: "Loading items_base…" });
|
||||
|
||||
let items: Array<{
|
||||
@@ -218,9 +258,11 @@ export async function runCatalogAudit(
|
||||
itemId: item.id,
|
||||
message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`,
|
||||
});
|
||||
// Only include items that actually have a .nitro asset — a catalog
|
||||
// entry for a furni without a bundle would be un-placeable in-game.
|
||||
if (existsSync(path.join(nitroDir, `${item.item_name}.nitro`))) {
|
||||
// Badge items use .gif files, not .nitro bundles.
|
||||
if (
|
||||
!badgeSet.has(item.item_name) &&
|
||||
nitroSet.has(`${item.item_name}.nitro`)
|
||||
) {
|
||||
missingCatalogEntries.push({
|
||||
classname: item.item_name,
|
||||
itemId: item.id,
|
||||
@@ -289,8 +331,9 @@ export async function runCatalogAudit(
|
||||
const missingNitroClassnames: string[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const nitroPath = path.join(nitroDir, `${item.item_name}.nitro`);
|
||||
if (!existsSync(nitroPath)) {
|
||||
// Badge items use .gif files, not .nitro bundles.
|
||||
if (badgeSet.has(item.item_name)) continue;
|
||||
if (!nitroSet.has(`${item.item_name}.nitro`)) {
|
||||
missingNitroClassnames.push(item.item_name);
|
||||
issues.push({
|
||||
type: "missing_nitro",
|
||||
@@ -315,8 +358,10 @@ export async function runCatalogAudit(
|
||||
const missingIconClassnames: string[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const iconPath = path.join(iconDir, `${item.item_name}_icon.png`);
|
||||
if (!existsSync(iconPath)) {
|
||||
// Badge items use .gif icons (stored in album1584), not .png icon files.
|
||||
if (badgeSet.has(item.item_name)) continue;
|
||||
const iconFileName = `${item.item_name}_icon.png`;
|
||||
if (!iconSet.has(iconFileName)) {
|
||||
missingIconClassnames.push(item.item_name);
|
||||
issues.push({
|
||||
type: "missing_icon",
|
||||
@@ -367,7 +412,7 @@ export async function runCatalogAudit(
|
||||
// and drop the ones the repair fixed from the issue list.
|
||||
const stillMissing: string[] = [];
|
||||
for (const classname of missingIconClassnames) {
|
||||
if (!existsSync(path.join(iconDir, `${classname}_icon.png`))) {
|
||||
if (!iconSet.has(`${classname}_icon.png`)) {
|
||||
stillMissing.push(classname);
|
||||
}
|
||||
}
|
||||
@@ -422,7 +467,7 @@ export async function runCatalogAudit(
|
||||
// and drop the ones the repair fixed from the issue list.
|
||||
const stillMissing: string[] = [];
|
||||
for (const classname of missingNitroClassnames) {
|
||||
if (!existsSync(path.join(nitroDir, `${classname}.nitro`))) {
|
||||
if (!nitroSet.has(`${classname}.nitro`)) {
|
||||
stillMissing.push(classname);
|
||||
}
|
||||
}
|
||||
@@ -450,6 +495,81 @@ export async function runCatalogAudit(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Structural repair: orphaned catalog refs + duplicate classnames ──
|
||||
let orphanedRemoved = 0;
|
||||
let orphanedCleaned = 0;
|
||||
let duplicatesMerged = 0;
|
||||
let duplicateRowsRemoved = 0;
|
||||
let remappedReferences = 0;
|
||||
if (options?.repairStructure) {
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
message: "Repairing structural issues (orphaned refs + duplicates)…",
|
||||
});
|
||||
try {
|
||||
const orphanResult = await repairOrphanedCatalog();
|
||||
orphanedRemoved = orphanResult.removed;
|
||||
orphanedCleaned = orphanResult.cleaned;
|
||||
|
||||
// Re-derive orphaned_catalog issues from the fixed data.
|
||||
const [afterCatalog] = (await db.execute(sql`
|
||||
SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id
|
||||
`)) as unknown as [
|
||||
Array<{ id: number; item_ids: string; catalog_name: string }>,
|
||||
unknown,
|
||||
];
|
||||
const orphanedCatalogIds = new Set<number>();
|
||||
for (const ci of afterCatalog) {
|
||||
const ids = ci.item_ids.split(";").map(Number).filter(Boolean);
|
||||
for (const id of ids) {
|
||||
if (!itemById.has(id)) orphanedCatalogIds.add(ci.id);
|
||||
}
|
||||
}
|
||||
issues = issues.filter(
|
||||
(i) =>
|
||||
i.type !== "orphaned_catalog" ||
|
||||
orphanedCatalogIds.has(i.catalogId ?? -1),
|
||||
);
|
||||
|
||||
const duplicateResult = await repairDuplicateClassnames();
|
||||
duplicatesMerged = duplicateResult.merged;
|
||||
duplicateRowsRemoved = duplicateResult.rowsRemoved;
|
||||
remappedReferences = duplicateResult.remapped;
|
||||
|
||||
// Re-derive duplicate_classname issues from the fixed data.
|
||||
const [afterItems] = (await db.execute(sql`
|
||||
SELECT item_name FROM items_base
|
||||
`)) as unknown as [Array<{ item_name: string }>, unknown];
|
||||
const afterCount = new Map<string, number>();
|
||||
for (const row of afterItems) {
|
||||
afterCount.set(row.item_name, (afterCount.get(row.item_name) ?? 0) + 1);
|
||||
}
|
||||
const dupNames = new Set(
|
||||
[...afterCount].filter(([, count]) => count > 1).map(([name]) => name),
|
||||
);
|
||||
issues = issues.filter(
|
||||
(i) =>
|
||||
i.type !== "duplicate_classname" || dupNames.has(i.classname ?? ""),
|
||||
);
|
||||
|
||||
onEvent?.({
|
||||
type: "structure_repair_complete",
|
||||
structure: {
|
||||
orphanedRemoved,
|
||||
orphanedCleaned,
|
||||
duplicatesMerged,
|
||||
duplicateRowsRemoved,
|
||||
remappedReferences,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `Structural repair failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Catalog SQL generation / apply ────────────────────────────────
|
||||
let catalogSqlCount = 0;
|
||||
let catalogSqlApplied = 0;
|
||||
@@ -510,26 +630,46 @@ export async function runCatalogAudit(
|
||||
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
|
||||
|
||||
const sources = await listSources();
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const entries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
for (const entry of entries) {
|
||||
if (!itemByName.has(entry.classname)) {
|
||||
missingFromSources.push({
|
||||
sourceName: source.name,
|
||||
sourceId: source.id,
|
||||
classname: entry.classname,
|
||||
itemName: entry.name,
|
||||
});
|
||||
const sourceResults = await Promise.all(
|
||||
sources
|
||||
.filter((s) => s.furnidataUrl)
|
||||
.map(async (source) => {
|
||||
try {
|
||||
const entries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
return {
|
||||
source,
|
||||
entries,
|
||||
error: null,
|
||||
} as const;
|
||||
} catch {
|
||||
return {
|
||||
source,
|
||||
entries: [] as Array<{ classname: string; name: string }>,
|
||||
error: true,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of sourceResults) {
|
||||
if (result.error) {
|
||||
issues.push({
|
||||
type: "source_fetch_failed",
|
||||
severity: "error",
|
||||
sourceName: source.name,
|
||||
message: `Failed to fetch furnidata from "${source.name}"`,
|
||||
sourceName: result.source.name,
|
||||
message: `Failed to fetch furnidata from "${result.source.name}"`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const entry of result.entries) {
|
||||
if (!itemByName.has(entry.classname)) {
|
||||
missingFromSources.push({
|
||||
sourceName: result.source.name,
|
||||
sourceId: result.source.id,
|
||||
classname: entry.classname,
|
||||
itemName: entry.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +697,11 @@ export async function runCatalogAudit(
|
||||
catalogSqlApplied,
|
||||
furniDataAdded,
|
||||
furniDataDuplicatesRemoved,
|
||||
orphanedRemoved,
|
||||
orphanedCleaned,
|
||||
duplicatesMerged,
|
||||
duplicateRowsRemoved,
|
||||
remappedReferences,
|
||||
};
|
||||
|
||||
onEvent?.({
|
||||
|
||||
@@ -96,6 +96,205 @@ export async function applyCatalogSql(
|
||||
return { applied, failed };
|
||||
}
|
||||
|
||||
export interface OrphanedCatalogResult {
|
||||
removed: number;
|
||||
cleaned: number;
|
||||
}
|
||||
|
||||
export interface DuplicateClassnameResult {
|
||||
merged: number;
|
||||
rowsRemoved: number;
|
||||
remapped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove catalog_items rows whose item_ids only reference non-existent
|
||||
* items_base entries, and strip orphaned ids from mixed rows. Also deletes
|
||||
* catalog_items_limited rows that pointed at the removed catalog entries so no
|
||||
* new orphans are left behind.
|
||||
*/
|
||||
export async function repairOrphanedCatalog(): Promise<OrphanedCatalogResult> {
|
||||
const [itemRows] = (await db.execute(sql`
|
||||
SELECT id FROM items_base
|
||||
`)) as unknown as [Array<{ id: number }>, unknown];
|
||||
const valid = new Set<number>();
|
||||
for (const row of itemRows) valid.add(Number(row.id));
|
||||
|
||||
const [catalogRows] = (await db.execute(sql`
|
||||
SELECT id, item_ids FROM catalog_items
|
||||
`)) as unknown as [Array<{ id: number; item_ids: string }>, unknown];
|
||||
|
||||
let removed = 0;
|
||||
let cleaned = 0;
|
||||
|
||||
for (const row of catalogRows) {
|
||||
const ids = (row.item_ids ?? "")
|
||||
.split(";")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) continue;
|
||||
const kept = ids.filter((id) => valid.has(id));
|
||||
if (kept.length === ids.length) continue;
|
||||
|
||||
if (kept.length === 0) {
|
||||
await db.execute(sql`
|
||||
DELETE FROM catalog_items_limited WHERE catalog_item_id = ${row.id}
|
||||
`);
|
||||
await db.execute(sql`DELETE FROM catalog_items WHERE id = ${row.id}`);
|
||||
removed++;
|
||||
} else {
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items SET item_ids = ${kept.join(";")} WHERE id = ${row.id}
|
||||
`);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge duplicate classnames in items_base into a single canonical row per
|
||||
* classname, remapping every reference (catalog item id lists, player items,
|
||||
* presents, marketplace, etc.) to the canonical id, then delete the duplicate
|
||||
* rows. The canonical id is the one players actually purchase through
|
||||
* catalog_items when present, otherwise the lowest id.
|
||||
*/
|
||||
export async function repairDuplicateClassnames(): Promise<DuplicateClassnameResult> {
|
||||
const [itemRows] = (await db.execute(sql`
|
||||
SELECT id, item_name FROM items_base
|
||||
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
|
||||
|
||||
const byName = new Map<string, number[]>();
|
||||
for (const row of itemRows) {
|
||||
const arr = byName.get(row.item_name) ?? [];
|
||||
arr.push(Number(row.id));
|
||||
byName.set(row.item_name, arr);
|
||||
}
|
||||
|
||||
const [catalogRows] = (await db.execute(sql`
|
||||
SELECT item_ids FROM catalog_items
|
||||
`)) as unknown as [Array<{ item_ids: string }>, unknown];
|
||||
const referenced = new Set<number>();
|
||||
for (const row of catalogRows) {
|
||||
for (const part of (row.item_ids ?? "").split(";")) {
|
||||
const id = Number(part.trim());
|
||||
if (id) referenced.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const remap = new Map<number, number>();
|
||||
let merged = 0;
|
||||
for (const ids of byName.values()) {
|
||||
if (ids.length <= 1) continue;
|
||||
merged++;
|
||||
const sorted = [...ids].sort((a, b) => a - b);
|
||||
const canonical = sorted.find((id) => referenced.has(id)) ?? sorted[0];
|
||||
for (const id of ids) {
|
||||
if (id !== canonical) remap.set(id, canonical);
|
||||
}
|
||||
}
|
||||
|
||||
if (remap.size === 0) return { merged: 0, rowsRemoved: 0, remapped: 0 };
|
||||
|
||||
let remapped = 0;
|
||||
|
||||
// Semicolon-separated item_ids columns.
|
||||
const listTables: Array<{ table: string; idCol: string }> = [
|
||||
{ table: "catalog_items", idCol: "id" },
|
||||
{ table: "catalog_items_bc", idCol: "id" },
|
||||
{ table: "logs_shop_purchases", idCol: "id" },
|
||||
];
|
||||
for (const { table, idCol } of listTables) {
|
||||
const [rows] = (await db.execute(
|
||||
sql.raw(`SELECT ${idCol} AS pk, item_ids AS v FROM ${table}`),
|
||||
)) as unknown as [Array<{ pk: number; v: string | null }>, unknown];
|
||||
for (const row of rows) {
|
||||
if (!row.v) continue;
|
||||
const parts = row.v
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
let changed = false;
|
||||
const out: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const part of parts) {
|
||||
const id = Number(part);
|
||||
const mapped = remap.get(id) ?? id;
|
||||
if (mapped !== id) changed = true;
|
||||
if (seen.has(mapped)) continue;
|
||||
seen.add(mapped);
|
||||
out.push(mapped);
|
||||
}
|
||||
if (!changed) continue;
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
`UPDATE ${table} SET item_ids = '${out.join(";")}' WHERE ${idCol} = ${row.pk}`,
|
||||
),
|
||||
);
|
||||
remapped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Single item_id columns that reference items_base.id. Remap in chunks via
|
||||
// a single CASE statement per table to keep the query count low even with
|
||||
// thousands of duplicate rows.
|
||||
const singleTables = [
|
||||
"catalog_items_limited",
|
||||
"items",
|
||||
"items_presents",
|
||||
"pet_items",
|
||||
"pet_foods",
|
||||
"pet_drinks",
|
||||
"items_hoppers",
|
||||
"marketplace_items",
|
||||
"items_highscore_data",
|
||||
"items_crackable",
|
||||
"builders_club_items",
|
||||
"recycler_prizes",
|
||||
"gift_wrappers",
|
||||
"youtube_playlists",
|
||||
"room_trade_log_items",
|
||||
"calendar_rewards",
|
||||
"room_trax_playlist",
|
||||
"trax_playlist",
|
||||
"website_event_prizes",
|
||||
"room_templates_items",
|
||||
"crafting_recipes_ingredients",
|
||||
"website_rare_values",
|
||||
"logs_economy",
|
||||
];
|
||||
|
||||
const entries = [...remap.entries()];
|
||||
const CHUNK = 400;
|
||||
for (const table of singleTables) {
|
||||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
const cases = chunk
|
||||
.map(([dup, canonical]) => `WHEN ${dup} THEN ${canonical}`)
|
||||
.join(" ");
|
||||
const dupList = chunk.map(([dup]) => `${dup}`).join(",");
|
||||
try {
|
||||
const [result] = (await db.execute(
|
||||
sql.raw(
|
||||
`UPDATE ${table} SET item_id = CASE item_id ${cases} ELSE item_id END WHERE item_id IN (${dupList})`,
|
||||
),
|
||||
)) as unknown as [Record<string, unknown>, unknown];
|
||||
remapped += Number(result.affectedRows ?? 0);
|
||||
} catch {
|
||||
// Table may not exist on some hotel schemas — skip it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dupIds = entries.map(([dup]) => dup);
|
||||
await db.execute(
|
||||
sql.raw(`DELETE FROM items_base WHERE id IN (${dupIds.join(",")})`),
|
||||
);
|
||||
|
||||
return { merged, rowsRemoved: dupIds.length, remapped };
|
||||
}
|
||||
|
||||
export interface FurniDataRepairResult {
|
||||
added: number;
|
||||
removedDuplicates: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureDirectories,
|
||||
getOrCreateCategoryPage,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
|
||||
@@ -87,7 +88,10 @@ export async function fetchSourceFurnidata(
|
||||
): Promise<SourceFurni[]> {
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
headers: browserHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
|
||||
let list: SourceFurni[];
|
||||
try {
|
||||
|
||||
@@ -11,47 +11,50 @@ export interface CloneSource {
|
||||
|
||||
const KEY = "clone_sources";
|
||||
|
||||
export const DEFAULT_ICON_SOURCES: CloneSource[] = [
|
||||
/**
|
||||
* Verified working sources (checked Aug 2026). Used as the default list and
|
||||
* as the fallback when the `clone_sources` site setting is empty.
|
||||
*/
|
||||
export const DEFAULT_SOURCES: CloneSource[] = [
|
||||
{
|
||||
id: "default-habboassets",
|
||||
name: "HabboAssets",
|
||||
furnidataUrl: "",
|
||||
id: "default-habbo",
|
||||
name: "Habbo",
|
||||
furnidataUrl:
|
||||
"https://raw.githubusercontent.com/Izzxt/habbo-resource/master/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "https://www.habboassets.com/assets/furniture",
|
||||
iconBaseUrl: "",
|
||||
},
|
||||
{
|
||||
id: "default-hubbly",
|
||||
name: "Hubbly",
|
||||
furnidataUrl: "",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "https://hubbly.pw/swf/dcr/hof_furni/icons",
|
||||
},
|
||||
{
|
||||
id: "default-leet",
|
||||
name: "Leet",
|
||||
furnidataUrl: "",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "https://images.leet.city/library/hof_furni/icons",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_NITRO_SOURCES: CloneSource[] = [
|
||||
{
|
||||
id: "default-wibbo",
|
||||
name: "Wibbo",
|
||||
furnidataUrl: "",
|
||||
furnidataUrl: "https://assets.wibbo.org/gamedata/FurnitureData.json",
|
||||
nitroBaseUrl: "https://assets.wibbo.org/bundled/furniture",
|
||||
iconBaseUrl: "https://assets.wibbo.org/icons",
|
||||
},
|
||||
{
|
||||
id: "default-hubba",
|
||||
name: "Hubba.cc",
|
||||
furnidataUrl: "https://nitro.hubba.cc/gamedata/FurnitureData.json",
|
||||
nitroBaseUrl: "https://nitro.hubba.cc/bundled/furniture",
|
||||
iconBaseUrl: "",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_ICON_SOURCES: CloneSource[] = DEFAULT_SOURCES.filter(
|
||||
(s) => s.iconBaseUrl,
|
||||
);
|
||||
|
||||
export const DEFAULT_NITRO_SOURCES: CloneSource[] = DEFAULT_SOURCES.filter(
|
||||
(s) => s.nitroBaseUrl,
|
||||
);
|
||||
|
||||
export async function listSources(): Promise<CloneSource[]> {
|
||||
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
return Array.isArray(arr) && arr.length > 0 ? arr : DEFAULT_SOURCES;
|
||||
} catch {
|
||||
return [];
|
||||
return DEFAULT_SOURCES;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -620,6 +620,7 @@ export async function importSingleFurni(params: {
|
||||
else if (logicType.includes("exchange")) interactionType = "exchange";
|
||||
else if (logicType.includes("habbowheel")) interactionType = "habbowheel";
|
||||
else if (logicType.includes("soundmachine")) interactionType = "jukebox";
|
||||
else if (logicType.includes("badge")) interactionType = "badge";
|
||||
|
||||
if (interactionType === "default" && /^wf_/.test(classname)) {
|
||||
interactionType = classname;
|
||||
|
||||
@@ -72,7 +72,11 @@ describe("official Habbo furnidata cache", () => {
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://www.habbo.nl/gamedata/furnidata_json/1",
|
||||
expect.objectContaining({ headers: { Accept: "application/json" } }),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: expect.stringContaining("application/json"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
|
||||
import type { OfficialHabboFurniEntry } from "@/types/furni";
|
||||
|
||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -42,7 +43,7 @@ export async function getOfficialHabboFurnidata(): Promise<
|
||||
console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: { Accept: "application/json" },
|
||||
headers: browserHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export type BrowserFetchDest =
|
||||
| "document"
|
||||
| "image"
|
||||
| "script"
|
||||
| "style"
|
||||
| "empty";
|
||||
|
||||
const CHROME_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
||||
|
||||
const SEC_CH_UA =
|
||||
'"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"';
|
||||
|
||||
/**
|
||||
* Realistic browser request headers so CDNs (Cloudflare, etc.) treat the
|
||||
* server-side fetches as a normal browser instead of a bot/curl and block us.
|
||||
*/
|
||||
export function browserHeaders(
|
||||
dest: BrowserFetchDest = "empty",
|
||||
extra: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const accept =
|
||||
dest === "document"
|
||||
? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
|
||||
: dest === "image"
|
||||
? "image/avif,image/webp,image/apng,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"
|
||||
: dest === "script"
|
||||
? "*/*"
|
||||
: "application/json,text/plain,*/*;q=0.9";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": CHROME_UA,
|
||||
"sec-ch-ua": SEC_CH_UA,
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
Accept: accept,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"Sec-Fetch-Mode":
|
||||
dest === "document" ? "navigate" : dest === "image" ? "no-cors" : "cors",
|
||||
"Sec-Fetch-Dest": dest,
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
};
|
||||
|
||||
if (dest === "document") headers["Upgrade-Insecure-Requests"] = "1";
|
||||
|
||||
return { ...headers, ...extra };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { browserHeaders } from "./browser-headers";
|
||||
|
||||
export function validateSwfBytes(buffer: Buffer): boolean {
|
||||
if (buffer.length < 8) return false;
|
||||
const sig = buffer.toString("ascii", 0, 3);
|
||||
@@ -31,11 +33,9 @@ export async function downloadFile(
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
|
||||
headers: browserHeaders("image", {
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const deterministic =
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { browserHeaders } from "./browser-headers";
|
||||
|
||||
const EXTERNAL_VARIABLES_URL =
|
||||
"https://www.habbo.com/gamedata/external_variables/1";
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
@@ -14,6 +16,7 @@ export async function resolveGordonBuildUrl(): Promise<string> {
|
||||
if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url;
|
||||
const res = await fetch(EXTERNAL_VARIABLES_URL, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: browserHeaders("document"),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`external_variables fetch failed: ${res.status}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
type FurniAssetWriteTargets,
|
||||
getFurniAssetWriteTargets,
|
||||
getGamedataRoot,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
@@ -140,6 +141,23 @@ export async function repairMissingIcons(
|
||||
...targets.mirrorDirs.map((d) => d.nitroDir),
|
||||
]);
|
||||
|
||||
// Load known badge codes from the album directory so we can skip badge
|
||||
// items — they use .gif files, not .png icons.
|
||||
let badgeSet: Set<string> | null = null;
|
||||
try {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot) {
|
||||
const badgeFiles = await fs
|
||||
.readdir(path.join(gamedataRoot, "album1584"))
|
||||
.catch(() => [] as string[]);
|
||||
badgeSet = new Set(
|
||||
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
badgeSet = null;
|
||||
}
|
||||
|
||||
let items: Array<{ id: number; item_name: string }>;
|
||||
try {
|
||||
const [rows] = (await db.execute(sql`
|
||||
@@ -158,10 +176,15 @@ export async function repairMissingIcons(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
// Badge items that only exist as .gif files (in album1584) don't have
|
||||
// .png icon files to repair. Skip them.
|
||||
if (badgeSet?.has(classname)) {
|
||||
return { status: "skipped" } as const;
|
||||
}
|
||||
const fileName = `${classname}_icon.png`;
|
||||
|
||||
// A dir may already have the icon (e.g. the full gamedata) while the
|
||||
// webroot is missing it — treat the item as present when every target has it.
|
||||
// Badge items store icons as badge_<code>_icon.png (the classname already
|
||||
// includes the `badge_` prefix, so fileName matches correctly).
|
||||
const presentDirs = iconDirs.filter((dir) =>
|
||||
existsSync(path.join(dir, fileName)),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
type FurniAssetWriteTargets,
|
||||
getFurniAssetWriteTargets,
|
||||
getGamedataRoot,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
@@ -136,6 +137,23 @@ export async function repairMissingNitros(
|
||||
...targets.mirrorDirs.map((d) => d.nitroDir),
|
||||
]);
|
||||
|
||||
// Load known badge codes from the album directory so we can skip badge
|
||||
// items — they use .gif files, not .nitro bundles.
|
||||
let badgeSet: Set<string> | null = null;
|
||||
try {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot) {
|
||||
const badgeFiles = await fs
|
||||
.readdir(path.join(gamedataRoot, "album1584"))
|
||||
.catch(() => [] as string[]);
|
||||
badgeSet = new Set(
|
||||
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
badgeSet = null;
|
||||
}
|
||||
|
||||
let items: Array<{ id: number; item_name: string }>;
|
||||
try {
|
||||
const [rows] = (await db.execute(sql`
|
||||
@@ -154,6 +172,10 @@ export async function repairMissingNitros(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
// Badge items use .gif files, not .nitro bundles.
|
||||
if (badgeSet?.has(classname) ?? false) {
|
||||
return { status: "skipped" } as const;
|
||||
}
|
||||
const fileName = `${classname}.nitro`;
|
||||
|
||||
const presentDirs = nitroDirs.filter((dir) =>
|
||||
|
||||
+8
-11
@@ -40,17 +40,14 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
||||
|
||||
const response = NextResponse.next({ request: { headers } });
|
||||
|
||||
if (token) {
|
||||
response.headers.set(
|
||||
"Cache-Control",
|
||||
"private, no-cache, no-store, max-age=0, must-revalidate",
|
||||
);
|
||||
} else {
|
||||
response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=60, s-maxage=300, stale-while-revalidate=300",
|
||||
);
|
||||
}
|
||||
// HTML is never cached (browser/CDN/edge) so that after a deploy the page
|
||||
// always references the current build's chunks. Static assets are
|
||||
// content-hashed + immutable and can be cached aggressively; a stale HTML
|
||||
// document would reference chunk URLs that no longer exist after a rebuild.
|
||||
response.headers.set(
|
||||
"Cache-Control",
|
||||
"private, no-cache, no-store, max-age=0, must-revalidate",
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
response.headers.set(key, value);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
// Integration test against the live database. Vitest runs without a populated
|
||||
// .env (SKIP_ENV_VALIDATION=1), so only execute when a DATABASE_URL is provided.
|
||||
const hasDb =
|
||||
typeof process.env.DATABASE_URL === "string" &&
|
||||
process.env.DATABASE_URL.length > 0;
|
||||
|
||||
describe.skipIf(!hasDb)("repair icons", () => {
|
||||
it("runs icon repair", async () => {
|
||||
const { repairMissingIcons } = await import("@/lib/services/repair-icons");
|
||||
|
||||
const result = await repairMissingIcons((evt) => {
|
||||
if (evt.type === "batch_complete") {
|
||||
console.log("BATCH COMPLETE:", JSON.stringify(evt));
|
||||
} else if (evt.type === "error") {
|
||||
console.log("ERROR:", evt.message);
|
||||
}
|
||||
});
|
||||
console.log("Result:", JSON.stringify(result));
|
||||
}, 600_000);
|
||||
});
|
||||
+125
-52
@@ -9,8 +9,8 @@ set -Eeo pipefail
|
||||
# =============================================================================
|
||||
# CONSTANTS
|
||||
# =============================================================================
|
||||
readonly SCRIPT_VERSION="8.0.0"
|
||||
readonly SCRIPT_BUILD="20260720"
|
||||
readonly SCRIPT_VERSION="8.0.2"
|
||||
readonly SCRIPT_BUILD="20260803"
|
||||
readonly SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly START_TIME=$(date +%s)
|
||||
@@ -105,19 +105,24 @@ DB_USER="${NITRO_DB_USER:-${DB_USER:-root}}"
|
||||
DB_PASS="${NITRO_DB_PASS:-${DB_PASS:-}}"
|
||||
EMULATOR_SERVICE="${NITRO_EMULATOR_SERVICE:-emulator}"
|
||||
EMULATOR_DIR="${NITRO_EMULATOR_PATH:-/var/www/emulator}"
|
||||
SQL_DIR="${NITRO_SQL_DIR:-$EMULATOR_DIR/Database Updates}"
|
||||
# Polaris-Emulator: the git repository root is $EMULATOR_DIR itself, while the
|
||||
# Maven project (pom.xml + target/) lives in the $EMULATOR_DIR/Emulator submodule.
|
||||
EMULATOR_REPO="${NITRO_EMULATOR_REPO:-$EMULATOR_DIR}"
|
||||
EMULATOR_MODULE="${NITRO_EMULATOR_MODULE:-$EMULATOR_DIR/Emulator}"
|
||||
SQL_DIR="${NITRO_SQL_DIR:-$EMULATOR_DIR/Database/Database Updates}"
|
||||
GAMEDATA_CONF_DIR="${NITRO_GAMEDATA_DIR:-/var/www/Gamedata/config}"
|
||||
NITRO_SRC_DIR="${NITRO_CLIENT_DIR:-/var/www/Nitro-V3/public/configuration}"
|
||||
BACKUP_DIR="${NITRO_BACKUP_DIR:-$EMULATOR_DIR/Database Updates/backups}"
|
||||
BACKUP_DIR="${NITRO_BACKUP_DIR:-$EMULATOR_DIR/Database/Database Updates/backups}"
|
||||
NITRO_CLIENT="${NITRO_CLIENT_SRC:-/var/www/Nitro-V3}"
|
||||
NITRO_RENDERER="${NITRO_RENDERER_SRC:-/var/www/Nitro_Render_V3}"
|
||||
NITRO_BRANCH="${NITRO_BRANCH:-main}"
|
||||
NITRO_BRANCH="${NITRO_BRANCH:-}"
|
||||
MIN_DISK_GB="${NITRO_MIN_DISK_GB:-5}"
|
||||
HEALTH_RETRIES="${NITRO_HEALTH_RETRIES:-12}"
|
||||
HEALTH_INTERVAL="${NITRO_HEALTH_INTERVAL:-5}"
|
||||
NITRO_SSL_VERIFY="${NITRO_SSL_VERIFY:-OFF}"
|
||||
CONFIG_BACKUP_DIR="${SCRIPT_DIR}/storage/config-backups"
|
||||
mkdir -p "$CONFIG_BACKUP_DIR" 2>/dev/null || true
|
||||
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
|
||||
NITRO_LANG="${NITRO_LANG:-en}"
|
||||
|
||||
MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=${NITRO_SSL_VERIFY}"
|
||||
@@ -857,7 +862,7 @@ trap 'release_lock; cursor_show; exit 1' SIGINT SIGTERM
|
||||
# GIT HELPERS
|
||||
# =============================================================================
|
||||
detect_branches() {
|
||||
local repos=("$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER")
|
||||
local repos=("$EMULATOR_REPO" "$NITRO_CLIENT" "$NITRO_RENDERER")
|
||||
for repo in "${repos[@]}"; do
|
||||
[ -d "$repo/.git" ] || continue
|
||||
(
|
||||
@@ -876,10 +881,11 @@ detect_best_branch() {
|
||||
local repo="$1" preferred="$2"
|
||||
(
|
||||
cd "$repo" 2>/dev/null || { echo "main"; exit 0; }
|
||||
for v in "$preferred" "${preferred^}" "main" "master"; do
|
||||
git rev-parse --verify "$v" &>/dev/null && { echo "$v"; exit 0; }
|
||||
local cur; cur=$(git branch --show-current 2>/dev/null || echo "")
|
||||
for v in "$preferred" "${preferred^}" "$cur" "main" "master"; do
|
||||
[ -n "$v" ] && git rev-parse --verify "$v" &>/dev/null && { echo "$v"; exit 0; }
|
||||
done
|
||||
echo "$(git branch --show-current 2>/dev/null || echo "main")"
|
||||
echo "${cur:-main}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -924,33 +930,36 @@ clean_node_modules() {
|
||||
# =============================================================================
|
||||
PARALLEL_PIDS=()
|
||||
|
||||
PARALLEL_NAMES=()
|
||||
PARALLEL_FAILED=()
|
||||
PARALLEL_RESULTS=()
|
||||
|
||||
parallel_run() {
|
||||
local name="$1"; shift
|
||||
PARALLEL_NAMES+=("$name")
|
||||
(
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo "__PARALLEL_OK__${name}"
|
||||
else
|
||||
echo "__PARALLEL_FAIL__${name}"
|
||||
fi
|
||||
"$@" >/dev/null 2>&1
|
||||
) &
|
||||
PARALLEL_PIDS+=($!)
|
||||
}
|
||||
|
||||
parallel_wait() {
|
||||
# Collect per-job pass/fail markers from stdout of each backgrounded job.
|
||||
# Collect per-job pass/fail based on the real exit status of each job.
|
||||
PARALLEL_RESULTS=()
|
||||
PARALLEL_FAILED=()
|
||||
local i=0
|
||||
for pid in "${PARALLEL_PIDS[@]}"; do
|
||||
local line
|
||||
line=$(wait "$pid" 2>/dev/null; true)
|
||||
PARALLEL_RESULTS+=("$line")
|
||||
if [ "$line" = "__PARALLEL_FAIL__"* ]; then
|
||||
PARALLEL_FAILED+=("${line#__PARALLEL_FAIL__}")
|
||||
local name="${PARALLEL_NAMES[$i]}"
|
||||
if wait "$pid" 2>/dev/null; then
|
||||
PARALLEL_RESULTS+=("__PARALLEL_OK__${name}")
|
||||
else
|
||||
PARALLEL_RESULTS+=("__PARALLEL_FAIL__${name}")
|
||||
PARALLEL_FAILED+=("$name")
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
PARALLEL_PIDS=()
|
||||
PARALLEL_NAMES=()
|
||||
}
|
||||
|
||||
parallel_failed() {
|
||||
@@ -987,6 +996,9 @@ validate_configs() {
|
||||
local errors=0
|
||||
for f in "$dir"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$(basename "$f")" in
|
||||
*jsonc*|*.example*) continue ;;
|
||||
esac
|
||||
if ! validate_json "$f"; then
|
||||
warn "Invalid JSON: %s" "$(basename "$f")"
|
||||
errors=$((errors + 1))
|
||||
@@ -1003,7 +1015,7 @@ backup_binaries() {
|
||||
BINARY_BACKUP_DIR="$CONFIG_BACKUP_DIR/binaries-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BINARY_BACKUP_DIR"
|
||||
|
||||
local jar_dir="$EMULATOR_DIR/Emulator/target"
|
||||
local jar_dir="$EMULATOR_MODULE/target"
|
||||
if [ -d "$jar_dir" ] && ls "$jar_dir"/Polaris-*-jar-with-dependencies.jar &>/dev/null 2>&1; then
|
||||
cp "$jar_dir"/Polaris-*-jar-with-dependencies.jar "$BINARY_BACKUP_DIR/" 2>/dev/null || true
|
||||
ok "Emulator JAR backed up"
|
||||
@@ -1023,7 +1035,7 @@ restore_binaries() {
|
||||
echo -e " ${C_BG_RED}${C_WHITE}${C_BOLD} $(_t "RESTORE BINARIES") ${C_RESET}"
|
||||
|
||||
if ls "$BINARY_BACKUP_DIR"/Polaris-*-jar-with-dependencies.jar &>/dev/null 2>&1; then
|
||||
local jar_dir="$EMULATOR_DIR/Emulator/target"
|
||||
local jar_dir="$EMULATOR_MODULE/target"
|
||||
mkdir -p "$jar_dir"
|
||||
cp "$BINARY_BACKUP_DIR"/Polaris-*-jar-with-dependencies.jar "$jar_dir/" 2>/dev/null || true
|
||||
ok "Emulator JAR restored"
|
||||
@@ -1052,7 +1064,7 @@ preflight_check() {
|
||||
|
||||
ok "All commands available"
|
||||
|
||||
for d in "$EMULATOR_DIR/Emulator" "$NITRO_RENDERER" "$NITRO_CLIENT" "$NITRO_SRC_DIR"; do
|
||||
for d in "$EMULATOR_REPO" "$EMULATOR_MODULE" "$NITRO_RENDERER" "$NITRO_CLIENT" "$NITRO_SRC_DIR"; do
|
||||
[ -d "$d" ] || die "Missing dir: $d"
|
||||
done
|
||||
ok "All directories exist"
|
||||
@@ -1075,9 +1087,9 @@ preflight_check() {
|
||||
# =============================================================================
|
||||
update_emulator() {
|
||||
step 2 8 "Update & Build Emulator"
|
||||
if git_update "$EMULATOR_DIR/Emulator" "$NITRO_BRANCH"; then
|
||||
if git_update "$EMULATOR_REPO" "$NITRO_BRANCH"; then
|
||||
HAD_UPDATES=true; UPDATED_REPOS+=("Emulator")
|
||||
cd "$EMULATOR_DIR/Emulator"
|
||||
cd "$EMULATOR_MODULE"
|
||||
backup_binaries
|
||||
ROLLBACK_NEEDED=true
|
||||
spinner_start "Building emulator (parallel)..."
|
||||
@@ -1118,11 +1130,22 @@ update_renderer() {
|
||||
HAD_UPDATES=true; UPDATED_REPOS+=("Nitro_Render_V3")
|
||||
cd "$NITRO_RENDERER"
|
||||
spinner_start "Installing deps..."
|
||||
yarn install --frozen-lockfile --prefer-offline 2>&1 || { clean_node_modules && yarn install 2>&1; } || { spinner_stop fail; die "yarn install failed"; }
|
||||
if ! yarn install --frozen-lockfile --prefer-offline >> "$LOG_FILE" 2>&1; then
|
||||
warn "Renderer: yarn install failed — cleaning node_modules and retrying"
|
||||
clean_node_modules
|
||||
yarn install >> "$LOG_FILE" 2>&1 || { spinner_stop fail; die "yarn install failed (see: %s)" "$LOG_FILE"; }
|
||||
fi
|
||||
if [ ! -d node_modules/pixi.js ]; then
|
||||
warn "Renderer: node_modules incomplete — cleaning and doing full install"
|
||||
clean_node_modules
|
||||
yarn install >> "$LOG_FILE" 2>&1 || { spinner_stop fail; die "yarn install failed (see: %s)" "$LOG_FILE"; }
|
||||
fi
|
||||
spinner_stop ok; ok "Renderer dependencies installed"
|
||||
else
|
||||
info "Renderer: already up to date"
|
||||
fi
|
||||
fix_perms
|
||||
return 0
|
||||
}
|
||||
|
||||
update_client() {
|
||||
@@ -1133,14 +1156,25 @@ update_client() {
|
||||
backup_binaries
|
||||
ROLLBACK_NEEDED=true
|
||||
spinner_start "Installing deps..."
|
||||
yarn install --frozen-lockfile --prefer-offline 2>&1 || { clean_node_modules && yarn install 2>&1; } || { spinner_stop fail; die "yarn install failed"; }
|
||||
# yarn 1 can "succeed" while leaving node_modules incomplete, so verify
|
||||
# that the vite build binary actually exists before attempting a build.
|
||||
if ! yarn install --frozen-lockfile --prefer-offline >> "$LOG_FILE" 2>&1; then
|
||||
warn "Nitro-V3: yarn install failed — cleaning node_modules and retrying"
|
||||
clean_node_modules
|
||||
yarn install >> "$LOG_FILE" 2>&1 || { spinner_stop fail; die "yarn install failed (see: %s)" "$LOG_FILE"; }
|
||||
fi
|
||||
if [ ! -x node_modules/.bin/vite ]; then
|
||||
warn "Nitro-V3: vite missing after install — cleaning node_modules and doing full install"
|
||||
clean_node_modules
|
||||
yarn install >> "$LOG_FILE" 2>&1 || { spinner_stop fail; die "yarn install failed (see: %s)" "$LOG_FILE"; }
|
||||
fi
|
||||
spinner_stop ok
|
||||
spinner_start "Building frontend..."
|
||||
if yarn build >> "$LOG_FILE" 2>&1; then
|
||||
spinner_stop ok
|
||||
else
|
||||
spinner_stop fail
|
||||
tail -10 "$LOG_FILE" || true
|
||||
tail -30 "$LOG_FILE" || true
|
||||
die "yarn build failed (see: %s)" "$LOG_FILE"
|
||||
fi
|
||||
else
|
||||
@@ -1148,6 +1182,8 @@ update_client() {
|
||||
fi
|
||||
mkdir -p "$NITRO_CLIENT/dist/custom-themes"
|
||||
[ ! -f "$NITRO_CLIENT/dist/custom-themes/index.json" ] && echo '{"themes":[]}' > "$NITRO_CLIENT/dist/custom-themes/index.json"
|
||||
fix_perms
|
||||
return 0
|
||||
}
|
||||
|
||||
sync_configs() {
|
||||
@@ -1178,6 +1214,12 @@ sync_configs() {
|
||||
# Validate the generated configs
|
||||
validate_configs "$NITRO_SRC_DIR"
|
||||
|
||||
# Remove legacy .json5 leftovers — the client only supports JSON/JSONC
|
||||
for d in "$NITRO_SRC_DIR" "$dcd" "$GAMEDATA_CONF_DIR"; do
|
||||
[ -d "$d" ] && find "$d" -maxdepth 1 -name '*.json5' -type f -delete 2>/dev/null || true
|
||||
done
|
||||
ok "Legacy .json5 files removed"
|
||||
|
||||
# Pre-flight: warn if NITRO_IMAGE_LIBRARY_URL looks wrong before applying configs
|
||||
if [ -n "$NITRO_IMAGE_LIBRARY_URL" ]; then
|
||||
case "$NITRO_IMAGE_LIBRARY_URL" in
|
||||
@@ -1199,6 +1241,11 @@ sync_configs() {
|
||||
NITRO_FURNI_ASSET_ICON_URL="$NITRO_FURNI_ASSET_ICON_URL" \
|
||||
python3 -c '
|
||||
import json, os
|
||||
def to_jsonc(value):
|
||||
if isinstance(value, dict): return {k: to_jsonc(v) for k, v in value.items()}
|
||||
if isinstance(value, list): return [to_jsonc(v) for v in value]
|
||||
if isinstance(value, str): return value.replace(".json5", ".jsonc")
|
||||
return value
|
||||
paths = [
|
||||
os.path.join(os.environ.get("NITRO_SRC_DIR", ""), "renderer-config.json"),
|
||||
os.path.join(os.environ.get("NITRO_DIST_CONFIG_DIR", ""), "renderer-config.json"),
|
||||
@@ -1208,6 +1255,7 @@ paths = [
|
||||
for path in paths:
|
||||
if not os.path.exists(path): continue
|
||||
with open(path, "r") as f: data = json.load(f)
|
||||
data = to_jsonc(data)
|
||||
for key in ["image.library.url", "hof.furni.url", "gamedata.url", "asset.url"]:
|
||||
env_val = os.environ.get(f"NITRO_{key.upper().replace(chr(46), chr(95))}")
|
||||
if env_val: data[key] = env_val
|
||||
@@ -1216,8 +1264,8 @@ for path in paths:
|
||||
env_val = os.environ.get(f"NITRO_{key.upper().replace(chr(46), chr(95))}")
|
||||
if env_val: data[key] = env_val
|
||||
if "gamedata.url" in data:
|
||||
data["radio.url"] = data["gamedata.url"] + "/config/radio-stations.json5?t=%timestamp%"
|
||||
data["soundboard.url"] = data["gamedata.url"] + "/config/soundboard-sounds.json5?t=%timestamp%"
|
||||
data["radio.url"] = data["gamedata.url"] + "/config/radio-stations.jsonc?t=%timestamp%"
|
||||
data["soundboard.url"] = data["gamedata.url"] + "/config/soundboard-sounds.jsonc?t=%timestamp%"
|
||||
if "show.google.ads" in data: data["show.google.ads"] = False
|
||||
with open(path, "w") as f: json.dump(data, f, indent=(4 if "dist" not in path else None), separators=(",", ":") if "dist" in path else (",", ": "))
|
||||
print(f" [OK] Fixed: {os.path.basename(path)}")
|
||||
@@ -1227,6 +1275,7 @@ for path in paths:
|
||||
if command -v redis-cli &>/dev/null; then
|
||||
redis-cli FLUSHALL 2>/dev/null && ok "Redis cache flushed" || true
|
||||
fi
|
||||
fix_perms
|
||||
}
|
||||
|
||||
do_cleanup() {
|
||||
@@ -1243,16 +1292,31 @@ do_cleanup() {
|
||||
yarn cache clean 2>/dev/null || true
|
||||
rm -rf /tmp/nitro-* /tmp/epicnext-* 2>/dev/null || true
|
||||
# Clean old Maven build artifacts (older than 7 days)
|
||||
find "$EMULATOR_DIR/Emulator" -maxdepth 3 -name '*.jar' -mtime +7 2>/dev/null -exec rm -f {} \; || true
|
||||
find "$EMULATOR_MODULE" -maxdepth 3 -name '*.jar' -mtime +7 2>/dev/null -exec rm -f {} \; || true
|
||||
ok "Cleanup done (%s logs removed)" "$lc"
|
||||
}
|
||||
|
||||
fix_perms() {
|
||||
# Make build output web-servable no matter which user ran the build.
|
||||
# Running as root we chown directly; otherwise use `sudo -n` (no TTY, so it
|
||||
# also works from cron). Never silently swallow failures.
|
||||
local runner=""
|
||||
[ "$(id -u)" -eq 0 ] || runner="sudo -n"
|
||||
local changed=false
|
||||
for d in "$NITRO_CLIENT" "$NITRO_RENDERER" "$GAMEDATA_CONF_DIR"; do
|
||||
[ -d "$d" ] || continue
|
||||
if $runner chown -R www-data:www-data "$d" 2>/dev/null; then
|
||||
changed=true
|
||||
else
|
||||
warn "Could not chown %s to www-data:www-data" "$d"
|
||||
fi
|
||||
done
|
||||
[ "$changed" = true ] && ok "Permissions set"
|
||||
}
|
||||
|
||||
do_permissions() {
|
||||
step 7 8 "Set Permissions"
|
||||
for d in "$NITRO_CLIENT" "$NITRO_RENDERER" "$EMULATOR_DIR" "$GAMEDATA_CONF_DIR"; do
|
||||
[ -d "$d" ] && sudo chown -R www-data:www-data "$d" 2>/dev/null || true
|
||||
done
|
||||
ok "Permissions set"
|
||||
fix_perms
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
@@ -1288,7 +1352,7 @@ do_restart() {
|
||||
emulator_port_open() {
|
||||
local port="${NITRO_EMULATOR_PORT:-}"
|
||||
if [ -z "$port" ]; then
|
||||
port=$(grep -m1 '^game\.port=' "$EMULATOR_DIR/Emulator/target/config.ini" 2>/dev/null | cut -d= -f2)
|
||||
port=$(grep -m1 '^game\.port=' "$EMULATOR_MODULE/target/config.ini" 2>/dev/null | cut -d= -f2)
|
||||
fi
|
||||
if [ -z "$port" ]; then
|
||||
port=30000
|
||||
@@ -1382,7 +1446,7 @@ cmd_update() {
|
||||
esac
|
||||
|
||||
show_summary
|
||||
sudo chown -R www-data:www-data /var/www/Nitro-V3/
|
||||
fix_perms
|
||||
release_lock
|
||||
}
|
||||
|
||||
@@ -1466,7 +1530,7 @@ cmd_status() {
|
||||
done; box_bottom 56
|
||||
echo ""
|
||||
box_top "GIT REPOS" 56
|
||||
for re in "Emulator:$EMULATOR_DIR/Emulator" "Nitro-V3:$NITRO_CLIENT" "Render:$NITRO_RENDERER"; do
|
||||
for re in "Emulator:$EMULATOR_REPO" "Nitro-V3:$NITRO_CLIENT" "Render:$NITRO_RENDERER"; do
|
||||
local nm="${re%%:*}" pa="${re##*:}"
|
||||
[ -d "$pa/.git" ] || continue
|
||||
local br=$(cd "$pa" && git branch --show-current 2>/dev/null || echo "?")
|
||||
@@ -1487,8 +1551,8 @@ cmd_status() {
|
||||
cmd_health() {
|
||||
step 1 1 "Health Check"
|
||||
local t=0 p=0 f=0 w=0
|
||||
check() { t=$((t+1)); "$@" &>/dev/null && { p=$((p+1)); ok "$(_t "$1")"; } || { f=$((f+1)); fail "$(_t "$1")"; }; }
|
||||
checkw() { t=$((t+1)); "$@" &>/dev/null && { p=$((p+1)); ok "$(_t "$1")"; } || { w=$((w+1)); warn "$(_t "$1")"; }; }
|
||||
check() { t=$((t+1)); local lbl="$1"; shift; "$@" &>/dev/null && { p=$((p+1)); ok "$(_t "$lbl")"; } || { f=$((f+1)); fail "$(_t "$lbl")"; }; }
|
||||
checkw() { t=$((t+1)); local lbl="$1"; shift; "$@" &>/dev/null && { p=$((p+1)); ok "$(_t "$lbl")"; } || { w=$((w+1)); warn "$(_t "$lbl")"; }; }
|
||||
echo ""
|
||||
echo -e " ${C_BOLD}$(_t "Commands:")${C_RESET}"
|
||||
checkw "Git" command -v git; checkw "Node.js" command -v node; checkw "Yarn" command -v yarn
|
||||
@@ -1503,6 +1567,9 @@ cmd_health() {
|
||||
local cerr=0
|
||||
for cf in "$NITRO_SRC_DIR"/*.json; do
|
||||
[ -f "$cf" ] || continue
|
||||
case "$(basename "$cf")" in
|
||||
*jsonc*|*.example*) continue ;;
|
||||
esac
|
||||
python3 -m json.tool "$cf" >/dev/null 2>&1 || cerr=$((cerr+1))
|
||||
done
|
||||
[ "$cerr" -eq 0 ] && ok "$(_t "All JSON valid")" || warn "$(_t "JSON errors:") $cerr"
|
||||
@@ -1528,7 +1595,7 @@ cmd_health() {
|
||||
fi
|
||||
done
|
||||
echo -e "\n ${C_BOLD}$(_t "Directories:")${C_RESET}"
|
||||
checkw "Emulator" test -d "$EMULATOR_DIR/Emulator"; checkw "Nitro-V3" test -d "$NITRO_CLIENT"; checkw "Renderer" test -d "$NITRO_RENDERER"
|
||||
checkw "Emulator" test -d "$EMULATOR_MODULE"; checkw "Nitro-V3" test -d "$NITRO_CLIENT"; checkw "Renderer" test -d "$NITRO_RENDERER"
|
||||
checkw "$(_t "Gamedata config")" test -d "$GAMEDATA_CONF_DIR"; checkw "$(_t "Backup dir")" test -d "$BACKUP_DIR"
|
||||
echo -e "\n ${C_BOLD}$(_t "Services:")${C_RESET}"
|
||||
checkw "MariaDB" service_active mariadb; checkw "Nginx" service_active nginx; checkw "Redis" service_active redis-server
|
||||
@@ -1583,7 +1650,7 @@ cmd_flyaway_repair() {
|
||||
step 2 6 "$(_t "Flyaway Repair")"
|
||||
local repaired=0
|
||||
|
||||
for repo in "$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
for repo in "$EMULATOR_REPO" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
[ -d "$repo/.git" ] || continue
|
||||
local name; name=$(basename "$repo")
|
||||
info "Checking %s..." "$name"
|
||||
@@ -1622,9 +1689,7 @@ cmd_flyaway_repair() {
|
||||
|
||||
# Fix permissions
|
||||
step 3 6 "$(_t "Set Permissions")"
|
||||
for d in "$NITRO_CLIENT" "$NITRO_RENDERER" "$EMULATOR_DIR" "$GAMEDATA_CONF_DIR"; do
|
||||
[ -d "$d" ] && sudo chown -R www-data:www-data "$d" 2>/dev/null || true
|
||||
done
|
||||
fix_perms
|
||||
ok "Permissions fixed"
|
||||
|
||||
# Sync / validate configs
|
||||
@@ -1649,9 +1714,9 @@ cmd_flyaway_repair() {
|
||||
fi
|
||||
|
||||
# Rebuild emulator if jar is missing
|
||||
if [ ! -d "$EMULATOR_DIR/Emulator/target" ] || ! ls "$EMULATOR_DIR/Emulator/target"/Polaris-*-jar-with-dependencies.jar &>/dev/null 2>&1; then
|
||||
if [ ! -d "$EMULATOR_MODULE/target" ] || ! ls "$EMULATOR_MODULE"/target/Polaris-*-jar-with-dependencies.jar &>/dev/null 2>&1; then
|
||||
step 6 6 "$(_t "Rebuild Emulator")"
|
||||
cd "$EMULATOR_DIR/Emulator"
|
||||
cd "$EMULATOR_MODULE"
|
||||
backup_binaries
|
||||
ROLLBACK_NEEDED=true
|
||||
spinner_start "$(_t "Building emulator (parallel)...")"
|
||||
@@ -1672,7 +1737,7 @@ cmd_flyaway_repair() {
|
||||
do_restart
|
||||
|
||||
show_summary
|
||||
sudo chown -R www-data:www-data /var/www/Nitro-V3/
|
||||
fix_perms
|
||||
release_lock
|
||||
}
|
||||
|
||||
@@ -1789,7 +1854,7 @@ cmd_clean() {
|
||||
4) find "$EMULATOR_DIR" -name "*.log" -mtime +14 -exec rm -f {} \; 2>/dev/null; ok "Logs cleaned" ;;
|
||||
5) yarn cache clean 2>/dev/null; find "$EMULATOR_DIR" -name "*.log" -mtime +14 -exec rm -f {} \; 2>/dev/null; rm -rf /tmp/nitro-* /tmp/epicnext-* 2>/dev/null; ok "Full cleanup" ;;
|
||||
6) command -v docker &>/dev/null && docker system prune -f 2>/dev/null && ok "Docker pruned" || warn "Docker N/A" ;;
|
||||
7) find "$EMULATOR_DIR/Emulator" -maxdepth 3 -name '*.jar' -mtime +7 -exec rm -f {} \; 2>/dev/null; ok "Old Maven artifacts cleaned" ;;
|
||||
7) find "$EMULATOR_MODULE" -maxdepth 3 -name '*.jar' -mtime +7 -exec rm -f {} \; 2>/dev/null; ok "Old Maven artifacts cleaned" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -1802,7 +1867,7 @@ cmd_gitlog() {
|
||||
echo -e " ${C_BOLD}1)${C_RESET} $(_t "Emulator") ${C_BOLD}2)${C_RESET} $(_t "Nitro-V3") ${C_BOLD}3)${C_RESET} $(_t "Renderer")"
|
||||
echo -en "\n $(_t "Select:") "; local c; read -rt 30 c
|
||||
local repo=""
|
||||
case "$c" in 1) repo="$EMULATOR_DIR/Emulator" ;; 2) repo="$NITRO_CLIENT" ;; 3) repo="$NITRO_RENDERER" ;; *) return ;; esac
|
||||
case "$c" in 1) repo="$EMULATOR_REPO" ;; 2) repo="$NITRO_CLIENT" ;; 3) repo="$NITRO_RENDERER" ;; *) return ;; esac
|
||||
[ ! -d "$repo/.git" ] && { warn "Not a git repo"; return; }
|
||||
echo ""
|
||||
local branch
|
||||
@@ -1832,7 +1897,7 @@ cmd_compare() {
|
||||
[[ "$ba" =~ ^[0-9]+$ ]] && [[ "$bb" =~ ^[0-9]+$ ]] && [ "$ba" -ge 1 ] && [ "$bb" -ge 1 ] && [ "$ba" -le ${#branches[@]} ] && [ "$bb" -le ${#branches[@]} ] || { warn "Invalid"; return; }
|
||||
local bA="${branches[$((ba-1))]}" bB="${branches[$((bb-1))]}"
|
||||
echo ""
|
||||
for repo in "$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
for repo in "$EMULATOR_REPO" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
[ -d "$repo/.git" ] || continue
|
||||
echo -e " ${C_BOLD}$(basename "$repo"):${C_RESET}"
|
||||
cd "$repo" 2>/dev/null || continue
|
||||
@@ -1873,7 +1938,7 @@ cmd_schedule() {
|
||||
cmd_stash() {
|
||||
step 1 1 "Git Stashes"
|
||||
local found=false
|
||||
for repo in "$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
for repo in "$EMULATOR_REPO" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
[ -d "$repo/.git" ] || continue
|
||||
local list
|
||||
list=$(cd "$repo" && git stash list 2>/dev/null)
|
||||
@@ -2103,6 +2168,14 @@ main() {
|
||||
done
|
||||
[ -z "$cmd" ] && cmd="interactive"
|
||||
|
||||
# Auto-detect the branch from the emulator repo when none was configured,
|
||||
# so "Quick Update — current branch" actually updates the current branch.
|
||||
if [ -z "${NITRO_BRANCH:-}" ]; then
|
||||
NITRO_BRANCH="$(cd "$EMULATOR_REPO" 2>/dev/null && git branch --show-current 2>/dev/null || echo "main")"
|
||||
[ -z "$NITRO_BRANCH" ] && NITRO_BRANCH="main"
|
||||
info "Auto-detected branch: %s" "$NITRO_BRANCH"
|
||||
fi
|
||||
|
||||
if [ -z "${NITRO_SITE_URL:-}" ] && [ -n "${APP_URL:-}" ]; then NITRO_SITE_URL="$APP_URL"; fi
|
||||
case "${NITRO_SITE_URL:-}" in
|
||||
https://*) NITRO_WS_PROTO="wss://" ; NITRO_DOMAIN="${NITRO_SITE_URL#https://}" ;;
|
||||
|
||||
Reference in new issue
Block a user