mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(editor): Avoid sanitizing output to search node data (#8126)
## Summary In search feature, output sanitization was added to support `<mark` tag in output panel to highlight searched text. This removes any html like data in the input/output panel.. This PR removes sanitization while keeping text highlights.. ## Related tickets and issues https://community.n8n.io/t/n8n-output/33997 https://community.n8n.io/t/html-tags-in-editor-rendered/34240 https://github.com/n8n-io/n8n/issues/8081 https://linear.app/n8n/issue/ADO-1594/node-output-view-not-consistent https://linear.app/n8n/issue/ADO-1597/bug-xml-display-issue ## Review / Merge checklist - [X] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [ ] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. > A feature is not complete without tests.
This commit is contained in:
@@ -33,7 +33,9 @@
|
||||
@update:selectedValue="selectedJsonPath = $event"
|
||||
>
|
||||
<template #renderNodeKey="{ node }">
|
||||
<span
|
||||
<TextWithHighlights
|
||||
:content="getContent(node.key)"
|
||||
:search="search"
|
||||
data-target="mappable"
|
||||
:data-value="getJsonParameterPath(node.path)"
|
||||
:data-name="node.key"
|
||||
@@ -43,13 +45,18 @@
|
||||
[$style.mappable]: mappingEnabled,
|
||||
[$style.dragged]: draggingPath === node.path,
|
||||
}"
|
||||
v-html="highlightSearchTerm(node.key)"
|
||||
/>
|
||||
</template>
|
||||
<template #renderNodeValue="{ node }">
|
||||
<span v-if="isNaN(node.index)" v-html="highlightSearchTerm(node.content)" />
|
||||
<span
|
||||
<TextWithHighlights
|
||||
v-if="isNaN(node.index)"
|
||||
:content="getContent(node.content)"
|
||||
:search="search"
|
||||
/>
|
||||
<TextWithHighlights
|
||||
v-else
|
||||
:content="getContent(node.content)"
|
||||
:search="search"
|
||||
data-target="mappable"
|
||||
:data-value="getJsonParameterPath(node.path)"
|
||||
:data-name="getListItemName(node.path)"
|
||||
@@ -60,7 +67,6 @@
|
||||
[$style.dragged]: draggingPath === node.path,
|
||||
}"
|
||||
class="ph-no-capture"
|
||||
v-html="highlightSearchTerm(node.content)"
|
||||
/>
|
||||
</template>
|
||||
</vue-json-pretty>
|
||||
@@ -76,7 +82,6 @@ import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import Draggable from '@/components/Draggable.vue';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { isString } from '@/utils/typeGuards';
|
||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { shorten } from '@/utils/typesUtils';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { mapStores } from 'pinia';
|
||||
@@ -86,6 +91,7 @@ import { getMappedExpression } from '@/utils/mappingUtils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { nonExistingJsonPath } from '@/constants';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
|
||||
const RunDataJsonActions = defineAsyncComponent(
|
||||
async () => import('@/components/RunDataJsonActions.vue'),
|
||||
@@ -98,6 +104,7 @@ export default defineComponent({
|
||||
Draggable,
|
||||
RunDataJsonActions,
|
||||
MappingPill,
|
||||
TextWithHighlights,
|
||||
},
|
||||
props: {
|
||||
editMode: {
|
||||
@@ -202,9 +209,6 @@ export default defineComponent({
|
||||
getListItemName(path: string): string {
|
||||
return path.replace(/^(\["?\d"?]\.?)/g, '');
|
||||
},
|
||||
highlightSearchTerm(value: string): string {
|
||||
return sanitizeHtml(highlightText(this.getContent(value), this.search));
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { INodeUi, Schema } from '@/Interface';
|
||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { checkExhaustive } from '@/utils/typeGuards';
|
||||
import { shorten } from '@/utils/typesUtils';
|
||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
|
||||
type Props = {
|
||||
schema: Schema;
|
||||
@@ -30,12 +30,8 @@ const isFlat = computed(
|
||||
props.schema.value.every((v) => !Array.isArray(v.value)),
|
||||
);
|
||||
const key = computed((): string | undefined => {
|
||||
const highlightedKey = sanitizeHtml(highlightText(props.schema.key, props.search));
|
||||
return isSchemaParentTypeArray.value ? `[${highlightedKey}]` : highlightedKey;
|
||||
return isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key;
|
||||
});
|
||||
const parentKey = computed((): string | undefined =>
|
||||
sanitizeHtml(highlightText(props.parent.key, props.search)),
|
||||
);
|
||||
const schemaName = computed(() =>
|
||||
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
|
||||
);
|
||||
@@ -99,8 +95,17 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
||||
data-target="mappable"
|
||||
>
|
||||
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
||||
<span v-if="isSchemaParentTypeArray" v-html="parentKey" />
|
||||
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }" v-html="key" />
|
||||
<TextWithHighlights
|
||||
v-if="isSchemaParentTypeArray"
|
||||
:content="props.parent?.key"
|
||||
:search="props.search"
|
||||
/>
|
||||
<TextWithHighlights
|
||||
v-if="key"
|
||||
:class="{ [$style.arrayIndex]: isSchemaParentTypeArray }"
|
||||
:content="key"
|
||||
:search="props.search"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="text" :class="$style.text">{{ text }}</span>
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
[$style.draggingHeader]: isDragging,
|
||||
}"
|
||||
>
|
||||
<span v-html="highlightSearchTerm(column || '')" />
|
||||
<TextWithHighlights
|
||||
:content="getValueToRender(column || '')"
|
||||
:search="search"
|
||||
/>
|
||||
<div :class="$style.dragButton">
|
||||
<font-awesome-icon icon="grip-vertical" />
|
||||
</div>
|
||||
@@ -117,10 +120,11 @@
|
||||
@mouseleave="onMouseLeaveCell"
|
||||
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
|
||||
>
|
||||
<span
|
||||
<TextWithHighlights
|
||||
v-if="isSimple(data)"
|
||||
:content="getValueToRender(data)"
|
||||
:search="search"
|
||||
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
|
||||
v-html="highlightSearchTerm(data)"
|
||||
/>
|
||||
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
|
||||
<template #label="{ label, path }">
|
||||
@@ -141,9 +145,10 @@
|
||||
>
|
||||
</template>
|
||||
<template #value="{ value }">
|
||||
<span
|
||||
<TextWithHighlights
|
||||
:content="getValueToRender(value)"
|
||||
:search="search"
|
||||
:class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
|
||||
v-html="highlightSearchTerm(value)"
|
||||
/>
|
||||
</template>
|
||||
</n8n-tree>
|
||||
@@ -162,7 +167,6 @@ import type { PropType } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import type { INodeUi, ITableData, NDVState } from '@/Interface';
|
||||
import { shorten } from '@/utils/typesUtils';
|
||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { getPairedItemId } from '@/utils/pairedItemUtils';
|
||||
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import Draggable from './Draggable.vue';
|
||||
@@ -171,6 +175,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
||||
import MappingPill from './MappingPill.vue';
|
||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
|
||||
const MAX_COLUMNS_LIMIT = 40;
|
||||
|
||||
@@ -178,7 +183,7 @@ type DraggableRef = InstanceType<typeof Draggable>;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'run-data-table',
|
||||
components: { Draggable, MappingPill },
|
||||
components: { Draggable, MappingPill, TextWithHighlights },
|
||||
props: {
|
||||
node: {
|
||||
type: Object as PropType<INodeUi>,
|
||||
@@ -392,9 +397,6 @@ export default defineComponent({
|
||||
}
|
||||
return value;
|
||||
},
|
||||
highlightSearchTerm(value: string): string {
|
||||
return sanitizeHtml(highlightText(this.getValueToRender(value), this.search));
|
||||
},
|
||||
onDragStart() {
|
||||
this.draggedColumn = true;
|
||||
this.ndvStore.resetMappingTelemetry();
|
||||
|
||||
52
packages/editor-ui/src/components/TextWithHighlights.vue
Normal file
52
packages/editor-ui/src/components/TextWithHighlights.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import type { GenericValue } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: [Object, String, Number] as PropType<GenericValue>,
|
||||
},
|
||||
search: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
|
||||
const splitTextBySearch = (
|
||||
text = '',
|
||||
search = '',
|
||||
): Array<{ tag: 'span' | 'mark'; content: string }> => {
|
||||
if (!search) {
|
||||
return [
|
||||
{
|
||||
tag: 'span',
|
||||
content: text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
const pattern = new RegExp(`(${escapeRegExp(search)})`, 'i');
|
||||
const splitText = text.split(pattern);
|
||||
|
||||
return splitText.map((t) => ({ tag: pattern.test(t) ? 'mark' : 'span', content: t }));
|
||||
};
|
||||
|
||||
const parts = computed(() => {
|
||||
return props.search && typeof props.content === 'string'
|
||||
? splitTextBySearch(props.content, props.search)
|
||||
: [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="parts.length && typeof props.content === 'string'">
|
||||
<template v-for="(part, index) in parts">
|
||||
<mark v-if="part.tag === 'mark' && part.content" :key="`mark-${index}`">{{
|
||||
part.content
|
||||
}}</mark>
|
||||
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else>{{ props.content }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import TextWithHighlights from '@/components/TextWithHighlights.vue';
|
||||
|
||||
describe('TextWithHighlights', () => {
|
||||
it('highlights the search text in the content', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content',
|
||||
search: 'Test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('<mark>Test</mark>');
|
||||
expect(wrapper.html()).toContain('<span> content</span>');
|
||||
});
|
||||
|
||||
it('renders correctly when search is not set', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>Test content</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('renders correctly numbers when search is not set', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>1</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('renders correctly objects when search is not set', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: { hello: 'world' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>{\n "hello": "world"\n}</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('renders correctly objects ignoring search', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: { hello: 'world' },
|
||||
search: 'yo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>{\n "hello": "world"\n}</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('highlights the search text in middle of the content', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content hello world',
|
||||
search: 'con',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual(
|
||||
'<span><span>Test </span><mark>con</mark><span>tent hello world</span></span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles special regex characters in search correctly', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content (hello) world',
|
||||
search: '(hello)',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual(
|
||||
'<span><span>Test content </span><mark>(hello)</mark><span> world</span></span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('searches for special regex characters correctly', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
content: 'Test content ()^${}[] world',
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
search: '()^${}[]',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual(
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
'<span><span>Test content </span><mark>()^${}[]</mark><span> world</span></span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { highlightText } from '@/utils/htmlUtils';
|
||||
|
||||
describe('highlightText', () => {
|
||||
it('should return original text if search parameter is an empty string', () => {
|
||||
const text = 'some text';
|
||||
const result = highlightText(text);
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('should return original text if it is an empty string', () => {
|
||||
const text = '';
|
||||
const result = highlightText(text, 'search');
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('should escape special characters in the search string', () => {
|
||||
const text = 'some text [example]';
|
||||
const result = highlightText(text, '[example]');
|
||||
expect(result).toBe('some text <mark class="highlight">[example]</mark>');
|
||||
});
|
||||
|
||||
it('should escape other special characters in the search string', () => {
|
||||
const text = 'phone number: +123-456-7890';
|
||||
const result = highlightText(text, '+123-456-7890');
|
||||
expect(result).toBe('phone number: <mark class="highlight">+123-456-7890</mark>');
|
||||
});
|
||||
|
||||
it('should highlight occurrences of the search string in text', () => {
|
||||
const text = 'example text example';
|
||||
const result = highlightText(text, 'example');
|
||||
expect(result).toBe(
|
||||
'<mark class="highlight">example</mark> text <mark class="highlight">example</mark>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original text if the search string is not found', () => {
|
||||
const text = 'some text';
|
||||
const result = highlightText(text, 'notfound');
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -63,9 +63,3 @@ export const getBannerRowHeight = async (): Promise<number> => {
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
export const highlightText = (text: string, search = ''): string => {
|
||||
const pattern = search.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
|
||||
const regex = new RegExp(`(${pattern})`, 'gi');
|
||||
return search ? text?.replace(regex, '<mark class="highlight">$1</mark>') : text;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user