diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8577c7e1b9cc..5ff5a3d809b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,9 +47,6 @@ jobs: - name: Lint run: npm run lint - - name: Check renderer Node import baseline - run: npm run check:renderer-node-imports - - name: Type checks run: npm run type-check diff --git a/.gitignore b/.gitignore index 9e48e2a92e53..dfc36daba60f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ rootCA2.* final.cpp insomnia.ico final.rc +.tmp* diff --git a/packages/insomnia/config/renderer-node-import-baseline.json b/packages/insomnia/config/renderer-node-import-baseline.json deleted file mode 100644 index 0fb28b2a741e..000000000000 --- a/packages/insomnia/config/renderer-node-import-baseline.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "entries": [ - { - "importer": "../insomnia-testing/src/generate/generate.ts", - "builtin": "fs" - }, - { - "importer": "../insomnia-testing/src/run/run.ts", - "builtin": "fs" - }, - { - "importer": "../insomnia-testing/src/run/run.ts", - "builtin": "os" - }, - { - "importer": "../insomnia-testing/src/run/run.ts", - "builtin": "path" - }, - { - "importer": "src/plugins/context/response.ts", - "builtin": "fs" - }, - { - "importer": "src/plugins/context/response.ts", - "builtin": "zlib" - }, - { - "importer": "src/plugins/index.ts", - "builtin": "fs" - }, - { - "importer": "src/plugins/index.ts", - "builtin": "path" - }, - { - "importer": "src/scripting/require-interceptor.ts", - "builtin": "buffer" - }, - { - "importer": "src/scripting/require-interceptor.ts", - "builtin": "timers" - }, - { - "importer": "src/scripting/require-interceptor.ts", - "builtin": "util" - }, - { - "importer": "src/templating/liquid-extension.ts", - "builtin": "crypto" - }, - { - "importer": "src/templating/liquid-extension.ts", - "builtin": "os" - } - ] -} diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 88fe46336212..95ac82ad0738 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -20,9 +20,6 @@ "verify-bundle-plugins": "esr --cache ./scripts/verify-bundle-plugins.ts", "install-x64-native-dependencies": "esr --cache ./scripts/install-x64-native-dependencies.ts", "build": "react-router build && esr --cache ./scripts/build.ts --noErrorTruncation", - "analyze:renderer-node-imports": "cross-env NODE_OPTIONS=--max-old-space-size=8192 INSOMNIA_NODE_IMPORT_REPORT=1 react-router build", - "check:renderer-node-imports": "npm run analyze:renderer-node-imports && esr --cache ./scripts/check-renderer-node-imports.ts", - "update:renderer-node-import-baseline": "npm run analyze:renderer-node-imports && esr --cache ./scripts/check-renderer-node-imports.ts --write-baseline", "build:react-router": "react-router build", "generate:schema": "esr ./src/schema.ts", "build:electron-entrypoints": "cross-env NODE_ENV=development esr esbuild.entrypoints.ts", diff --git a/packages/insomnia/scripts/check-renderer-node-imports.ts b/packages/insomnia/scripts/check-renderer-node-imports.ts deleted file mode 100644 index 0541065641de..000000000000 --- a/packages/insomnia/scripts/check-renderer-node-imports.ts +++ /dev/null @@ -1,115 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -interface ReportRecord { - builtin: string; - importer: string; -} - -interface ReportFile { - records: ReportRecord[]; -} - -interface BaselineEntry { - builtin: string; - importer: string; -} - -interface BaselineFile { - entries: BaselineEntry[]; -} - -const packageRoot = path.resolve(__dirname, '..'); -const reportPath = path.resolve(packageRoot, '.reports', 'renderer-node-imports.json'); -const baselinePath = path.resolve(packageRoot, 'config', 'renderer-node-import-baseline.json'); -const shouldWriteBaseline = process.argv.includes('--write-baseline'); - -const readJson = (filePath: string): T => { - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; -}; - -const toKey = (entry: BaselineEntry) => `${entry.importer}::${entry.builtin}`; - -const normalizeEntries = (entries: BaselineEntry[]) => { - const uniqueEntries = new Map(); - - for (const entry of entries) { - uniqueEntries.set(toKey(entry), { - importer: entry.importer, - builtin: entry.builtin, - }); - } - - return [...uniqueEntries.values()].sort( - (left, right) => left.importer.localeCompare(right.importer) || left.builtin.localeCompare(right.builtin), - ); -}; - -if (!fs.existsSync(reportPath)) { - console.error(`Renderer Node import report not found at ${path.relative(packageRoot, reportPath)}.`); - console.error('Run `npm run analyze:renderer-node-imports -w insomnia` first.'); - process.exit(1); -} - -const report = readJson(reportPath); -const currentEntries = normalizeEntries(report.records.map(({ importer, builtin }) => ({ importer, builtin }))); - -if (shouldWriteBaseline) { - fs.mkdirSync(path.dirname(baselinePath), { recursive: true }); - fs.writeFileSync( - baselinePath, - JSON.stringify( - { - entries: currentEntries, - }, - null, - 2, - ) + '\n', - ); - console.log(`Updated renderer Node import baseline at ${path.relative(packageRoot, baselinePath)}.`); - process.exit(0); -} - -if (!fs.existsSync(baselinePath)) { - console.error(`Renderer Node import baseline not found at ${path.relative(packageRoot, baselinePath)}.`); - console.error('Run `npm run update:renderer-node-import-baseline -w insomnia` to create it.'); - process.exit(1); -} - -const baseline = readJson(baselinePath); -const baselineEntries = normalizeEntries(baseline.entries); - -const currentByKey = new Map(currentEntries.map(entry => [toKey(entry), entry])); -const baselineByKey = new Map(baselineEntries.map(entry => [toKey(entry), entry])); - -const additions = currentEntries.filter(entry => !baselineByKey.has(toKey(entry))); -const removals = baselineEntries.filter(entry => !currentByKey.has(toKey(entry))); - -if (additions.length > 0) { - console.error('Renderer Node import baseline check failed. New renderer Node builtin imports were introduced:'); - for (const addition of additions) { - console.error(`- ${addition.importer} -> ${addition.builtin}`); - } - - if (removals.length > 0) { - console.error('Resolved imports detected during the same run:'); - for (const removal of removals) { - console.error(`- ${removal.importer} -> ${removal.builtin}`); - } - } - - console.error('If these additions are intentional migration baseline changes, update the baseline explicitly.'); - process.exit(1); -} - -console.log('Renderer Node import baseline check passed. No new renderer Node builtin imports were introduced.'); - -if (removals.length > 0) { - console.log('Resolved imports not yet reflected in the baseline:'); - for (const removal of removals) { - console.log(`- ${removal.importer} -> ${removal.builtin}`); - } - console.log( - 'Run `npm run update:renderer-node-import-baseline -w insomnia` after intentionally ratcheting the baseline down.', - ); -} diff --git a/packages/insomnia/src/account/session.ts b/packages/insomnia/src/account/session.ts index 5fd9ee5c7f1c..9d573bc6ed82 100644 --- a/packages/insomnia/src/account/session.ts +++ b/packages/insomnia/src/account/session.ts @@ -5,7 +5,7 @@ import { models, services } from '~/insomnia-data'; import { AI_PLUGIN_NAME, LLM_BACKENDS } from '../common/constants'; import { database } from '../common/database'; -import * as crypt from './crypt'; +import type { AESMessage } from './crypt'; export interface SessionData { accountId: string; @@ -15,7 +15,7 @@ export interface SessionData { lastName: string; symmetricKey: JsonWebKey; publicKey: JsonWebKey; - encPrivateKey: crypt.AESMessage; + encPrivateKey: AESMessage; } /** Creates a session from a sessionId and derived symmetric key. */ @@ -28,7 +28,8 @@ export async function absorbKey(sessionId: string, key: string) { ]); const { public_key: publicKey, enc_private_key: encPrivateKey, enc_symmetric_key: encSymmetricKey } = keys; const { email, id: accountId, first_name: firstName, last_name: lastName } = profile; - const symmetricKeyStr = crypt.decryptAES(key, JSON.parse(encSymmetricKey)); + const { decryptAES } = await import('./crypt'); + const symmetricKeyStr = decryptAES(key, JSON.parse(encSymmetricKey)); // Store the information for later await setSessionData( @@ -58,7 +59,8 @@ export async function getPrivateKey() { throw new Error("Can't get private key: session is missing keys."); } - const privateKeyStr = crypt.decryptAES(symmetricKey, encPrivateKey); + const { decryptAES } = await import('./crypt'); + const privateKeyStr = decryptAES(symmetricKey, encPrivateKey); return JSON.parse(privateKeyStr) as JsonWebKey; } @@ -105,7 +107,7 @@ export async function setSessionData( email: string, symmetricKey: JsonWebKey, publicKey: JsonWebKey, - encPrivateKey: crypt.AESMessage, + encPrivateKey: AESMessage, ) { const sessionData: SessionData = { id, diff --git a/packages/insomnia/src/common/__tests__/har.test.ts b/packages/insomnia/src/common/__tests__/har.test.ts index 6d334d0f3541..9c7c030b950b 100644 --- a/packages/insomnia/src/common/__tests__/har.test.ts +++ b/packages/insomnia/src/common/__tests__/har.test.ts @@ -6,7 +6,7 @@ import type { Cookie, Request, Response } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import { database as db } from '../../common/database'; -import { exportHar, exportHarResponse, exportHarWithRequest } from '../har'; +import { exportHar, exportHarResponse, exportHarWithRequest } from '../../main/har'; import { getRenderedRequestAndContext } from '../render'; describe('export', () => { diff --git a/packages/insomnia/src/common/cookies.ts b/packages/insomnia/src/common/cookies.ts index b81fbae8af78..a3b8c1d6ad41 100644 --- a/packages/insomnia/src/common/cookies.ts +++ b/packages/insomnia/src/common/cookies.ts @@ -1,7 +1,10 @@ +import type * as Har from 'har-format'; import { Cookie as ToughCookie, CookieJar, type CookieJSON } from 'tough-cookie'; import type { Cookie } from '~/insomnia-data'; +import { getSetCookieHeaders } from './misc'; + /** * Get a list of cookie objects from a request.jar() */ @@ -47,6 +50,64 @@ export const jarFromCookies = (cookies: Cookie[] | ToughCookie[]) => { return jar; }; +export function mapCookie(cookie: ToughCookie): Har.Cookie { + const harCookie: Har.Cookie = { + name: cookie.key, + value: cookie.value, + }; + + if (cookie.path) { + harCookie.path = cookie.path; + } + + if (cookie.domain) { + harCookie.domain = cookie.domain; + } + + if (cookie.expires) { + let expires: Date | null = null; + + if (cookie.expires instanceof Date) { + expires = cookie.expires; + } else if (typeof cookie.expires === 'string') { + expires = new Date(cookie.expires); + } else if (typeof cookie.expires === 'number') { + expires = new Date(); + expires.setTime(cookie.expires); + } + + if (expires && !Number.isNaN(expires.getTime())) { + harCookie.expires = expires.toISOString(); + } + } + + if (cookie.httpOnly) { + harCookie.httpOnly = true; + } + + if (cookie.secure) { + harCookie.secure = true; + } + + return harCookie; +} + +export function getResponseCookiesFromHeaders(headers: Har.Cookie[]) { + return getSetCookieHeaders(headers).reduce((accumulator, harCookie) => { + let cookie: null | undefined | ToughCookie = null; + + try { + cookie = ToughCookie.parse(harCookie.value || '', { loose: true }); + } catch {} + + if (cookie === null || cookie === undefined) { + return accumulator; + } + + return [...accumulator, mapCookie(cookie)]; + }, [] as Har.Cookie[]); +} + export const cookieToString = (cookie: Parameters[0] | ToughCookie) => { // Cookie can either be a plain JS object or Cookie instance if (!(cookie instanceof ToughCookie)) { diff --git a/packages/insomnia/src/common/mime.ts b/packages/insomnia/src/common/mime.ts new file mode 100644 index 000000000000..24e66c642e58 --- /dev/null +++ b/packages/insomnia/src/common/mime.ts @@ -0,0 +1,46 @@ +const extensionToMimeType: Record = { + csv: 'text/csv', + gif: 'image/gif', + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'application/javascript', + json: 'application/json', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + txt: 'text/plain', + xml: 'application/xml', + yaml: 'application/yaml', + yml: 'application/yaml', +}; + +const mimeTypeToExtension: Record = { + ...Object.fromEntries( + Object.entries(extensionToMimeType).map(([extension, mimeType]) => [mimeType, extension]), + ), + 'application/octet-stream': 'bin', +}; + +export const lookupMimeType = (filePath: string) => { + const match = /\.([^.]+)$/.exec(filePath.trim().toLowerCase()); + if (!match) { + return false; + } + + return extensionToMimeType[match[1]] || false; +}; + +export const mimeTypeExtension = (contentType: string) => { + const normalizedType = contentType.split(';', 1)[0]?.trim().toLowerCase(); + if (!normalizedType) { + return false; + } + + if (mimeTypeToExtension[normalizedType]) { + return mimeTypeToExtension[normalizedType]; + } + + const subtype = normalizedType.split('/')[1]; + return subtype?.split('+').pop() || false; +}; diff --git a/packages/insomnia/src/entry.client.tsx b/packages/insomnia/src/entry.client.tsx index 580274be7234..5d574d2cd578 100644 --- a/packages/insomnia/src/entry.client.tsx +++ b/packages/insomnia/src/entry.client.tsx @@ -8,12 +8,12 @@ import { HydratedRouter } from 'react-router/dom'; import { insomniaFetch } from '~/common/insomnia-fetch'; import { initDatabase, initServices, services } from '~/insomnia-data'; +import { plugins } from '~/plugins/renderer-bridge'; import { database as clientDatabase } from '~/ui/database.client'; import { clearOAuthWindowSessionId } from '~/ui/spawn-oauth-window'; import { migrateFromLocalStorage, type SessionData, setSessionData, setVaultSessionData } from './account/session'; import { getInsomniaSession, getInsomniaVaultKey, getInsomniaVaultSalt, getSkipOnboarding } from './common/constants'; -import { init as initPlugins } from './plugins'; import { applyColorScheme } from './plugins/misc'; import { registerSyncMergeConflictListener } from './sync/vcs/insomnia-sync'; import { HtmlElementWrapper } from './ui/components/html-element-wrapper'; @@ -40,7 +40,7 @@ delete window._dataServices; configureFetch(options => insomniaFetch({ ...options, onDeepLink: (uri: string) => window.main.openDeepLink(uri) })); -await initPlugins(); +await plugins.reloadPlugins(); await migrateFromLocalStorage(); registerSyncMergeConflictListener(); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 2a782fd4832a..7bb8f5495003 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -282,6 +282,14 @@ const main: Window['main'] = { writeFile: options => invokeWithNormalizedError('writeFile', options), deleteRulesetFile: options => invokeWithNormalizedError('deleteRulesetFile', options), writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options), + exportHarRequest: (requestId, environmentOrWorkspaceId, addContentLength) => + invokeWithNormalizedError('exportHarRequest', requestId, environmentOrWorkspaceId, addContentLength), + exportHarCurrentRequest: (requestId, responseId) => + invokeWithNormalizedError('exportHarCurrentRequest', requestId, responseId), + exportWorkspacesHAR: (workspaceIds, includePrivateDocs) => + invokeWithNormalizedError('exportWorkspacesHAR', workspaceIds, includePrivateDocs), + exportRequestsHAR: (requestIds, includePrivateDocs) => + invokeWithNormalizedError('exportRequestsHAR', requestIds, includePrivateDocs), getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise => invokeWithNormalizedError('getAuthHeader', renderedRequest, url), getOAuth2Token: ( diff --git a/packages/insomnia/src/insomnia-data/src/models/settings.ts b/packages/insomnia/src/insomnia-data/src/models/settings.ts index 107d06178b66..a8d09e4ffdba 100644 --- a/packages/insomnia/src/insomnia-data/src/models/settings.ts +++ b/packages/insomnia/src/insomnia-data/src/models/settings.ts @@ -22,7 +22,7 @@ export type ThemeSettings = Pick): model is Settings => model.type === type; // force vertical layout for playwright tests to avoid horizontal scrolling issues -const forceVerticalLayout = process.env.PLAYWRIGHT_TEST ? true : false; +const forceVerticalLayout = (typeof window !== 'undefined' ? window.env?.PLAYWRIGHT_TEST : process.env.PLAYWRIGHT_TEST) ? true : false; export function init(): BaseSettings { return { diff --git a/packages/insomnia/src/common/har.ts b/packages/insomnia/src/main/har.ts similarity index 83% rename from packages/insomnia/src/common/har.ts rename to packages/insomnia/src/main/har.ts index f18893c37b2b..b9aa11d839a2 100644 --- a/packages/insomnia/src/common/har.ts +++ b/packages/insomnia/src/main/har.ts @@ -1,10 +1,14 @@ import clone from 'clone'; import type * as Har from 'har-format'; -import { Cookie as ToughCookie } from 'tough-cookie'; import type { BaseModel, Environment, Request, RequestGroup, Response, Workspace } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; +import { getAppVersion } from '../common/constants'; +import { getResponseCookiesFromHeaders, jarFromCookies, mapCookie } from '../common/cookies'; +import { database } from '../common/database'; +import { filterHeaders, hasAuthHeader } from '../common/misc'; +import { getRenderedRequestAndContext } from '../common/render'; import * as plugins from '../plugins'; import * as pluginApp from '../plugins/context/app'; import * as pluginRequest from '../plugins/context/request'; @@ -13,11 +17,8 @@ import { RenderError } from '../templating/render-error'; import type { RenderedRequest } from '../templating/types'; import { parseGraphQLReqeustBody } from '../utils/graph-ql'; import { smartEncodeUrl } from '../utils/url/querystring'; -import { getAppVersion } from './constants'; -import { jarFromCookies } from './cookies'; -import { database } from './database'; -import { filterHeaders, getSetCookieHeaders, hasAuthHeader } from './misc'; -import { getRenderedRequestAndContext } from './render'; +import { getAuthHeader } from './network/get-auth-header'; +import { secureReadFile } from './secure-read-file'; const { isRequest } = models.request; @@ -25,10 +26,7 @@ const getDocWithDescendants = (includePrivateDocs = false) => async (parentDoc: BaseModel | null) => { const docs = parentDoc ? await database.getWithDescendants(parentDoc) : []; - return docs.filter( - // Don't include if private, except if we want to - doc => !doc?.isPrivate || includePrivateDocs, - ); + return docs.filter(doc => !doc?.isPrivate || includePrivateDocs); }; export async function exportWorkspacesHAR(workspaces: Workspace[], includePrivateDocs = false) { @@ -82,7 +80,6 @@ export async function exportRequestsHAR(requests: BaseModel[], includePrivateDoc const workspace = mapRequestIdToWorkspace[request._id]; if (workspace == null) { - // Workspace not found for request, so don't export it. continue; } @@ -96,6 +93,7 @@ export async function exportRequestsHAR(requests: BaseModel[], includePrivateDoc const data = await exportHar(harRequests); return JSON.stringify(data, null, '\t'); } + export interface ExportRequest { requestId: string; environmentId: string | null; @@ -129,7 +127,6 @@ export async function exportHarCurrentRequest(request: Request, response: Respon } export async function exportHar(exportRequests: ExportRequest[]) { - // Export HAR entries with the same start time in order to keep their workspace sort order. const startedDateTime = new Date().toISOString(); const entries: Har.Entry[] = []; @@ -301,12 +298,7 @@ export async function exportHarWithRenderedRequest(renderedRequest: RenderedRequ } } - // Set auth header if we have it if (!hasAuthHeader(renderedRequest.headers)) { - const getAuthHeader = - process.type === 'renderer' - ? window.main.getAuthHeader - : (await import('../main/network/get-auth-header')).getAuthHeader; const header = await getAuthHeader(renderedRequest, url); if (header) { @@ -332,90 +324,17 @@ export async function exportHarWithRenderedRequest(renderedRequest: RenderedRequ } function getRequestCookies(renderedRequest: RenderedRequest) { - // filter out invalid cookies to avoid getCookiesSync complaining const jar = jarFromCookies(renderedRequest.cookieJar.cookies); const domainCookies = renderedRequest.url ? jar.getCookiesSync(renderedRequest.url) : []; const harCookies: Har.Cookie[] = domainCookies.map(mapCookie); return harCookies; } -export function getResponseCookiesFromHeaders(headers: Har.Cookie[]) { - return getSetCookieHeaders(headers).reduce((accumulator, harCookie) => { - let cookie: null | undefined | ToughCookie = null; - - try { - cookie = ToughCookie.parse(harCookie.value || '', { loose: true }); - } catch {} - - if (cookie === null || cookie === undefined) { - return accumulator; - } - - return [...accumulator, mapCookie(cookie)]; - }, [] as Har.Cookie[]); -} - function getResponseCookies(response: Response) { const headers = response.headers.filter(Boolean); return getResponseCookiesFromHeaders(headers); } -function mapCookie(cookie: ToughCookie) { - const harCookie: Har.Cookie = { - name: cookie.key, - value: cookie.value, - }; - - if (cookie.path) { - harCookie.path = cookie.path; - } - - if (cookie.domain) { - harCookie.domain = cookie.domain; - } - - if (cookie.expires) { - let expires: Date | null = null; - - if (cookie.expires instanceof Date) { - expires = cookie.expires; - } else if (typeof cookie.expires === 'string') { - expires = new Date(cookie.expires); - } else if (typeof cookie.expires === 'number') { - expires = new Date(); - expires.setTime(cookie.expires); - } - - if (expires && !Number.isNaN(expires.getTime())) { - harCookie.expires = expires.toISOString(); - } - } - - if (cookie.httpOnly) { - harCookie.httpOnly = true; - } - - if (cookie.secure) { - harCookie.secure = true; - } - - return harCookie; -} - -async function getResponseContent(response: Response) { - let body = await services.helpers.getResponseBodyBuffer(response); - - if (body === null) { - body = Buffer.alloc(0); - } - const harContent: Har.Content = { - size: Buffer.byteLength(body), - mimeType: response.contentType, - text: body.toString('utf8'), - }; - return harContent; -} - function getResponseHeaders(response: Response) { return response.headers .filter(header => header.name) @@ -445,17 +364,13 @@ async function getRequestPostData(renderedRequest: RenderedRequest): Promise>; error: undefined } | { response: undefined; error: string } >; + exportHarRequest: ( + requestId: string, + environmentOrWorkspaceId?: string, + addContentLength?: boolean, + ) => Promise; + exportHarCurrentRequest: (requestId: string, responseId: string) => Promise; + exportWorkspacesHAR: (workspaceIds: string[], includePrivateDocs?: boolean) => Promise; + exportRequestsHAR: (requestIds: string[], includePrivateDocs?: boolean) => Promise; syncNewWorkspaceIfNeeded: typeof syncNewWorkspaceIfNeeded; plugins: PluginsBridgeAPI; notifyPluginPromptResult: (id: string, value: string | null) => void; @@ -400,6 +415,33 @@ export function registerMainHandlers() { } }); ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile); + ipcMainHandle( + 'exportHarRequest', + (_, requestId: string, environmentOrWorkspaceId?: string, addContentLength?: boolean) => + exportHarRequestFromHar(requestId, environmentOrWorkspaceId ?? '', addContentLength), + ); + ipcMainHandle('exportHarCurrentRequest', async (_, requestId: string, responseId: string) => { + const [request, response] = await Promise.all([ + services.request.getById(requestId), + services.response.getById(responseId), + ]); + if (!request || !response) { + throw new Error('Request or response not found'); + } + return exportHarCurrentRequestFromHar(request, response); + }); + ipcMainHandle('exportWorkspacesHAR', async (_, workspaceIds: string[], includePrivateDocs?: boolean) => { + const workspaces = (await Promise.all(workspaceIds.map(id => services.workspace.getById(id)))).filter( + (w): w is NonNullable => w != null, + ); + return exportWorkspacesHARFromHar(workspaces, includePrivateDocs); + }); + ipcMainHandle('exportRequestsHAR', async (_, requestIds: string[], includePrivateDocs?: boolean) => { + const requests = (await Promise.all(requestIds.map(id => services.request.getById(id)))).filter( + (r): r is NonNullable => r != null, + ); + return exportRequestsHARFromHar(requests, includePrivateDocs); + }); ipcMainHandle('getAuthHeader', (_, renderedRequest: RenderedRequest, url: string) => { return getAuthHeaderInMain(renderedRequest, url); }); diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index b1754e254818..ad22879f8734 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -21,7 +21,6 @@ import { isLinux, isMac } from '~/insomnia-data/common'; import { getAppBuildDate, getAppVersion, getProductName, isDevelopment, MNEMONIC_SYM } from '../common/constants'; import { docsBase } from '../common/documentation'; import { invariant } from '../utils/invariant'; -import { AnalyticsEvent, trackAnalyticsEvent } from './analytics'; import { getElectronStorage } from './electron-storage'; import { ipcMainOn } from './ipc/electron'; import { getLogDirectory } from './log'; @@ -201,8 +200,8 @@ export function createWindow(): ElectronBrowserWindow { webPreferences: { preload: path.join(__dirname, 'entry.preload.min.js'), zoomFactor: getZoomFactor(), - nodeIntegration: true, - nodeIntegrationInWorker: false, // must remain false to ensure the nunjucks web worker sandbox does not have access to Node.js APIs + nodeIntegration: false, + nodeIntegrationInWorker: false, webviewTag: true, // TODO: enable context isolation contextIsolation: false, @@ -270,7 +269,6 @@ export function createWindow(): ElectronBrowserWindow { { label: `${MNEMONIC_SYM}Preferences`, click: () => { - trackAnalyticsEvent(AnalyticsEvent.AppMenuPreferencesClicked); mainBrowserWindow.webContents?.send('toggle-preferences'); }, }, diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index 6e50e5502599..06e79d16f743 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -54,7 +54,6 @@ import { QUERY_PARAMS } from './api-key/constants'; import { getAuthObjectOrNull, isAuthEnabled } from './authentication'; import { filterClientCertificates } from './certificate'; import type { TransformedExecuteScriptContext } from './concurrency'; -import { addSetCookiesToToughCookieJar } from './set-cookie-util'; const { isRequest } = models.request; const { isRequestGroup } = models.requestGroup; @@ -1009,6 +1008,8 @@ const extractCookies = async ( const totalSetCookies = setCookieStrings.length; if (totalSetCookies) { const currentUrl = getCurrentUrl({ headerResults, finalUrl }); + // Lazy load cookie utilities only when needed to avoid upfront overhead + const { addSetCookiesToToughCookieJar } = await import('./set-cookie-util'); const { cookies, rejectedCookies } = await addSetCookiesToToughCookieJar({ setCookieStrings, currentUrl, diff --git a/packages/insomnia/src/plugins/context/data.ts b/packages/insomnia/src/plugins/context/data.ts index c40bcfc9b16b..81f7f7e369c6 100644 --- a/packages/insomnia/src/plugins/context/data.ts +++ b/packages/insomnia/src/plugins/context/data.ts @@ -1,7 +1,6 @@ import type { Workspace } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import { exportWorkspacesHAR } from '../../common/har'; import { fetchImportContentFromURI, importResourcesToProject, scanResources } from '../../common/import'; import { getInsomniaV5DataExport } from '../../common/insomnia-v5'; @@ -87,8 +86,10 @@ export const init = (activeProjectId?: string) => ({ return allInsomniaExports; }, - har: async ({ workspace, includePrivate }: HarExport = {}) => - exportWorkspacesHAR(workspace ? [workspace] : await getWorkspaces(activeProjectId), Boolean(includePrivate)), + har: async ({ workspace, includePrivate }: HarExport = {}) => { + const { exportWorkspacesHAR } = await import('../../main/har'); + return exportWorkspacesHAR(workspace ? [workspace] : await getWorkspaces(activeProjectId), Boolean(includePrivate)); + }, }, }, }); diff --git a/packages/insomnia/src/plugins/misc.ts b/packages/insomnia/src/plugins/misc.ts index f5794a366ae4..ef349a087318 100644 --- a/packages/insomnia/src/plugins/misc.ts +++ b/packages/insomnia/src/plugins/misc.ts @@ -3,8 +3,7 @@ import Color from 'color'; import type { ThemeSettings } from '~/insomnia-data'; import { getAppDefaultTheme } from '~/insomnia-data/common'; -import type { Theme } from './index'; -import { type ColorScheme, getThemes } from './index'; +import type { ColorScheme } from './index'; export type HexColor = `#${string}`; export type RGBColor = `rgb(${string})`; @@ -331,7 +330,8 @@ export async function setTheme(themeName: string) { return; } - const themes: Theme[] = await getThemes(); + const { plugins } = await import('./renderer-bridge'); + const themes = await plugins.getThemes(); let selectedTheme = themes.find(t => t.theme.name === themeName); if (!selectedTheme) { diff --git a/packages/insomnia/src/plugins/renderer-bridge.ts b/packages/insomnia/src/plugins/renderer-bridge.ts index 7b4c4c3652e4..96cdd2821ed7 100644 --- a/packages/insomnia/src/plugins/renderer-bridge.ts +++ b/packages/insomnia/src/plugins/renderer-bridge.ts @@ -5,7 +5,7 @@ import { invokePluginMethod } from './invoke-method'; // back to running plugins directly in the renderer (legacy behaviour). // This module lives in the renderer bundle (not the preload) so the heavy // plugin-system deps it pulls in don't inflate the preload. -const bridgeEnabled = process.env.INSOMNIA_ENABLE_PLUGIN_BRIDGE !== 'false'; +const bridgeEnabled = (typeof window !== 'undefined' ? window.env?.INSOMNIA_ENABLE_PLUGIN_BRIDGE : process.env.INSOMNIA_ENABLE_PLUGIN_BRIDGE) !== 'false'; function call>( method: M, diff --git a/packages/insomnia/src/polyfills/node-querystring.ts b/packages/insomnia/src/polyfills/node-querystring.ts new file mode 100644 index 000000000000..ee52d8cd3fc8 --- /dev/null +++ b/packages/insomnia/src/polyfills/node-querystring.ts @@ -0,0 +1,39 @@ +/** + * Minimal polyfill for Node.js's legacy `querystring` module. + * Used in the renderer (nodeIntegration: false) by third-party packages like httpsnippet. + */ + +export function stringify(obj: Record, sep = '&', eq = '='): string { + if (!obj || typeof obj !== 'object') { + return ''; + } + return Object.entries(obj) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => encodeURIComponent(k) + eq + encodeURIComponent(String(v))) + .join(sep); +} + +export function parse(str: string, sep = '&', eq = '='): Record { + if (!str) { + return {}; + } + return Object.fromEntries( + str.split(sep).filter(Boolean).map(pair => { + const idx = pair.indexOf(eq); + if (idx === -1) { + return [decodeURIComponent(pair), '']; + } + return [decodeURIComponent(pair.slice(0, idx)), decodeURIComponent(pair.slice(idx + 1))]; + }), + ); +} + +export function escape(str: string): string { + return encodeURIComponent(String(str)); +} + +export function unescape(str: string): string { + return decodeURIComponent(str); +} + +export default { stringify, parse, escape, unescape }; diff --git a/packages/insomnia/src/polyfills/node-url.ts b/packages/insomnia/src/polyfills/node-url.ts new file mode 100644 index 000000000000..d570950ee3e4 --- /dev/null +++ b/packages/insomnia/src/polyfills/node-url.ts @@ -0,0 +1,78 @@ +/** + * Minimal polyfill for Node.js's legacy `url` module. + * Used in the renderer (nodeIntegration: false) so packages like httpsnippet + * that call url.parse() don't crash at runtime. + */ + +export interface Url { + href?: string; + protocol?: string; + auth?: string; + host?: string; + port?: string; + hostname?: string; + hash?: string; + search?: string; + query?: string | Record; + pathname?: string; + path?: string; + slashes?: boolean; +} + +export function parse(urlString: string, parseQueryString = false): Url { + try { + const u = new URL(urlString); + const query: string | Record = parseQueryString + ? Object.fromEntries(u.searchParams.entries()) + : (u.search ? u.search.slice(1) : ''); + return { + href: u.href, + protocol: u.protocol, + slashes: true, + auth: u.username ? `${u.username}${u.password ? ':' + u.password : ''}` : undefined, + host: u.host, + port: u.port || undefined, + hostname: u.hostname, + hash: u.hash || undefined, + search: u.search || undefined, + query, + pathname: u.pathname, + path: u.pathname + (u.search || ''), + }; + } catch { + return { href: urlString }; + } +} + +export function format(urlObject: Url): string { + if (typeof urlObject === 'string') { + return urlObject; + } + const { protocol, host, hostname, port, pathname, search, hash, auth } = urlObject; + let result = ''; + if (protocol) { + result += protocol + (urlObject.slashes !== false ? '//' : ''); + } + if (auth) { + result += auth + '@'; + } + if (host) { + result += host; + } else if (hostname) { + result += hostname + (port ? ':' + port : ''); + } + result += pathname || '/'; + if (search) { + result += search; + } + if (hash) { + result += hash; + } + return result; +} + +export function resolve(from: string, to: string): string { + return new URL(to, from).href; +} + +export default { parse, format, resolve }; diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index 35118b6900e6..29d16b1a11d7 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -568,7 +568,7 @@ const Root = () => { // gracefully handle open org in app from browser const userSession = await services.userSession.get(); if (!userSession.id || userSession.id === '') { - const url = new URL(getLoginUrl()); + const url = new URL(await getLoginUrl()); window.main.openInBrowser(url.toString()); window.localStorage.setItem('specificOrgRedirectAfterAuthorize', params.organizationId); return navigate(href('/auth/authorize')); diff --git a/packages/insomnia/src/routes/auth.authorize.tsx b/packages/insomnia/src/routes/auth.authorize.tsx index a7532fce336d..37c2fff927ac 100644 --- a/packages/insomnia/src/routes/auth.authorize.tsx +++ b/packages/insomnia/src/routes/auth.authorize.tsx @@ -1,5 +1,5 @@ import { getVault } from 'insomnia-api'; -import { Fragment } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { Button, Heading } from 'react-aria-components'; import { href, redirect, useFetchers, useNavigate } from 'react-router'; @@ -10,7 +10,6 @@ import { Icon } from '~/ui/components/icon'; import { validateVaultKey } from '~/ui/vault-key.client'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; -import { getVaultKeyFromStorage } from '~/utils/vault'; import type { Route } from './+types/auth.authorize'; @@ -45,6 +44,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { // save vault salt to session await services.userSession.update({ vaultSalt }); // get vault key saved in local + const { getVaultKeyFromStorage } = await import('~/utils/vault'); const localVaultKey = await getVaultKeyFromStorage(accountId); if (localVaultKey) { // validate vault key with server @@ -75,7 +75,12 @@ export const useAuthorizeActionFetcher = createFetcherSubmitHook( ); const Component = () => { - const url = getLoginUrl(); + const [url, setUrl] = useState(''); + + useEffect(() => { + void getLoginUrl().then(setUrl); + }, []); + const copyUrl = () => { window.clipboard.writeText(url); }; diff --git a/packages/insomnia/src/routes/auth.clear-vault-key.tsx b/packages/insomnia/src/routes/auth.clear-vault-key.tsx index eede8df6658c..32f5b7ea5996 100644 --- a/packages/insomnia/src/routes/auth.clear-vault-key.tsx +++ b/packages/insomnia/src/routes/auth.clear-vault-key.tsx @@ -1,8 +1,8 @@ -import electron from 'electron'; import { getVault } from 'insomnia-api'; import { href } from 'react-router'; import { services } from '~/insomnia-data'; +import { showToast } from '~/ui/components/toast-notification'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/auth.clear-vault-key'; @@ -23,11 +23,9 @@ export async function clientAction({ request }: Route.ClientActionArgs) { // Update vault salt and delete vault key from session await services.userSession.update({ vaultSalt: newVaultSalt, vaultKey: '' }); // show notification - electron.ipcRenderer.emit('show-toast', null, { - content: { - title: 'Your vault key has been reset, all you local secrets have been deleted.', - status: 'info', - }, + showToast({ + title: 'Your vault key has been reset, all you local secrets have been deleted.', + status: 'info', }); return true; } diff --git a/packages/insomnia/src/routes/auth.login.tsx b/packages/insomnia/src/routes/auth.login.tsx index ad5651f5cf99..63c25e0e5483 100644 --- a/packages/insomnia/src/routes/auth.login.tsx +++ b/packages/insomnia/src/routes/auth.login.tsx @@ -37,7 +37,7 @@ const GoogleIcon = (props: React.ReactSVGElement['props']) => { export async function clientAction({ request }: Route.ClientActionArgs) { const data = await request.formData(); const provider = data.get('provider'); - const url = new URL(getLoginUrl()); + const url = new URL(await getLoginUrl()); if (typeof provider === 'string' && provider) { url.searchParams.set('provider', provider); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx index 0d9ac37b5c03..2a5fefddf2e5 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx @@ -1,5 +1,3 @@ -import contentDisposition from 'content-disposition'; -import { extension as mimeExtension } from 'mime-types'; import { href, redirect } from 'react-router'; import { v4 as uuidv4 } from 'uuid'; @@ -69,6 +67,65 @@ export interface RunnerContextForRequest { responseId: string; } +const stripQuotedValue = (value: string) => { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/\\(.)/g, '$1'); + } + return trimmed; +}; + +const parseContentDispositionFilename = (headerValue: string) => { + const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^;]+)/i); + if (filenameStarMatch) { + const encodedValue = stripQuotedValue(filenameStarMatch[1]); + const parts = encodedValue.split("'"); + const value = parts.length >= 3 ? parts.slice(2).join("'") : encodedValue; + + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + const filenameMatch = headerValue.match(/filename\s*=\s*("(?:[^"\\]|\\.)*"|[^;]+)/i); + return filenameMatch ? stripQuotedValue(filenameMatch[1]) : null; +}; + +const getDownloadFileExtension = (contentType?: string | null) => { + const normalizedType = contentType?.split(';', 1)[0]?.trim().toLowerCase(); + if (!normalizedType) { + return 'unknown'; + } + + switch (normalizedType) { + case 'application/json': { + return 'json'; + } + case 'application/pdf': { + return 'pdf'; + } + case 'application/xml': + case 'text/xml': { + return 'xml'; + } + case 'text/csv': { + return 'csv'; + } + case 'text/html': { + return 'html'; + } + case 'text/plain': { + return 'txt'; + } + default: { + const subtype = normalizedType.split('/')[1]; + return subtype?.split('+').pop() || 'unknown'; + } + } +}; + const writeToDownloadPath = async ( downloadPathAndName: string, responsePatch: ResponsePatch, @@ -312,9 +369,8 @@ export const sendActionImplementation = async (options: { if (requestMeta.downloadPath) { const header = getContentDispositionHeader(responsePatch.headers || []); - const name = header - ? contentDisposition.parse(header.value).parameters.filename - : `${requestData.request.name.replace(/\s/g, '-').toLowerCase()}.${(responsePatch.contentType && mimeExtension(responsePatch.contentType)) || 'unknown'}`; + const fallbackName = `${requestData.request.name.replace(/\s/g, '-').toLowerCase()}.${getDownloadFileExtension(responsePatch.contentType)}`; + const name = header ? parseContentDispositionFilename(header.value) || fallbackName : fallbackName; await writeToDownloadPath( window.path.join(requestMeta.downloadPath, name), responsePatch, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx index 9dce91fbbd80..bfb5eaf00954 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx @@ -16,7 +16,6 @@ import { RESPONSE_CODE_REASONS, } from '~/common/constants'; import { database as db } from '~/common/database'; -import { getResponseCookiesFromHeaders } from '~/common/har'; import type { MockRoute, MockServer, Request, RequestHeader, Response } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; @@ -87,7 +86,7 @@ export const isInMockContentTypeList = (contentType: string): boolean => Boolean(contentType && mockContentTypes.includes(contentType)); // mockbin expect a HAR response structure -export const mockRouteToHar = ({ +export const mockRouteToHar = async ({ statusCode, statusText, mimeType, @@ -99,8 +98,9 @@ export const mockRouteToHar = ({ mimeType: string; headersArray: RequestHeader[]; body: string; -}): Har.Response => { +}): Promise => { const validHeaders = headersArray.filter(({ name }) => !!name); + const { getResponseCookiesFromHeaders } = await import('~/common/cookies'); return { status: +statusCode, statusText: statusText || RESPONSE_CODE_REASONS[+statusCode] || '', @@ -168,7 +168,7 @@ export const MockRouteRoute = () => { organizationId, sessionId: userSession.id, method: mockRoute.method, - data: mockRouteToHar({ + data: await mockRouteToHar({ statusCode: mockRoute.statusCode, statusText: mockRoute.statusText, headersArray: mockRoute.headers, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx index 132be74f1348..32e054274dfe 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx @@ -474,7 +474,7 @@ async function createMockRoutes( organizationId, sessionId, method: route.method, - data: mockRouteToHar({ + data: await mockRouteToHar({ statusCode: mockRoute.statusCode, statusText: mockRoute.statusText || '', headersArray: mockRoute.headers, diff --git a/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts b/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts index 0dd424538985..336ecf74c6ba 100644 --- a/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts +++ b/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { requireInterceptor } from '../require-interceptor'; import { defaultSecurityPolicy } from '../sandbox'; import { interceptorRules, maskRules } from '../script-security-policy'; @@ -49,8 +48,8 @@ describe('ScriptSecurityPolicy.buildMaskScope()', () => { }); describe('require interceptor', () => { - it('masks require with requireInterceptor', () => { - expect(maskMap.get('require')).toBe(requireInterceptor); + it('masks require with an interceptor function', () => { + expect(typeof maskMap.get('require')).toBe('function'); }); }); diff --git a/packages/insomnia/src/scripting/script-security-policy.ts b/packages/insomnia/src/scripting/script-security-policy.ts index 5e566891709d..2ca88e720d9a 100644 --- a/packages/insomnia/src/scripting/script-security-policy.ts +++ b/packages/insomnia/src/scripting/script-security-policy.ts @@ -1,5 +1,12 @@ import { invariant } from '../utils/invariant'; -import { requireInterceptor } from './require-interceptor'; + +const getRequireInterceptor = () => { + if (typeof window === 'undefined' || !window.bridge?.requireInterceptor) { + throw new Error('require interceptor is unavailable'); + } + + return window.bridge.requireInterceptor; +}; export interface ASTRule { name: string; // the identifier / property name being blocked. @@ -56,7 +63,7 @@ export const interceptorRules: ThreatRule[] = [ name: 'require', description: 'Replaces the require() function with an interceptor to prevent access to modules outside an explicit allowlist.', maskName: 'require', - maskValue: requireInterceptor, + maskValue: (moduleName: string) => getRequireInterceptor()(moduleName), }, { name: 'window', diff --git a/packages/insomnia/src/templating/index.ts b/packages/insomnia/src/templating/index.ts index 700bb7c45f0c..61d3fdbbbe5d 100644 --- a/packages/insomnia/src/templating/index.ts +++ b/packages/insomnia/src/templating/index.ts @@ -2,7 +2,6 @@ import { localTemplateTags } from 'insomnia/src/templating/local-template-tags'; import type { Liquid } from 'liquidjs'; import { buildLiquidEngine, stripLiquidComments } from './liquid-engine'; -import { createLiquidTag } from './liquid-extension'; import { extractUndefinedVariableKey, translateLiquidError } from './render-error'; export const NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME = '_'; @@ -100,7 +99,11 @@ async function getLiquid(ignoreUndefinedEnvVariable?: boolean): Promise<{ engine return { engine: liquidAll, tagMetadata: liquidAllTagMetadata }; } - const pluginTemplateTags = await (await import('../plugins')).getTemplateTags(); + const [{ getTemplateTags }, { createLiquidTag }] = await Promise.all([ + import('../plugins'), + import('./liquid-extension'), + ]); + const pluginTemplateTags = await getTemplateTags(); const allTags = [ ...localTemplateTags, diff --git a/packages/insomnia/src/ui/auth-session-provider.client.ts b/packages/insomnia/src/ui/auth-session-provider.client.ts index cf33fbfee5c7..852e8cfd018c 100644 --- a/packages/insomnia/src/ui/auth-session-provider.client.ts +++ b/packages/insomnia/src/ui/auth-session-provider.client.ts @@ -1,33 +1,56 @@ import * as session from '../account/session'; import { getAppWebsiteBaseURL, getInsomniaPublicKey, getInsomniaSecretKey } from '../common/constants'; import { invariant } from '../utils/invariant'; -import { keyPair, open } from '../utils/sealedbox'; interface AuthBox { token: string; key: string; } -const sessionKeyPair = keyPair(); -encodeBase64(sessionKeyPair.publicKey).then(res => { - try { - window.localStorage.setItem('insomnia.publicKey', getInsomniaPublicKey() || res); - } catch { - console.error('Failed to store public key in localStorage.'); - } -}); -encodeBase64(sessionKeyPair.secretKey).then(res => { - try { - window.localStorage.setItem('insomnia.secretKey', getInsomniaSecretKey() || res); - } catch { - console.error('Failed to store secret key in localStorage.'); - } -}); /** * Keypair used for the login handshake. * This keypair can be re-used for the entire session. */ +interface SessionKeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + +let sessionKeyPairPromise: Promise | null = null; + +async function getSessionKeyPair() { + if (!sessionKeyPairPromise) { + sessionKeyPairPromise = (async () => { + const { keyPair } = await import('../utils/sealedbox'); + const sessionKeyPair = keyPair(); + + const [publicKeyEncoded, secretKeyEncoded] = await Promise.all([ + encodeBase64(sessionKeyPair.publicKey), + encodeBase64(sessionKeyPair.secretKey), + ]); + + // Session keypairs are ephemeral and used only for the initial login handshake. + // They are NOT persistent credentials and are discarded after the session ends. + try { + window.localStorage.setItem('insomnia.publicKey', getInsomniaPublicKey() || publicKeyEncoded); + } catch { + console.error('Failed to store public key in localStorage.'); + } + + try { + window.localStorage.setItem('insomnia.secretKey', getInsomniaSecretKey() || secretKeyEncoded); + } catch { + console.error('Failed to store secret key in localStorage.'); + } + + return sessionKeyPair; + })(); + } + + return sessionKeyPairPromise; +} + export async function decodeBase64(base64: string): Promise { try { let uri = 'data:application/octet-binary;base64,'; @@ -65,9 +88,11 @@ export async function encodeBase64(data: Uint8Array): Promise { export async function submitAuthCode(code: string) { try { + await getSessionKeyPair(); const rawBox = await decodeBase64(code.trim()); const publicKey = await decodeBase64(window.localStorage.getItem('insomnia.publicKey') || ''); const secretKey = await decodeBase64(window.localStorage.getItem('insomnia.secretKey') || ''); + const { open } = await import('../utils/sealedbox'); const boxData = open(rawBox, publicKey, secretKey); invariant(boxData, 'Invalid authentication code.'); @@ -80,7 +105,8 @@ export async function submitAuthCode(code: string) { } } -export function getLoginUrl() { +export async function getLoginUrl() { + await getSessionKeyPair(); const publicKey = window.localStorage.getItem('insomnia.publicKey'); if (!publicKey) { console.log('[auth] No public key found'); diff --git a/packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts b/packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts index 174ccf082220..49dd0da5e994 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts @@ -37,7 +37,7 @@ import 'codemirror/addon/merge/merge.js'; // for the code that uses this yaml parser, see https://github.com/codemirror/CodeMirror/blob/master/addon/lint/yaml-lint.js import * as jsyaml from 'js-yaml'; -global.jsyaml = jsyaml; +globalThis.jsyaml = jsyaml; import 'codemirror/addon/lint/yaml-lint'; /**/ import 'codemirror/keymap/vim'; @@ -54,7 +54,6 @@ import './modes/nunjucks'; import './modes/curl'; import './modes/openapi'; import './modes/clojure'; -import './lint/javascript-async-lint'; import './lint/json-lint'; import './extensions/autocomplete'; import './extensions/clickable'; diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index ca7509cf4b66..61d9f36ef422 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -10,7 +10,6 @@ import CodeMirror, { } from 'codemirror'; import type { GraphQLInfoOptions } from 'codemirror-graphql/info'; import type { ModifiedGraphQLJumpOptions } from 'codemirror-graphql/jump'; -import deepEqual from 'deep-equal'; import { JSONPath } from 'jsonpath-plus'; import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Button, Menu, MenuItem, MenuTrigger, Popover, Toolbar } from 'react-aria-components'; @@ -45,6 +44,18 @@ import { normalizeIrregularWhitespace } from './normalize-irregular-whitespace'; const TAB_SIZE = 4; const MAX_SIZE_FOR_LINTING = 1_000_000; // Around 1MB +const isOptionValueEqual = (currentValue: unknown, nextValue: unknown) => { + if (currentValue === nextValue) { + return true; + } + + try { + return JSON.stringify(currentValue) === JSON.stringify(nextValue); + } catch { + return false; + } +}; + interface EditorState { scroll: CodeMirror.ScrollInfo; selections: CodeMirror.Range[]; @@ -587,7 +598,7 @@ export const CodeEditor = memo( const lintOption = lintOptions || true; try { const newValue = shouldLint ? lintOption : false; - if (!deepEqual(codeMirror.current?.getOption('lint'), newValue)) { + if (!isOptionValueEqual(codeMirror.current?.getOption('lint'), newValue)) { tryToSetOption('lint', newValue); } } catch (err) { diff --git a/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts b/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts index 6dfb0f4b5f9a..42d473b16c35 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts @@ -6,7 +6,6 @@ import 'codemirror/addon/lint/json-lint'; import CodeMirror from 'codemirror'; -import * as jsonlint from 'jsonlint-mod-fixed'; import { render } from '~/templating/index'; CodeMirror.registerHelper('lint', 'json', validator); @@ -17,11 +16,29 @@ interface ValidationError { to: CodeMirror.Position; } +interface ParseErrorHash { + line?: number; + loc?: { + first_line: number; + first_column: number; + last_line: number; + last_column: number; + }; +} + +interface JsonLintModule { + parser: { + parseError: (str: string, hash: ParseErrorHash) => void; + }; + parse: (text: string) => unknown; +} + async function validator(text: string): Promise { const found: ValidationError[] = []; + const jsonlint = (await import('jsonlint-mod-fixed')) as unknown as JsonLintModule; // Override jsonlint's parseError function so we pull the errors into our collection of ValidationErrors - jsonlint.parser.parseError = (str: string, hash: jsonlint.ParseErrorHash) => { + jsonlint.parser.parseError = (str: string, hash: ParseErrorHash) => { if (hash.line && !hash.loc) { found.push({ from: CodeMirror.Pos(hash.line), diff --git a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx index 3385879e8f75..2985e31190a2 100644 --- a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx @@ -4,7 +4,6 @@ import { Button } from 'react-aria-components'; import { models, services } from '~/insomnia-data'; import { getPreviewModeName, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from '~/insomnia-data/common'; -import { exportHarCurrentRequest } from '../../../common/har'; import { type RequestLoaderData, useRequestLoaderData, @@ -36,7 +35,7 @@ export const PreviewModeDropdown: FC = ({ download, copyToClipboard }) => return; } - const data = await exportHarCurrentRequest(activeRequest, activeResponse); + const data = await window.main.exportHarCurrentRequest(activeRequest._id, activeResponse._id); const har = JSON.stringify(data, null, '\t'); const { filePath } = await window.dialog.showSaveDialog({ diff --git a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx index 915bdc30bdf3..5cc9e864b285 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx @@ -21,7 +21,6 @@ import { useRequestDeleteActionFetcher } from '~/routes/organization.$organizati import { AnalyticsEvent } from '~/ui/analytics'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; -import { exportHarRequest } from '../../../common/har'; import { toKebabCase } from '../../../common/misc'; import type { SerializableActionMeta } from '../../../plugins/bridge-types'; import { useRequestMetaPatcher } from '../../hooks/use-request'; @@ -147,7 +146,7 @@ export const RequestActionsDropdown = ({ const copyAsCurl = async () => { try { - const har = await exportHarRequest(request._id, workspaceId); + const har = await window.main.exportHarRequest(request._id, workspaceId); const { HTTPSnippet } = await import('httpsnippet'); const snippet = new HTTPSnippet(har); const cmd = snippet.convert('shell', 'curl'); diff --git a/packages/insomnia/src/ui/components/editors/body/body-editor.tsx b/packages/insomnia/src/ui/components/editors/body/body-editor.tsx index bc7d35897e53..ba5308b27b94 100644 --- a/packages/insomnia/src/ui/components/editors/body/body-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/body/body-editor.tsx @@ -1,5 +1,4 @@ import clone from 'clone'; -import { lookup } from 'mime-types'; import React, { type FC, useCallback } from 'react'; import { Toolbar } from 'react-aria-components'; import { useParams } from 'react-router'; @@ -10,6 +9,7 @@ import { CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_GRAPHQL, getContentTypeFromH import { CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA } from '../../../../common/constants'; import { documentationLinks } from '../../../../common/documentation'; +import { lookupMimeType } from '../../../../common/mime'; import { getContentTypeHeader } from '../../../../common/misc'; import { useRequestPatcher } from '../../../hooks/use-request'; import { ContentTypeDropdown } from '../../dropdowns/content-type-dropdown'; @@ -90,7 +90,7 @@ export const BodyEditor: FC = ({ request, environmentId }) => { // Update Content-Type header if the user wants const contentType = contentTypeHeader.value; - const newContentType = lookup(path) || CONTENT_TYPE_FILE; + const newContentType = lookupMimeType(path) || CONTENT_TYPE_FILE; if (contentType !== newContentType && path) { contentTypeHeader.value = newContentType; diff --git a/packages/insomnia/src/ui/components/editors/request-script-editor.tsx b/packages/insomnia/src/ui/components/editors/request-script-editor.tsx index a7466409887e..ffb0c77de4e1 100644 --- a/packages/insomnia/src/ui/components/editors/request-script-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/request-script-editor.tsx @@ -1,5 +1,5 @@ import type { Snippet } from 'codemirror'; -import React, { type FC, useRef } from 'react'; +import React, { type FC, useEffect, useRef } from 'react'; import { Button, Collection, @@ -16,19 +16,14 @@ import type { Settings } from '~/insomnia-data'; import { translateHandlersInScript } from '~/main/importers/importers/translate-postman-script'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; -import { - CookieObject, - Environment, - Execution, - InsomniaObject, - Request as ScriptRequest, - RequestInfo, - Response as ScriptResponse, - Url, - Variables, - Vault, -} from '../../../../../insomnia-scripting-environment/src/objects'; +// Scripting environment types are imported explicitly from separate modules as they're +// part of the public API for request script execution. +import { Environment, Variables, Vault } from '../../../../../insomnia-scripting-environment/src/objects/environments'; +import { Execution } from '../../../../../insomnia-scripting-environment/src/objects/execution'; import { ParentFolders } from '../../../../../insomnia-scripting-environment/src/objects/folders'; +import { Request as ScriptRequest } from '../../../../../insomnia-scripting-environment/src/objects/request'; +import { RequestInfo } from '../../../../../insomnia-scripting-environment/src/objects/request-info'; +import { Url } from '../../../../../insomnia-scripting-environment/src/objects/urls'; import { Icon } from '../icon'; interface Props { @@ -152,7 +147,7 @@ const lintOptions = { // TODO: introduce this functionality for other objects, such as Url, UrlMatchPattern and so on // TODO: introduce function arguments // TODO: provide snippets for environment keys if possible -function getRequestScriptSnippets(insomniaObject: InsomniaObject, path: string): Snippet[] { +function getRequestScriptSnippets(insomniaObject: Record, path: string): Snippet[] { let snippets: Snippet[] = []; const refs = new Set(); @@ -543,6 +538,10 @@ export const RequestScriptEditor: FC = ({ }) => { const editorRef = useRef(null); + useEffect(() => { + void import('~/ui/components/.client/codemirror/lint/javascript-async-lint'); + }, []); + // Inserts at the line below the cursor and moves to the line beneath const addSnippet = (snippet: string) => { const cursorRow = editorRef.current?.getCursor()?.line || 0; @@ -563,7 +562,7 @@ export const RequestScriptEditor: FC = ({ }); // TODO(george): Add more to this object to provide improved autocomplete const requestScriptSnippets = getRequestScriptSnippets( - new InsomniaObject({ + { globals: new Environment('globals', {}), baseGlobals: new Environment('baseGlobals', {}), iterationData: new Environment('iterationData', {}), @@ -580,34 +579,25 @@ export const RequestScriptEditor: FC = ({ }), vault: settings.enableVaultInScripts ? new Vault('vault', {}, settings.enableVaultInScripts) : undefined, request: req, - response: new ScriptResponse({ + response: { code: 200, - reason: 'OK', - header: [ + status: 'OK', + headers: [ { key: 'header1', value: 'val1' }, { key: 'header2', value: 'val2' }, ], - cookie: [ + cookies: [ { key: 'header1', value: 'val1' }, { key: 'header2', value: 'val2' }, ], body: '{"key": 888}', - stream: undefined, responseTime: 100, - originalRequest: req, - }), + }, settings, clientCertificates: [], - cookies: new CookieObject({ - _id: '', - type: 'CookieJar', - parentId: '', - modified: 0, - created: 0, - isPrivate: false, - name: '', - cookies: [], - }), + cookies: { + toObject: () => ({}), + }, requestInfo: new RequestInfo({ // @TODO - Look into this event name when we introduce iteration data eventName: 'prerequest', @@ -620,7 +610,7 @@ export const RequestScriptEditor: FC = ({ location: ['path'], }), parentFolders: new ParentFolders([]), - }), + }, 'insomnia', ); diff --git a/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx b/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx index 9c8652c2c7f3..2249ac5a444e 100644 --- a/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx +++ b/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx @@ -12,7 +12,6 @@ import { useMockRouteLoaderData } from '~/routes/organization.$organizationId.pr import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor'; import { getMockServiceURL } from '../../../common/constants'; -import { exportHarCurrentRequest } from '../../../common/har'; import { cancelRequestById } from '../../../network/cancellation'; import { jsonPrettify } from '../../../utils/prettify/json'; import { useExecutionState } from '../../hooks/use-execution-state'; @@ -385,7 +384,7 @@ const PreviewModeDropdown = ({ if (canceled || !filePath || !activeRequest) { return; } - const data = await exportHarCurrentRequest(activeRequest, activeResponse); + const data = await window.main.exportHarCurrentRequest(activeRequest._id, activeResponse._id); const har = JSON.stringify(data, null, '\t'); await window.main.writeFile({ diff --git a/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts b/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts index a130479a9170..e611b693f897 100644 --- a/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts +++ b/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts @@ -1,4 +1,4 @@ -import { exportRequestsHAR, exportWorkspacesHAR } from 'insomnia/src/common/har'; +import { exportRequestsHAR, exportWorkspacesHAR } from 'insomnia/src/main/har'; import { beforeEach, describe, expect, it } from 'vitest'; import { database as db, services } from '~/insomnia-data'; diff --git a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx index aec2e05c2a91..b4bf5ad7f923 100644 --- a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx @@ -6,7 +6,6 @@ import type { Request } from '~/insomnia-data'; import { AnalyticsEvent } from '~/ui/analytics'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; -import { exportHarWithRequest } from '../../../common/har'; import { CopyButton } from '../base/copy-button'; import { Dropdown, DropdownItem, ItemContent } from '../base/dropdown'; import { Link } from '../base/link'; @@ -67,39 +66,43 @@ export const GenerateCodeModal = forwardRef((pro const generateCode = useCallback( async (request: Request, target?: HTTPSnippetTarget, client?: HTTPSnippetClient) => { - const { HTTPSnippet, availableTargets } = await import('httpsnippet'); + try { + const { HTTPSnippet, availableTargets } = await import('httpsnippet'); - const targets = availableTargets(); - const targetOrFallback = target || (targets.find(t => t.key === 'shell') as HTTPSnippetTarget); - const clientOrFallback = client || (targetOrFallback.clients.find(t => t.key === 'curl') as HTTPSnippetClient); + const targets = availableTargets(); + const targetOrFallback = target || (targets.find(t => t.key === 'shell') as HTTPSnippetTarget); + const clientOrFallback = client || (targetOrFallback.clients.find(t => t.key === 'curl') as HTTPSnippetClient); - setState({ - request, - client: clientOrFallback, - target: targetOrFallback, - targets, - }); - // Save client/target for next time - window.localStorage.setItem('insomnia::generateCode::client', JSON.stringify(clientOrFallback)); - window.localStorage.setItem('insomnia::generateCode::target', JSON.stringify(targetOrFallback)); + setState({ + request, + client: clientOrFallback, + target: targetOrFallback, + targets, + }); + // Save client/target for next time + window.localStorage.setItem('insomnia::generateCode::client', JSON.stringify(clientOrFallback)); + window.localStorage.setItem('insomnia::generateCode::target', JSON.stringify(targetOrFallback)); - // Some clients need a content-length for the request to succeed - const addContentLength = Boolean( - (TO_ADD_CONTENT_LENGTH[targetOrFallback.key] || []).find(c => c === clientOrFallback.key), - ); - const har = await exportHarWithRequest(request, props.environmentId, addContentLength); - if (har) { - const snippet = new HTTPSnippet(har); - const cmd = snippet.convert(targetOrFallback.key, clientOrFallback.key) || ''; - setSnippet(cmd); - } + // Some clients need a content-length for the request to succeed + const addContentLength = Boolean( + (TO_ADD_CONTENT_LENGTH[targetOrFallback.key] || []).find(c => c === clientOrFallback.key), + ); + const har = await window.main.exportHarRequest(request._id, props.environmentId, addContentLength); + if (har) { + const snippet = new HTTPSnippet(har); + const cmd = snippet.convert(targetOrFallback.key, clientOrFallback.key) || ''; + setSnippet(cmd); + } - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.generateCodeLanguageChanged, - properties: { - language: target?.title, - }, - }); + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.generateCodeLanguageChanged, + properties: { + language: target?.title, + }, + }); + } catch (err) { + console.error('[generate-code] failed to generate code snippet:', err); + } }, [props.environmentId], ); diff --git a/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx b/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx index 5d189f44ab22..eeb0605fe8ff 100644 --- a/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx +++ b/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx @@ -25,7 +25,6 @@ import { AnalyticsEvent } from '~/ui/analytics'; import { Icon } from '~/ui/components/icon'; import { useIsLightTheme } from '~/ui/hooks/theme'; -import { startInvite } from './encryption'; import { OrganizationMemberRolesSelector, SELECTOR_TYPE } from './organization-member-roles-selector'; export function getSearchParamsString( @@ -372,6 +371,7 @@ export const InviteForm = ({ const emailsToInvite = emails.filter(({ teamId }) => !teamId).map(({ email }) => email); const groupsToInvite = emails.filter(({ teamId }) => teamId).map(({ teamId }) => teamId as string); + const { startInvite } = await import('./encryption'); startInvite({ emails: emailsToInvite, teamIds: groupsToInvite, diff --git a/packages/insomnia/src/ui/components/panes/response-pane-utils.ts b/packages/insomnia/src/ui/components/panes/response-pane-utils.ts index d041cc3f5e6f..beb121dfef25 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane-utils.ts +++ b/packages/insomnia/src/ui/components/panes/response-pane-utils.ts @@ -1,5 +1,4 @@ -import { extension as mimeExtension } from 'mime-types'; - +import { mimeTypeExtension } from '~/common/mime'; import { jsonPrettify } from '~/utils/prettify/json'; export async function downloadResponseBody( @@ -13,7 +12,7 @@ export async function downloadResponseBody( } const { contentType } = activeResponse; - const extension = mimeExtension(contentType) || 'unknown'; + const extension = mimeTypeExtension(contentType) || 'unknown'; const { canceled, filePath: outputPath } = await window.dialog.showSaveDialog({ title: 'Save Response Body', buttonLabel: 'Save', diff --git a/packages/insomnia/src/ui/components/project/organization-select.tsx b/packages/insomnia/src/ui/components/project/organization-select.tsx index 5101ad1bda47..00102a099fd6 100644 --- a/packages/insomnia/src/ui/components/project/organization-select.tsx +++ b/packages/insomnia/src/ui/components/project/organization-select.tsx @@ -68,9 +68,9 @@ export const OrganizationSelect = ({