diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index ef9d1b19fa..6633153ec6 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..3929b2047d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -23,6 +23,8 @@ import { getInputType, } from './utils'; +import { maskTextRule } from '@rrweb/types'; + let _id = 1; const tagNameRegex = new RegExp('[^a-z0-9-_:]'); @@ -317,6 +319,7 @@ export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, + customMaskTextRule: maskTextRule[], ): boolean { try { const el: HTMLElement | null = @@ -336,12 +339,40 @@ 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 +466,7 @@ function serializeNode( blockSelector: string | null; maskTextClass: string | RegExp; maskTextSelector: string | null; + customMaskTextRule: maskTextRule[]; inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; @@ -456,6 +488,7 @@ function serializeNode( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, inlineStylesheet, maskInputOptions = {}, maskTextFn, @@ -509,6 +542,7 @@ function serializeNode( return serializeTextNode(n as Text, { maskTextClass, maskTextSelector, + customMaskTextRule, maskTextFn, rootId, }); @@ -540,11 +574,18 @@ 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 +620,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 +973,7 @@ export function serializeNodeWithId( blockSelector: string | null; maskTextClass: string | RegExp; maskTextSelector: string | null; + customMaskTextRule: maskTextRule[]; skipChild: boolean; inlineStylesheet: boolean; newlyAddedElement?: boolean; @@ -962,6 +1006,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, @@ -987,6 +1032,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, inlineStylesheet, maskInputOptions, maskTextFn, @@ -1059,6 +1105,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild, inlineStylesheet, maskInputOptions, @@ -1119,6 +1166,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1166,6 +1214,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + customMaskTextRule, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1207,6 +1256,7 @@ function snapshot( blockSelector?: string | null; maskTextClass?: string | RegExp; maskTextSelector?: string | null; + customMaskTextRule?: maskTextRule[]; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; @@ -1236,6 +1286,7 @@ function snapshot( blockSelector = null, maskTextClass = 'rr-mask', maskTextSelector = null, + customMaskTextRule = [], inlineStylesheet = true, inlineImages = false, recordCanvas = false, @@ -1302,6 +1353,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..32e28e0478 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,24 @@ 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..209c2f3c87 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 maskTextSelector & maskTextFn + * once one of customMaskTextRule match, maskTextSelector & 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..b02e40a3b0 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1118,6 +1118,26 @@ 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..83b3172d7e 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