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/package.json b/package.json index 24861f27eebe..4a6c8a5377e5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "scripts": { "dev": "npm start -w insomnia", "dev:autoRestart": "npm run start:autoRestart -w insomnia", - "check:renderer-node-imports": "npm run check:renderer-node-imports -w insomnia", "lint": "npm run lint --workspaces --if-present", "type-check": "npm run type-check --workspaces --if-present", "test": "npm run test --workspaces --if-present", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index e174355c7a72..2728abfbc7c5 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/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/constants.ts b/packages/insomnia/src/common/constants.ts index ab46c2c54e7b..75e1ed8c9960 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -13,10 +13,8 @@ import { import appConfig from '../../config/config.json'; import { version } from '../../package.json'; -// Vite is filtering out process.env variables that are not prefixed with VITE_. -const ENV = 'env'; - -const env = process[ENV]; +const env = + typeof window !== 'undefined' && window.app?.env ? window.app.env : typeof process !== 'undefined' ? process.env : {}; export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI; export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID; @@ -37,7 +35,7 @@ export const getProductName = () => appConfig.productName; export const getAppSynopsis = () => appConfig.synopsis; export const getAppId = () => appConfig.appId; export const getAppBundlePlugins = () => appConfig.bundlePlugins; -export const getAppEnvironment = () => process.env.INSOMNIA_ENV || 'production'; +export const getAppEnvironment = () => env.INSOMNIA_ENV || 'production'; export const isDevelopment = () => getAppEnvironment() === 'development'; export const getSegmentWriteKey = () => appConfig.segmentWriteKeys[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production']; @@ -46,7 +44,7 @@ export const getCioWriteKey = () => appConfig.cio[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production'].writeKey; export const getCioSiteId = () => appConfig.cio[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production'].siteId; -export const getAppBuildDate = () => new Date(process.env.BUILD_DATE ?? '').toLocaleDateString(); +export const getAppBuildDate = () => new Date(env.BUILD_DATE ?? '').toLocaleDateString(); export const getBrowserUserAgent = () => encodeURIComponent( @@ -62,7 +60,7 @@ export function updatesSupported() { } // Updates are not supported for Windows portable binaries - if (isWindows && process.env['PORTABLE_EXECUTABLE_DIR']) { + if (isWindows && env['PORTABLE_EXECUTABLE_DIR']) { return false; } diff --git a/packages/insomnia/src/common/har.ts b/packages/insomnia/src/common/har.ts index f18893c37b2b..1e7bc0292e30 100644 --- a/packages/insomnia/src/common/har.ts +++ b/packages/insomnia/src/common/har.ts @@ -1,20 +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 type { BaseModel, Cookie, Environment, Request, RequestGroup, Response, Workspace } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; +import { applyRequestHooks } from '~/network/network-adapter'; -import * as plugins from '../plugins'; -import * as pluginApp from '../plugins/context/app'; -import * as pluginRequest from '../plugins/context/request'; -import * as pluginStore from '../plugins/context/store'; 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'; @@ -211,7 +205,7 @@ export async function exportHarResponse(response?: Response) { status: response.statusCode, statusText: response.statusMessage, httpVersion: 'HTTP/1.1', - cookies: getResponseCookies(response), + cookies: await getResponseCookies(response), headers: getResponseHeaders(response), content: await getResponseContent(response), redirectURL: '', @@ -264,25 +258,7 @@ async function _applyRequestPluginHooks( renderedRequest: RenderedRequest, renderedContext: Record, ): Promise { - let newRenderedRequest = renderedRequest; - - for (const { plugin, hook } of await plugins.getRequestHooks()) { - newRenderedRequest = clone(newRenderedRequest); - const context = { - ...(pluginApp.init() as Record), - ...(pluginRequest.init(newRenderedRequest, renderedContext) as Record), - ...(pluginStore.init(plugin) as Record), - }; - - try { - await hook(context); - } catch (err) { - err.plugin = plugin; - throw err; - } - } - - return newRenderedRequest; + return applyRequestHooks(renderedRequest, renderedContext); } export async function exportHarWithRenderedRequest(renderedRequest: RenderedRequest, addContentLength = false) { @@ -321,7 +297,7 @@ export async function exportHarWithRenderedRequest(renderedRequest: RenderedRequ method: renderedRequest.method, url, httpVersion: 'HTTP/1.1', - cookies: getRequestCookies(renderedRequest), + cookies: await getRequestCookies(renderedRequest), headers: getRequestHeaders(renderedRequest), queryString: getRequestQueryString(renderedRequest), postData: await getRequestPostData(renderedRequest), @@ -331,36 +307,56 @@ export async function exportHarWithRenderedRequest(renderedRequest: RenderedRequ return harRequest; } -function getRequestCookies(renderedRequest: RenderedRequest) { - // filter out invalid cookies to avoid getCookiesSync complaining +async function getRequestCookies(renderedRequest: RenderedRequest): Promise { + if (!renderedRequest.url) { + return []; + } + if (typeof window !== 'undefined' && window.main?.cookies) { + const domainCookies = await window.main.cookies.getCookiesForUrl({ + cookies: renderedRequest.cookieJar.cookies, + url: renderedRequest.url, + }); + return domainCookies.map(mapCookieToHar); + } + // Fallback for non-renderer contexts (tests, plugin window) + const { jarFromCookies } = await import('./cookies'); const jar = jarFromCookies(renderedRequest.cookieJar.cookies); - const domainCookies = renderedRequest.url ? jar.getCookiesSync(renderedRequest.url) : []; - const harCookies: Har.Cookie[] = domainCookies.map(mapCookie); - return harCookies; + const domainCookies = jar.getCookiesSync(renderedRequest.url); + return domainCookies.map(c => mapCookieToHar(c.toJSON() as Cookie)); } -export function getResponseCookiesFromHeaders(headers: Har.Cookie[]) { - return getSetCookieHeaders(headers).reduce((accumulator, harCookie) => { - let cookie: null | undefined | ToughCookie = null; - +export async function getResponseCookiesFromHeaders(headers: Har.Cookie[]): Promise { + const setCookieHeaders = getSetCookieHeaders(headers); + if (typeof window !== 'undefined' && window.main?.cookies) { + const results: Har.Cookie[] = []; + for (const harCookie of setCookieHeaders) { + const cookie = await window.main.cookies.parse(harCookie.value || ''); + if (cookie) { + results.push(mapCookieToHar(cookie)); + } + } + return results; + } + // Fallback for non-renderer contexts (tests, plugin window) + const { Cookie: ToughCookie } = await import('tough-cookie'); + return setCookieHeaders.reduce((accumulator, harCookie) => { + let cookie = null; try { cookie = ToughCookie.parse(harCookie.value || '', { loose: true }); } catch {} - - if (cookie === null || cookie === undefined) { + if (!cookie) { return accumulator; } - - return [...accumulator, mapCookie(cookie)]; + return [...accumulator, mapCookieToHar(cookie.toJSON() as Cookie)]; }, [] as Har.Cookie[]); } -function getResponseCookies(response: Response) { +async function getResponseCookies(response: Response): Promise { const headers = response.headers.filter(Boolean); return getResponseCookiesFromHeaders(headers); } -function mapCookie(cookie: ToughCookie) { +function mapCookieToHar(cookie: Cookie): Har.Cookie { const harCookie: Har.Cookie = { name: cookie.key, value: cookie.value, diff --git a/packages/insomnia/src/common/mime.ts b/packages/insomnia/src/common/mime.ts new file mode 100644 index 000000000000..5b4804cb2118 --- /dev/null +++ b/packages/insomnia/src/common/mime.ts @@ -0,0 +1,43 @@ +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]), +); + +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.main.ts b/packages/insomnia/src/entry.main.ts index c1c5e58d5784..209922306847 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -26,6 +26,7 @@ import { registerInsomniaProtocols } from './main/api.protocol'; import { backupIfNewerVersionAvailable } from './main/backup'; import { registerSyncHandlers } from './main/cloud-sync/ipc'; import { registerGitServiceAPI } from './main/git-service'; +import { registerCookieHandlers } from './main/ipc/cookies'; import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron'; import { registerElectronStorageHandlers } from './main/ipc/electron-storage'; import { registergRPCHandlers } from './main/ipc/grpc'; @@ -88,6 +89,7 @@ app.on('ready', async () => { // @TODO - Maybe move the register stuff in the registerMainHandlers function registerMainHandlers(); registerPathHandlers(); + registerCookieHandlers(); registergRPCHandlers(); registerGitServiceAPI(); registerLLMConfigServiceAPI(); diff --git a/packages/insomnia/src/entry.plugin-window-preload.ts b/packages/insomnia/src/entry.plugin-window-preload.ts index 97384583b422..b8fffbc1feed 100644 --- a/packages/insomnia/src/entry.plugin-window-preload.ts +++ b/packages/insomnia/src/entry.plugin-window-preload.ts @@ -3,6 +3,9 @@ import { ipcRenderer } from 'electron'; // Provide window.app so plugin-loading code (which checks process.type === 'renderer') // can resolve the userData path without needing the main renderer's full preload. window.app = { + env: Object.fromEntries( + Object.entries(process.env).filter(([key, value]) => value !== undefined && key.startsWith('INSOMNIA_')), + ), getPath: (name: string) => ipcRenderer.sendSync('getPath', name) as string, getAppPath: () => ipcRenderer.sendSync('getAppPath') as string, process: { platform: process.platform as NodeJS.Platform }, diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 2a782fd4832a..6f03ccefae21 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -9,6 +9,7 @@ import { servicesProxy } from '~/ui/renderer-services-proxy'; import type { SyncBridgeAPI } from './main/cloud-sync/ipc'; import type { GitServiceAPI } from './main/git-service'; +import type { CookiesBridgeAPI } from './main/ipc/cookies'; import type { electronStorageBridgeAPI } from './main/ipc/electron-storage'; import type { gRPCBridgeAPI } from './main/ipc/grpc'; import type { secretStorageBridgeAPI } from './main/ipc/secret-storage'; @@ -123,9 +124,17 @@ const grpc: gRPCBridgeAPI = { closeAll: () => ipcRenderer.send('grpc.closeAll'), loadMethods: options => invokeWithNormalizedError('grpc.loadMethods', options), loadMethodsFromReflection: options => invokeWithNormalizedError('grpc.loadMethodsFromReflection', options), + validateProtoFile: filePath => invokeWithNormalizedError('grpc.validateProtoFile', filePath), writeProtoFile: protoFileId => invokeWithNormalizedError('grpc.writeProtoFile', protoFileId), }; +const cookies: CookiesBridgeAPI = { + fromJSON: cookie => invokeWithNormalizedError('cookies.fromJSON', cookie), + parse: cookie => invokeWithNormalizedError('cookies.parse', cookie), + toString: cookie => invokeWithNormalizedError('cookies.toString', cookie), + getCookiesForUrl: args => invokeWithNormalizedError('cookies.getCookiesForUrl', args), +}; + const secretStorage: secretStorageBridgeAPI = { setSecret: (key, secret) => invokeWithNormalizedError('secretStorage.setSecret', key, secret), getSecret: key => invokeWithNormalizedError('secretStorage.getSecret', key), @@ -252,6 +261,30 @@ const llm: LLMConfigServiceAPI = { invokeWithNormalizedError('llm.setAIFeatureEnabled', feature, enabled), }; +const rendererProcessEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key, value]) => + value !== undefined && + (key.startsWith('INSOMNIA_') || + key === 'BUILD_DATE' || + key === 'NODE_ENV' || + key === 'PLAYWRIGHT_TEST' || + key === 'PORTABLE_EXECUTABLE_DIR'), + ), +); + +const rendererProcess = { + env: rendererProcessEnv, + platform: process.platform as NodeJS.Platform, + type: 'renderer' as const, + versions: { + chrome: process.versions.chrome, + electron: process.versions.electron, + node: process.versions.node, + v8: process.versions.v8, + }, +}; + const main: Window['main'] = { startExecution: options => ipcRenderer.send('startExecution', options), addExecutionStep: options => ipcRenderer.send('addExecutionStep', options), @@ -305,6 +338,7 @@ const main: Window['main'] = { webSocket, socketIO, mcp, + cookies, git, llm, grpc, @@ -413,6 +447,7 @@ const dialog: Window['dialog'] = { showSaveDialog: options => invokeWithNormalizedError('showSaveDialog', options), }; const app: Window['app'] = { + env: rendererProcessEnv, getPath: options => ipcRenderer.sendSync('getPath', options), getAppPath: () => ipcRenderer.sendSync('getAppPath'), process: { @@ -439,6 +474,8 @@ const database: Window['database'] = { if (process.contextIsolated) { contextBridge.exposeInMainWorld('main', main); + contextBridge.exposeInMainWorld('process', rendererProcess); + contextBridge.exposeInMainWorld('global', globalThis); contextBridge.exposeInMainWorld('dialog', dialog); contextBridge.exposeInMainWorld('app', app); contextBridge.exposeInMainWorld('shell', shell); @@ -449,6 +486,14 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('_dataServices', servicesProxy); } else { window.main = main; + Object.defineProperty(window, 'process', { + configurable: true, + value: rendererProcess, + }); + Object.defineProperty(window, 'global', { + configurable: true, + value: window, + }); window.dialog = dialog; window.app = app; window.shell = shell; diff --git a/packages/insomnia/src/main/ipc/cookies.ts b/packages/insomnia/src/main/ipc/cookies.ts new file mode 100644 index 000000000000..11eb4f7ca91a --- /dev/null +++ b/packages/insomnia/src/main/ipc/cookies.ts @@ -0,0 +1,64 @@ +import { Cookie as ToughCookie, CookieJar } from 'tough-cookie'; + +import type { Cookie } from '~/insomnia-data'; + +import { ipcMainHandle } from './electron'; + +type CookieInput = Cookie | string; + +const parseCookieFromJSON = (cookie: CookieInput) => { + return typeof cookie === 'string' ? ToughCookie.fromJSON(cookie) : ToughCookie.fromJSON(cookie); +}; + +const cookieToString = (cookie: CookieInput) => { + const parsedCookie = parseCookieFromJSON(cookie); + + if (parsedCookie === null) { + throw new Error(`Unable to read cookie: ${cookie}`); + } + + let value = parsedCookie.toString(); + + if (parsedCookie.domain && parsedCookie.hostOnly) { + value += `; Domain=${parsedCookie.domain}`; + } + + return value; +}; + +const getCookiesForUrl = (cookies: Cookie[], url: string): Cookie[] => { + try { + const sanitized = cookies.map(c => ({ + ...c, + expires: c.expires === null || c.expires === undefined ? 'Infinity' : c.expires, + })); + const jar = CookieJar.fromJSON(JSON.stringify({ cookies: sanitized })); + jar.rejectPublicSuffixes = false; + jar.looseMode = true; + return jar.getCookiesSync(url).map(c => c.toJSON() as Cookie); + } catch { + return []; + } +}; + +export interface CookiesBridgeAPI { + fromJSON: (cookie: CookieInput) => Promise; + parse: (cookie: string) => Promise; + toString: (cookie: CookieInput) => Promise; + getCookiesForUrl: (args: { cookies: Cookie[]; url: string }) => Promise; +} + +export function registerCookieHandlers() { + ipcMainHandle('cookies.fromJSON', (_, cookie: CookieInput) => { + return parseCookieFromJSON(cookie)?.toJSON() as Cookie | null; + }); + ipcMainHandle('cookies.parse', (_, cookie: string) => { + return ToughCookie.parse(cookie, { loose: true })?.toJSON() as Cookie | null; + }); + ipcMainHandle('cookies.toString', (_, cookie: CookieInput) => { + return cookieToString(cookie); + }); + ipcMainHandle('cookies.getCookiesForUrl', (_, { cookies, url }: { cookies: Cookie[]; url: string }) => { + return getCookiesForUrl(cookies, url); + }); +} diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 8e8f02f6586c..c5cfbf3d7603 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -26,6 +26,10 @@ export type HandleChannels = | 'curl.event.findMany' | 'curl.open' | 'curl.readyState' + | 'cookies.fromJSON' + | 'cookies.getCookiesForUrl' + | 'cookies.parse' + | 'cookies.toString' | 'createPlugin' | 'curlRequest' | 'database.caCertificate.create' @@ -80,6 +84,7 @@ export type HandleChannels = | 'git.getGitProviderEmails' | 'grpc.loadMethods' | 'grpc.loadMethodsFromReflection' + | 'grpc.validateProtoFile' | 'grpc.writeProtoFile' | 'initializeWorkspaceBackendProject' | 'insecureReadFile' diff --git a/packages/insomnia/src/main/ipc/grpc.ts b/packages/insomnia/src/main/ipc/grpc.ts index c554a481f048..15796b1961df 100644 --- a/packages/insomnia/src/main/ipc/grpc.ts +++ b/packages/insomnia/src/main/ipc/grpc.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { FileDescriptorSet as ProtobufEsFileDescriptorSet, MethodIdempotency, @@ -60,6 +62,7 @@ export interface gRPCBridgeAPI { cancel: typeof cancel; loadMethods: typeof loadMethods; loadMethodsFromReflection: typeof loadMethodsFromReflection; + validateProtoFile: (filePath: string) => Promise; closeAll: typeof closeAll; writeProtoFile: (protoFileId: string) => Promise<{ filePath: string; dirs: string[] }>; } @@ -91,9 +94,17 @@ export function registergRPCHandlers() { ipcMainOn('grpc.closeAll', closeAll); ipcMainHandle('grpc.loadMethods', (_, requestId) => loadMethods(requestId)); ipcMainHandle('grpc.loadMethodsFromReflection', (_, requestId) => loadMethodsFromReflection(requestId)); + ipcMainHandle('grpc.validateProtoFile', (_, filePath: string) => validateProtoFile(filePath)); ipcMainHandle('grpc.writeProtoFile', (_, protoFileId: string) => writeProtoFileById(protoFileId)); } +const validateProtoFile = async (filePath: string): Promise => { + await protoLoader.load(filePath, { + ...grpcOptions, + includeDirs: [path.dirname(filePath)], + }); +}; + const loadMethodsFromFilePath = async (filePath: string, includeDirs: string[]): Promise => { const definition = await protoLoader.load(filePath, { ...grpcOptions, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 5fd282317f92..2db557711487 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -67,6 +67,7 @@ import { import type { SocketIOBridgeAPI } from '../network/socket-io'; import type { WebSocketBridgeAPI } from '../network/websocket'; import { registerPluginIpcHandlers } from '../plugin-window'; +import type { CookiesBridgeAPI } from './cookies'; import { ipcMainHandle, ipcMainOn, type RendererOnChannels } from './electron'; import type { electronStorageBridgeAPI } from './electron-storage'; import extractPostmanDataDumpHandler from './extract-postman-data-dump'; @@ -226,6 +227,7 @@ export interface RendererToMainBridgeAPI { webSocket: WebSocketBridgeAPI; socketIO: SocketIOBridgeAPI; mcp: McpBridgeAPI; + cookies: CookiesBridgeAPI; grpc: gRPCBridgeAPI; curl: CurlBridgeAPI; git: GitServiceAPI; diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 2d7bf356df14..1642fe137744 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -200,7 +200,7 @@ export function createWindow(): ElectronBrowserWindow { webPreferences: { preload: path.join(__dirname, 'entry.preload.min.js'), zoomFactor: getZoomFactor(), - nodeIntegration: true, + nodeIntegration: false, nodeIntegrationInWorker: false, // must remain false to ensure the nunjucks web worker sandbox does not have access to Node.js APIs webviewTag: true, // TODO: enable context isolation diff --git a/packages/insomnia/src/network/network-adapter.node.ts b/packages/insomnia/src/network/network-adapter.node.ts index 746e2c221680..739d4908f71b 100644 --- a/packages/insomnia/src/network/network-adapter.node.ts +++ b/packages/insomnia/src/network/network-adapter.node.ts @@ -4,6 +4,7 @@ import nodePath from 'node:path'; import clone from 'clone'; import type { RequestHeader } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import type { RenderedRequest } from '~/templating/types'; import type { RequestContext } from '../../../insomnia-scripting-environment/src/objects'; @@ -47,10 +48,23 @@ export const runScript = (options: { context: RequestContext; }): Promise => executeScript(options); +async function canRunPluginHooksInNodeAdapter() { + if (!process.versions?.electron || typeof global.require !== 'function') { + return false; + } + + const settings = await services.settings.get(); + return Boolean(settings); +} + export async function applyRequestHooks( newRenderedRequest: RenderedRequest, renderedContext: Record, ): Promise { + if (!await canRunPluginHooksInNodeAdapter()) { + return newRenderedRequest; + } + const pluginIndex = require('../plugins/index'); for (const { plugin, hook } of await pluginIndex.getRequestHooks()) { const context = { @@ -76,6 +90,10 @@ export async function applyResponseHooks( renderedRequest: RenderedRequest, renderedContext: Record, ): Promise { + if (!await canRunPluginHooksInNodeAdapter()) { + return response; + } + const newResponse = clone(response); const newRequest = clone(renderedRequest); const pluginIndex = require('../plugins/index'); diff --git a/packages/insomnia/src/network/network-adapter.renderer.ts b/packages/insomnia/src/network/network-adapter.renderer.ts index c03f93780b90..fe580e9fde0a 100644 --- a/packages/insomnia/src/network/network-adapter.renderer.ts +++ b/packages/insomnia/src/network/network-adapter.renderer.ts @@ -31,6 +31,10 @@ export async function applyRequestHooks( newRenderedRequest: RenderedRequest, renderedContext: Record, ): Promise { + if (!globalThis.window?.main?.plugins) { + return newRenderedRequest; + } + if (!await pluginsBridge.hasRequestHooks()) { return newRenderedRequest; } @@ -46,6 +50,10 @@ export async function applyResponseHooks( renderedRequest: RenderedRequest, renderedContext: Record, ): Promise { + if (!globalThis.window?.main?.plugins) { + return response; + } + if (!await pluginsBridge.hasResponseHooks()) { return response; } diff --git a/packages/insomnia/src/network/network-adapter.ts b/packages/insomnia/src/network/network-adapter.ts index 9409e0d4775c..065be33b3270 100644 --- a/packages/insomnia/src/network/network-adapter.ts +++ b/packages/insomnia/src/network/network-adapter.ts @@ -4,7 +4,10 @@ import type * as AdapterType from './network-adapter.renderer'; const impl = ( - (process as any).type === 'renderer' + (process as any).type === 'renderer' && + globalThis.window !== undefined && + globalThis.window.main !== undefined && + globalThis.window.main.plugins !== undefined ? require('./network-adapter.renderer') : require(/* @vite-ignore */ './network-adapter.node') ) as typeof AdapterType; diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index 6e50e5502599..ce190d188f59 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,7 @@ const extractCookies = async ( const totalSetCookies = setCookieStrings.length; if (totalSetCookies) { const currentUrl = getCurrentUrl({ headerResults, finalUrl }); + const { addSetCookiesToToughCookieJar } = await import('./set-cookie-util'); const { cookies, rejectedCookies } = await addSetCookiesToToughCookieJar({ setCookieStrings, currentUrl, diff --git a/packages/insomnia/src/plugins/misc.ts b/packages/insomnia/src/plugins/misc.ts index 97d828cc3ab8..802cd823d73b 100644 --- a/packages/insomnia/src/plugins/misc.ts +++ b/packages/insomnia/src/plugins/misc.ts @@ -3,8 +3,10 @@ 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 { SerializableTheme } from './bridge-types'; +import { plugins } from './renderer-bridge'; + +export type ColorScheme = 'default' | 'light' | 'dark'; export type HexColor = `#${string}`; export type RGBColor = `rgb(${string})`; @@ -331,7 +333,7 @@ export async function setTheme(themeName: string) { return; } - const themes: Theme[] = await getThemes(); + const themes: SerializableTheme[] = 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..85c1768b57e1 100644 --- a/packages/insomnia/src/plugins/renderer-bridge.ts +++ b/packages/insomnia/src/plugins/renderer-bridge.ts @@ -1,5 +1,4 @@ import type { PluginBridgeMetrics, PluginsBridgeAPI } from './bridge-types'; -import { invokePluginMethod } from './invoke-method'; // Phase 1a rollback switch: set INSOMNIA_ENABLE_PLUGIN_BRIDGE=false to fall // back to running plugins directly in the renderer (legacy behaviour). @@ -7,15 +6,17 @@ import { invokePluginMethod } from './invoke-method'; // plugin-system deps it pulls in don't inflate the preload. const bridgeEnabled = process.env.INSOMNIA_ENABLE_PLUGIN_BRIDGE !== 'false'; -function call>( +async function call>( method: M, args?: Parameters[0], -): ReturnType { +): Promise>> { if (bridgeEnabled) { const fn = (window.main.plugins[method] as (...a: any[]) => any); - return fn(args) as ReturnType; + return fn(args) as Promise>>; } - return invokePluginMethod(method as any, args) as ReturnType; + + const { invokePluginMethod } = await import('./invoke-method'); + return invokePluginMethod(method as any, args) as Promise>>; } const emptyBridgeMetrics: PluginBridgeMetrics = { 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 f5a3b2630f0c..35de96d6e687 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'; @@ -68,6 +66,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, @@ -311,9 +368,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..40a97fb0480c 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'; @@ -99,24 +98,27 @@ export const mockRouteToHar = ({ mimeType: string; headersArray: RequestHeader[]; body: string; -}): Har.Response => { +}): Promise => { const validHeaders = headersArray.filter(({ name }) => !!name); - return { - status: +statusCode, - statusText: statusText || RESPONSE_CODE_REASONS[+statusCode] || '', - httpVersion: 'HTTP/1.1', - headers: validHeaders, - cookies: getResponseCookiesFromHeaders(validHeaders), - content: { - size: Buffer.byteLength(body), - mimeType, - text: body, - compression: 0, - }, - headersSize: -1, - bodySize: -1, - redirectURL: '', - }; + return (async () => { + const { getResponseCookiesFromHeaders } = await import('~/common/har'); + return { + status: +statusCode, + statusText: statusText || RESPONSE_CODE_REASONS[+statusCode] || '', + httpVersion: 'HTTP/1.1', + headers: validHeaders, + cookies: await getResponseCookiesFromHeaders(validHeaders), + content: { + size: Buffer.byteLength(body), + mimeType, + text: body, + compression: 0, + }, + headersSize: -1, + bodySize: -1, + redirectURL: '', + }; + })(); }; export const useMockRoutePatcher = () => { @@ -168,7 +170,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/base-extension.ts b/packages/insomnia/src/templating/base-extension.ts index 80694ff293e8..033d8dc14301 100644 --- a/packages/insomnia/src/templating/base-extension.ts +++ b/packages/insomnia/src/templating/base-extension.ts @@ -1,7 +1,9 @@ import type { BinaryToTextEncoding } from 'node:crypto'; import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; import os from 'node:os'; +import electron from 'electron'; import iconv from 'iconv-lite'; import { jarFromCookies } from '~/common/cookies'; @@ -118,7 +120,7 @@ export default class BaseExtension { userInfo: os.userInfo(), }; }, - readFile: async (path: string) => window.main.secureReadFile({ path }), + readFile: async (path: string) => fs.readFile(path, 'utf8'), decode: async (buffer: Buffer, encoding = 'utf8') => iconv.decode(buffer, encoding), encode: async (input: string, encoding: BinaryToTextEncoding) => crypto.createHash('md5').update(input).digest(encoding), @@ -126,7 +128,7 @@ export default class BaseExtension { templating.render(str, { context: renderContext, }), - openInBrowser: (url: string) => window.main.openInBrowser(url), + openInBrowser: (url: string) => electron.shell.openExternal(url), models: { request: { getById: services.request.getById, diff --git a/packages/insomnia/src/templating/index.ts b/packages/insomnia/src/templating/index.ts index 3e433c2cf7e8..dd606c670fc9 100644 --- a/packages/insomnia/src/templating/index.ts +++ b/packages/insomnia/src/templating/index.ts @@ -1,176 +1,72 @@ -import { localTemplateTags } from 'insomnia/src/templating/local-template-tags'; -import type { Environment } from 'nunjucks'; +import type * as nodeTemplating from './node'; +import * as workerTemplating from './worker'; -import BaseExtension from './base-extension'; -import { nunjucks } from './nunjucks.client'; -import { extractUndefinedVariableKey, RenderError } from './render-error'; +type RenderFn = typeof workerTemplating.render; +type TemplatingModule = typeof nodeTemplating; -// Some constants -export const NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME = '_'; +let nodeTemplatingPromise: Promise | null = null; -type NunjucksEnvironment = Environment & { - extensions: Record; -}; +function shouldUseWorkerTemplating() { + return ( + (process.type === 'renderer' || process.type === 'worker') && + globalThis.window !== undefined && + globalThis.window.main !== undefined + ); +} -// Cached globals -let nunjucksAll: NunjucksEnvironment | null = null; - -/** - * Render text based on stuff - * @param {String} text - Nunjucks template in text form - * @param {Object} [config] - Config options for rendering - * @param {Object} [config.context] - Context to render with - * @param {Object} [config.path] - Path to include in the error message - */ -export function render( - text: string, - config: { - context?: Record; - path?: string; - ignoreUndefinedEnvVariable?: boolean; - } = {}, -) { - const hasNunjucksInterpolationSymbols = text.includes('{{') && text.includes('}}'); - const hasNunjucksCustomTagSymbols = text.includes('{%') && text.includes('%}'); - const hasNunjucksCommentSymbols = text.includes('{#') && text.includes('#}'); - if (!hasNunjucksInterpolationSymbols && !hasNunjucksCustomTagSymbols && !hasNunjucksCommentSymbols) { - return text; - } - const context = config.context || {}; - // context needs to exist on the root for the old templating syntax, and in _ for the new templating syntax - // old: {{ arr[0].prop }} - // new: {{ _['arr-name-with-dash'][0].prop }} - const templatingContext = { ...context, [NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME]: context }; - const path = config.path || null; - return new Promise(async (resolve, reject) => { - // NOTE: this is added as a breadcrumb because renderString sometimes hangs - const id = setTimeout(() => console.log('[templating] Warning: nunjucks failed to respond within 5 seconds'), 5000); - const nj = await getNunjucks(config.ignoreUndefinedEnvVariable); - nj?.renderString(text, templatingContext, (err: Error | null, result: any) => { - clearTimeout(id); - if (!err) { - return resolve(result); - } - console.warn('[templating] Error rendering template', err); - const sanitizedMsg = err.message - .replace(/\(unknown path\)\s/, '') - .replace(/\[Line \d+, Column \d*]/, '') - .replace(/^\s*Error:\s*/, '') - .trim(); - const location = err.message.match(/\[Line (\d+), Column (\d+)*]/); - const line = location ? Number.parseInt(location[1]) : 1; - const column = location ? Number.parseInt(location[2]) : 1; - const reason = err.message.includes('attempted to output null or undefined value') ? 'undefined' : 'error'; - const newError = new RenderError(sanitizedMsg); - newError.path = path || ''; - newError.message = sanitizedMsg; - newError.location = { - line, - column, - }; - newError.type = 'render'; - newError.reason = reason; - // regard as environment variable missing - if (hasNunjucksInterpolationSymbols && reason === 'undefined') { - newError.extraInfo = { - subType: 'environmentVariable', - undefinedEnvironmentVariables: extractUndefinedVariableKey(text, templatingContext), - }; - } - reject(newError); - }); - }); +function hasNunjucksSyntax(input: string) { + const hasNunjucksInterpolationSymbols = input.includes('{{') && input.includes('}}'); + const hasNunjucksCustomTagSymbols = input.includes('{%') && input.includes('%}'); + const hasNunjucksCommentSymbols = input.includes('{#') && input.includes('#}'); + return hasNunjucksInterpolationSymbols || hasNunjucksCustomTagSymbols || hasNunjucksCommentSymbols; } -/** - * Reload Nunjucks environments. Useful for if plugins change. - */ -export function reload() { - nunjucksAll = null; +function getNodeTemplatingModule() { + if (!nodeTemplatingPromise) { + nodeTemplatingPromise = import('./node') as Promise; + } + + return nodeTemplatingPromise; } -/** - * Get definitions of template tags - */ -export async function getTagDefinitions() { - const env = await getNunjucks(); - - return Object.keys(env.extensions) - .map(k => env.extensions[k]) - .filter(ext => !ext.isDeprecated()) - .sort((a, b) => (a.getPriority() > b.getPriority() ? 1 : -1)) - .map(ext => ({ - name: ext.getTag() || '', - displayName: ext.getName() || '', - liveDisplayName: ext.getLiveDisplayName(), - description: ext.getDescription(), - disablePreview: ext.getDisablePreview(), - args: ext.getArgs(), - actions: ext.getActions(), - })); +function getTemplatingModule(): typeof workerTemplating | Promise { + if (shouldUseWorkerTemplating()) { + return workerTemplating; + } + + return getNodeTemplatingModule(); } -async function getNunjucks(ignoreUndefinedEnvVariable?: boolean): Promise { - let throwOnUndefined = true; - if (ignoreUndefinedEnvVariable) { - throwOnUndefined = false; - } else if (nunjucksAll) { - return nunjucksAll; +export const NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME = workerTemplating.NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME; + +export const render: RenderFn = (...args) => { + const templatingModule = getTemplatingModule(); + if ('render' in templatingModule) { + return templatingModule.render(...args); } - // ~~~~~~~~~~~~ // - // Setup Config // - // ~~~~~~~~~~~~ // - const config = { - autoescape: false, - // Don't escape HTML - throwOnUndefined, - // Strict mode - tags: { - blockStart: '{%', - blockEnd: '%}', - variableStart: '{{', - variableEnd: '}}', - commentStart: '{#', - commentEnd: '#}', - }, - }; - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~ // - // Create Env with Extensions // - // ~~~~~~~~~~~~~~~~~~~~~~~~~~ // - const nunjucksEnvironment = nunjucks.configure(config) as NunjucksEnvironment; - nunjucksEnvironment.addGlobal('range', () => {}); - nunjucksEnvironment.addGlobal('cycler', () => {}); - nunjucksEnvironment.addGlobal('joiner', () => {}); - const pluginTemplateTags = await (await import('../plugins')).getTemplateTags(); - - const allExtensions = [ - ...localTemplateTags, - - // Spread after local tags to allow plugins to override them. - // TODO: Determine if this is in fact the behavior we've explicitly decided to support. - ...pluginTemplateTags, - ]; - - for (const extension of allExtensions) { - const { templateTag, plugin } = extension; - templateTag.priority = templateTag.priority || allExtensions.indexOf(extension); - const instance = new BaseExtension(templateTag, plugin); - nunjucksEnvironment.addExtension(instance.getTag() || '', instance); - // Hidden helper filter to debug complicated things - // eg. `{{ foo | urlencode | debug | upper }}` - nunjucksEnvironment.addFilter('debug', (o: any) => o); + const [input] = args; + if (!hasNunjucksSyntax(input)) { + return input; } - // ~~~~~~~~~~~~~~~~~~~~ // - // Cache Env and Return (when ignoreUndefinedEnvVariable is false) // - // ~~~~~~~~~~~~~~~~~~~~ // - if (ignoreUndefinedEnvVariable) { - return nunjucksEnvironment; + return templatingModule.then(module => module.render(...args)); +}; + +export function reload() { + const templatingModule = getTemplatingModule(); + if ('reload' in templatingModule) { + return templatingModule.reload(); } - nunjucksAll = nunjucksEnvironment; + return templatingModule.then(module => module.reload()); +} + +export async function getTagDefinitions() { + const templatingModule = getTemplatingModule(); + if ('getTagDefinitions' in templatingModule) { + return templatingModule.getTagDefinitions(); + } - return nunjucksEnvironment; + return templatingModule.then(module => module.getTagDefinitions()); } diff --git a/packages/insomnia/src/templating/node.ts b/packages/insomnia/src/templating/node.ts new file mode 100644 index 000000000000..24d811724447 --- /dev/null +++ b/packages/insomnia/src/templating/node.ts @@ -0,0 +1,178 @@ +import type { Environment } from 'nunjucks'; +import nunjucks from 'nunjucks'; + +import { localTemplateTags } from '~/templating/local-template-tags'; + +import type { TemplateTag } from '../plugins'; +import BaseExtension from './base-extension'; +import { extractUndefinedVariableKey, RenderError } from './render-error'; + +// Some constants +export const NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME = '_'; + +type NunjucksEnvironment = Environment & { + extensions: Record; +}; + +// Cached globals +let nunjucksAll: NunjucksEnvironment | null = null; + +/** + * Render text based on stuff + * @param {String} text - Nunjucks template in text form + * @param {Object} [config] - Config options for rendering + * @param {Object} [config.context] - Context to render with + * @param {Object} [config.path] - Path to include in the error message + */ +export function render( + text: string, + config: { + context?: Record; + path?: string; + ignoreUndefinedEnvVariable?: boolean; + } = {}, +) { + const hasNunjucksInterpolationSymbols = text.includes('{{') && text.includes('}}'); + const hasNunjucksCustomTagSymbols = text.includes('{%') && text.includes('%}'); + const hasNunjucksCommentSymbols = text.includes('{#') && text.includes('#}'); + if (!hasNunjucksInterpolationSymbols && !hasNunjucksCustomTagSymbols && !hasNunjucksCommentSymbols) { + return text; + } + const context = config.context || {}; + // context needs to exist on the root for the old templating syntax, and in _ for the new templating syntax + // old: {{ arr[0].prop }} + // new: {{ _['arr-name-with-dash'][0].prop }} + const templatingContext = { ...context, [NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME]: context }; + const path = config.path || null; + return new Promise(async (resolve, reject) => { + // NOTE: this is added as a breadcrumb because renderString sometimes hangs + const id = setTimeout(() => console.log('[templating] Warning: nunjucks failed to respond within 5 seconds'), 5000); + const nj = await getNunjucks(config.ignoreUndefinedEnvVariable); + nj?.renderString(text, templatingContext, (err: Error | null, result: any) => { + clearTimeout(id); + if (!err) { + return resolve(result); + } + console.warn('[templating] Error rendering template', err); + const sanitizedMsg = err.message + .replace(/\(unknown path\)\s/, '') + .replace(/\[Line \d+, Column \d*]/, '') + .replace(/^\s*Error:\s*/, '') + .trim(); + const location = err.message.match(/\[Line (\d+), Column (\d+)*]/); + const line = location ? Number.parseInt(location[1]) : 1; + const column = location ? Number.parseInt(location[2]) : 1; + const reason = err.message.includes('attempted to output null or undefined value') ? 'undefined' : 'error'; + const newError = new RenderError(sanitizedMsg); + newError.path = path || ''; + newError.message = sanitizedMsg; + newError.location = { + line, + column, + }; + newError.type = 'render'; + newError.reason = reason; + // regard as environment variable missing + if (hasNunjucksInterpolationSymbols && reason === 'undefined') { + newError.extraInfo = { + subType: 'environmentVariable', + undefinedEnvironmentVariables: extractUndefinedVariableKey(text, templatingContext), + }; + } + reject(newError); + }); + }); +} + +/** + * Reload Nunjucks environments. Useful for if plugins change. + */ +export function reload() { + nunjucksAll = null; +} + +/** + * Get definitions of template tags + */ +export async function getTagDefinitions() { + const env = await getNunjucks(); + + return Object.keys(env.extensions) + .map(k => env.extensions[k]) + .filter(ext => !ext.isDeprecated()) + .sort((a, b) => (a.getPriority() > b.getPriority() ? 1 : -1)) + .map(ext => ({ + name: ext.getTag() || '', + displayName: ext.getName() || '', + liveDisplayName: ext.getLiveDisplayName(), + description: ext.getDescription(), + disablePreview: ext.getDisablePreview(), + args: ext.getArgs(), + actions: ext.getActions(), + })); +} + +async function getNunjucks(ignoreUndefinedEnvVariable?: boolean): Promise { + let throwOnUndefined = true; + if (ignoreUndefinedEnvVariable) { + throwOnUndefined = false; + } else if (nunjucksAll) { + return nunjucksAll; + } + + // ~~~~~~~~~~~~ // + // Setup Config // + // ~~~~~~~~~~~~ // + const config = { + autoescape: false, + // Don't escape HTML + throwOnUndefined, + // Strict mode + tags: { + blockStart: '{%', + blockEnd: '%}', + variableStart: '{{', + variableEnd: '}}', + commentStart: '{#', + commentEnd: '#}', + }, + }; + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~ // + // Create Env with Extensions // + // ~~~~~~~~~~~~~~~~~~~~~~~~~~ // + const nunjucksEnvironment = nunjucks.configure(config) as NunjucksEnvironment; + nunjucksEnvironment.addGlobal('range', () => {}); + nunjucksEnvironment.addGlobal('cycler', () => {}); + nunjucksEnvironment.addGlobal('joiner', () => {}); + const pluginTemplateTags: TemplateTag[] = []; + + const allExtensions = [ + ...localTemplateTags, + + // Spread after local tags to allow plugins to override them. + // TODO: Determine if this is in fact the behavior we've explicitly decided to support. + ...pluginTemplateTags, + ]; + + for (const extension of allExtensions) { + const { templateTag, plugin } = extension; + templateTag.priority = templateTag.priority || allExtensions.indexOf(extension); + const instance = new BaseExtension(templateTag, plugin); + nunjucksEnvironment.addExtension(instance.getTag() || '', instance); + // Hidden helper filter to debug complicated things + // eg. `{{ foo | urlencode | debug | upper }}` + nunjucksEnvironment.addFilter('debug', (o: any) => o); + } + + // ~~~~~~~~~~~~~~~~~~~~ // + // Cache Env and Return (when ignoreUndefinedEnvVariable is false) // + // ~~~~~~~~~~~~~~~~~~~~ // + if (ignoreUndefinedEnvVariable) { + return nunjucksEnvironment; + } + + nunjucksAll = nunjucksEnvironment; + + return nunjucksEnvironment; +} diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index 2682b9b73697..c28ffc342575 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -1,7 +1,6 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror'; import { models, services } from '~/insomnia-data'; -import { decryptSecretValue } from '~/utils/vault'; import type { NunjucksParsedTag, NunjucksParsedTagArg, RenderPurpose } from '../templating/types'; import { decryptVaultKeyFromSession } from '../utils/vault'; @@ -246,6 +245,7 @@ export async function maskOrDecryptVaultDataIfNecessary(vaultEnvironmentData: an const { vaultKey, vaultSalt } = await services.userSession.get(); const isVaultEnabled = !!vaultSalt; if (isVaultEnabled && vaultKey) { + const { decryptSecretValue } = await import('~/utils/vault-crypto'); const symmetricKey = (await decryptVaultKeyFromSession(vaultKey, true)) as JsonWebKey; // decrypt all secret values under vaultEnvironmentPath property in context Object.keys(vaultEnvironmentData).forEach(vaultContextKey => { diff --git a/packages/insomnia/src/templating/worker.ts b/packages/insomnia/src/templating/worker.ts index 29a77b2b080e..2549308f7643 100644 --- a/packages/insomnia/src/templating/worker.ts +++ b/packages/insomnia/src/templating/worker.ts @@ -1,7 +1,6 @@ import type { Environment } from 'nunjucks'; import { localTemplateTags } from '~/templating/local-template-tags'; -import { nunjucks } from '~/templating/nunjucks.client'; import type { TemplateTag } from '../plugins'; import BaseExtensionWorker, { fetchFromTemplateWorkerDatabase } from './base-extension-worker'; @@ -141,6 +140,7 @@ async function getNunjucks(ignoreUndefinedEnvVariable?: boolean): Promise {}); nunjucksEnvironment.addGlobal('cycler', () => {}); diff --git a/packages/insomnia/src/ui/auth-session-provider.client.ts b/packages/insomnia/src/ui/auth-session-provider.client.ts index cf33fbfee5c7..a1cc84134034 100644 --- a/packages/insomnia/src/ui/auth-session-provider.client.ts +++ b/packages/insomnia/src/ui/auth-session-provider.client.ts @@ -1,33 +1,52 @@ 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(); + + 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.'); + } + }); + + return sessionKeyPair; + })(); + } + + return sessionKeyPairPromise; +} + export async function decodeBase64(base64: string): Promise { try { let uri = 'data:application/octet-binary;base64,'; @@ -65,9 +84,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 +101,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 f51f37bad8ba..742793bcdb13 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 ff01375e6c2f..4f2bb57e8739 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..791d2da4fd6c 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,6 +35,7 @@ export const PreviewModeDropdown: FC = ({ download, copyToClipboard }) => return; } + const { exportHarCurrentRequest } = await import('../../../common/har'); const data = await exportHarCurrentRequest(activeRequest, activeResponse); const har = JSON.stringify(data, null, '\t'); 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..e4a15fb0489a 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,6 +146,7 @@ export const RequestActionsDropdown = ({ const copyAsCurl = async () => { try { + const { exportHarRequest } = await import('../../../common/har'); const har = await exportHarRequest(request._id, workspaceId); const { HTTPSnippet } = await import('httpsnippet'); const snippet = new HTTPSnippet(har); 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/environment-key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx index 831f094dcc2a..187f96ec2681 100644 --- a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx @@ -20,7 +20,7 @@ import { checkNestedKeys, ensureKeyIsValid } from '~/utils/environment-utils'; import { generateId } from '../../../../common/misc'; import { base64decode } from '../../../../utils/vault'; -import { decryptSecretValue, encryptSecretValue } from '../../../../utils/vault'; +import { decryptSecretValue, encryptSecretValue } from '../../../../utils/vault-crypto'; import { PromptButton } from '../../base/prompt-button'; import { Icon } from '../../icon'; import { showModal } from '../../modals'; 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..8032233c2e9d 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,12 @@ 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'; +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 +145,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 +536,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 +560,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 +577,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 +608,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..c893143bda46 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,6 +384,7 @@ const PreviewModeDropdown = ({ if (canceled || !filePath || !activeRequest) { return; } + const { exportHarCurrentRequest } = await import('../../../common/har'); const data = await exportHarCurrentRequest(activeRequest, activeResponse); const har = JSON.stringify(data, null, '\t'); diff --git a/packages/insomnia/src/ui/components/modals/cookies-modal.tsx b/packages/insomnia/src/ui/components/modals/cookies-modal.tsx index 6bae82a3cd39..39d08d76bca4 100644 --- a/packages/insomnia/src/ui/components/modals/cookies-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/cookies-modal.tsx @@ -1,6 +1,6 @@ import clone from 'clone'; import { isValid } from 'date-fns'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Button, Dialog, @@ -18,14 +18,12 @@ import { TextField, } from 'react-aria-components'; import { useParams } from 'react-router'; -import { Cookie as ToughCookie } from 'tough-cookie'; import { v4 as uuidv4 } from 'uuid'; import type { Cookie, CookieJar } from '~/insomnia-data'; import { useUpdateCookieJarActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.update-cookie-jar'; import { OneLineEditor } from '~/ui/components/.client/codemirror/one-line-editor'; -import { cookieToString } from '../../../common/cookies'; import { fuzzyMatch } from '../../../common/misc'; import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useNunjucks } from '../../context/nunjucks/use-nunjucks'; @@ -269,13 +267,36 @@ export interface CookieListProps { const CookieList = ({ cookies, onCookieDelete, onUpdateCookie }: CookieListProps) => { const [cookieToEdit, setCookieToEdit] = useState(null); + const [cookieStrings, setCookieStrings] = useState>({}); + + useEffect(() => { + let cancelled = false; + + void Promise.all( + cookies.map(async cookie => { + try { + return [cookie.id, await window.main.cookies.toString(cookie)] as const; + } catch (err) { + console.warn('Failed to parse cookie string', err); + return [cookie.id, ''] as const; + } + }), + ).then(entries => { + if (!cancelled) { + setCookieStrings(Object.fromEntries(entries)); + } + }); + + return () => { + cancelled = true; + }; + }, [cookies]); return ( <> {cookies.map((cookie, index) => { - const cookieJSON = ToughCookie.fromJSON(cookie); - const cookieString = cookieJSON ? cookieToString(cookieJSON) : ''; + const cookieString = cookieStrings[cookie.id] || ''; if (cookie.expires && !isValid(new Date(cookie.expires))) { cookie.expires = null; @@ -401,24 +422,41 @@ interface CookieModifyModalProps { const CookieModifyModal = ({ cookie, isOpen, setIsOpen, onUpdateCookie }: CookieModifyModalProps) => { const [editCookie, setEditCookie] = useState(cookie); + const [rawDefaultValue, setRawDefaultValue] = useState(''); let localDateTime: string; if (editCookie && editCookie.expires && isValid(new Date(editCookie.expires))) { localDateTime = new Date(editCookie.expires).toISOString().slice(0, 16); } - let rawDefaultValue; - if (!editCookie) { - rawDefaultValue = ''; - } else { - try { - const c = ToughCookie.fromJSON(JSON.stringify(editCookie)); - rawDefaultValue = c ? cookieToString(c) : ''; - } catch (err) { - console.warn('Failed to parse cookie string', err); - rawDefaultValue = ''; - } - } + useEffect(() => { + let cancelled = false; + + void (async () => { + if (!cookie) { + if (!cancelled) { + setRawDefaultValue(''); + } + return; + } + + try { + const value = await window.main.cookies.toString(cookie); + if (!cancelled) { + setRawDefaultValue(value); + } + } catch (err) { + console.warn('Failed to parse cookie string', err); + if (!cancelled) { + setRawDefaultValue(''); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [cookie]); return ( { + onChange={async event => { try { - // NOTE: Perform toJSON so we have a plain JS object instead of Cookie instance - const parsed = ToughCookie.parse(event.target.value, { loose: true })?.toJSON(); + const parsed = await window.main.cookies.parse(event.target.value); if (parsed) { // Make sure cookie has an id and keep its host-only-flag parsed.id = editCookie.id; 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..3fb55304e3ed 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'; @@ -87,6 +86,7 @@ export const GenerateCodeModal = forwardRef((pro const addContentLength = Boolean( (TO_ADD_CONTENT_LENGTH[targetOrFallback.key] || []).find(c => c === clientOrFallback.key), ); + const { exportHarWithRequest } = await import('../../../common/har'); const har = await exportHarWithRequest(request, props.environmentId, addContentLength); if (har) { const snippet = new HTTPSnippet(har); 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/modals/proto-files-modal.tsx b/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx index 957954a5d9e2..6dac3eeae5e0 100644 --- a/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx @@ -1,4 +1,3 @@ -import * as protoLoader from '@grpc/proto-loader'; import React, { type FC, useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router'; @@ -49,13 +48,7 @@ const tryToSelectFolderPath = async () => { }; const isProtofileValid = async (filePath: string) => { try { - await protoLoader.load(filePath, { - keepCase: true, - longs: String, - enums: String, - defaults: true, - oneofs: true, - }); + await window.main.grpc.validateProtoFile(filePath); return true; } catch (error) { showError({ 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 = ({