mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
cd8951e536
Restoring `yarn start` from "takes forever" back to seconds.
A previous session had symlinked `public/nitro-assets` and `public/swf`
to a sibling `Nitro-Files/` tree (~177k files) so Vite could serve them
through `publicDir`. The cost was massive: chokidar tried to install a
watcher on every file at startup and the dev server hung for minutes
on Windows. Upstream `duckietm/Nitro-V3` never does this — assets live
on a separate HTTP server referenced by URL in the JSON configs.
Changes:
- Remove the two symlinks under `public/` and add a .gitignore entry
with a note explaining why they must not come back.
- Add a small Vite plugin (`nitroAssetsServer`) that mounts `sirv` on
`/nitro-assets/*` and `/swf/*`, reading from
`../Nitro-Files/{nitro-assets,swf}`. sirv is a connect-style
middleware that bypasses chokidar entirely, so 177k files no longer
cost anything at startup. The plugin also wires the same handler
into `configurePreviewServer` so `yarn preview` keeps working.
- Drop the matching `/nitro-assets` and `/swf` entries from
`server.proxy` — they had been pointed at the auth proxy on :2096
which does not expose those paths.
- Disable `login.turnstile.enabled` in `renderer-config.json`. The
configured sitekey is Cloudflare's "always-passes" test key but the
widget still requires user interaction and blocks the login flow
in local dev.
Login flow fixes that fell out of debugging:
- `prepare()` in App.tsx ran twice under React Strict Mode (mount →
cleanup → mount). The first pass set `setShowLogin(true)`, the
second raced ahead and fell through to `onSessionExpired()`,
clobbering the login UI. Guard the effect with
`lastPrepareTriggerRef` so duplicate runs at the same trigger value
are skipped while intentional re-runs (after a successful login,
which bumps `prepareTrigger`) still go through.
- Call `GetConfiguration().init()` from `bootstrap.ts` before
importing `./index`. The renderer's ConfigurationManager logs
"Missing configuration key" the first time any key is read against
an uninitialised store, and components mounted in the first paint
(login screen, hooks, the renderer warmup) were all hitting that
path before prepare()'s deferred init landed. Pre-loading the
config means the store is already populated when React mounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
608 lines
19 KiB
TypeScript
608 lines
19 KiB
TypeScript
type SecureSession = {
|
|
publicKey: string;
|
|
key: CryptoKey;
|
|
fingerprint: string;
|
|
};
|
|
|
|
export type NitroClientMode = {
|
|
distObfuscationEnabled: boolean;
|
|
secureAssetsEnabled: boolean;
|
|
secureApiEnabled: boolean;
|
|
apiBaseUrl?: string;
|
|
plainConfigBaseUrl?: string;
|
|
plainGamedataBaseUrl?: string;
|
|
};
|
|
|
|
const CLIENT_MODE_DEFAULTS: NitroClientMode = {
|
|
distObfuscationEnabled: true,
|
|
secureAssetsEnabled: true,
|
|
secureApiEnabled: true
|
|
};
|
|
|
|
const getDeployBaseUrl = (): string =>
|
|
{
|
|
try
|
|
{
|
|
const loaderBase = (window as any).__nitroLoaderBase;
|
|
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
|
|
}
|
|
catch
|
|
{}
|
|
|
|
try
|
|
{
|
|
const moduleUrl = (import.meta as any).url;
|
|
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
|
|
}
|
|
catch
|
|
{}
|
|
|
|
try
|
|
{
|
|
const base = (import.meta as any).env?.BASE_URL;
|
|
if(typeof base === 'string' && base.length)
|
|
{
|
|
const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
|
|
}
|
|
}
|
|
catch
|
|
{}
|
|
|
|
return `${ window.location.origin }/`;
|
|
};
|
|
|
|
const isDebugEnabled = (): boolean =>
|
|
{
|
|
try
|
|
{
|
|
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 SECURE_RESPONSE_CACHE_LIMIT = 128;
|
|
let secureSessionCreatedAt = 0;
|
|
const SECURE_SESSION_TTL_MS = 5 * 60 * 1000;
|
|
const REKEY_ENDPOINTS = new Set([
|
|
'/api/auth/login',
|
|
'/api/auth/remember',
|
|
'/api/auth/logout'
|
|
]);
|
|
|
|
let clientModeCache: NitroClientMode | null = null;
|
|
|
|
export const getClientMode = (): NitroClientMode =>
|
|
{
|
|
if(clientModeCache) return clientModeCache;
|
|
|
|
try
|
|
{
|
|
const configured = (window as any).__nitroClientMode;
|
|
|
|
if(configured && typeof configured === 'object')
|
|
{
|
|
clientModeCache = {
|
|
distObfuscationEnabled: configured.distObfuscationEnabled !== false,
|
|
secureAssetsEnabled: configured.secureAssetsEnabled !== false,
|
|
secureApiEnabled: configured.secureApiEnabled !== false,
|
|
apiBaseUrl: typeof configured.apiBaseUrl === 'string' ? configured.apiBaseUrl : '',
|
|
plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '',
|
|
plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : ''
|
|
};
|
|
|
|
return clientModeCache;
|
|
}
|
|
}
|
|
catch
|
|
{}
|
|
|
|
return CLIENT_MODE_DEFAULTS;
|
|
};
|
|
|
|
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 randomHex = (byteLength: number): string =>
|
|
{
|
|
const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
|
|
|
|
return Array.from(bytes).map(value => value.toString(16).padStart(2, '0')).join('');
|
|
};
|
|
|
|
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 mode = getClientMode();
|
|
if(typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return mode.apiBaseUrl.replace(/\/$/, '');
|
|
|
|
const configured = (window as any).NitroSecureApiUrl;
|
|
|
|
if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, '');
|
|
|
|
return window.location.origin;
|
|
};
|
|
|
|
const getPlainAssetBase = (kind: 'config' | 'gamedata'): string =>
|
|
{
|
|
const mode = getClientMode();
|
|
const configured = kind === 'config' ? mode.plainConfigBaseUrl : mode.plainGamedataBaseUrl;
|
|
|
|
if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`;
|
|
|
|
if(kind === 'config') return new URL('configuration/', getDeployBaseUrl()).toString();
|
|
|
|
return `${ window.location.origin }/nitro/gamedata/`;
|
|
};
|
|
|
|
const mapSecureAssetRequestToPlainUrl = (requestUrl: string): string =>
|
|
{
|
|
const url = new URL(requestUrl, window.location.href);
|
|
const kind = (url.searchParams.get('kind') || 'config') as 'config' | 'gamedata';
|
|
const file = (url.searchParams.get('file') || '').replace(/^[\\/]+/, '');
|
|
const plainUrl = new URL(file, getPlainAssetBase(kind));
|
|
const cacheBust = url.searchParams.get('v');
|
|
|
|
if(cacheBust) plainUrl.searchParams.set('v', cacheBust);
|
|
|
|
return plainUrl.toString();
|
|
};
|
|
|
|
export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = false): string =>
|
|
{
|
|
if(!getClientMode().secureAssetsEnabled)
|
|
{
|
|
const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase(kind));
|
|
|
|
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
|
|
|
|
return plainUrl.toString();
|
|
}
|
|
|
|
const base = getApiBase();
|
|
const version = cacheBust ? `&v=${ encodeURIComponent(Date.now().toString(36)) }` : '';
|
|
|
|
return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }${ version }`;
|
|
};
|
|
|
|
export const configFileUrl = (file: string, cacheBust = false): string =>
|
|
{
|
|
if(getClientMode().secureAssetsEnabled) return secureUrl('config', file, cacheBust);
|
|
|
|
const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase('config'));
|
|
|
|
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
|
|
|
|
return plainUrl.toString();
|
|
};
|
|
|
|
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);
|
|
|
|
secureSessionCreatedAt = Date.now();
|
|
|
|
return { publicKey: clientPublicKey, key: derived.key, fingerprint: derived.fingerprint };
|
|
};
|
|
|
|
const clearSecureSession = (clearCache = false): void =>
|
|
{
|
|
secureSessionPromise = null;
|
|
secureSessionCreatedAt = 0;
|
|
if(clearCache) secureResponseCache.clear();
|
|
};
|
|
|
|
export const getSecureSession = (): Promise<SecureSession> =>
|
|
{
|
|
if(secureSessionPromise && secureSessionCreatedAt && ((Date.now() - secureSessionCreatedAt) > SECURE_SESSION_TTL_MS))
|
|
{
|
|
setDebugState('secure: session expired, rotating');
|
|
clearSecureSession();
|
|
}
|
|
|
|
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 cacheSecureResponse = (cacheKey: string, responsePromise: Promise<Response>): void =>
|
|
{
|
|
secureResponseCache.set(cacheKey, responsePromise);
|
|
|
|
responsePromise.catch(() => secureResponseCache.delete(cacheKey));
|
|
|
|
while(secureResponseCache.size > SECURE_RESPONSE_CACHE_LIMIT)
|
|
{
|
|
const oldestKey = secureResponseCache.keys().next().value;
|
|
|
|
if(!oldestKey) break;
|
|
|
|
secureResponseCache.delete(oldestKey);
|
|
}
|
|
};
|
|
|
|
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') || '';
|
|
if(kind === 'config') return requestUrl;
|
|
|
|
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;
|
|
};
|
|
|
|
const buildSecureApiEnvelope = (requestUrl: string, method: string, clearBody: ArrayBuffer | null): ArrayBuffer | null =>
|
|
{
|
|
if(!clearBody) return null;
|
|
|
|
const url = new URL(requestUrl, window.location.href);
|
|
const envelope = {
|
|
ts: Date.now(),
|
|
nonce: randomHex(16),
|
|
method,
|
|
path: `${ url.pathname }${ url.search }`,
|
|
body: bytesToBase64(clearBody)
|
|
};
|
|
|
|
return textEncoder.encode(JSON.stringify(envelope)).buffer;
|
|
};
|
|
|
|
const scheduleSecureRekey = (): void =>
|
|
{
|
|
queueMicrotask(() =>
|
|
{
|
|
clearSecureSession();
|
|
});
|
|
};
|
|
|
|
export const installSecureFetch = (): void =>
|
|
{
|
|
if(installed) return;
|
|
|
|
const mode = getClientMode();
|
|
|
|
if(!mode.secureAssetsEnabled && !mode.secureApiEnabled)
|
|
{
|
|
installed = true;
|
|
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'))
|
|
{
|
|
if(!getClientMode().secureAssetsEnabled) return nativeFetch(mapSecureAssetRequestToPlainUrl(requestUrl), init);
|
|
|
|
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) cacheSecureResponse(cacheKey, responsePromise);
|
|
|
|
return cloneCachedResponse(responsePromise);
|
|
}
|
|
|
|
if(getClientMode().secureApiEnabled && 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 secureBody = buildSecureApiEnvelope(requestUrl, method, clearBody);
|
|
const encryptedInit: RequestInit = { ...init, method, headers };
|
|
|
|
headers.set('X-Nitro-Key', session.publicKey);
|
|
headers.set('X-Nitro-Api', '1');
|
|
|
|
if(secureBody)
|
|
{
|
|
encryptedInit.body = await encryptBytes(session, secureBody);
|
|
headers.set('Content-Type', 'text/plain; charset=utf-8');
|
|
}
|
|
|
|
const response = await nativeFetch(input, encryptedInit);
|
|
|
|
if(response.headers.get('X-Nitro-Sec') === '1')
|
|
{
|
|
const decrypted = await decryptResponse(session, response);
|
|
|
|
try
|
|
{
|
|
const pathname = new URL(requestUrl, window.location.href).pathname;
|
|
|
|
if(response.ok && REKEY_ENDPOINTS.has(pathname))
|
|
{
|
|
setDebugState(`secure: rekey after ${ pathname }`);
|
|
scheduleSecureRekey();
|
|
}
|
|
}
|
|
catch
|
|
{}
|
|
|
|
return decrypted;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
return nativeFetch(input, init);
|
|
};
|
|
};
|