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/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);