@openreplay/tracker 3.4.14 → 3.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/app/index.js +1 -1
- package/cjs/app/observer/top_observer.js +0 -4
- package/cjs/app/observer.d.ts +47 -0
- package/cjs/app/observer.js +395 -0
- package/cjs/index.js +1 -1
- package/lib/app/index.js +1 -1
- package/lib/app/observer/top_observer.js +0 -4
- package/lib/app/observer.d.ts +47 -0
- package/lib/app/observer.js +392 -0
- package/lib/index.js +1 -1
- package/package.json +1 -1
package/cjs/app/index.js
CHANGED
|
@@ -41,10 +41,6 @@ class TopObserver extends observer_js_1.default {
|
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
const observer = new iframe_observer_js_1.default(this.app, this.options, context);
|
|
44
|
-
// @ts-ignore
|
|
45
|
-
observer.commited = this.commited;
|
|
46
|
-
// @ts-ignore
|
|
47
|
-
//observers.recents = this.recents
|
|
48
44
|
this.iframeObservers.push(observer);
|
|
49
45
|
observer.observe(iframe);
|
|
50
46
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import App from "./index.js";
|
|
2
|
+
interface Window extends WindowProxy {
|
|
3
|
+
HTMLInputElement: typeof HTMLInputElement;
|
|
4
|
+
HTMLLinkElement: typeof HTMLLinkElement;
|
|
5
|
+
HTMLStyleElement: typeof HTMLStyleElement;
|
|
6
|
+
SVGStyleElement: typeof SVGStyleElement;
|
|
7
|
+
HTMLIFrameElement: typeof HTMLIFrameElement;
|
|
8
|
+
Text: typeof Text;
|
|
9
|
+
Element: typeof Element;
|
|
10
|
+
}
|
|
11
|
+
export interface Options {
|
|
12
|
+
obscureTextEmails: boolean;
|
|
13
|
+
obscureTextNumbers: boolean;
|
|
14
|
+
captureIFrames: boolean;
|
|
15
|
+
}
|
|
16
|
+
export default class Observer {
|
|
17
|
+
private readonly app;
|
|
18
|
+
private readonly options;
|
|
19
|
+
private readonly context;
|
|
20
|
+
private readonly observer;
|
|
21
|
+
private readonly commited;
|
|
22
|
+
private readonly recents;
|
|
23
|
+
private readonly indexes;
|
|
24
|
+
private readonly attributesList;
|
|
25
|
+
private readonly textSet;
|
|
26
|
+
private readonly textMasked;
|
|
27
|
+
constructor(app: App, options: Options, context?: Window);
|
|
28
|
+
private clear;
|
|
29
|
+
private isInstance;
|
|
30
|
+
private isIgnored;
|
|
31
|
+
private sendNodeAttribute;
|
|
32
|
+
getInnerTextSecure(el: HTMLElement): string;
|
|
33
|
+
private checkObscure;
|
|
34
|
+
private sendNodeData;
|
|
35
|
+
private bindNode;
|
|
36
|
+
private bindTree;
|
|
37
|
+
private unbindNode;
|
|
38
|
+
private _commitNode;
|
|
39
|
+
private commitNode;
|
|
40
|
+
private commitNodes;
|
|
41
|
+
private iframeObservers;
|
|
42
|
+
private handleIframe;
|
|
43
|
+
private observeIframe;
|
|
44
|
+
observe(): void;
|
|
45
|
+
disconnect(): void;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_js_1 = require("../utils.js");
|
|
4
|
+
const index_js_1 = require("../messages/index.js");
|
|
5
|
+
function isSVGElement(node) {
|
|
6
|
+
return node.namespaceURI === 'http://www.w3.org/2000/svg';
|
|
7
|
+
}
|
|
8
|
+
class Observer {
|
|
9
|
+
constructor(app, options, context = window) {
|
|
10
|
+
this.app = app;
|
|
11
|
+
this.options = options;
|
|
12
|
+
this.context = context;
|
|
13
|
+
this.iframeObservers = [];
|
|
14
|
+
this.observer = new MutationObserver(this.app.safe((mutations) => {
|
|
15
|
+
var _a;
|
|
16
|
+
for (const mutation of mutations) {
|
|
17
|
+
const target = mutation.target;
|
|
18
|
+
const type = mutation.type;
|
|
19
|
+
// Special case
|
|
20
|
+
// Document 'childList' might happen in case of iframe.
|
|
21
|
+
// TODO: generalize as much as possible
|
|
22
|
+
if (this.isInstance(target, Document)
|
|
23
|
+
&& type === 'childList'
|
|
24
|
+
//&& new Array(mutation.addedNodes).some(node => this.isInstance(node, HTMLHtmlElement))
|
|
25
|
+
) {
|
|
26
|
+
const parentFrame = (_a = target.defaultView) === null || _a === void 0 ? void 0 : _a.frameElement;
|
|
27
|
+
if (!parentFrame) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
this.bindTree(target.documentElement);
|
|
31
|
+
const frameID = this.app.nodes.getID(parentFrame);
|
|
32
|
+
const docID = this.app.nodes.getID(target.documentElement);
|
|
33
|
+
if (frameID === undefined || docID === undefined) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
this.app.send((0, index_js_1.CreateIFrameDocument)(frameID, docID));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (this.isIgnored(target) || !context.document.contains(target)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (type === 'childList') {
|
|
43
|
+
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
|
44
|
+
this.bindTree(mutation.removedNodes[i]);
|
|
45
|
+
}
|
|
46
|
+
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
|
47
|
+
this.bindTree(mutation.addedNodes[i]);
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const id = this.app.nodes.getID(target);
|
|
52
|
+
if (id === undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (id >= this.recents.length) {
|
|
56
|
+
this.recents[id] = undefined;
|
|
57
|
+
}
|
|
58
|
+
if (type === 'attributes') {
|
|
59
|
+
const name = mutation.attributeName;
|
|
60
|
+
if (name === null) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
let attr = this.attributesList[id];
|
|
64
|
+
if (attr === undefined) {
|
|
65
|
+
this.attributesList[id] = attr = new Set();
|
|
66
|
+
}
|
|
67
|
+
attr.add(name);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (type === 'characterData') {
|
|
71
|
+
this.textSet.add(id);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.commitNodes();
|
|
76
|
+
}));
|
|
77
|
+
this.commited = [];
|
|
78
|
+
this.recents = [];
|
|
79
|
+
this.indexes = [0];
|
|
80
|
+
this.attributesList = [];
|
|
81
|
+
this.textSet = new Set();
|
|
82
|
+
this.textMasked = new Set();
|
|
83
|
+
}
|
|
84
|
+
clear() {
|
|
85
|
+
this.commited.length = 0;
|
|
86
|
+
this.recents.length = 0;
|
|
87
|
+
this.indexes.length = 1;
|
|
88
|
+
this.attributesList.length = 0;
|
|
89
|
+
this.textSet.clear();
|
|
90
|
+
this.textMasked.clear();
|
|
91
|
+
}
|
|
92
|
+
// TODO: we need a type expert here so we won't have to ignore the lines
|
|
93
|
+
isInstance(node, constr) {
|
|
94
|
+
let context = this.context;
|
|
95
|
+
while (context.parent && context.parent !== context) {
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
if (node instanceof context[constr.name]) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
// @ts-ignore
|
|
101
|
+
context = context.parent;
|
|
102
|
+
}
|
|
103
|
+
// @ts-ignore
|
|
104
|
+
return node instanceof context[constr.name];
|
|
105
|
+
}
|
|
106
|
+
isIgnored(node) {
|
|
107
|
+
if (this.isInstance(node, Text)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (!this.isInstance(node, Element)) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const tag = node.tagName.toUpperCase();
|
|
114
|
+
if (tag === 'LINK') {
|
|
115
|
+
const rel = node.getAttribute('rel');
|
|
116
|
+
const as = node.getAttribute('as');
|
|
117
|
+
return !((rel === null || rel === void 0 ? void 0 : rel.includes('stylesheet')) || as === "style" || as === "font");
|
|
118
|
+
}
|
|
119
|
+
return (tag === 'SCRIPT' ||
|
|
120
|
+
tag === 'NOSCRIPT' ||
|
|
121
|
+
tag === 'META' ||
|
|
122
|
+
tag === 'TITLE' ||
|
|
123
|
+
tag === 'BASE');
|
|
124
|
+
}
|
|
125
|
+
sendNodeAttribute(id, node, name, value) {
|
|
126
|
+
if (isSVGElement(node)) {
|
|
127
|
+
if (name.substr(0, 6) === 'xlink:') {
|
|
128
|
+
name = name.substr(6);
|
|
129
|
+
}
|
|
130
|
+
if (value === null) {
|
|
131
|
+
this.app.send(new index_js_1.RemoveNodeAttribute(id, name));
|
|
132
|
+
}
|
|
133
|
+
else if (name === 'href') {
|
|
134
|
+
if (value.length > 1e5) {
|
|
135
|
+
value = '';
|
|
136
|
+
}
|
|
137
|
+
this.app.send(new index_js_1.SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
this.app.send(new index_js_1.SetNodeAttribute(id, name, value));
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (name === 'src' ||
|
|
145
|
+
name === 'srcset' ||
|
|
146
|
+
name === 'integrity' ||
|
|
147
|
+
name === 'crossorigin' ||
|
|
148
|
+
name === 'autocomplete' ||
|
|
149
|
+
name.substr(0, 2) === 'on') {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (name === 'value' &&
|
|
153
|
+
this.isInstance(node, HTMLInputElement) &&
|
|
154
|
+
node.type !== 'button' &&
|
|
155
|
+
node.type !== 'reset' &&
|
|
156
|
+
node.type !== 'submit') {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (value === null) {
|
|
160
|
+
this.app.send(new index_js_1.RemoveNodeAttribute(id, name));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (name === 'style' || name === 'href' && this.isInstance(node, HTMLLinkElement)) {
|
|
164
|
+
this.app.send(new index_js_1.SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (name === 'href' || value.length > 1e5) {
|
|
168
|
+
value = '';
|
|
169
|
+
}
|
|
170
|
+
this.app.send(new index_js_1.SetNodeAttribute(id, name, value));
|
|
171
|
+
}
|
|
172
|
+
/* TODO: abstract sanitation */
|
|
173
|
+
getInnerTextSecure(el) {
|
|
174
|
+
const id = this.app.nodes.getID(el);
|
|
175
|
+
if (!id) {
|
|
176
|
+
return '';
|
|
177
|
+
}
|
|
178
|
+
return this.checkObscure(id, el.innerText);
|
|
179
|
+
}
|
|
180
|
+
checkObscure(id, data) {
|
|
181
|
+
if (this.textMasked.has(id)) {
|
|
182
|
+
return data.replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█');
|
|
183
|
+
}
|
|
184
|
+
if (this.options.obscureTextNumbers) {
|
|
185
|
+
data = data.replace(/\d/g, '0');
|
|
186
|
+
}
|
|
187
|
+
if (this.options.obscureTextEmails) {
|
|
188
|
+
data = data.replace(/([^\s]+)@([^\s]+)\.([^\s]+)/g, (...f) => (0, utils_js_1.stars)(f[1]) + '@' + (0, utils_js_1.stars)(f[2]) + '.' + (0, utils_js_1.stars)(f[3]));
|
|
189
|
+
}
|
|
190
|
+
return data;
|
|
191
|
+
}
|
|
192
|
+
sendNodeData(id, parentElement, data) {
|
|
193
|
+
if (this.isInstance(parentElement, HTMLStyleElement) || this.isInstance(parentElement, SVGStyleElement)) {
|
|
194
|
+
this.app.send(new index_js_1.SetCSSDataURLBased(id, data, this.app.getBaseHref()));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
data = this.checkObscure(id, data);
|
|
198
|
+
this.app.send(new index_js_1.SetNodeData(id, data));
|
|
199
|
+
}
|
|
200
|
+
/* end TODO: abstract sanitation */
|
|
201
|
+
bindNode(node) {
|
|
202
|
+
const r = this.app.nodes.registerNode(node);
|
|
203
|
+
const id = r[0];
|
|
204
|
+
this.recents[id] = r[1] || this.recents[id] || false;
|
|
205
|
+
}
|
|
206
|
+
bindTree(node) {
|
|
207
|
+
if (this.isIgnored(node)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.bindNode(node);
|
|
211
|
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
|
|
212
|
+
acceptNode: (node) => this.isIgnored(node) || this.app.nodes.getID(node) !== undefined
|
|
213
|
+
? NodeFilter.FILTER_REJECT
|
|
214
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
215
|
+
},
|
|
216
|
+
// @ts-ignore
|
|
217
|
+
false);
|
|
218
|
+
while (walker.nextNode()) {
|
|
219
|
+
this.bindNode(walker.currentNode);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
unbindNode(node) {
|
|
223
|
+
const id = this.app.nodes.unregisterNode(node);
|
|
224
|
+
if (id !== undefined && this.recents[id] === false) {
|
|
225
|
+
this.app.send(new index_js_1.RemoveNode(id));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
_commitNode(id, node) {
|
|
229
|
+
const parent = node.parentNode;
|
|
230
|
+
let parentID;
|
|
231
|
+
if (this.isInstance(node, HTMLHtmlElement)) {
|
|
232
|
+
this.indexes[id] = 0;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
if (parent === null) {
|
|
236
|
+
this.unbindNode(node);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
parentID = this.app.nodes.getID(parent);
|
|
240
|
+
if (parentID === undefined) {
|
|
241
|
+
this.unbindNode(node);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (!this.commitNode(parentID)) {
|
|
245
|
+
this.unbindNode(node);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
if (this.textMasked.has(parentID) ||
|
|
249
|
+
(this.isInstance(node, Element) && (0, utils_js_1.hasOpenreplayAttribute)(node, 'masked'))) {
|
|
250
|
+
this.textMasked.add(id);
|
|
251
|
+
}
|
|
252
|
+
let sibling = node.previousSibling;
|
|
253
|
+
while (sibling !== null) {
|
|
254
|
+
const siblingID = this.app.nodes.getID(sibling);
|
|
255
|
+
if (siblingID !== undefined) {
|
|
256
|
+
this.commitNode(siblingID);
|
|
257
|
+
this.indexes[id] = this.indexes[siblingID] + 1;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
sibling = sibling.previousSibling;
|
|
261
|
+
}
|
|
262
|
+
if (sibling === null) {
|
|
263
|
+
this.indexes[id] = 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const isNew = this.recents[id];
|
|
267
|
+
const index = this.indexes[id];
|
|
268
|
+
if (index === undefined) {
|
|
269
|
+
throw 'commitNode: missing node index';
|
|
270
|
+
}
|
|
271
|
+
if (isNew === true) {
|
|
272
|
+
if (this.isInstance(node, Element)) {
|
|
273
|
+
if (parentID !== undefined) {
|
|
274
|
+
this.app.send(new index_js_1.CreateElementNode(id, parentID, index, node.tagName, isSVGElement(node)));
|
|
275
|
+
}
|
|
276
|
+
for (let i = 0; i < node.attributes.length; i++) {
|
|
277
|
+
const attr = node.attributes[i];
|
|
278
|
+
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
|
|
279
|
+
}
|
|
280
|
+
if (this.isInstance(node, HTMLIFrameElement) &&
|
|
281
|
+
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))) {
|
|
282
|
+
this.handleIframe(node);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (this.isInstance(node, Text)) {
|
|
286
|
+
// for text node id != 0, hence parentID !== undefined and parent is Element
|
|
287
|
+
this.app.send(new index_js_1.CreateTextNode(id, parentID, index));
|
|
288
|
+
this.sendNodeData(id, parent, node.data);
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
if (isNew === false && parentID !== undefined) {
|
|
293
|
+
this.app.send(new index_js_1.MoveNode(id, parentID, index));
|
|
294
|
+
}
|
|
295
|
+
const attr = this.attributesList[id];
|
|
296
|
+
if (attr !== undefined) {
|
|
297
|
+
if (!this.isInstance(node, Element)) {
|
|
298
|
+
throw 'commitNode: node is not an element';
|
|
299
|
+
}
|
|
300
|
+
for (const name of attr) {
|
|
301
|
+
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (this.textSet.has(id)) {
|
|
305
|
+
if (!this.isInstance(node, Text)) {
|
|
306
|
+
throw 'commitNode: node is not a text';
|
|
307
|
+
}
|
|
308
|
+
// for text node id != 0, hence parent is Element
|
|
309
|
+
this.sendNodeData(id, parent, node.data);
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
commitNode(id) {
|
|
314
|
+
const node = this.app.nodes.getNode(id);
|
|
315
|
+
if (node === undefined) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const cmt = this.commited[id];
|
|
319
|
+
if (cmt !== undefined) {
|
|
320
|
+
return cmt;
|
|
321
|
+
}
|
|
322
|
+
return (this.commited[id] = this._commitNode(id, node));
|
|
323
|
+
}
|
|
324
|
+
commitNodes() {
|
|
325
|
+
let node;
|
|
326
|
+
for (let id = 0; id < this.recents.length; id++) {
|
|
327
|
+
this.commitNode(id);
|
|
328
|
+
if (this.recents[id] === true && (node = this.app.nodes.getNode(id))) {
|
|
329
|
+
this.app.nodes.callNodeCallbacks(node);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
this.clear();
|
|
333
|
+
}
|
|
334
|
+
handleIframe(iframe) {
|
|
335
|
+
let context = null;
|
|
336
|
+
const handle = this.app.safe(() => {
|
|
337
|
+
const id = this.app.nodes.getID(iframe);
|
|
338
|
+
if (id === undefined) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (iframe.contentWindow === context) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
context = iframe.contentWindow;
|
|
345
|
+
if (!context) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const observer = new Observer(this.app, this.options, context);
|
|
349
|
+
this.iframeObservers.push(observer);
|
|
350
|
+
observer.observeIframe(id, context);
|
|
351
|
+
});
|
|
352
|
+
this.app.attachEventListener(iframe, "load", handle);
|
|
353
|
+
handle();
|
|
354
|
+
}
|
|
355
|
+
// TODO: abstract common functionality, separate FrameObserver
|
|
356
|
+
observeIframe(id, context) {
|
|
357
|
+
const doc = context.document;
|
|
358
|
+
this.observer.observe(doc, {
|
|
359
|
+
childList: true,
|
|
360
|
+
attributes: true,
|
|
361
|
+
characterData: true,
|
|
362
|
+
subtree: true,
|
|
363
|
+
attributeOldValue: false,
|
|
364
|
+
characterDataOldValue: false,
|
|
365
|
+
});
|
|
366
|
+
this.bindTree(doc.documentElement);
|
|
367
|
+
const docID = this.app.nodes.getID(doc.documentElement);
|
|
368
|
+
if (docID === undefined) {
|
|
369
|
+
console.log("Wrong");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.app.send((0, index_js_1.CreateIFrameDocument)(id, docID));
|
|
373
|
+
this.commitNodes();
|
|
374
|
+
}
|
|
375
|
+
observe() {
|
|
376
|
+
this.observer.observe(this.context.document, {
|
|
377
|
+
childList: true,
|
|
378
|
+
attributes: true,
|
|
379
|
+
characterData: true,
|
|
380
|
+
subtree: true,
|
|
381
|
+
attributeOldValue: false,
|
|
382
|
+
characterDataOldValue: false,
|
|
383
|
+
});
|
|
384
|
+
this.app.send(new index_js_1.CreateDocument());
|
|
385
|
+
this.bindTree(this.context.document.documentElement);
|
|
386
|
+
this.commitNodes();
|
|
387
|
+
}
|
|
388
|
+
disconnect() {
|
|
389
|
+
this.iframeObservers.forEach(o => o.disconnect());
|
|
390
|
+
this.iframeObservers = [];
|
|
391
|
+
this.observer.disconnect();
|
|
392
|
+
this.clear();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
exports.default = Observer;
|
package/cjs/index.js
CHANGED
|
@@ -115,7 +115,7 @@ class API {
|
|
|
115
115
|
// no-cors issue only with text/plain or not-set Content-Type
|
|
116
116
|
// req.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
|
|
117
117
|
req.send(JSON.stringify({
|
|
118
|
-
trackerVersion: '3.4.
|
|
118
|
+
trackerVersion: '3.4.15',
|
|
119
119
|
projectKey: options.projectKey,
|
|
120
120
|
doNotTrack,
|
|
121
121
|
// TODO: add precise reason (an exact API missing)
|
package/lib/app/index.js
CHANGED
|
@@ -39,10 +39,6 @@ export default class TopObserver extends Observer {
|
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
const observer = new IFrameObserver(this.app, this.options, context);
|
|
42
|
-
// @ts-ignore
|
|
43
|
-
observer.commited = this.commited;
|
|
44
|
-
// @ts-ignore
|
|
45
|
-
//observers.recents = this.recents
|
|
46
42
|
this.iframeObservers.push(observer);
|
|
47
43
|
observer.observe(iframe);
|
|
48
44
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import App from "./index.js";
|
|
2
|
+
interface Window extends WindowProxy {
|
|
3
|
+
HTMLInputElement: typeof HTMLInputElement;
|
|
4
|
+
HTMLLinkElement: typeof HTMLLinkElement;
|
|
5
|
+
HTMLStyleElement: typeof HTMLStyleElement;
|
|
6
|
+
SVGStyleElement: typeof SVGStyleElement;
|
|
7
|
+
HTMLIFrameElement: typeof HTMLIFrameElement;
|
|
8
|
+
Text: typeof Text;
|
|
9
|
+
Element: typeof Element;
|
|
10
|
+
}
|
|
11
|
+
export interface Options {
|
|
12
|
+
obscureTextEmails: boolean;
|
|
13
|
+
obscureTextNumbers: boolean;
|
|
14
|
+
captureIFrames: boolean;
|
|
15
|
+
}
|
|
16
|
+
export default class Observer {
|
|
17
|
+
private readonly app;
|
|
18
|
+
private readonly options;
|
|
19
|
+
private readonly context;
|
|
20
|
+
private readonly observer;
|
|
21
|
+
private readonly commited;
|
|
22
|
+
private readonly recents;
|
|
23
|
+
private readonly indexes;
|
|
24
|
+
private readonly attributesList;
|
|
25
|
+
private readonly textSet;
|
|
26
|
+
private readonly textMasked;
|
|
27
|
+
constructor(app: App, options: Options, context?: Window);
|
|
28
|
+
private clear;
|
|
29
|
+
private isInstance;
|
|
30
|
+
private isIgnored;
|
|
31
|
+
private sendNodeAttribute;
|
|
32
|
+
getInnerTextSecure(el: HTMLElement): string;
|
|
33
|
+
private checkObscure;
|
|
34
|
+
private sendNodeData;
|
|
35
|
+
private bindNode;
|
|
36
|
+
private bindTree;
|
|
37
|
+
private unbindNode;
|
|
38
|
+
private _commitNode;
|
|
39
|
+
private commitNode;
|
|
40
|
+
private commitNodes;
|
|
41
|
+
private iframeObservers;
|
|
42
|
+
private handleIframe;
|
|
43
|
+
private observeIframe;
|
|
44
|
+
observe(): void;
|
|
45
|
+
disconnect(): void;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { stars, hasOpenreplayAttribute } from "../utils.js";
|
|
2
|
+
import { CreateDocument, CreateElementNode, CreateTextNode, SetNodeData, SetCSSDataURLBased, SetNodeAttribute, SetNodeAttributeURLBased, RemoveNodeAttribute, MoveNode, RemoveNode, CreateIFrameDocument, } from "../messages/index.js";
|
|
3
|
+
function isSVGElement(node) {
|
|
4
|
+
return node.namespaceURI === 'http://www.w3.org/2000/svg';
|
|
5
|
+
}
|
|
6
|
+
export default class Observer {
|
|
7
|
+
constructor(app, options, context = window) {
|
|
8
|
+
this.app = app;
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.context = context;
|
|
11
|
+
this.iframeObservers = [];
|
|
12
|
+
this.observer = new MutationObserver(this.app.safe((mutations) => {
|
|
13
|
+
var _a;
|
|
14
|
+
for (const mutation of mutations) {
|
|
15
|
+
const target = mutation.target;
|
|
16
|
+
const type = mutation.type;
|
|
17
|
+
// Special case
|
|
18
|
+
// Document 'childList' might happen in case of iframe.
|
|
19
|
+
// TODO: generalize as much as possible
|
|
20
|
+
if (this.isInstance(target, Document)
|
|
21
|
+
&& type === 'childList'
|
|
22
|
+
//&& new Array(mutation.addedNodes).some(node => this.isInstance(node, HTMLHtmlElement))
|
|
23
|
+
) {
|
|
24
|
+
const parentFrame = (_a = target.defaultView) === null || _a === void 0 ? void 0 : _a.frameElement;
|
|
25
|
+
if (!parentFrame) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
this.bindTree(target.documentElement);
|
|
29
|
+
const frameID = this.app.nodes.getID(parentFrame);
|
|
30
|
+
const docID = this.app.nodes.getID(target.documentElement);
|
|
31
|
+
if (frameID === undefined || docID === undefined) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
this.app.send(CreateIFrameDocument(frameID, docID));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (this.isIgnored(target) || !context.document.contains(target)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (type === 'childList') {
|
|
41
|
+
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
|
42
|
+
this.bindTree(mutation.removedNodes[i]);
|
|
43
|
+
}
|
|
44
|
+
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
|
45
|
+
this.bindTree(mutation.addedNodes[i]);
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const id = this.app.nodes.getID(target);
|
|
50
|
+
if (id === undefined) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (id >= this.recents.length) {
|
|
54
|
+
this.recents[id] = undefined;
|
|
55
|
+
}
|
|
56
|
+
if (type === 'attributes') {
|
|
57
|
+
const name = mutation.attributeName;
|
|
58
|
+
if (name === null) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let attr = this.attributesList[id];
|
|
62
|
+
if (attr === undefined) {
|
|
63
|
+
this.attributesList[id] = attr = new Set();
|
|
64
|
+
}
|
|
65
|
+
attr.add(name);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (type === 'characterData') {
|
|
69
|
+
this.textSet.add(id);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.commitNodes();
|
|
74
|
+
}));
|
|
75
|
+
this.commited = [];
|
|
76
|
+
this.recents = [];
|
|
77
|
+
this.indexes = [0];
|
|
78
|
+
this.attributesList = [];
|
|
79
|
+
this.textSet = new Set();
|
|
80
|
+
this.textMasked = new Set();
|
|
81
|
+
}
|
|
82
|
+
clear() {
|
|
83
|
+
this.commited.length = 0;
|
|
84
|
+
this.recents.length = 0;
|
|
85
|
+
this.indexes.length = 1;
|
|
86
|
+
this.attributesList.length = 0;
|
|
87
|
+
this.textSet.clear();
|
|
88
|
+
this.textMasked.clear();
|
|
89
|
+
}
|
|
90
|
+
// TODO: we need a type expert here so we won't have to ignore the lines
|
|
91
|
+
isInstance(node, constr) {
|
|
92
|
+
let context = this.context;
|
|
93
|
+
while (context.parent && context.parent !== context) {
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
if (node instanceof context[constr.name]) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
// @ts-ignore
|
|
99
|
+
context = context.parent;
|
|
100
|
+
}
|
|
101
|
+
// @ts-ignore
|
|
102
|
+
return node instanceof context[constr.name];
|
|
103
|
+
}
|
|
104
|
+
isIgnored(node) {
|
|
105
|
+
if (this.isInstance(node, Text)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if (!this.isInstance(node, Element)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const tag = node.tagName.toUpperCase();
|
|
112
|
+
if (tag === 'LINK') {
|
|
113
|
+
const rel = node.getAttribute('rel');
|
|
114
|
+
const as = node.getAttribute('as');
|
|
115
|
+
return !((rel === null || rel === void 0 ? void 0 : rel.includes('stylesheet')) || as === "style" || as === "font");
|
|
116
|
+
}
|
|
117
|
+
return (tag === 'SCRIPT' ||
|
|
118
|
+
tag === 'NOSCRIPT' ||
|
|
119
|
+
tag === 'META' ||
|
|
120
|
+
tag === 'TITLE' ||
|
|
121
|
+
tag === 'BASE');
|
|
122
|
+
}
|
|
123
|
+
sendNodeAttribute(id, node, name, value) {
|
|
124
|
+
if (isSVGElement(node)) {
|
|
125
|
+
if (name.substr(0, 6) === 'xlink:') {
|
|
126
|
+
name = name.substr(6);
|
|
127
|
+
}
|
|
128
|
+
if (value === null) {
|
|
129
|
+
this.app.send(new RemoveNodeAttribute(id, name));
|
|
130
|
+
}
|
|
131
|
+
else if (name === 'href') {
|
|
132
|
+
if (value.length > 1e5) {
|
|
133
|
+
value = '';
|
|
134
|
+
}
|
|
135
|
+
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.app.send(new SetNodeAttribute(id, name, value));
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (name === 'src' ||
|
|
143
|
+
name === 'srcset' ||
|
|
144
|
+
name === 'integrity' ||
|
|
145
|
+
name === 'crossorigin' ||
|
|
146
|
+
name === 'autocomplete' ||
|
|
147
|
+
name.substr(0, 2) === 'on') {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (name === 'value' &&
|
|
151
|
+
this.isInstance(node, HTMLInputElement) &&
|
|
152
|
+
node.type !== 'button' &&
|
|
153
|
+
node.type !== 'reset' &&
|
|
154
|
+
node.type !== 'submit') {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (value === null) {
|
|
158
|
+
this.app.send(new RemoveNodeAttribute(id, name));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (name === 'style' || name === 'href' && this.isInstance(node, HTMLLinkElement)) {
|
|
162
|
+
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (name === 'href' || value.length > 1e5) {
|
|
166
|
+
value = '';
|
|
167
|
+
}
|
|
168
|
+
this.app.send(new SetNodeAttribute(id, name, value));
|
|
169
|
+
}
|
|
170
|
+
/* TODO: abstract sanitation */
|
|
171
|
+
getInnerTextSecure(el) {
|
|
172
|
+
const id = this.app.nodes.getID(el);
|
|
173
|
+
if (!id) {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
return this.checkObscure(id, el.innerText);
|
|
177
|
+
}
|
|
178
|
+
checkObscure(id, data) {
|
|
179
|
+
if (this.textMasked.has(id)) {
|
|
180
|
+
return data.replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█');
|
|
181
|
+
}
|
|
182
|
+
if (this.options.obscureTextNumbers) {
|
|
183
|
+
data = data.replace(/\d/g, '0');
|
|
184
|
+
}
|
|
185
|
+
if (this.options.obscureTextEmails) {
|
|
186
|
+
data = data.replace(/([^\s]+)@([^\s]+)\.([^\s]+)/g, (...f) => stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]));
|
|
187
|
+
}
|
|
188
|
+
return data;
|
|
189
|
+
}
|
|
190
|
+
sendNodeData(id, parentElement, data) {
|
|
191
|
+
if (this.isInstance(parentElement, HTMLStyleElement) || this.isInstance(parentElement, SVGStyleElement)) {
|
|
192
|
+
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
data = this.checkObscure(id, data);
|
|
196
|
+
this.app.send(new SetNodeData(id, data));
|
|
197
|
+
}
|
|
198
|
+
/* end TODO: abstract sanitation */
|
|
199
|
+
bindNode(node) {
|
|
200
|
+
const r = this.app.nodes.registerNode(node);
|
|
201
|
+
const id = r[0];
|
|
202
|
+
this.recents[id] = r[1] || this.recents[id] || false;
|
|
203
|
+
}
|
|
204
|
+
bindTree(node) {
|
|
205
|
+
if (this.isIgnored(node)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.bindNode(node);
|
|
209
|
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
|
|
210
|
+
acceptNode: (node) => this.isIgnored(node) || this.app.nodes.getID(node) !== undefined
|
|
211
|
+
? NodeFilter.FILTER_REJECT
|
|
212
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
213
|
+
},
|
|
214
|
+
// @ts-ignore
|
|
215
|
+
false);
|
|
216
|
+
while (walker.nextNode()) {
|
|
217
|
+
this.bindNode(walker.currentNode);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
unbindNode(node) {
|
|
221
|
+
const id = this.app.nodes.unregisterNode(node);
|
|
222
|
+
if (id !== undefined && this.recents[id] === false) {
|
|
223
|
+
this.app.send(new RemoveNode(id));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
_commitNode(id, node) {
|
|
227
|
+
const parent = node.parentNode;
|
|
228
|
+
let parentID;
|
|
229
|
+
if (this.isInstance(node, HTMLHtmlElement)) {
|
|
230
|
+
this.indexes[id] = 0;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
if (parent === null) {
|
|
234
|
+
this.unbindNode(node);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
parentID = this.app.nodes.getID(parent);
|
|
238
|
+
if (parentID === undefined) {
|
|
239
|
+
this.unbindNode(node);
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
if (!this.commitNode(parentID)) {
|
|
243
|
+
this.unbindNode(node);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
if (this.textMasked.has(parentID) ||
|
|
247
|
+
(this.isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))) {
|
|
248
|
+
this.textMasked.add(id);
|
|
249
|
+
}
|
|
250
|
+
let sibling = node.previousSibling;
|
|
251
|
+
while (sibling !== null) {
|
|
252
|
+
const siblingID = this.app.nodes.getID(sibling);
|
|
253
|
+
if (siblingID !== undefined) {
|
|
254
|
+
this.commitNode(siblingID);
|
|
255
|
+
this.indexes[id] = this.indexes[siblingID] + 1;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
sibling = sibling.previousSibling;
|
|
259
|
+
}
|
|
260
|
+
if (sibling === null) {
|
|
261
|
+
this.indexes[id] = 0;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const isNew = this.recents[id];
|
|
265
|
+
const index = this.indexes[id];
|
|
266
|
+
if (index === undefined) {
|
|
267
|
+
throw 'commitNode: missing node index';
|
|
268
|
+
}
|
|
269
|
+
if (isNew === true) {
|
|
270
|
+
if (this.isInstance(node, Element)) {
|
|
271
|
+
if (parentID !== undefined) {
|
|
272
|
+
this.app.send(new CreateElementNode(id, parentID, index, node.tagName, isSVGElement(node)));
|
|
273
|
+
}
|
|
274
|
+
for (let i = 0; i < node.attributes.length; i++) {
|
|
275
|
+
const attr = node.attributes[i];
|
|
276
|
+
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
|
|
277
|
+
}
|
|
278
|
+
if (this.isInstance(node, HTMLIFrameElement) &&
|
|
279
|
+
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))) {
|
|
280
|
+
this.handleIframe(node);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (this.isInstance(node, Text)) {
|
|
284
|
+
// for text node id != 0, hence parentID !== undefined and parent is Element
|
|
285
|
+
this.app.send(new CreateTextNode(id, parentID, index));
|
|
286
|
+
this.sendNodeData(id, parent, node.data);
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
if (isNew === false && parentID !== undefined) {
|
|
291
|
+
this.app.send(new MoveNode(id, parentID, index));
|
|
292
|
+
}
|
|
293
|
+
const attr = this.attributesList[id];
|
|
294
|
+
if (attr !== undefined) {
|
|
295
|
+
if (!this.isInstance(node, Element)) {
|
|
296
|
+
throw 'commitNode: node is not an element';
|
|
297
|
+
}
|
|
298
|
+
for (const name of attr) {
|
|
299
|
+
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (this.textSet.has(id)) {
|
|
303
|
+
if (!this.isInstance(node, Text)) {
|
|
304
|
+
throw 'commitNode: node is not a text';
|
|
305
|
+
}
|
|
306
|
+
// for text node id != 0, hence parent is Element
|
|
307
|
+
this.sendNodeData(id, parent, node.data);
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
commitNode(id) {
|
|
312
|
+
const node = this.app.nodes.getNode(id);
|
|
313
|
+
if (node === undefined) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const cmt = this.commited[id];
|
|
317
|
+
if (cmt !== undefined) {
|
|
318
|
+
return cmt;
|
|
319
|
+
}
|
|
320
|
+
return (this.commited[id] = this._commitNode(id, node));
|
|
321
|
+
}
|
|
322
|
+
commitNodes() {
|
|
323
|
+
let node;
|
|
324
|
+
for (let id = 0; id < this.recents.length; id++) {
|
|
325
|
+
this.commitNode(id);
|
|
326
|
+
if (this.recents[id] === true && (node = this.app.nodes.getNode(id))) {
|
|
327
|
+
this.app.nodes.callNodeCallbacks(node);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
this.clear();
|
|
331
|
+
}
|
|
332
|
+
handleIframe(iframe) {
|
|
333
|
+
let context = null;
|
|
334
|
+
const handle = this.app.safe(() => {
|
|
335
|
+
const id = this.app.nodes.getID(iframe);
|
|
336
|
+
if (id === undefined) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (iframe.contentWindow === context) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
context = iframe.contentWindow;
|
|
343
|
+
if (!context) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const observer = new Observer(this.app, this.options, context);
|
|
347
|
+
this.iframeObservers.push(observer);
|
|
348
|
+
observer.observeIframe(id, context);
|
|
349
|
+
});
|
|
350
|
+
this.app.attachEventListener(iframe, "load", handle);
|
|
351
|
+
handle();
|
|
352
|
+
}
|
|
353
|
+
// TODO: abstract common functionality, separate FrameObserver
|
|
354
|
+
observeIframe(id, context) {
|
|
355
|
+
const doc = context.document;
|
|
356
|
+
this.observer.observe(doc, {
|
|
357
|
+
childList: true,
|
|
358
|
+
attributes: true,
|
|
359
|
+
characterData: true,
|
|
360
|
+
subtree: true,
|
|
361
|
+
attributeOldValue: false,
|
|
362
|
+
characterDataOldValue: false,
|
|
363
|
+
});
|
|
364
|
+
this.bindTree(doc.documentElement);
|
|
365
|
+
const docID = this.app.nodes.getID(doc.documentElement);
|
|
366
|
+
if (docID === undefined) {
|
|
367
|
+
console.log("Wrong");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
this.app.send(CreateIFrameDocument(id, docID));
|
|
371
|
+
this.commitNodes();
|
|
372
|
+
}
|
|
373
|
+
observe() {
|
|
374
|
+
this.observer.observe(this.context.document, {
|
|
375
|
+
childList: true,
|
|
376
|
+
attributes: true,
|
|
377
|
+
characterData: true,
|
|
378
|
+
subtree: true,
|
|
379
|
+
attributeOldValue: false,
|
|
380
|
+
characterDataOldValue: false,
|
|
381
|
+
});
|
|
382
|
+
this.app.send(new CreateDocument());
|
|
383
|
+
this.bindTree(this.context.document.documentElement);
|
|
384
|
+
this.commitNodes();
|
|
385
|
+
}
|
|
386
|
+
disconnect() {
|
|
387
|
+
this.iframeObservers.forEach(o => o.disconnect());
|
|
388
|
+
this.iframeObservers = [];
|
|
389
|
+
this.observer.disconnect();
|
|
390
|
+
this.clear();
|
|
391
|
+
}
|
|
392
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -111,7 +111,7 @@ export default class API {
|
|
|
111
111
|
// no-cors issue only with text/plain or not-set Content-Type
|
|
112
112
|
// req.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
|
|
113
113
|
req.send(JSON.stringify({
|
|
114
|
-
trackerVersion: '3.4.
|
|
114
|
+
trackerVersion: '3.4.15',
|
|
115
115
|
projectKey: options.projectKey,
|
|
116
116
|
doNotTrack,
|
|
117
117
|
// TODO: add precise reason (an exact API missing)
|