diff --git a/localization/badge-texts-en.json b/localization/badge-texts-en.json new file mode 100644 index 0000000..a8ab19a --- /dev/null +++ b/localization/badge-texts-en.json @@ -0,0 +1,3 @@ +{ + "notification.badge.received": "New Badge!" +} diff --git a/localization/badge-texts-it.json b/localization/badge-texts-it.json new file mode 100644 index 0000000..10c1271 --- /dev/null +++ b/localization/badge-texts-it.json @@ -0,0 +1,3 @@ +{ + "notification.badge.received": "Nuovo Distintivo!" +} diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index c1cfde3..19e2307 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -1,9 +1,9 @@ (() => { const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); const MODE_DEFAULTS = { - distObfuscationEnabled: true, - secureAssetsEnabled: true, - secureApiEnabled: true + distObfuscationEnabled: false, + secureAssetsEnabled: false, + secureApiEnabled: false }; const isDebug = () => { @@ -37,6 +37,9 @@ }; const getBase = () => { + if(typeof window.__nitroLoaderBase === "string" && window.__nitroLoaderBase) { + try { return new URL(window.__nitroLoaderBase); } catch {} + } const source = document.currentScript?.src || location.href; return new URL(".", source); }; @@ -81,10 +84,17 @@ return [...new Map(urls.map(url => [url.href, url])).values()]; }; + const expandAssetCandidates = (path) => { + const base = getBase(); + if(/^https?:\/\//i.test(path)) return [new URL(path)]; + if(path.startsWith("/")) return [new URL(path, base.origin + "/")]; + return resolveAssetCandidates(path); + }; + const fetchBytes = async (path) => { let error = null; debug("loader: fetching " + path); - for(const candidate of resolveAssetCandidates(path)) { + for(const candidate of expandAssetCandidates(path)) { try { debug("loader: try " + candidate.href); const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); @@ -110,9 +120,39 @@ debug("loader: css injected from dat"); }; + const matchesContentType = (contentType, accepted) => { + if(!contentType) return true; + return accepted.some(token => contentType.indexOf(token) !== -1); + }; + + const probePlainAsset = async (path, accepted) => { + let lastError = null; + for(const candidate of expandAssetCandidates(path)) { + try { + debug("loader: probe " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + lastError = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if(!matchesContentType(contentType, accepted)) { + lastError = new Error("asset " + candidate.pathname + " wrong type " + contentType); + continue; + } + debug("loader: probe ok " + candidate.href); + const url = new URL(candidate.href); + url.searchParams.set("v", Date.now().toString(36)); + return url; + } catch(caught) { + lastError = caught; + } + } + throw lastError || new Error("asset " + path + " not found"); + }; + const loadPlainCss = async (path) => { - const href = resolveAssetCandidates(path)[0]; - href.searchParams.set("v", Date.now().toString(36)); + const href = await probePlainAsset(path, ["text/css"]); await new Promise((resolve, reject) => { const link = document.createElement("link"); link.rel = "stylesheet"; @@ -136,9 +176,8 @@ }; const importPlainJs = async (path) => { - const href = resolveAssetCandidates(path)[0]; - href.searchParams.set("v", Date.now().toString(36)); - debug("loader: importing plain js"); + const href = await probePlainAsset(path, ["javascript", "ecmascript"]); + debug("loader: importing plain js " + href.href); await import(href.href); debug("loader: plain js imported"); }; @@ -164,24 +203,135 @@ } }; + const fetchManifest = async () => { + const base = getBase(); + const candidates = [ + new URL(".vite/manifest.json", base.origin + "/"), + new URL("manifest.json", base.origin + "/"), + new URL(".vite/manifest.json", base), + new URL("manifest.json", base) + ]; + const seen = new Set(); + for(const candidate of candidates) { + if(seen.has(candidate.href)) continue; + seen.add(candidate.href); + try { + const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); + if(!response.ok) continue; + const json = await response.json(); + if(json && typeof json === "object") { + debug("loader: manifest from " + candidate.href); + return { manifest: json, base: new URL(".", candidate.href) }; + } + } catch {} + } + return null; + }; + + const findEntryFromManifest = (manifest) => { + let bootstrap = null; + for(const key of Object.keys(manifest)) { + const entry = manifest[key]; + if(!entry || typeof entry !== "object" || !entry.isEntry) continue; + if(/bootstrap\./.test(key) || /bootstrap\./.test(entry.file || "")) { + bootstrap = entry; + break; + } + if(!bootstrap) bootstrap = entry; + } + if(!bootstrap) return null; + const css = Array.isArray(bootstrap.css) ? bootstrap.css.slice() : []; + return { js: bootstrap.file, css }; + }; + + const resolveManifestPath = (manifestBase, file) => { + if(/^https?:\/\//i.test(file)) return file; + if(file.startsWith("/")) return file; + return new URL(file, manifestBase.origin + "/").pathname; + }; + + const isLoaderUrl = (href) => /(?:^|\/)bootstrap\.js(?:$|\?|#)/i.test(href) || /(?:^|\/)asset-loader\.js(?:$|\?|#)/i.test(href); + + const fetchEntryFromIndexHtml = async () => { + const base = getBase(); + const candidates = [ + new URL("/index.html", base.origin + "/"), + new URL("/", base.origin + "/") + ]; + for(const candidate of candidates) { + try { + const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); + if(!response.ok) continue; + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if(contentType && contentType.indexOf("html") === -1) continue; + const html = await response.text(); + const doc = new DOMParser().parseFromString(html, "text/html"); + if(!doc) continue; + const resolveAttr = (raw) => { + if(!raw) return ""; + if(/^https?:\/\//i.test(raw)) return raw; + try { return new URL(raw, candidate.href).pathname; } + catch { return raw; } + }; + const scriptNode = Array.from(doc.querySelectorAll('script[type="module"][src]')) + .map(node => node.getAttribute("src") || "") + .find(src => src && !isLoaderUrl(src)); + if(!scriptNode) continue; + const cssNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"][href]')) + .map(node => node.getAttribute("href") || "") + .filter(href => href && !isLoaderUrl(href)); + const jsAbs = resolveAttr(scriptNode); + const cssAbs = cssNodes.map(resolveAttr); + debug("loader: entry from index.html " + jsAbs); + return { js: jsAbs, css: cssAbs }; + } catch {} + } + return null; + }; + (async () => { debug("loader: start"); renderShell(); const mode = await readClientMode(); + + let jsPath = null; + let cssPaths = []; + const manifestResult = await fetchManifest(); + if(manifestResult) { + const entry = findEntryFromManifest(manifestResult.manifest); + if(entry) { + jsPath = resolveManifestPath(manifestResult.base, entry.js); + if(entry.css.length) cssPaths = entry.css.map(file => resolveManifestPath(manifestResult.base, file)); + debug("loader: entry from manifest " + jsPath); + } + } + if(!jsPath) { + const indexEntry = await fetchEntryFromIndexHtml(); + if(indexEntry) { + jsPath = indexEntry.js; + if(indexEntry.css.length) cssPaths = indexEntry.css; + } + } + if(!jsPath) { + jsPath = "./assets/app.js"; + cssPaths = ["./assets/app.css"]; + debug("loader: entry fallback to app.js/app.css"); + } + if(mode.distObfuscationEnabled) { - const [cssBytes, jsBytes] = await Promise.all([ - loadDatAsset("./assets/app.css.dat"), - loadDatAsset("./assets/app.js.dat") + const [cssBytesList, jsBytes] = await Promise.all([ + Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))), + loadDatAsset(jsPath + ".dat") ]); - injectCssText(cssBytes); + cssBytesList.forEach(bytes => injectCssText(bytes)); await importBytes(jsBytes); return; } - await loadPlainCss("./assets/app.css"); - await importPlainJs("./assets/app.js"); + for(const css of cssPaths) await loadPlainCss(css); + await importPlainJs(jsPath); })().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/configuration/bootstrap.js b/public/configuration/bootstrap.js index 104703f..997602f 100644 --- a/public/configuration/bootstrap.js +++ b/public/configuration/bootstrap.js @@ -12,12 +12,16 @@ }; ensureMobileViewport(); + const FALLBACK_API_BASE = ""; const getBase = () => { const source = document.currentScript?.src || location.href; return new URL(".", source); }; + const LOADER_BASE = getBase(); + window.__nitroLoaderBase = LOADER_BASE.href; + const withCacheBust = (url) => { url.searchParams.set("v", Date.now().toString(36)); return url; @@ -81,18 +85,34 @@ } }; + const fetchPlainClientMode = async () => { + try { + const url = withCacheBust(new URL("./client-mode.json", LOADER_BASE)); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("HTTP " + response.status); + const payload = await response.json(); + if(payload && typeof payload === "object") { + window.__nitroClientMode = payload; + return payload; + } + } catch(error) { + console.warn("[Nitro] client-mode fetch failed:", error?.message || error); + } + return null; + }; + const loadPlainBootstrap = async () => { - const url = withCacheBust(new URL("./asset-loader.js", getBase())); + const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE)); await import(url.href); }; - const loadSecureBootstrap = async () => { - if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + const loadSecureBootstrap = async (apiBase) => { + if(!apiBase) throw new Error("Missing apiBaseUrl for secure bootstrap."); const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey); const publicKey = bytesToBase64(publicKeyBuffer); - const base = API_BASE.replace(/\/$/, ""); + const base = apiBase.replace(/\/$/, ""); const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -132,12 +152,20 @@ }; (async () => { - try { - await loadSecureBootstrap(); - } catch(error) { - console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); - await loadPlainBootstrap(); + const mode = await fetchPlainClientMode(); + const wantsSecure = !!(mode && mode.secureAssetsEnabled); + const apiBase = (mode && typeof mode.apiBaseUrl === "string" && mode.apiBaseUrl) || FALLBACK_API_BASE; + + if(wantsSecure) { + try { + await loadSecureBootstrap(apiBase); + return; + } catch(error) { + console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); + } } + + await loadPlainBootstrap(); })().catch(error => { console.error(error); document.body.textContent = "Unable to load client."; diff --git a/public/infostand_backgrounds.json b/public/configuration/infostand_backgrounds.json similarity index 100% rename from public/infostand_backgrounds.json rename to public/configuration/infostand_backgrounds.json diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 8ee77b6..a5a0241 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -28,29 +28,44 @@ "generic.asset.url": "${asset.url}/generic/%libname%.nitro", "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", "furni.rotation.bounce.steps": 20, - "furni.rotation.bounce.height": 0.0625, - "enable.avatar.arrow": false, - "system.log.debug": true, - "system.log.warn": true, - "system.log.error": true, - "system.log.events": false, - "system.log.packets": true, - "system.fps.animation": 24, - "system.fps.max": 60, - "system.pong.manually": true, - "system.pong.interval.ms": 20000, - "room.color.skip.transition": true, - "room.landscapes.enabled": true, - "room.zoom.enabled": true, - "login.screen.enabled": true, + "furni.rotation.bounce.height": 0.0625, + "enable.avatar.arrow": false, + "system.log.debug": true, + "system.log.warn": true, + "system.log.error": true, + "system.log.events": false, + "system.log.packets": false, + "system.fps.animation": 24, + "system.fps.max": 60, + "system.pong.manually": true, + "system.pong.interval.ms": 20000, + "room.color.skip.transition": true, + "room.landscapes.enabled": true, + "room.zoom.enabled": true, + "timezone.settings": "Europe/Amsterdam", + "youtube.publish.disabled": false, + "user.badges.group.slot.enabled": true, + "login.screen.enabled": true, "login.endpoint": "${api.url}/api/auth/login", "login.register.endpoint": "${api.url}/api/auth/register", "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", "login.logout.endpoint": "${api.url}/api/auth/logout", - "login.remember.endpoint": "${api.url}/api/auth/remember", - "login.turnstile.enabled": false, - "login.turnstile.sitekey": "", - "avatar.mandatory.libraries": [ + "login.health.endpoint": "${api.url}/api/health", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.room_templates.endpoint": "${api.url}/api/auth/room-templates", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.server_key.endpoint": "${api.url}/api/auth/server-key", + "login.sso-token.endpoint": "${api.url}/api/auth/sso-token", + "login.refresh.endpoint": "${api.url}/api/auth/refresh", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts", + "login.turnstile.enabled": true, + "login.turnstile.sitekey": "1x00000000000000000000AA", + "avatar.mandatory.libraries": [ "bd:1", "li:0" ], @@ -60,32 +75,536 @@ "dance.3", "dance.4" ], - "avatar.default.figuredata": { - "palettes": [], - "setTypes": [] - }, - "avatar.default.actions": { - "actions": [] - }, - "pet.types": [], - "preload.assets.urls": [ - "${asset.url}/generic/avatar_additions.nitro", - "${asset.url}/generic/group_badge.nitro", - "${asset.url}/generic/floor_editor.nitro", - "${images.url}/loading_icon.png", - "${images.url}/clear_icon.png", - "${images.url}/big_arrow.png" - ], - "login.health.endpoint": "${api.url}/api/health", - "login.health.method": "GET", - "login.check-email.endpoint": "${api.url}/api/auth/check-email", - "login.check-username.endpoint": "${api.url}/api/auth/check-username", - "login.register.imaging.url": "${api.url}/api/avatar/imaging", - "crypto.ws.enabled": true, - "login.news.url": "${api.url}/api/auth/news", - "badges.custom.list.endpoint": "${api.url}/api/badges/custom", - "badges.custom.create.endpoint": "${api.url}/api/badges/custom", - "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", - "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", - "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts" -} + "avatar.default.figuredata": { + "palettes": [ + { + "id": 1, + "colors": [ + { + "id": 99999, + "index": 1001, + "club": 0, + "selectable": false, + "hexCode": "DDDDDD" + }, + { + "id": 99998, + "index": 1001, + "club": 0, + "selectable": false, + "hexCode": "FAFAFA" + } + ] + }, + { + "id": 3, + "colors": [ + { + "id": 10001, + "index": 1001, + "club": 0, + "selectable": false, + "hexCode": "EEEEEE" + }, + { + "id": 10002, + "index": 1002, + "club": 0, + "selectable": false, + "hexCode": "FA3831" + }, + { + "id": 10003, + "index": 1003, + "club": 0, + "selectable": false, + "hexCode": "FD92A0" + }, + { + "id": 10004, + "index": 1004, + "club": 0, + "selectable": false, + "hexCode": "2AC7D2" + }, + { + "id": 10005, + "index": 1005, + "club": 0, + "selectable": false, + "hexCode": "35332C" + }, + { + "id": 10006, + "index": 1006, + "club": 0, + "selectable": false, + "hexCode": "EFFF92" + }, + { + "id": 10007, + "index": 1007, + "club": 0, + "selectable": false, + "hexCode": "C6FF98" + }, + { + "id": 10008, + "index": 1008, + "club": 0, + "selectable": false, + "hexCode": "FF925A" + }, + { + "id": 10009, + "index": 1009, + "club": 0, + "selectable": false, + "hexCode": "9D597E" + }, + { + "id": 10010, + "index": 1010, + "club": 0, + "selectable": false, + "hexCode": "B6F3FF" + }, + { + "id": 10011, + "index": 1011, + "club": 0, + "selectable": false, + "hexCode": "6DFF33" + }, + { + "id": 10012, + "index": 1012, + "club": 0, + "selectable": false, + "hexCode": "3378C9" + }, + { + "id": 10013, + "index": 1013, + "club": 0, + "selectable": false, + "hexCode": "FFB631" + }, + { + "id": 10014, + "index": 1014, + "club": 0, + "selectable": false, + "hexCode": "DFA1E9" + }, + { + "id": 10015, + "index": 1015, + "club": 0, + "selectable": false, + "hexCode": "F9FB32" + }, + { + "id": 10016, + "index": 1016, + "club": 0, + "selectable": false, + "hexCode": "CAAF8F" + }, + { + "id": 10017, + "index": 1017, + "club": 0, + "selectable": false, + "hexCode": "C5C6C5" + }, + { + "id": 10018, + "index": 1018, + "club": 0, + "selectable": false, + "hexCode": "47623D" + }, + { + "id": 10019, + "index": 1019, + "club": 0, + "selectable": false, + "hexCode": "8A8361" + }, + { + "id": 10020, + "index": 1020, + "club": 0, + "selectable": false, + "hexCode": "FF8C33" + }, + { + "id": 10021, + "index": 1021, + "club": 0, + "selectable": false, + "hexCode": "54C627" + }, + { + "id": 10022, + "index": 1022, + "club": 0, + "selectable": false, + "hexCode": "1E6C99" + }, + { + "id": 10023, + "index": 1023, + "club": 0, + "selectable": false, + "hexCode": "984F88" + }, + { + "id": 10024, + "index": 1024, + "club": 0, + "selectable": false, + "hexCode": "77C8FF" + }, + { + "id": 10025, + "index": 1025, + "club": 0, + "selectable": false, + "hexCode": "FFC08E" + }, + { + "id": 10026, + "index": 1026, + "club": 0, + "selectable": false, + "hexCode": "3C4B87" + }, + { + "id": 10027, + "index": 1027, + "club": 0, + "selectable": false, + "hexCode": "7C2C47" + }, + { + "id": 10028, + "index": 1028, + "club": 0, + "selectable": false, + "hexCode": "D7FFE3" + }, + { + "id": 10029, + "index": 1029, + "club": 0, + "selectable": false, + "hexCode": "8F3F1C" + }, + { + "id": 10030, + "index": 1030, + "club": 0, + "selectable": false, + "hexCode": "FF6393" + }, + { + "id": 10031, + "index": 1031, + "club": 0, + "selectable": false, + "hexCode": "1F9B79" + }, + { + "id": 10032, + "index": 1032, + "club": 0, + "selectable": false, + "hexCode": "FDFF33" + } + ] + } + ], + "setTypes": [ + { + "type": "hd", + "paletteId": 1, + "mandatory_f_0": true, + "mandatory_f_1": true, + "mandatory_m_0": true, + "mandatory_m_1": true, + "sets": [ + { + "id": 99999, + "gender": "U", + "club": 0, + "colorable": true, + "selectable": false, + "preselectable": false, + "sellable": false, + "parts": [ + { + "id": 1, + "type": "bd", + "colorable": true, + "index": 0, + "colorindex": 1 + }, + { + "id": 1, + "type": "hd", + "colorable": true, + "index": 0, + "colorindex": 1 + }, + { + "id": 1, + "type": "lh", + "colorable": true, + "index": 0, + "colorindex": 1 + }, + { + "id": 1, + "type": "rh", + "colorable": true, + "index": 0, + "colorindex": 1 + } + ] + } + ] + }, + { + "type": "bds", + "paletteId": 1, + "mandatory_f_0": false, + "mandatory_f_1": false, + "mandatory_m_0": false, + "mandatory_m_1": false, + "sets": [ + { + "id": 10001, + "gender": "U", + "club": 0, + "colorable": true, + "selectable": false, + "preselectable": false, + "sellable": false, + "parts": [ + { + "id": 10001, + "type": "bds", + "colorable": true, + "index": 0, + "colorindex": 1 + }, + { + "id": 10001, + "type": "lhs", + "colorable": true, + "index": 0, + "colorindex": 1 + }, + { + "id": 10001, + "type": "rhs", + "colorable": true, + "index": 0, + "colorindex": 1 + } + ], + "hiddenLayers": [ + { + "partType": "bd" + }, + { + "partType": "rh" + }, + { + "partType": "lh" + } + ] + } + ] + }, + { + "type": "ss", + "paletteId": 3, + "mandatory_f_0": false, + "mandatory_f_1": false, + "mandatory_m_0": false, + "mandatory_m_1": false, + "sets": [ + { + "id": 10010, + "gender": "F", + "club": 0, + "colorable": true, + "selectable": false, + "preselectable": false, + "sellable": false, + "parts": [ + { + "id": 10001, + "type": "ss", + "colorable": true, + "index": 0, + "colorindex": 1 + } + ], + "hiddenLayers": [ + { + "partType": "ch" + }, + { + "partType": "lg" + }, + { + "partType": "ca" + }, + { + "partType": "wa" + }, + { + "partType": "sh" + }, + { + "partType": "ls" + }, + { + "partType": "rs" + }, + { + "partType": "lc" + }, + { + "partType": "rc" + }, + { + "partType": "cc" + }, + { + "partType": "cp" + } + ] + }, + { + "id": 10011, + "gender": "M", + "club": 0, + "colorable": true, + "selectable": false, + "preselectable": false, + "sellable": false, + "parts": [ + { + "id": 10002, + "type": "ss", + "colorable": true, + "index": 0, + "colorindex": 1 + } + ], + "hiddenLayers": [ + { + "partType": "ch" + }, + { + "partType": "lg" + }, + { + "partType": "ca" + }, + { + "partType": "wa" + }, + { + "partType": "sh" + }, + { + "partType": "ls" + }, + { + "partType": "rs" + }, + { + "partType": "lc" + }, + { + "partType": "rc" + }, + { + "partType": "cc" + }, + { + "partType": "cp" + } + ] + } + ] + } + ] + }, + "avatar.default.actions": { + "actions": [ + { + "id": "Default", + "state": "std", + "precedence": 1000, + "main": true, + "isDefault": true, + "geometryType": "vertical", + "activePartSet": "figure", + "assetPartDefinition": "std" + } + ] + }, + "pet.types": [ + "dog", + "cat", + "croco", + "terrier", + "bear", + "pig", + "lion", + "rhino", + "spider", + "turtle", + "chicken", + "frog", + "dragon", + "monster", + "monkey", + "horse", + "monsterplant", + "bunnyeaster", + "bunnyevil", + "bunnydepressed", + "bunnylove", + "pigeongood", + "pigeonevil", + "demonmonkey", + "bearbaby", + "terrierbaby", + "gnome", + "gnome", + "kittenbaby", + "puppybaby", + "pigletbaby", + "haloompa", + "fools", + "pterosaur", + "velociraptor", + "cow", + "LeetPen", + "bbwibb", + "elephants" + ], + "preload.assets.urls": [ + "${images.url}/loading_icon.png", + "${images.url}/clear_icon.png", + "${images.url}/big_arrow.png" + ] +} \ No newline at end of file diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 59e403c..d70ec98 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -1,215 +1,22 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { dirname, resolve } from 'path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const loader = `(() => { - const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); - const MODE_DEFAULTS = { - distObfuscationEnabled: true, - secureAssetsEnabled: true, - secureApiEnabled: true - }; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = resolve(__dirname, '..'); +const OUTPUT_DIR = resolve(ROOT, 'public', 'configuration'); - 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 BOOTSTRAP_JS = `(() => { + const FALLBACK_API_BASE = ""; 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 { - if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") { - debug("loader: client-mode preset"); - return window.__nitroClientMode; - } - 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 clientModePath = resolve('public', 'configuration', 'client-mode.json'); -let bootstrapApiBase = ''; - -if(existsSync(clientModePath)) -{ - try - { - const clientMode = JSON.parse(readFileSync(clientModePath, 'utf8')); - - if(typeof clientMode.apiBaseUrl === 'string') bootstrapApiBase = clientMode.apiBaseUrl; - } - catch {} -} - -const bootstrap = `(() => { - const API_BASE = ${ JSON.stringify(bootstrapApiBase) }; - - const getBase = () => { - const source = document.currentScript?.src || location.href; - return new URL(".", source); - }; + const LOADER_BASE = getBase(); + window.__nitroLoaderBase = LOADER_BASE.href; const withCacheBust = (url) => { url.searchParams.set("v", Date.now().toString(36)); @@ -274,18 +81,34 @@ const bootstrap = `(() => { } }; + const fetchPlainClientMode = async () => { + try { + const url = withCacheBust(new URL("./client-mode.json", LOADER_BASE)); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("HTTP " + response.status); + const payload = await response.json(); + if(payload && typeof payload === "object") { + window.__nitroClientMode = payload; + return payload; + } + } catch(error) { + console.warn("[Nitro] client-mode fetch failed:", error?.message || error); + } + return null; + }; + const loadPlainBootstrap = async () => { - const url = withCacheBust(new URL("./asset-loader.js", getBase())); + const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE)); await import(url.href); }; - const loadSecureBootstrap = async () => { - if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + const loadSecureBootstrap = async (apiBase) => { + if(!apiBase) throw new Error("Missing apiBaseUrl for secure bootstrap."); const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey); const publicKey = bytesToBase64(publicKeyBuffer); - const base = API_BASE.replace(/\\/$/, ""); + const base = apiBase.replace(/\\/$/, ""); const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -325,21 +148,371 @@ const bootstrap = `(() => { }; (async () => { - try { - await loadSecureBootstrap(); - } catch(error) { - console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); - await loadPlainBootstrap(); + const mode = await fetchPlainClientMode(); + const wantsSecure = !!(mode && mode.secureAssetsEnabled); + const apiBase = (mode && typeof mode.apiBaseUrl === "string" && mode.apiBaseUrl) || FALLBACK_API_BASE; + + if(wantsSecure) { + try { + await loadSecureBootstrap(apiBase); + return; + } catch(error) { + console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); + } } + + await loadPlainBootstrap(); })().catch(error => { console.error(error); document.body.textContent = "Unable to load client."; }); -})();`; +})(); +`; -const target = resolve('public', 'configuration', 'asset-loader.js'); -const bootstrapTarget = resolve('public', 'configuration', 'bootstrap.js'); +const ASSET_LOADER_JS = `(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: false, + secureAssetsEnabled: false, + secureApiEnabled: false + }; -mkdirSync(dirname(target), { recursive: true }); -writeFileSync(target, loader); -writeFileSync(bootstrapTarget, bootstrap); + 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 = () => { + if(typeof window.__nitroLoaderBase === "string" && window.__nitroLoaderBase) { + try { return new URL(window.__nitroLoaderBase); } catch {} + } + 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 expandAssetCandidates = (path) => { + const base = getBase(); + if(/^https?:\\/\\//i.test(path)) return [new URL(path)]; + if(path.startsWith("/")) return [new URL(path, base.origin + "/")]; + return resolveAssetCandidates(path); + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of expandAssetCandidates(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 matchesContentType = (contentType, accepted) => { + if(!contentType) return true; + return accepted.some(token => contentType.indexOf(token) !== -1); + }; + + const probePlainAsset = async (path, accepted) => { + let lastError = null; + for(const candidate of expandAssetCandidates(path)) { + try { + debug("loader: probe " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + lastError = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if(!matchesContentType(contentType, accepted)) { + lastError = new Error("asset " + candidate.pathname + " wrong type " + contentType); + continue; + } + debug("loader: probe ok " + candidate.href); + const url = new URL(candidate.href); + url.searchParams.set("v", Date.now().toString(36)); + return url; + } catch(caught) { + lastError = caught; + } + } + throw lastError || new Error("asset " + path + " not found"); + }; + + const loadPlainCss = async (path) => { + const href = await probePlainAsset(path, ["text/css"]); + 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 = await probePlainAsset(path, ["javascript", "ecmascript"]); + debug("loader: importing plain js " + href.href); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") { + debug("loader: client-mode preset"); + return window.__nitroClientMode; + } + 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; + } + }; + + const fetchManifest = async () => { + const base = getBase(); + const candidates = [ + new URL(".vite/manifest.json", base.origin + "/"), + new URL("manifest.json", base.origin + "/"), + new URL(".vite/manifest.json", base), + new URL("manifest.json", base) + ]; + const seen = new Set(); + for(const candidate of candidates) { + if(seen.has(candidate.href)) continue; + seen.add(candidate.href); + try { + const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); + if(!response.ok) continue; + const json = await response.json(); + if(json && typeof json === "object") { + debug("loader: manifest from " + candidate.href); + return { manifest: json, base: new URL(".", candidate.href) }; + } + } catch {} + } + return null; + }; + + const findEntryFromManifest = (manifest) => { + let bootstrap = null; + for(const key of Object.keys(manifest)) { + const entry = manifest[key]; + if(!entry || typeof entry !== "object" || !entry.isEntry) continue; + if(/bootstrap\\./.test(key) || /bootstrap\\./.test(entry.file || "")) { + bootstrap = entry; + break; + } + if(!bootstrap) bootstrap = entry; + } + if(!bootstrap) return null; + const css = Array.isArray(bootstrap.css) ? bootstrap.css.slice() : []; + return { js: bootstrap.file, css }; + }; + + const resolveManifestPath = (manifestBase, file) => { + if(/^https?:\\/\\//i.test(file)) return file; + if(file.startsWith("/")) return file; + return new URL(file, manifestBase.origin + "/").pathname; + }; + + const isLoaderUrl = (href) => /(?:^|\\/)bootstrap\\.js(?:$|\\?|#)/i.test(href) || /(?:^|\\/)asset-loader\\.js(?:$|\\?|#)/i.test(href); + + const fetchEntryFromIndexHtml = async () => { + const base = getBase(); + const candidates = [ + new URL("/index.html", base.origin + "/"), + new URL("/", base.origin + "/") + ]; + for(const candidate of candidates) { + try { + const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); + if(!response.ok) continue; + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if(contentType && contentType.indexOf("html") === -1) continue; + const html = await response.text(); + const doc = new DOMParser().parseFromString(html, "text/html"); + if(!doc) continue; + const resolveAttr = (raw) => { + if(!raw) return ""; + if(/^https?:\\/\\//i.test(raw)) return raw; + try { return new URL(raw, candidate.href).pathname; } + catch { return raw; } + }; + const scriptNode = Array.from(doc.querySelectorAll('script[type="module"][src]')) + .map(node => node.getAttribute("src") || "") + .find(src => src && !isLoaderUrl(src)); + if(!scriptNode) continue; + const cssNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"][href]')) + .map(node => node.getAttribute("href") || "") + .filter(href => href && !isLoaderUrl(href)); + const jsAbs = resolveAttr(scriptNode); + const cssAbs = cssNodes.map(resolveAttr); + debug("loader: entry from index.html " + jsAbs); + return { js: jsAbs, css: cssAbs }; + } catch {} + } + return null; + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + + let jsPath = null; + let cssPaths = []; + const manifestResult = await fetchManifest(); + if(manifestResult) { + const entry = findEntryFromManifest(manifestResult.manifest); + if(entry) { + jsPath = resolveManifestPath(manifestResult.base, entry.js); + if(entry.css.length) cssPaths = entry.css.map(file => resolveManifestPath(manifestResult.base, file)); + debug("loader: entry from manifest " + jsPath); + } + } + if(!jsPath) { + const indexEntry = await fetchEntryFromIndexHtml(); + if(indexEntry) { + jsPath = indexEntry.js; + if(indexEntry.css.length) cssPaths = indexEntry.css; + } + } + if(!jsPath) { + jsPath = "./assets/app.js"; + cssPaths = ["./assets/app.css"]; + debug("loader: entry fallback to app.js/app.css"); + } + + if(mode.distObfuscationEnabled) { + const [cssBytesList, jsBytes] = await Promise.all([ + Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))), + loadDatAsset(jsPath + ".dat") + ]); + cssBytesList.forEach(bytes => injectCssText(bytes)); + await importBytes(jsBytes); + return; + } + for(const css of cssPaths) await loadPlainCss(css); + await importPlainJs(jsPath); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})(); +`; + +const writeFileEnsuring = async (path, contents) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); + console.log(`[write-asset-loader] wrote ${path}`); +}; + +await writeFileEnsuring(resolve(OUTPUT_DIR, 'bootstrap.js'), BOOTSTRAP_JS); +await writeFileEnsuring(resolve(OUTPUT_DIR, 'asset-loader.js'), ASSET_LOADER_JS); diff --git a/src/api/nitro/GetConfigurationValue.ts b/src/api/nitro/GetConfigurationValue.ts index 755ca1d..da063ff 100644 --- a/src/api/nitro/GetConfigurationValue.ts +++ b/src/api/nitro/GetConfigurationValue.ts @@ -4,3 +4,8 @@ export function GetConfigurationValue