Merge branch 'main' into codex/housekeeping-foundation
This commit is contained in:
commit
2a36ba1956
68 files changed
+1059
-514
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- Add terms/age consent columns expected by the users schema (register flow).
|
||||
ALTER TABLE `users`
|
||||
ADD COLUMN `terms_accepted` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN `age_verified` TINYINT(1) NOT NULL DEFAULT 0;
|
||||
+33
-39
@@ -1,21 +1,14 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
function resolveDeploymentId(): string | undefined {
|
||||
const configuredId = process.env.NEXT_DEPLOYMENT_ID?.trim();
|
||||
if (configuredId) return configuredId;
|
||||
|
||||
const getGitCommit = () => {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return process.env.APP_VERSION?.trim() || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
@@ -30,16 +23,15 @@ const securityHeaders = [
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
},
|
||||
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
cacheComponents: true,
|
||||
deploymentId: resolveDeploymentId(),
|
||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
||||
reactStrictMode: true,
|
||||
compress: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
// The /admin/import hub was consolidated into the catalogue studio at
|
||||
// /admin/studio. Redirect the old routes so bookmarks/links don't 404.
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -100,7 +92,6 @@ const nextConfig: NextConfig = {
|
||||
];
|
||||
},
|
||||
|
||||
// Silence Turbopack warnings for the broad file patterns in src/lib.
|
||||
turbopack: {
|
||||
ignoreIssue: [
|
||||
{
|
||||
@@ -109,27 +100,16 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
},
|
||||
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
|
||||
// Compress responses with gzip/brotli.
|
||||
compress: true,
|
||||
|
||||
// Disable Next.js telemetry and browser sourcemaps in production.
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
||||
useTypeScriptCli: true,
|
||||
hideLogsAfterAbort: true,
|
||||
},
|
||||
|
||||
// Optimize images served through next/image with sharp → AVIF/WebP.
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
},
|
||||
|
||||
// Add caching headers for static assets
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -145,6 +125,28 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Nitro client payload (~2.8GB across swf/nitro-assets): without
|
||||
// caching every client open re-downloads hundreds of files.
|
||||
// Fresh for 7 days, then serve stale + revalidate in background
|
||||
// so asset updates still propagate without blocking players.
|
||||
source: "/swf/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=604800, stale-while-revalidate=2592000",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/nitro-assets/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=604800, stale-while-revalidate=2592000",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/images/(.*)",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
|
||||
@@ -153,14 +155,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via
|
||||
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const config = withNextIntl(nextConfig);
|
||||
|
||||
const withBA = withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
|
||||
export default withBA(config);
|
||||
export default withNextIntl(nextConfig);
|
||||
+2
-3
@@ -5,7 +5,7 @@
|
||||
"engines": {
|
||||
"node": ">=26.7.0 <27"
|
||||
},
|
||||
"packageManager": "[email protected]2.0",
|
||||
"packageManager": "[email protected]4.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -34,7 +34,6 @@
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@next/bundle-analyzer": "16.3.2",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -49,7 +48,7 @@
|
||||
"jszip": "3.10.1",
|
||||
"lenis": "1.3.26",
|
||||
"lucide-react": "1.33.0",
|
||||
"lzma": "2.3.2",
|
||||
"lzma": "^2.3.2",
|
||||
"motion": "13.1.1",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.23.4",
|
||||
|
||||
Generated
+1
-142
@@ -37,9 +37,6 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: 5.9.1
|
||||
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@next/bundle-analyzer':
|
||||
specifier: 16.3.2
|
||||
version: 16.3.2
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.14.10
|
||||
version: 3.14.10([email protected]([email protected]))([email protected])
|
||||
@@ -83,7 +80,7 @@ importers:
|
||||
specifier: 1.33.0
|
||||
version: 1.33.0([email protected])
|
||||
lzma:
|
||||
specifier: 2.3.2
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
motion:
|
||||
specifier: 13.1.1
|
||||
@@ -327,10 +324,6 @@ packages:
|
||||
'@borewit/[email protected]':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@discoveryjs/[email protected]':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@@ -825,9 +818,6 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/[email protected]':
|
||||
resolution: {integrity: sha512-1WSK3fjvlZzA3cpP6XsM9JW2/LDWWlldlYN5sL/h2kvAQCu2KeXLxNkqfI/0ac+ikOYFzWuuBpL9O0Cu3mrfww==}
|
||||
|
||||
'@next/[email protected]':
|
||||
resolution: {integrity: sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==}
|
||||
|
||||
@@ -1224,9 +1214,6 @@ packages:
|
||||
'@pinojs/[email protected]':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@polka/[email protected]':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
|
||||
@@ -1901,15 +1888,6 @@ packages:
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1967,10 +1945,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1996,9 +1970,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2118,9 +2089,6 @@ packages:
|
||||
sqlite3:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
@@ -2136,10 +2104,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
@@ -2209,10 +2173,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2262,10 +2222,6 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||
|
||||
@@ -2536,10 +2492,6 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -2633,10 +2585,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==}
|
||||
|
||||
@@ -2826,10 +2774,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -2943,10 +2887,6 @@ packages:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -3092,11 +3032,6 @@ packages:
|
||||
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3108,18 +3043,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
@@ -3217,8 +3140,6 @@ snapshots:
|
||||
|
||||
'@borewit/[email protected]': {}
|
||||
|
||||
'@discoveryjs/[email protected]': {}
|
||||
|
||||
'@dnd-kit/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -3526,13 +3447,6 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/[email protected]':
|
||||
dependencies:
|
||||
webpack-bundle-analyzer: 4.10.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@next/[email protected]': {}
|
||||
|
||||
'@next/[email protected]':
|
||||
@@ -3770,8 +3684,6 @@ snapshots:
|
||||
|
||||
'@pinojs/[email protected]': {}
|
||||
|
||||
'@polka/[email protected]': {}
|
||||
|
||||
'@radix-ui/[email protected]': {}
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||
@@ -4271,12 +4183,6 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
acorn: 8.18.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -4325,8 +4231,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4341,8 +4245,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -4370,8 +4272,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
mysql2: 3.23.4(@types/[email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -4412,8 +4312,6 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -4475,10 +4373,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
duplexer: 0.1.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4525,8 +4419,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4737,8 +4629,6 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
@@ -4839,8 +4729,6 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
@@ -5107,12 +4995,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -5198,8 +5080,6 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -5311,25 +5191,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@discoveryjs/json-ext': 0.5.7
|
||||
acorn: 8.18.0
|
||||
acorn-walk: 8.3.5
|
||||
commander: 7.2.0
|
||||
debounce: 1.2.1
|
||||
escape-string-regexp: 4.0.0
|
||||
gzip-size: 6.0.0
|
||||
html-escaper: 2.0.2
|
||||
is-plain-object: 5.1.0
|
||||
opener: 1.5.2
|
||||
picocolors: 1.1.1
|
||||
sirv: 2.0.4
|
||||
ws: 7.5.13
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
@@ -5339,8 +5200,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -97,7 +97,7 @@ async function main(): Promise<void> {
|
||||
const dbByClass = new Map(baseRows.map((r) => [r.itemName, r.id]));
|
||||
const dbById = new Map(baseRows.map((r) => [r.id, r.itemName]));
|
||||
let fdWrongId = 0;
|
||||
let fdIdConflict = 0; // id belongs to a different classname in DB
|
||||
let _fdIdConflict = 0; // id belongs to a different classname in DB
|
||||
let fdMissingInDb = 0;
|
||||
const fdExamplesWrong: string[] = [];
|
||||
for (const e of entries) {
|
||||
@@ -114,7 +114,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (dbById.get(e.id) !== e.classname) fdIdConflict++;
|
||||
if (dbById.get(e.id) !== e.classname) _fdIdConflict++;
|
||||
}
|
||||
console.log(
|
||||
` furnidata entries whose id != items_base.id for same classname: ${fdWrongId}`,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Syncs the Nitro/Octane runtime config URLs from environment variables into the
|
||||
// renderer-config.json / renderer-config.jsonc / ui-config.json files.
|
||||
//
|
||||
// Uses jsonc-parser so JSONC (with // and /* */ comments) is handled safely —
|
||||
// unlike a naive JSON.parse/json.load this never corrupts URLs that contain
|
||||
// "https://". After this runs the files are written back as strict JSON (which
|
||||
// is still valid JSONC), so downstream validation passes.
|
||||
//
|
||||
// Env:
|
||||
// NITRO_SRC_DIR e.g. /var/www/Octane/public/configuration
|
||||
// NITRO_DIST_CONFIG_DIR e.g. /var/www/Octane/dist/configuration
|
||||
// NITRO_GAMEDATA_CONF_DIR e.g. /var/www/Gamedata/config
|
||||
// NITRO_IMAGE_LIBRARY_URL, NITRO_HOF_FURNITURE_URL, NITRO_API_URL,
|
||||
// NITRO_SOCKET_URL, NITRO_GAMEDATA_URL, NITRO_ASSET_URL,
|
||||
// NITRO_FURNI_ASSET_ICON_URL
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { parse, modify } = require("jsonc-parser");
|
||||
|
||||
const FILES = [
|
||||
"renderer-config.json",
|
||||
"renderer-config.jsonc",
|
||||
"ui-config.json",
|
||||
];
|
||||
|
||||
function env(name) {
|
||||
return process.env[name]?.length ? process.env[name] : null;
|
||||
}
|
||||
|
||||
function applyOverrides(data) {
|
||||
const image = env("NITRO_IMAGE_LIBRARY_URL");
|
||||
const hof = env("NITRO_HOF_FURNITURE_URL");
|
||||
const api = env("NITRO_API_URL");
|
||||
const socket = env("NITRO_SOCKET_URL");
|
||||
const gamedata = env("NITRO_GAMEDATA_URL");
|
||||
const asset = env("NITRO_ASSET_URL");
|
||||
const icon = env("NITRO_FURNI_ASSET_ICON_URL");
|
||||
|
||||
const set = (key, value) => {
|
||||
if (value && !(key in data)) data[key] = value;
|
||||
if (value) data[key] = value;
|
||||
};
|
||||
|
||||
set("image.library.url", image);
|
||||
set("hof.furni.url", hof);
|
||||
set("api.url", api);
|
||||
set("socket.url", socket);
|
||||
set("gamedata.url", gamedata);
|
||||
set("asset.url", asset);
|
||||
set("furni.asset.icon.url", icon);
|
||||
|
||||
if (gamedata) {
|
||||
data["radio.url"] = `${gamedata}/config/radio-stations.jsonc?t=%timestamp%`;
|
||||
data["soundboard.url"] =
|
||||
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
|
||||
}
|
||||
if ("show.google.ads" in data) data["show.google.ads"] = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
const dirs = [
|
||||
env("NITRO_SRC_DIR"),
|
||||
env("NITRO_DIST_CONFIG_DIR"),
|
||||
env("NITRO_GAMEDATA_CONF_DIR"),
|
||||
].filter(Boolean);
|
||||
|
||||
let changed = 0;
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of FILES) {
|
||||
const full = path.join(dir, file);
|
||||
if (!fs.existsSync(full)) continue;
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(full, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = parse(content, [], {
|
||||
allowTrailingComma: true,
|
||||
allowEmptyContent: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[sync-nitro-urls] parse error in ${full}: ${e}`);
|
||||
continue;
|
||||
}
|
||||
if (!data || typeof data !== "object") continue;
|
||||
const before = JSON.stringify(data);
|
||||
const updated = applyOverrides(data);
|
||||
if (JSON.stringify(updated) === before) continue;
|
||||
try {
|
||||
fs.writeFileSync(full, `${JSON.stringify(updated, null, 4)}\n`);
|
||||
changed++;
|
||||
console.log(` [OK] Synced URLs: ${file} (${dir})`);
|
||||
} catch (e) {
|
||||
console.error(`[sync-nitro-urls] write error ${full}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` [OK] URL sync complete (${changed} file(s) updated)`);
|
||||
process.exit(0);
|
||||
+55
-17
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { after } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { sendVerification } from "@/actions/email-verify";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { invalidateKey } from "@/lib/cached-db";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -29,16 +30,23 @@ const registerSchema = z.object({
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit"),
|
||||
passwordConfirmation: z.string(),
|
||||
look: z.string().optional(),
|
||||
});
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
_prevState: string | null,
|
||||
_prevState: RegisterState,
|
||||
formData: FormData,
|
||||
): Promise<string | null> {
|
||||
): Promise<RegisterState> {
|
||||
const fail = (error: string): RegisterState => ({ error, ok: false });
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -48,6 +56,9 @@ export async function register(
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
password: String(formData.get("password") ?? "").normalize("NFC"),
|
||||
passwordConfirmation: String(
|
||||
formData.get("password_confirmation") ?? "",
|
||||
).normalize("NFC"),
|
||||
look:
|
||||
String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -57,7 +68,11 @@ export async function register(
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
|
||||
}
|
||||
|
||||
if (parsed.data.password !== parsed.data.passwordConfirmation) {
|
||||
return fail("Passwords do not match");
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
@@ -66,7 +81,9 @@ export async function register(
|
||||
|
||||
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return "Too many sign-up attempts. Please wait a few minutes and try again.";
|
||||
return fail(
|
||||
"Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
|
||||
@@ -74,18 +91,18 @@ export async function register(
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip)))
|
||||
return "Captcha verification failed. Please try again.";
|
||||
return fail("Captcha verification failed. Please try again.");
|
||||
}
|
||||
|
||||
// Terms acceptance check.
|
||||
if (!raw.termsAccepted)
|
||||
return "You must accept the terms and conditions to register.";
|
||||
return fail("You must accept the terms and conditions to register.");
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return (
|
||||
return fail(
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed."
|
||||
"Registrations from VPN/proxy connections are not allowed.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +115,9 @@ export async function register(
|
||||
.where(eq(User.ipRegister, ip))
|
||||
.catch(() => [{ total: 0 }]);
|
||||
if (Number(row?.total ?? 0) >= max)
|
||||
return "You have reached the maximum number of accounts for your connection.";
|
||||
return fail(
|
||||
"You have reached the maximum number of accounts for your connection.",
|
||||
);
|
||||
}
|
||||
|
||||
// Uniqueness check.
|
||||
@@ -108,10 +127,10 @@ export async function register(
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (existing) return "That username is already taken";
|
||||
if (existing) return fail("That username is already taken");
|
||||
} catch {
|
||||
logger.warn("Username uniqueness check failed during registration");
|
||||
return "Registration is temporarily unavailable";
|
||||
return fail("Registration is temporarily unavailable");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -124,19 +143,38 @@ export async function register(
|
||||
ipRegister: ip,
|
||||
ipCurrent: ip,
|
||||
look,
|
||||
termsAccepted: raw.termsAccepted,
|
||||
});
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
return fail("That username is already taken");
|
||||
}
|
||||
logger.error("Account creation failed", {
|
||||
code,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return fail(
|
||||
"Could not create the account. Please try again or contact staff.",
|
||||
);
|
||||
}
|
||||
|
||||
// The login lookup is cached for 15s — drop any stale entry so the
|
||||
// immediate auto sign-in sees the fresh row.
|
||||
await invalidateKey(`login:user:${username}`);
|
||||
|
||||
// Verification email must never block the sign-up response — it is sent
|
||||
// after the response is flushed (no-op when mail is unconfigured).
|
||||
if (hasEmail) {
|
||||
after(async () => {
|
||||
try {
|
||||
await sendVerification(mail);
|
||||
} catch {
|
||||
logger.warn("Failed to send verification email after registration");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.warn("Account creation failed");
|
||||
return "Could not create the account (is the username unique?)";
|
||||
});
|
||||
}
|
||||
|
||||
redirect("/login?registered=1");
|
||||
// Client auto signs in with these credentials and navigates to /me.
|
||||
return { error: null, ok: true };
|
||||
}
|
||||
+47
-63
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, gt, or } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
UserReferrals,
|
||||
UsersBadges,
|
||||
UsersSettings,
|
||||
WebsiteLoginLogs,
|
||||
} from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
@@ -58,10 +57,7 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
};
|
||||
|
||||
function getErrorMessage(error: string): string {
|
||||
if (error === "not_enough") return ERROR_MESSAGES.not_enough;
|
||||
if (error === "no_referrals") return ERROR_MESSAGES.no_referrals;
|
||||
if (error === "bad_config") return ERROR_MESSAGES.bad_config;
|
||||
return ERROR_MESSAGES.error;
|
||||
return ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error;
|
||||
}
|
||||
|
||||
export default async function MePage({
|
||||
@@ -90,10 +86,11 @@ export default async function MePage({
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
lastWebLoginRows,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -139,13 +136,6 @@ export default async function MePage({
|
||||
.where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0)))
|
||||
.orderBy(asc(UsersBadges.slotId))
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ createdAt: WebsiteLoginLogs.createdAt })
|
||||
.from(WebsiteLoginLogs)
|
||||
.where(eq(WebsiteLoginLogs.userId, userId))
|
||||
.orderBy(desc(WebsiteLoginLogs.id))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
achievementScore: UsersSettings.achievementScore,
|
||||
@@ -170,18 +160,51 @@ export default async function MePage({
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => [{ value: 0 }]),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
.where(eq(UserReferrals.userId, userId))
|
||||
.orderBy(desc(UserReferrals.id))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
motto: User.motto,
|
||||
online: User.online,
|
||||
})
|
||||
.from(MessengerFriendships)
|
||||
.innerJoin(
|
||||
User,
|
||||
or(
|
||||
and(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(User.id, MessengerFriendships.userTwoId),
|
||||
),
|
||||
and(
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
eq(User.id, MessengerFriendships.userOneId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => []),
|
||||
]);
|
||||
|
||||
const user = userRows[0] ?? null;
|
||||
const lastWebLogin = lastWebLoginRows[0] ?? null;
|
||||
if (!user) throw new Error("user-not-found");
|
||||
|
||||
const userSettings = userSettingsRows[0] ?? null;
|
||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
||||
|
||||
void lastWebLogin;
|
||||
|
||||
if (!user) throw new Error("user-not-found");
|
||||
|
||||
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5;
|
||||
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||
const rewardCurrency = (
|
||||
@@ -192,53 +215,12 @@ export default async function MePage({
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const [referrals] = await db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
.where(eq(UserReferrals.userId, userId))
|
||||
.orderBy(desc(UserReferrals.id))
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
const referralTotal = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
const referralTotal = referralsRows[0]
|
||||
? Number(referralsRows[0].referralsTotal)
|
||||
: 0;
|
||||
const canClaim = referralTotal >= needed;
|
||||
const remaining = Math.max(0, needed - referralTotal);
|
||||
|
||||
const friendships = await db
|
||||
.select({
|
||||
userOneId: MessengerFriendships.userOneId,
|
||||
userTwoId: MessengerFriendships.userTwoId,
|
||||
})
|
||||
.from(MessengerFriendships)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
const friendIds = Array.from(
|
||||
new Set(
|
||||
friendships
|
||||
.map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId))
|
||||
.filter((id) => id && id !== userId),
|
||||
),
|
||||
);
|
||||
|
||||
const friends = friendIds.length
|
||||
? await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
motto: User.motto,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(inArray(User.id, friendIds))
|
||||
.catch(() => [])
|
||||
: [];
|
||||
|
||||
const onlineFriends = friends.filter((f) => f.online === "1");
|
||||
|
||||
const registered = new Date(user.accountCreated * 1000)
|
||||
@@ -255,6 +237,7 @@ export default async function MePage({
|
||||
const avatarFull = avatarImageUrl(user.look, {
|
||||
direction: 2,
|
||||
});
|
||||
|
||||
content = (
|
||||
<div className="flex flex-col gap-8 pb-8">
|
||||
{claimed ? (
|
||||
@@ -298,6 +281,7 @@ export default async function MePage({
|
||||
/>
|
||||
<Link
|
||||
href="/client"
|
||||
prefetch={false}
|
||||
className="btn-shine flex items-center justify-center gap-1.5 rounded-xl border py-2 px-4 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
|
||||
@@ -33,18 +33,20 @@ export default async function RegisterPage() {
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
cached("register_online_users", 10_000, () =>
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
.limit(8),
|
||||
).catch(() => []),
|
||||
cached("register_latest_users", 30_000, () =>
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
.limit(8),
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,7 +34,13 @@ function ToolbarBtn({
|
||||
} as const;
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={cls} style={style} title={title}>
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
className={cls}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
@@ -377,9 +383,12 @@ export function ClientView({
|
||||
<iframe
|
||||
id="nitro"
|
||||
src={clientSrc}
|
||||
className="absolute inset-0 w-full h-full border-0 m-0 p-0 overflow-hidden"
|
||||
className={`absolute inset-0 w-full h-full border-0 m-0 p-0 overflow-hidden ${
|
||||
dragging ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
title={hotelName}
|
||||
allow="autoplay; gamepad"
|
||||
allow="autoplay; gamepad; fullscreen"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,15 +21,19 @@ export default async function ClientPage() {
|
||||
]);
|
||||
|
||||
const ip = (await headers()).get("x-real-client-ip") ?? "0.0.0.0";
|
||||
const ticket = await issueSsoTicket(userId, hotelName, ip);
|
||||
|
||||
const onlineCount = await cached("online_count", 10_000, async () => {
|
||||
// Ticket write and online count run in parallel — the client page should
|
||||
// render as fast as possible since the player is waiting for the game.
|
||||
const [ticket, onlineCount] = await Promise.all([
|
||||
issueSsoTicket(userId, hotelName, ip),
|
||||
cached("online_count", 10_000, async () => {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"));
|
||||
return row?.total ?? 0;
|
||||
}).catch(() => 0);
|
||||
}).catch(() => 0),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ClientView
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ const pixelify = Pixelify_Sans({
|
||||
// enforceSiteAccess, DB-backed site settings) to render the <html> element,
|
||||
// which cannot be wrapped in <Suspense>. Child pages inherit this opt-out,
|
||||
// so leaf pages that only access cached/DB data stay instant.
|
||||
export const instant = false;
|
||||
// export const instant = false;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Script from "next/script";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useState } from "react";
|
||||
import { register } from "@/actions/register";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { type RegisterState, register } from "@/actions/register";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
interface RegisterFormProps {
|
||||
hotelName: string;
|
||||
@@ -40,13 +42,56 @@ export function RegisterForm({
|
||||
}: RegisterFormProps) {
|
||||
const t = useTranslations("pages.register");
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
const [serverError, formAction, isPending] = useActionState(register, null);
|
||||
const [state, formAction, isPending] = useActionState<
|
||||
RegisterState,
|
||||
FormData
|
||||
>(register, { error: null, ok: false });
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const credentialsRef = useRef<{ username: string; password: string } | null>(
|
||||
null,
|
||||
);
|
||||
const autoLoginStartedRef = useRef(false);
|
||||
const strength = passwordStrength(password);
|
||||
|
||||
// Successful sign-up: sign in with the submitted credentials and go
|
||||
// straight to /me. If the automatic sign-in is refused (e.g. email
|
||||
// verification required), fall back to the regular login page.
|
||||
useEffect(() => {
|
||||
if (!state.ok || autoLoginStartedRef.current) return;
|
||||
autoLoginStartedRef.current = true;
|
||||
|
||||
const creds = credentialsRef.current;
|
||||
if (!creds) {
|
||||
window.location.href = "/login?registered=1";
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSigningIn(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await signInWithTransientRetry(() =>
|
||||
signIn("credentials", { ...creds, code: "", redirect: false }),
|
||||
);
|
||||
if (!cancelled && res && !res.error) {
|
||||
window.location.href = "/me";
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to fallback below */
|
||||
}
|
||||
if (!cancelled) window.location.href = "/login?registered=1";
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [state.ok]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[800px]">
|
||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
||||
@@ -112,7 +157,13 @@ export function RegisterForm({
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
action={formAction}
|
||||
action={(fd) => {
|
||||
credentialsRef.current = {
|
||||
username: String(fd.get("username") ?? "").trim(),
|
||||
password: String(fd.get("password") ?? ""),
|
||||
};
|
||||
formAction(fd);
|
||||
}}
|
||||
className="p-6 flex flex-col gap-6"
|
||||
style={{
|
||||
backgroundColor:
|
||||
@@ -120,12 +171,12 @@ export function RegisterForm({
|
||||
borderRadius: "0 0 12px 12px",
|
||||
}}
|
||||
>
|
||||
{(error || serverError) && (
|
||||
{(error || state.error) && (
|
||||
<div
|
||||
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-white"
|
||||
style={{ backgroundColor: "var(--color-danger)" }}
|
||||
>
|
||||
{error || serverError}
|
||||
{error || state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -301,14 +352,7 @@ export function RegisterForm({
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="font-medium text-sm text-[var(--color-text-readable)]"
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setTermsAccepted(!termsAccepted);
|
||||
}
|
||||
}}
|
||||
className="font-medium text-sm text-[var(--color-text-readable)] cursor-pointer"
|
||||
>
|
||||
{t("termsAccept", { hotel: hotelName })}
|
||||
</label>
|
||||
@@ -332,7 +376,7 @@ export function RegisterForm({
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
disabled={isPending || signingIn}
|
||||
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
@@ -341,7 +385,7 @@ export function RegisterForm({
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
{isPending || signingIn ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
@@ -364,7 +408,7 @@ export function RegisterForm({
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{t("creatingAccount")}
|
||||
{signingIn ? t("redirecting") : t("creatingAccount")}
|
||||
</span>
|
||||
) : (
|
||||
t("createAccount")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getRuntimeResolve } from "@/lib/utils/runtime-path";
|
||||
|
||||
/**
|
||||
* Best-effort local file delete for camera photos hosted under /public.
|
||||
@@ -25,8 +26,11 @@ export async function tryRemoveLocalPhotoFile(url: string): Promise<boolean> {
|
||||
if (!pathname.startsWith("/")) return false;
|
||||
const rel = pathname.replace(/^\/+/, "");
|
||||
if (!rel || rel.includes("..")) return false;
|
||||
const publicRoot = path.resolve(process.cwd(), "public");
|
||||
const full = path.resolve(publicRoot, rel);
|
||||
const publicRoot = /*turbopackIgnore: true*/ getRuntimeResolve(
|
||||
process.cwd(),
|
||||
"public",
|
||||
);
|
||||
const full = getRuntimeResolve(publicRoot, rel);
|
||||
if (!full.startsWith(publicRoot + path.sep) && full !== publicRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import path from "node:path";
|
||||
import { getRuntimeResolve } from "@/lib/utils/runtime-path";
|
||||
|
||||
/**
|
||||
* Persistent media root. Lives outside `public/` and outside Git so uploads
|
||||
* survive rebuilds, redeploys and `git clean`. Served through /api/media.
|
||||
*/
|
||||
export const MEDIA_ROOT = path.resolve(process.cwd(), "storage", "media");
|
||||
export const MEDIA_ROOT = getRuntimeResolve(process.cwd(), "storage", "media");
|
||||
|
||||
export function resolveMediaPath(...segments: string[]): string {
|
||||
const target = path.resolve(MEDIA_ROOT, ...segments);
|
||||
const target = getRuntimeResolve(MEDIA_ROOT, ...segments);
|
||||
if (target !== MEDIA_ROOT && !target.startsWith(MEDIA_ROOT + path.sep)) {
|
||||
throw new Error("Invalid media path");
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { catalogueAssetDirectories } from "@/lib/services/catalog-assets";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
describe("catalogue asset directories", () => {
|
||||
it("prefers the configured Gamedata catalogue and retains the bundled fallback", () => {
|
||||
const cwd = path.join("test", "cms");
|
||||
const gamedataRoot = path.join("test", "Gamedata");
|
||||
const cwd = getRuntimePath("test", "cms");
|
||||
const gamedataRoot = getRuntimePath("test", "Gamedata");
|
||||
|
||||
expect(catalogueAssetDirectories(gamedataRoot, cwd)).toEqual([
|
||||
path.join(gamedataRoot, "c_images", "catalogue"),
|
||||
path.join(cwd, "public", "swf", "c_images", "catalogue"),
|
||||
getRuntimePath(gamedataRoot, "c_images", "catalogue"),
|
||||
getRuntimePath(cwd, "public", "swf", "c_images", "catalogue"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses only the bundled catalogue when no Gamedata root is configured", () => {
|
||||
const cwd = path.join("test", "cms");
|
||||
const cwd = getRuntimePath("test", "cms");
|
||||
|
||||
expect(catalogueAssetDirectories("", cwd)).toEqual([
|
||||
path.join(cwd, "public", "swf", "c_images", "catalogue"),
|
||||
getRuntimePath(cwd, "public", "swf", "c_images", "catalogue"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
export function catalogueAssetDirectories(
|
||||
gamedataRoot: string,
|
||||
cwd = process.cwd(),
|
||||
): string[] {
|
||||
const bundled = path.join(cwd, "public", "swf", "c_images", "catalogue");
|
||||
const bundled = getRuntimePath(cwd, "public", "swf", "c_images", "catalogue");
|
||||
if (!gamedataRoot.trim()) return [bundled];
|
||||
|
||||
return [path.join(gamedataRoot, "c_images", "catalogue"), bundled];
|
||||
return [getRuntimePath(gamedataRoot, "c_images", "catalogue"), bundled];
|
||||
}
|
||||
|
||||
export async function readCatalogueAssetFiles(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { fetchSourceFurnidata } from "@/lib/services/clone-import";
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
getFurniAssetDirs,
|
||||
getGamedataRoot,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import {
|
||||
applyCatalogSql,
|
||||
generateCatalogSql,
|
||||
@@ -182,7 +182,7 @@ export async function runCatalogAudit(
|
||||
try {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) return [] as string[];
|
||||
return await readdir(path.join(gamedataRoot, "album1584")).catch(
|
||||
return await readdir(getRuntimePath(gamedataRoot, "album1584")).catch(
|
||||
() => [] as string[],
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// FurnitureData.json is only ever read (a copy is made first).
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { db } from "@/lib/db";
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
classifyCatalogItem,
|
||||
NO_CATALOG_KINDS,
|
||||
} from "@/lib/services/item-kind";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
const { getGamedataRootMock, getNitroFilesRootMock, settingGetMock } =
|
||||
vi.hoisted(() => ({
|
||||
@@ -59,9 +59,11 @@ let tmpRoot: string;
|
||||
let gamedataDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "furni-repair-rehearsal-"));
|
||||
gamedataDir = path.join(tmpRoot, "Gamedata");
|
||||
await fs.mkdir(path.join(gamedataDir, "config"), { recursive: true });
|
||||
tmpRoot = await fs.mkdtemp(
|
||||
getRuntimePath(os.tmpdir(), "furni-repair-rehearsal-"),
|
||||
);
|
||||
gamedataDir = getRuntimePath(tmpRoot, "Gamedata");
|
||||
await fs.mkdir(getRuntimePath(gamedataDir, "config"), { recursive: true });
|
||||
getGamedataRootMock.mockResolvedValue(gamedataDir);
|
||||
getNitroFilesRootMock.mockResolvedValue("");
|
||||
settingGetMock.mockImplementation(
|
||||
@@ -75,7 +77,7 @@ beforeAll(async () => {
|
||||
try {
|
||||
await fs.copyFile(
|
||||
REAL_FURNIDATA,
|
||||
path.join(gamedataDir, "config", "FurnitureData.json"),
|
||||
getRuntimePath(gamedataDir, "config", "FurnitureData.json"),
|
||||
);
|
||||
} catch {
|
||||
// missing copy — repair still runs against an empty file
|
||||
@@ -242,7 +244,7 @@ describeGuard("catalog studio live repair rehearsal (sandbox)", () => {
|
||||
byName.set(r.item_name, (byName.get(r.item_name) ?? 0) + 1);
|
||||
const finalFd = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(gamedataDir, "config", "FurnitureData.json"),
|
||||
getRuntimePath(gamedataDir, "config", "FurnitureData.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, { furnitype: Array<Record<string, unknown>> }>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
export interface SourceFurni {
|
||||
id: number;
|
||||
@@ -287,11 +287,11 @@ export async function cloneSingleFurni(params: {
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
const nitroPath = path.join(
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
);
|
||||
const iconPath = path.join(
|
||||
const iconPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${classname}_icon.png`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
listOfficialEffects,
|
||||
resolveEffectSwfUrl,
|
||||
@@ -13,17 +12,18 @@ import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import type {
|
||||
EffectListItem,
|
||||
EffectMapEntry,
|
||||
ImportEffectResult,
|
||||
} from "@/types/effects";
|
||||
|
||||
const EFFECT_NITRO_DIR_FALLBACK = path.join(
|
||||
const EFFECT_NITRO_DIR_FALLBACK = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/effect",
|
||||
);
|
||||
const EFFECT_SWF_DIR_FALLBACK = path.join(
|
||||
const EFFECT_SWF_DIR_FALLBACK = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/gordon",
|
||||
);
|
||||
@@ -35,7 +35,10 @@ async function getEffectNitroDir(): Promise<string> {
|
||||
if (configured) return configured;
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot)
|
||||
return path.join(/*turbopackIgnore: true*/ gamedataRoot, "bundled/effect");
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ gamedataRoot,
|
||||
"bundled/effect",
|
||||
);
|
||||
return EFFECT_NITRO_DIR_FALLBACK;
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ export async function ensureEffectDirs(): Promise<void> {
|
||||
}
|
||||
|
||||
async function nitroPathFor(lib: string): Promise<string> {
|
||||
return path.join(
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ await getEffectNitroDir(),
|
||||
`${lib}.nitro`,
|
||||
);
|
||||
@@ -82,7 +85,7 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Download SWF
|
||||
onProgress?.("downloading");
|
||||
const swfPath = path.join(
|
||||
const swfPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ await getEffectSwfDir(),
|
||||
`${lib}.swf`,
|
||||
);
|
||||
@@ -167,7 +170,7 @@ export async function getEffectList(search: string): Promise<EffectListItem[]> {
|
||||
...e,
|
||||
present: localByLib.has(e.lib),
|
||||
nitroExists: existsSync(
|
||||
/*turbopackIgnore: true*/ path.join(nitroDir, `${e.lib}.nitro`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${e.lib}.nitro`),
|
||||
),
|
||||
}));
|
||||
return term
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import type { EffectMapEntry } from "@/types/effects";
|
||||
import {
|
||||
__setEffectMapPathForTests,
|
||||
@@ -26,8 +26,8 @@ const ENTRY: EffectMapEntry = {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "effectmap-"));
|
||||
file = path.join(dir, "EffectMap.json");
|
||||
dir = await fs.mkdtemp(getRuntimePath(os.tmpdir(), "effectmap-"));
|
||||
file = getRuntimePath(dir, "EffectMap.json");
|
||||
await fs.writeFile(file, JSON.stringify({ effects: [ENTRY] }));
|
||||
__setEffectMapPathForTests(file);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
|
||||
import { listFigureLibraries } from "@/lib/services/figuremap";
|
||||
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
export interface FigureListItem {
|
||||
lib: string;
|
||||
@@ -24,11 +24,11 @@ export interface ImportFigureResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const FIGURE_NITRO_DIR_FALLBACK = path.join(
|
||||
const FIGURE_NITRO_DIR_FALLBACK = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/figure",
|
||||
);
|
||||
const FIGURE_SWF_DIR_FALLBACK = path.join(
|
||||
const FIGURE_SWF_DIR_FALLBACK = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/gordon",
|
||||
);
|
||||
@@ -40,7 +40,10 @@ async function getFigureNitroDir(): Promise<string> {
|
||||
if (configured) return configured;
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot)
|
||||
return path.join(/*turbopackIgnore: true*/ gamedataRoot, "bundled/figure");
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ gamedataRoot,
|
||||
"bundled/figure",
|
||||
);
|
||||
return FIGURE_NITRO_DIR_FALLBACK;
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ export async function ensureFigureDirs(): Promise<void> {
|
||||
}
|
||||
|
||||
async function nitroPathFor(lib: string): Promise<string> {
|
||||
return path.join(
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ await getFigureNitroDir(),
|
||||
`${lib}.nitro`,
|
||||
);
|
||||
@@ -81,7 +84,7 @@ export async function importSingleFigure(params: {
|
||||
await ensureFigureDirs();
|
||||
|
||||
onProgress?.("downloading");
|
||||
const swfPath = path.join(
|
||||
const swfPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ await getFigureSwfDir(),
|
||||
`${lib}.swf`,
|
||||
);
|
||||
@@ -158,7 +161,7 @@ export async function getFigureList(params: {
|
||||
lib: l.id,
|
||||
revision: l.revision,
|
||||
nitroExists: existsSync(
|
||||
/*turbopackIgnore: true*/ path.join(nitroDir, `${l.id}.nitro`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
|
||||
),
|
||||
previewType: l.parts?.[0]?.type,
|
||||
previewId: l.parts?.[0]?.id,
|
||||
@@ -176,7 +179,9 @@ export async function getFigureStats(): Promise<{
|
||||
getFigureNitroDir(),
|
||||
]);
|
||||
const imported = libs.filter((l) =>
|
||||
existsSync(/*turbopackIgnore: true*/ path.join(nitroDir, `${l.id}.nitro`)),
|
||||
existsSync(
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
|
||||
),
|
||||
).length;
|
||||
return { total: libs.length, imported, missing: libs.length - imported };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import type { FigureLibrary } from "./figuremap";
|
||||
import {
|
||||
__setFigureMapPathForTests,
|
||||
@@ -23,8 +23,8 @@ const LIB: FigureLibrary = {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "figuremap-"));
|
||||
file = path.join(dir, "FigureMap.json");
|
||||
dir = await fs.mkdtemp(getRuntimePath(os.tmpdir(), "figuremap-"));
|
||||
file = getRuntimePath(dir, "FigureMap.json");
|
||||
await fs.writeFile(file, JSON.stringify({ libraries: [LIB] }));
|
||||
__setFigureMapPathForTests(file);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
const { existsSyncMock, settingGetMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
@@ -19,9 +19,9 @@ vi.mock("@/lib/services/site-settings", () => ({
|
||||
import { getFurniAssetWriteTargets } from "./furni-asset-dirs";
|
||||
|
||||
const primary = {
|
||||
swfDir: path.join("test-assets", "primary", "swf"),
|
||||
iconDir: path.join("test-assets", "primary", "icons"),
|
||||
nitroDir: path.join("test-assets", "primary", "furniture"),
|
||||
swfDir: getRuntimePath("test-assets", "primary", "swf"),
|
||||
iconDir: getRuntimePath("test-assets", "primary", "icons"),
|
||||
nitroDir: getRuntimePath("test-assets", "primary", "furniture"),
|
||||
};
|
||||
|
||||
function configureSettings(values: Record<string, string>): void {
|
||||
@@ -38,7 +38,7 @@ describe("getFurniAssetWriteTargets", () => {
|
||||
});
|
||||
|
||||
it("maps a configured production Gamedata root to the directories served by Nitro", async () => {
|
||||
const gamedataRoot = path.join("test-assets", "Gamedata");
|
||||
const gamedataRoot = getRuntimePath("test-assets", "Gamedata");
|
||||
configureSettings({
|
||||
furni_swf_dir: primary.swfDir,
|
||||
furni_icon_dir: primary.iconDir,
|
||||
@@ -51,8 +51,8 @@ describe("getFurniAssetWriteTargets", () => {
|
||||
|
||||
expect(targets.mirrorDirs).toContainEqual({
|
||||
swfDir: primary.swfDir,
|
||||
iconDir: path.join(gamedataRoot, "icons"),
|
||||
nitroDir: path.join(gamedataRoot, "bundled", "furniture"),
|
||||
iconDir: getRuntimePath(gamedataRoot, "icons"),
|
||||
nitroDir: getRuntimePath(gamedataRoot, "bundled", "furniture"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,11 +71,11 @@ describe("getFurniAssetWriteTargets", () => {
|
||||
});
|
||||
|
||||
it("does not duplicate a Gamedata layout already used as the primary target", async () => {
|
||||
const gamedataRoot = path.join("test-assets", "Gamedata");
|
||||
const gamedataRoot = getRuntimePath("test-assets", "Gamedata");
|
||||
configureSettings({
|
||||
furni_swf_dir: primary.swfDir,
|
||||
furni_icon_dir: path.join(gamedataRoot, "icons"),
|
||||
furni_nitro_dir: path.join(gamedataRoot, "bundled", "furniture"),
|
||||
furni_icon_dir: getRuntimePath(gamedataRoot, "icons"),
|
||||
furni_nitro_dir: getRuntimePath(gamedataRoot, "bundled", "furniture"),
|
||||
nitro_files_root: "",
|
||||
gamedata_root: gamedataRoot,
|
||||
});
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
export const DEFAULT_NITRO_FILES_ROOT = path.join(
|
||||
export const DEFAULT_NITRO_FILES_ROOT = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ path.dirname(process.cwd()),
|
||||
"DEV",
|
||||
"Nitro-Files",
|
||||
);
|
||||
export const DEFAULT_GAMEDATA_ROOT =
|
||||
process.platform === "win32" ? "" : "/var/www/Gamedata";
|
||||
export const DEFAULT_FURNI_ICON_DIR = path.join(
|
||||
export const DEFAULT_FURNI_ICON_DIR = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/dcr/hof_furni/icons",
|
||||
);
|
||||
export const DEFAULT_FURNI_SWF_DIR = path.join(
|
||||
export const DEFAULT_FURNI_SWF_DIR = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/dcr/hof_furni/swf",
|
||||
);
|
||||
export const DEFAULT_FURNI_NITRO_DIR = path.join(
|
||||
export const DEFAULT_FURNI_NITRO_DIR = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/furniture",
|
||||
);
|
||||
@@ -77,12 +78,12 @@ export async function getGamedataRoot(): Promise<string> {
|
||||
|
||||
function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
|
||||
return {
|
||||
swfDir: path.join(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni"),
|
||||
iconDir: path.join(
|
||||
swfDir: getRuntimePath(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni"),
|
||||
iconDir: getRuntimePath(
|
||||
/*turbopackIgnore: true*/ root,
|
||||
"swf/dcr/hof_furni/icons",
|
||||
),
|
||||
nitroDir: path.join(
|
||||
nitroDir: getRuntimePath(
|
||||
/*turbopackIgnore: true*/ root,
|
||||
"nitro-assets/bundled/furniture",
|
||||
),
|
||||
@@ -95,8 +96,11 @@ function dirsFromGamedataRoot(
|
||||
): FurniAssetDirs {
|
||||
return {
|
||||
swfDir: primarySwfDir,
|
||||
iconDir: path.join(/*turbopackIgnore: true*/ root, "icons"),
|
||||
nitroDir: path.join(/*turbopackIgnore: true*/ root, "bundled/furniture"),
|
||||
iconDir: getRuntimePath(/*turbopackIgnore: true*/ root, "icons"),
|
||||
nitroDir: getRuntimePath(
|
||||
/*turbopackIgnore: true*/ root,
|
||||
"bundled/furniture",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
/**
|
||||
* Localized furnidata builder — generates FurnitureData_<lang>.json files
|
||||
* (nl/en/de/fr/es/tr/it/pt/fi/pl/ru/ar/ja) from the master FurnitureData.json
|
||||
@@ -10,7 +12,6 @@
|
||||
*/
|
||||
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { HabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
|
||||
import { habboFurnidataUrl } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -105,8 +106,8 @@ async function loadLibreCache(): Promise<Map<string, string>> {
|
||||
try {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
const cachePath = gamedataRoot
|
||||
? path.join(gamedataRoot, "config", LIBRE_CACHE_FILE)
|
||||
: path.join(process.cwd(), LIBRE_CACHE_FILE);
|
||||
? getRuntimePath(gamedataRoot, "config", LIBRE_CACHE_FILE)
|
||||
: getRuntimePath(process.cwd(), LIBRE_CACHE_FILE);
|
||||
if (existsSync(cachePath)) {
|
||||
const raw = await fs.readFile(cachePath, "utf-8");
|
||||
const obj = JSON.parse(raw) as LibreCache;
|
||||
@@ -126,8 +127,8 @@ async function saveLibreCache(): Promise<void> {
|
||||
try {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
const cachePath = gamedataRoot
|
||||
? path.join(gamedataRoot, "config", LIBRE_CACHE_FILE)
|
||||
: path.join(process.cwd(), LIBRE_CACHE_FILE);
|
||||
? getRuntimePath(gamedataRoot, "config", LIBRE_CACHE_FILE)
|
||||
: getRuntimePath(process.cwd(), LIBRE_CACHE_FILE);
|
||||
const obj: LibreCache = Object.fromEntries(libreMemoryCache.entries());
|
||||
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await fs.writeFile(cachePath, JSON.stringify(obj), "utf-8");
|
||||
@@ -363,7 +364,7 @@ async function getLocalizedWritePaths(lang: string): Promise<string[]> {
|
||||
for (const base of bases) {
|
||||
const dir = path.dirname(base);
|
||||
const langFile = `FurnitureData_${lang}.json`;
|
||||
out.push(path.join(dir, langFile));
|
||||
out.push(getRuntimePath(dir, langFile));
|
||||
}
|
||||
// Deduplicate same normalized path.
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment node
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
const { getGamedataRootMock, getNitroFilesRootMock, settingGetMock } =
|
||||
vi.hoisted(() => ({
|
||||
@@ -26,13 +26,15 @@ describe("writeFurniData production mirror", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "furni-data-paths-"));
|
||||
tempDir = await fs.mkdtemp(
|
||||
getRuntimePath(os.tmpdir(), "furni-data-paths-"),
|
||||
);
|
||||
getNitroFilesRootMock.mockResolvedValue("");
|
||||
getGamedataRootMock.mockResolvedValue(path.join(tempDir, "Gamedata"));
|
||||
getGamedataRootMock.mockResolvedValue(getRuntimePath(tempDir, "Gamedata"));
|
||||
settingGetMock.mockImplementation(
|
||||
async (key: string, fallback: string | null = null) => {
|
||||
if (key === "furni_data_url") {
|
||||
return path.join(tempDir, "cms", "FurnitureData.json");
|
||||
return getRuntimePath(tempDir, "cms", "FurnitureData.json");
|
||||
}
|
||||
if (key === "furni_data_mirror_path") return "";
|
||||
return fallback;
|
||||
@@ -50,7 +52,7 @@ describe("writeFurniData production mirror", () => {
|
||||
|
||||
await writeFurniData(data);
|
||||
|
||||
const livePath = path.join(
|
||||
const livePath = getRuntimePath(
|
||||
tempDir,
|
||||
"Gamedata",
|
||||
"config",
|
||||
@@ -67,7 +69,7 @@ describe("writeFurniData production mirror", () => {
|
||||
);
|
||||
|
||||
await expect(getFurnitureDataPath()).resolves.toBe(
|
||||
path.join(tempDir, "Gamedata", "config", "FurnitureData.json"),
|
||||
getRuntimePath(tempDir, "Gamedata", "config", "FurnitureData.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
const LOCK_STALE_MS = 60_000;
|
||||
const DEFAULT_FURNI_DATA_URL = "/gamedata/config/FurnitureData.json";
|
||||
@@ -27,14 +28,18 @@ export async function getFurnitureDataPath(): Promise<string> {
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot) {
|
||||
const relative = url.replace(/^\/gamedata\/?/, "");
|
||||
return path.join(/*turbopackIgnore: true*/ gamedataRoot, relative);
|
||||
return getRuntimePath(/*turbopackIgnore: true*/ gamedataRoot, relative);
|
||||
}
|
||||
}
|
||||
const relative = url.replace(/^\/+/, "");
|
||||
// The `relative` segment is admin-configurable so its concrete value is
|
||||
// unknown at build time; without the hint Turbopack traces the entire
|
||||
// public/ tree (~400k files) into the route's NFT.
|
||||
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative);
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public",
|
||||
relative,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFurnitureDataWritePaths(): Promise<string[]> {
|
||||
@@ -48,14 +53,14 @@ export async function getFurnitureDataWritePaths(): Promise<string[]> {
|
||||
const mirror = configuredMirror.trim()
|
||||
? configuredMirror
|
||||
: nitroFilesRoot
|
||||
? path.join(
|
||||
? getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroFilesRoot,
|
||||
"nitro-assets/gamedata/FurnitureData.json",
|
||||
)
|
||||
: "";
|
||||
|
||||
const gamedataPath = gamedataRoot
|
||||
? path.join(
|
||||
? getRuntimePath(
|
||||
/*turbopackIgnore: true*/ gamedataRoot,
|
||||
"config/FurnitureData.json",
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
extractIconFromSwfAsync,
|
||||
parseNitroBundle,
|
||||
} from "@/lib/services/swf-to-nitro";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import type { ImportSingleResult } from "@/types/furni";
|
||||
|
||||
// Re-export the download helpers now owned by the shared import core.
|
||||
@@ -95,7 +96,7 @@ async function mirrorFileToDirs(
|
||||
if (!existsSync(/*turbopackIgnore: true*/ sourcePath)) return;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName);
|
||||
const targetPath = getRuntimePath(/*turbopackIgnore: true*/ dir, fileName);
|
||||
const sourceKey =
|
||||
process.platform === "win32" ? sourcePath.toLowerCase() : sourcePath;
|
||||
const targetKey =
|
||||
@@ -362,10 +363,10 @@ async function copyIfMissing(
|
||||
entry: string,
|
||||
copied: string[],
|
||||
): Promise<void> {
|
||||
const dest = path.join(targetDir, entry);
|
||||
const dest = getRuntimePath(targetDir, entry);
|
||||
if (existsSync(dest)) return;
|
||||
try {
|
||||
await fs.copyFile(path.join(sourceDir, entry), dest);
|
||||
await fs.copyFile(getRuntimePath(sourceDir, entry), dest);
|
||||
copied.push(entry);
|
||||
} catch (err) {
|
||||
logger.warn("[import-furni] gamedata bundle sync copy failed", {
|
||||
@@ -423,7 +424,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
const copiedNitros: string[] = [];
|
||||
const copiedIcons: string[] = [];
|
||||
|
||||
const nitroTarget = path.join(gamedataRoot, "bundled/furniture");
|
||||
const nitroTarget = /*turbopackIgnore: true*/ getRuntimePath(
|
||||
gamedataRoot,
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
@@ -431,7 +435,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
copiedNitros.push(...copied);
|
||||
}
|
||||
|
||||
const iconTarget = path.join(gamedataRoot, "icons");
|
||||
const iconTarget = /*turbopackIgnore: true*/ getRuntimePath(
|
||||
gamedataRoot,
|
||||
"icons",
|
||||
);
|
||||
if (iconDir !== iconTarget) {
|
||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||
entry.endsWith("_icon.png"),
|
||||
@@ -680,15 +687,15 @@ export async function importSingleFurni(params: {
|
||||
const iconFileName = classname.replace(/\*/g, "_");
|
||||
const safeSwfName = baseClassname;
|
||||
const safeNitroName = baseClassname;
|
||||
const swfPath = path.join(
|
||||
const swfPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ swfDir,
|
||||
`${safeSwfName}.swf`,
|
||||
);
|
||||
const nitroPath = path.join(
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${safeNitroName}.nitro`,
|
||||
);
|
||||
const iconPath = path.join(
|
||||
const iconPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
);
|
||||
@@ -1092,7 +1099,7 @@ async function realNitroModes(classname: string): Promise<number | undefined> {
|
||||
const cached = nitroModesCache.get(base);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const nitroPath = path.join(
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ FURNI_NITRO_DIR,
|
||||
`${base}.nitro`,
|
||||
);
|
||||
@@ -1317,16 +1324,25 @@ export async function deleteImportedItem(classname: string): Promise<{
|
||||
|
||||
// 4. Delete files (.nitro, .swf, icon)
|
||||
const filesToDelete = [
|
||||
path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`),
|
||||
path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
|
||||
path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`),
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
),
|
||||
getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
),
|
||||
...assetTargets.mirrorDirs.flatMap((dirs) => [
|
||||
path.join(
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
),
|
||||
path.join(/*turbopackIgnore: true*/ dirs.swfDir, `${baseClassname}.swf`),
|
||||
path.join(
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.swfDir,
|
||||
`${baseClassname}.swf`,
|
||||
),
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
),
|
||||
@@ -1366,14 +1382,16 @@ export async function cleanupConvertedSwfs(): Promise<{
|
||||
|
||||
for (const swfFile of swfFiles) {
|
||||
const cn = swfFile.replace(".swf", "");
|
||||
const nitroFilePath = path.join(
|
||||
const nitroFilePath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${cn}.nitro`,
|
||||
);
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
|
||||
try {
|
||||
await fs.unlink(path.join(/*turbopackIgnore: true*/ swfDir, swfFile));
|
||||
await fs.unlink(
|
||||
getRuntimePath(/*turbopackIgnore: true*/ swfDir, swfFile),
|
||||
);
|
||||
deleted++;
|
||||
} catch (err) {
|
||||
errors.push(`Failed to delete ${swfFile}: ${(err as Error).message}`);
|
||||
@@ -1513,13 +1531,16 @@ export async function rollbackFurniImport(
|
||||
|
||||
// 4. Delete asset files
|
||||
const filesToDelete = [
|
||||
path.join(nitroDir, `${base}.nitro`),
|
||||
path.join(swfDir, `${base}.swf`),
|
||||
path.join(iconDir, `${classname.replace(/\*/g, "_")}_icon.png`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${base}.nitro`),
|
||||
getRuntimePath(swfDir, `${base}.swf`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
iconDir,
|
||||
`${classname.replace(/\*/g, "_")}_icon.png`,
|
||||
),
|
||||
];
|
||||
|
||||
for (const filePath of filesToDelete) {
|
||||
if (existsSync(filePath)) {
|
||||
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
@@ -1533,11 +1554,17 @@ export async function rollbackFurniImport(
|
||||
// Mirror directories
|
||||
for (const dirs of assetTargets.mirrorDirs) {
|
||||
for (const filePath of [
|
||||
path.join(dirs.nitroDir, `${classname.split("*")[0]}.nitro`),
|
||||
path.join(dirs.swfDir, `${classname.split("*")[0]}.swf`),
|
||||
path.join(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
dirs.nitroDir,
|
||||
`${classname.split("*")[0]}.nitro`,
|
||||
),
|
||||
getRuntimePath(dirs.swfDir, `${classname.split("*")[0]}.swf`),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
dirs.iconDir,
|
||||
`${classname.replace(/\*/g, "_")}_icon.png`,
|
||||
),
|
||||
]) {
|
||||
if (existsSync(filePath)) {
|
||||
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
export type ImportBadgeResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; status: number };
|
||||
|
||||
function externalTextsPath(gamedataRoot: string): string {
|
||||
return path.join(gamedataRoot, "config", "ExternalTexts.json");
|
||||
return getRuntimePath(gamedataRoot, "config", "ExternalTexts.json");
|
||||
}
|
||||
|
||||
/** Write badge name/desc into ExternalTexts.json (409 if already present). */
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// production FurnitureData.json, so production files are never touched.
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
const { getGamedataRootMock, getNitroFilesRootMock, settingGetMock } =
|
||||
vi.hoisted(() => ({
|
||||
@@ -126,9 +126,9 @@ beforeAll(async () => {
|
||||
// ignore cleanup failures; the assertions will surface real problems
|
||||
}
|
||||
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "furni-live-audit-"));
|
||||
gamedataDir = path.join(tmpRoot, "Gamedata");
|
||||
await fs.mkdir(path.join(gamedataDir, "config"), { recursive: true });
|
||||
tmpRoot = await fs.mkdtemp(getRuntimePath(os.tmpdir(), "furni-live-audit-"));
|
||||
gamedataDir = getRuntimePath(tmpRoot, "Gamedata");
|
||||
await fs.mkdir(getRuntimePath(gamedataDir, "config"), { recursive: true });
|
||||
getGamedataRootMock.mockResolvedValue(gamedataDir);
|
||||
getNitroFilesRootMock.mockResolvedValue("");
|
||||
settingGetMock.mockImplementation(
|
||||
@@ -146,7 +146,7 @@ beforeAll(async () => {
|
||||
try {
|
||||
await fs.copyFile(
|
||||
productionFile,
|
||||
path.join(gamedataDir, "config", "FurnitureData.json"),
|
||||
getRuntimePath(gamedataDir, "config", "FurnitureData.json"),
|
||||
);
|
||||
} catch {
|
||||
// file unavailable — reconcile test still runs against an empty file
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
// The CMS default gamedata JSON paths point under public/nitro-assets/gamedata/,
|
||||
// but this deployment keeps the gamedata JSON (FigureMap/FigureData/EffectMap…)
|
||||
@@ -11,7 +12,11 @@ import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||
// `bundled/` dir, the location the Nitro client loads from.)
|
||||
|
||||
function pub(...segs: string[]): string {
|
||||
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", ...segs);
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public",
|
||||
...segs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +46,7 @@ export async function resolveGamedataFile(
|
||||
// Deployment gamedata root (e.g. /var/www/Gamedata/config/…).
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot) {
|
||||
const rootConfig = path.join(
|
||||
const rootConfig = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ gamedataRoot,
|
||||
"config",
|
||||
fallbackName,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import {
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
@@ -12,8 +12,8 @@ let dir: string;
|
||||
let file: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "gamedata-"));
|
||||
file = path.join(dir, "EffectMap.json");
|
||||
dir = await fs.mkdtemp(getRuntimePath(os.tmpdir(), "gamedata-"));
|
||||
file = getRuntimePath(dir, "EffectMap.json");
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({ effects: [{ id: "1", lib: "A" }] }),
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
|
||||
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
// Pet .nitro can live in the CMS import target or the bundled asset set.
|
||||
const PET_DIRS = [
|
||||
path.join(
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/pet",
|
||||
),
|
||||
path.join(
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/Gamedata/bundled/pet",
|
||||
),
|
||||
@@ -54,7 +54,7 @@ const iconCache = new Map<string, Buffer | null>();
|
||||
|
||||
function findNitro(lib: string): string | null {
|
||||
for (const dir of PET_DIRS) {
|
||||
const p = path.join(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
|
||||
const p = getRuntimePath(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) return p;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { CANONICAL_PETS, resolvePetSwfUrl } from "@/lib/services/pet-source";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
export interface PetListItem {
|
||||
lib: string;
|
||||
@@ -16,11 +16,11 @@ export interface ImportPetResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const PET_NITRO_DIR = path.join(
|
||||
export const PET_NITRO_DIR = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/pet",
|
||||
);
|
||||
export const PET_SWF_DIR = path.join(
|
||||
export const PET_SWF_DIR = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/gordon",
|
||||
);
|
||||
@@ -36,7 +36,10 @@ export async function ensurePetDirs(): Promise<void> {
|
||||
}
|
||||
|
||||
function nitroPathFor(lib: string): string {
|
||||
return path.join(/*turbopackIgnore: true*/ PET_NITRO_DIR, `${lib}.nitro`);
|
||||
return getRuntimePath(
|
||||
/*turbopackIgnore: true*/ PET_NITRO_DIR,
|
||||
`${lib}.nitro`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function importSinglePet(params: {
|
||||
@@ -48,7 +51,7 @@ export async function importSinglePet(params: {
|
||||
await ensurePetDirs();
|
||||
|
||||
onProgress?.("downloading");
|
||||
const swfPath = path.join(
|
||||
const swfPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ PET_SWF_DIR,
|
||||
`${lib}.swf`,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
|
||||
const REPAIR_CONCURRENCY = 6;
|
||||
@@ -61,7 +61,7 @@ async function writeIconToDirs(
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, fileName), icon);
|
||||
await fs.writeFile(getRuntimePath(dir, fileName), icon);
|
||||
} catch (err) {
|
||||
errors.push(`write icon to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -75,12 +75,12 @@ async function copyIconAcrossDirs(
|
||||
fileName: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const src = path.join(srcDir, fileName);
|
||||
const src = getRuntimePath(srcDir, fileName);
|
||||
for (const dir of destDirs) {
|
||||
if (dir === srcDir) continue;
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.copyFile(src, path.join(dir, fileName));
|
||||
await fs.copyFile(src, getRuntimePath(dir, fileName));
|
||||
} catch (err) {
|
||||
errors.push(`copy icon to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export async function repairMissingIcons(
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot) {
|
||||
const badgeFiles = await fs
|
||||
.readdir(path.join(gamedataRoot, "album1584"))
|
||||
.readdir(getRuntimePath(gamedataRoot, "album1584"))
|
||||
.catch(() => [] as string[]);
|
||||
const albumBadgeSet = new Set(
|
||||
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||
@@ -214,7 +214,7 @@ export async function repairMissingIcons(
|
||||
// Badge items store icons as badge_<code>_icon.png (the classname already
|
||||
// includes the `badge_` prefix, so fileName matches correctly).
|
||||
const presentDirs = iconDirs.filter((dir) =>
|
||||
existsSync(path.join(dir, fileName)),
|
||||
existsSync(getRuntimePath(dir, fileName)),
|
||||
);
|
||||
const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir));
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function repairMissingIcons(
|
||||
if (!ok) {
|
||||
let foundNitro = false;
|
||||
for (const dir of nitroDirs) {
|
||||
const nitroPath = path.join(dir, `${classname}.nitro`);
|
||||
const nitroPath = getRuntimePath(dir, `${classname}.nitro`);
|
||||
if (!existsSync(nitroPath)) continue;
|
||||
foundNitro = true;
|
||||
try {
|
||||
@@ -308,12 +308,18 @@ export async function repairMissingIcons(
|
||||
try {
|
||||
const dl = await downloadFile(
|
||||
`${source.iconBaseUrl}/${iconName}`,
|
||||
path.join(targets.iconDir, fileName),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
targets.iconDir,
|
||||
fileName,
|
||||
),
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = await fs.readFile(
|
||||
path.join(targets.iconDir, fileName),
|
||||
/*turbopackIgnore: true*/ getRuntimePath(
|
||||
targets.iconDir,
|
||||
fileName,
|
||||
),
|
||||
);
|
||||
const writeErrors = await writeIconToDirs(
|
||||
icon,
|
||||
@@ -336,7 +342,10 @@ export async function repairMissingIcons(
|
||||
// Some sources only ship icons embedded in their `.nitro` bundles —
|
||||
// grab the bundle and extract the catalog icon from it.
|
||||
if (source.nitroBaseUrl) {
|
||||
const nitroTmp = path.join(os.tmpdir(), `.repair_${classname}.nitro`);
|
||||
const nitroTmp = getRuntimePath(
|
||||
os.tmpdir(),
|
||||
`.repair_${classname}.nitro`,
|
||||
);
|
||||
try {
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
import { convertSwfToNitro } from "./swf";
|
||||
import { addSize32ToBundle } from "./swf/add-size32";
|
||||
@@ -63,7 +63,7 @@ async function writeNitroToDirs(
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, fileName), nitro);
|
||||
await fs.writeFile(getRuntimePath(dir, fileName), nitro);
|
||||
} catch (err) {
|
||||
errors.push(`write .nitro to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -77,12 +77,12 @@ async function copyNitroAcrossDirs(
|
||||
fileName: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const src = path.join(srcDir, fileName);
|
||||
const src = getRuntimePath(srcDir, fileName);
|
||||
for (const dir of destDirs) {
|
||||
if (dir === srcDir) continue;
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.copyFile(src, path.join(dir, fileName));
|
||||
await fs.copyFile(src, getRuntimePath(dir, fileName));
|
||||
} catch (err) {
|
||||
errors.push(`copy .nitro to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export async function repairMissingNitros(
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (gamedataRoot) {
|
||||
const badgeFiles = await fs
|
||||
.readdir(path.join(gamedataRoot, "album1584"))
|
||||
.readdir(getRuntimePath(gamedataRoot, "album1584"))
|
||||
.catch(() => [] as string[]);
|
||||
albumBadgeSet = new Set(
|
||||
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||
@@ -210,7 +210,7 @@ export async function repairMissingNitros(
|
||||
const fileName = `${classname}.nitro`;
|
||||
|
||||
const presentDirs = nitroDirs.filter((dir) =>
|
||||
existsSync(path.join(dir, fileName)),
|
||||
existsSync(getRuntimePath(dir, fileName)),
|
||||
);
|
||||
const missingDirs = nitroDirs.filter((dir) => !presentDirs.includes(dir));
|
||||
|
||||
@@ -269,7 +269,7 @@ export async function repairMissingNitros(
|
||||
|
||||
for (const source of sources) {
|
||||
for (const nitroName of nitroCandidates) {
|
||||
const tmpPath = path.join(
|
||||
const tmpPath = getRuntimePath(
|
||||
os.tmpdir(),
|
||||
`.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`,
|
||||
);
|
||||
@@ -469,7 +469,7 @@ export async function reconvertAllNitros(
|
||||
|
||||
const basesToProcess: string[] = [];
|
||||
for (const base of existingBases) {
|
||||
if (existsSync(path.join(targets.swfDir, `${base}.swf`))) {
|
||||
if (existsSync(getRuntimePath(targets.swfDir, `${base}.swf`))) {
|
||||
basesToProcess.push(base);
|
||||
}
|
||||
}
|
||||
@@ -492,7 +492,7 @@ export async function reconvertAllNitros(
|
||||
return;
|
||||
}
|
||||
|
||||
const swfPath = path.join(targets.swfDir, `${base}.swf`);
|
||||
const swfPath = getRuntimePath(targets.swfDir, `${base}.swf`);
|
||||
let swf: Buffer;
|
||||
try {
|
||||
swf = await fs.readFile(swfPath);
|
||||
@@ -636,7 +636,7 @@ export async function addSize32AllNitros(
|
||||
let buf: Buffer | null = null;
|
||||
for (const dir of nitroDirs) {
|
||||
try {
|
||||
buf = await fs.readFile(path.join(dir, fileName));
|
||||
buf = await fs.readFile(getRuntimePath(dir, fileName));
|
||||
break;
|
||||
} catch {
|
||||
/* try next dir */
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
* `soundtrack_base_url` — see site-settings.ts defaults.
|
||||
*/
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseBuffer } from "music-metadata";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
/** On-disk directory where MP3s live. Fixed (not configurable from UI to
|
||||
* avoid any path traversal risk). */
|
||||
export const SOUNDTRACK_DIR = path.join(
|
||||
export const SOUNDTRACK_DIR = getRuntimePath(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"swf",
|
||||
@@ -48,7 +48,7 @@ export function getSoundtrackPath(code: string): string {
|
||||
if (!isSafeSoundtrackCode(code)) {
|
||||
throw new Error(`Unsafe soundtrack code: ${code}`);
|
||||
}
|
||||
return path.join(SOUNDTRACK_DIR, `${code}.mp3`);
|
||||
return getRuntimePath(SOUNDTRACK_DIR, `${code}.mp3`);
|
||||
}
|
||||
|
||||
/** Ensure the sounds directory exists (mkdir -p). */
|
||||
|
||||
@@ -31,21 +31,21 @@ if (parentPort) {
|
||||
try {
|
||||
if (msg.type === "convert") {
|
||||
const result = convertSwfToNitro(msg.buffer, msg.classname);
|
||||
parentPort!.postMessage({
|
||||
parentPort?.postMessage({
|
||||
id: msg.id,
|
||||
ok: true,
|
||||
result,
|
||||
} satisfies WorkerResponse);
|
||||
} else {
|
||||
const icon = extractIconFromSwf(msg.buffer, msg.classname);
|
||||
parentPort!.postMessage({
|
||||
parentPort?.postMessage({
|
||||
id: msg.id,
|
||||
ok: true,
|
||||
icon: icon ?? null,
|
||||
} satisfies WorkerResponse);
|
||||
}
|
||||
} catch (err) {
|
||||
parentPort!.postMessage({
|
||||
parentPort?.postMessage({
|
||||
id: msg.id,
|
||||
ok: false,
|
||||
error: (err as Error).message,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { inflateSync } from "node:zlib";
|
||||
import LZMA from "lzma";
|
||||
|
||||
const LZMA = require("lzma/src/lzma_worker").LZMA_WORKER;
|
||||
|
||||
// ── SWF Tag Types ──────────────────────────────────────────────────
|
||||
export const TAG_END = 0;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment node
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||
|
||||
const {
|
||||
appendFurniEntryMock,
|
||||
@@ -96,15 +96,15 @@ describe("uploadSingleFurni live asset mirrors", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
__resetUploadItemsBaseAllocatorForTests();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-furni-"));
|
||||
assetTargets.swfDir = path.join(tempDir, "cms", "swf");
|
||||
assetTargets.iconDir = path.join(tempDir, "cms", "icons");
|
||||
assetTargets.nitroDir = path.join(tempDir, "cms", "furniture");
|
||||
tempDir = await fs.mkdtemp(getRuntimePath(os.tmpdir(), "upload-furni-"));
|
||||
assetTargets.swfDir = getRuntimePath(tempDir, "cms", "swf");
|
||||
assetTargets.iconDir = getRuntimePath(tempDir, "cms", "icons");
|
||||
assetTargets.nitroDir = getRuntimePath(tempDir, "cms", "furniture");
|
||||
assetTargets.mirrorDirs = [
|
||||
{
|
||||
swfDir: assetTargets.swfDir,
|
||||
iconDir: path.join(tempDir, "Gamedata", "icons"),
|
||||
nitroDir: path.join(tempDir, "Gamedata", "bundled", "furniture"),
|
||||
iconDir: getRuntimePath(tempDir, "Gamedata", "icons"),
|
||||
nitroDir: getRuntimePath(tempDir, "Gamedata", "bundled", "furniture"),
|
||||
},
|
||||
];
|
||||
dbExecuteMock.mockReset();
|
||||
@@ -135,12 +135,12 @@ describe("uploadSingleFurni live asset mirrors", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
|
||||
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
|
||||
),
|
||||
).resolves.toEqual(nitroBuffer);
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"),
|
||||
getRuntimePath(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"),
|
||||
),
|
||||
).resolves.toEqual(iconBuffer);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/lib/services/furni-import";
|
||||
import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { getRuntimePath, getRuntimeResolve } from "@/lib/utils/runtime-path";
|
||||
|
||||
export interface UploadResult {
|
||||
ok: boolean;
|
||||
@@ -42,7 +43,7 @@ export interface UploadParams {
|
||||
customparams?: string;
|
||||
}
|
||||
|
||||
const MIGRATIONS_DIR = path.resolve(
|
||||
const MIGRATIONS_DIR = getRuntimeResolve(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"drizzle/migrations",
|
||||
);
|
||||
@@ -67,7 +68,7 @@ async function mirrorUploadedAsset(
|
||||
const sourceKey = pathKey(sourcePath);
|
||||
const seen = new Set<string>();
|
||||
for (const dir of dirs) {
|
||||
const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName);
|
||||
const targetPath = getRuntimePath(/*turbopackIgnore: true*/ dir, fileName);
|
||||
const targetKey = pathKey(targetPath);
|
||||
if (targetKey === sourceKey || seen.has(targetKey)) continue;
|
||||
seen.add(targetKey);
|
||||
@@ -166,7 +167,7 @@ async function writeSqlMigration(params: {
|
||||
const padded = String(num).padStart(4, "0");
|
||||
const className = params.classname.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const fileName = `${padded}_import_furni_${className}.sql`;
|
||||
const filePath = path.join(
|
||||
const filePath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ MIGRATIONS_DIR,
|
||||
fileName,
|
||||
);
|
||||
@@ -283,7 +284,7 @@ export async function uploadSingleFurni(params: {
|
||||
const { iconDir, nitroDir } = assetTargets;
|
||||
|
||||
const nitroFileName = `${classname}.nitro`;
|
||||
const nitroPath = path.join(
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
nitroFileName,
|
||||
);
|
||||
@@ -301,7 +302,7 @@ export async function uploadSingleFurni(params: {
|
||||
let iconPath: string | null = null;
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
iconFileName = `${classname}_icon.png`;
|
||||
iconPath = path.join(/*turbopackIgnore: true*/ iconDir, iconFileName);
|
||||
iconPath = getRuntimePath(/*turbopackIgnore: true*/ iconDir, iconFileName);
|
||||
await fs.writeFile(iconPath, iconBuffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function getRuntimePath(...segments: string[]): string {
|
||||
const resolvePath = new Function("p", "segs", "return p.join(...segs)");
|
||||
return resolvePath(path, segments);
|
||||
}
|
||||
|
||||
export function getRuntimeResolve(...segments: string[]): string {
|
||||
const resolvePath = new Function("p", "segs", "return p.resolve(...segs)");
|
||||
return resolvePath(path, segments);
|
||||
}
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
|
||||
"createAccount": "Създаване на акаунт",
|
||||
"creatingAccount": "Създаване на акаунт...",
|
||||
"redirecting": "Прехвърляне към вашия акаунт...",
|
||||
"alreadyHaveAccount": "Вече имате акаунт? Вход!",
|
||||
"register": "Регистрирайте се",
|
||||
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
|
||||
"createAccount": "Vytvořit účet",
|
||||
"creatingAccount": "Vytváření účtu...",
|
||||
"redirecting": "Přesměrovávám na váš účet...",
|
||||
"alreadyHaveAccount": "Už máte účet? Přihlášení!",
|
||||
"register": "Zaregistrujte se",
|
||||
"termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
|
||||
"createAccount": "Opret konto",
|
||||
"creatingAccount": "Opretter konto...",
|
||||
"redirecting": "Omdirigerer til din konto...",
|
||||
"alreadyHaveAccount": "Har du allerede en konto? Log ind!",
|
||||
"register": "Tilmeld dig",
|
||||
"termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
|
||||
|
||||
@@ -724,6 +724,7 @@
|
||||
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
|
||||
"createAccount": "Konto erstellen",
|
||||
"creatingAccount": "Konto wird erstellt...",
|
||||
"redirecting": "Weiterleitung zu deinem Account...",
|
||||
"alreadyHaveAccount": "Bereits ein Konto? Anmelden!",
|
||||
"register": "Registrieren",
|
||||
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
|
||||
"createAccount": "Δημιουργία λογαριασμού",
|
||||
"creatingAccount": "Δημιουργία λογαριασμού...",
|
||||
"redirecting": "Ανακατεύθυνση στον λογαριασμό σας...",
|
||||
"alreadyHaveAccount": "Έχετε ήδη λογαριασμό; Σύνδεση!",
|
||||
"register": "Εγγραφή",
|
||||
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
|
||||
|
||||
@@ -903,6 +903,7 @@
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"redirecting": "Redirecting to your account...",
|
||||
"alreadyHaveAccount": "Already have an account? Login!",
|
||||
"alreadyHaveAccountPre": "Already have an account?",
|
||||
"alreadyHaveAccountLink": "Sign in",
|
||||
|
||||
@@ -724,6 +724,7 @@
|
||||
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
|
||||
"createAccount": "Crear cuenta",
|
||||
"creatingAccount": "Creando cuenta...",
|
||||
"redirecting": "Redirigiendo a tu cuenta...",
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta? ¡Inicia sesión!",
|
||||
"register": "Registrarse",
|
||||
"termsError": "Debes aceptar los términos y reglas para registrarte.",
|
||||
|
||||
@@ -724,6 +724,7 @@
|
||||
"termsAccept": "J'ai 18 ans ou plus et j'accepte les conditions et règles de {hotel}.",
|
||||
"createAccount": "Créer un compte",
|
||||
"creatingAccount": "Création du compte...",
|
||||
"redirecting": "Redirection vers votre compte...",
|
||||
"alreadyHaveAccount": "Vous avez déjà un compte ? Connectez-vous !",
|
||||
"register": "S'inscrire",
|
||||
"termsError": "Vous devez accepter les conditions et règles pour vous inscrire.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Imam 18+ godina i prihvaćam odredbe i pravila hotela {hotel}.",
|
||||
"createAccount": "Napravi račun",
|
||||
"creatingAccount": "Izrada računa...",
|
||||
"redirecting": "Preusmjeravanje na vaš račun...",
|
||||
"alreadyHaveAccount": "Već imate račun? Prijava!",
|
||||
"register": "Registrirajte se",
|
||||
"termsError": "Za registraciju morate prihvatiti uvjete i pravila.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "18 évesnél idősebb vagyok, és elfogadom a {hotel} feltételeit és szabályait.",
|
||||
"createAccount": "Hozzon létre fiókot",
|
||||
"creatingAccount": "Fiók létrehozása...",
|
||||
"redirecting": "Átirányítás a fiókjához...",
|
||||
"alreadyHaveAccount": "Már van fiókja? Bejelentkezés!",
|
||||
"register": "Regisztráció",
|
||||
"termsError": "A regisztrációhoz el kell fogadnia a feltételeket és szabályokat.",
|
||||
|
||||
@@ -877,6 +877,7 @@
|
||||
"termsAccept": "Ho 18+ anni e accetto i termini e regole di {hotel}.",
|
||||
"createAccount": "Crea account",
|
||||
"creatingAccount": "Creazione account...",
|
||||
"redirecting": "Reindirizzamento al tuo account...",
|
||||
"alreadyHaveAccount": "Hai già un account? Accedi!",
|
||||
"register": "Registrati",
|
||||
"termsError": "Devi accettare i termini e regole per registrarti.",
|
||||
|
||||
@@ -907,6 +907,7 @@
|
||||
"termsAccept": "Ik ben 18+ en accepteer de {hotel} voorwaarden & regels.",
|
||||
"createAccount": "Account aanmaken",
|
||||
"creatingAccount": "Account wordt aangemaakt...",
|
||||
"redirecting": "Doorgaan naar je account...",
|
||||
"alreadyHaveAccount": "Al een account? Inloggen!",
|
||||
"register": "Registreren",
|
||||
"alreadyHaveAccountPre": "Al een account?",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Jeg er 18+ og godtar vilkårene og reglene for {hotel}.",
|
||||
"createAccount": "Opprett konto",
|
||||
"creatingAccount": "Oppretter konto ...",
|
||||
"redirecting": "Videre til kontoen din...",
|
||||
"alreadyHaveAccount": "Har du allerede en konto? Logg inn!",
|
||||
"register": "Registrer deg",
|
||||
"termsError": "Du må godta vilkårene og reglene for å registrere deg.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Mam ukończone 18 lat i akceptuję warunki i zasady hotelu {hotel}.",
|
||||
"createAccount": "Utwórz konto",
|
||||
"creatingAccount": "Tworzenie konta...",
|
||||
"redirecting": "Przekierowywanie na twoje konto...",
|
||||
"alreadyHaveAccount": "Masz już konto? Login!",
|
||||
"register": "Zarejestruj się",
|
||||
"termsError": "Aby się zarejestrować, musisz zaakceptować regulamin.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Tenho mais de 18 anos e aceito os termos e regras do {hotel}.",
|
||||
"createAccount": "Criar conta",
|
||||
"creatingAccount": "Criando conta...",
|
||||
"redirecting": "A redirecionar para a sua conta...",
|
||||
"alreadyHaveAccount": "Já tem uma conta? Conecte-se!",
|
||||
"register": "Cadastre-se",
|
||||
"termsError": "Você deve aceitar os termos e regras para se registrar.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Am peste 18 ani și accept termenii și regulile {hotel}.",
|
||||
"createAccount": "Creați cont",
|
||||
"creatingAccount": "Se creează cont...",
|
||||
"redirecting": "Se redirecționează către contul tău...",
|
||||
"alreadyHaveAccount": "Aveți deja un cont? Log in!",
|
||||
"register": "Înregistrează-te",
|
||||
"termsError": "Trebuie să acceptați termenii și regulile pentru a vă înregistra.",
|
||||
|
||||
@@ -752,6 +752,7 @@
|
||||
"termsAccept": "Мне 18+ и я принимаю условия и правила {hotel}.",
|
||||
"createAccount": "Создать аккаунт",
|
||||
"creatingAccount": "Создание учетной записи...",
|
||||
"redirecting": "Переход в ваш аккаунт...",
|
||||
"alreadyHaveAccount": "Уже есть аккаунт? Войти!",
|
||||
"register": "Регистрация",
|
||||
"termsError": "Чтобы зарегистрироваться, вы должны принять условия и правила.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Mám 18+ a súhlasím so zmluvnými podmienkami a pravidlami {hotel}.",
|
||||
"createAccount": "Vytvoriť účet",
|
||||
"creatingAccount": "Vytvára sa účet...",
|
||||
"redirecting": "Presmerovávam na váš účet...",
|
||||
"alreadyHaveAccount": "Už máte účet? Prihláste sa!",
|
||||
"register": "Zaregistrujte sa",
|
||||
"termsError": "Ak sa chcete zaregistrovať, musíte prijať podmienky a pravidlá.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
"createAccount": "Креирајте налог",
|
||||
"creatingAccount": "Прављење налога...",
|
||||
"redirecting": "Преусмеравање на ваш налог...",
|
||||
"alreadyHaveAccount": "Већ имате налог? Пријавите се!",
|
||||
"register": "Региструјте се",
|
||||
"termsError": "Морате прихватити услове и правила да бисте се регистровали.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Jag är 18+ och accepterar villkoren och reglerna för {hotel}.",
|
||||
"createAccount": "Skapa konto",
|
||||
"creatingAccount": "Skapar konto...",
|
||||
"redirecting": "Vidare till ditt konto...",
|
||||
"alreadyHaveAccount": "Har du redan ett konto? Inloggning!",
|
||||
"register": "Registrera dig",
|
||||
"termsError": "Du måste acceptera villkoren och reglerna för att registrera dig.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "18 yaşındayım ve {hotel} şartlarını ve kurallarını kabul ediyorum.",
|
||||
"createAccount": "Hesap oluştur",
|
||||
"creatingAccount": "Hesap oluşturuluyor...",
|
||||
"redirecting": "Hesabınıza yönlendiriliyorsunuz...",
|
||||
"alreadyHaveAccount": "Zaten bir hesabınız var mı? Giriş yapmak!",
|
||||
"register": "Kayıt ol",
|
||||
"termsError": "Kayıt olmak için şartları ve kuralları kabul etmelisiniz.",
|
||||
|
||||
@@ -754,6 +754,7 @@
|
||||
"termsAccept": "Мені 18+ і я приймаю умови та правила {hotel}.",
|
||||
"createAccount": "Створити акаунт",
|
||||
"creatingAccount": "Створення облікового запису...",
|
||||
"redirecting": "Перехід до вашого акаунту...",
|
||||
"alreadyHaveAccount": "Вже маєте акаунт? Вхід!",
|
||||
"register": "зареєструватися",
|
||||
"termsError": "Ви повинні прийняти умови та правила, щоб зареєструватися.",
|
||||
|
||||
Vendored
+12
-5
@@ -1,7 +1,14 @@
|
||||
declare module "lzma" {
|
||||
const lzma: {
|
||||
decompress(input: Buffer | Uint8Array | number[]): number[];
|
||||
compress(input: string | Uint8Array | number[], mode?: number): number[];
|
||||
};
|
||||
export default lzma;
|
||||
export const LZMA: any;
|
||||
export function decompress(
|
||||
byte_array: any,
|
||||
on_finish?: (result: any, error: any) => void,
|
||||
on_progress?: (percent: number) => void,
|
||||
): any;
|
||||
export function compress(
|
||||
string: string | Uint8Array,
|
||||
mode: number,
|
||||
on_finish?: (result: any, error: any) => void,
|
||||
on_progress?: (percent: number) => void,
|
||||
): any;
|
||||
}
|
||||
+438
-48
@@ -1143,10 +1143,17 @@ backup_configs() {
|
||||
|
||||
validate_json() {
|
||||
local file="$1"
|
||||
if ! python3 -m json.tool "$file" >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
# Strict JSON first...
|
||||
if python3 -m json.tool "$file" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# ...otherwise fall back to JSONC (the renderer configs legitimately carry
|
||||
# // and /* */ comments — that is not an error, it is by design).
|
||||
if command -v node &>/dev/null && \
|
||||
node -e "require('jsonc-parser').parse(require('fs').readFileSync(process.argv[1],'utf-8'),[],{allowTrailingComma:true,allowEmptyContent:true});" "$file" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_configs() {
|
||||
@@ -1434,6 +1441,7 @@ cmd_cleanup_old_nitro() {
|
||||
# =============================================================================
|
||||
auto_setup_missing_repos() {
|
||||
local cloned=false
|
||||
local needs_link=false
|
||||
|
||||
if [ ! -d "$NITRO_CLIENT" ] || [ ! -d "$NITRO_CLIENT/.git" ]; then
|
||||
echo ""
|
||||
@@ -1443,7 +1451,7 @@ auto_setup_missing_repos() {
|
||||
if git clone --depth 1 "$GIT_REPO_CLIENT" "$NITRO_CLIENT" >> "$LOG_FILE" 2>&1; then
|
||||
spinner_stop ok
|
||||
ok "Octane client cloned successfully"
|
||||
cloned=true
|
||||
cloned=true; needs_link=true
|
||||
else
|
||||
spinner_stop fail
|
||||
die "Failed to clone Octane client from %s" "$GIT_REPO_CLIENT"
|
||||
@@ -1458,32 +1466,75 @@ auto_setup_missing_repos() {
|
||||
if git clone --depth 1 "$GIT_REPO_RENDERER" "$NITRO_RENDERER" >> "$LOG_FILE" 2>&1; then
|
||||
spinner_stop ok
|
||||
ok "Octane renderer cloned successfully"
|
||||
cloned=true
|
||||
cloned=true; needs_link=true
|
||||
else
|
||||
spinner_stop fail
|
||||
die "Failed to clone Octane renderer from %s" "$GIT_REPO_RENDERER"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$cloned" = true ]; then
|
||||
echo ""
|
||||
info "Installing dependencies for cloned repos..."
|
||||
# Also detect when either repo exists but was never linked
|
||||
if [ "$needs_link" = false ]; then
|
||||
if [ -d "$NITRO_RENDERER" ] && [ -d "$NITRO_RENDERER/.git" ]; then
|
||||
# Check if yarn global link is registered — if not, we need to re-link
|
||||
if [ ! -d "$(yarn global dir 2>/dev/null)/node_modules/@nitrots/nitro-renderer" ] 2>/dev/null && \
|
||||
[ ! -L "$(yarn global dir 2>/dev/null)/node_modules/@nitrots/nitro-renderer" ] 2>/dev/null; then
|
||||
needs_link=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$cloned" = true ] || [ "$needs_link" = true ]; then
|
||||
echo ""
|
||||
info "Installing dependencies..."
|
||||
|
||||
# Step 1: Install renderer deps
|
||||
if [ -d "$NITRO_RENDERER" ] && [ -f "$NITRO_RENDERER/package.json" ]; then
|
||||
spinner_start "Installing renderer deps..."
|
||||
yarn_install_repo "$NITRO_RENDERER" --frozen-lockfile --prefer-offline >> "$LOG_FILE" 2>&1 || \
|
||||
yarn_install_repo "$NITRO_RENDERER" --prefer-offline >> "$LOG_FILE" 2>&1 || \
|
||||
yarn_install_repo "$NITRO_RENDERER" >> "$LOG_FILE" 2>&1 || warn "Renderer yarn install failed"
|
||||
spinner_stop ok
|
||||
ok "Renderer dependencies installed"
|
||||
|
||||
# Step 2: Register renderer as a global yarn link
|
||||
spinner_start "Linking renderer package..."
|
||||
cd "$NITRO_RENDERER"
|
||||
yarn link >> "$LOG_FILE" 2>&1 || warn "yarn link in renderer failed (may already be linked)"
|
||||
cd "$SCRIPT_DIR"
|
||||
spinner_stop ok
|
||||
ok "Renderer linked globally"
|
||||
fi
|
||||
|
||||
# Step 3: Install client deps
|
||||
if [ -d "$NITRO_CLIENT" ] && [ -f "$NITRO_CLIENT/package.json" ]; then
|
||||
spinner_start "Installing client deps..."
|
||||
yarn_install_repo "$NITRO_CLIENT" --frozen-lockfile --prefer-offline >> "$LOG_FILE" 2>&1 || \
|
||||
yarn_install_repo "$NITRO_CLIENT" --prefer-offline >> "$LOG_FILE" 2>&1 || \
|
||||
yarn_install_repo "$NITRO_CLIENT" >> "$LOG_FILE" 2>&1 || warn "Client yarn install failed"
|
||||
spinner_stop ok
|
||||
ok "Client dependencies installed"
|
||||
|
||||
# Step 4: Link renderer into client
|
||||
spinner_start "Linking renderer to client..."
|
||||
cd "$NITRO_CLIENT"
|
||||
yarn link "@nitrots/nitro-renderer" >> "$LOG_FILE" 2>&1 || warn "yarn link to renderer failed (may already be linked)"
|
||||
cd "$SCRIPT_DIR"
|
||||
spinner_stop ok
|
||||
ok "Renderer linked to client"
|
||||
fi
|
||||
|
||||
# Step 5: Setup config files from examples
|
||||
echo ""
|
||||
info "Setting up configuration files..."
|
||||
_setup_config_files
|
||||
|
||||
# Step 6: Create .nitro-build.json for JSONC mode
|
||||
if [ ! -f "$NITRO_CLIENT/.nitro-build.json" ]; then
|
||||
echo '{"jsonMode":"jsonc"}' > "$NITRO_CLIENT/.nitro-build.json"
|
||||
ok "Created .nitro-build.json (jsonc mode)"
|
||||
fi
|
||||
|
||||
# Step 7: Build client frontend
|
||||
if [ -d "$NITRO_CLIENT" ] && [ -f "$NITRO_CLIENT/package.json" ]; then
|
||||
spinner_start "Building client frontend..."
|
||||
if yarn_run "$NITRO_CLIENT" build >> "$LOG_FILE" 2>&1; then
|
||||
spinner_stop ok
|
||||
@@ -1505,6 +1556,60 @@ auto_setup_missing_repos() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Copy example config files to their runtime names, handling renamed files
|
||||
# in the Octane repo (renderer-config..jsonc.example.json etc.)
|
||||
_setup_config_files() {
|
||||
local src_dir="$NITRO_SRC_DIR"
|
||||
[ ! -d "$src_dir" ] && mkdir -p "$src_dir" 2>/dev/null
|
||||
local copied=0
|
||||
|
||||
# Standard *.example files (ui-config, client-mode, etc.)
|
||||
for ef in "$src_dir"/*.example; do
|
||||
[ -f "$ef" ] || continue
|
||||
local b=$(basename "$ef"); local tn="${b%.example}"
|
||||
case "$tn" in *.*) ;; *) tn="$tn.json" ;; esac
|
||||
if [ ! -f "$src_dir/$tn" ]; then
|
||||
cp "$ef" "$src_dir/$tn" 2>/dev/null && copied=$((copied + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Handle renamed renderer config files
|
||||
# Repo has: renderer-config..jsonc.example.json / renderer-config.json.example.json
|
||||
# Runtime needs: renderer-config.jsonc (JSONC) or renderer-config.json
|
||||
local rc_jsonc="$src_dir/renderer-config..jsonc.example.json"
|
||||
local rc_json="$src_dir/renderer-config.json.example.json"
|
||||
if [ -f "$rc_jsonc" ] && [ ! -f "$src_dir/renderer-config.jsonc" ]; then
|
||||
cp "$rc_jsonc" "$src_dir/renderer-config.jsonc" 2>/dev/null && copied=$((copied + 1))
|
||||
fi
|
||||
if [ -f "$rc_json" ] && [ ! -f "$src_dir/renderer-config.json" ]; then
|
||||
cp "$rc_json" "$src_dir/renderer-config.json" 2>/dev/null && copied=$((copied + 1))
|
||||
fi
|
||||
|
||||
# Copy to gamedata config dir too
|
||||
if [ -d "$GAMEDATA_CONF_DIR" ]; then
|
||||
for f in "$src_dir"/*.json "$src_dir"/*.jsonc; do
|
||||
[ -f "$f" ] || continue
|
||||
local fn=$(basename "$f")
|
||||
# Skip example/source files
|
||||
case "$fn" in *.example*|*.example) continue ;; esac
|
||||
[ ! -f "$GAMEDATA_CONF_DIR/$fn" ] && cp "$f" "$GAMEDATA_CONF_DIR/$fn" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Copy to dist/configuration if it exists
|
||||
local dcd="$NITRO_CLIENT/dist/configuration"
|
||||
if [ -d "$dcd" ]; then
|
||||
for f in "$src_dir"/*.json "$src_dir"/*.jsonc; do
|
||||
[ -f "$f" ] || continue
|
||||
local fn=$(basename "$f")
|
||||
case "$fn" in *.example*|*.example) continue ;; esac
|
||||
cp "$f" "$dcd/$fn" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
ok "%s config file(s) copied" "$copied"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# PRE-FLIGHT CHECKS
|
||||
# =============================================================================
|
||||
@@ -1629,6 +1734,10 @@ update_renderer() {
|
||||
yarn_install_repo "$NITRO_RENDERER" || { spinner_stop fail; git_revert_repo "$NITRO_RENDERER"; die "yarn install failed (see: %s)" "$LOG_FILE"; }
|
||||
fi
|
||||
spinner_stop ok; ok "Renderer dependencies installed"
|
||||
# Ensure renderer is globally linked
|
||||
cd "$NITRO_RENDERER"
|
||||
yarn link >> "$LOG_FILE" 2>&1 || true
|
||||
cd "$SCRIPT_DIR"
|
||||
else
|
||||
info "Renderer: already up to date"
|
||||
fi
|
||||
@@ -1670,6 +1779,10 @@ update_client() {
|
||||
cd "$NITRO_CLIENT"
|
||||
fi
|
||||
spinner_stop ok
|
||||
# Ensure renderer is linked into client
|
||||
cd "$NITRO_CLIENT"
|
||||
yarn link "@nitrots/nitro-renderer" >> "$LOG_FILE" 2>&1 || true
|
||||
cd "$SCRIPT_DIR"
|
||||
spinner_start "Building frontend..."
|
||||
if yarn_run "$NITRO_CLIENT" build >> "$LOG_FILE" 2>&1; then
|
||||
spinner_stop ok
|
||||
@@ -1702,6 +1815,7 @@ sync_configs() {
|
||||
[ -d "$d" ] && [ ! -w "$d" ] && sudo chown -R "$(whoami)":"$(whoami)" "$d" 2>/dev/null || true
|
||||
done
|
||||
local ec=0
|
||||
# Standard *.example files (ui-config, client-mode, etc.)
|
||||
for ef in "$NITRO_SRC_DIR"/*.example; do
|
||||
[ -f "$ef" ] || continue
|
||||
local b=$(basename "$ef"); local tn="${b%.example}"
|
||||
@@ -1711,6 +1825,24 @@ sync_configs() {
|
||||
[ -d "$dcd" ] && cp "$NITRO_SRC_DIR/$tn" "$dcd/$tn" 2>/dev/null || true
|
||||
ec=$((ec+1))
|
||||
done
|
||||
# Repo ships renamed examples that don't match the standard *.example glob:
|
||||
# renderer-config.json.example.json -> renderer-config.json
|
||||
# renderer-config..jsonc.example.json -> renderer-config.jsonc
|
||||
# (the ".." in that second name is a quirk of the upstream repo). Map them
|
||||
# explicitly so we never emit garbage like renderer-config.jsonc.json.
|
||||
local -A rc_map=(
|
||||
["renderer-config.json.example.json"]="renderer-config.json"
|
||||
["renderer-config..jsonc.example.json"]="renderer-config.jsonc"
|
||||
)
|
||||
for ex in "${!rc_map[@]}"; do
|
||||
local src="$NITRO_SRC_DIR/$ex"
|
||||
[ -f "$src" ] || continue
|
||||
local tgt="${rc_map[$ex]}"
|
||||
node "$ms" "$src" "$NITRO_SRC_DIR/$tgt" 2>/dev/null || true
|
||||
node "$ms" "$src" "$GAMEDATA_CONF_DIR/$tgt" 2>/dev/null || true
|
||||
[ -d "$dcd" ] && cp "$NITRO_SRC_DIR/$tgt" "$dcd/$tgt" 2>/dev/null || true
|
||||
ec=$((ec+1))
|
||||
done
|
||||
ok "%s config file(s) synced" "$ec"
|
||||
|
||||
# Validate the generated configs
|
||||
@@ -1736,42 +1868,16 @@ sync_configs() {
|
||||
fi
|
||||
|
||||
info "Applying critical config URLs..."
|
||||
NITRO_SRC_DIR="$NITRO_SRC_DIR" NITRO_DIST_CONFIG_DIR="$dcd" \
|
||||
# Use the node-based sync so JSONC configs (which contain // comments and
|
||||
# URLs like https://) are parsed and rewritten safely. The previous Python
|
||||
# json.load choked on the comments and silently skipped the URL injection,
|
||||
# leaving the example.com placeholders in place.
|
||||
NITRO_SRC_DIR="$NITRO_SRC_DIR" NITRO_DIST_CONFIG_DIR="$dcd" NITRO_GAMEDATA_CONF_DIR="$GAMEDATA_CONF_DIR" \
|
||||
NITRO_IMAGE_LIBRARY_URL="$NITRO_IMAGE_LIBRARY_URL" NITRO_HOF_FURNITURE_URL="$NITRO_HOF_FURNITURE_URL" \
|
||||
NITRO_API_URL="$NITRO_API_URL" NITRO_SOCKET_URL="$NITRO_SOCKET_URL" \
|
||||
NITRO_GAMEDATA_URL="$NITRO_GAMEDATA_URL" NITRO_ASSET_URL="$NITRO_ASSET_URL" \
|
||||
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"),
|
||||
os.path.join(os.environ.get("NITRO_SRC_DIR", ""), "ui-config.json"),
|
||||
os.path.join(os.environ.get("NITRO_DIST_CONFIG_DIR", ""), "ui-config.json"),
|
||||
]
|
||||
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
|
||||
for key in ["api.url", "socket.url", "furni.asset.icon.url"]:
|
||||
if key in data:
|
||||
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%"
|
||||
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)}")
|
||||
' && ok "All config URLs synced"
|
||||
node "$SCRIPT_DIR/scripts/sync-nitro-urls.cjs" 2>/dev/null && ok "All config URLs synced" || warn "Config URL sync failed"
|
||||
|
||||
# Flush Redis so new config is picked up immediately
|
||||
if command -v redis-cli &>/dev/null; then
|
||||
@@ -1886,6 +1992,7 @@ ensure_gamedata_redirect() {
|
||||
continue
|
||||
fi
|
||||
tmp=$(mktemp)
|
||||
mkdir -p /var/backups/nginx-cms 2>/dev/null || true
|
||||
snippet=" # Redirect /gamedata/ to /gamedata/config/
|
||||
location = /gamedata { return 301 /gamedata/config/; }
|
||||
location = /gamedata/ { return 301 /gamedata/config/; }
|
||||
@@ -1902,7 +2009,7 @@ ensure_gamedata_redirect() {
|
||||
warn "Could not inject gamedata redirect into %s" "$conf"
|
||||
continue
|
||||
fi
|
||||
bak="$conf.bak-$(date +%s)"
|
||||
bak="/var/backups/nginx-cms/$(basename "$conf").bak-$(date +%s)"
|
||||
$root_cmd cp "$conf" "$bak" 2>/dev/null || { rm -f "$tmp"; warn "Cannot backup %s" "$conf"; continue; }
|
||||
if ! $root_cmd install -m 640 "$tmp" "$conf" 2>/dev/null; then
|
||||
rm -f "$tmp" 2>/dev/null || true
|
||||
@@ -1921,9 +2028,255 @@ ensure_gamedata_redirect() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the emulator's public game (WebSocket) port — the port the Nitro
|
||||
# client actually connects to (socket.url -> wss://ws.<domain> -> here).
|
||||
#
|
||||
# NOTE: Emulator config.ini ships a stale `game.port=3000` that does NOT speak
|
||||
# the game WebSocket (it is an unused legacy listener); the real game socket
|
||||
# listens on 2096. We therefore do NOT trust config.ini and instead:
|
||||
# 1. explicit NITRO_EMULATOR_GAME_PORT override
|
||||
# 2. auto-detect a listening port of the emulator java process that is not a
|
||||
# well-known non-game port (3000/3001/3002/3030/3306/5432/6379/80/443/...).
|
||||
# This reliably yields 2096.
|
||||
# 3. fall back to 2096 (the conventional Nitro/Polaris game port).
|
||||
emulator_game_port() {
|
||||
[ -n "${NITRO_EMULATOR_GAME_PORT:-}" ] && { echo "$NITRO_EMULATOR_GAME_PORT"; return; }
|
||||
local pid
|
||||
pid=$(pgrep -f 'jar.*[Pp]olaris' 2>/dev/null | head -1)
|
||||
[ -z "$pid" ] && pid=$(pgrep -x java 2>/dev/null | head -1)
|
||||
if [ -n "$pid" ] && command -v ss >/dev/null 2>&1; then
|
||||
local ports
|
||||
ports=$(ss -ltnp 2>/dev/null | grep -E "pid=$pid," | grep -oE ':[0-9]+ ' | tr -d ' :' | sort -n -u)
|
||||
for cand in $ports; do
|
||||
case "$cand" in 80|443|3000|3001|3002|3030|3306|5432|6379|8080|8081|8422|9443|9444|5000|22|53|38939) continue ;; *) echo "$cand"; return ;; esac
|
||||
done
|
||||
fi
|
||||
echo 2096
|
||||
}
|
||||
|
||||
# Ensure nginx terminates the game WebSocket. The client connects to
|
||||
# wss://ws.<domain> (port 443) and that has to be proxied to the emulator's
|
||||
# game port, otherwise the client loads but never joins the server. Idempotent:
|
||||
# injects a server block (reusing the existing SSL cert), validates with
|
||||
# `nginx -t`, and reloads. Derives the domain automatically so this self-heals
|
||||
# on any hotel VPS.
|
||||
ensure_ws_proxy() {
|
||||
[ ${#NGINX_SITE_CONFS[@]} -eq 0 ] && return 0
|
||||
local domain="${NITRO_DOMAIN:-}"
|
||||
if [ -z "$domain" ]; then
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
domain=$(grep -oE 'server_name[^;]+;' "$conf" 2>/dev/null | head -1 \
|
||||
| tr -s ' ' '\n' | grep -vE 'server_name|;|127\.0\.0\.1|172\.|10\.|192\.168|localhost|\[?' | head -1)
|
||||
[ -n "$domain" ] && break
|
||||
done
|
||||
fi
|
||||
[ -z "$domain" ] && { warn "Could not derive domain for ws proxy — skipping"; return 0; }
|
||||
local ws_name="ws.${domain}"
|
||||
local gport; gport=$(emulator_game_port)
|
||||
local root_cmd=""
|
||||
[ "$(id -u)" -eq 0 ] || root_cmd="sudo -n"
|
||||
mkdir -p /var/backups/nginx-cms 2>/dev/null || true
|
||||
|
||||
local cert="" key=""
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
cert=$(grep -m1 'ssl_certificate[[:space:]]' "$conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
|
||||
key=$(grep -m1 'ssl_certificate_key[[:space:]]' "$conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
|
||||
[ -n "$cert" ] && [ -n "$key" ] && break
|
||||
done
|
||||
[ -z "$cert" ] && cert="/etc/ssl/epicnabbo-backend.pem"
|
||||
[ -z "$key" ] && key="/etc/ssl/epicnabbo-backend.key"
|
||||
|
||||
local conf
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
if grep -qs "$ws_name" "$conf" 2>/dev/null; then
|
||||
ok "Game WebSocket proxy already present for %s" "$ws_name"
|
||||
continue
|
||||
fi
|
||||
local tmp; tmp=$(mktemp)
|
||||
local snippet
|
||||
snippet="server {
|
||||
listen 9443 ssl;
|
||||
listen [::]:9443 ssl;
|
||||
http2 on;
|
||||
server_name $ws_name;
|
||||
|
||||
ssl_certificate $cert;
|
||||
ssl_certificate_key $key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:$gport;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection \"upgrade\";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
"
|
||||
printf "%s\n" "$snippet" > "$tmp"
|
||||
cat "$conf" >> "$tmp" 2>/dev/null
|
||||
local bak="/var/backups/nginx-cms/$(basename "$conf").bak-ws-$(date +%s)"
|
||||
$root_cmd cp "$conf" "$bak" 2>/dev/null || { rm -f "$tmp"; warn "Cannot backup %s" "$conf"; continue; }
|
||||
if ! $root_cmd install -m 640 "$tmp" "$conf" 2>/dev/null; then
|
||||
rm -f "$tmp" 2>/dev/null || true
|
||||
warn "Cannot update %s" "$conf"
|
||||
continue
|
||||
fi
|
||||
rm -f "$tmp" 2>/dev/null || true
|
||||
if ! sudo nginx -t >/dev/null 2>&1; then
|
||||
$root_cmd cp "$bak" "$conf" 2>/dev/null || true
|
||||
warn "nginx -t failed — reverted ws proxy in %s" "$conf"
|
||||
continue
|
||||
fi
|
||||
ok "Added game WebSocket proxy %s -> 127.0.0.1:%s" "$ws_name" "$gport"
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure the Octane client's emulator game API is reachable same-origin. The
|
||||
# client calls /api/auth/* (login, server-key, sso-token, register, change-*,
|
||||
# remember, refresh, ...) and /api/badges/custom via api.url (same origin =
|
||||
# the CMS domain). The Polaris game server serves these on its game port (2096);
|
||||
# the CMS does NOT. Proxying them here keeps api.url same-origin (no CORS) while
|
||||
# still reaching the emulator. Inserted before the generic `location /api/`
|
||||
# (which sends everything else to the CMS on :3002) inside the HTTPS vhost that
|
||||
# listens on 9443. Idempotent: skips when the block is already present.
|
||||
ensure_api_proxy() {
|
||||
[ ${#NGINX_SITE_CONFS[@]} -eq 0 ] && return 0
|
||||
local gport; gport=$(emulator_game_port)
|
||||
local root_cmd=""
|
||||
[ "$(id -u)" -eq 0 ] || root_cmd="sudo -n"
|
||||
mkdir -p /var/backups/nginx-cms 2>/dev/null || true
|
||||
local snippet=" # --- Nitro/Emulator game API (managed by update-Nitrov3.sh: ensure_api_proxy) ---
|
||||
# Octane client calls /api/auth/* and /api/badges/custom via api.url (same
|
||||
# origin). The emulator serves these on its game port; proxy them so the
|
||||
# client stays same-origin (no CORS). Inserted before the generic /api/.
|
||||
location /api/auth/ {
|
||||
proxy_pass http://127.0.0.1:${gport};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";
|
||||
}
|
||||
location /api/badges/custom {
|
||||
proxy_pass http://127.0.0.1:${gport};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";
|
||||
}
|
||||
"
|
||||
local conf
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
if grep -qs "location /api/auth/" "$conf" 2>/dev/null; then
|
||||
ok "Emulator /api/auth proxy already present in %s" "$(basename "$conf")"
|
||||
continue
|
||||
fi
|
||||
local bak="/var/backups/nginx-cms/$(basename "$conf").bak-api-$(date +%s)"
|
||||
$root_cmd cp "$conf" "$bak" 2>/dev/null || { warn "Cannot backup %s" "$conf"; continue; }
|
||||
# Insert the snippet before the first `location /api/ {` inside the
|
||||
# server block that listens on 9443 (the HTTPS client vhost).
|
||||
if ! SNIPPET="$snippet" $root_cmd perl -i -pe '
|
||||
BEGIN { $https=0; $inserted=0; $snip=$ENV{SNIPPET}; }
|
||||
if (/^\s*server\s*\{/) { $https=0; }
|
||||
if (/^\s*listen\s+9443\b/) { $https=1; }
|
||||
if ($https && !$inserted && /^\s*location\s+\/api\/\s*\{/) {
|
||||
$_ = $snip . $_;
|
||||
$inserted=1;
|
||||
}
|
||||
' "$conf" 2>/dev/null; then
|
||||
$root_cmd cp "$bak" "$conf" 2>/dev/null || true
|
||||
warn "Failed to insert emulator /api proxy into %s" "$(basename "$conf")"
|
||||
continue
|
||||
fi
|
||||
if ! sudo nginx -t >/dev/null 2>&1; then
|
||||
$root_cmd cp "$bak" "$conf" 2>/dev/null || true
|
||||
warn "nginx -t failed — reverted emulator /api proxy in %s" "$(basename "$conf")"
|
||||
continue
|
||||
fi
|
||||
ok "Added emulator /api proxy to %s" "$(basename "$conf")"
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure a catch-all default_server exists on the nginx vhost port. L7 proxies
|
||||
# such as Traefik health-check the upstream using the IP as the Host header,
|
||||
# which matches no real vhost; without this block those checks hit the first
|
||||
# server block (e.g. the ws proxy) and get a 400, marking the site unhealthy
|
||||
# (HTTP 503). Answering /health with 200 keeps proxy health checks green.
|
||||
ensure_default_server() {
|
||||
[ ${#NGINX_SITE_CONFS[@]} -eq 0 ] && return 0
|
||||
local found=false
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
if grep -qs "listen.*default_server" "$conf" 2>/dev/null; then found=true; break; fi
|
||||
done
|
||||
$found && { ok "Default-server health block already present"; return 0; }
|
||||
local root_cmd=""
|
||||
[ "$(id -u)" -eq 0 ] || root_cmd="sudo -n"
|
||||
local cert="" key=""
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
cert=$(grep -m1 'ssl_certificate[[:space:]]' "$conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
|
||||
key=$(grep -m1 'ssl_certificate_key[[:space:]]' "$conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
|
||||
[ -n "$cert" ] && [ -n "$key" ] && break
|
||||
done
|
||||
[ -z "$cert" ] && cert="/etc/ssl/epicnabbo-backend.pem"
|
||||
[ -z "$key" ] && key="/etc/ssl/epicnabbo-backend.key"
|
||||
local conf
|
||||
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$conf" ] || continue
|
||||
local tmp; tmp=$(mktemp)
|
||||
mkdir -p /var/backups/nginx-cms 2>/dev/null || true
|
||||
cat > "$tmp" <<EOF
|
||||
server {
|
||||
listen 9443 ssl default_server;
|
||||
listen [::]:9443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_certificate $cert;
|
||||
ssl_certificate_key $key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
location = /health { return 200 "OK"; }
|
||||
location / { return 444; }
|
||||
}
|
||||
|
||||
EOF
|
||||
cat "$conf" >> "$tmp" 2>/dev/null
|
||||
local bak="/var/backups/nginx-cms/$(basename "$conf").bak-default-$(date +%s)"
|
||||
$root_cmd cp "$conf" "$bak" 2>/dev/null || { rm -f "$tmp"; warn "Cannot backup %s" "$conf"; continue; }
|
||||
if ! $root_cmd install -m 640 "$tmp" "$conf" 2>/dev/null; then
|
||||
rm -f "$tmp" 2>/dev/null || true
|
||||
warn "Cannot update %s" "$conf"
|
||||
continue
|
||||
fi
|
||||
rm -f "$tmp" 2>/dev/null || true
|
||||
if ! sudo nginx -t >/dev/null 2>&1; then
|
||||
$root_cmd cp "$bak" "$conf" 2>/dev/null || true
|
||||
warn "nginx -t failed — reverted default-server block in %s" "$conf"
|
||||
continue
|
||||
fi
|
||||
ok "Added default-server /health block to %s" "$(basename "$conf")"
|
||||
done
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
step 8 8 "Restart Services"
|
||||
ensure_gamedata_redirect
|
||||
ensure_default_server
|
||||
ensure_ws_proxy
|
||||
ensure_api_proxy
|
||||
if systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then
|
||||
sudo systemctl restart "$EMULATOR_SERVICE" 2>/dev/null && ok "$EMULATOR_SERVICE restarted" || warn "Restart failed"
|
||||
if ! wait_for_emulator_health; then
|
||||
@@ -1959,7 +2312,15 @@ do_restart() {
|
||||
else
|
||||
warn "No service manager found"
|
||||
fi
|
||||
command -v nginx &>/dev/null && sudo nginx -t 2>/dev/null && sudo systemctl reload nginx 2>/dev/null && ok "Nginx reloaded"
|
||||
# Reload nginx. Prefer `nginx -s reload` (the running master picks it up
|
||||
# immediately); fall back to systemctl for hosts where nginx is a unit.
|
||||
if command -v nginx &>/dev/null && sudo nginx -t >/dev/null 2>&1; then
|
||||
if sudo nginx -s reload >/dev/null 2>&1 || sudo systemctl reload nginx >/dev/null 2>&1; then
|
||||
ok "Nginx reloaded"
|
||||
else
|
||||
warn "Nginx reload failed"
|
||||
fi
|
||||
fi
|
||||
command -v redis-cli &>/dev/null && redis-cli FLUSHALL 2>/dev/null && ok "Redis flushed"
|
||||
}
|
||||
|
||||
@@ -1968,10 +2329,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)
|
||||
fi
|
||||
if [ -z "$port" ]; then
|
||||
port=30000
|
||||
port=$(emulator_game_port)
|
||||
fi
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
local p; p=$(redis-cli GET nitro:emulator:port 2>/dev/null)
|
||||
@@ -2302,9 +2660,29 @@ cmd_flyaway_repair() {
|
||||
yarn_install_repo "$repo" --frozen-lockfile --prefer-offline 2>/dev/null || warn "yarn install still failed for %s" "$name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Re-link renderer: register global link + link into client
|
||||
if [ "$repo" = "$NITRO_RENDERER" ] && [ -d "$repo" ]; then
|
||||
spinner_start "Re-linking renderer..."
|
||||
cd "$NITRO_RENDERER"
|
||||
yarn link >> "$LOG_FILE" 2>&1 || warn "yarn link in renderer failed"
|
||||
cd "$SCRIPT_DIR"
|
||||
cd "$NITRO_CLIENT"
|
||||
yarn link "@nitrots/nitro-renderer" >> "$LOG_FILE" 2>&1 || warn "yarn link to renderer failed"
|
||||
cd "$SCRIPT_DIR"
|
||||
spinner_stop ok
|
||||
ok "Renderer re-linked"
|
||||
fi
|
||||
|
||||
repaired=$((repaired + 1))
|
||||
done
|
||||
|
||||
# Ensure .nitro-build.json exists
|
||||
if [ ! -f "$NITRO_CLIENT/.nitro-build.json" ]; then
|
||||
echo '{"jsonMode":"jsonc"}' > "$NITRO_CLIENT/.nitro-build.json"
|
||||
ok "Created .nitro-build.json (jsonc mode)"
|
||||
fi
|
||||
|
||||
# Realign Flyway schema history with the packaged migrations
|
||||
step 3 7 "$(_t "Flyway DB Repair")"
|
||||
if flyaway_repair_db; then
|
||||
@@ -2826,6 +3204,18 @@ main() {
|
||||
fi
|
||||
|
||||
if [ -z "${NITRO_SITE_URL:-}" ] && [ -n "${APP_URL:-}" ]; then NITRO_SITE_URL="$APP_URL"; fi
|
||||
# Auto-detect the site domain from the nginx vhost when no explicit URL is
|
||||
# configured (APP_URL / NITRO_SITE_URL / --url). This lets the update script
|
||||
# self-configure the client URLs and the ws.<domain> proxy on a fresh hotel
|
||||
# VPS without manual .env edits.
|
||||
if [ -z "${NITRO_SITE_URL:-}" ]; then
|
||||
for _c in "${NGINX_SITE_CONFS[@]}"; do
|
||||
[ -f "$_c" ] || continue
|
||||
NITRO_SITE_URL=$(grep -oE 'server_name[^;]+;' "$_c" 2>/dev/null | head -1 \
|
||||
| tr -s ' ' '\n' | grep -vE 'server_name|;|127\.0\.0\.1|172\.|10\.|192\.168|localhost|\[?' | head -1)
|
||||
[ -n "$NITRO_SITE_URL" ] && NITRO_SITE_URL="https://$NITRO_SITE_URL" && break
|
||||
done
|
||||
fi
|
||||
case "${NITRO_SITE_URL:-}" in
|
||||
https://*) NITRO_WS_PROTO="wss://" ; NITRO_DOMAIN="${NITRO_SITE_URL#https://}" ;;
|
||||
http://*) NITRO_WS_PROTO="ws://" ; NITRO_DOMAIN="${NITRO_SITE_URL#http://}" ;;
|
||||
|
||||
Reference in new issue
Block a user