@@ -475,8 +513,8 @@ export const LoginView: FC = ({ onAuthenticated }) =>
+ disabled={ submitting || isEntering || isLocked || loginServerReachable === false || loginPingingServer }
+ >{ isEntering ? 'Entrando…' : loginPingingServer ? 'Checking…' : 'OK' }
setMode('forgot') }>Forgotten your password?
diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css
index 984a68f..78c9d52 100644
--- a/src/css/login/LoginView.css
+++ b/src/css/login/LoginView.css
@@ -12,6 +12,28 @@
position: absolute;
background-repeat: no-repeat;
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 {
@@ -19,8 +41,8 @@
left: 0;
width: 100%;
height: 100%;
- background-repeat: repeat-x;
- background-position: center top;
+ object-fit: cover;
+ object-position: center top;
}
.nitro-login-view .login-sun {
@@ -28,8 +50,8 @@
transform: translateX(-50%);
width: 600px;
height: 600px;
- background-size: contain;
- background-position: center top;
+ object-fit: contain;
+ object-position: center top;
}
.nitro-login-view .login-drape {
@@ -38,6 +60,8 @@
width: 190px;
height: 220px;
z-index: 3;
+ object-fit: contain;
+ object-position: left top;
}
.nitro-login-view .login-left {
@@ -45,9 +69,8 @@
left: 0;
width: 100%;
height: 100%;
- background-position: left bottom;
- background-size: auto;
- background-repeat: no-repeat;
+ object-fit: none;
+ object-position: left bottom;
}
.nitro-login-view .login-right-repeat {
@@ -64,7 +87,8 @@
right: 0;
width: 400px;
height: 100%;
- background-position: right bottom;
+ object-fit: none;
+ object-position: right bottom;
}
/* ─── Foreground Login Card Stack ─── */
diff --git a/src/secure-assets.ts b/src/secure-assets.ts
new file mode 100644
index 0000000..6957316
--- /dev/null
+++ b/src/secure-assets.ts
@@ -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
= null;
+let installed = false;
+const secureResponseCache = new Map>();
+
+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 =>
+{
+ 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 =>
+{
+ if(!secureSessionPromise) secureSessionPromise = createSecureSession();
+
+ return secureSessionPromise;
+};
+
+const decryptResponse = async (session: SecureSession, response: Response): Promise =>
+{
+ 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): Promise =>
+{
+ 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 =>
+{
+ 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 =>
+{
+ 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 =>
+ {
+ 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);
+ };
+};
diff --git a/vite.config.mjs b/vite.config.mjs
index 8fcb161..e8fa8ff 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -48,20 +48,17 @@ export default defineConfig({
}
},
build: {
- assetsInlineLimit: 102400,
+ assetsInlineLimit: 4096,
chunkSizeWarningLimit: 200000,
rollupOptions: {
+ input: resolve(__dirname, 'index.html'),
output: {
- assetFileNames: 'src/assets/[name]-[hash].[ext]',
- manualChunks: id =>
- {
- if(id.includes('node_modules'))
- {
- if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3') || id.includes('Nitro_Render_V3')) return 'nitro-renderer';
-
- return 'vendor';
- }
- }
+ inlineDynamicImports: true,
+ entryFileNames: 'assets/app.js',
+ chunkFileNames: 'assets/app.js',
+ assetFileNames: assetInfo => assetInfo.name && assetInfo.name.endsWith('.css')
+ ? 'assets/app.css'
+ : 'src/assets/[name]-[hash].[ext]'
}
}
}