diff --git a/packages/insomnia/src/common/templating/__tests__/utils.test.ts b/packages/insomnia/src/common/templating/__tests__/utils.test.ts index 16c3ae2f553..525d7a576af 100644 --- a/packages/insomnia/src/common/templating/__tests__/utils.test.ts +++ b/packages/insomnia/src/common/templating/__tests__/utils.test.ts @@ -332,6 +332,72 @@ describe('decodeEncoding()', () => { }); }); +describe('replaceVaultTagIdIfNeeded()', () => { + const vaultTagIdRegex = utils.vaultTagIdRegex; + const normalTemplate = `{% vault 'aws', '${utils.generateExternalVaultTagId()}', 'eyJTZWNyZXRJZCI6IjEifQ==' %}`; + + it('leaves plain text without template tag symbols untouched', () => { + const template = 'normal string'; + expect(utils.replaceVaultTagIdIfNeeded(template)).toBe(template); + }); + + it('leaves multiple line of input unchanged', () => { + const input = 'line 1\nline 2\nline 3'; + expect(utils.replaceVaultTagIdIfNeeded(input)).toBe(input); + }); + + it('leaves a template tag with no vault tag id untouched', () => { + const template = "{% response 'body', 'req_123', 'b64', '' %}"; + expect(utils.replaceVaultTagIdIfNeeded(template)).toBe(template); + }); + + it('replaces the vault tag id inside a template tag with a freshly generated one', () => { + const result = utils.replaceVaultTagIdIfNeeded(normalTemplate); + + expect(result).not.toBe(normalTemplate); + expect(result).not.toContain('externalVaultTag_0b1ea47e317f4f03ae147dcb6c5f66c6'); + + const [, newId] = result.match(/'(externalVaultTag_\w+)'/) || []; + expect(newId).toMatch(vaultTagIdRegex); + }); + + it('generates a different id on each call', () => { + const first = utils.replaceVaultTagIdIfNeeded(normalTemplate); + const second = utils.replaceVaultTagIdIfNeeded(normalTemplate); + expect(first).not.toBe(second); + }); + + it('does not replace a vault tag id-like string outside of a {% %} block', () => { + const idLikeString = 'externalVaultTag_0b1ea47e317f4f03ae147dcb6c5f66c6'; + const template = `see ${idLikeString} for reference ${normalTemplate}`; + + const result = utils.replaceVaultTagIdIfNeeded(template); + + // the id-like text outside the tag is left alone + expect(result).toContain(idLikeString); + // the id inside the {% %} tag is still replaced + expect(result).not.toContain(normalTemplate); + }); + + it('replaces each vault tag id with a distinct new id when the template contains multiple tags', () => { + const template = + 'templateStart' + + "{% vault 'aws', 'externalVaultTag_0b1ef47e317f4f03ae147dcb6c5f66c6', 'eyJTZWNyZXRJZCI6IjEifQ==' %}" + + "{% vault 'aws', 'externalVaultTag_1b1ef47e317f4f03ae147dcb6c5f66c6', 'eyJTZWNyZXRJZCI6IjEifQ==' %}" + + "{% vault 'aws', 'externalVaultTag_2b1ef47e317f4f03ae147dcb6c5f66c6', 'eyJTZWNyZXRJZCI6IjEifQ==' %}" + + 'templateEnd'; + + const result = utils.replaceVaultTagIdIfNeeded(template); + + const newIds = result.match(new RegExp(`${utils.externalVaultTagPrefix}_\\w+`, 'g')) || []; + expect(newIds).toHaveLength(3); + expect(new Set(newIds).size).toBe(3); + newIds.forEach(id => expect(id).toMatch(vaultTagIdRegex)); + expect(result.startsWith('templateStart')).toBe(true); + expect(result.endsWith('templateEnd')).toBe(true); + }); +}); + describe('extractUndefinedVariableKey()', () => { it('extract nunjucks variable key', () => { expect(extractUndefinedVariableKey('{{name}}', {})).toEqual(['name']); diff --git a/packages/insomnia/src/common/templating/utils.ts b/packages/insomnia/src/common/templating/utils.ts index da0a65df999..f6cca8d99f3 100644 --- a/packages/insomnia/src/common/templating/utils.ts +++ b/packages/insomnia/src/common/templating/utils.ts @@ -1,10 +1,12 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror'; +import { generateId } from '~/common/misc'; import type { NunjucksParsedTag, NunjucksParsedTagArg } from '~/common/templating/types'; import { base64ToUtf8, utf8ToBase64 } from '~/common/utils/utf8-bytes'; import { tokenizeArgs } from './tokenize-args'; export { tokenizeArgs }; + import objectPath from './third_party/object-path'; /** @@ -163,3 +165,16 @@ export function extractNunjucksTagFromCoords( } export const responseTagRegex = new RegExp('{% *response *.* %}'); + +// Unique id prefix for external vault tags +export const externalVaultTagPrefix = 'externalVaultTag'; +export const generateExternalVaultTagId = () => generateId(externalVaultTagPrefix); +const tagRegex = /{%[\s\S]+?%}/g; +export const vaultTagIdRegex = new RegExp(`${externalVaultTagPrefix}_[a-z0-9]{32}`, 'g'); +export const containsExternalVaultTag = (input: string) => { + const hasTemplateTagSymbols = input.match(tagRegex); + return hasTemplateTagSymbols && input.includes(externalVaultTagPrefix); +}; +// Replace the unique id in vault tag with a new unique id to avoid duplicates when pasting +export const replaceVaultTagIdIfNeeded = (input: string) => + input.replace(tagRegex, tag => tag.replace(vaultTagIdRegex, () => generateExternalVaultTagId())); 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 95697e48fa1..3aafe8edd1c 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -22,7 +22,11 @@ import vkBeautify from 'vkbeautify'; import { DEBOUNCE_MILLIS } from '~/common/constants'; import * as misc from '~/common/misc'; import { type NunjucksParsedTag, type nunjucksTagContextMenuOptions } from '~/common/templating/types'; -import { extractNunjucksTagFromCoords } from '~/common/templating/utils'; +import { + containsExternalVaultTag, + extractNunjucksTagFromCoords, + replaceVaultTagIdIfNeeded, +} from '~/common/templating/utils'; import { useRootLoaderData } from '~/root'; import { AnalyticsEvent, trackOnceDaily } from '~/ui/analytics'; import { Icon } from '~/ui/components/icon'; @@ -489,10 +493,17 @@ export const CodeEditor = memo( doc.scrollTo(0, scrollPosition); } - if (onPaste && change.origin === 'paste' && change.update) { - const translatedText = onPaste(change.text.join('\n')).split('\n'); - - change.update(change.from, change.to, translatedText); + if (change.origin === 'paste' && change.update) { + let translatedText = change.text.join('\n'); + if (onPaste) { + translatedText = onPaste(translatedText); + } + if (containsExternalVaultTag(translatedText)) { + translatedText = replaceVaultTagIdIfNeeded(translatedText); + } + if (translatedText !== change.text.join('\n')) { + change.update(change.from, change.to, translatedText.split('\n')); + } } }); diff --git a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx index f7aad16ccfa..05e5822bd62 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx @@ -19,7 +19,11 @@ import * as reactUse from 'react-use'; import { DEBOUNCE_MILLIS } from '~/common/constants'; import * as misc from '~/common/misc'; import { type NunjucksParsedTag, type nunjucksTagContextMenuOptions } from '~/common/templating/types'; -import { extractNunjucksTagFromCoords } from '~/common/templating/utils'; +import { + containsExternalVaultTag, + extractNunjucksTagFromCoords, + replaceVaultTagIdIfNeeded, +} from '~/common/templating/utils'; import { isCurlCommand } from '~/common/utils/curl'; import { useRootLoaderData } from '~/root'; import { showModal } from '~/ui/components/modals'; @@ -200,7 +204,7 @@ export const OneLineEditor = forwardRef }; codeMirror.current = CodeMirror.fromTextArea(textAreaRef.current, initialOptions); codeMirror.current.on('beforeChange', (_: CodeMirror.Editor, change: CodeMirror.EditorChangeCancellable) => { - const isPaste = change.text && change.text.length > 1; + const isPaste = change.origin === 'paste' && change.update; if (isPaste) { const pastedText = change.text.join('\n'); const hasContent = pastedText.trim(); @@ -208,8 +212,12 @@ export const OneLineEditor = forwardRef change.cancel(); return; } + let editorPasteText = change.text.join('').replace(/\n/g, ' '); + if (containsExternalVaultTag(editorPasteText)) { + editorPasteText = replaceVaultTagIdIfNeeded(editorPasteText); + } // If we're in single-line mode, merge all changed lines into one - change.update?.(change.from, change.to, [change.text.join('').replace(/\n/g, ' ')]); + change.update?.(change.from, change.to, [editorPasteText]); } }); codeMirror.current.on('paste', (_, e: ClipboardEvent) => { diff --git a/packages/insomnia/src/ui/components/templating/tag-editor.tsx b/packages/insomnia/src/ui/components/templating/tag-editor.tsx index 6d9b4afb9ff..f16c1a101f2 100644 --- a/packages/insomnia/src/ui/components/templating/tag-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/tag-editor.tsx @@ -7,7 +7,6 @@ import { Button, Link } from 'react-aria-components'; import * as reactUse from 'react-use'; import { getAppBundlePlugins } from '~/common/constants'; -import { generateId } from '~/common/misc'; import { localTemplateTags } from '~/common/templating/local-template-tags'; import type { NunjucksParsedTag, NunjucksParsedTagArg } from '~/common/templating/types'; import * as templateUtils from '~/common/templating/utils'; @@ -193,7 +192,7 @@ export const TagEditor: FC = props => { // Generate a unique tag id for the vault and link it to the credential in pluginData. // Update the tag arg to store the unique id instead of the raw (per-user) credential id. function convertLegacyCredentialTag(legacyCredentialId: string, argIndex: number) { - const tagUniqueId = generateId('externalVaultTag'); + const tagUniqueId = templateUtils.generateExternalVaultTagId(); updateArg(tagUniqueId, argIndex); return services.pluginData .upsertByKey(vaultPluginName, tagUniqueId, legacyCredentialId)