1 Commits
Author SHA1 Message Date
remco c9130ecd2d chore(deps): update All dependencies
CI / check (pull_request) Successful in 24s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-03 00:00:47 +00:00
24 changed files with 293 additions and 919 deletions

No files matched your search

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