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(key: string, value: T = null): { return GetConfiguration().getValue(key, value); } + +export function GetOptionalConfigurationValue(key: string, value: T = null): T +{ + return GetConfiguration().definitions.has(key) ? GetConfiguration().getValue(key, value) : value; +} diff --git a/src/api/utils/RoomChatFormatter.ts b/src/api/utils/RoomChatFormatter.ts index 949de56..4a35a6e 100644 --- a/src/api/utils/RoomChatFormatter.ts +++ b/src/api/utils/RoomChatFormatter.ts @@ -80,11 +80,97 @@ const applyWiredTextMarkup = (content: string) => return result; }; +const FONT_NAMED_COLORS = new Set([ + 'red', 'green', 'blue', 'yellow', 'white', 'black', + 'orange', 'cyan', 'brown', 'purple', 'pink', 'magenta', + 'violet', 'gray', 'grey', 'lime', 'teal', 'gold', + 'silver', 'navy', 'maroon', 'olive', 'indigo' +]); + +export const sanitizeFontColor = (raw: string | null | undefined): string | null => +{ + if(!raw) return null; + if(raw.length > 20) return null; + + const value = raw.trim().toLowerCase(); + + if(/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(value)) return value; + if(FONT_NAMED_COLORS.has(value)) return value; + + return null; +}; + +export type FontSegment = { color: string | null; text: string }; + +const FONT_COLOR_ATTR = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i; + +export const parseFontSegments = (input: string): FontSegment[] => +{ + if(!input) return []; + + const pattern = /]{0,200}?)>([\s\S]{0,200}?)<\/font>/gi; + const segments: FontSegment[] = []; + + let lastIndex = 0; + let match: RegExpExecArray | null; + + while((match = pattern.exec(input)) !== null) + { + if(match.index > lastIndex) + { + segments.push({ color: null, text: input.slice(lastIndex, match.index) }); + } + + const colorMatch = FONT_COLOR_ATTR.exec(match[1] || ''); + const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null; + const color = sanitizeFontColor(rawColor); + + segments.push({ color, text: match[2] }); + lastIndex = pattern.lastIndex; + } + + if(lastIndex < input.length) + { + segments.push({ color: null, text: input.slice(lastIndex) }); + } + + return segments; +}; + +const applyFontMarkup = (content: string) => +{ + const fontPattern = /<font\b([^&]{0,200}?)>([\s\S]{0,4000}?)<\/font>/gi; + const colorAttr = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i; + + let previous = ''; + let next = content; + let guard = 0; + + while((previous !== next) && (guard < 20)) + { + previous = next; + next = next.replace(fontPattern, (_match, attrs: string, inner: string) => + { + const colorMatch = colorAttr.exec(attrs || ''); + const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null; + const color = sanitizeFontColor(rawColor); + + if(!color) return inner; + + return `${ inner }`; + }); + guard++; + } + + return next; +}; + export const RoomChatFormatter = (content: string) => { let result = ''; content = encodeHTML(content); + content = applyFontMarkup(content); content = applyWiredTextMarkup(content); //content = (joypixels.shortnameToUnicode(content) as string) diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 3fb602b..ecff7b4 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,4 +1,4 @@ -import { getClientMode, installSecureFetch, secureUrl } from './secure-assets'; +import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; const ensureMobileViewport = () => { @@ -31,13 +31,43 @@ const setBootDebug = (message: string) => setBootDebug('boot: secure fetch installed'); +const deployBaseUrl = (): string => +{ + try + { + const loaderBase = (window as any).__nitroLoaderBase; + if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString(); + } + catch {} + + try + { + const moduleUrl = (import.meta as any).url; + if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString(); + } + catch {} + + try + { + const base = (import.meta as any).env?.BASE_URL; + if(typeof base === 'string' && base.length) + { + const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, ''); + return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`; + } + } + catch {} + + return `${ window.location.origin }/`; +}; + const loadClientMode = async () => { try { if((window as any).__nitroClientMode) return; - const url = new URL('configuration/client-mode.json', `${ window.location.origin }/`); + const url = new URL('configuration/client-mode.json', deployBaseUrl()); url.searchParams.set('v', Date.now().toString(36)); const response = await fetch(url.toString()); @@ -57,21 +87,13 @@ await loadClientMode(); const search = new URLSearchParams(window.location.search); const clientMode = getClientMode(); -const cacheBustUrl = (path: string): string => -{ - const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`); - - url.searchParams.set('v', Date.now().toString(36)); - - return url.toString(); -}; (window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin; (window as any).NitroClientMode = clientMode; (window as any).NitroConfig = { 'config.urls': [ - clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('configuration/renderer-config.json'), - clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('configuration/ui-config.json') + configFileUrl('renderer-config.json', true), + configFileUrl('ui-config.json', true) ], 'sso.ticket': search.get('sso') || null, 'forward.type': search.get('room') ? 2 : -1, diff --git a/src/common/UserIdentityView.tsx b/src/common/UserIdentityView.tsx index fac5e9a..019eb21 100644 --- a/src/common/UserIdentityView.tsx +++ b/src/common/UserIdentityView.tsx @@ -1,6 +1,23 @@ -import { FC, useMemo } from 'react'; +import { FC, Fragment, ReactNode, useMemo } from 'react'; import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons'; -import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api'; +import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parseFontSegments, parsePrefixColors } from '../api'; + +const renderInlineFontMarkup = (text: string): ReactNode => +{ + if(!text) return text; + if(text.indexOf(' + { + if(segment.color) return { segment.text }; + + return { segment.text }; + }); +}; interface UserIdentityViewProps { @@ -87,7 +104,7 @@ export const UserIdentityView: FC = ({ ); case 'name': - return { username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }; + return { renderInlineFontMarkup(username) }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }; default: return null; } diff --git a/src/components/backgrounds/BackgroundsView.tsx b/src/components/backgrounds/BackgroundsView.tsx index dc347f3..9216447 100644 --- a/src/components/backgrounds/BackgroundsView.tsx +++ b/src/components/backgrounds/BackgroundsView.tsx @@ -1,7 +1,8 @@ -import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react'; +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common'; import { useRoom } from '../../hooks'; -import { GetConfigurationValue } from '../../api'; +import { GetOptionalConfigurationValue } from '../../api'; +import { configFileUrl } from '../../secure-assets'; interface ItemData { id: number; @@ -22,6 +23,8 @@ interface BackgroundsViewProps { const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const; type TabType = typeof TABS[number]; +type RemoteData = Partial>; + export const BackgroundsView: FC = ({ setIsVisible, selectedBackground, @@ -34,20 +37,36 @@ export const BackgroundsView: FC = ({ setSelectedCardBackground }) => { const [activeTab, setActiveTab] = useState('backgrounds'); + const [remoteData, setRemoteData] = useState(null); const { roomSession } = useRoom(); + useEffect(() => { + let cancelled = false; + fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + const processData = useCallback((configData: any[], idField: string): ItemData[] => { if (!configData?.length) return []; - return configData.map(item => ({ id: item[idField] })); + return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] })); }, []); + const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data'): any[] => { + const fromRemote = remoteData?.[key]; + if(Array.isArray(fromRemote)) return fromRemote; + return GetOptionalConfigurationValue(key, []) || []; + }, [remoteData]); + const allData = useMemo(() => ({ - backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'backgroundId'), - stands: processData(GetConfigurationValue('stands.data'), 'standId'), - overlays: processData(GetConfigurationValue('overlays.data'), 'overlayId'), - cards: processData(GetConfigurationValue('cards.data') || GetConfigurationValue('backgrounds.data'), 'backgroundId') - }), [processData]); + backgrounds: processData(readData('backgrounds.data'), 'backgroundId'), + stands: processData(readData('stands.data'), 'standId'), + overlays: processData(readData('overlays.data'), 'overlayId'), + cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId') + }), [processData, readData]); const handleSelection = useCallback((id: number) => { if (!roomSession) return; diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 1bf568c..32dc933 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,6 +1,6 @@ import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; @@ -15,6 +15,7 @@ import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png'; import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation'; import { NewsWindow } from './components/NewsWindow'; import { TurnstileWidget } from './TurnstileWidget'; +import { t } from './utils/i18n'; type DialogMode = 'login' | 'register' | 'forgot'; type LoginLocale = { code: string; file: string; label: string; flag: string }; @@ -194,6 +195,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const [ localeApplying, setLocaleApplying ] = useState(false); const [ localeError, setLocaleError ] = useState(''); const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); + const [ , setLocalizationVersion ] = useState(0); const submitTimeRef = useRef(0); const preloadedLoginImagesRef = useRef>(new Set()); @@ -204,8 +206,22 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const configuredLoginWidgets = useMemo>(() => (loginViewConfig?.['widgets'] as Record) ?? {}, [ loginViewConfig ]); + useEffect(() => + { + const refreshLocalization = () => setLocalizationVersion(value => (value + 1)); + window.addEventListener('nitro-localization-updated', refreshLocalization); + return () => window.removeEventListener('nitro-localization-updated', refreshLocalization); + }, []); + + const loginImages = useMemo>(() => + { + const configured = (loginViewConfig?.['images'] as Record) ?? {}; + return { ...getDefaultLoginImages(), ...configured }; + }, [ loginViewConfig ]); + const loginWidgetSlots = useMemo(() => { + const configuredLoginWidgets = (loginViewConfig?.['widgets'] as Record) ?? {}; return Object.entries(configuredLoginWidgets) .filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0) .map(([ key, value ]) => @@ -217,7 +233,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }) .filter(slot => slot.slotNum > 0) .sort((a, b) => a.slotNum - b.slotNum); - }, [ configuredLoginWidgets ]); + }, [ loginViewConfig ]); const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -226,15 +242,11 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); - const widgetImageUrls = useMemo(() => loginWidgetSlots - .map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '') - .filter(Boolean), [ loginWidgetSlots ]); - const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]); - const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); - const newsUrl = interpolate(GetConfigurationValue('login.news.url', '')); + const configuredNewsUrl = interpolate(GetOptionalConfigurationValue('login.news.url', '')); + const newsUrl = configuredNewsUrl || configFileUrl('news.json'); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); const turnstileEnabled = (rawTurnstileEnabled === true @@ -401,7 +413,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }, []); const healthUrl = GetConfigurationValue('login.health.endpoint', ''); - const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); + const healthMethodRaw = GetOptionalConfigurationValue('login.health.method', 'GET'); const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); const checkServerReachable = useCallback(async (): Promise => { @@ -457,19 +469,19 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(state.lockedUntil > nowTs) { const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); - setError(`Too many attempts. Try again in ${ remaining }s.`); + setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ])); return; } if(!username.trim() || !password) { - setError('Please enter both your Habbo name and password.'); + setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.')); return; } if(turnstileEnabled && !loginTurnstileToken) { - setError('Please complete the security check.'); + setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -497,14 +509,14 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } recordFailure(); - const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.'; + const message = typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.'); setError(message); resetLoginTurnstile(); } catch(err) { recordFailure(); - setError('Unable to reach the login service. Please try again.'); + setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.')); resetLoginTurnstile(); } finally @@ -515,7 +527,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); - const imagingUrl = GetConfigurationValue('login.register.imaging.url', ''); + const imagingUrl = GetOptionalConfigurationValue('login.register.imaging.url', ''); const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; @@ -541,7 +553,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const { ok, status, payload } = await postJson(checkEmailUrl, { email }); const result = interpretAvailability(ok, status, payload); if(result.available) return { available: true }; - return { available: false, error: result.error || 'This email is already in use.' }; + return { available: false, error: result.error || t('nitro.login.error.email_taken', 'This email is already in use.') }; } catch { @@ -556,7 +568,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const { ok, status, payload } = await postJson(checkUsernameUrl, { username }); const result = interpretAvailability(ok, status, payload); if(result.available) return { available: true }; - return { available: false, error: result.error || 'This Habbo name is already taken.' }; + return { available: false, error: result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.') }; } catch { @@ -568,7 +580,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { if(turnstileEnabled && !body.turnstileToken) { - setError('Please complete the security check.'); + setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -589,7 +601,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok) { - const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`; + const friendly = t('nitro.login.register.success', 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', [ 'username' ], [ body.username ]); setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); setUsername(body.username); @@ -597,12 +609,12 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa return; } - setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.'); + setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.')); onDialogReset(); } catch { - setError('Unable to reach the registration service.'); + setError(t('nitro.login.error.register_unreachable', 'Unable to reach the registration service.')); onDialogReset(); } finally @@ -615,7 +627,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { if(turnstileEnabled && !body.turnstileToken) { - setError('Please complete the security check.'); + setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -632,18 +644,18 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok) { - const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).'; + const friendly = t('nitro.login.forgot.success', 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).'); setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); return; } - setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.'); + setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.')); onDialogReset(); } catch { - setError('Unable to reach the password reset service.'); + setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.')); onDialogReset(); } finally @@ -663,9 +675,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { left ? : null } { rightRepeat ?
: null } { right ? : null } - { loginWidgetSlots.length > 0 &&
@@ -702,8 +711,8 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa
-
Choose your language
-
+
{ t('nitro.login.language.title', 'Choose your language') }
+
{ LOGIN_LOCALES.map(locale =>
{ localeError.length > 0 &&
{ localeError }
}
-
First time here?
+
{ t('nitro.login.firsttime.title', 'First time here?') }
- Don't have a Habbo yet? - setMode('register') }>You can create one here + { t('nitro.login.firsttime.text', 'Don\'t have a Habbo yet?') } + setMode('register') }>{ t('nitro.login.firsttime.link', 'You can create one here') }
-
What's your Habbo called?
+
{ t('nitro.login.card.title', 'What\'s your Habbo called?') }
- + = ({ onAuthenticated, isEntering = fa />
- + = ({ onAuthenticated, isEntering = fa checked={ rememberMe } onChange={ e => setRememberMe(e.target.checked) } /> - Ricordami + { t('login.remember_me', 'Remember me') } { turnstileEnabled && mode === 'login' && = ({ onAuthenticated, isEntering = fa /> } { loginServerReachable === false &&
- The gameserver isn't running right now. Please try again in a moment. + { t('nitro.login.server.offline.short', 'The gameserver isn\'t running right now. Please try again in a moment.') }
} @@ -791,9 +800,9 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa type="submit" className="ok-button" disabled={ submitting || isEntering || isLocked } - >{ isEntering ? 'Entrando…' : loginPingingServer ? 'Checking…' : 'OK' } + >{ isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }
- setMode('forgot') }>Forgotten your password? + setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }
@@ -1144,22 +1153,22 @@ const RegisterDialog: FC = props => if(!email.trim() || !password || !confirm) { - setLocalError('Please fill in every field.'); + setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.')); return; } if(!EMAIL_REGEX.test(email.trim())) { - setLocalError('Please enter a valid email address.'); + setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.')); return; } if(password.length < 8) { - setLocalError('Your password must be at least 8 characters.'); + setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.')); return; } if(password !== confirm) { - setLocalError('Passwords do not match.'); + setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.')); return; } @@ -1169,13 +1178,13 @@ const RegisterDialog: FC = props => const serverOk = await pingServer(); if(!serverOk) { - setLocalError('The gameserver is not running. Please try again later.'); + setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); return; } const result = await onCheckEmail(email.trim()); if(!result.available) { - setLocalError(result.error || 'This email is already in use.'); + setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); return; } setStep('avatar'); @@ -1254,18 +1263,18 @@ const RegisterDialog: FC = props => const trimmed = username.trim(); if(!trimmed) { - setLocalError('Please choose a Habbo name.'); + setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.')); return; } if(trimmed.length < 3 || trimmed.length > 16) { - setLocalError('Habbo name must be 3–16 characters.'); + setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 3–16 characters.')); return; } if(turnstileEnabled && !turnstileToken) { - setLocalError('Please complete the security check.'); + setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -1275,13 +1284,13 @@ const RegisterDialog: FC = props => const serverOk = await pingServer(); if(!serverOk) { - setLocalError('The gameserver is not running. Please try again later.'); + setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); return; } const result = await onCheckUsername(trimmed); if(!result.available) { - setLocalError(result.error || 'This Habbo name is already taken.'); + setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); return; } } @@ -1308,35 +1317,35 @@ const RegisterDialog: FC = props =>
- Habbo Details - + { t('nitro.login.register.title', 'Habbo Details') } +
{ step === 'credentials' &&
- Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use. + { t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') }
{ serverOffline &&
- The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment. + { t('nitro.login.register.server.offline', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
}
- + setEmail(e.target.value) } />
- + setPassword(e.target.value) } />
- + setConfirm(e.target.value) } />
@@ -1345,7 +1354,7 @@ const RegisterDialog: FC = props =>
1/2
@@ -1354,29 +1363,29 @@ const RegisterDialog: FC = props => { step === 'avatar' &&
- Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name. + { t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') }
{ serverOffline &&
- The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment. + { t('nitro.login.register.server.offline', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
}
- setUsername(e.target.value) } />
@@ -1424,8 +1433,10 @@ const RegisterDialog: FC = props =>
@@ -1442,10 +1453,10 @@ const RegisterDialog: FC = props => { info &&
{ info }
}
- + 2/2
@@ -1483,7 +1494,7 @@ const ForgotDialog: FC = props => if(!email.trim()) { - setLocalError('Please enter your email address.'); + setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.')); return; } @@ -1495,12 +1506,12 @@ const ForgotDialog: FC = props =>
- Reset password - + { t('nitro.login.forgot.title', 'Reset password') } +
- + setEmail(e.target.value) } />
@@ -1516,7 +1527,7 @@ const ForgotDialog: FC = props => { (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
- +
diff --git a/src/components/login/components/NewsWindow.tsx b/src/components/login/components/NewsWindow.tsx index ce76ae7..bd31947 100644 --- a/src/components/login/components/NewsWindow.tsx +++ b/src/components/login/components/NewsWindow.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { t } from '../utils/i18n'; +import { interpolate, t } from '../utils/i18n'; import { resolveNewsImage, resolveNewsLink } from '../utils/news'; interface NewsItem @@ -12,6 +12,26 @@ interface NewsItem linkUrl: string; } +interface RawNewsItem +{ + id?: number; + title?: string; + body?: string; + image?: string | null; + link?: string; + linkUrl?: string; + linkText?: string; +} + +const normalizeNewsItem = (raw: RawNewsItem, fallbackId: number): NewsItem => ({ + id: typeof raw.id === 'number' ? raw.id : fallbackId, + title: typeof raw.title === 'string' ? raw.title : '', + body: typeof raw.body === 'string' ? raw.body : '', + image: typeof raw.image === 'string' && raw.image.length ? interpolate(raw.image) : null, + linkText: typeof raw.linkText === 'string' ? raw.linkText : '', + linkUrl: interpolate((typeof raw.linkUrl === 'string' && raw.linkUrl) || (typeof raw.link === 'string' ? raw.link : '')) +}); + interface NewsWindowProps { newsUrl: string; } const NEWS_AUTO_ADVANCE_MS = 10000; @@ -38,10 +58,10 @@ export const NewsWindow: FC = ({ newsUrl }) => .then((json: unknown) => { if(cancelled) return; - const list = Array.isArray((json as { news?: unknown })?.news) - ? (json as { news: NewsItem[] }).news - : []; - setItems(list); + const rawList = Array.isArray((json as { news?: unknown })?.news) + ? (json as { news: RawNewsItem[] }).news + : Array.isArray(json) ? (json as RawNewsItem[]) : []; + setItems(rawList.map((raw, idx) => normalizeNewsItem(raw, idx + 1))); }) .catch(() => { if(!cancelled) setFailed(true); }); return () => diff --git a/src/css/nitrocard/NitroCardView.css b/src/css/nitrocard/NitroCardView.css index 3b8fad9..296e8b7 100644 --- a/src/css/nitrocard/NitroCardView.css +++ b/src/css/nitrocard/NitroCardView.css @@ -153,16 +153,16 @@ &.active { height: 100%; overflow: hidden; - background: rgba(#fff, 0.5); - border-bottom: 1px solid rgba(#000, 0.2); + background: rgba(255, 255, 255, 0.5); + border-bottom: 1px solid rgba(0, 0, 0, 0.2); } .nitro-card-accordion-set-header { - border-bottom: 1px solid rgba(#000, 0.2); + border-bottom: 1px solid rgba(0, 0, 0, 0.2); } } -@include media-breakpoint-down(lg) { +@media (max-width: 991.98px) { .nitro-card { resize: none !important; max-width: calc(100vw - 16px) !important; diff --git a/src/css/room/RoomWidgets.css b/src/css/room/RoomWidgets.css index 2612d94..eabbfb6 100644 --- a/src/css/room/RoomWidgets.css +++ b/src/css/room/RoomWidgets.css @@ -65,7 +65,7 @@ background: transparent; padding: 3px 0px; color: #FFF; - border-color: rgba(#000, 0.3); + border-color: rgba(0, 0, 0, 0.3); cursor: pointer; &:hover { @@ -137,9 +137,9 @@ left: 50%; transform: translateX(-50%); font-size: large; - background: rgba(#000, 0.95); - box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4); - border-radius: $border-radius; + background: rgba(0, 0, 0, 0.95); + box-shadow: inset 0px 5px rgba(10, 10, 10, 0.6), inset 0 -4px rgba(0, 0, 0, 0.6); + border-radius: 0.375rem; transition: all 0.2s ease; z-index: 21; diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 825ce71..f8ea08c 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -19,6 +19,36 @@ const CLIENT_MODE_DEFAULTS: NitroClientMode = { secureApiEnabled: true }; +const getDeployBaseUrl = (): string => +{ + try + { + const loaderBase = (window as any).__nitroLoaderBase; + if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString(); + } + catch {} + + try + { + const moduleUrl = (import.meta as any).url; + if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString(); + } + catch {} + + try + { + const base = (import.meta as any).env?.BASE_URL; + if(typeof base === 'string' && base.length) + { + const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, ''); + return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`; + } + } + catch {} + + return `${ window.location.origin }/`; +}; + const isDebugEnabled = (): boolean => { try @@ -205,7 +235,7 @@ const getPlainAssetBase = (kind: 'config' | 'gamedata'): string => if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`; - if(kind === 'config') return `${ window.location.origin }/configuration/`; + if(kind === 'config') return new URL('configuration/', getDeployBaseUrl()).toString(); return `${ window.location.origin }/nitro/gamedata/`; }; @@ -227,7 +257,7 @@ export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = { if(!getClientMode().secureAssetsEnabled) { - const plainUrl = new URL(file.replace(/^\/+/, ''), `${ window.location.origin }/`); + const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase(kind)); if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36)); @@ -244,7 +274,7 @@ export const configFileUrl = (file: string, cacheBust = false): string => { if(getClientMode().secureAssetsEnabled) return secureUrl('config', file, cacheBust); - const plainUrl = new URL(`configuration/${ file.replace(/^\/+/, '') }`, `${ window.location.origin }/`); + const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase('config')); if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36)); diff --git a/vite.config.mjs b/vite.config.mjs index 7bf9554..a73910a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -16,13 +16,17 @@ export default defineConfig({ rendererRoot, ] }, - + proxy: { + '/api': { + target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096', + changeOrigin: true, + } + } }, resolve: { tsconfigPaths: true, alias: { '@': resolve(__dirname, 'src'), - '@layout': resolve(__dirname, 'src/layout'), '~': resolve(__dirname, 'node_modules'), '@nitrots/api': resolve(rendererRoot, 'packages/api/src/index.ts'), '@nitrots/assets': resolve(rendererRoot, 'packages/assets/src/index.ts'), @@ -43,17 +47,21 @@ export default defineConfig({ } }, build: { - assetsInlineLimit: 4096, + assetsInlineLimit: 102400, chunkSizeWarningLimit: 200000, + manifest: true, rollupOptions: { - input: resolve(__dirname, 'index.html'), output: { - inlineDynamicImports: true, - entryFileNames: 'assets/app.js', - chunkFileNames: 'assets/app.js', - assetFileNames: assetInfo => assetInfo.name && assetInfo.name.endsWith('.css') - ? 'assets/app.css' - : 'src/assets/[name]-[hash].[ext]' + assetFileNames: 'src/assets/[name]-[hash].[ext]', + manualChunks: id => + { + if(id.includes('node_modules')) + { + if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3') || id.includes('Nitro_Render_V3')) return 'nitro-renderer'; + + return 'vendor'; + } + } } } }