From 826be3cb10149c70a03592b3247759d0294e459e Mon Sep 17 00:00:00 2001 From: caipei Date: Thu, 27 Apr 2023 11:05:36 +0800 Subject: [PATCH 1/3] custom mask text selectors & mask functions --- packages/rrweb-snapshot/src/index.ts | 2 + packages/rrweb-snapshot/src/snapshot.ts | 53 +++++++++++++++++-- .../__snapshots__/integration.test.ts.snap | 6 +++ packages/rrweb-snapshot/test/snapshot.test.ts | 2 + packages/rrweb/src/record/index.ts | 4 ++ packages/rrweb/src/record/mutation.ts | 21 ++++---- packages/rrweb/src/types.ts | 9 ++++ packages/rrweb/test/integration.test.ts | 18 +++++++ packages/rrweb/test/utils.ts | 1 + packages/types/src/index.ts | 5 ++ 10 files changed, 107 insertions(+), 14 deletions(-) diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index ef9d1b19fa..71c251646f 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -8,6 +8,7 @@ import snapshot, { classMatchesRegex, IGNORED_NODE, genId, + getMatchedCustomMaskTextFn } from './snapshot'; import rebuild, { buildNodeWithSN, @@ -32,4 +33,5 @@ export { classMatchesRegex, IGNORED_NODE, genId, + getMatchedCustomMaskTextFn }; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 0eeb289b4f..f3caef330f 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -23,6 +23,10 @@ import { getInputType, } from './utils'; +import { + maskTextRule +} from '@rrweb/types'; + let _id = 1; const tagNameRegex = new RegExp('[^a-z0-9-_:]'); @@ -317,6 +321,7 @@ export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, + customMaskTextRule: maskTextRule[] ): boolean { try { const el: HTMLElement | null = @@ -336,12 +341,37 @@ export function needMaskingText( if (el.matches(maskTextSelector)) return true; if (el.closest(maskTextSelector)) return true; } + if(customMaskTextRule) { + for(let rule of customMaskTextRule){ + if (el.matches(rule.cssSelector)) return true; + if (el.closest(rule.cssSelector)) return true; + } + } } catch (e) { // } return false; } +export function getMatchedCustomMaskTextFn(node: Node, customMaskTextRule: maskTextRule[]): ((originText: string) => string) | null{ + try { + const el: HTMLElement | null = + node.nodeType === node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; + if (el === null) return null; + + for(let rule of customMaskTextRule){ + if (el.matches(rule.cssSelector)) return rule.maskFn; + if (el.closest(rule.cssSelector)) return rule.maskFn; + } + } catch (error) { + return null + } + + return null; +} + // https://stackoverflow.com/a/36155560 function onceIframeLoaded( iframeEl: HTMLIFrameElement, @@ -435,6 +465,7 @@ function serializeNode( blockSelector: string | null; maskTextClass: string | RegExp; maskTextSelector: string | null; + customMaskTextRule: maskTextRule[]; inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; @@ -456,6 +487,7 @@ function serializeNode( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, inlineStylesheet, maskInputOptions = {}, maskTextFn, @@ -509,6 +541,7 @@ function serializeNode( return serializeTextNode(n as Text, { maskTextClass, maskTextSelector, + customMaskTextRule, maskTextFn, rootId, }); @@ -540,11 +573,12 @@ function serializeTextNode( options: { maskTextClass: string | RegExp; maskTextSelector: string | null; + customMaskTextRule: maskTextRule[]; maskTextFn: MaskTextFn | undefined; rootId: number | undefined; }, ): serializedNode { - const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options; + const { maskTextClass, maskTextSelector, customMaskTextRule, maskTextFn, rootId } = options; // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName; @@ -579,10 +613,12 @@ function serializeTextNode( !isStyle && !isScript && textContent && - needMaskingText(n, maskTextClass, maskTextSelector) + needMaskingText(n, maskTextClass, maskTextSelector, customMaskTextRule) ) { - textContent = maskTextFn - ? maskTextFn(textContent) + const customMaskFn = getMatchedCustomMaskTextFn(n, customMaskTextRule); + const maskFn = customMaskFn ?? maskTextFn; + textContent = maskFn + ? maskFn(textContent) : textContent.replace(/[\S]/g, '*'); } @@ -930,6 +966,7 @@ export function serializeNodeWithId( blockSelector: string | null; maskTextClass: string | RegExp; maskTextSelector: string | null; + customMaskTextRule: maskTextRule[]; skipChild: boolean; inlineStylesheet: boolean; newlyAddedElement?: boolean; @@ -962,6 +999,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, @@ -987,6 +1025,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, inlineStylesheet, maskInputOptions, maskTextFn, @@ -1059,6 +1098,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild, inlineStylesheet, maskInputOptions, @@ -1119,6 +1159,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1166,6 +1207,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1207,6 +1249,7 @@ function snapshot( blockSelector?: string | null; maskTextClass?: string | RegExp; maskTextSelector?: string | null; + customMaskTextRule?: maskTextRule[]; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; @@ -1236,6 +1279,7 @@ function snapshot( blockSelector = null, maskTextClass = 'rr-mask', maskTextSelector = null, + customMaskTextRule = [], inlineStylesheet = true, inlineImages = false, recordCanvas = false, @@ -1302,6 +1346,7 @@ function snapshot( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild: false, inlineStylesheet, maskInputOptions, diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 529a51eeff..d6d424d92e 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -363,6 +363,12 @@ exports[`integration tests [html file]: picture-in-frame.html 1`] = ` " `; +exports[`integration tests [html file]: picture-with-inline-onload.html 1`] = ` +" + \\"This + " +`; + exports[`integration tests [html file]: preload.html 1`] = ` " diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 75d635e0c0..318361d5cb 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -145,6 +145,7 @@ describe('style elements', () => { blockSelector: null, maskTextClass: 'maskmask', maskTextSelector: null, + customMaskTextRule: [], skipChild: false, inlineStylesheet: true, maskTextFn: undefined, @@ -190,6 +191,7 @@ describe('scrollTop/scrollLeft', () => { blockSelector: null, maskTextClass: 'maskmask', maskTextSelector: null, + customMaskTextRule: [], skipChild: false, inlineStylesheet: true, maskTextFn: undefined, diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index c0e69a025f..0e6d79dfff 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -65,6 +65,7 @@ function record( ignoreClass = 'rr-ignore', maskTextClass = 'rr-mask', maskTextSelector = null, + customMaskTextRule = [], inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, @@ -324,6 +325,7 @@ function record( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, inlineStylesheet, maskInputOptions, dataURLOptions, @@ -367,6 +369,7 @@ function record( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, inlineStylesheet, maskAllInputs: maskInputOptions, maskTextFn, @@ -523,6 +526,7 @@ function record( ignoreClass, maskTextClass, maskTextSelector, + customMaskTextRule, maskInputOptions, inlineStylesheet, sampling, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 2e5132c9ae..5bb10618a8 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -5,6 +5,7 @@ import { ignoreAttribute, isShadowRoot, needMaskingText, + getMatchedCustomMaskTextFn, maskInputValue, Mirror, isNativeShadowDom, @@ -164,6 +165,7 @@ export default class MutationBuffer { private blockSelector: observerParam['blockSelector']; private maskTextClass: observerParam['maskTextClass']; private maskTextSelector: observerParam['maskTextSelector']; + private customMaskTextRule: observerParam['customMaskTextRule']; private inlineStylesheet: observerParam['inlineStylesheet']; private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; @@ -189,6 +191,7 @@ export default class MutationBuffer { 'blockSelector', 'maskTextClass', 'maskTextSelector', + 'customMaskTextRule', 'inlineStylesheet', 'maskInputOptions', 'maskTextFn', @@ -290,6 +293,7 @@ export default class MutationBuffer { blockSelector: this.blockSelector, maskTextClass: this.maskTextClass, maskTextSelector: this.maskTextSelector, + customMaskTextRule: this.customMaskTextRule, skipChild: true, newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, @@ -469,17 +473,14 @@ export default class MutationBuffer { !isBlocked(m.target, this.blockClass, this.blockSelector, false) && value !== m.oldValue ) { + let textValue = value; + if(needMaskingText(m.target, this.maskTextClass, this.maskTextSelector, this.customMaskTextRule)){ + const customMaskFn = getMatchedCustomMaskTextFn(m.target, this.customMaskTextRule); + const maskFn = customMaskFn ?? this.maskTextFn; + textValue = maskFn ? maskFn(value) : value.replace(/[\S]/g, '*'); + } this.texts.push({ - value: - needMaskingText( - m.target, - this.maskTextClass, - this.maskTextSelector, - ) && value - ? this.maskTextFn - ? this.maskTextFn(value) - : value.replace(/[\S]/g, '*') - : value, + value: textValue, node: m.target, }); } diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index dd9a516709..0239b4e787 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -25,6 +25,7 @@ import type { KeepIframeSrcFn, listenerHandler, maskTextClass, + maskTextRule, mediaInteractionCallback, mouseInteractionCallBack, mousemoveCallBack, @@ -48,6 +49,12 @@ export type recordOptions = { ignoreClass?: string; maskTextClass?: maskTextClass; maskTextSelector?: string; + /** + * only the first matched rule will be applied + * customMaskTextRule has higher priority than priomaskTextSelector & maskTextFn + * once one of customMaskTextRule match, priomaskTextSelector & maskTextFn won't be applied + */ + customMaskTextRule?: maskTextRule[], maskAllInputs?: boolean; maskInputOptions?: MaskInputOptions; maskInputFn?: MaskInputFn; @@ -86,6 +93,7 @@ export type observerParam = { ignoreClass: string; maskTextClass: maskTextClass; maskTextSelector: string | null; + customMaskTextRule: maskTextRule[], maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; @@ -128,6 +136,7 @@ export type MutationBufferParam = Pick< | 'blockSelector' | 'maskTextClass' | 'maskTextSelector' + | 'customMaskTextRule' | 'inlineStylesheet' | 'maskInputOptions' | 'maskTextFn' diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 39e32dbcd6..50f8e0793f 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1118,6 +1118,24 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('should mask texts with custom selector & function', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'mask-text.html', { + customMaskTextRule:[{ + cssSelector: '[data-masking="true"]', + maskFn: (t: string) => t.replace(/[a-z]/g, '*') + }] + }), + ); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + it('can mask character data mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index dd5a8cf7cc..81581c09c4 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -595,6 +595,7 @@ export function generateRecordSnippet(options: recordOptions) { window.snapshots.push(event); }, maskTextSelector: ${JSON.stringify(options.maskTextSelector)}, + customMaskTextRule: ${JSON.stringify(options.customMaskTextRule)} maskAllInputs: ${options.maskAllInputs}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, userTriggeredOnInput: ${options.userTriggeredOnInput}, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6601457291..2bb57f2822 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -180,6 +180,11 @@ export type blockClass = string | RegExp; export type maskTextClass = string | RegExp; +export type maskTextRule = { + cssSelector: string, + maskFn: (originText: string) => string +} + export type SamplingStrategy = Partial<{ /** * false means not to record mouse/touch move events From 7acfba51b71a7a37ed90c6e4df9bd6759d3f3597 Mon Sep 17 00:00:00 2001 From: caipei Date: Thu, 27 Apr 2023 11:13:25 +0800 Subject: [PATCH 2/3] correct misspell --- packages/rrweb/src/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 0239b4e787..2f8636f8fa 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -51,8 +51,8 @@ export type recordOptions = { maskTextSelector?: string; /** * only the first matched rule will be applied - * customMaskTextRule has higher priority than priomaskTextSelector & maskTextFn - * once one of customMaskTextRule match, priomaskTextSelector & maskTextFn won't be applied + * customMaskTextRule has higher priority than maskTextSelector & maskTextFn + * once one of customMaskTextRule match, maskTextSelector & maskTextFn won't be applied */ customMaskTextRule?: maskTextRule[], maskAllInputs?: boolean; From 6eaa531cffdacf2bf35c0b86ee52e61ea92c204f Mon Sep 17 00:00:00 2001 From: caipei Date: Thu, 27 Apr 2023 14:28:16 +0800 Subject: [PATCH 3/3] prettier format --- packages/rrweb-snapshot/src/index.ts | 4 ++-- packages/rrweb-snapshot/src/snapshot.ts | 27 ++++++++++++++++--------- packages/rrweb/src/record/mutation.ts | 14 +++++++++++-- packages/rrweb/src/types.ts | 4 ++-- packages/rrweb/test/integration.test.ts | 10 +++++---- packages/types/src/index.ts | 6 +++--- 6 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index 71c251646f..6633153ec6 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -8,7 +8,7 @@ import snapshot, { classMatchesRegex, IGNORED_NODE, genId, - getMatchedCustomMaskTextFn + getMatchedCustomMaskTextFn, } from './snapshot'; import rebuild, { buildNodeWithSN, @@ -33,5 +33,5 @@ export { classMatchesRegex, IGNORED_NODE, genId, - getMatchedCustomMaskTextFn + getMatchedCustomMaskTextFn, }; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index f3caef330f..3929b2047d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -23,9 +23,7 @@ import { getInputType, } from './utils'; -import { - maskTextRule -} from '@rrweb/types'; +import { maskTextRule } from '@rrweb/types'; let _id = 1; const tagNameRegex = new RegExp('[^a-z0-9-_:]'); @@ -321,7 +319,7 @@ export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, - customMaskTextRule: maskTextRule[] + customMaskTextRule: maskTextRule[], ): boolean { try { const el: HTMLElement | null = @@ -341,8 +339,8 @@ export function needMaskingText( if (el.matches(maskTextSelector)) return true; if (el.closest(maskTextSelector)) return true; } - if(customMaskTextRule) { - for(let rule of customMaskTextRule){ + if (customMaskTextRule) { + for (let rule of customMaskTextRule) { if (el.matches(rule.cssSelector)) return true; if (el.closest(rule.cssSelector)) return true; } @@ -353,7 +351,10 @@ export function needMaskingText( return false; } -export function getMatchedCustomMaskTextFn(node: Node, customMaskTextRule: maskTextRule[]): ((originText: string) => string) | null{ +export function getMatchedCustomMaskTextFn( + node: Node, + customMaskTextRule: maskTextRule[], +): ((originText: string) => string) | null { try { const el: HTMLElement | null = node.nodeType === node.ELEMENT_NODE @@ -361,12 +362,12 @@ export function getMatchedCustomMaskTextFn(node: Node, customMaskTextRule: maskT : node.parentElement; if (el === null) return null; - for(let rule of customMaskTextRule){ + for (let rule of customMaskTextRule) { if (el.matches(rule.cssSelector)) return rule.maskFn; if (el.closest(rule.cssSelector)) return rule.maskFn; } } catch (error) { - return null + return null; } return null; @@ -578,7 +579,13 @@ function serializeTextNode( rootId: number | undefined; }, ): serializedNode { - const { maskTextClass, maskTextSelector, customMaskTextRule, maskTextFn, rootId } = options; + const { + maskTextClass, + maskTextSelector, + customMaskTextRule, + maskTextFn, + rootId, + } = options; // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 5bb10618a8..32e28e0478 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -474,8 +474,18 @@ export default class MutationBuffer { value !== m.oldValue ) { let textValue = value; - if(needMaskingText(m.target, this.maskTextClass, this.maskTextSelector, this.customMaskTextRule)){ - const customMaskFn = getMatchedCustomMaskTextFn(m.target, this.customMaskTextRule); + if ( + needMaskingText( + m.target, + this.maskTextClass, + this.maskTextSelector, + this.customMaskTextRule, + ) + ) { + const customMaskFn = getMatchedCustomMaskTextFn( + m.target, + this.customMaskTextRule, + ); const maskFn = customMaskFn ?? this.maskTextFn; textValue = maskFn ? maskFn(value) : value.replace(/[\S]/g, '*'); } diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 2f8636f8fa..209c2f3c87 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -54,7 +54,7 @@ export type recordOptions = { * customMaskTextRule has higher priority than maskTextSelector & maskTextFn * once one of customMaskTextRule match, maskTextSelector & maskTextFn won't be applied */ - customMaskTextRule?: maskTextRule[], + customMaskTextRule?: maskTextRule[]; maskAllInputs?: boolean; maskInputOptions?: MaskInputOptions; maskInputFn?: MaskInputFn; @@ -93,7 +93,7 @@ export type observerParam = { ignoreClass: string; maskTextClass: maskTextClass; maskTextSelector: string | null; - customMaskTextRule: maskTextRule[], + customMaskTextRule: maskTextRule[]; maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 50f8e0793f..b02e40a3b0 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1123,10 +1123,12 @@ describe('record integration tests', function (this: ISuite) { await page.goto('about:blank'); await page.setContent( getHtml.call(this, 'mask-text.html', { - customMaskTextRule:[{ - cssSelector: '[data-masking="true"]', - maskFn: (t: string) => t.replace(/[a-z]/g, '*') - }] + customMaskTextRule: [ + { + cssSelector: '[data-masking="true"]', + maskFn: (t: string) => t.replace(/[a-z]/g, '*'), + }, + ], }), ); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2bb57f2822..83b3172d7e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -181,9 +181,9 @@ export type blockClass = string | RegExp; export type maskTextClass = string | RegExp; export type maskTextRule = { - cssSelector: string, - maskFn: (originText: string) => string -} + cssSelector: string; + maskFn: (originText: string) => string; +}; export type SamplingStrategy = Partial<{ /**