mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
checkpoint: secure assets and login flow baseline
This commit is contained in:
+1
-35
@@ -1,35 +1 @@
|
|||||||
<!doctype html>
|
<div id="root"></div><script type="module" src="/src/bootstrap.ts"></script>
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Nitro</title>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<link rel="manifest" crossorigin="use-credentials" href="/site.webmanifest" />
|
|
||||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Nitro" />
|
|
||||||
<meta name="application-name" content="Nitro" />
|
|
||||||
<meta name="msapplication-TileColor" content="#000000" />
|
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root" class="w-full h-full"></div>
|
|
||||||
<script>
|
|
||||||
window.NitroConfig = {
|
|
||||||
"config.urls": ["renderer-config.json?v=" + Math.random(), "ui-config.json?v=" + Math.random()],
|
|
||||||
"sso.ticket": new URLSearchParams(window.location.search).get("sso") || null,
|
|
||||||
"forward.type": new URLSearchParams(window.location.search).get("room", ) ? 2 : -1,
|
|
||||||
"forward.id": new URLSearchParams(window.location.search).get("room") || 0,
|
|
||||||
"friend.id": new URLSearchParams(window.location.search).get("friend") || 0,
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+3
-2
@@ -4,8 +4,9 @@
|
|||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite --base=/client/ --host",
|
"prebuild": "node scripts/write-asset-loader.mjs",
|
||||||
"build": "vite --base=/client/ build",
|
"start": "vite --host",
|
||||||
|
"build": "vite build && node scripts/minify-dist.mjs",
|
||||||
"build:prod": "npx browserslist@latest --update-db && yarn build",
|
"build:prod": "npx browserslist@latest --update-db && yarn build",
|
||||||
"eslint": "eslint ./src"
|
"eslint": "eslint ./src"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='<div style="position:fixed;inset:0;background:#6eadc8;overflow:hidden;z-index:1"><img src="https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center top" alt=""><img src="https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png" style="position:absolute;left:0;bottom:0;width:100%;height:100%;object-fit:none;object-position:left bottom" alt=""><img src="https://hotel.slogga.it/client/nitro/images/reception/background_right.png" style="position:absolute;right:0;bottom:0;width:400px;height:100%;object-fit:none;object-position:right bottom" alt=""><img src="https://hotel.slogga.it/client/nitro/images/reception/drape.png" style="position:absolute;left:0;top:0;width:190px;height:220px;object-fit:contain;object-position:left top" alt=""><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i<b.length;i++)o[i]=b[i]^k[i%k.length]^i*31&255;return o},z=async b=>{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\.\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="it">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Nitro Current Messenger Mockup</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #27313a;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-shell {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 2px solid #000;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f2f2eb;
|
|
||||||
box-shadow: 0 8px 22px rgba(0, 0, 0, .28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-header-shell {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 31px;
|
|
||||||
max-height: 31px;
|
|
||||||
border: 2px solid #3c88a6;
|
|
||||||
border-bottom-color: #000;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
background: #30728c;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-title {
|
|
||||||
margin: 0 auto;
|
|
||||||
color: #fff;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-close-button {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
min-width: 20px;
|
|
||||||
border: 2px solid #000;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #bf2c2c;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-close-button::before,
|
|
||||||
.nitro-card-close-button::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
width: 10px;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: #fff;
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-close-button::before {
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-close-button::after {
|
|
||||||
transform: translate(-50%, -50%) rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-card-content-shell {
|
|
||||||
height: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nitro-friends-messenger {
|
|
||||||
width: 800px;
|
|
||||||
height: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messenger-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 4fr 8fr;
|
|
||||||
gap: 8px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messenger-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messenger-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-grid-item {
|
|
||||||
position: relative;
|
|
||||||
min-height: 50px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: 1px solid #7f8b94;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #d9e4ea;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-grid-item.active {
|
|
||||||
background: #f7fbff;
|
|
||||||
box-shadow: inset 0 0 0 2px #4d9fc7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-item-count {
|
|
||||||
position: absolute;
|
|
||||||
right: 4px;
|
|
||||||
top: 4px;
|
|
||||||
min-width: 18px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #f2d64b;
|
|
||||||
color: #000;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-row {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
gap: 4px;
|
|
||||||
min-height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-head {
|
|
||||||
position: relative;
|
|
||||||
width: 50px;
|
|
||||||
height: 80px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-end;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-head img {
|
|
||||||
position: absolute;
|
|
||||||
left: -16px;
|
|
||||||
top: -13px;
|
|
||||||
width: 64px;
|
|
||||||
height: auto;
|
|
||||||
image-rendering: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-name {
|
|
||||||
align-self: center;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-thread-title {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-left {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: 1px solid #1e7295;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #1e7295;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: inset 0 2px rgba(255,255,255,.15), inset 0 -2px rgba(0,0,0,.10), 0 1px rgba(0,0,0,.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.danger {
|
|
||||||
border-color: #a81a12;
|
|
||||||
background: #a81a12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.success {
|
|
||||||
border-color: #00800b;
|
|
||||||
background: #00800b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgba(255,255,255,.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #cfd7dd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-group {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-group.own {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-avatar {
|
|
||||||
position: relative;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-avatar img {
|
|
||||||
position: absolute;
|
|
||||||
left: -19px;
|
|
||||||
top: -22px;
|
|
||||||
width: 72px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble {
|
|
||||||
position: relative;
|
|
||||||
max-width: 420px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #dfdfdf;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble.left::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: -8px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-right: 8px solid #dfdfdf;
|
|
||||||
border-top: 8px solid transparent;
|
|
||||||
border-bottom: 8px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble.right::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: -8px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 8px solid #dfdfdf;
|
|
||||||
border-top: 8px solid transparent;
|
|
||||||
border-bottom: 8px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-header {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-line {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-row input {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: 1px solid #9aa6ad;
|
|
||||||
border-radius: 4px;
|
|
||||||
outline: none;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="nitro-card-shell nitro-friends-messenger">
|
|
||||||
<div class="nitro-card-header-shell">
|
|
||||||
<span class="nitro-card-title">Le tue chat aperte (2)</span>
|
|
||||||
<div class="nitro-card-close-button"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nitro-card-content-shell">
|
|
||||||
<div class="messenger-grid">
|
|
||||||
<div class="messenger-column">
|
|
||||||
<div class="section-title">Messenger</div>
|
|
||||||
<div class="messenger-list">
|
|
||||||
<div class="layout-grid-item active">
|
|
||||||
<div class="layout-item-count">1</div>
|
|
||||||
<div class="friend-row">
|
|
||||||
<div class="avatar-head">
|
|
||||||
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&headonly=1&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="friend-name">Jarchy</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="layout-grid-item">
|
|
||||||
<div class="friend-row">
|
|
||||||
<div class="avatar-head">
|
|
||||||
<img alt=",Homy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403&direction=2&head_direction=2&headonly=1&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="friend-name">,Homy</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="messenger-column">
|
|
||||||
<div class="active-thread-title">Tu + Jarchy</div>
|
|
||||||
|
|
||||||
<div class="actions-row">
|
|
||||||
<div class="actions-left">
|
|
||||||
<button class="button"><span class="mini-icon"></span></button>
|
|
||||||
<button class="button"><span class="mini-icon"></span></button>
|
|
||||||
<button class="button danger">Denuncia</button>
|
|
||||||
</div>
|
|
||||||
<button class="button">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-messages">
|
|
||||||
<div class="thread-group">
|
|
||||||
<div class="message-avatar">
|
|
||||||
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="message-bubble left">
|
|
||||||
<div class="message-header">Jarchy</div>
|
|
||||||
<div class="message-line">dddove sei?</div>
|
|
||||||
</div>
|
|
||||||
<div class="message-time">7 ore fa</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="thread-group own">
|
|
||||||
<div>
|
|
||||||
<div class="message-bubble right">
|
|
||||||
<div class="message-header">Tu</div>
|
|
||||||
<div class="message-line">su</div>
|
|
||||||
<div class="message-line">slogga</div>
|
|
||||||
<div class="message-line">vieni li</div>
|
|
||||||
</div>
|
|
||||||
<div class="message-time">6 ore fa</div>
|
|
||||||
</div>
|
|
||||||
<div class="message-avatar own">
|
|
||||||
<img alt="Tu" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403&direction=4&head_direction=4&size=l">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="thread-group">
|
|
||||||
<div class="message-avatar">
|
|
||||||
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="message-bubble left">
|
|
||||||
<div class="message-header">Jarchy</div>
|
|
||||||
<div class="message-line">arrivo</div>
|
|
||||||
</div>
|
|
||||||
<div class="message-time">6 ore fa</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-row">
|
|
||||||
<input value="" placeholder="Fai clic qui per scrivere a Jarchy">
|
|
||||||
<button class="button success">Parla</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
|
|
||||||
<style>
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0;}
|
|
||||||
.root{display:flex;justify-content:center;padding:1.5rem 0;font-family:Arial,Helvetica,sans-serif;}
|
|
||||||
.card{width:332px;height:445px;display:flex;flex-direction:column;border:2px solid #000;border-radius:10px;overflow:hidden;background:#f2f2eb;box-shadow:0 6px 20px rgba(0,0,0,.25);}
|
|
||||||
.hdr{display:flex;align-items:center;justify-content:space-between;padding:0 8px;min-height:30px;background:#30728c;border-bottom:1px solid #000;flex-shrink:0;}
|
|
||||||
.hdr-title{color:#fff;font-size:13px;}
|
|
||||||
.hdr-min{width:18px;height:18px;border:2px solid #000;border-radius:3px;background:#bbb;cursor:pointer;display:flex;align-items:center;justify-content:center;}
|
|
||||||
.hdr-min::after{content:"";width:8px;height:2px;background:#555;display:block;}
|
|
||||||
.avatar-bar{display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid #000;background:#d9e4ea;flex-shrink:0;overflow-x:auto;}
|
|
||||||
.av-item{width:36px;height:36px;border:2px solid #7f8b94;border-radius:4px;background:#c0cdd5;overflow:hidden;position:relative;cursor:pointer;flex-shrink:0;}
|
|
||||||
.av-item.active{border-color:#1e7295;box-shadow:0 0 0 1px #1e7295;}
|
|
||||||
.av-item img{position:absolute;left:50%;top:50%;transform:translate(-50%,-62%) scale(0.65);width:64px;}
|
|
||||||
.av-badge{position:absolute;top:-3px;right:-3px;min-width:12px;height:12px;border-radius:6px;background:#f2d64b;border:1px solid #000;font-size:8px;font-weight:700;color:#000;display:flex;align-items:center;justify-content:center;padding:0 2px;}
|
|
||||||
.thread-hdr{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;border-bottom:1px solid #000;flex-shrink:0;background:#e8eef2;}
|
|
||||||
.thread-name{font-size:12px;font-weight:700;color:#000;}
|
|
||||||
.acts{display:flex;gap:4px;align-items:center;}
|
|
||||||
.btn{display:inline-flex;align-items:center;justify-content:center;height:22px;padding:0 7px;border:1px solid #1e7295;border-radius:3px;background:#1e7295;color:#fff;font-size:11px;cursor:pointer;}
|
|
||||||
.btn.danger{border-color:#a81a12;background:#a81a12;}
|
|
||||||
.btn.close-btn{border-color:#888;background:#ccc;color:#000;font-size:13px;padding:0 5px;}
|
|
||||||
.btn.icon-btn{width:22px;padding:0;}
|
|
||||||
.icon-sq{width:10px;height:10px;background:rgba(255,255,255,.35);border-radius:1px;display:block;}
|
|
||||||
.messages{flex:1;min-height:0;overflow-y:auto;padding:8px;background:#cfd7dd;display:flex;flex-direction:column;gap:8px;}
|
|
||||||
.msg-row{display:flex;gap:6px;align-items:flex-start;}
|
|
||||||
.msg-row.own{flex-direction:row-reverse;}
|
|
||||||
.msg-av{width:40px;height:52px;flex-shrink:0;position:relative;overflow:hidden;}
|
|
||||||
.msg-av img{position:absolute;left:50%;top:0;transform:translateX(-50%);width:64px;}
|
|
||||||
.msg-body{display:flex;flex-direction:column;gap:2px;max-width:200px;}
|
|
||||||
.bubble{background:#dfdfdf;border:1px solid #bbb;border-radius:3px;padding:4px 7px;font-size:12px;line-height:1.4;color:#000;position:relative;}
|
|
||||||
.bubble.left::before{content:"";position:absolute;top:10px;left:-7px;border:6px solid transparent;border-right-color:#bbb;}
|
|
||||||
.bubble.left::after{content:"";position:absolute;top:10px;left:-5px;border:5px solid transparent;border-right-color:#dfdfdf;}
|
|
||||||
.bubble.right::before{content:"";position:absolute;top:10px;right:-7px;border:6px solid transparent;border-left-color:#bbb;}
|
|
||||||
.bubble.right::after{content:"";position:absolute;top:10px;right:-5px;border:5px solid transparent;border-left-color:#dfdfdf;}
|
|
||||||
.msg-time{font-size:10px;color:#666;}
|
|
||||||
.msg-row.own .msg-time{text-align:right;}
|
|
||||||
.input-row{display:flex;gap:5px;padding:6px 8px;border-top:1px solid #000;background:#e8eef2;flex-shrink:0;align-items:center;}
|
|
||||||
.input-row input{flex:1;height:26px;border:1px solid #9aa6ad;border-radius:3px;padding:0 7px;font-size:12px;background:#fff;outline:none;}
|
|
||||||
.btn.send{border-color:#00800b;background:#00800b;}
|
|
||||||
</style>
|
|
||||||
<div class="root">
|
|
||||||
<div class="card">
|
|
||||||
<div class="hdr">
|
|
||||||
<span class="hdr-title">Le tue chat aperte (7)</span>
|
|
||||||
<div class="hdr-min"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="avatar-bar">
|
|
||||||
<div class="av-item active" style="position:relative;">
|
|
||||||
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l">
|
|
||||||
<span class="av-badge">1</span>
|
|
||||||
</div>
|
|
||||||
<div class="av-item">
|
|
||||||
<img alt=",Homy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="av-item">
|
|
||||||
<img alt="u3" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-1.ch-215-62.lg-280-110&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="av-item">
|
|
||||||
<img alt="u4" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-3096-1370.ch-3030-110.lg-3023-110&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="av-item">
|
|
||||||
<img alt="u5" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-3.ch-210-66.lg-270-82&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="av-item">
|
|
||||||
<img alt="u6" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-185-10.ch-220-1338.lg-275-110&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
<div class="av-item">
|
|
||||||
<img alt="u7" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-2.ch-230-62.lg-285-110&direction=2&head_direction=2&size=l">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="thread-hdr">
|
|
||||||
<span class="thread-name">Tu + Jarchy</span>
|
|
||||||
<div class="acts">
|
|
||||||
<button class="btn icon-btn"><span class="icon-sq"></span></button>
|
|
||||||
<button class="btn icon-btn"><span class="icon-sq"></span></button>
|
|
||||||
<button class="btn danger">Denuncia</button>
|
|
||||||
<button class="btn close-btn">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="messages">
|
|
||||||
<div class="msg-row">
|
|
||||||
<div class="msg-av"><img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l"></div>
|
|
||||||
<div class="msg-body">
|
|
||||||
<div style="font-size:11px;font-weight:700;color:#000;">Jarchy:</div>
|
|
||||||
<div class="bubble left">dddove sei?</div>
|
|
||||||
<div class="msg-time">7 ore fa</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="msg-row own">
|
|
||||||
<div class="msg-av"><img alt="Tu" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403&direction=4&head_direction=4&size=l"></div>
|
|
||||||
<div class="msg-body">
|
|
||||||
<div style="font-size:11px;font-weight:700;color:#000;text-align:right;">,Homy:</div>
|
|
||||||
<div class="bubble right">su<br>slogga<br>vieni li</div>
|
|
||||||
<div class="msg-time">7 ore fa</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="msg-row">
|
|
||||||
<div class="msg-av"><img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l"></div>
|
|
||||||
<div class="msg-body">
|
|
||||||
<div style="font-size:11px;font-weight:700;color:#000;">Jarchy:</div>
|
|
||||||
<div class="bubble left">arrivo</div>
|
|
||||||
<div class="msg-time">7 ore fa</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-row">
|
|
||||||
<input placeholder="Fai clic qui per scrivere a Jarchy" type="text">
|
|
||||||
<button class="btn send">Parla</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"socket.url": "wss://nitro.slogga.it:2096",
|
"socket.url": "wss://nitro.slogga.it:2096",
|
||||||
"api.url": "https://nitro.slogga.it:2096",
|
"api.url": "https://nitro.slogga.it:2096",
|
||||||
"asset.url": "https://client.slogga.it/nitro/bundled",
|
"asset.url": "https://hotel.slogga.it/client/nitro/bundled",
|
||||||
"image.library.url": "https://client.slogga.it/c_images/",
|
"image.library.url": "https://hotel.slogga.it/client/c_images/",
|
||||||
"hof.furni.url": "https://client.slogga.it/c_images/dcr/hof_furni",
|
"hof.furni.url": "https://hotel.slogga.it/client/c_images/dcr/hof_furni",
|
||||||
"images.url": "https://client.slogga.it/nitro/images",
|
"images.url": "https://hotel.slogga.it/client/nitro/images",
|
||||||
"gamedata.url": "https://client.slogga.it/nitro/gamedata",
|
"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=",
|
||||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
||||||
"external.texts.url": [
|
"external.texts.url": [
|
||||||
"${gamedata.url}/ExternalTexts.json",
|
"${gamedata.url}/ExternalTexts.json",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
const KEY = new TextEncoder().encode('slogga-dist-assets-2026');
|
||||||
|
|
||||||
|
export const encodeBytes = bytes =>
|
||||||
|
{
|
||||||
|
const output = new Uint8Array(bytes.length);
|
||||||
|
|
||||||
|
for(let index = 0; index < bytes.length; index++)
|
||||||
|
{
|
||||||
|
output[index] = bytes[index] ^ KEY[index % KEY.length] ^ ((index * 31) & 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { encodeBytes } from './asset-codec.mjs';
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { gzipSync } from 'zlib';
|
||||||
|
|
||||||
|
const dist = 'dist';
|
||||||
|
const buildVersion = Date.now().toString(36);
|
||||||
|
|
||||||
|
const walk = dir =>
|
||||||
|
{
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for(const entry of readdirSync(dir))
|
||||||
|
{
|
||||||
|
const path = join(dir, entry);
|
||||||
|
const stat = statSync(path);
|
||||||
|
|
||||||
|
if(stat.isDirectory()) out.push(...walk(path));
|
||||||
|
else out.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const minifyJson = path =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
writeFileSync(path, JSON.stringify(JSON.parse(readFileSync(path, 'utf8'))));
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptFile = path =>
|
||||||
|
{
|
||||||
|
const bytes = gzipSync(readFileSync(path), { level: 9 });
|
||||||
|
writeFileSync(path + '.dat', encodeBytes(bytes));
|
||||||
|
rmSync(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
if(!existsSync(dist)) throw new Error('dist folder not found');
|
||||||
|
|
||||||
|
for(const file of walk(dist))
|
||||||
|
{
|
||||||
|
if(file.endsWith('.json')) minifyJson(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const file of [ 'renderer-config.json', 'ui-config.json' ])
|
||||||
|
{
|
||||||
|
const target = join(dist, file);
|
||||||
|
if(existsSync(target)) rmSync(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const file of walk(dist))
|
||||||
|
{
|
||||||
|
if(file.endsWith('.js') && !file.endsWith('asset-loader.js')) encryptFile(file);
|
||||||
|
if(file.endsWith('.css')) encryptFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetMirrorDir = join(dist, 'src', 'assets');
|
||||||
|
mkdirSync(assetMirrorDir, { recursive: true });
|
||||||
|
|
||||||
|
for(const file of [ 'app.css.dat', 'app.js.dat' ])
|
||||||
|
{
|
||||||
|
const source = join(dist, 'assets', file);
|
||||||
|
const target = join(assetMirrorDir, file);
|
||||||
|
|
||||||
|
if(existsSync(source)) copyFileSync(source, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicLoaderAssets = [
|
||||||
|
[ 'src/assets/images/loading/loading.gif', 'loading.gif' ],
|
||||||
|
[ 'src/assets/images/notifications/nitro_v3.png', 'nitro_v3.png' ]
|
||||||
|
];
|
||||||
|
|
||||||
|
for(const [ source, file ] of publicLoaderAssets)
|
||||||
|
{
|
||||||
|
const target = join(dist, 'assets', file);
|
||||||
|
const mirrorTarget = join(assetMirrorDir, file);
|
||||||
|
|
||||||
|
if(existsSync(source))
|
||||||
|
{
|
||||||
|
copyFileSync(source, target);
|
||||||
|
copyFileSync(source, mirrorTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(join(dist, 'index.html'), `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="root"></div><script src="asset-loader.js?v=${ buildVersion }"></script></body></html>`);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
|
||||||
|
const loader = `(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='<div style="position:fixed;inset:0;background:#6eadc8;overflow:hidden;z-index:1"><img src="https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center top" alt=""><img src="https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png" style="position:absolute;left:0;bottom:0;width:100%;height:100%;object-fit:none;object-position:left bottom" alt=""><img src="https://hotel.slogga.it/client/nitro/images/reception/background_right.png" style="position:absolute;right:0;bottom:0;width:400px;height:100%;object-fit:none;object-position:right bottom" alt=""><img src="https://hotel.slogga.it/client/nitro/images/reception/drape.png" style="position:absolute;left:0;top:0;width:190px;height:220px;object-fit:contain;object-position:left top" alt=""><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i<b.length;i++)o[i]=b[i]^k[i%k.length]^i*31&255;return o},z=async b=>{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\\.\\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();`;
|
||||||
|
const target = resolve('public', 'asset-loader.js');
|
||||||
|
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, loader);
|
||||||
+144
-49
@@ -1,5 +1,5 @@
|
|||||||
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
|
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useCallback, useEffect, useState } from 'react';
|
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { GetUIVersion } from './api';
|
import { GetUIVersion } from './api';
|
||||||
import { Base } from './common';
|
import { Base } from './common';
|
||||||
import { LoadingView } from './components/loading/LoadingView';
|
import { LoadingView } from './components/loading/LoadingView';
|
||||||
@@ -10,13 +10,51 @@ import { useMessageEvent, useNitroEvent } from './hooks';
|
|||||||
|
|
||||||
NitroVersion.UI_VERSION = GetUIVersion();
|
NitroVersion.UI_VERSION = GetUIVersion();
|
||||||
|
|
||||||
|
const preloadUrl = async (url: string): Promise<void> =>
|
||||||
|
{
|
||||||
|
if(!url) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await fetch(url, { cache: 'force-cache' });
|
||||||
|
await response.arrayBuffer();
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const preloadImage = (url: string): void =>
|
||||||
|
{
|
||||||
|
if(!url) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.src = url;
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const asStringArray = (value: unknown): string[] =>
|
||||||
|
{
|
||||||
|
if(Array.isArray(value)) return value.filter(item => typeof item === 'string');
|
||||||
|
if(typeof value === 'string' && value.length) return [ value ];
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
export const App: FC<{}> = props =>
|
export const App: FC<{}> = props =>
|
||||||
{
|
{
|
||||||
const [ isReady, setIsReady ] = useState(false);
|
const [ isReady, setIsReady ] = useState(false);
|
||||||
const [ errorMessage, setErrorMessage ] = useState('');
|
const [ errorMessage, setErrorMessage ] = useState('');
|
||||||
const [ homeUrl, setHomeUrl ] = useState('');
|
const [ homeUrl, setHomeUrl ] = useState('');
|
||||||
const [ showLogin, setShowLogin ] = useState(false);
|
const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket']);
|
||||||
|
const [ isEnteringHotel, setIsEnteringHotel ] = useState(false);
|
||||||
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
||||||
|
const warmupPromiseRef = useRef<Promise<void>>(null);
|
||||||
|
const rendererPromiseRef = useRef<Promise<any>>(null);
|
||||||
|
const tickersStartedRef = useRef(false);
|
||||||
|
const heartbeatIntervalRef = useRef<number>(null);
|
||||||
const showSessionExpired = useCallback(() =>
|
const showSessionExpired = useCallback(() =>
|
||||||
{
|
{
|
||||||
const baseUrl = window.location.origin + '/';
|
const baseUrl = window.location.origin + '/';
|
||||||
@@ -24,13 +62,15 @@ export const App: FC<{}> = props =>
|
|||||||
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
|
setIsEnteringHotel(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAuthenticated = useCallback((ssoTicket: string) =>
|
const handleAuthenticated = useCallback((ssoTicket: string) =>
|
||||||
{
|
{
|
||||||
if(!ssoTicket) return;
|
if(!ssoTicket) return;
|
||||||
window.NitroConfig['sso.ticket'] = ssoTicket;
|
window.NitroConfig['sso.ticket'] = ssoTicket;
|
||||||
setShowLogin(false);
|
GetConfiguration().setValue('sso.ticket', ssoTicket);
|
||||||
|
setIsEnteringHotel(true);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setPrepareTrigger(prev => prev + 1);
|
setPrepareTrigger(prev => prev + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -47,10 +87,89 @@ export const App: FC<{}> = props =>
|
|||||||
LegacyExternalInterface.callGame('showGame', parser.url);
|
LegacyExternalInterface.callGame('showGame', parser.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const startRenderer = useCallback((width: number, height: number) =>
|
||||||
|
{
|
||||||
|
if(rendererPromiseRef.current) return rendererPromiseRef.current;
|
||||||
|
|
||||||
|
const rawUseBackBuffer = window.NitroConfig?.['renderer.useBackBuffer'];
|
||||||
|
const useBackBuffer = (rawUseBackBuffer === undefined)
|
||||||
|
? true
|
||||||
|
: ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true'));
|
||||||
|
|
||||||
|
rendererPromiseRef.current = PrepareRenderer({
|
||||||
|
width: Math.floor(width),
|
||||||
|
height: Math.floor(height),
|
||||||
|
resolution: window.devicePixelRatio,
|
||||||
|
autoDensity: true,
|
||||||
|
backgroundAlpha: 0,
|
||||||
|
preference: 'webgl',
|
||||||
|
eventMode: 'none',
|
||||||
|
failIfMajorPerformanceCaveat: false,
|
||||||
|
roundPixels: true,
|
||||||
|
useBackBuffer
|
||||||
|
});
|
||||||
|
|
||||||
|
return rendererPromiseRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startWarmup = useCallback((width: number, height: number) =>
|
||||||
|
{
|
||||||
|
if(warmupPromiseRef.current) return warmupPromiseRef.current;
|
||||||
|
|
||||||
|
warmupPromiseRef.current = (async () =>
|
||||||
|
{
|
||||||
|
await GetConfiguration().init();
|
||||||
|
|
||||||
|
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
||||||
|
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
||||||
|
NitroLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
|
||||||
|
NitroLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
|
||||||
|
NitroLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
|
||||||
|
NitroLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);
|
||||||
|
|
||||||
|
startRenderer(width, height).catch(error => NitroLogger.error('[LoginScreen] Renderer warmup failed', error));
|
||||||
|
|
||||||
|
const interpolate = (value: string) => GetConfiguration().interpolate(value);
|
||||||
|
const assetUrls = asStringArray(GetConfiguration().getValue<unknown>('preload.assets.urls')).map(interpolate);
|
||||||
|
const gamedataUrls = [
|
||||||
|
...asStringArray(GetConfiguration().getValue<unknown>('external.texts.url')).map(interpolate),
|
||||||
|
...[
|
||||||
|
'furnidata.url',
|
||||||
|
'productdata.url',
|
||||||
|
'avatar.actions.url',
|
||||||
|
'avatar.figuredata.url',
|
||||||
|
'avatar.figuremap.url',
|
||||||
|
'avatar.effectmap.url'
|
||||||
|
].map(key => interpolate(GetConfiguration().getValue<string>(key, ''))).filter(Boolean)
|
||||||
|
];
|
||||||
|
const loginImages = ((GetConfiguration().getValue<Record<string, unknown>>('loginview', {})?.images) as Record<string, string>) ?? {};
|
||||||
|
const loginImageUrls = [
|
||||||
|
loginImages.background,
|
||||||
|
loginImages.sun,
|
||||||
|
loginImages.drape,
|
||||||
|
loginImages.left,
|
||||||
|
loginImages['right.repeat'],
|
||||||
|
loginImages.right
|
||||||
|
].filter(Boolean).map(interpolate);
|
||||||
|
|
||||||
|
loginImageUrls.forEach(preloadImage);
|
||||||
|
gamedataUrls.forEach(url => preloadUrl(url));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[
|
||||||
|
GetAssetManager().downloadAssets(assetUrls),
|
||||||
|
GetLocalizationManager().init(),
|
||||||
|
GetAvatarRenderManager().init(),
|
||||||
|
GetSoundManager().init()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return warmupPromiseRef.current;
|
||||||
|
}, [ startRenderer ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
let heartbeatInterval: number = null;
|
|
||||||
|
|
||||||
const prepare = async (width: number, height: number) =>
|
const prepare = async (width: number, height: number) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -58,6 +177,7 @@ export const App: FC<{}> = props =>
|
|||||||
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
||||||
|
|
||||||
const ssoTicket = window.NitroConfig['sso.ticket'];
|
const ssoTicket = window.NitroConfig['sso.ticket'];
|
||||||
|
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
||||||
|
|
||||||
if(!ssoTicket || ssoTicket === '')
|
if(!ssoTicket || ssoTicket === '')
|
||||||
{
|
{
|
||||||
@@ -79,6 +199,7 @@ export const App: FC<{}> = props =>
|
|||||||
{
|
{
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
|
startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +209,7 @@ export const App: FC<{}> = props =>
|
|||||||
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
|
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
|
setIsEnteringHotel(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,46 +217,11 @@ export const App: FC<{}> = props =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUseBackBuffer = window.NitroConfig['renderer.useBackBuffer'];
|
const renderer = await startRenderer(width, height);
|
||||||
const useBackBuffer = (rawUseBackBuffer === undefined)
|
|
||||||
? true
|
|
||||||
: ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true'));
|
|
||||||
|
|
||||||
const renderer = await PrepareRenderer({
|
|
||||||
width: Math.floor(width),
|
|
||||||
height: Math.floor(height),
|
|
||||||
resolution: window.devicePixelRatio,
|
|
||||||
autoDensity: true,
|
|
||||||
backgroundAlpha: 0,
|
|
||||||
preference: 'webgl',
|
|
||||||
eventMode: 'none',
|
|
||||||
failIfMajorPerformanceCaveat: false,
|
|
||||||
roundPixels: true,
|
|
||||||
useBackBuffer // Keep disabled by default unless explicitly enabled in NitroConfig
|
|
||||||
});
|
|
||||||
|
|
||||||
await GetConfiguration().init();
|
|
||||||
|
|
||||||
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
|
||||||
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
|
||||||
NitroLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
|
|
||||||
NitroLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
|
|
||||||
NitroLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
|
|
||||||
NitroLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);
|
|
||||||
|
|
||||||
const assetUrls = GetConfiguration().getValue<string[]>('preload.assets.urls').map(url => GetConfiguration().interpolate(url)) ?? [];
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
[
|
|
||||||
GetAssetManager().downloadAssets(assetUrls),
|
|
||||||
GetLocalizationManager().init(),
|
|
||||||
GetAvatarRenderManager().init(),
|
|
||||||
GetSoundManager().init(),
|
|
||||||
GetSessionDataManager().init(),
|
|
||||||
GetRoomSessionManager().init()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
await startWarmup(width, height);
|
||||||
|
await GetSessionDataManager().init();
|
||||||
|
await GetRoomSessionManager().init();
|
||||||
await GetRoomEngine().init();
|
await GetRoomEngine().init();
|
||||||
await GetCommunication().init();
|
await GetCommunication().init();
|
||||||
|
|
||||||
@@ -142,17 +229,25 @@ export const App: FC<{}> = props =>
|
|||||||
|
|
||||||
HabboWebTools.sendHeartBeat();
|
HabboWebTools.sendHeartBeat();
|
||||||
|
|
||||||
heartbeatInterval = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
|
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||||
|
heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
|
||||||
|
|
||||||
|
if(!tickersStartedRef.current)
|
||||||
|
{
|
||||||
|
tickersStartedRef.current = true;
|
||||||
GetTicker().add(ticker => GetRoomEngine().update(ticker));
|
GetTicker().add(ticker => GetRoomEngine().update(ticker));
|
||||||
GetTicker().add(ticker => renderer.render(GetStage()));
|
GetTicker().add(ticker => renderer.render(GetStage()));
|
||||||
GetTicker().add(ticker => GetTexturePool().run());
|
GetTicker().add(ticker => GetTexturePool().run());
|
||||||
|
}
|
||||||
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
|
setShowLogin(false);
|
||||||
|
setIsEnteringHotel(false);
|
||||||
}
|
}
|
||||||
catch(err)
|
catch(err)
|
||||||
{
|
{
|
||||||
NitroLogger.error(err);
|
NitroLogger.error(err);
|
||||||
|
setIsEnteringHotel(false);
|
||||||
showSessionExpired();
|
showSessionExpired();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -161,15 +256,15 @@ export const App: FC<{}> = props =>
|
|||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
{
|
{
|
||||||
if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval);
|
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||||
};
|
};
|
||||||
}, [ prepareTrigger ]);
|
}, [ prepareTrigger, startWarmup, startRenderer ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
|
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
|
||||||
{ !isReady && !showLogin &&
|
{ !isReady && !showLogin && errorMessage.length > 0 &&
|
||||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
||||||
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } /> }
|
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
||||||
{ isReady && <MainView /> }
|
{ isReady && <MainView /> }
|
||||||
<ReconnectView />
|
<ReconnectView />
|
||||||
<Base id="draggable-windows-container" />
|
<Base id="draggable-windows-container" />
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { installSecureFetch, secureUrl } from './secure-assets';
|
||||||
|
|
||||||
|
installSecureFetch();
|
||||||
|
|
||||||
|
const setBootDebug = (message: string) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(window as any).__nitroBootDebug = message;
|
||||||
|
const secureNode = document.getElementById('nitro-secure-debug');
|
||||||
|
|
||||||
|
if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`;
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
setBootDebug('boot: secure fetch installed');
|
||||||
|
|
||||||
|
const search = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
(window as any).NitroSecureApiUrl = 'https://nitro.slogga.it:2096';
|
||||||
|
(window as any).NitroConfig = {
|
||||||
|
'config.urls': [
|
||||||
|
secureUrl('config', 'renderer-config.json'),
|
||||||
|
secureUrl('config', 'ui-config.json')
|
||||||
|
],
|
||||||
|
'sso.ticket': search.get('sso') || null,
|
||||||
|
'forward.type': search.get('room') ? 2 : -1,
|
||||||
|
'forward.id': search.get('room') || 0,
|
||||||
|
'friend.id': search.get('friend') || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
setBootDebug('boot: NitroConfig assigned');
|
||||||
|
|
||||||
|
import('./index')
|
||||||
|
.then(() => setBootDebug('boot: app bundle imported'))
|
||||||
|
.catch(error =>
|
||||||
|
{
|
||||||
|
setBootDebug(`boot: import failed ${ error?.message || error }`);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
@@ -11,11 +11,9 @@ export const LoadingView: FC<LoadingViewProps> = props => {
|
|||||||
const { isError = false, message = '', homeUrl = '' } = props;
|
const { isError = false, message = '', homeUrl = '' } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column fullHeight position="relative" className="relative z-[100] bg-[radial-gradient(#1d1a24,#003a6b)]">
|
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
|
||||||
<Base fullHeight className="container h-100">
|
<Base fullHeight className="container h-100">
|
||||||
<Column fullHeight alignItems="center" justifyContent="center">
|
<Column fullHeight alignItems="center" justifyContent="center">
|
||||||
{ !isError &&
|
|
||||||
<Base className="absolute inset-0 m-auto w-[84px] h-[84px] [zoom:1.5] [image-rendering:pixelated] bg-[url('@/assets/images/loading/loading.gif')] bg-no-repeat bg-left-top" /> }
|
|
||||||
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
|
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
|
||||||
{ isError && (message && message.length) ?
|
{ isError && (message && message.length) ?
|
||||||
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
||||||
@@ -31,10 +29,7 @@ export const LoadingView: FC<LoadingViewProps> = props => {
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
:
|
: null
|
||||||
<Text fontSizeCustom={32} variant="white" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
|
||||||
The hotel is loading ...
|
|
||||||
</Text>
|
|
||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const LOCK_KEY = 'nitro.login.lock';
|
|||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ATTEMPTS = 5;
|
||||||
const LOCK_WINDOW_MS = 60_000;
|
const LOCK_WINDOW_MS = 60_000;
|
||||||
const LOCK_DURATION_MS = 2 * 60_000;
|
const LOCK_DURATION_MS = 2 * 60_000;
|
||||||
|
const DEFAULT_LOGIN_IMAGES: Record<string, string> = {
|
||||||
|
background: 'https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png',
|
||||||
|
'background.colour': '#6eadc8',
|
||||||
|
drape: 'https://hotel.slogga.it/client/nitro/images/reception/drape.png',
|
||||||
|
left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png',
|
||||||
|
right: 'https://hotel.slogga.it/client/nitro/images/reception/background_right.png'
|
||||||
|
};
|
||||||
|
|
||||||
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
|
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
|
||||||
|
|
||||||
@@ -39,9 +46,10 @@ const writeLock = (state: AttemptState) =>
|
|||||||
export interface LoginViewProps
|
export interface LoginViewProps
|
||||||
{
|
{
|
||||||
onAuthenticated: (ssoTicket: string) => void;
|
onAuthenticated: (ssoTicket: string) => void;
|
||||||
|
isEntering?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = false }) =>
|
||||||
{
|
{
|
||||||
const [ mode, setMode ] = useState<DialogMode>('login');
|
const [ mode, setMode ] = useState<DialogMode>('login');
|
||||||
const [ username, setUsername ] = useState('');
|
const [ username, setUsername ] = useState('');
|
||||||
@@ -55,7 +63,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
const [ loginPingingServer, setLoginPingingServer ] = useState(false);
|
const [ loginPingingServer, setLoginPingingServer ] = useState(false);
|
||||||
const submitTimeRef = useRef(0);
|
const submitTimeRef = useRef(0);
|
||||||
|
|
||||||
const loginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
|
const configuredLoginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
|
||||||
|
const loginImages: Record<string, string> = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages };
|
||||||
|
|
||||||
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
|
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
|
||||||
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
|
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
|
||||||
@@ -64,6 +73,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
|
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
|
||||||
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
|
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
|
||||||
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
|
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
|
||||||
|
const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right ]);
|
||||||
|
const [ loginImagesVersion, setLoginImagesVersion ] = useState(0);
|
||||||
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
|
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
|
||||||
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
|
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
|
||||||
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
|
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
|
||||||
@@ -86,6 +97,30 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
if(mode === 'login') resetLoginTurnstile();
|
if(mode === 'login') resetLoginTurnstile();
|
||||||
}, [ mode, resetLoginTurnstile ]);
|
}, [ mode, resetLoginTurnstile ]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(!loginImageUrls.length) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loginImageUrls.forEach(url =>
|
||||||
|
{
|
||||||
|
const image = new Image();
|
||||||
|
|
||||||
|
image.onload = image.onerror = () =>
|
||||||
|
{
|
||||||
|
if(!cancelled) setLoginImagesVersion(version => version + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ loginImageUrls ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if(!info) return;
|
if(!info) return;
|
||||||
@@ -138,7 +173,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
return { ok: response.ok, status: response.status, payload };
|
return { ok: response.ok, status: response.status, payload };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '/api/health');
|
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
|
||||||
const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
|
const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
|
||||||
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
|
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
|
||||||
const checkServerReachable = useCallback(async (): Promise<boolean> =>
|
const checkServerReachable = useCallback(async (): Promise<boolean> =>
|
||||||
@@ -196,7 +231,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
{
|
{
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if(submitting) return;
|
if(submitting || isEntering) return;
|
||||||
|
|
||||||
const nowTs = Date.now();
|
const nowTs = Date.now();
|
||||||
if(nowTs - submitTimeRef.current < 1000) return;
|
if(nowTs - submitTimeRef.current < 1000) return;
|
||||||
@@ -263,7 +298,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
{
|
{
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
|
}, [ submitting, isEntering, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
|
||||||
|
|
||||||
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
||||||
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
||||||
@@ -409,12 +444,15 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
className="nitro-login-view"
|
className="nitro-login-view"
|
||||||
style={ backgroundColor ? { background: backgroundColor } : undefined }
|
style={ backgroundColor ? { background: backgroundColor } : undefined }
|
||||||
>
|
>
|
||||||
{ background ? <div className="login-background login-layer" style={ { backgroundImage: `url(${ background })` } } /> : null }
|
{ background ? <img className="login-background login-layer login-layer-img" src={ background } alt="" draggable={ false } /> : null }
|
||||||
{ sun ? <div className="login-sun login-layer" style={ { backgroundImage: `url(${ sun })` } } /> : null }
|
{ sun ? <img className="login-sun login-layer login-layer-img" src={ sun } alt="" draggable={ false } /> : null }
|
||||||
{ drape ? <div className="login-drape login-layer" style={ { backgroundImage: `url(${ drape })` } } /> : null }
|
{ drape ? <img className="login-drape login-layer login-layer-img" src={ drape } alt="" draggable={ false } /> : null }
|
||||||
{ left ? <div className="login-left login-layer" style={ { backgroundImage: `url(${ left })` } } /> : null }
|
{ left ? <img className="login-left login-layer login-layer-img" src={ left } alt="" draggable={ false } /> : null }
|
||||||
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
|
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
|
||||||
{ right ? <div className="login-right login-layer" style={ { backgroundImage: `url(${ right })` } } /> : null }
|
{ right ? <img className="login-right login-layer login-layer-img" src={ right } alt="" draggable={ false } /> : null }
|
||||||
|
<div className="login-image-preloader" aria-hidden="true" data-version={ loginImagesVersion }>
|
||||||
|
{ loginImageUrls.map(url => <img key={ url } src={ url } decoding="async" loading="eager" alt="" />) }
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="login-stack">
|
<div className="login-stack">
|
||||||
<div className="nitro-login-card">
|
<div className="nitro-login-card">
|
||||||
@@ -475,8 +513,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ok-button"
|
className="ok-button"
|
||||||
disabled={ submitting || isLocked || loginServerReachable === false || loginPingingServer }
|
disabled={ submitting || isEntering || isLocked || loginServerReachable === false || loginPingingServer }
|
||||||
>{ loginPingingServer ? 'Checking…' : 'OK' }</button>
|
>{ isEntering ? 'Entrando…' : loginPingingServer ? 'Checking…' : 'OK' }</button>
|
||||||
</div>
|
</div>
|
||||||
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
|
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,6 +12,28 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-login-view .login-layer-img {
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
object-fit: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-login-view .login-image-preloader {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-login-view .login-image-preloader img {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-login-view .login-background {
|
.nitro-login-view .login-background {
|
||||||
@@ -19,8 +41,8 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-repeat: repeat-x;
|
object-fit: cover;
|
||||||
background-position: center top;
|
object-position: center top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-login-view .login-sun {
|
.nitro-login-view .login-sun {
|
||||||
@@ -28,8 +50,8 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 600px;
|
width: 600px;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
background-size: contain;
|
object-fit: contain;
|
||||||
background-position: center top;
|
object-position: center top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-login-view .login-drape {
|
.nitro-login-view .login-drape {
|
||||||
@@ -38,6 +60,8 @@
|
|||||||
width: 190px;
|
width: 190px;
|
||||||
height: 220px;
|
height: 220px;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: left top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-login-view .login-left {
|
.nitro-login-view .login-left {
|
||||||
@@ -45,9 +69,8 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-position: left bottom;
|
object-fit: none;
|
||||||
background-size: auto;
|
object-position: left bottom;
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-login-view .login-right-repeat {
|
.nitro-login-view .login-right-repeat {
|
||||||
@@ -64,7 +87,8 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-position: right bottom;
|
object-fit: none;
|
||||||
|
object-position: right bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Foreground Login Card Stack ─── */
|
/* ─── Foreground Login Card Stack ─── */
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
type SecureSession = {
|
||||||
|
publicKey: string;
|
||||||
|
key: CryptoKey;
|
||||||
|
fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDebugEnabled = (): boolean =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const search = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
return search.get('secureDebug') === '1' || localStorage.getItem('nitro.secure.debug') === '1';
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDebugState = (message: string): void =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(window as any).__nitroSecureDebug = message;
|
||||||
|
const log = Array.isArray((window as any).__nitroSecureDebugLog)
|
||||||
|
? (window as any).__nitroSecureDebugLog
|
||||||
|
: [];
|
||||||
|
|
||||||
|
log.push(message);
|
||||||
|
(window as any).__nitroSecureDebugLog = log.slice(-50);
|
||||||
|
|
||||||
|
if(!isDebugEnabled()) return;
|
||||||
|
|
||||||
|
const existing = document.getElementById('nitro-secure-debug');
|
||||||
|
|
||||||
|
if(existing)
|
||||||
|
{
|
||||||
|
existing.textContent = (window as any).__nitroSecureDebugLog.slice(-8).join('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.id = 'nitro-secure-debug';
|
||||||
|
node.style.position = 'fixed';
|
||||||
|
node.style.left = '8px';
|
||||||
|
node.style.bottom = '8px';
|
||||||
|
node.style.zIndex = '2147483647';
|
||||||
|
node.style.padding = '6px 8px';
|
||||||
|
node.style.maxWidth = '70vw';
|
||||||
|
node.style.background = 'rgba(0,0,0,0.85)';
|
||||||
|
node.style.color = '#00ff90';
|
||||||
|
node.style.font = '12px monospace';
|
||||||
|
node.style.whiteSpace = 'pre-wrap';
|
||||||
|
node.style.pointerEvents = 'none';
|
||||||
|
node.textContent = (window as any).__nitroSecureDebugLog.slice(-8).join('\n');
|
||||||
|
document.body.appendChild(node);
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
let secureSessionPromise: Promise<SecureSession> = null;
|
||||||
|
let installed = false;
|
||||||
|
const secureResponseCache = new Map<string, Promise<Response>>();
|
||||||
|
|
||||||
|
const bytesToBase64 = (bytes: ArrayBuffer): string =>
|
||||||
|
{
|
||||||
|
let binary = '';
|
||||||
|
const view = new Uint8Array(bytes);
|
||||||
|
|
||||||
|
for(let index = 0; index < view.length; index++) binary += String.fromCharCode(view[index]);
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexValue = (code: number): number =>
|
||||||
|
{
|
||||||
|
if(code >= 48 && code <= 57) return code - 48;
|
||||||
|
if(code >= 65 && code <= 70) return code - 55;
|
||||||
|
if(code >= 97 && code <= 102) return code - 87;
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToBytes = (hex: string): Uint8Array =>
|
||||||
|
{
|
||||||
|
const normalized = hex.trim();
|
||||||
|
|
||||||
|
if((normalized.length % 2) !== 0) throw new Error('Invalid encrypted hex payload.');
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(normalized.length / 2);
|
||||||
|
|
||||||
|
for(let index = 0; index < bytes.length; index++)
|
||||||
|
{
|
||||||
|
const high = hexValue(normalized.charCodeAt(index * 2));
|
||||||
|
const low = hexValue(normalized.charCodeAt((index * 2) + 1));
|
||||||
|
|
||||||
|
if(high < 0 || low < 0) throw new Error('Invalid encrypted hex payload.');
|
||||||
|
|
||||||
|
bytes[index] = (high << 4) | low;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveAesKey = async (privateKey: CryptoKey, serverKeyBase64: string): Promise<{ key: CryptoKey; fingerprint: string }> =>
|
||||||
|
{
|
||||||
|
const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0));
|
||||||
|
const serverKey = await crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
serverBytes,
|
||||||
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const secret = await crypto.subtle.deriveBits({ name: 'ECDH', public: serverKey }, privateKey, 256);
|
||||||
|
const salt = textEncoder.encode('nitro-secure-assets-v1');
|
||||||
|
const material = new Uint8Array(secret.byteLength + salt.length);
|
||||||
|
material.set(new Uint8Array(secret), 0);
|
||||||
|
material.set(salt, secret.byteLength);
|
||||||
|
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', material);
|
||||||
|
const fingerprintHash = await crypto.subtle.digest('SHA-256', hash);
|
||||||
|
const fingerprint = Array.from(new Uint8Array(fingerprintHash).slice(0, 8)).map(value => value.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: await crypto.subtle.importKey('raw', hash, 'AES-GCM', false, [ 'encrypt', 'decrypt' ]),
|
||||||
|
fingerprint
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiBase = (): string =>
|
||||||
|
{
|
||||||
|
const configured = (window as any).NitroSecureApiUrl;
|
||||||
|
|
||||||
|
if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, '');
|
||||||
|
|
||||||
|
return 'https://nitro.slogga.it:2096';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secureUrl = (kind: 'config' | 'gamedata', file: string): string =>
|
||||||
|
{
|
||||||
|
const base = getApiBase();
|
||||||
|
|
||||||
|
return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSecureSession = async (): Promise<SecureSession> =>
|
||||||
|
{
|
||||||
|
setDebugState('secure: generating ECDH session');
|
||||||
|
|
||||||
|
const pair = await crypto.subtle.generateKey(
|
||||||
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||||
|
true,
|
||||||
|
[ 'deriveBits' ]
|
||||||
|
);
|
||||||
|
const publicKey = await crypto.subtle.exportKey('spki', pair.publicKey);
|
||||||
|
const response = await fetch(`${ getApiBase() }/nitro-sec/bootstrap`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: bytesToBase64(publicKey) })
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!response.ok) throw new Error(`Secure bootstrap failed: HTTP ${ response.status }`);
|
||||||
|
|
||||||
|
const fingerprint = response.headers.get('X-Nitro-Key-Fp') || 'none';
|
||||||
|
const payload = await response.json();
|
||||||
|
const serverKey = typeof payload.key === 'string' ? payload.key : '';
|
||||||
|
const clientPublicKey = bytesToBase64(publicKey);
|
||||||
|
|
||||||
|
if(!serverKey) throw new Error('Secure bootstrap returned an invalid server key.');
|
||||||
|
|
||||||
|
setDebugState(`secure: bootstrap ok fp=${ fingerprint }`);
|
||||||
|
|
||||||
|
const derived = await deriveAesKey(pair.privateKey, serverKey);
|
||||||
|
|
||||||
|
return { publicKey: clientPublicKey, key: derived.key, fingerprint: derived.fingerprint };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSecureSession = (): Promise<SecureSession> =>
|
||||||
|
{
|
||||||
|
if(!secureSessionPromise) secureSessionPromise = createSecureSession();
|
||||||
|
|
||||||
|
return secureSessionPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptResponse = async (session: SecureSession, response: Response): Promise<Response> =>
|
||||||
|
{
|
||||||
|
setDebugState(`secure: decrypt start status=${ response.status }`);
|
||||||
|
const bytes = hexToBytes(await response.text());
|
||||||
|
|
||||||
|
if(bytes.length < 13) throw new Error('Encrypted response is too short.');
|
||||||
|
|
||||||
|
const iv = bytes.slice(0, 12);
|
||||||
|
const payload = bytes.slice(12);
|
||||||
|
const clear = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, session.key, payload);
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
|
||||||
|
headers.set('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
headers.delete('X-Nitro-Sec');
|
||||||
|
|
||||||
|
const text = textDecoder.decode(clear);
|
||||||
|
setDebugState(`secure: decrypt ok bytes=${ bytes.length }`);
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneCachedResponse = async (responsePromise: Promise<Response>): Promise<Response> =>
|
||||||
|
{
|
||||||
|
const response = await responsePromise;
|
||||||
|
|
||||||
|
return response.clone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSecureCacheKey = (requestUrl: string): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const url = new URL(requestUrl, window.location.href);
|
||||||
|
|
||||||
|
if(!url.pathname.includes('/nitro-sec/file')) return requestUrl;
|
||||||
|
|
||||||
|
const kind = url.searchParams.get('kind') || '';
|
||||||
|
const file = (url.searchParams.get('file') || '')
|
||||||
|
.replace(/^[\\/]+/, '')
|
||||||
|
.split('?')[0]
|
||||||
|
.split('#')[0];
|
||||||
|
|
||||||
|
return `${ url.origin }${ url.pathname }?kind=${ kind }&file=${ file }`;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return requestUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytesToHex = (bytes: Uint8Array): string =>
|
||||||
|
{
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
for(let index = 0; index < bytes.length; index++) output += bytes[index].toString(16).padStart(2, '0');
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptBytes = async (session: SecureSession, clear: ArrayBuffer): Promise<string> =>
|
||||||
|
{
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, session.key, clear));
|
||||||
|
const out = new Uint8Array(iv.length + encrypted.length);
|
||||||
|
|
||||||
|
out.set(iv, 0);
|
||||||
|
out.set(encrypted, iv.length);
|
||||||
|
|
||||||
|
return bytesToHex(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isApiUrl = (requestUrl: string): boolean =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new URL(requestUrl, window.location.href).pathname.startsWith('/api/');
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return requestUrl.startsWith('/api/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRequestBody = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<ArrayBuffer | null> =>
|
||||||
|
{
|
||||||
|
if(method === 'GET' || method === 'HEAD') return null;
|
||||||
|
if(init?.body !== undefined)
|
||||||
|
{
|
||||||
|
if(typeof init.body === 'string') return textEncoder.encode(init.body).buffer;
|
||||||
|
if(init.body instanceof ArrayBuffer) return init.body;
|
||||||
|
if(ArrayBuffer.isView(init.body)) return init.body.buffer.slice(init.body.byteOffset, init.body.byteOffset + init.body.byteLength);
|
||||||
|
if(init.body instanceof Blob) return init.body.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(input instanceof Request) return input.clone().arrayBuffer();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installSecureFetch = (): void =>
|
||||||
|
{
|
||||||
|
if(installed) return;
|
||||||
|
|
||||||
|
installed = true;
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
||||||
|
{
|
||||||
|
const requestUrl = typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
|
||||||
|
if(requestUrl.includes('/nitro-sec/file'))
|
||||||
|
{
|
||||||
|
const method = init?.method || (input instanceof Request ? input.method : 'GET');
|
||||||
|
const cacheKey = method.toUpperCase() === 'GET' ? normalizeSecureCacheKey(requestUrl) : null;
|
||||||
|
|
||||||
|
if(cacheKey && secureResponseCache.has(cacheKey)) return cloneCachedResponse(secureResponseCache.get(cacheKey));
|
||||||
|
|
||||||
|
const responsePromise = (async () =>
|
||||||
|
{
|
||||||
|
const session = await getSecureSession();
|
||||||
|
setDebugState(`secure: fetching ${ requestUrl }`);
|
||||||
|
const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
|
||||||
|
|
||||||
|
headers.set('X-Nitro-Key', session.publicKey);
|
||||||
|
|
||||||
|
const response = await nativeFetch(input, { ...init, headers });
|
||||||
|
setDebugState(`secure: response ${ response.status } encrypted=${ response.headers.get('X-Nitro-Sec') === '1' } fp=${ response.headers.get('X-Nitro-Key-Fp') || 'none' } derive=${ response.headers.get('X-Nitro-Derive-Fp') || 'none' } client=${ session.fingerprint }`);
|
||||||
|
|
||||||
|
if(response.headers.get('X-Nitro-Sec') === '1')
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const decrypted = await decryptResponse(session, response);
|
||||||
|
setDebugState(`secure: decrypted ${ requestUrl }`);
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
catch(error)
|
||||||
|
{
|
||||||
|
setDebugState(`secure: decrypt failed ${ (error as Error)?.message || error }`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDebugState(`secure: plain response ${ requestUrl } status=${ response.status }`);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if(cacheKey) secureResponseCache.set(cacheKey, responsePromise);
|
||||||
|
|
||||||
|
return cloneCachedResponse(responsePromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isApiUrl(requestUrl))
|
||||||
|
{
|
||||||
|
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||||
|
const session = await getSecureSession();
|
||||||
|
const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
|
||||||
|
const clearBody = await readRequestBody(input, init, method);
|
||||||
|
const encryptedInit: RequestInit = { ...init, method, headers };
|
||||||
|
|
||||||
|
headers.set('X-Nitro-Key', session.publicKey);
|
||||||
|
headers.set('X-Nitro-Api', '1');
|
||||||
|
|
||||||
|
if(clearBody)
|
||||||
|
{
|
||||||
|
encryptedInit.body = await encryptBytes(session, clearBody);
|
||||||
|
headers.set('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await nativeFetch(input, encryptedInit);
|
||||||
|
|
||||||
|
if(response.headers.get('X-Nitro-Sec') === '1') return decryptResponse(session, response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nativeFetch(input, init);
|
||||||
|
};
|
||||||
|
};
|
||||||
+8
-11
@@ -48,20 +48,17 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
assetsInlineLimit: 102400,
|
assetsInlineLimit: 4096,
|
||||||
chunkSizeWarningLimit: 200000,
|
chunkSizeWarningLimit: 200000,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
input: resolve(__dirname, 'index.html'),
|
||||||
output: {
|
output: {
|
||||||
assetFileNames: 'src/assets/[name]-[hash].[ext]',
|
inlineDynamicImports: true,
|
||||||
manualChunks: id =>
|
entryFileNames: 'assets/app.js',
|
||||||
{
|
chunkFileNames: 'assets/app.js',
|
||||||
if(id.includes('node_modules'))
|
assetFileNames: assetInfo => assetInfo.name && assetInfo.name.endsWith('.css')
|
||||||
{
|
? 'assets/app.css'
|
||||||
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3') || id.includes('Nitro_Render_V3')) return 'nitro-renderer';
|
: 'src/assets/[name]-[hash].[ext]'
|
||||||
|
|
||||||
return 'vendor';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user