From 42731218f8c4fa606c97a262322443ee77416849 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Fri, 24 Apr 2026 15:53:17 +0200 Subject: [PATCH] Add runtime toggle docs and secure mode switches --- docs/secure-runtime-modes.en.md | 307 ++++++++++++++++++++++++++++++++ docs/secure-runtime-modes.md | 307 ++++++++++++++++++++++++++++++++ public/asset-loader.js | 184 ++++++++++++++++++- public/client-mode.json | 8 + scripts/minify-dist.mjs | 1 - scripts/write-asset-loader.mjs | 185 ++++++++++++++++++- src/bootstrap.ts | 18 +- src/secure-assets.ts | 79 +++++++- 8 files changed, 1081 insertions(+), 8 deletions(-) create mode 100644 docs/secure-runtime-modes.en.md create mode 100644 docs/secure-runtime-modes.md create mode 100644 public/client-mode.json diff --git a/docs/secure-runtime-modes.en.md b/docs/secure-runtime-modes.en.md new file mode 100644 index 0000000..c2d6fbf --- /dev/null +++ b/docs/secure-runtime-modes.en.md @@ -0,0 +1,307 @@ +# Secure runtime modes + +This document summarizes all values you may need to configure for: + +- `dist` bundle obfuscation (`app.js` / `app.css` → `.dat`) +- secure runtime assets (`renderer-config.json`, `ui-config.json`, `gamedata`) +- secure runtime API (`/api/*`) +- plain fallbacks when you want to disable the secure layer without removing the code + +## 1. `Nitro-V3/public/client-mode.json` + +This file controls everything at runtime. + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +### Fields + +- `distObfuscationEnabled` + - `true`: `asset-loader.js` loads `app.css.dat` and `app.js.dat` + - `false`: it loads plain `assets/app.css` and `assets/app.js` + +- `secureAssetsEnabled` + - `true`: `bootstrap.ts` and `secure-assets.ts` use `/nitro-sec/file` + - `false`: `renderer-config.json`, `ui-config.json`, and gamedata are loaded in plain mode + +- `secureApiEnabled` + - `true`: the `fetch` wrapper encrypts `/api/*` requests + - `false`: `/api/*` requests stay plain + +- `apiBaseUrl` + - Nitro emulator / API base URL + - example: `https://nitro.slogga.it:2096` + - it is best to always set this explicitly, so you do not depend on the hardcoded fallback + +- `plainConfigBaseUrl` + - base URL for plain config files + - usually: `https://hotel.slogga.it/` + +- `plainGamedataBaseUrl` + - base URL for plain gamedata files + - usually: `https://hotel.slogga.it/client/nitro/gamedata/` + +## 2. `Nitro-V3/src/bootstrap.ts` + +`bootstrap.ts`: + +- installs the secure fetch wrapper +- reads `window.__nitroClientMode` +- builds `NitroConfig['config.urls']` + +### Current behavior + +- if `secureAssetsEnabled=true` + - it uses `secureUrl('config', 'renderer-config.json', true)` + - it uses `secureUrl('config', 'ui-config.json', true)` + +- if `secureAssetsEnabled=false` + - it uses plain files with cache busting (`?v=...`) + +### Important note + +The current fallback is: + +```ts +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +``` + +So in production it is better to always set `apiBaseUrl` inside `client-mode.json`. + +## 3. `Nitro-V3/src/secure-assets.ts` + +This file contains the runtime logic for: + +- ECDH bootstrap +- asset decrypt/encrypt +- secure `/api/*` +- plain fallback when the toggles are disabled + +### In practice + +- it reads flags from `window.__nitroClientMode` +- if `secureAssetsEnabled=false` + - it automatically rewrites `/nitro-sec/file?...` into plain URLs +- if `secureApiEnabled=false` + - it does not encrypt `/api/*` + +Normally you should not need to touch it unless you want to change the secure protocol itself. + +## 4. `Nitro-V3/public/renderer-config.json` + +This file still defines the paths used by the renderer. + +### Things to check + +- `api.url` +- `socket.url` +- `gamedata.url` +- `external.texts.url` +- `external.texts.translation.url` +- `furnidata.url` +- `furnidata.translation.url` + +### With secure assets enabled + +You can use: + +```json +"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=" +``` + +and the equivalent secure URLs for the other gamedata resources. + +### With secure assets disabled + +You can use plain classic paths, for example: + +```json +"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata" +``` + +or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion. + +## 5. `Nitro-V3/public/ui-config.json` + +There is no secure logic here, but it is one of the files loaded through `config.urls`. + +If `secureAssetsEnabled=true`, it is served from `/nitro-sec/file`. +If `secureAssetsEnabled=false`, it is loaded from the static file with `?v=...`. + +So you only need to maintain the content itself correctly. + +## 6. `Nitro-V3/scripts/write-asset-loader.mjs` + +This script generates `public/asset-loader.js`. + +### What it does now + +- renders the initial shell +- reads `client-mode.json` +- decides whether to load: + - `app.css.dat` / `app.js.dat` + - or `assets/app.css` / `assets/app.js` + +### Important + +If you modify this script, the updated loader is regenerated on the next: + +```bash +yarn build +``` + +because `package.json` already contains: + +```json +"prebuild": "node scripts/write-asset-loader.mjs" +``` + +## 7. `Nitro-V3/scripts/minify-dist.mjs` + +This script now: + +- generates the `.dat` files +- keeps the original `app.css` and `app.js` files too + +This is required, otherwise `distObfuscationEnabled=false` would not have a working fallback. + +## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example` + +The current backend flags are: + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root= +nitro.secure.gamedata.root= +nitro.secure.master_key=change-me-to-a-long-random-secret +``` + +### Meaning + +- `nitro.secure.assets.enabled` + - enables `/nitro-sec/bootstrap` and `/nitro-sec/file` + +- `nitro.secure.api.enabled` + - enables the secure layer for `/api/*` + +- `nitro.secure.config.root` + - folder used to read `renderer-config.json` and `ui-config.json` + +- `nitro.secure.gamedata.root` + - folder used to read live gamedata + +- `nitro.secure.master_key` + - persistent server-side secret + - especially important when running behind Cloudflare / multiple backend requests + +## 9. Example setups + +### Everything enabled + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro +nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata +nitro.secure.master_key=a-long-random-secret +``` + +### `.dat` only, no secure assets/API + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +### Everything plain + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +## 10. When rebuild is required + +### No rebuild required + +For changes to: + +- `client-mode.json` +- `renderer-config.json` +- `ui-config.json` +- live gamedata +- `config.ini` + +### Rebuild required + +For changes to: + +- `src/bootstrap.ts` +- `src/secure-assets.ts` +- `scripts/write-asset-loader.mjs` +- `scripts/minify-dist.mjs` + +## 11. Deployment note + +To make the toggles work properly: + +- always deploy both plain files and `.dat` files +- make sure IIS / your host serves the `.dat` MIME type +- if you disable secure mode on the client, disable it on the backend too for consistency + +## 12. Quick checklist + +- `client-mode.json` configured +- `apiBaseUrl` correct +- `nitro.secure.master_key` set +- `nitro.secure.config.root` correct +- `nitro.secure.gamedata.root` correct +- both `.dat` and plain files deployed +- `.dat` MIME type configured on the web server diff --git a/docs/secure-runtime-modes.md b/docs/secure-runtime-modes.md new file mode 100644 index 0000000..fc36fb5 --- /dev/null +++ b/docs/secure-runtime-modes.md @@ -0,0 +1,307 @@ +# Secure runtime modes + +Questa doc riassume tutti i dati da impostare per: + +- offuscamento bundle `dist` (`app.js` / `app.css` → `.dat`) +- secure assets runtime (`renderer-config.json`, `ui-config.json`, `gamedata`) +- secure API runtime (`/api/*`) +- fallback plain quando vuoi spegnere tutto senza togliere il codice + +## 1. `Nitro-V3/public/client-mode.json` + +Questo file controlla tutto a runtime. + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +### Campi + +- `distObfuscationEnabled` + - `true`: `asset-loader.js` carica `app.css.dat` e `app.js.dat` + - `false`: carica i file normali `assets/app.css` e `assets/app.js` + +- `secureAssetsEnabled` + - `true`: `bootstrap.ts` e `secure-assets.ts` usano `/nitro-sec/file` + - `false`: `renderer-config.json`, `ui-config.json` e gamedata vengono letti in plain + +- `secureApiEnabled` + - `true`: il wrapper `fetch` cifra le chiamate `/api/*` + - `false`: le chiamate `/api/*` restano normali + +- `apiBaseUrl` + - base URL dell’emulatore / API Nitro + - esempio: `https://nitro.slogga.it:2096` + - meglio valorizzarlo sempre, così non dipendi dal fallback hardcoded + +- `plainConfigBaseUrl` + - base URL dei file config plain + - normalmente: `https://hotel.slogga.it/` + +- `plainGamedataBaseUrl` + - base URL del gamedata plain + - normalmente: `https://hotel.slogga.it/client/nitro/gamedata/` + +## 2. `Nitro-V3/src/bootstrap.ts` + +`bootstrap.ts`: + +- installa il secure fetch wrapper +- legge `window.__nitroClientMode` +- costruisce `NitroConfig['config.urls']` + +### Comportamento attuale + +- se `secureAssetsEnabled=true` + - usa `secureUrl('config', 'renderer-config.json', true)` + - usa `secureUrl('config', 'ui-config.json', true)` + +- se `secureAssetsEnabled=false` + - usa i file plain con cache bust (`?v=...`) + +### Nota importante + +Il fallback attuale è: + +```ts +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +``` + +Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `client-mode.json`. + +## 3. `Nitro-V3/src/secure-assets.ts` + +Qui vive tutta la logica runtime: + +- bootstrap ECDH +- decrypt/encrypt assets +- secure `/api/*` +- fallback plain quando i toggle sono spenti + +### In pratica + +- legge i flag da `window.__nitroClientMode` +- se `secureAssetsEnabled=false` + - converte automaticamente `/nitro-sec/file?...` in URL plain +- se `secureApiEnabled=false` + - non cifra `/api/*` + +Normalmente non serve toccarlo, a meno che tu non voglia cambiare il protocollo secure. + +## 4. `Nitro-V3/public/renderer-config.json` + +Questo file continua a definire i path usati dal renderer. + +### Da controllare + +- `api.url` +- `socket.url` +- `gamedata.url` +- `external.texts.url` +- `external.texts.translation.url` +- `furnidata.url` +- `furnidata.translation.url` + +### Con secure assets attivo + +Puoi usare: + +```json +"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=" +``` + +e gli altri URL secure equivalenti. + +### Con secure assets disattivo + +Conviene usare i path plain classici, per esempio: + +```json +"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata" +``` + +oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`. + +## 5. `Nitro-V3/public/ui-config.json` + +Qui non c’è logica secure, ma è uno dei file caricati da `config.urls`. + +Se `secureAssetsEnabled=true`, arriva da `/nitro-sec/file`. +Se `secureAssetsEnabled=false`, arriva dal file statico con `?v=...`. + +Quindi basta mantenerlo corretto come contenuto, non serve altro. + +## 6. `Nitro-V3/scripts/write-asset-loader.mjs` + +Questo script genera `public/asset-loader.js`. + +### Cosa fa ora + +- mostra la shell iniziale +- legge `client-mode.json` +- decide se caricare: + - `app.css.dat` / `app.js.dat` + - oppure `assets/app.css` / `assets/app.js` + +### Importante + +Se modifichi questo script, il loader aggiornato viene rigenerato al prossimo: + +```bash +yarn build +``` + +perché in `package.json` c’è: + +```json +"prebuild": "node scripts/write-asset-loader.mjs" +``` + +## 7. `Nitro-V3/scripts/minify-dist.mjs` + +Adesso questo script: + +- genera i `.dat` +- lascia anche i file originali `app.css` e `app.js` + +Questa parte è fondamentale, altrimenti il toggle `distObfuscationEnabled=false` non avrebbe fallback. + +## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example` + +I flag backend attuali sono: + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root= +nitro.secure.gamedata.root= +nitro.secure.master_key=change-me-to-a-long-random-secret +``` + +### Significato + +- `nitro.secure.assets.enabled` + - abilita `/nitro-sec/bootstrap` e `/nitro-sec/file` + +- `nitro.secure.api.enabled` + - abilita il layer secure per `/api/*` + +- `nitro.secure.config.root` + - cartella dove leggere `renderer-config.json` e `ui-config.json` + +- `nitro.secure.gamedata.root` + - cartella dove leggere il gamedata live + +- `nitro.secure.master_key` + - segreto persistente lato server + - necessario soprattutto con Cloudflare / richieste multiple + +## 9. Esempi di configurazione + +### Tutto attivo + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro +nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata +nitro.secure.master_key=una-chiave-lunga-random +``` + +### Solo `.dat`, senza secure assets/api + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +### Tutto plain + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +## 10. Quando serve rebuild + +### Non serve rebuild + +Per cambiare: + +- `client-mode.json` +- `renderer-config.json` +- `ui-config.json` +- gamedata live +- `config.ini` + +### Serve rebuild + +Per cambiare: + +- `src/bootstrap.ts` +- `src/secure-assets.ts` +- `scripts/write-asset-loader.mjs` +- `scripts/minify-dist.mjs` + +## 11. Nota pratica deployment + +Per usare bene i toggle: + +- pubblica sempre sia i file plain sia i `.dat` +- assicurati che IIS/host serva il MIME type per `.dat` +- se spegni il secure mode nel client, spegnilo anche nel backend per coerenza + +## 12. Checklist veloce + +- `client-mode.json` configurato +- `apiBaseUrl` corretto +- `nitro.secure.master_key` valorizzata +- `nitro.secure.config.root` corretto +- `nitro.secure.gamedata.root` corretto +- `.dat` e file plain entrambi deployati +- MIME `.dat` presente sul web server diff --git a/public/asset-loader.js b/public/asset-loader.js index 569b19d..e02be4d 100644 --- a/public/asset-loader.js +++ b/public/asset-loader.js @@ -1 +1,183 @@ -(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\.\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})(); \ No newline at end of file +(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true + }; + + const isDebug = () => { + try { + const search = new URLSearchParams(location.search); + return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1"; + } catch { + return false; + } + }; + + const debug = (message) => { + try { + window.__nitroLoaderDebug = message; + const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : []; + log.push(message); + window.__nitroLoaderDebugLog = log.slice(-30); + if(!isDebug()) { + document.getElementById("nitro-loader-debug")?.remove(); + return; + } + let node = document.getElementById("nitro-loader-debug"); + if(!node) { + node = document.createElement("div"); + node.id = "nitro-loader-debug"; + node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap"; + document.body.appendChild(node); + } + node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\n"); + } catch {} + }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const renderShell = () => { + const root = document.getElementById("root"); + if(!root || root.firstChild) return; + root.innerHTML = '
'; + }; + + const decodeAsset = (bytes) => { + const output = new Uint8Array(bytes.length); + for(let index = 0; index < bytes.length; index++) { + output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255); + } + return output; + }; + + const gunzip = async (bytes) => { + if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + + const resolveAssetCandidates = (path) => { + const base = getBase(); + const normalized = path.replace(/^\.\//, ""); + const file = normalized.split("/").pop(); + const urls = [ + new URL("./src/assets/" + file, base), + new URL("./assets/" + file, base), + new URL("/src/assets/" + file, base.origin), + new URL("/assets/" + file, base.origin), + new URL("/client/src/assets/" + file, base.origin), + new URL("/client/assets/" + file, base.origin) + ]; + return [...new Map(urls.map(url => [url.href, url])).values()]; + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of resolveAssetCandidates(path)) { + try { + debug("loader: try " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + error = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + debug("loader: ok " + candidate.href); + return new Uint8Array(await response.arrayBuffer()); + } catch(caught) { + error = caught; + } + } + throw error || new Error("asset " + path + " not found"); + }; + + const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path))); + + const injectCssText = (bytes) => { + const node = document.createElement("style"); + node.textContent = new TextDecoder().decode(bytes); + document.head.appendChild(node); + debug("loader: css injected from dat"); + }; + + const loadPlainCss = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href.href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error("plain css failed")); + document.head.appendChild(link); + }); + debug("loader: css linked"); + }; + + const importBytes = async (bytes) => { + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); + try { + debug("loader: importing app blob"); + await import(blobUrl); + debug("loader: app blob imported"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const importPlainJs = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + debug("loader: importing plain js"); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + const url = withCacheBust(new URL("./client-mode.json", getBase())); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("client-mode " + response.status); + const payload = await response.json(); + const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) }; + window.__nitroClientMode = mode; + debug("loader: client-mode loaded"); + return mode; + } catch(error) { + window.__nitroClientMode = { ...MODE_DEFAULTS }; + debug("loader: client-mode fallback " + (error?.message || error)); + return window.__nitroClientMode; + } + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + if(mode.distObfuscationEnabled) { + const [cssBytes, jsBytes] = await Promise.all([ + loadDatAsset("./assets/app.css.dat"), + loadDatAsset("./assets/app.js.dat") + ]); + injectCssText(cssBytes); + await importBytes(jsBytes); + return; + } + await loadPlainCss("./assets/app.css"); + await importPlainJs("./assets/app.js"); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})(); \ No newline at end of file diff --git a/public/client-mode.json b/public/client-mode.json new file mode 100644 index 0000000..a738f14 --- /dev/null +++ b/public/client-mode.json @@ -0,0 +1,8 @@ +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "", + "plainConfigBaseUrl": "", + "plainGamedataBaseUrl": "" +} diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs index a5c7970..a9d3d18 100644 --- a/scripts/minify-dist.mjs +++ b/scripts/minify-dist.mjs @@ -35,7 +35,6 @@ const encryptFile = path => { const bytes = gzipSync(readFileSync(path), { level: 9 }); writeFileSync(path + '.dat', encodeBytes(bytes)); - rmSync(path); }; if(!existsSync(dist)) throw new Error('dist folder not found'); diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 995e49c..b33aa36 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -1,7 +1,190 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -const loader = `(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\\.\\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();`; +const loader = `(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true + }; + + const isDebug = () => { + try { + const search = new URLSearchParams(location.search); + return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1"; + } catch { + return false; + } + }; + + const debug = (message) => { + try { + window.__nitroLoaderDebug = message; + const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : []; + log.push(message); + window.__nitroLoaderDebugLog = log.slice(-30); + if(!isDebug()) { + document.getElementById("nitro-loader-debug")?.remove(); + return; + } + let node = document.getElementById("nitro-loader-debug"); + if(!node) { + node = document.createElement("div"); + node.id = "nitro-loader-debug"; + node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap"; + document.body.appendChild(node); + } + node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n"); + } catch {} + }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const renderShell = () => { + const root = document.getElementById("root"); + if(!root || root.firstChild) return; + root.innerHTML = '
'; + }; + + const decodeAsset = (bytes) => { + const output = new Uint8Array(bytes.length); + for(let index = 0; index < bytes.length; index++) { + output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255); + } + return output; + }; + + const gunzip = async (bytes) => { + if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + + const resolveAssetCandidates = (path) => { + const base = getBase(); + const normalized = path.replace(/^\\.\\//, ""); + const file = normalized.split("/").pop(); + const urls = [ + new URL("./src/assets/" + file, base), + new URL("./assets/" + file, base), + new URL("/src/assets/" + file, base.origin), + new URL("/assets/" + file, base.origin), + new URL("/client/src/assets/" + file, base.origin), + new URL("/client/assets/" + file, base.origin) + ]; + return [...new Map(urls.map(url => [url.href, url])).values()]; + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of resolveAssetCandidates(path)) { + try { + debug("loader: try " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + error = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + debug("loader: ok " + candidate.href); + return new Uint8Array(await response.arrayBuffer()); + } catch(caught) { + error = caught; + } + } + throw error || new Error("asset " + path + " not found"); + }; + + const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path))); + + const injectCssText = (bytes) => { + const node = document.createElement("style"); + node.textContent = new TextDecoder().decode(bytes); + document.head.appendChild(node); + debug("loader: css injected from dat"); + }; + + const loadPlainCss = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href.href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error("plain css failed")); + document.head.appendChild(link); + }); + debug("loader: css linked"); + }; + + const importBytes = async (bytes) => { + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); + try { + debug("loader: importing app blob"); + await import(blobUrl); + debug("loader: app blob imported"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const importPlainJs = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + debug("loader: importing plain js"); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + const url = withCacheBust(new URL("./client-mode.json", getBase())); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("client-mode " + response.status); + const payload = await response.json(); + const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) }; + window.__nitroClientMode = mode; + debug("loader: client-mode loaded"); + return mode; + } catch(error) { + window.__nitroClientMode = { ...MODE_DEFAULTS }; + debug("loader: client-mode fallback " + (error?.message || error)); + return window.__nitroClientMode; + } + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + if(mode.distObfuscationEnabled) { + const [cssBytes, jsBytes] = await Promise.all([ + loadDatAsset("./assets/app.css.dat"), + loadDatAsset("./assets/app.js.dat") + ]); + injectCssText(cssBytes); + await importBytes(jsBytes); + return; + } + await loadPlainCss("./assets/app.css"); + await importPlainJs("./assets/app.js"); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})();`; + const target = resolve('public', 'asset-loader.js'); mkdirSync(dirname(target), { recursive: true }); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 052bf70..077a6c9 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,4 +1,4 @@ -import { installSecureFetch, secureUrl } from './secure-assets'; +import { getClientMode, installSecureFetch, secureUrl } from './secure-assets'; installSecureFetch(); @@ -17,12 +17,22 @@ const setBootDebug = (message: string) => setBootDebug('boot: secure fetch installed'); const search = new URLSearchParams(window.location.search); +const clientMode = getClientMode(); +const cacheBustUrl = (path: string): string => +{ + const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`); -(window as any).NitroSecureApiUrl = 'http://192.168.1.52:2096/'; + url.searchParams.set('v', Date.now().toString(36)); + + return url.toString(); +}; + +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +(window as any).NitroClientMode = clientMode; (window as any).NitroConfig = { 'config.urls': [ - secureUrl('config', 'renderer-config.json', true), - secureUrl('config', 'ui-config.json', true) + clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('renderer-config.json'), + clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('ui-config.json') ], 'sso.ticket': search.get('sso') || null, 'forward.type': search.get('room') ? 2 : -1, diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 05acb46..f0af707 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -4,6 +4,21 @@ type SecureSession = { fingerprint: string; }; +export type NitroClientMode = { + distObfuscationEnabled: boolean; + secureAssetsEnabled: boolean; + secureApiEnabled: boolean; + apiBaseUrl?: string; + plainConfigBaseUrl?: string; + plainGamedataBaseUrl?: string; +}; + +const CLIENT_MODE_DEFAULTS: NitroClientMode = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true +}; + const isDebugEnabled = (): boolean => { try @@ -73,6 +88,29 @@ const REKEY_ENDPOINTS = new Set([ '/api/auth/logout' ]); +export const getClientMode = (): NitroClientMode => +{ + try + { + const configured = (window as any).__nitroClientMode; + + if(configured && typeof configured === 'object') + { + return { + distObfuscationEnabled: configured.distObfuscationEnabled !== false, + secureAssetsEnabled: configured.secureAssetsEnabled !== false, + secureApiEnabled: configured.secureApiEnabled !== false, + apiBaseUrl: typeof configured.apiBaseUrl === 'string' ? configured.apiBaseUrl : '', + plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '', + plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : '' + }; + } + } + catch {} + + return { ...CLIENT_MODE_DEFAULTS }; +}; + const bytesToBase64 = (bytes: ArrayBuffer): string => { let binary = ''; @@ -149,6 +187,9 @@ const deriveAesKey = async (privateKey: CryptoKey, serverKeyBase64: string): Pro const getApiBase = (): string => { + const mode = getClientMode(); + if(typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return mode.apiBaseUrl.replace(/\/$/, ''); + const configured = (window as any).NitroSecureApiUrl; if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); @@ -156,8 +197,42 @@ const getApiBase = (): string => return 'http://localhost:8443/'; }; +const getPlainAssetBase = (kind: 'config' | 'gamedata'): string => +{ + const mode = getClientMode(); + const configured = kind === 'config' ? mode.plainConfigBaseUrl : mode.plainGamedataBaseUrl; + + if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`; + + if(kind === 'config') return `${ window.location.origin }/`; + + return `${ window.location.origin }/nitro/gamedata/`; +}; + +const mapSecureAssetRequestToPlainUrl = (requestUrl: string): string => +{ + const url = new URL(requestUrl, window.location.href); + const kind = (url.searchParams.get('kind') || 'config') as 'config' | 'gamedata'; + const file = (url.searchParams.get('file') || '').replace(/^[\\/]+/, ''); + const plainUrl = new URL(file, getPlainAssetBase(kind)); + const cacheBust = url.searchParams.get('v'); + + if(cacheBust) plainUrl.searchParams.set('v', cacheBust); + + return plainUrl.toString(); +}; + export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = false): string => { + if(!getClientMode().secureAssetsEnabled) + { + const plainUrl = new URL(file.replace(/^\/+/, ''), `${ window.location.origin }/`); + + if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36)); + + return plainUrl.toString(); + } + const base = getApiBase(); const version = cacheBust ? `&v=${ encodeURIComponent(Date.now().toString(36)) }` : ''; @@ -364,6 +439,8 @@ export const installSecureFetch = (): void => if(requestUrl.includes('/nitro-sec/file')) { + if(!getClientMode().secureAssetsEnabled) return nativeFetch(mapSecureAssetRequestToPlainUrl(requestUrl), init); + const method = init?.method || (input instanceof Request ? input.method : 'GET'); const cacheKey = method.toUpperCase() === 'GET' ? normalizeSecureCacheKey(requestUrl) : null; @@ -405,7 +482,7 @@ export const installSecureFetch = (): void => return cloneCachedResponse(responsePromise); } - if(isApiUrl(requestUrl)) + if(getClientMode().secureApiEnabled && isApiUrl(requestUrl)) { const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); const session = await getSecureSession();