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 AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here= APP_KEY=base64:your-app-key-here=
CONVERT_PASSWORDS=true CONVERT_PASSWORDS=true
ARGON2_MEMORY_KB=65536 # CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
ARGON2_ITERATIONS=4 BCRYPT_COST=12
ARGON2_PARALLELISM=1
# --- PATHS --- # --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges BADGE_UPLOAD_DIR=./public/assets/images/badges
+2 -2
View File
@@ -58,7 +58,7 @@
"motion": "^12.43.0", "motion": "^12.43.0",
"music-metadata": "^11.14.0", "music-metadata": "^11.14.0",
"mysql2": "^3.23.2", "mysql2": "^3.23.2",
"next": "^16.2.12", "next": "^16.3.0",
"next-auth": "5.0.0-beta.32", "next-auth": "5.0.0-beta.32",
"next-intl": "^4.13.4", "next-intl": "^4.13.4",
"otplib": "^13.4.1", "otplib": "^13.4.1",
@@ -76,7 +76,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.6", "@biomejs/biome": "2.5.6",
"@next/bundle-analyzer": "^16.2.12", "@next/bundle-analyzer": "^16.3.0",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
+52 -52
View File
@@ -90,14 +90,14 @@ importers:
specifier: ^3.23.2 specifier: ^3.23.2
version: 3.23.2(@types/[email protected]) version: 3.23.2(@types/[email protected])
next: next:
specifier: ^16.2.12 specifier: ^16.3.0
version: 16.2.12(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]) version: 16.3.0(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-auth: next-auth:
specifier: 5.0.0-beta.32 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: next-intl:
specifier: ^4.13.4 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: otplib:
specifier: ^13.4.1 specifier: ^13.4.1
version: 13.4.1 version: 13.4.1
@@ -139,8 +139,8 @@ importers:
specifier: 2.5.6 specifier: 2.5.6
version: 2.5.6 version: 2.5.6
'@next/bundle-analyzer': '@next/bundle-analyzer':
specifier: ^16.2.12 specifier: ^16.3.0
version: 16.2.12 version: 16.3.0
'@tailwindcss/forms': '@tailwindcss/forms':
specifier: ^0.5.11 specifier: ^0.5.11
version: 0.5.11([email protected]) version: 0.5.11([email protected])
@@ -1203,60 +1203,60 @@ packages:
'@emnapi/core': ^2.0.0-alpha.3 '@emnapi/core': ^2.0.0-alpha.3
'@emnapi/runtime': ^2.0.0-alpha.3 '@emnapi/runtime': ^2.0.0-alpha.3
'@next/bundle-analyzer@16.2.12': '@next/bundle-analyzer@16.3.0':
resolution: {integrity: sha512-0dYhmCYsTMFYUFaoEUx+VKw/oYP6b3XiGgq47EujXTE2UGgwVWAaur2tbko2pxfUka3WRZ9YWxa3PlSv3luWNQ==} resolution: {integrity: sha512-o8OiXKIATcSC6kHm5BIEmaDEDTFUDhpy9POQIbzAQTlb8NWwFhEQheTLFSbyQXxH2ywhjZ/e8EUlbOjj2k22Yg==}
'@next/env@16.2.12': '@next/env@16.3.0':
resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
'@next/swc-darwin-arm64@16.2.12': '@next/swc-darwin-arm64@16.3.0':
resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@16.2.12': '@next/swc-darwin-x64@16.3.0':
resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.12': '@next/swc-linux-arm64-gnu@16.3.0':
resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.12': '@next/swc-linux-arm64-musl@16.3.0':
resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-linux-x64-gnu@16.2.12': '@next/swc-linux-x64-gnu@16.3.0':
resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-x64-musl@16.2.12': '@next/swc-linux-x64-musl@16.3.0':
resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.12': '@next/swc-win32-arm64-msvc@16.3.0':
resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@16.2.12': '@next/swc-win32-x64-msvc@16.3.0':
resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -3430,8 +3430,8 @@ packages:
typescript: typescript:
optional: true optional: true
next@16.2.12: next@16.3.0:
resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -4784,37 +4784,37 @@ snapshots:
'@tybys/wasm-util': 0.10.3 '@tybys/wasm-util': 0.10.3
optional: true optional: true
'@next/bundle-analyzer@16.2.12': '@next/bundle-analyzer@16.3.0':
dependencies: dependencies:
webpack-bundle-analyzer: 4.10.1 webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - 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 optional: true
'@next/swc-darwin-x64@16.2.12': '@next/swc-darwin-x64@16.3.0':
optional: true optional: true
'@next/swc-linux-arm64-gnu@16.2.12': '@next/swc-linux-arm64-gnu@16.3.0':
optional: true optional: true
'@next/swc-linux-arm64-musl@16.2.12': '@next/swc-linux-arm64-musl@16.3.0':
optional: true optional: true
'@next/swc-linux-x64-gnu@16.2.12': '@next/swc-linux-x64-gnu@16.3.0':
optional: true optional: true
'@next/swc-linux-x64-musl@16.2.12': '@next/swc-linux-x64-musl@16.3.0':
optional: true optional: true
'@next/swc-win32-arm64-msvc@16.2.12': '@next/swc-win32-arm64-msvc@16.3.0':
optional: true optional: true
'@next/swc-win32-x64-msvc@16.2.12': '@next/swc-win32-x64-msvc@16.3.0':
optional: true optional: true
'@noble/[email protected]': {} '@noble/[email protected]': {}
@@ -6697,22 +6697,22 @@ snapshots:
[email protected]: {} [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: dependencies:
'@auth/core': 0.41.3 '@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 react: 19.2.8
[email protected]: {} [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: dependencies:
'@formatjs/intl-localematcher': 0.8.13 '@formatjs/intl-localematcher': 0.8.13
'@parcel/watcher': 2.6.0 '@parcel/watcher': 2.6.0
'@swc/core': 1.15.46 '@swc/core': 1.15.46
icu-minify: 4.13.4 icu-minify: 4.13.4
negotiator: 1.0.0 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 next-intl-swc-plugin-extractor: 4.13.4
po-parser: 2.1.1 po-parser: 2.1.1
react: 19.2.8 react: 19.2.8
@@ -6722,9 +6722,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@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: dependencies:
'@next/env': 16.2.12 '@next/env': 16.3.0
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
baseline-browser-mapping: 2.11.6 baseline-browser-mapping: 2.11.6
caniuse-lite: 1.0.30001806 caniuse-lite: 1.0.30001806
@@ -6733,14 +6733,14 @@ snapshots:
react-dom: 19.2.8([email protected]) react-dom: 19.2.8([email protected])
styled-jsx: 5.1.6([email protected]) styled-jsx: 5.1.6([email protected])
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.2.12 '@next/swc-darwin-arm64': 16.3.0
'@next/swc-darwin-x64': 16.2.12 '@next/swc-darwin-x64': 16.3.0
'@next/swc-linux-arm64-gnu': 16.2.12 '@next/swc-linux-arm64-gnu': 16.3.0
'@next/swc-linux-arm64-musl': 16.2.12 '@next/swc-linux-arm64-musl': 16.3.0
'@next/swc-linux-x64-gnu': 16.2.12 '@next/swc-linux-x64-gnu': 16.3.0
'@next/swc-linux-x64-musl': 16.2.12 '@next/swc-linux-x64-musl': 16.3.0
'@next/swc-win32-arm64-msvc': 16.2.12 '@next/swc-win32-arm64-msvc': 16.3.0
'@next/swc-win32-x64-msvc': 16.2.12 '@next/swc-win32-x64-msvc': 16.3.0
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@playwright/test': 1.62.0 '@playwright/test': 1.62.0
babel-plugin-react-compiler: 1.0.0 babel-plugin-react-compiler: 1.0.0
+150 -63
View File
@@ -10,6 +10,7 @@ import {
SearchX, SearchX,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -48,6 +49,11 @@ interface AuditSummary {
catalogSqlApplied: number; catalogSqlApplied: number;
furniDataAdded: number; furniDataAdded: number;
furniDataDuplicatesRemoved: number; furniDataDuplicatesRemoved: number;
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
} }
interface RepairStats { interface RepairStats {
@@ -73,6 +79,13 @@ interface AuditEvent {
applied?: number; applied?: number;
sqlFailed?: number; sqlFailed?: number;
furniData?: { added: number; removedDuplicates: number }; furniData?: { added: number; removedDuplicates: number };
structure?: {
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
};
} }
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -131,6 +144,7 @@ export function AuditClient() {
const [generateSql, setGenerateSql] = useState(false); const [generateSql, setGenerateSql] = useState(false);
const [applySql, setApplySql] = useState(false); const [applySql, setApplySql] = useState(false);
const [repairFurniData, setRepairFurniData] = useState(false); const [repairFurniData, setRepairFurniData] = useState(false);
const [repairStructure, setRepairStructure] = useState(false);
const [issues, setIssues] = useState<AuditIssue[]>([]); const [issues, setIssues] = useState<AuditIssue[]>([]);
const [missingFromSources, setMissingFromSources] = useState< const [missingFromSources, setMissingFromSources] = useState<
@@ -150,6 +164,13 @@ export function AuditClient() {
added: number; added: number;
removedDuplicates: number; removedDuplicates: number;
} | null>(null); } | null>(null);
const [structureResult, setStructureResult] = useState<{
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
} | null>(null);
const [checkProgress, setCheckProgress] = useState<{ const [checkProgress, setCheckProgress] = useState<{
section: string; section: string;
@@ -170,6 +191,7 @@ export function AuditClient() {
setSqlText(null); setSqlText(null);
setSqlApplied(null); setSqlApplied(null);
setFurniDataResult(null); setFurniDataResult(null);
setStructureResult(null);
setCheckProgress(null); setCheckProgress(null);
setActiveTab("issues"); setActiveTab("issues");
@@ -187,6 +209,7 @@ export function AuditClient() {
sql: generateSql || applySql, sql: generateSql || applySql,
applySql, applySql,
repairFurniData, repairFurniData,
repairStructure,
}), }),
}); });
@@ -224,76 +247,82 @@ export function AuditClient() {
try { try {
const evt = JSON.parse(part.slice(6)) as AuditEvent; const evt = JSON.parse(part.slice(6)) as AuditEvent;
if (evt.type === "progress") { flushSync(() => {
setProgress(evt.message ?? ""); if (evt.type === "progress") {
} setProgress(evt.message ?? "");
}
if (evt.type === "items_loaded") { if (evt.type === "items_loaded") {
setProgress(`Loaded ${evt.total} items_base entries`); setProgress(`Loaded ${evt.total} items_base entries`);
} }
if (evt.type === "catalog_loaded") { if (evt.type === "catalog_loaded") {
setProgress(`Loaded ${evt.total} catalog_items entries`); setProgress(`Loaded ${evt.total} catalog_items entries`);
} }
if (evt.type === "checking") { if (evt.type === "checking") {
setProgress(`Checking ${evt.section}…`); setProgress(`Checking ${evt.section}…`);
if (evt.total) { if (evt.total) {
setCheckProgress({ setCheckProgress({
section: evt.section ?? "", section: evt.section ?? "",
current: evt.current ?? 0, current: evt.current ?? 0,
total: evt.total, 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.type === "furnidata_repair_complete") {
if (evt.repair) setRepairStats(evt.repair); if (evt.furniData) setFurniDataResult(evt.furniData);
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 === "error") { if (evt.type === "structure_repair_complete") {
setProgress(`ERROR: ${evt.message ?? ""}`); if (evt.structure) setStructureResult(evt.structure);
toast.error(evt.message ?? "Audit failed"); }
}
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 { } catch {
// skip parse errors // skip parse errors
} }
@@ -309,7 +338,14 @@ export function AuditClient() {
setLoading(false); setLoading(false);
abortRef.current = null; abortRef.current = null;
} }
}, [repairEnabled, repairNitros, generateSql, applySql, repairFurniData]); }, [
repairEnabled,
repairNitros,
generateSql,
applySql,
repairFurniData,
repairStructure,
]);
const errors = issues.filter((i) => i.severity === "error"); const errors = issues.filter((i) => i.severity === "error");
const warnings = issues.filter((i) => i.severity === "warning"); const warnings = issues.filter((i) => i.severity === "warning");
@@ -372,6 +408,13 @@ export function AuditClient() {
> >
Repair FurnitureData.json (add missing + dedupe) Repair FurnitureData.json (add missing + dedupe)
</AuditCheckbox> </AuditCheckbox>
<AuditCheckbox
checked={repairStructure}
disabled={loading}
onChange={setRepairStructure}
>
Repair structural issues (orphaned refs + duplicates)
</AuditCheckbox>
{loading && ( {loading && (
<Button <Button
variant="outline" variant="outline"
@@ -601,6 +644,49 @@ export function AuditClient() {
</span> </span>
</div> </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> </div>
{/* Tabs */} {/* Tabs */}
@@ -733,6 +819,7 @@ export function AuditClient() {
setSqlText(null); setSqlText(null);
setSqlApplied(null); setSqlApplied(null);
setFurniDataResult(null); setFurniDataResult(null);
setStructureResult(null);
setProgress(""); setProgress("");
setCheckProgress(null); setCheckProgress(null);
}} }}
+4
View File
@@ -16,6 +16,7 @@ export const POST = withAdmin(
let sql = false; let sql = false;
let applySql = false; let applySql = false;
let repairFurniData = false; let repairFurniData = false;
let repairStructure = false;
try { try {
const body = (await request.json().catch(() => ({}))) as { const body = (await request.json().catch(() => ({}))) as {
repair?: unknown; repair?: unknown;
@@ -23,12 +24,14 @@ export const POST = withAdmin(
sql?: unknown; sql?: unknown;
applySql?: unknown; applySql?: unknown;
repairFurniData?: unknown; repairFurniData?: unknown;
repairStructure?: unknown;
}; };
repair = body.repair === true; repair = body.repair === true;
repairNitros = body.repairNitros === true; repairNitros = body.repairNitros === true;
sql = body.sql === true; sql = body.sql === true;
applySql = body.applySql === true; applySql = body.applySql === true;
repairFurniData = body.repairFurniData === true; repairFurniData = body.repairFurniData === true;
repairStructure = body.repairStructure === true;
} catch { } catch {
/* no body */ /* no body */
} }
@@ -50,6 +53,7 @@ export const POST = withAdmin(
sql, sql,
applySql, applySql,
repairFurniData, repairFurniData,
repairStructure,
}); });
} catch (err) { } catch (err) {
send({ send({
+1 -1
View File
@@ -52,7 +52,7 @@ export default async function RootLayout({
const nonce = (await headers()).get("x-nonce") ?? undefined; const nonce = (await headers()).get("x-nonce") ?? undefined;
return ( return (
<html lang={locale} className="app"> <html lang={locale} className="app" suppressHydrationWarning>
<head> <head>
<meta name="theme-default-dark" content={String(defaultDark)} /> <meta name="theme-default-dark" content={String(defaultDark)} />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
+3 -5
View File
@@ -51,15 +51,13 @@ const schema = z
APP_KEY: z.string().optional(), APP_KEY: z.string().optional(),
// Mirrors Laravel config('habbo.site.convert_passwords') — enables // 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 CONVERT_PASSWORDS: z
.string() .string()
.optional() .optional()
.transform((v) => v === "true" || v === "1"), .transform((v) => v === "true" || v === "1"),
// Argon2id parameters — defaults match the old AtomCMS (Laravel) setup. // bcrypt cost factor used for new password hashes.
ARGON2_MEMORY_KB: z.coerce.number().int().positive().default(65_536), BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's // Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset. // when unset.
+15 -29
View File
@@ -1,9 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const mockEnv = vi.hoisted(() => ({ const mockEnv = vi.hoisted(() => ({
ARGON2_MEMORY_KB: 65_536, BCRYPT_COST: 12,
ARGON2_ITERATIONS: 4,
ARGON2_PARALLELISM: 1,
})); }));
vi.mock("@/env", () => ({ vi.mock("@/env", () => ({
@@ -28,16 +26,16 @@ describe("md5Hex", () => {
}); });
describe("hashPassword", () => { 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!"); const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$argon2id\$/); expect(h).toMatch(/^\$2[aby]\$/);
expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("s3cret!", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false); expect(await verifyPassword("wrong", h)).toBe(false);
}); });
}); });
describe("isArgon2idOf", () => { describe("isArgon2idOf", () => {
it("verifies an argon2id hash (AtomCMS/Laravel)", async () => { it("verifies a legacy argon2id hash (pre-migration accounts)", async () => {
const stored = const stored =
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0"; "$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
expect(await isArgon2idOf("test-password-123", stored)).toBe(true); expect(await isArgon2idOf("test-password-123", stored)).toBe(true);
@@ -47,7 +45,7 @@ describe("isArgon2idOf", () => {
}); });
describe("isBcryptOf", () => { describe("isBcryptOf", () => {
it("verifies a legacy bcrypt hash", async () => { it("verifies a bcrypt hash", async () => {
const { bcrypt } = await import("hash-wasm"); const { bcrypt } = await import("hash-wasm");
const { randomBytes } = await import("node:crypto"); const { randomBytes } = await import("node:crypto");
const stored = await bcrypt({ const stored = await bcrypt({
@@ -71,20 +69,20 @@ describe("isMd5Of", () => {
}); });
describe("verifyPassword", () => { describe("verifyPassword", () => {
it("verifies argon2id hashes", async () => { it("verifies bcrypt hashes", async () => {
const h = await hashPassword("hunter2"); const h = await hashPassword("hunter2");
expect(h).toMatch(/^\$argon2id\$/); expect(h).toMatch(/^\$2[aby]\$/);
expect(await verifyPassword("hunter2", h)).toBe(true); expect(await verifyPassword("hunter2", h)).toBe(true);
expect(await verifyPassword("nope", h)).toBe(false); expect(await verifyPassword("nope", h)).toBe(false);
}); });
}); });
describe("checkLogin", () => { 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 stored = await md5Hex("oldpass");
const res = await checkLogin("oldpass", stored, { convertPasswords: true }); const res = await checkLogin("oldpass", stored, { convertPasswords: true });
expect(res.valid).toBe(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( expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
true, true,
); );
@@ -99,37 +97,25 @@ describe("checkLogin", () => {
expect(res.upgradedHash).toBeUndefined(); expect(res.upgradedHash).toBeUndefined();
}); });
it("accepts an argon2id hash with no rehash", async () => { it("migrates a legacy argon2id hash to bcrypt", async () => {
const stored = const stored =
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0"; "$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
const res = await checkLogin("test-password-123", stored, { const res = await checkLogin("test-password-123", stored, {
convertPasswords: true, convertPasswords: true,
}); });
expect(res.valid).toBe(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 () => { it("accepts an existing bcrypt hash with no rehash", async () => {
// Generate a real bcrypt hash via hash-wasm and verify the upgrade path.
const { bcrypt } = await import("hash-wasm"); const { bcrypt } = await import("hash-wasm");
const { randomBytes } = await import("node:crypto");
const stored = await bcrypt({ const stored = await bcrypt({
password: "oldbcrypt", password: "modern",
salt: await import("node:crypto").then((c) => c.randomBytes(16)), salt: randomBytes(16),
costFactor: 10, costFactor: 10,
outputType: "encoded", 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 }); const res = await checkLogin("modern", stored, { convertPasswords: true });
expect(res.valid).toBe(true); expect(res.valid).toBe(true);
expect(res.upgradedHash).toBeUndefined(); expect(res.upgradedHash).toBeUndefined();
+22 -16
View File
@@ -1,16 +1,13 @@
import { randomBytes } from "node:crypto"; 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"; import { env } from "@/env";
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return await argon2id({ return await bcrypt({
password, password,
salt: randomBytes(16), salt: randomBytes(16),
parallelism: env.ARGON2_PARALLELISM, costFactor: env.BCRYPT_COST,
iterations: env.ARGON2_ITERATIONS,
memorySize: env.ARGON2_MEMORY_KB,
hashLength: 32,
outputType: "encoded", 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( export async function isArgon2idOf(
password: string, password: string,
stored: 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( export async function isBcryptOf(
password: string, password: string,
stored: string, stored: string,
@@ -58,9 +59,6 @@ export async function verifyPassword(
password: string, password: string,
stored: string, stored: string,
): Promise<boolean> { ): Promise<boolean> {
if (/^\$argon2id\$/.test(stored)) {
return isArgon2idOf(password, stored);
}
return isBcryptOf(password, stored); return isBcryptOf(password, stored);
} }
@@ -74,14 +72,22 @@ export async function checkLogin(
stored: string, stored: string,
opts: { convertPasswords: boolean }, opts: { convertPasswords: boolean },
): Promise<LoginCheck> { ): 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))) { if (opts.convertPasswords && (await isMd5Of(password, stored))) {
return { valid: true, upgradedHash: await hashPassword(password) }; 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) }; 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 path from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { fetchSourceFurnidata } from "@/lib/services/clone-import"; import { fetchSourceFurnidata } from "@/lib/services/clone-import";
import { listSources } from "@/lib/services/clone-sources"; 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 { import {
applyCatalogSql, applyCatalogSql,
generateCatalogSql, generateCatalogSql,
type MissingCatalogEntry, type MissingCatalogEntry,
repairDuplicateClassnames,
repairFurniData, repairFurniData,
repairOrphanedCatalog,
} from "./catalog-repair"; } from "./catalog-repair";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
import { repairMissingIcons } from "./repair-icons"; import { repairMissingIcons } from "./repair-icons";
@@ -25,6 +30,7 @@ export interface AuditEvent {
| "sql_ready" | "sql_ready"
| "sql_applied" | "sql_applied"
| "furnidata_repair_complete" | "furnidata_repair_complete"
| "structure_repair_complete"
| "batch_complete" | "batch_complete"
| "error"; | "error";
message?: string; message?: string;
@@ -41,6 +47,13 @@ export interface AuditEvent {
applied?: number; applied?: number;
sqlFailed?: number; sqlFailed?: number;
furniData?: { added: number; removedDuplicates: number }; furniData?: { added: number; removedDuplicates: number };
structure?: {
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
};
} }
export interface RepairStats { export interface RepairStats {
@@ -84,6 +97,11 @@ export interface AuditSummary {
catalogSqlApplied: number; catalogSqlApplied: number;
furniDataAdded: number; furniDataAdded: number;
furniDataDuplicatesRemoved: number; furniDataDuplicatesRemoved: number;
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
} }
export interface CatalogAuditOptions { export interface CatalogAuditOptions {
@@ -92,6 +110,7 @@ export interface CatalogAuditOptions {
sql?: boolean; sql?: boolean;
applySql?: boolean; applySql?: boolean;
repairFurniData?: boolean; repairFurniData?: boolean;
repairStructure?: boolean;
} }
export async function runCatalogAudit( export async function runCatalogAudit(
@@ -125,6 +144,27 @@ export async function runCatalogAudit(
return; 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…" }); onEvent?.({ type: "progress", message: "Loading items_base…" });
let items: Array<{ let items: Array<{
@@ -218,9 +258,11 @@ export async function runCatalogAudit(
itemId: item.id, itemId: item.id,
message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`, 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 // Badge items use .gif files, not .nitro bundles.
// entry for a furni without a bundle would be un-placeable in-game. if (
if (existsSync(path.join(nitroDir, `${item.item_name}.nitro`))) { !badgeSet.has(item.item_name) &&
nitroSet.has(`${item.item_name}.nitro`)
) {
missingCatalogEntries.push({ missingCatalogEntries.push({
classname: item.item_name, classname: item.item_name,
itemId: item.id, itemId: item.id,
@@ -289,8 +331,9 @@ export async function runCatalogAudit(
const missingNitroClassnames: string[] = []; const missingNitroClassnames: string[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
const nitroPath = path.join(nitroDir, `${item.item_name}.nitro`); // Badge items use .gif files, not .nitro bundles.
if (!existsSync(nitroPath)) { if (badgeSet.has(item.item_name)) continue;
if (!nitroSet.has(`${item.item_name}.nitro`)) {
missingNitroClassnames.push(item.item_name); missingNitroClassnames.push(item.item_name);
issues.push({ issues.push({
type: "missing_nitro", type: "missing_nitro",
@@ -315,8 +358,10 @@ export async function runCatalogAudit(
const missingIconClassnames: string[] = []; const missingIconClassnames: string[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
const iconPath = path.join(iconDir, `${item.item_name}_icon.png`); // Badge items use .gif icons (stored in album1584), not .png icon files.
if (!existsSync(iconPath)) { if (badgeSet.has(item.item_name)) continue;
const iconFileName = `${item.item_name}_icon.png`;
if (!iconSet.has(iconFileName)) {
missingIconClassnames.push(item.item_name); missingIconClassnames.push(item.item_name);
issues.push({ issues.push({
type: "missing_icon", type: "missing_icon",
@@ -367,7 +412,7 @@ export async function runCatalogAudit(
// and drop the ones the repair fixed from the issue list. // and drop the ones the repair fixed from the issue list.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingIconClassnames) { for (const classname of missingIconClassnames) {
if (!existsSync(path.join(iconDir, `${classname}_icon.png`))) { if (!iconSet.has(`${classname}_icon.png`)) {
stillMissing.push(classname); stillMissing.push(classname);
} }
} }
@@ -422,7 +467,7 @@ export async function runCatalogAudit(
// and drop the ones the repair fixed from the issue list. // and drop the ones the repair fixed from the issue list.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingNitroClassnames) { for (const classname of missingNitroClassnames) {
if (!existsSync(path.join(nitroDir, `${classname}.nitro`))) { if (!nitroSet.has(`${classname}.nitro`)) {
stillMissing.push(classname); 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 ──────────────────────────────── // ── Catalog SQL generation / apply ────────────────────────────────
let catalogSqlCount = 0; let catalogSqlCount = 0;
let catalogSqlApplied = 0; let catalogSqlApplied = 0;
@@ -510,26 +630,46 @@ export async function runCatalogAudit(
onEvent?.({ type: "progress", message: "Comparing with clone sources…" }); onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
const sources = await listSources(); const sources = await listSources();
for (const source of sources) { const sourceResults = await Promise.all(
try { sources
const entries = await fetchSourceFurnidata(source.furnidataUrl); .filter((s) => s.furnidataUrl)
for (const entry of entries) { .map(async (source) => {
if (!itemByName.has(entry.classname)) { try {
missingFromSources.push({ const entries = await fetchSourceFurnidata(source.furnidataUrl);
sourceName: source.name, return {
sourceId: source.id, source,
classname: entry.classname, entries,
itemName: entry.name, 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({ issues.push({
type: "source_fetch_failed", type: "source_fetch_failed",
severity: "error", severity: "error",
sourceName: source.name, sourceName: result.source.name,
message: `Failed to fetch furnidata from "${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, catalogSqlApplied,
furniDataAdded, furniDataAdded,
furniDataDuplicatesRemoved, furniDataDuplicatesRemoved,
orphanedRemoved,
orphanedCleaned,
duplicatesMerged,
duplicateRowsRemoved,
remappedReferences,
}; };
onEvent?.({ onEvent?.({
+199
View File
@@ -96,6 +96,205 @@ export async function applyCatalogSql(
return { applied, failed }; 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 { export interface FurniDataRepairResult {
added: number; added: number;
removedDuplicates: number; removedDuplicates: number;
+5 -1
View File
@@ -13,6 +13,7 @@ import {
ensureDirectories, ensureDirectories,
getOrCreateCategoryPage, getOrCreateCategoryPage,
} from "@/lib/services/furni-import"; } from "@/lib/services/furni-import";
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
@@ -87,7 +88,10 @@ export async function fetchSourceFurnidata(
): Promise<SourceFurni[]> { ): Promise<SourceFurni[]> {
const hit = cache.get(url); const hit = cache.get(url);
if (hit && now && now - hit.ts < TTL) return hit.list; 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}`); if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
let list: SourceFurni[]; let list: SourceFurni[];
try { try {
+28 -25
View File
@@ -11,47 +11,50 @@ export interface CloneSource {
const KEY = "clone_sources"; 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", id: "default-habbo",
name: "HabboAssets", name: "Habbo",
furnidataUrl: "", furnidataUrl:
"https://raw.githubusercontent.com/Izzxt/habbo-resource/master/gamedata/furnidata.json",
nitroBaseUrl: "", 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", id: "default-wibbo",
name: "Wibbo", name: "Wibbo",
furnidataUrl: "", furnidataUrl: "https://assets.wibbo.org/gamedata/FurnitureData.json",
nitroBaseUrl: "https://assets.wibbo.org/bundled/furniture", 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: "", 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[]> { export async function listSources(): Promise<CloneSource[]> {
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]"; const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
try { try {
const arr = JSON.parse(raw); const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : []; return Array.isArray(arr) && arr.length > 0 ? arr : DEFAULT_SOURCES;
} catch { } 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("exchange")) interactionType = "exchange";
else if (logicType.includes("habbowheel")) interactionType = "habbowheel"; else if (logicType.includes("habbowheel")) interactionType = "habbowheel";
else if (logicType.includes("soundmachine")) interactionType = "jukebox"; else if (logicType.includes("soundmachine")) interactionType = "jukebox";
else if (logicType.includes("badge")) interactionType = "badge";
if (interactionType === "default" && /^wf_/.test(classname)) { if (interactionType === "default" && /^wf_/.test(classname)) {
interactionType = classname; interactionType = classname;
@@ -72,7 +72,11 @@ describe("official Habbo furnidata cache", () => {
expect(fetchMock).toHaveBeenNthCalledWith( expect(fetchMock).toHaveBeenNthCalledWith(
2, 2,
"https://www.habbo.nl/gamedata/furnidata_json/1", "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"; } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
import type { OfficialHabboFurniEntry } from "@/types/furni"; import type { OfficialHabboFurniEntry } from "@/types/furni";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes 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}...`); console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
const res = await fetch(url, { const res = await fetch(url, {
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20000),
headers: { Accept: "application/json" }, headers: browserHeaders(),
}); });
if (!res.ok) { 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 { promises as fs } from "node:fs";
import { browserHeaders } from "./browser-headers";
export function validateSwfBytes(buffer: Buffer): boolean { export function validateSwfBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false; if (buffer.length < 8) return false;
const sig = buffer.toString("ascii", 0, 3); const sig = buffer.toString("ascii", 0, 3);
@@ -31,11 +33,9 @@ export async function downloadFile(
} }
const res = await fetch(url, { const res = await fetch(url, {
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
headers: { headers: browserHeaders("image", {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
Accept: "image/png,image/*,*/*;q=0.8", Accept: "image/png,image/*,*/*;q=0.8",
}, }),
}); });
if (!res.ok) { if (!res.ok) {
const deterministic = const deterministic =
+3
View File
@@ -1,3 +1,5 @@
import { browserHeaders } from "./browser-headers";
const EXTERNAL_VARIABLES_URL = const EXTERNAL_VARIABLES_URL =
"https://www.habbo.com/gamedata/external_variables/1"; "https://www.habbo.com/gamedata/external_variables/1";
const CACHE_TTL = 30 * 60 * 1000; 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; if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url;
const res = await fetch(EXTERNAL_VARIABLES_URL, { const res = await fetch(EXTERNAL_VARIABLES_URL, {
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20000),
headers: browserHeaders("document"),
}); });
if (!res.ok) if (!res.ok)
throw new Error(`external_variables fetch failed: ${res.status}`); throw new Error(`external_variables fetch failed: ${res.status}`);
+25 -2
View File
@@ -12,6 +12,7 @@ import {
import { import {
type FurniAssetWriteTargets, type FurniAssetWriteTargets,
getFurniAssetWriteTargets, getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs"; } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
@@ -140,6 +141,23 @@ export async function repairMissingIcons(
...targets.mirrorDirs.map((d) => d.nitroDir), ...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 }>; let items: Array<{ id: number; item_name: string }>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
@@ -158,10 +176,15 @@ export async function repairMissingIcons(
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { 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`; const fileName = `${classname}_icon.png`;
// A dir may already have the icon (e.g. the full gamedata) while the // Badge items store icons as badge_<code>_icon.png (the classname already
// webroot is missing it — treat the item as present when every target has it. // includes the `badge_` prefix, so fileName matches correctly).
const presentDirs = iconDirs.filter((dir) => const presentDirs = iconDirs.filter((dir) =>
existsSync(path.join(dir, fileName)), existsSync(path.join(dir, fileName)),
); );
+22
View File
@@ -11,6 +11,7 @@ import {
import { import {
type FurniAssetWriteTargets, type FurniAssetWriteTargets,
getFurniAssetWriteTargets, getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs"; } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
@@ -136,6 +137,23 @@ export async function repairMissingNitros(
...targets.mirrorDirs.map((d) => d.nitroDir), ...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 }>; let items: Array<{ id: number; item_name: string }>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
@@ -154,6 +172,10 @@ export async function repairMissingNitros(
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { 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 fileName = `${classname}.nitro`;
const presentDirs = nitroDirs.filter((dir) => 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 } }); const response = NextResponse.next({ request: { headers } });
if (token) { // HTML is never cached (browser/CDN/edge) so that after a deploy the page
response.headers.set( // always references the current build's chunks. Static assets are
"Cache-Control", // content-hashed + immutable and can be cached aggressively; a stale HTML
"private, no-cache, no-store, max-age=0, must-revalidate", // document would reference chunk URLs that no longer exist after a rebuild.
); response.headers.set(
} else { "Cache-Control",
response.headers.set( "private, no-cache, no-store, max-age=0, must-revalidate",
"Cache-Control", );
"public, max-age=60, s-maxage=300, stale-while-revalidate=300",
);
}
for (const [key, value] of Object.entries(SECURITY_HEADERS)) { for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(key, value); 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 # CONSTANTS
# ============================================================================= # =============================================================================
readonly SCRIPT_VERSION="8.0.0" readonly SCRIPT_VERSION="8.0.2"
readonly SCRIPT_BUILD="20260720" readonly SCRIPT_BUILD="20260803"
readonly SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly START_TIME=$(date +%s) readonly START_TIME=$(date +%s)
@@ -105,19 +105,24 @@ DB_USER="${NITRO_DB_USER:-${DB_USER:-root}}"
DB_PASS="${NITRO_DB_PASS:-${DB_PASS:-}}" DB_PASS="${NITRO_DB_PASS:-${DB_PASS:-}}"
EMULATOR_SERVICE="${NITRO_EMULATOR_SERVICE:-emulator}" EMULATOR_SERVICE="${NITRO_EMULATOR_SERVICE:-emulator}"
EMULATOR_DIR="${NITRO_EMULATOR_PATH:-/var/www/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}" GAMEDATA_CONF_DIR="${NITRO_GAMEDATA_DIR:-/var/www/Gamedata/config}"
NITRO_SRC_DIR="${NITRO_CLIENT_DIR:-/var/www/Nitro-V3/public/configuration}" 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_CLIENT="${NITRO_CLIENT_SRC:-/var/www/Nitro-V3}"
NITRO_RENDERER="${NITRO_RENDERER_SRC:-/var/www/Nitro_Render_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}" MIN_DISK_GB="${NITRO_MIN_DISK_GB:-5}"
HEALTH_RETRIES="${NITRO_HEALTH_RETRIES:-12}" HEALTH_RETRIES="${NITRO_HEALTH_RETRIES:-12}"
HEALTH_INTERVAL="${NITRO_HEALTH_INTERVAL:-5}" HEALTH_INTERVAL="${NITRO_HEALTH_INTERVAL:-5}"
NITRO_SSL_VERIFY="${NITRO_SSL_VERIFY:-OFF}" NITRO_SSL_VERIFY="${NITRO_SSL_VERIFY:-OFF}"
CONFIG_BACKUP_DIR="${SCRIPT_DIR}/storage/config-backups" CONFIG_BACKUP_DIR="${SCRIPT_DIR}/storage/config-backups"
mkdir -p "$CONFIG_BACKUP_DIR" 2>/dev/null || true mkdir -p "$CONFIG_BACKUP_DIR" 2>/dev/null || true
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
NITRO_LANG="${NITRO_LANG:-en}" NITRO_LANG="${NITRO_LANG:-en}"
MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=${NITRO_SSL_VERIFY}" 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 # GIT HELPERS
# ============================================================================= # =============================================================================
detect_branches() { detect_branches() {
local repos=("$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER") local repos=("$EMULATOR_REPO" "$NITRO_CLIENT" "$NITRO_RENDERER")
for repo in "${repos[@]}"; do for repo in "${repos[@]}"; do
[ -d "$repo/.git" ] || continue [ -d "$repo/.git" ] || continue
( (
@@ -876,10 +881,11 @@ detect_best_branch() {
local repo="$1" preferred="$2" local repo="$1" preferred="$2"
( (
cd "$repo" 2>/dev/null || { echo "main"; exit 0; } cd "$repo" 2>/dev/null || { echo "main"; exit 0; }
for v in "$preferred" "${preferred^}" "main" "master"; do local cur; cur=$(git branch --show-current 2>/dev/null || echo "")
git rev-parse --verify "$v" &>/dev/null && { echo "$v"; exit 0; } for v in "$preferred" "${preferred^}" "$cur" "main" "master"; do
[ -n "$v" ] && git rev-parse --verify "$v" &>/dev/null && { echo "$v"; exit 0; }
done done
echo "$(git branch --show-current 2>/dev/null || echo "main")" echo "${cur:-main}"
) )
} }
@@ -924,33 +930,36 @@ clean_node_modules() {
# ============================================================================= # =============================================================================
PARALLEL_PIDS=() PARALLEL_PIDS=()
PARALLEL_NAMES=()
PARALLEL_FAILED=() PARALLEL_FAILED=()
PARALLEL_RESULTS=() PARALLEL_RESULTS=()
parallel_run() { parallel_run() {
local name="$1"; shift local name="$1"; shift
PARALLEL_NAMES+=("$name")
( (
if "$@" >/dev/null 2>&1; then "$@" >/dev/null 2>&1
echo "__PARALLEL_OK__${name}"
else
echo "__PARALLEL_FAIL__${name}"
fi
) & ) &
PARALLEL_PIDS+=($!) PARALLEL_PIDS+=($!)
} }
parallel_wait() { 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_RESULTS=()
PARALLEL_FAILED=()
local i=0
for pid in "${PARALLEL_PIDS[@]}"; do for pid in "${PARALLEL_PIDS[@]}"; do
local line local name="${PARALLEL_NAMES[$i]}"
line=$(wait "$pid" 2>/dev/null; true) if wait "$pid" 2>/dev/null; then
PARALLEL_RESULTS+=("$line") PARALLEL_RESULTS+=("__PARALLEL_OK__${name}")
if [ "$line" = "__PARALLEL_FAIL__"* ]; then else
PARALLEL_FAILED+=("${line#__PARALLEL_FAIL__}") PARALLEL_RESULTS+=("__PARALLEL_FAIL__${name}")
PARALLEL_FAILED+=("$name")
fi fi
i=$((i+1))
done done
PARALLEL_PIDS=() PARALLEL_PIDS=()
PARALLEL_NAMES=()
} }
parallel_failed() { parallel_failed() {
@@ -987,6 +996,9 @@ validate_configs() {
local errors=0 local errors=0
for f in "$dir"/*.json; do for f in "$dir"/*.json; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
case "$(basename "$f")" in
*jsonc*|*.example*) continue ;;
esac
if ! validate_json "$f"; then if ! validate_json "$f"; then
warn "Invalid JSON: %s" "$(basename "$f")" warn "Invalid JSON: %s" "$(basename "$f")"
errors=$((errors + 1)) errors=$((errors + 1))
@@ -1003,7 +1015,7 @@ backup_binaries() {
BINARY_BACKUP_DIR="$CONFIG_BACKUP_DIR/binaries-$(date +%Y%m%d-%H%M%S)" BINARY_BACKUP_DIR="$CONFIG_BACKUP_DIR/binaries-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BINARY_BACKUP_DIR" 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 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 cp "$jar_dir"/Polaris-*-jar-with-dependencies.jar "$BINARY_BACKUP_DIR/" 2>/dev/null || true
ok "Emulator JAR backed up" 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}" 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 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" mkdir -p "$jar_dir"
cp "$BINARY_BACKUP_DIR"/Polaris-*-jar-with-dependencies.jar "$jar_dir/" 2>/dev/null || true cp "$BINARY_BACKUP_DIR"/Polaris-*-jar-with-dependencies.jar "$jar_dir/" 2>/dev/null || true
ok "Emulator JAR restored" ok "Emulator JAR restored"
@@ -1052,7 +1064,7 @@ preflight_check() {
ok "All commands available" 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" [ -d "$d" ] || die "Missing dir: $d"
done done
ok "All directories exist" ok "All directories exist"
@@ -1075,9 +1087,9 @@ preflight_check() {
# ============================================================================= # =============================================================================
update_emulator() { update_emulator() {
step 2 8 "Update & Build 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") HAD_UPDATES=true; UPDATED_REPOS+=("Emulator")
cd "$EMULATOR_DIR/Emulator" cd "$EMULATOR_MODULE"
backup_binaries backup_binaries
ROLLBACK_NEEDED=true ROLLBACK_NEEDED=true
spinner_start "Building emulator (parallel)..." spinner_start "Building emulator (parallel)..."
@@ -1118,11 +1130,22 @@ update_renderer() {
HAD_UPDATES=true; UPDATED_REPOS+=("Nitro_Render_V3") HAD_UPDATES=true; UPDATED_REPOS+=("Nitro_Render_V3")
cd "$NITRO_RENDERER" cd "$NITRO_RENDERER"
spinner_start "Installing deps..." 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" spinner_stop ok; ok "Renderer dependencies installed"
else else
info "Renderer: already up to date" info "Renderer: already up to date"
fi fi
fix_perms
return 0
} }
update_client() { update_client() {
@@ -1133,14 +1156,25 @@ update_client() {
backup_binaries backup_binaries
ROLLBACK_NEEDED=true ROLLBACK_NEEDED=true
spinner_start "Installing deps..." 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_stop ok
spinner_start "Building frontend..." spinner_start "Building frontend..."
if yarn build >> "$LOG_FILE" 2>&1; then if yarn build >> "$LOG_FILE" 2>&1; then
spinner_stop ok spinner_stop ok
else else
spinner_stop fail spinner_stop fail
tail -10 "$LOG_FILE" || true tail -30 "$LOG_FILE" || true
die "yarn build failed (see: %s)" "$LOG_FILE" die "yarn build failed (see: %s)" "$LOG_FILE"
fi fi
else else
@@ -1148,6 +1182,8 @@ update_client() {
fi fi
mkdir -p "$NITRO_CLIENT/dist/custom-themes" mkdir -p "$NITRO_CLIENT/dist/custom-themes"
[ ! -f "$NITRO_CLIENT/dist/custom-themes/index.json" ] && echo '{"themes":[]}' > "$NITRO_CLIENT/dist/custom-themes/index.json" [ ! -f "$NITRO_CLIENT/dist/custom-themes/index.json" ] && echo '{"themes":[]}' > "$NITRO_CLIENT/dist/custom-themes/index.json"
fix_perms
return 0
} }
sync_configs() { sync_configs() {
@@ -1178,6 +1214,12 @@ sync_configs() {
# Validate the generated configs # Validate the generated configs
validate_configs "$NITRO_SRC_DIR" 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 # Pre-flight: warn if NITRO_IMAGE_LIBRARY_URL looks wrong before applying configs
if [ -n "$NITRO_IMAGE_LIBRARY_URL" ]; then if [ -n "$NITRO_IMAGE_LIBRARY_URL" ]; then
case "$NITRO_IMAGE_LIBRARY_URL" in case "$NITRO_IMAGE_LIBRARY_URL" in
@@ -1199,6 +1241,11 @@ sync_configs() {
NITRO_FURNI_ASSET_ICON_URL="$NITRO_FURNI_ASSET_ICON_URL" \ NITRO_FURNI_ASSET_ICON_URL="$NITRO_FURNI_ASSET_ICON_URL" \
python3 -c ' python3 -c '
import json, os 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 = [ paths = [
os.path.join(os.environ.get("NITRO_SRC_DIR", ""), "renderer-config.json"), 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"), os.path.join(os.environ.get("NITRO_DIST_CONFIG_DIR", ""), "renderer-config.json"),
@@ -1208,6 +1255,7 @@ paths = [
for path in paths: for path in paths:
if not os.path.exists(path): continue if not os.path.exists(path): continue
with open(path, "r") as f: data = json.load(f) 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"]: 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))}") env_val = os.environ.get(f"NITRO_{key.upper().replace(chr(46), chr(95))}")
if env_val: data[key] = env_val 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))}") env_val = os.environ.get(f"NITRO_{key.upper().replace(chr(46), chr(95))}")
if env_val: data[key] = env_val if env_val: data[key] = env_val
if "gamedata.url" in data: if "gamedata.url" in data:
data["radio.url"] = data["gamedata.url"] + "/config/radio-stations.json5?t=%timestamp%" data["radio.url"] = data["gamedata.url"] + "/config/radio-stations.jsonc?t=%timestamp%"
data["soundboard.url"] = data["gamedata.url"] + "/config/soundboard-sounds.json5?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 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 (",", ": ")) 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)}") print(f" [OK] Fixed: {os.path.basename(path)}")
@@ -1227,6 +1275,7 @@ for path in paths:
if command -v redis-cli &>/dev/null; then if command -v redis-cli &>/dev/null; then
redis-cli FLUSHALL 2>/dev/null && ok "Redis cache flushed" || true redis-cli FLUSHALL 2>/dev/null && ok "Redis cache flushed" || true
fi fi
fix_perms
} }
do_cleanup() { do_cleanup() {
@@ -1243,16 +1292,31 @@ do_cleanup() {
yarn cache clean 2>/dev/null || true yarn cache clean 2>/dev/null || true
rm -rf /tmp/nitro-* /tmp/epicnext-* 2>/dev/null || true rm -rf /tmp/nitro-* /tmp/epicnext-* 2>/dev/null || true
# Clean old Maven build artifacts (older than 7 days) # 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" 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() { do_permissions() {
step 7 8 "Set Permissions" step 7 8 "Set Permissions"
for d in "$NITRO_CLIENT" "$NITRO_RENDERER" "$EMULATOR_DIR" "$GAMEDATA_CONF_DIR"; do fix_perms
[ -d "$d" ] && sudo chown -R www-data:www-data "$d" 2>/dev/null || true
done
ok "Permissions set"
} }
do_restart() { do_restart() {
@@ -1288,7 +1352,7 @@ do_restart() {
emulator_port_open() { emulator_port_open() {
local port="${NITRO_EMULATOR_PORT:-}" local port="${NITRO_EMULATOR_PORT:-}"
if [ -z "$port" ]; then 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 fi
if [ -z "$port" ]; then if [ -z "$port" ]; then
port=30000 port=30000
@@ -1382,7 +1446,7 @@ cmd_update() {
esac esac
show_summary show_summary
sudo chown -R www-data:www-data /var/www/Nitro-V3/ fix_perms
release_lock release_lock
} }
@@ -1466,7 +1530,7 @@ cmd_status() {
done; box_bottom 56 done; box_bottom 56
echo "" echo ""
box_top "GIT REPOS" 56 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##*:}" local nm="${re%%:*}" pa="${re##*:}"
[ -d "$pa/.git" ] || continue [ -d "$pa/.git" ] || continue
local br=$(cd "$pa" && git branch --show-current 2>/dev/null || echo "?") local br=$(cd "$pa" && git branch --show-current 2>/dev/null || echo "?")
@@ -1487,8 +1551,8 @@ cmd_status() {
cmd_health() { cmd_health() {
step 1 1 "Health Check" step 1 1 "Health Check"
local t=0 p=0 f=0 w=0 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")"; }; } 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)); "$@" &>/dev/null && { p=$((p+1)); ok "$(_t "$1")"; } || { w=$((w+1)); warn "$(_t "$1")"; }; } checkw() { t=$((t+1)); local lbl="$1"; shift; "$@" &>/dev/null && { p=$((p+1)); ok "$(_t "$lbl")"; } || { w=$((w+1)); warn "$(_t "$lbl")"; }; }
echo "" echo ""
echo -e " ${C_BOLD}$(_t "Commands:")${C_RESET}" echo -e " ${C_BOLD}$(_t "Commands:")${C_RESET}"
checkw "Git" command -v git; checkw "Node.js" command -v node; checkw "Yarn" command -v yarn 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 local cerr=0
for cf in "$NITRO_SRC_DIR"/*.json; do for cf in "$NITRO_SRC_DIR"/*.json; do
[ -f "$cf" ] || continue [ -f "$cf" ] || continue
case "$(basename "$cf")" in
*jsonc*|*.example*) continue ;;
esac
python3 -m json.tool "$cf" >/dev/null 2>&1 || cerr=$((cerr+1)) python3 -m json.tool "$cf" >/dev/null 2>&1 || cerr=$((cerr+1))
done done
[ "$cerr" -eq 0 ] && ok "$(_t "All JSON valid")" || warn "$(_t "JSON errors:") $cerr" [ "$cerr" -eq 0 ] && ok "$(_t "All JSON valid")" || warn "$(_t "JSON errors:") $cerr"
@@ -1528,7 +1595,7 @@ cmd_health() {
fi fi
done done
echo -e "\n ${C_BOLD}$(_t "Directories:")${C_RESET}" 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" 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}" 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 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")" step 2 6 "$(_t "Flyaway Repair")"
local repaired=0 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 [ -d "$repo/.git" ] || continue
local name; name=$(basename "$repo") local name; name=$(basename "$repo")
info "Checking %s..." "$name" info "Checking %s..." "$name"
@@ -1622,9 +1689,7 @@ cmd_flyaway_repair() {
# Fix permissions # Fix permissions
step 3 6 "$(_t "Set Permissions")" step 3 6 "$(_t "Set Permissions")"
for d in "$NITRO_CLIENT" "$NITRO_RENDERER" "$EMULATOR_DIR" "$GAMEDATA_CONF_DIR"; do fix_perms
[ -d "$d" ] && sudo chown -R www-data:www-data "$d" 2>/dev/null || true
done
ok "Permissions fixed" ok "Permissions fixed"
# Sync / validate configs # Sync / validate configs
@@ -1649,9 +1714,9 @@ cmd_flyaway_repair() {
fi fi
# Rebuild emulator if jar is missing # 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")" step 6 6 "$(_t "Rebuild Emulator")"
cd "$EMULATOR_DIR/Emulator" cd "$EMULATOR_MODULE"
backup_binaries backup_binaries
ROLLBACK_NEEDED=true ROLLBACK_NEEDED=true
spinner_start "$(_t "Building emulator (parallel)...")" spinner_start "$(_t "Building emulator (parallel)...")"
@@ -1672,7 +1737,7 @@ cmd_flyaway_repair() {
do_restart do_restart
show_summary show_summary
sudo chown -R www-data:www-data /var/www/Nitro-V3/ fix_perms
release_lock 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" ;; 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" ;; 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" ;; 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 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 -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 echo -en "\n $(_t "Select:") "; local c; read -rt 30 c
local repo="" 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; } [ ! -d "$repo/.git" ] && { warn "Not a git repo"; return; }
echo "" echo ""
local branch 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; } [[ "$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))]}" local bA="${branches[$((ba-1))]}" bB="${branches[$((bb-1))]}"
echo "" 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 [ -d "$repo/.git" ] || continue
echo -e " ${C_BOLD}$(basename "$repo"):${C_RESET}" echo -e " ${C_BOLD}$(basename "$repo"):${C_RESET}"
cd "$repo" 2>/dev/null || continue cd "$repo" 2>/dev/null || continue
@@ -1873,7 +1938,7 @@ cmd_schedule() {
cmd_stash() { cmd_stash() {
step 1 1 "Git Stashes" step 1 1 "Git Stashes"
local found=false 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 [ -d "$repo/.git" ] || continue
local list local list
list=$(cd "$repo" && git stash list 2>/dev/null) list=$(cd "$repo" && git stash list 2>/dev/null)
@@ -2103,6 +2168,14 @@ main() {
done done
[ -z "$cmd" ] && cmd="interactive" [ -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 if [ -z "${NITRO_SITE_URL:-}" ] && [ -n "${APP_URL:-}" ]; then NITRO_SITE_URL="$APP_URL"; fi
case "${NITRO_SITE_URL:-}" in case "${NITRO_SITE_URL:-}" in
https://*) NITRO_WS_PROTO="wss://" ; NITRO_DOMAIN="${NITRO_SITE_URL#https://}" ;; https://*) NITRO_WS_PROTO="wss://" ; NITRO_DOMAIN="${NITRO_SITE_URL#https://}" ;;