@mparticle/web-sdk 3.26.1 → 3.28.0

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.
@@ -0,0 +1,306 @@
1
+ // Masks the parts of a page path that can single out one shopper: pagePath for
2
+ // the path a click logs, pageUrlPath for the page URL and page views every event
3
+ // carries.
4
+
5
+ // A token of letters and digits mixed, six or more long, as session ids,
6
+ // hex and UUID parts and invite codes are: `A1B2C3D4E5F6`, `550e8400`.
7
+ const MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{6,}$/;
8
+ // Letters alone, eight or more long, switching case three times or more, as a
9
+ // random code does (`xKpQzRtV`) and a camel-case route word rarely does.
10
+ const CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{8,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
11
+ // Route words whose next segment names one person or carries a one-time code,
12
+ // as in `/profile/jane-smith` or `/reset/abcdefgh`.
13
+ const PERSONAL_ROUTE = /^(?:u|user|users|profile|profiles|member|members|people|reset|reset-password|password|verify|verification|confirm|activate|invite|invitation|token|unsubscribe|magic-link)$/i;
14
+
15
+ // Route words whose next segment is one shopper's order: any with `order` or
16
+ // `orders` as a whole word part, as in `view-order`, `order-received` or `order_id`.
17
+ const ORDER_ROUTE = /(?:^|[-_])orders?(?:[-_]|$)/i;
18
+ // Product words that name a way to buy, not an order: `pre-order`, `on-order`,
19
+ // `back-order`, `made-to-order`, as the whole route word only, so
20
+ // `return-to-order` and `pre-order-status` stay routes.
21
+ const PRODUCT_ORDER_WORD = /^(?:(?:pre|on|back)[-_]|made[-_]to[-_])orders?$/i;
22
+ // A `;` that starts a matrix parameter, plain or encoded any number of times.
23
+ const MATRIX_SEPARATOR = /;|%(?:25)*3B/i;
24
+ // A valid escape left after decoding stopped, as when a literal `%` before it
25
+ // can't be decoded again. What it hides is unknown, so it counts as sensitive.
26
+ export const LEFTOVER_ESCAPE = /%[0-9A-F]{2}/i;
27
+
28
+ // Pages under a personal route that name no one: the sign-in, sign-up and
29
+ // account steps sites keep there, as in `/u/login` or `/u/mfa-sms-challenge`.
30
+ const ACCOUNT_PAGE = /^(?:log-?in|log-?out|sign-?in|sign-?out|sign-?up|register|registration|create-account|account|settings|forgot-password|reset-password|password|challenge|consent|callback|authorize|continue|mfa(?:-[a-z]+)*)$/i;
31
+
32
+ // Whether a segment after a route goes as `*`: always after an order route, and
33
+ // after a personal route unless it is one of the account pages kept there.
34
+ export function hidesSegmentAfter(route: string, segment: string): boolean {
35
+ return (
36
+ hidesNextSegment(route) &&
37
+ !(PERSONAL_ROUTE.test(route) && ACCOUNT_PAGE.test(segment))
38
+ );
39
+ }
40
+
41
+ export function hidesNextSegment(route: string): boolean {
42
+ return (
43
+ PERSONAL_ROUTE.test(route) ||
44
+ (ORDER_ROUTE.test(route) && !PRODUCT_ORDER_WORD.test(route))
45
+ );
46
+ }
47
+
48
+ // How many times a value is decoded at most, so one encoded twice or more reads
49
+ // as its plain text.
50
+ const MAX_DECODES = 5;
51
+
52
+ // Decodes until the text stops changing. Undefined when the first decode fails;
53
+ // a later failure keeps what decoded so far, as a literal `%` would.
54
+ export function decodeFully(text: string): string | undefined {
55
+ let current = text;
56
+ for (let i = 0; i < MAX_DECODES; i++) {
57
+ let next: string;
58
+ try {
59
+ next = decodeURIComponent(current);
60
+ } catch (e) {
61
+ return i === 0 ? undefined : current;
62
+ }
63
+ if (next === current) {
64
+ return current;
65
+ }
66
+ current = next;
67
+ }
68
+ return current;
69
+ }
70
+
71
+ // The route word in a raw path segment: its last part behind an encoded slash,
72
+ // read before any matrix parameter such as `;jsessionid=`, its `;` encoded once,
73
+ // twice or not at all, so a parameter's own value never hides it. Undefined when
74
+ // it can't be decoded, or is still encoded after MAX_DECODES.
75
+ function routeWord(segment: string): string | undefined {
76
+ let text = segment;
77
+ for (let i = 0; i < MAX_DECODES; i++) {
78
+ const route = text.split(MATRIX_SEPARATOR)[0];
79
+ let next: string;
80
+ try {
81
+ next = decodeURIComponent(route);
82
+ } catch (e) {
83
+ return i === 0 ? undefined : route.split('/').pop();
84
+ }
85
+ if (next === route) {
86
+ return route.split('/').pop();
87
+ }
88
+ text = next;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ // The last segment before index i that isn't empty, so `/orders//12345678`
94
+ // still reads the order route.
95
+ export function routeSegment(segments: string[], i: number): string {
96
+ let routeIndex = i - 1;
97
+ while (routeIndex > 0 && segments[routeIndex] === '') {
98
+ routeIndex--;
99
+ }
100
+ return segments[routeIndex];
101
+ }
102
+
103
+ // A piece with a lowercase letter right before an uppercase one, as a random
104
+ // code has and a Title-Case word doesn't.
105
+ const INNER_CASE_SWITCH = /[a-z][A-Z]/;
106
+
107
+ const isToken = (text: string): boolean =>
108
+ MIXED_TOKEN.test(text) || CASE_SWITCHING_TOKEN.test(text);
109
+
110
+ const mixesLettersAndDigits = (piece: string): boolean =>
111
+ /[0-9]/.test(piece) && /[A-Za-z]/.test(piece);
112
+
113
+ // `+` sits inside base64 tokens, so it never splits one. A token that `-` or `_`
114
+ // cuts into short pieces is still one when the pieces read as one joined and
115
+ // either a piece switches case inside it or two or more pieces mix letters and
116
+ // digits, as `a1b2-c3d4-e5f6` does. Title-Case-Words and plain slugs such as
117
+ // `iphone-15-pro` do neither.
118
+ export function isTokenSegment(segment: string): boolean {
119
+ const text = segment.replace(/\+/g, '');
120
+ const pieces = text.split(/[-_.~]/);
121
+ const joined = pieces.join('');
122
+ return (
123
+ pieces.some(isToken) ||
124
+ (pieces.some(piece => INNER_CASE_SWITCH.test(piece)) &&
125
+ isToken(joined)) ||
126
+ (pieces.filter(mixesLettersAndDigits).length >= 2 &&
127
+ MIXED_TOKEN.test(joined))
128
+ );
129
+ }
130
+
131
+ // A word a product name is made of: three or more letters, lowercase or
132
+ // Capitalised. A code's mixed case or capital-letter groups aren't words.
133
+ const isWord = (piece: string): boolean =>
134
+ /^(?:[a-z]{3,}|[A-Z][a-z]{2,})$/.test(piece);
135
+
136
+ const UUID_IN_TEXT = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
137
+
138
+ // Route words whose next segment names a product, as in `/p/<name>` or
139
+ // `/product/<name>`. Only there can words beside a token be a product's name
140
+ // rather than a person's or part of a code.
141
+ const PRODUCT_ROUTE = /^(?:p|product|products|item|items|dp|shop|catalog)$/i;
142
+
143
+ // A segment that holds a token goes as `*`. After a product route, only the
144
+ // token goes, so `/p/trail-runner-gtx-zq7Kd93Lm41` keeps its name as
145
+ // `/p/trail-runner-gtx-*`.
146
+ // Every piece that isn't a word in an unbroken run with the token goes with it
147
+ // as one `*`, so the rest of a UUID or of a code cut into short pieces never
148
+ // stays. A piece that can't be decoded or decodes to a separator counts as a
149
+ // token, and so does any piece that mixes letters and digits or switches case
150
+ // inside, as a code's short pieces do. The segment goes whole as `*` when no
151
+ // piece is a word or it holds a UUID. Undefined when it holds no token. `segment` is as the page
152
+ // has it, `text` decoded, `route` the route word before it.
153
+ export function maskTokenSegment(
154
+ segment: string,
155
+ text: string,
156
+ route: string | undefined
157
+ ): string | undefined {
158
+ if (!isTokenSegment(text)) {
159
+ return undefined;
160
+ }
161
+ // A UUID anywhere in the decoded text, however its hyphens are escaped,
162
+ // takes the whole segment.
163
+ if (!route || !PRODUCT_ROUTE.test(route) || UUID_IN_TEXT.test(text)) {
164
+ return '*';
165
+ }
166
+ const parts = segment.split(/([-_.~])/);
167
+ const raws = parts.filter((_, i) => i % 2 === 0);
168
+ const separators = parts.filter((_, i) => i % 2 === 1);
169
+ const decoded = raws.map(raw => decodeFully(raw));
170
+ const kinds = decoded.map(piece => {
171
+ if (piece === undefined || /[-_.~]/.test(piece)) {
172
+ return 'token';
173
+ }
174
+ const plain = piece.replace(/\+/g, '');
175
+ return isToken(plain) ||
176
+ mixesLettersAndDigits(plain) ||
177
+ INNER_CASE_SWITCH.test(plain)
178
+ ? 'token'
179
+ : isWord(plain)
180
+ ? 'word'
181
+ : 'other';
182
+ });
183
+ // Each unbroken run of pieces that aren't words is hidden when it holds a
184
+ // token.
185
+ const masked = kinds.map(() => false);
186
+ for (let start = 0; start < kinds.length; ) {
187
+ if (kinds[start] === 'word') {
188
+ start++;
189
+ continue;
190
+ }
191
+ let end = start;
192
+ while (end < kinds.length && kinds[end] !== 'word') {
193
+ end++;
194
+ }
195
+ // A run reads as a token also when only its pieces joined do.
196
+ if (
197
+ kinds.slice(start, end).indexOf('token') !== -1 ||
198
+ isToken(raws.slice(start, end).join(''))
199
+ ) {
200
+ for (let k = start; k < end; k++) {
201
+ masked[k] = true;
202
+ }
203
+ }
204
+ start = end;
205
+ }
206
+ if (
207
+ masked.indexOf(true) === -1 ||
208
+ kinds.indexOf('word') === -1
209
+ ) {
210
+ return '*';
211
+ }
212
+ let out = '';
213
+ for (let k = 0; k < raws.length; k++) {
214
+ if (masked[k] && k > 0 && masked[k - 1]) {
215
+ continue;
216
+ }
217
+ out += (k > 0 ? separators[k - 1] : '') + (masked[k] ? '*' : raws[k]);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ // The path a click logs. A segment that can single out one shopper
223
+ // goes as `*`: one holding an @, `;` or `=`, an id, or the one after a route
224
+ // that names a person, carries a code or opens an order. A token inside a name
225
+ // goes as `*` alone. Kept segments stay exactly as the page has them, plain
226
+ // numbers included, since on a product page they name the product.
227
+ export function pagePath(pathname: string): string {
228
+ const segments = pathname.split('/');
229
+ return segments
230
+ .map((segment, i) => {
231
+ const text = decodeFully(segment);
232
+ const route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
233
+ // A route word that can't be decoded may be any route, so the
234
+ // segment after it is hidden too.
235
+ const hiddenByRoute =
236
+ i > 0 &&
237
+ !!text &&
238
+ (route === undefined || hidesSegmentAfter(route, text));
239
+ // An encoded slash hides several segments in one, so it goes whole.
240
+ if (
241
+ text === undefined ||
242
+ /[@;=/]/.test(text) ||
243
+ LEFTOVER_ESCAPE.test(text) ||
244
+ hiddenByRoute
245
+ ) {
246
+ return '*';
247
+ }
248
+ const masked = maskTokenSegment(segment, text, route);
249
+ return masked === undefined ? segment : masked;
250
+ })
251
+ .join('/');
252
+ }
253
+
254
+ // A token long enough that no page or product name looks like it: 16 or more
255
+ // letters and digits mixed, as checkout tokens are, 16 or more letters
256
+ // switching case like a random code, or a UUID.
257
+ const LONG_MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{16,}$/;
258
+ const LONG_CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{16,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
259
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
260
+
261
+ // A token that `-` or `_` cuts into shorter pieces counts when two or more
262
+ // pieces mix letters and digits, as `a1b2c3d4-e5f6g7h8` does and a product slug
263
+ // such as `acmephone-15-pro-max-256gb` doesn't.
264
+ function isLongToken(segment: string): boolean {
265
+ const pieces = segment.replace(/\+/g, '').split(/[-_.~]/);
266
+ return (
267
+ UUID.test(segment) ||
268
+ pieces.some(
269
+ piece =>
270
+ LONG_MIXED_TOKEN.test(piece) ||
271
+ LONG_CASE_SWITCHING_TOKEN.test(piece)
272
+ ) ||
273
+ (pieces.filter(mixesLettersAndDigits).length >= 2 &&
274
+ LONG_MIXED_TOKEN.test(pieces.join('')))
275
+ );
276
+ }
277
+
278
+ // The path every event carries, in its page URL and page views, where a
279
+ // wrong `*` costs every output the page it names. Only what reliably singles
280
+ // out one shopper goes as `*`: a segment holding an @, `;` or `=`, one that
281
+ // can't be decoded or still holds an escape, a long token, and the one after a
282
+ // route that names a person or opens an order when it holds a digit or a token.
283
+ // Product codes and pages such as `/profile/edit` or `/orders/history` stay.
284
+ export function pageUrlPath(pathname: string): string {
285
+ const segments = pathname.split('/');
286
+ return segments
287
+ .map((segment, i) => {
288
+ const text = decodeFully(segment);
289
+ const route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
290
+ const holdsId =
291
+ !!text && (/[0-9]/.test(text) || isTokenSegment(text));
292
+ // A route word that can't be decoded may be any route.
293
+ const hiddenByRoute =
294
+ i > 0 &&
295
+ !!text &&
296
+ (route === undefined || (hidesNextSegment(route) && holdsId));
297
+ return text === undefined ||
298
+ /[@;=/]/.test(text) ||
299
+ LEFTOVER_ESCAPE.test(text) ||
300
+ isLongToken(text) ||
301
+ hiddenByRoute
302
+ ? '*'
303
+ : segment;
304
+ })
305
+ .join('/');
306
+ }