checkpoint: secure assets and login flow baseline

This commit is contained in:
Lorenzune
2026-04-23 07:01:09 +02:00
parent f6096371be
commit 237c523f9a
17 changed files with 3573 additions and 694 deletions
+1 -35
View File
@@ -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
View File
@@ -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"
}, },
+1
View File
@@ -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."})})();
-437
View File
@@ -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>
-116
View File
@@ -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>
+5 -5
View File
@@ -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
+13
View File
@@ -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;
};
+88
View File
@@ -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>`);
+8
View File
@@ -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
View File
@@ -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" />
+41
View File
@@ -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;
});
+2 -7
View File
@@ -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>
+50 -12
View File
@@ -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>
+32 -8
View File
@@ -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 ─── */
+378
View File
@@ -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
View File
@@ -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';
}
}
} }
} }
} }