frat(tracker): 3.4.16: observer for shRoot, topObserver & sanitizer refactor; 3.4.17:autoResetOnWindowOpen; userID onStart option; restart fix
This commit is contained in:
parent
7196b81979
commit
b3fba224cc
16 changed files with 853 additions and 622 deletions
2
tracker/tracker/package-lock.json
generated
2
tracker/tracker/package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.12",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "3.4.12",
|
||||
"version": "3.4.17",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
72
tracker/tracker/src/main/app/context.ts
Normal file
72
tracker/tracker/src/main/app/context.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// TODO: global type
|
||||
export interface Window extends globalThis.Window {
|
||||
HTMLInputElement: typeof HTMLInputElement,
|
||||
HTMLLinkElement: typeof HTMLLinkElement,
|
||||
HTMLStyleElement: typeof HTMLStyleElement,
|
||||
SVGStyleElement: typeof SVGStyleElement,
|
||||
HTMLIFrameElement: typeof HTMLIFrameElement,
|
||||
Text: typeof Text,
|
||||
Element: typeof Element,
|
||||
ShadowRoot: typeof ShadowRoot,
|
||||
//parent: Window,
|
||||
}
|
||||
|
||||
type WindowConstructor =
|
||||
Document |
|
||||
Element |
|
||||
Text |
|
||||
ShadowRoot |
|
||||
HTMLInputElement |
|
||||
HTMLLinkElement |
|
||||
HTMLStyleElement |
|
||||
HTMLIFrameElement
|
||||
|
||||
// type ConstructorNames =
|
||||
// 'Element' |
|
||||
// 'Text' |
|
||||
// 'HTMLInputElement' |
|
||||
// 'HTMLLinkElement' |
|
||||
// 'HTMLStyleElement' |
|
||||
// 'HTMLIFrameElement'
|
||||
type Constructor<T> = { new (...args: any[]): T , name: string };
|
||||
|
||||
// TODO: we need a type expert here so we won't have to ignore the lines
|
||||
// TODO: use it everywhere (static function; export from which file? <-- global Window typing required)
|
||||
export function isInstance<T extends WindowConstructor>(node: Node, constr: Constructor<T>): node is T {
|
||||
const doc = node.ownerDocument;
|
||||
if (!doc) { // null if Document
|
||||
return constr.name === 'Document';
|
||||
}
|
||||
let context: Window =
|
||||
// @ts-ignore (for EI, Safary)
|
||||
doc.parentWindow ||
|
||||
doc.defaultView; // TODO: smart global typing for Window object
|
||||
while(context.parent && context.parent !== context) {
|
||||
// @ts-ignore
|
||||
if (node instanceof context[constr.name]) {
|
||||
return true
|
||||
}
|
||||
// @ts-ignore
|
||||
context = context.parent
|
||||
}
|
||||
// @ts-ignore
|
||||
return node instanceof context[constr.name]
|
||||
}
|
||||
|
||||
export function inDocument(node: Node): boolean {
|
||||
const doc = node.ownerDocument
|
||||
if (!doc) { return false }
|
||||
if (doc.contains(node)) { return true }
|
||||
let context: Window =
|
||||
// @ts-ignore (for EI, Safary)
|
||||
doc.parentWindow ||
|
||||
doc.defaultView;
|
||||
while(context.parent && context.parent !== context) {
|
||||
if (context.document.contains(node)) {
|
||||
return true
|
||||
}
|
||||
// @ts-ignore
|
||||
context = context.parent
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2,12 +2,15 @@ import { timestamp, log, warn } from "../utils.js";
|
|||
import { Timestamp, PageClose } from "../../messages/index.js";
|
||||
import Message from "../../messages/message.js";
|
||||
import Nodes from "./nodes.js";
|
||||
import Observer from "./observer.js";
|
||||
import Observer from "./observer/top_observer.js";
|
||||
import Sanitizer from "./sanitizer.js";
|
||||
import Ticker from "./ticker.js";
|
||||
|
||||
import { deviceMemory, jsHeapSizeLimit } from "../modules/performance.js";
|
||||
|
||||
import type { Options as ObserverOptions } from "./observer.js";
|
||||
import type { Options as ObserverOptions } from "./observer/top_observer.js";
|
||||
import type { Options as SanitizerOptions } from "./sanitizer.js";
|
||||
|
||||
|
||||
import type { Options as WebworkerOptions, WorkerMessageData } from "../../messages/webworker.js";
|
||||
|
||||
|
|
@ -17,11 +20,17 @@ export interface OnStartInfo {
|
|||
userUUID: string,
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
export interface StartOptions {
|
||||
userID?: string,
|
||||
forceNew: boolean,
|
||||
}
|
||||
|
||||
type AppOptions = {
|
||||
revID: string;
|
||||
node_id: string;
|
||||
session_token_key: string;
|
||||
session_pageno_key: string;
|
||||
session_reset_key: string;
|
||||
local_uuid_key: string;
|
||||
ingestPoint: string;
|
||||
resourceBaseHref: string | null, // resourceHref?
|
||||
|
|
@ -30,7 +39,9 @@ export type Options = {
|
|||
__debug_report_edp: string | null;
|
||||
__debug_log: boolean;
|
||||
onStart?: (info: OnStartInfo) => void;
|
||||
} & ObserverOptions & WebworkerOptions;
|
||||
} & WebworkerOptions;
|
||||
|
||||
export type Options = AppOptions & ObserverOptions & SanitizerOptions
|
||||
|
||||
type Callback = () => void;
|
||||
type CommitCallback = (messages: Array<Message>) => void;
|
||||
|
|
@ -43,21 +54,23 @@ export default class App {
|
|||
readonly nodes: Nodes;
|
||||
readonly ticker: Ticker;
|
||||
readonly projectKey: string;
|
||||
readonly sanitizer: Sanitizer;
|
||||
private readonly messages: Array<Message> = [];
|
||||
/*private*/ readonly observer: Observer; // temp, for fast security fix. TODO: separate security/obscure module with nodeCallback that incapsulates `textMasked` functionality from Observer
|
||||
private readonly observer: Observer;
|
||||
private readonly startCallbacks: Array<Callback> = [];
|
||||
private readonly stopCallbacks: Array<Callback> = [];
|
||||
private readonly commitCallbacks: Array<CommitCallback> = [];
|
||||
private readonly options: Options;
|
||||
private readonly options: AppOptions;
|
||||
private readonly revID: string;
|
||||
private _sessionID: string | null = null;
|
||||
private _userID: string | undefined;
|
||||
private isActive = false;
|
||||
private version = 'TRACKER_VERSION';
|
||||
private readonly worker?: Worker;
|
||||
constructor(
|
||||
projectKey: string,
|
||||
sessionToken: string | null | undefined,
|
||||
opts: Partial<Options>,
|
||||
options: Partial<Options>,
|
||||
) {
|
||||
this.projectKey = projectKey;
|
||||
this.options = Object.assign(
|
||||
|
|
@ -66,24 +79,23 @@ export default class App {
|
|||
node_id: '__openreplay_id',
|
||||
session_token_key: '__openreplay_token',
|
||||
session_pageno_key: '__openreplay_pageno',
|
||||
session_reset_key: '__openreplay_reset',
|
||||
local_uuid_key: '__openreplay_uuid',
|
||||
ingestPoint: DEFAULT_INGEST_POINT,
|
||||
resourceBaseHref: null,
|
||||
__is_snippet: false,
|
||||
__debug_report_edp: null,
|
||||
__debug_log: false,
|
||||
obscureTextEmails: true,
|
||||
obscureTextNumbers: false,
|
||||
captureIFrames: false,
|
||||
},
|
||||
opts,
|
||||
options,
|
||||
);
|
||||
if (sessionToken != null) {
|
||||
sessionStorage.setItem(this.options.session_token_key, sessionToken);
|
||||
}
|
||||
this.revID = this.options.revID;
|
||||
this.sanitizer = new Sanitizer(this, options);
|
||||
this.nodes = new Nodes(this.options.node_id);
|
||||
this.observer = new Observer(this, this.options);
|
||||
this.observer = new Observer(this, options);
|
||||
this.ticker = new Ticker(this);
|
||||
this.ticker.attach(() => this.commit());
|
||||
try {
|
||||
|
|
@ -102,7 +114,10 @@ export default class App {
|
|||
this.stop();
|
||||
} else if (data === "restart") {
|
||||
this.stop();
|
||||
this.start(true);
|
||||
this.start({
|
||||
forceNew: true,
|
||||
userID: this._userID,
|
||||
});
|
||||
}
|
||||
};
|
||||
const alertWorker = () => {
|
||||
|
|
@ -244,104 +259,132 @@ export default class App {
|
|||
active(): boolean {
|
||||
return this.isActive;
|
||||
}
|
||||
private _start(reset: boolean): Promise<OnStartInfo> {
|
||||
if (!this.isActive) {
|
||||
if (!this.worker) {
|
||||
return Promise.reject("No worker found: perhaps, CSP is not set.");
|
||||
}
|
||||
this.isActive = true;
|
||||
|
||||
let pageNo: number = 0;
|
||||
const pageNoStr = sessionStorage.getItem(this.options.session_pageno_key);
|
||||
if (pageNoStr != null) {
|
||||
pageNo = parseInt(pageNoStr);
|
||||
pageNo++;
|
||||
}
|
||||
sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString());
|
||||
const startTimestamp = timestamp();
|
||||
|
||||
const messageData: WorkerMessageData = {
|
||||
ingestPoint: this.options.ingestPoint,
|
||||
pageNo,
|
||||
startTimestamp,
|
||||
connAttemptCount: this.options.connAttemptCount,
|
||||
connAttemptGap: this.options.connAttemptGap,
|
||||
}
|
||||
this.worker.postMessage(messageData); // brings delay of 10th ms?
|
||||
return window.fetch(this.options.ingestPoint + '/v1/web/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: sessionStorage.getItem(this.options.session_token_key),
|
||||
userUUID: localStorage.getItem(this.options.local_uuid_key),
|
||||
projectKey: this.projectKey,
|
||||
revID: this.revID,
|
||||
timestamp: startTimestamp,
|
||||
trackerVersion: this.version,
|
||||
isSnippet: this.options.__is_snippet,
|
||||
deviceMemory,
|
||||
jsHeapSizeLimit,
|
||||
reset,
|
||||
}),
|
||||
})
|
||||
.then(r => {
|
||||
if (r.status === 200) {
|
||||
return r.json()
|
||||
} else { // TODO: handle canceling && 403
|
||||
return r.text().then(text => {
|
||||
throw new Error(`Server error: ${r.status}. ${text}`);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
const { token, userUUID, sessionID, beaconSizeLimit } = r;
|
||||
if (typeof token !== 'string' ||
|
||||
typeof userUUID !== 'string' ||
|
||||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')) {
|
||||
throw new Error(`Incorrect server response: ${ JSON.stringify(r) }`);
|
||||
}
|
||||
sessionStorage.setItem(this.options.session_token_key, token);
|
||||
localStorage.setItem(this.options.local_uuid_key, userUUID);
|
||||
if (typeof sessionID === 'string') {
|
||||
this._sessionID = sessionID;
|
||||
}
|
||||
if (!this.worker) {
|
||||
throw new Error("no worker found after start request (this might not happen)");
|
||||
}
|
||||
this.worker.postMessage({ token, beaconSizeLimit });
|
||||
this.startCallbacks.forEach((cb) => cb());
|
||||
this.observer.observe();
|
||||
this.ticker.start();
|
||||
|
||||
log("OpenReplay tracking started.");
|
||||
const onStartInfo = { sessionToken: token, userUUID, sessionID };
|
||||
if (typeof this.options.onStart === 'function') {
|
||||
this.options.onStart(onStartInfo);
|
||||
}
|
||||
return onStartInfo;
|
||||
})
|
||||
.catch(e => {
|
||||
sessionStorage.removeItem(this.options.session_token_key)
|
||||
this.stop()
|
||||
warn("OpenReplay was unable to start. ", e)
|
||||
this._debug("session_start", e);
|
||||
throw e
|
||||
})
|
||||
resetNextPageSession(flag: boolean) {
|
||||
if (flag) {
|
||||
sessionStorage.setItem(this.options.session_reset_key, 't');
|
||||
} else {
|
||||
sessionStorage.removeItem(this.options.session_reset_key);
|
||||
}
|
||||
return Promise.reject("Player is already active");
|
||||
}
|
||||
private _start(startOpts: StartOptions): Promise<OnStartInfo> {
|
||||
if (!this.worker) {
|
||||
return Promise.reject("No worker found: perhaps, CSP is not set.");
|
||||
}
|
||||
if (this.isActive) {
|
||||
return Promise.reject("OpenReplay: trying to call `start()` on the instance that has been started already.")
|
||||
}
|
||||
this.isActive = true;
|
||||
|
||||
let pageNo: number = 0;
|
||||
const pageNoStr = sessionStorage.getItem(this.options.session_pageno_key);
|
||||
if (pageNoStr != null) {
|
||||
pageNo = parseInt(pageNoStr);
|
||||
pageNo++;
|
||||
}
|
||||
sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString());
|
||||
const startTimestamp = timestamp();
|
||||
|
||||
const messageData: WorkerMessageData = {
|
||||
ingestPoint: this.options.ingestPoint,
|
||||
pageNo,
|
||||
startTimestamp,
|
||||
connAttemptCount: this.options.connAttemptCount,
|
||||
connAttemptGap: this.options.connAttemptGap,
|
||||
}
|
||||
this.worker.postMessage(messageData); // brings delay of 10th ms?
|
||||
|
||||
|
||||
// let token = sessionStorage.getItem(this.options.session_token_key)
|
||||
// const tokenIsActive = localStorage.getItem("__or_at_" + token)
|
||||
// if (tokenIsActive) {
|
||||
// token = null
|
||||
// }
|
||||
|
||||
const sReset = sessionStorage.getItem(this.options.session_reset_key);
|
||||
sessionStorage.removeItem(this.options.session_reset_key);
|
||||
|
||||
this._userID = startOpts.userID || undefined
|
||||
return window.fetch(this.options.ingestPoint + '/v1/web/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: sessionStorage.getItem(this.options.session_token_key),
|
||||
userUUID: localStorage.getItem(this.options.local_uuid_key),
|
||||
projectKey: this.projectKey,
|
||||
revID: this.revID,
|
||||
timestamp: startTimestamp,
|
||||
trackerVersion: this.version,
|
||||
isSnippet: this.options.__is_snippet,
|
||||
deviceMemory,
|
||||
jsHeapSizeLimit,
|
||||
reset: startOpts.forceNew || sReset !== null,
|
||||
userID: this._userID,
|
||||
}),
|
||||
})
|
||||
.then(r => {
|
||||
if (r.status === 200) {
|
||||
return r.json()
|
||||
} else { // TODO: handle canceling && 403
|
||||
return r.text().then(text => {
|
||||
throw new Error(`Server error: ${r.status}. ${text}`);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
const { token, userUUID, sessionID, beaconSizeLimit } = r;
|
||||
if (typeof token !== 'string' ||
|
||||
typeof userUUID !== 'string' ||
|
||||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')) {
|
||||
throw new Error(`Incorrect server response: ${ JSON.stringify(r) }`);
|
||||
}
|
||||
sessionStorage.setItem(this.options.session_token_key, token);
|
||||
localStorage.setItem(this.options.local_uuid_key, userUUID);
|
||||
// localStorage.setItem("__or_at_" + token, "true")
|
||||
// this.attachEventListener(window, 'beforeunload', ()=>{
|
||||
// localStorage.removeItem("__or_at_" + token)
|
||||
// }, false);
|
||||
// this.attachEventListener(window, 'pagehide', ()=>{
|
||||
// localStorage.removeItem("__or_at_" + token)
|
||||
// }, false);
|
||||
if (typeof sessionID === 'string') {
|
||||
this._sessionID = sessionID;
|
||||
}
|
||||
if (!this.worker) {
|
||||
throw new Error("no worker found after start request (this might not happen)");
|
||||
}
|
||||
this.worker.postMessage({ token, beaconSizeLimit });
|
||||
this.startCallbacks.forEach((cb) => cb());
|
||||
this.observer.observe();
|
||||
this.ticker.start();
|
||||
|
||||
log("OpenReplay tracking started.");
|
||||
const onStartInfo = { sessionToken: token, userUUID, sessionID };
|
||||
if (typeof this.options.onStart === 'function') {
|
||||
this.options.onStart(onStartInfo);
|
||||
}
|
||||
return onStartInfo;
|
||||
})
|
||||
.catch(e => {
|
||||
sessionStorage.removeItem(this.options.session_token_key)
|
||||
this.stop()
|
||||
warn("OpenReplay was unable to start. ", e)
|
||||
this._debug("session_start", e);
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
start(reset: boolean = false): Promise<OnStartInfo> {
|
||||
start(options: StartOptions = { forceNew: false }): Promise<OnStartInfo> {
|
||||
if (!document.hidden) {
|
||||
return this._start(reset);
|
||||
return this._start(options);
|
||||
} else {
|
||||
return new Promise((resolve) => {
|
||||
const onVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
resolve(this._start(reset));
|
||||
resolve(this._start(options));
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
|
@ -354,6 +397,7 @@ export default class App {
|
|||
if (this.worker) {
|
||||
this.worker.postMessage("stop");
|
||||
}
|
||||
this.sanitizer.clear();
|
||||
this.observer.disconnect();
|
||||
this.nodes.clear();
|
||||
this.ticker.stop();
|
||||
|
|
|
|||
|
|
@ -1,484 +0,0 @@
|
|||
import { stars, hasOpenreplayAttribute } from "../utils.js";
|
||||
import {
|
||||
CreateDocument,
|
||||
CreateElementNode,
|
||||
CreateTextNode,
|
||||
SetNodeData,
|
||||
SetCSSDataURLBased,
|
||||
SetNodeAttribute,
|
||||
SetNodeAttributeURLBased,
|
||||
RemoveNodeAttribute,
|
||||
MoveNode,
|
||||
RemoveNode,
|
||||
CreateIFrameDocument,
|
||||
} from "../../messages/index.js";
|
||||
import App from "./index.js";
|
||||
|
||||
interface Window extends WindowProxy {
|
||||
HTMLInputElement: typeof HTMLInputElement,
|
||||
HTMLLinkElement: typeof HTMLLinkElement,
|
||||
HTMLStyleElement: typeof HTMLStyleElement,
|
||||
SVGStyleElement: typeof SVGStyleElement,
|
||||
HTMLIFrameElement: typeof HTMLIFrameElement,
|
||||
Text: typeof Text,
|
||||
Element: typeof Element,
|
||||
//parent: Window,
|
||||
}
|
||||
|
||||
|
||||
type WindowConstructor =
|
||||
Document |
|
||||
Element |
|
||||
Text |
|
||||
HTMLInputElement |
|
||||
HTMLLinkElement |
|
||||
HTMLStyleElement |
|
||||
HTMLIFrameElement
|
||||
|
||||
// type ConstructorNames =
|
||||
// 'Element' |
|
||||
// 'Text' |
|
||||
// 'HTMLInputElement' |
|
||||
// 'HTMLLinkElement' |
|
||||
// 'HTMLStyleElement' |
|
||||
// 'HTMLIFrameElement'
|
||||
type Constructor<T> = { new (...args: any[]): T , name: string };
|
||||
|
||||
|
||||
function isSVGElement(node: Element): node is SVGElement {
|
||||
return node.namespaceURI === 'http://www.w3.org/2000/svg';
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
obscureTextEmails: boolean;
|
||||
obscureTextNumbers: boolean;
|
||||
captureIFrames: boolean;
|
||||
}
|
||||
|
||||
export default class Observer {
|
||||
private readonly observer: MutationObserver;
|
||||
private readonly commited: Array<boolean | undefined>;
|
||||
private readonly recents: Array<boolean | undefined>;
|
||||
private readonly indexes: Array<number>;
|
||||
private readonly attributesList: Array<Set<string> | undefined>;
|
||||
private readonly textSet: Set<number>;
|
||||
private readonly textMasked: Set<number>;
|
||||
constructor(private readonly app: App, private readonly options: Options, private readonly context: Window = window) {
|
||||
this.observer = new MutationObserver(
|
||||
this.app.safe((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const target = mutation.target;
|
||||
const type = mutation.type;
|
||||
|
||||
// Special case
|
||||
// Document 'childList' might happen in case of iframe.
|
||||
// TODO: generalize as much as possible
|
||||
if (this.isInstance(target, Document)
|
||||
&& type === 'childList'
|
||||
//&& new Array(mutation.addedNodes).some(node => this.isInstance(node, HTMLHtmlElement))
|
||||
) {
|
||||
const parentFrame = target.defaultView?.frameElement
|
||||
if (!parentFrame) { continue }
|
||||
this.bindTree(target.documentElement)
|
||||
const frameID = this.app.nodes.getID(parentFrame)
|
||||
const docID = this.app.nodes.getID(target.documentElement)
|
||||
if (frameID === undefined || docID === undefined) { continue }
|
||||
this.app.send(CreateIFrameDocument(frameID, docID));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isIgnored(target) || !context.document.contains(target)) {
|
||||
continue;
|
||||
}
|
||||
if (type === 'childList') {
|
||||
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
||||
this.bindTree(mutation.removedNodes[i]);
|
||||
}
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
this.bindTree(mutation.addedNodes[i]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const id = this.app.nodes.getID(target);
|
||||
if (id === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (id >= this.recents.length) {
|
||||
this.recents[id] = undefined;
|
||||
}
|
||||
if (type === 'attributes') {
|
||||
const name = mutation.attributeName;
|
||||
if (name === null) {
|
||||
continue;
|
||||
}
|
||||
let attr = this.attributesList[id];
|
||||
if (attr === undefined) {
|
||||
this.attributesList[id] = attr = new Set();
|
||||
}
|
||||
attr.add(name);
|
||||
continue;
|
||||
}
|
||||
if (type === 'characterData') {
|
||||
this.textSet.add(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this.commitNodes();
|
||||
}),
|
||||
);
|
||||
this.commited = [];
|
||||
this.recents = [];
|
||||
this.indexes = [0];
|
||||
this.attributesList = [];
|
||||
this.textSet = new Set();
|
||||
this.textMasked = new Set();
|
||||
}
|
||||
private clear(): void {
|
||||
this.commited.length = 0;
|
||||
this.recents.length = 0;
|
||||
this.indexes.length = 1;
|
||||
this.attributesList.length = 0;
|
||||
this.textSet.clear();
|
||||
this.textMasked.clear();
|
||||
}
|
||||
|
||||
// TODO: we need a type expert here so we won't have to ignore the lines
|
||||
private isInstance<T extends WindowConstructor>(node: Node, constr: Constructor<T>): node is T {
|
||||
let context = this.context;
|
||||
while(context.parent && context.parent !== context) {
|
||||
// @ts-ignore
|
||||
if (node instanceof context[constr.name]) {
|
||||
return true
|
||||
}
|
||||
// @ts-ignore
|
||||
context = context.parent
|
||||
}
|
||||
// @ts-ignore
|
||||
return node instanceof context[constr.name]
|
||||
}
|
||||
|
||||
private isIgnored(node: Node): boolean {
|
||||
if (this.isInstance(node, Text)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.isInstance(node, Element)) {
|
||||
return true;
|
||||
}
|
||||
const tag = node.tagName.toUpperCase();
|
||||
if (tag === 'LINK') {
|
||||
const rel = node.getAttribute('rel');
|
||||
const as = node.getAttribute('as');
|
||||
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
|
||||
}
|
||||
return (
|
||||
tag === 'SCRIPT' ||
|
||||
tag === 'NOSCRIPT' ||
|
||||
tag === 'META' ||
|
||||
tag === 'TITLE' ||
|
||||
tag === 'BASE'
|
||||
);
|
||||
}
|
||||
|
||||
private sendNodeAttribute(
|
||||
id: number,
|
||||
node: Element,
|
||||
name: string,
|
||||
value: string | null,
|
||||
): void {
|
||||
if (isSVGElement(node)) {
|
||||
if (name.substr(0, 6) === 'xlink:') {
|
||||
name = name.substr(6);
|
||||
}
|
||||
if (value === null) {
|
||||
this.app.send(new RemoveNodeAttribute(id, name));
|
||||
} else if (name === 'href') {
|
||||
if (value.length > 1e5) {
|
||||
value = '';
|
||||
}
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
||||
} else {
|
||||
this.app.send(new SetNodeAttribute(id, name, value));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === 'src' ||
|
||||
name === 'srcset' ||
|
||||
name === 'integrity' ||
|
||||
name === 'crossorigin' ||
|
||||
name === 'autocomplete' ||
|
||||
name.substr(0, 2) === 'on'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === 'value' &&
|
||||
this.isInstance(node, HTMLInputElement) &&
|
||||
node.type !== 'button' &&
|
||||
node.type !== 'reset' &&
|
||||
node.type !== 'submit'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
this.app.send(new RemoveNodeAttribute(id, name));
|
||||
return;
|
||||
}
|
||||
if (name === 'style' || name === 'href' && this.isInstance(node, HTMLLinkElement)) {
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
||||
return;
|
||||
}
|
||||
if (name === 'href' || value.length > 1e5) {
|
||||
value = '';
|
||||
}
|
||||
this.app.send(new SetNodeAttribute(id, name, value));
|
||||
}
|
||||
|
||||
/* TODO: abstract sanitation */
|
||||
getInnerTextSecure(el: HTMLElement): string {
|
||||
const id = this.app.nodes.getID(el)
|
||||
if (!id) { return '' }
|
||||
return this.checkObscure(id, el.innerText)
|
||||
|
||||
}
|
||||
|
||||
private checkObscure(id: number, data: string): string {
|
||||
if (this.textMasked.has(id)) {
|
||||
return data.replace(
|
||||
/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g,
|
||||
'█',
|
||||
);
|
||||
}
|
||||
if (this.options.obscureTextNumbers) {
|
||||
data = data.replace(/\d/g, '0');
|
||||
}
|
||||
if (this.options.obscureTextEmails) {
|
||||
data = data.replace(
|
||||
/([^\s]+)@([^\s]+)\.([^\s]+)/g,
|
||||
(...f: Array<string>) =>
|
||||
stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]),
|
||||
);
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private sendNodeData(id: number, parentElement: Element, data: string): void {
|
||||
if (this.isInstance(parentElement, HTMLStyleElement) || this.isInstance(parentElement, SVGStyleElement)) {
|
||||
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
|
||||
return;
|
||||
}
|
||||
data = this.checkObscure(id, data)
|
||||
this.app.send(new SetNodeData(id, data));
|
||||
}
|
||||
/* end TODO: abstract sanitation */
|
||||
|
||||
private bindNode(node: Node): void {
|
||||
const r = this.app.nodes.registerNode(node);
|
||||
const id = r[0];
|
||||
this.recents[id] = r[1] || this.recents[id] || false;
|
||||
}
|
||||
|
||||
private bindTree(node: Node): void {
|
||||
if (this.isIgnored(node)) {
|
||||
return;
|
||||
}
|
||||
this.bindNode(node);
|
||||
const walker = document.createTreeWalker(
|
||||
node,
|
||||
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) =>
|
||||
this.isIgnored(node) || this.app.nodes.getID(node) !== undefined
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT,
|
||||
},
|
||||
// @ts-ignore
|
||||
false,
|
||||
);
|
||||
while (walker.nextNode()) {
|
||||
this.bindNode(walker.currentNode);
|
||||
}
|
||||
}
|
||||
|
||||
private unbindNode(node: Node): void {
|
||||
const id = this.app.nodes.unregisterNode(node);
|
||||
if (id !== undefined && this.recents[id] === false) {
|
||||
this.app.send(new RemoveNode(id));
|
||||
}
|
||||
}
|
||||
|
||||
private _commitNode(id: number, node: Node): boolean {
|
||||
const parent = node.parentNode;
|
||||
let parentID: number | undefined;
|
||||
if (this.isInstance(node, HTMLHtmlElement)) {
|
||||
this.indexes[id] = 0
|
||||
} else {
|
||||
if (parent === null) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
}
|
||||
parentID = this.app.nodes.getID(parent);
|
||||
if (parentID === undefined) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
}
|
||||
if (!this.commitNode(parentID)) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.textMasked.has(parentID) ||
|
||||
(this.isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))
|
||||
) {
|
||||
this.textMasked.add(id);
|
||||
}
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling !== null) {
|
||||
const siblingID = this.app.nodes.getID(sibling);
|
||||
if (siblingID !== undefined) {
|
||||
this.commitNode(siblingID);
|
||||
this.indexes[id] = this.indexes[siblingID] + 1;
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
if (sibling === null) {
|
||||
this.indexes[id] = 0;
|
||||
}
|
||||
}
|
||||
const isNew = this.recents[id];
|
||||
const index = this.indexes[id];
|
||||
if (index === undefined) {
|
||||
throw 'commitNode: missing node index';
|
||||
}
|
||||
if (isNew === true) {
|
||||
if (this.isInstance(node, Element)) {
|
||||
if (parentID !== undefined) {
|
||||
this.app.send(new
|
||||
CreateElementNode(
|
||||
id,
|
||||
parentID,
|
||||
index,
|
||||
node.tagName,
|
||||
isSVGElement(node),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < node.attributes.length; i++) {
|
||||
const attr = node.attributes[i];
|
||||
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
|
||||
}
|
||||
|
||||
if (this.isInstance(node, HTMLIFrameElement) &&
|
||||
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))) {
|
||||
this.handleIframe(node);
|
||||
}
|
||||
} else if (this.isInstance(node, Text)) {
|
||||
// for text node id != 0, hence parentID !== undefined and parent is Element
|
||||
this.app.send(new CreateTextNode(id, parentID as number, index));
|
||||
this.sendNodeData(id, parent as Element, node.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (isNew === false && parentID !== undefined) {
|
||||
this.app.send(new MoveNode(id, parentID, index));
|
||||
}
|
||||
const attr = this.attributesList[id];
|
||||
if (attr !== undefined) {
|
||||
if (!this.isInstance(node, Element)) {
|
||||
throw 'commitNode: node is not an element';
|
||||
}
|
||||
for (const name of attr) {
|
||||
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
|
||||
}
|
||||
}
|
||||
if (this.textSet.has(id)) {
|
||||
if (!this.isInstance(node, Text)) {
|
||||
throw 'commitNode: node is not a text';
|
||||
}
|
||||
// for text node id != 0, hence parent is Element
|
||||
this.sendNodeData(id, parent as Element, node.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private commitNode(id: number): boolean {
|
||||
const node = this.app.nodes.getNode(id);
|
||||
if (node === undefined) {
|
||||
return false;
|
||||
}
|
||||
const cmt = this.commited[id];
|
||||
if (cmt !== undefined) {
|
||||
return cmt;
|
||||
}
|
||||
return (this.commited[id] = this._commitNode(id, node));
|
||||
}
|
||||
private commitNodes(): void {
|
||||
let node;
|
||||
for (let id = 0; id < this.recents.length; id++) {
|
||||
this.commitNode(id);
|
||||
if (this.recents[id] === true && (node = this.app.nodes.getNode(id))) {
|
||||
this.app.nodes.callNodeCallbacks(node);
|
||||
}
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
private iframeObservers: Observer[] = [];
|
||||
private handleIframe(iframe: HTMLIFrameElement): void {
|
||||
let context: Window | null = null
|
||||
const handle = this.app.safe(() => {
|
||||
const id = this.app.nodes.getID(iframe)
|
||||
if (id === undefined) { return }
|
||||
if (iframe.contentWindow === context) { return }
|
||||
context = iframe.contentWindow as Window | null;
|
||||
if (!context) { return }
|
||||
const observer = new Observer(this.app, this.options, context)
|
||||
this.iframeObservers.push(observer)
|
||||
observer.observeIframe(id, context)
|
||||
})
|
||||
this.app.attachEventListener(iframe, "load", handle)
|
||||
handle()
|
||||
}
|
||||
|
||||
// TODO: abstract common functionality, separate FrameObserver
|
||||
private observeIframe(id: number, context: Window) {
|
||||
const doc = context.document;
|
||||
this.observer.observe(doc, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributeOldValue: false,
|
||||
characterDataOldValue: false,
|
||||
});
|
||||
this.bindTree(doc.documentElement);
|
||||
const docID = this.app.nodes.getID(doc.documentElement);
|
||||
if (docID === undefined) {
|
||||
console.log("Wrong")
|
||||
return;
|
||||
}
|
||||
this.app.send(CreateIFrameDocument(id,docID));
|
||||
this.commitNodes();
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
this.observer.observe(this.context.document, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributeOldValue: false,
|
||||
characterDataOldValue: false,
|
||||
});
|
||||
this.app.send(new CreateDocument());
|
||||
this.bindTree(this.context.document.documentElement);
|
||||
this.commitNodes();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.iframeObservers.forEach(o => o.disconnect());
|
||||
this.iframeObservers = [];
|
||||
this.observer.disconnect();
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
19
tracker/tracker/src/main/app/observer/iframe_observer.ts
Normal file
19
tracker/tracker/src/main/app/observer/iframe_observer.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Observer from "./observer.js";
|
||||
import { CreateIFrameDocument } from "../../../messages/index.js";
|
||||
|
||||
export default class IFrameObserver extends Observer {
|
||||
observe(iframe: HTMLIFrameElement) {
|
||||
const doc = iframe.contentDocument;
|
||||
const hostID = this.app.nodes.getID(iframe);
|
||||
if (!doc || hostID === undefined) { return } //log TODO common app.logger
|
||||
// Have to observe document, because the inner <html> might be changed
|
||||
this.observeRoot(doc, (docID) => {
|
||||
if (docID === undefined) {
|
||||
console.log("OpenReplay: Iframe document not bound")
|
||||
return;
|
||||
}
|
||||
this.app.send(CreateIFrameDocument(hostID, docID));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
353
tracker/tracker/src/main/app/observer/observer.ts
Normal file
353
tracker/tracker/src/main/app/observer/observer.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { hasOpenreplayAttribute } from "../../utils.js";
|
||||
import {
|
||||
RemoveNodeAttribute,
|
||||
SetNodeAttribute,
|
||||
SetNodeAttributeURLBased,
|
||||
SetCSSDataURLBased,
|
||||
SetNodeData,
|
||||
CreateTextNode,
|
||||
CreateElementNode,
|
||||
MoveNode,
|
||||
RemoveNode,
|
||||
} from "../../../messages/index.js";
|
||||
import App from "../index.js";
|
||||
import { isInstance, inDocument } from "../context.js";
|
||||
|
||||
|
||||
function isSVGElement(node: Element): node is SVGElement {
|
||||
return node.namespaceURI === 'http://www.w3.org/2000/svg';
|
||||
}
|
||||
|
||||
function isIgnored(node: Node): boolean {
|
||||
if (isInstance(node, Text)) {
|
||||
return false;
|
||||
}
|
||||
if (!isInstance(node, Element)) {
|
||||
return true;
|
||||
}
|
||||
const tag = node.tagName.toUpperCase();
|
||||
if (tag === 'LINK') {
|
||||
const rel = node.getAttribute('rel');
|
||||
const as = node.getAttribute('as');
|
||||
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
|
||||
}
|
||||
return (
|
||||
tag === 'SCRIPT' ||
|
||||
tag === 'NOSCRIPT' ||
|
||||
tag === 'META' ||
|
||||
tag === 'TITLE' ||
|
||||
tag === 'BASE'
|
||||
);
|
||||
}
|
||||
|
||||
function isRootNode(node: Node): boolean {
|
||||
return isInstance(node, Document) || isInstance(node, ShadowRoot);
|
||||
}
|
||||
|
||||
function isObservable(node: Node): boolean {
|
||||
if (isRootNode(node)) {
|
||||
return true;
|
||||
}
|
||||
return !isIgnored(node);
|
||||
}
|
||||
|
||||
export default abstract class Observer {
|
||||
private readonly observer: MutationObserver;
|
||||
private readonly commited: Array<boolean | undefined> = [];
|
||||
private readonly recents: Array<boolean | undefined> = [];
|
||||
private readonly myNodes: Array<boolean | undefined> = [];
|
||||
private readonly indexes: Array<number> = [];
|
||||
private readonly attributesList: Array<Set<string> | undefined> = [];
|
||||
private readonly textSet: Set<number> = new Set();
|
||||
private readonly inUpperContext: boolean;
|
||||
constructor(protected readonly app: App, protected readonly context: Window = window) {
|
||||
this.inUpperContext = context.parent === context //TODO: get rid of context here
|
||||
this.observer = new MutationObserver(
|
||||
this.app.safe((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const target = mutation.target;
|
||||
const type = mutation.type;
|
||||
|
||||
if (!isObservable(target) || !inDocument(target)) {
|
||||
continue;
|
||||
}
|
||||
if (type === 'childList') {
|
||||
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
||||
this.bindTree(mutation.removedNodes[i]);
|
||||
}
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
this.bindTree(mutation.addedNodes[i]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const id = this.app.nodes.getID(target);
|
||||
if (id === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (id >= this.recents.length) { // TODO: something more convinient
|
||||
this.recents[id] = undefined;
|
||||
}
|
||||
if (type === 'attributes') {
|
||||
const name = mutation.attributeName;
|
||||
if (name === null) {
|
||||
continue;
|
||||
}
|
||||
let attr = this.attributesList[id];
|
||||
if (attr === undefined) {
|
||||
this.attributesList[id] = attr = new Set();
|
||||
}
|
||||
attr.add(name);
|
||||
continue;
|
||||
}
|
||||
if (type === 'characterData') {
|
||||
this.textSet.add(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this.commitNodes();
|
||||
}),
|
||||
);
|
||||
}
|
||||
private clear(): void {
|
||||
this.commited.length = 0;
|
||||
this.recents.length = 0;
|
||||
this.indexes.length = 1;
|
||||
this.attributesList.length = 0;
|
||||
this.textSet.clear();
|
||||
}
|
||||
|
||||
private sendNodeAttribute(
|
||||
id: number,
|
||||
node: Element,
|
||||
name: string,
|
||||
value: string | null,
|
||||
): void {
|
||||
if (isSVGElement(node)) {
|
||||
if (name.substr(0, 6) === 'xlink:') {
|
||||
name = name.substr(6);
|
||||
}
|
||||
if (value === null) {
|
||||
this.app.send(new RemoveNodeAttribute(id, name));
|
||||
} else if (name === 'href') {
|
||||
if (value.length > 1e5) {
|
||||
value = '';
|
||||
}
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
||||
} else {
|
||||
this.app.send(new SetNodeAttribute(id, name, value));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === 'src' ||
|
||||
name === 'srcset' ||
|
||||
name === 'integrity' ||
|
||||
name === 'crossorigin' ||
|
||||
name === 'autocomplete' ||
|
||||
name.substr(0, 2) === 'on'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === 'value' &&
|
||||
isInstance(node, HTMLInputElement) &&
|
||||
node.type !== 'button' &&
|
||||
node.type !== 'reset' &&
|
||||
node.type !== 'submit'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
this.app.send(new RemoveNodeAttribute(id, name));
|
||||
return;
|
||||
}
|
||||
if (name === 'style' || name === 'href' && isInstance(node, HTMLLinkElement)) {
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
||||
return;
|
||||
}
|
||||
if (name === 'href' || value.length > 1e5) {
|
||||
value = '';
|
||||
}
|
||||
this.app.send(new SetNodeAttribute(id, name, value));
|
||||
}
|
||||
|
||||
private sendNodeData(id: number, parentElement: Element, data: string): void {
|
||||
if (isInstance(parentElement, HTMLStyleElement) || isInstance(parentElement, SVGStyleElement)) {
|
||||
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
|
||||
return;
|
||||
}
|
||||
data = this.app.sanitizer.sanitize(id, data)
|
||||
this.app.send(new SetNodeData(id, data));
|
||||
}
|
||||
|
||||
private bindNode(node: Node): void {
|
||||
const r = this.app.nodes.registerNode(node);
|
||||
const id = r[0];
|
||||
this.recents[id] = r[1] || this.recents[id] || false;
|
||||
|
||||
this.myNodes[id] = true;
|
||||
}
|
||||
|
||||
private bindTree(node: Node): void {
|
||||
if (!isObservable(node)) {
|
||||
return
|
||||
}
|
||||
this.bindNode(node);
|
||||
const walker = document.createTreeWalker(
|
||||
node,
|
||||
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) =>
|
||||
isIgnored(node) || this.app.nodes.getID(node) !== undefined
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT,
|
||||
},
|
||||
// @ts-ignore
|
||||
false,
|
||||
);
|
||||
while (walker.nextNode()) {
|
||||
this.bindNode(walker.currentNode);
|
||||
}
|
||||
}
|
||||
|
||||
private unbindNode(node: Node): void {
|
||||
const id = this.app.nodes.unregisterNode(node);
|
||||
if (id !== undefined && this.recents[id] === false) {
|
||||
this.app.send(new RemoveNode(id));
|
||||
}
|
||||
}
|
||||
|
||||
private _commitNode(id: number, node: Node): boolean {
|
||||
if (isRootNode(node)) {
|
||||
return true;
|
||||
}
|
||||
const parent = node.parentNode;
|
||||
let parentID: number | undefined;
|
||||
// Disable parent check for the upper context HTMLHtmlElement, because it is root there... (before)
|
||||
// TODO: get rid of "special" cases (there is an issue with CreateDocument altered behaviour though)
|
||||
// TODO: Clean the logic (though now it workd fine)
|
||||
if (!isInstance(node, HTMLHtmlElement) || !this.inUpperContext) {
|
||||
if (parent === null) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
}
|
||||
parentID = this.app.nodes.getID(parent);
|
||||
if (parentID === undefined) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
}
|
||||
if (!this.commitNode(parentID)) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
}
|
||||
this.app.sanitizer.handleNode(id, parentID, node);
|
||||
}
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling !== null) {
|
||||
const siblingID = this.app.nodes.getID(sibling);
|
||||
if (siblingID !== undefined) {
|
||||
this.commitNode(siblingID);
|
||||
this.indexes[id] = this.indexes[siblingID] + 1;
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
if (sibling === null) {
|
||||
this.indexes[id] = 0; //
|
||||
}
|
||||
const isNew = this.recents[id];
|
||||
const index = this.indexes[id];
|
||||
if (index === undefined) {
|
||||
throw 'commitNode: missing node index';
|
||||
}
|
||||
if (isNew === true) {
|
||||
if (isInstance(node, Element)) {
|
||||
if (parentID !== undefined) {
|
||||
this.app.send(new
|
||||
CreateElementNode(
|
||||
id,
|
||||
parentID,
|
||||
index,
|
||||
node.tagName,
|
||||
isSVGElement(node),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < node.attributes.length; i++) {
|
||||
const attr = node.attributes[i];
|
||||
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
|
||||
}
|
||||
} else if (isInstance(node, Text)) {
|
||||
// for text node id != 0, hence parentID !== undefined and parent is Element
|
||||
this.app.send(new CreateTextNode(id, parentID as number, index));
|
||||
this.sendNodeData(id, parent as Element, node.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (isNew === false && parentID !== undefined) {
|
||||
this.app.send(new MoveNode(id, parentID, index));
|
||||
}
|
||||
const attr = this.attributesList[id];
|
||||
if (attr !== undefined) {
|
||||
if (!isInstance(node, Element)) {
|
||||
throw 'commitNode: node is not an element';
|
||||
}
|
||||
for (const name of attr) {
|
||||
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
|
||||
}
|
||||
}
|
||||
if (this.textSet.has(id)) {
|
||||
if (!isInstance(node, Text)) {
|
||||
throw 'commitNode: node is not a text';
|
||||
}
|
||||
// for text node id != 0, hence parent is Element
|
||||
this.sendNodeData(id, parent as Element, node.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private commitNode(id: number): boolean {
|
||||
const node = this.app.nodes.getNode(id);
|
||||
if (node === undefined) {
|
||||
return false;
|
||||
}
|
||||
const cmt = this.commited[id];
|
||||
if (cmt !== undefined) {
|
||||
return cmt;
|
||||
}
|
||||
return (this.commited[id] = this._commitNode(id, node));
|
||||
}
|
||||
private commitNodes(): void {
|
||||
let node;
|
||||
for (let id = 0; id < this.recents.length; id++) {
|
||||
// TODO: make things/logic nice here.
|
||||
// commit required in any case if recents[id] true or false (in case of unbinding) or undefined (in case of attr change).
|
||||
if (!this.myNodes[id]) { continue }
|
||||
this.commitNode(id);
|
||||
if (this.recents[id] === true && (node = this.app.nodes.getNode(id))) {
|
||||
this.app.nodes.callNodeCallbacks(node);
|
||||
}
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
// ISSSUE
|
||||
protected observeRoot(node: Node, beforeCommit: (id?: number) => unknown, nodeToBind: Node = node) {
|
||||
this.observer.observe(node, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributeOldValue: false,
|
||||
characterDataOldValue: false,
|
||||
});
|
||||
this.bindTree(nodeToBind);
|
||||
beforeCommit(this.app.nodes.getID(node))
|
||||
this.commitNodes();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.observer.disconnect();
|
||||
this.clear();
|
||||
this.myNodes.length = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import Observer from "./observer.js";
|
||||
import { CreateIFrameDocument } from "../../../messages/index.js";
|
||||
|
||||
export default class ShadowRootObserver extends Observer {
|
||||
observe(el: Element) {
|
||||
const shRoot = el.shadowRoot;
|
||||
const hostID = this.app.nodes.getID(el);
|
||||
if (!shRoot || hostID === undefined) { return } // log
|
||||
this.observeRoot(shRoot, (rootID) => {
|
||||
if (rootID === undefined) {
|
||||
console.log("OpenReplay: Shadow Root was not bound")
|
||||
return;
|
||||
}
|
||||
this.app.send(CreateIFrameDocument(hostID,rootID));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
98
tracker/tracker/src/main/app/observer/top_observer.ts
Normal file
98
tracker/tracker/src/main/app/observer/top_observer.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import Observer from "./observer.js";
|
||||
import { isInstance } from "../context.js";
|
||||
import type { Window } from "../context.js";
|
||||
import IFrameObserver from "./iframe_observer.js";
|
||||
import ShadowRootObserver from "./shadow_root_observer.js";
|
||||
|
||||
import { CreateDocument } from "../../../messages/index.js";
|
||||
import App from "../index.js";
|
||||
import { IN_BROWSER } from '../../utils.js'
|
||||
|
||||
export interface Options {
|
||||
captureIFrames: boolean
|
||||
}
|
||||
|
||||
const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : ()=>new ShadowRoot();
|
||||
|
||||
export default class TopObserver extends Observer {
|
||||
private readonly options: Options;
|
||||
constructor(app: App, options: Partial<Options>) {
|
||||
super(app);
|
||||
this.options = Object.assign({
|
||||
captureIFrames: false
|
||||
}, options);
|
||||
|
||||
// IFrames
|
||||
this.app.nodes.attachNodeCallback(node => {
|
||||
if (isInstance(node, HTMLIFrameElement) &&
|
||||
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))
|
||||
) {
|
||||
this.handleIframe(node)
|
||||
}
|
||||
})
|
||||
|
||||
// ShadowDOM
|
||||
this.app.nodes.attachNodeCallback(node => {
|
||||
if (isInstance(node, Element) && node.shadowRoot !== null) {
|
||||
this.handleShadowRoot(node.shadowRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private iframeObservers: IFrameObserver[] = [];
|
||||
private handleIframe(iframe: HTMLIFrameElement): void {
|
||||
let context: Window | null = null
|
||||
const handle = this.app.safe(() => {
|
||||
const id = this.app.nodes.getID(iframe)
|
||||
if (id === undefined) { return } //log
|
||||
if (iframe.contentWindow === context) { return } //Does this happen frequently?
|
||||
context = iframe.contentWindow as Window | null;
|
||||
if (!context) { return }
|
||||
const observer = new IFrameObserver(this.app, context)
|
||||
|
||||
this.iframeObservers.push(observer)
|
||||
observer.observe(iframe)
|
||||
})
|
||||
this.app.attachEventListener(iframe, "load", handle)
|
||||
handle()
|
||||
}
|
||||
|
||||
private shadowRootObservers: ShadowRootObserver[] = []
|
||||
private handleShadowRoot(shRoot: ShadowRoot) {
|
||||
const observer = new ShadowRootObserver(this.app, this.context)
|
||||
|
||||
this.shadowRootObservers.push(observer)
|
||||
observer.observe(shRoot.host)
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
// Protection from several subsequent calls?
|
||||
const observer = this;
|
||||
Element.prototype.attachShadow = function() {
|
||||
const shadow = attachShadowNativeFn.apply(this, arguments)
|
||||
observer.handleShadowRoot(shadow)
|
||||
return shadow
|
||||
}
|
||||
|
||||
// Can observe documentElement (<html>) here, because it is not supposed to be changing.
|
||||
// However, it is possible in some exotic cases and may cause an ignorance of the newly created <html>
|
||||
// In this case context.document have to be observed, but this will cause
|
||||
// the change in the re-player behaviour caused by CreateDocument message:
|
||||
// the 0-node ("fRoot") will become #document rather than documentElement as it is now.
|
||||
// Alternatively - observe(#document) then bindNode(documentElement)
|
||||
this.observeRoot(this.context.document, () => {
|
||||
this.app.send(new CreateDocument())
|
||||
}, this.context.document.documentElement);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
Element.prototype.attachShadow = attachShadowNativeFn
|
||||
this.iframeObservers.forEach(o => o.disconnect())
|
||||
this.iframeObservers = []
|
||||
this.shadowRootObservers.forEach(o => o.disconnect())
|
||||
this.shadowRootObservers = []
|
||||
super.disconnect()
|
||||
}
|
||||
|
||||
}
|
||||
66
tracker/tracker/src/main/app/sanitizer.ts
Normal file
66
tracker/tracker/src/main/app/sanitizer.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { stars, hasOpenreplayAttribute } from "../utils.js";
|
||||
import App from "./index.js";
|
||||
import { isInstance } from "./context.js";
|
||||
|
||||
export interface Options {
|
||||
obscureTextEmails: boolean;
|
||||
obscureTextNumbers: boolean;
|
||||
}
|
||||
|
||||
export default class Sanitizer {
|
||||
private readonly masked: Set<number> = new Set();
|
||||
private readonly options: Options;
|
||||
|
||||
constructor(private readonly app: App, options: Partial<Options>) {
|
||||
this.options = Object.assign({
|
||||
obscureTextEmails: true,
|
||||
obscureTextNumbers: false,
|
||||
}, options);
|
||||
}
|
||||
|
||||
handleNode(id: number, parentID: number, node: Node) {
|
||||
if (
|
||||
this.masked.has(parentID) ||
|
||||
(isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))
|
||||
) {
|
||||
this.masked.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
sanitize(id: number, data: string): string {
|
||||
if (this.masked.has(id)) {
|
||||
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
|
||||
return data.trim().replace(
|
||||
/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g,
|
||||
'█',
|
||||
);
|
||||
}
|
||||
if (this.options.obscureTextNumbers) {
|
||||
data = data.replace(/\d/g, '0');
|
||||
}
|
||||
if (this.options.obscureTextEmails) {
|
||||
data = data.replace(
|
||||
/([^\s]+)@([^\s]+)\.([^\s]+)/g,
|
||||
(...f: Array<string>) =>
|
||||
stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]),
|
||||
);
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
isMasked(id: number): boolean {
|
||||
return this.masked.has(id);
|
||||
}
|
||||
|
||||
getInnerTextSecure(el: HTMLElement): string {
|
||||
const id = this.app.nodes.getID(el)
|
||||
if (!id) { return '' }
|
||||
return this.sanitize(id, el.innerText)
|
||||
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.masked.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -19,14 +19,15 @@ import Longtasks from "./modules/longtasks.js";
|
|||
import CSSRules from "./modules/cssrules.js";
|
||||
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from "./utils.js";
|
||||
|
||||
import { Options as AppOptions } from "./app/index.js";
|
||||
import { Options as ConsoleOptions } from "./modules/console.js";
|
||||
import { Options as ExceptionOptions } from "./modules/exception.js";
|
||||
import { Options as InputOptions } from "./modules/input.js";
|
||||
import { Options as PerformanceOptions } from "./modules/performance.js";
|
||||
import { Options as TimingOptions } from "./modules/timing.js";
|
||||
|
||||
export type { OnStartInfo } from './app/index.js';
|
||||
import type { Options as AppOptions } from "./app/index.js";
|
||||
import type { Options as ConsoleOptions } from "./modules/console.js";
|
||||
import type { Options as ExceptionOptions } from "./modules/exception.js";
|
||||
import type { Options as InputOptions } from "./modules/input.js";
|
||||
import type { Options as PerformanceOptions } from "./modules/performance.js";
|
||||
import type { Options as TimingOptions } from "./modules/timing.js";
|
||||
import type { StartOptions } from './app/index.js'
|
||||
//TODO: unique options init
|
||||
import type { OnStartInfo } from './app/index.js';
|
||||
|
||||
export type Options = Partial<
|
||||
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions
|
||||
|
|
@ -35,6 +36,7 @@ export type Options = Partial<
|
|||
projectKey: string;
|
||||
sessionToken?: string;
|
||||
respectDoNotTrack?: boolean;
|
||||
autoResetOnWindowOpen?: boolean;
|
||||
// dev only
|
||||
__DISABLE_SECURE_MODE?: boolean;
|
||||
};
|
||||
|
|
@ -84,7 +86,7 @@ export default class API {
|
|||
(navigator.doNotTrack == '1'
|
||||
// @ts-ignore
|
||||
|| window.doNotTrack == '1');
|
||||
this.app = doNotTrack ||
|
||||
const app = this.app = doNotTrack ||
|
||||
!('Map' in window) ||
|
||||
!('Set' in window) ||
|
||||
!('MutationObserver' in window) ||
|
||||
|
|
@ -95,20 +97,35 @@ export default class API {
|
|||
!('Worker' in window)
|
||||
? null
|
||||
: new App(options.projectKey, options.sessionToken, options);
|
||||
if (this.app !== null) {
|
||||
Viewport(this.app);
|
||||
CSSRules(this.app);
|
||||
Connection(this.app);
|
||||
Console(this.app, options);
|
||||
Exception(this.app, options);
|
||||
Img(this.app);
|
||||
Input(this.app, options);
|
||||
Mouse(this.app);
|
||||
Timing(this.app, options);
|
||||
Performance(this.app, options);
|
||||
Scroll(this.app);
|
||||
Longtasks(this.app);
|
||||
if (app !== null) {
|
||||
Viewport(app);
|
||||
CSSRules(app);
|
||||
Connection(app);
|
||||
Console(app, options);
|
||||
Exception(app, options);
|
||||
Img(app);
|
||||
Input(app, options);
|
||||
Mouse(app);
|
||||
Timing(app, options);
|
||||
Performance(app, options);
|
||||
Scroll(app);
|
||||
Longtasks(app);
|
||||
(window as any).__OPENREPLAY__ = this;
|
||||
|
||||
if (options.autoResetOnWindowOpen) {
|
||||
const wOpen = window.open;
|
||||
app.attachStartCallback(() => {
|
||||
// @ts-ignore ?
|
||||
window.open = function(...args) {
|
||||
app.resetNextPageSession(true)
|
||||
wOpen.call(window, ...args)
|
||||
app.resetNextPageSession(false)
|
||||
}
|
||||
})
|
||||
app.attachStopCallback(() => {
|
||||
window.open = wOpen;
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log("OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.")
|
||||
const req = new XMLHttpRequest();
|
||||
|
|
@ -140,7 +157,7 @@ export default class API {
|
|||
return this.isActive();
|
||||
}
|
||||
|
||||
start() /*: Promise<OnStartInfo>*/ {
|
||||
start(startOpts?: StartOptions) : Promise<OnStartInfo> {
|
||||
if (!IN_BROWSER) {
|
||||
console.error(`OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`)
|
||||
return Promise.reject("Trying to start not in browser.");
|
||||
|
|
@ -148,7 +165,7 @@ export default class API {
|
|||
if (this.app === null) {
|
||||
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.");
|
||||
}
|
||||
return this.app.start();
|
||||
return this.app.start(startOpts);
|
||||
}
|
||||
stop(): void {
|
||||
if (this.app === null) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ export function getExceptionMessageFromEvent(e: ErrorEvent | PromiseRejectionEve
|
|||
if (e.reason instanceof Error) {
|
||||
return getExceptionMessage(e.reason, [])
|
||||
} else {
|
||||
return new JSException('Unhandled Promise Rejection', String(e.reason), '[]');
|
||||
let message: string;
|
||||
try {
|
||||
message = JSON.stringify(e.reason)
|
||||
} catch(_) {
|
||||
message = String(e.reason)
|
||||
}
|
||||
return new JSException('Unhandled Promise Rejection', message, '[]');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { timestamp, isURL } from "../utils.js";
|
||||
import App from "../app/index.js";
|
||||
import { ResourceTiming, SetNodeAttributeURLBased } from "../../messages/index.js";
|
||||
import { ResourceTiming, SetNodeAttributeURLBased, SetNodeAttribute } from "../../messages/index.js";
|
||||
|
||||
const PLACEHOLDER_SRC = "https://static.openreplay.com/tracker/placeholder.jpeg";
|
||||
|
||||
export default function (app: App): void {
|
||||
function sendPlaceholder(id: number, node: HTMLImageElement): void {
|
||||
app.send(new SetNodeAttribute(id, "src", PLACEHOLDER_SRC))
|
||||
const { width, height } = node.getBoundingClientRect();
|
||||
if (!node.hasAttribute("width")){
|
||||
app.send(new SetNodeAttribute(id, "width", String(width)))
|
||||
}
|
||||
if (!node.hasAttribute("height")){
|
||||
app.send(new SetNodeAttribute(id, "height", String(height)))
|
||||
}
|
||||
}
|
||||
|
||||
const sendImgSrc = app.safe(function (this: HTMLImageElement): void {
|
||||
const id = app.nodes.getID(this);
|
||||
if (id === undefined) {
|
||||
|
|
@ -16,7 +29,9 @@ export default function (app: App): void {
|
|||
if (src != null && isURL(src)) { // TODO: How about relative urls ? Src type is null sometimes.
|
||||
app.send(new ResourceTiming(timestamp(), 0, 0, 0, 0, 0, src, 'img'));
|
||||
}
|
||||
} else if (src.length < 1e5) {
|
||||
} else if (src.length >= 1e5 || app.sanitizer.isMasked(id)) {
|
||||
sendPlaceholder(id, this)
|
||||
} else {
|
||||
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } fro
|
|||
import App from "../app/index.js";
|
||||
import { SetInputTarget, SetInputValue, SetInputChecked } from "../../messages/index.js";
|
||||
|
||||
function isInput(node: any): node is HTMLInputElement {
|
||||
// TODO: take into consideration "contenteditable" attribute
|
||||
type TextEditableElement = HTMLInputElement | HTMLTextAreaElement
|
||||
function isTextEditable(node: any): node is TextEditableElement {
|
||||
if (node instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
if (!(node instanceof HTMLInputElement)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -16,6 +21,7 @@ function isInput(node: any): node is HTMLInputElement {
|
|||
type === 'range'
|
||||
);
|
||||
}
|
||||
|
||||
function isCheckable(node: any): node is HTMLInputElement {
|
||||
if (!(node instanceof HTMLInputElement)) {
|
||||
return false;
|
||||
|
|
@ -25,7 +31,7 @@ function isCheckable(node: any): node is HTMLInputElement {
|
|||
}
|
||||
|
||||
const labelElementFor: (
|
||||
node: HTMLInputElement,
|
||||
node: TextEditableElement,
|
||||
) => HTMLLabelElement | undefined =
|
||||
IN_BROWSER && 'labels' in HTMLInputElement.prototype
|
||||
? (node): HTMLLabelElement | undefined => {
|
||||
|
|
@ -56,7 +62,7 @@ const labelElementFor: (
|
|||
}
|
||||
};
|
||||
|
||||
export function getInputLabel(node: HTMLInputElement): string {
|
||||
export function getInputLabel(node: TextEditableElement): string {
|
||||
let label = getLabelAttribute(node);
|
||||
if (label === null) {
|
||||
const labelElement = labelElementFor(node);
|
||||
|
|
@ -89,13 +95,13 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
},
|
||||
opts,
|
||||
);
|
||||
function sendInputTarget(id: number, node: HTMLInputElement): void {
|
||||
function sendInputTarget(id: number, node: TextEditableElement): void {
|
||||
const label = getInputLabel(node);
|
||||
if (label !== '') {
|
||||
app.send(new SetInputTarget(id, label));
|
||||
}
|
||||
}
|
||||
function sendInputValue(id: number, node: HTMLInputElement): void {
|
||||
function sendInputValue(id: number, node: TextEditableElement): void {
|
||||
let value = node.value;
|
||||
let inputMode: InputMode = options.defaultInputMode;
|
||||
if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) {
|
||||
|
|
@ -136,7 +142,7 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
app.ticker.attach((): void => {
|
||||
inputValues.forEach((value, id) => {
|
||||
const node = app.nodes.getNode(id);
|
||||
if (!isInput(node)) {
|
||||
if (!isTextEditable(node)) {
|
||||
inputValues.delete(id);
|
||||
return;
|
||||
}
|
||||
|
|
@ -169,7 +175,7 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
if (isInput(node)) {
|
||||
if (isTextEditable(node)) {
|
||||
inputValues.set(id, node.value);
|
||||
sendInputValue(id, node);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function (app: App): void {
|
|||
(target as HTMLElement).onclick != null ||
|
||||
target.getAttribute('role') === 'button'
|
||||
) {
|
||||
const label: string = app.observer.getInnerTextSecure(target as HTMLElement);
|
||||
const label: string = app.sanitizer.getInnerTextSecure(target as HTMLElement);
|
||||
return normSpaces(label).slice(0, 100);
|
||||
}
|
||||
return '';
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ function sendBatch(batch: Uint8Array):void {
|
|||
return; // happens simultaneously with onerror TODO: clear codeflow
|
||||
}
|
||||
if (this.status >= 400) { // TODO: test workflow. After 400+ it calls /start for some reason
|
||||
busy = false;
|
||||
reset();
|
||||
sendQueue.length = 0;
|
||||
if (this.status === 401) { // Unauthorised (Token expired)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue