17 Commits
Author SHA1 Message Date
remco 084e090ba3 chore(deps): update All dependencies
CI / check (pull_request) Successful in 24s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-03 21:00:36 +00:00
openhands b1a1e5125c fix: identify badge items by .gif presence in album1584 directory
CI / check (push) Successful in 30s
CI / release (push) Skipped
CI / deploy (push) Successful in 58s
Badge items like HC_Badge, BADGE_SHREK_03 have item_names that don't start
with 'badge_' and interaction_type='default'. Now loads known badge codes
from <gamedataRoot>/album1584/*.gif files and uses that set to exclude
badge items from .nitro and icon audit checks. Also removes incorrect
startsWith('badge_') check that would wrongly skip badge display cases
which DO have .nitro files.
2026-08-03 22:05:49 +02:00
openhands 750973de38 fix: correct badge item detection by checking classname prefix
CI / check (push) Successful in 30s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s
Badge item_names already start with 'badge_' (e.g. badge_citycpa3),
so checking for badge_<item_name>_icon.png produces a double prefix
(badge_badge_citycpa3_icon.png). Now uses item_name.startsWith('badge_')
instead, which correctly identifies badge items without depending on
icon files existing in the directory.
2026-08-03 21:47:10 +02:00
openhands 1167a48344 fix: use badge icon file pattern to exclude badge items from audit and repair
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 55s
Previously identified badge items by interaction_type='badge', but
clone-imported badges have interaction_type='default'. Now checks for
badge_<code>_icon.png file existence instead.
2026-08-03 21:39:27 +02:00
openhands 40da24bd8c Fix badge icon detection in catalog audit and repair
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m6s
Badge icons are stored as badge_<code>_icon.png (with badge_ prefix)
instead of <code>_icon.png like regular furni. Update the audit and
icon repair to check for both patterns so badge items are not falsely
flagged as missing icons.
2026-08-03 21:34:11 +02:00
openhands e97213e5d1 Exclude badge items from missing icon check in catalog audit
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s
Badge items use .gif icons, not .png icons, so they should not
be flagged as missing icons in the catalog audit.
2026-08-03 21:28:30 +02:00
openhands 86d59195ec Exclude badge items from catalog audit and repair checks
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m5s
Badge items use .gif files, not .nitro bundles, so they should not
be flagged as missing .nitro or missing catalog entries. Also add
badge logicType handling in furni-import.ts so badges get the correct
interaction_type when imported.
2026-08-03 21:23:13 +02:00
openhands 1cbb33a4e2 perf: speed up catalog audit by batching file checks and parallelizing source fetches
CI / check (push) Successful in 38s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m13s
2026-08-03 19:52:09 +02:00
openhands 40a21ba767 fix: wrap SSE state updates in flushSync to resolve React error #418
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m3s
2026-08-03 19:42:20 +02:00
openhands cf89681576 fix: root-safe www-data chown after builds and config sync
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s
2026-08-03 19:12:22 +02:00
openhands 2fba923a3a feat: browser headers on audit fetches + verified clone furnidata sources
CI / check (push) Successful in 32s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m18s
2026-08-03 19:00:45 +02:00
openhands 080dc34e23 fix: never cache HTML so deploys always serve current chunks
CI / check (push) Successful in 30s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s
Anonymous HTML was sent with 'public, max-age=60, s-maxage=300,
stale-while-revalidate=300'. After a rebuild the old chunk URLs (keyed by
deploy id) are deleted, so any browser/CDN holding the stale HTML got 404s
for up to five minutes. Since the deploy id is the git commit, the HTML must
be re-fetched after every deploy; only content-hashed static assets should
be cached. Return no-store for all HTML documents.
2026-08-03 18:39:41 +02:00
openhands 450e7e8d00 fix: suppress hydration warning on html for theme-init class
CI / check (push) Successful in 25s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m6s
theme-init.js adds the 'dark' class to <html> before React hydrates,
causing a hydration mismatch (React #418) for users with a saved dark
theme. Mark the root element with suppressHydrationWarning.
2026-08-03 18:29:22 +02:00
openhands 30ed2b8ce2 refactor: switch password hashing from argon2 to bcrypt
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s
- hashPassword now emits bcrypt (cost 12) instead of argon2id
- checkLogin migrates legacy md5/argon2id hashes to bcrypt on sign-in
- keep argon2id verification only as a one-time migration path
- replace ARGON2_* env vars with BCRYPT_COST
2026-08-03 18:08:57 +02:00
openhands 6fc14b9b84 feat: catalog audit can repair orphaned refs and duplicate classnames
- add repairOrphanedCatalog: remove catalog_items rows whose item_ids only
  reference missing items_base entries, strip orphaned ids from mixed rows
- add repairDuplicateClassnames: merge items_base duplicates into one
  canonical row per classname, remap references, delete duplicate rows
- add 'repair structural issues' checkbox + result display in audit UI
2026-08-03 18:08:45 +02:00
openhands bee55e1fd4 fix: skip icon-repair integration test when no DB is configured
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s
2026-08-03 17:47:07 +02:00
openhands 613b7502e1 fix: repair updater and migrate configs to JSONC only
- fix emulator git path (repo root vs Maven submodule) and SQL/backup dirs
- auto-detect branch; track real parallel job exit status
- verify vite presence and clean+full install when node_modules is incomplete
- fix health-check label handling and skip jsonc/example files in JSON scan
- stop hardcoding the Nitro client path in chown
- drop JSON5: sync config URLs to .jsonc, remove legacy .json5 files
- bump to v8.0.2
2026-08-03 17:43:19 +02:00
24 changed files with 920 additions and 294 deletions

No files matched your search

+2 -3
View File
@@ -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
View File
@@ -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",
+52 -52
View File
@@ -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
+150 -63
View File
@@ -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);
}}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+15 -29
View File
@@ -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
View File
@@ -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) };
}
+171 -26
View File
@@ -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?.({
+199
View File
@@ -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;
+5 -1
View File
@@ -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 {
+28 -25
View File
@@ -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;
}
}
+1
View File
@@ -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"),
}),
}),
);
});
});
+2 -1
View File
@@ -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 };
}
+4 -4
View File
@@ -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 =
+3
View File
@@ -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}`);
+25 -2
View File
@@ -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)),
);
+22
View File
@@ -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
View File
@@ -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);
+22
View File
@@ -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
View File
@@ -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://}" ;;