Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084e090ba3 | ||
|
|
b1a1e5125c | ||
|
|
750973de38 | ||
|
|
1167a48344 | ||
|
|
40da24bd8c | ||
|
|
e97213e5d1 | ||
|
|
86d59195ec | ||
|
|
1cbb33a4e2 | ||
|
|
40a21ba767 | ||
|
|
cf89681576 | ||
|
|
2fba923a3a | ||
|
|
080dc34e23 | ||
|
|
450e7e8d00 | ||
|
|
30ed2b8ce2 | ||
|
|
6fc14b9b84 | ||
|
|
bee55e1fd4 | ||
|
|
613b7502e1 |
No files matched your search
+2
-3
@@ -32,9 +32,8 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
|||||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
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
@@ -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",
|
||||||
|
|||||||
Generated
+52
-52
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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) };
|
||||||
}
|
}
|
||||||
@@ -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?.({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
// Integration test against the live database. Vitest runs without a populated
|
||||||
|
// .env (SKIP_ENV_VALIDATION=1), so only execute when a DATABASE_URL is provided.
|
||||||
|
const hasDb =
|
||||||
|
typeof process.env.DATABASE_URL === "string" &&
|
||||||
|
process.env.DATABASE_URL.length > 0;
|
||||||
|
|
||||||
|
describe.skipIf(!hasDb)("repair icons", () => {
|
||||||
|
it("runs icon repair", async () => {
|
||||||
|
const { repairMissingIcons } = await import("@/lib/services/repair-icons");
|
||||||
|
|
||||||
|
const result = await repairMissingIcons((evt) => {
|
||||||
|
if (evt.type === "batch_complete") {
|
||||||
|
console.log("BATCH COMPLETE:", JSON.stringify(evt));
|
||||||
|
} else if (evt.type === "error") {
|
||||||
|
console.log("ERROR:", evt.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("Result:", JSON.stringify(result));
|
||||||
|
}, 600_000);
|
||||||
|
});
|
||||||
+125
-52
@@ -9,8 +9,8 @@ set -Eeo pipefail
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CONSTANTS
|
# 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://}" ;;
|
||||||
|
|||||||
Reference in new issue
Block a user