From 10cf8c8ed4e318e6f9b34c6c6c19ee91e7e2ffed Mon Sep 17 00:00:00 2001 From: AJ Horst Date: Wed, 12 May 2021 16:31:25 -0700 Subject: [PATCH] fix(cookies): AMP-35904 dedup cookies (#390) * fix(cookie): add constants for cookie prop indexes * fix(cookie): dedup cookie on load * fix(cookie): make getLastEventTime more resilient * fix(cookie): fix event time sort * fix(cookie): clarify cookie constant comment * fix(cookie): clean up getAll method * fix(cookie): add unit tests for cookie parsing and sorting * fix(cookie): return empty array from getAll on exception * fix(cookie): remove duplicate test * fix(cookie): refactor getLastEventTime and add warning logs for malformed cookie Co-authored-by: AJ Horst --- src/base-cookie.js | 48 +++++++++++++++++++++++++++++++++++++++++ src/constants.js | 10 +++++++++ src/metadata-storage.js | 47 ++++++++++++++++++++++++++-------------- test/base-cookie.js | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/src/base-cookie.js b/src/base-cookie.js index fa7b9294..d57fb5dd 100644 --- a/src/base-cookie.js +++ b/src/base-cookie.js @@ -23,6 +23,25 @@ const get = (name) => { } }; +const getAll = (name) => { + try { + const cookieArray = document.cookie.split(';').map((c) => c.trimStart()); + let values = []; + for (let cookie of cookieArray) { + while (cookie.charAt(0) === ' ') { + cookie = cookie.substring(1); + } + if (cookie.indexOf(name) === 0) { + values.push(cookie.substring(name.length)); + } + } + + return values; + } catch (e) { + return []; + } +}; + const set = (name, value, opts) => { let expires = value !== null ? opts.expirationDays : -1; if (expires) { @@ -47,6 +66,32 @@ const set = (name, value, opts) => { document.cookie = str; }; +const getLastEventTime = (cookie = '') => { + const strValue = cookie.split('.')[Constants.LAST_EVENT_TIME_INDEX]; + + let parsedValue; + if (strValue) { + parsedValue = parseInt(strValue, 32); + } + + if (parsedValue) { + return parsedValue; + } else { + utils.log.warn(`unable to parse malformed cookie: ${cookie}`); + return 0; + } +}; + +const sortByEventTime = (cookies) => { + return [...cookies].sort((c1, c2) => { + const t1 = getLastEventTime(c1); + const t2 = getLastEventTime(c2); + // sort c1 first if its last event time is more recent + // i.e its event time integer is larger that c2's + return t2 - t1; + }); +}; + // test that cookies are enabled - navigator.cookiesEnabled yields false positives in IE, need to test directly const areCookiesEnabled = (opts = {}) => { const cookieName = Constants.COOKIE_TEST_PREFIX + base64Id(); @@ -68,5 +113,8 @@ const areCookiesEnabled = (opts = {}) => { export default { set, get, + getAll, + getLastEventTime, + sortByEventTime, areCookiesEnabled, }; diff --git a/src/constants.js b/src/constants.js index f732e2af..dd4f8cab 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,6 +18,16 @@ export default { OPT_OUT: 'amplitude_optOut', USER_ID: 'amplitude_userId', + // indexes of properties in cookie v2 storage format + DEVICE_ID_INDEX: 0, + USER_ID_INDEX: 1, + OPT_OUT_INDEX: 2, + SESSION_ID_INDEX: 3, + LAST_EVENT_TIME_INDEX: 4, + EVENT_ID_INDEX: 5, + IDENTIFY_ID_INDEX: 6, + SEQUENCE_NUMBER_INDEX: 7, + COOKIE_TEST_PREFIX: 'amp_cookie_test', COOKIE_PREFIX: 'amp', diff --git a/src/metadata-storage.js b/src/metadata-storage.js index e912d248..f83b15f8 100644 --- a/src/metadata-storage.js +++ b/src/metadata-storage.js @@ -96,20 +96,35 @@ class MetadataStorage { ampLocalStorage.setItem(this.storageKey, value); break; case Constants.STORAGE_COOKIES: - baseCookie.set(this.getCookieStorageKey(), value, { - domain: this.cookieDomain, - secure: this.secure, - sameSite: this.sameSite, - expirationDays: this.expirationDays, - }); + this.saveCookie(value); break; } } + saveCookie(value) { + baseCookie.set(this.getCookieStorageKey(), value, { + domain: this.cookieDomain, + secure: this.secure, + sameSite: this.sameSite, + expirationDays: this.expirationDays, + }); + } + load() { let str; if (this.storage === Constants.STORAGE_COOKIES) { - str = baseCookie.get(this.getCookieStorageKey() + '='); + const cookieKey = this.getCookieStorageKey() + '='; + const allCookies = baseCookie.getAll(cookieKey); + if (allCookies.length === 0 || allCookies.length === 1) { + str = allCookies[0]; + } else { + // dedup cookies by deleting them all and restoring + // the one with the most recent event time + const latestCookie = baseCookie.sortByEventTime(allCookies)[0]; + allCookies.forEach(() => baseCookie.set(this.getCookieStorageKey(), null, {})); + this.saveCookie(latestCookie); + str = baseCookie.get(cookieKey); + } } if (!str) { str = ampLocalStorage.getItem(this.storageKey); @@ -129,23 +144,23 @@ class MetadataStorage { const values = str.split('.'); let userId = null; - if (values[1]) { + if (values[Constants.USER_ID_INDEX]) { try { - userId = Base64.decode(values[1]); + userId = Base64.decode(values[Constants.USER_ID_INDEX]); } catch (e) { userId = null; } } return { - deviceId: values[0], + deviceId: values[Constants.DEVICE_ID_INDEX], userId, - optOut: values[2] === '1', - sessionId: parseInt(values[3], 32), - lastEventTime: parseInt(values[4], 32), - eventId: parseInt(values[5], 32), - identifyId: parseInt(values[6], 32), - sequenceNumber: parseInt(values[7], 32), + optOut: values[Constants.OPT_OUT_INDEX] === '1', + sessionId: parseInt(values[Constants.SESSION_ID_INDEX], 32), + lastEventTime: parseInt(values[Constants.LAST_EVENT_TIME_INDEX], 32), + eventId: parseInt(values[Constants.EVENT_ID_INDEX], 32), + identifyId: parseInt(values[Constants.IDENTIFY_ID_INDEX], 32), + sequenceNumber: parseInt(values[Constants.SEQUENCE_NUMBER_INDEX], 32), }; } } diff --git a/test/base-cookie.js b/test/base-cookie.js index e8ac6de3..42f416b7 100644 --- a/test/base-cookie.js +++ b/test/base-cookie.js @@ -105,5 +105,50 @@ describe('cookie', function () { spyLogWarning.restore(); }); }); + + describe('getLastEventTime tests', () => { + it('should return 0 if cookie is undefined', () => { + const cookieStr = undefined; + const lastEventTime = cookie.getLastEventTime(cookieStr); + assert.equal(lastEventTime, 0); + }); + + it('should return 0 if cookie is an empty string', () => { + const cookieStr = ''; + const lastEventTime = cookie.getLastEventTime(cookieStr); + assert.equal(lastEventTime, 0); + }); + + it('should return 0 if cookie is a malformed cookie', () => { + const cookieStr = 'asdfasdfasdfasdf'; + const lastEventTime = cookie.getLastEventTime(cookieStr); + assert.equal(lastEventTime, 0); + }); + + it('should return a number thats base 32 encoded and put into the amplitude cookie format', () => { + const originalTime = 1620698180822; + const cookieStr = `....${originalTime.toString(32)}...`; + const lastEventTime = cookie.getLastEventTime(cookieStr); + assert.equal(lastEventTime, originalTime); + }); + }); + + describe('sortByEventTime tests', () => { + it('should sort cookies by last event time from greatest to least', () => { + const firstTime = 10; + const secondTime = 20; + const thirdTime = 30; + const invalidTime = ''; + + const cookieArray = [secondTime, invalidTime, thirdTime, firstTime].map((t) => `....${t.toString(32)}...`); + const sortedCookieArray = cookie.sortByEventTime(cookieArray); + + assert.notEqual(cookieArray, sortedCookieArray); // returns a shallow copy, not the same array + assert.equal(sortedCookieArray[0], cookieArray[2]); // third time + assert.equal(sortedCookieArray[1], cookieArray[0]); // second time + assert.equal(sortedCookieArray[2], cookieArray[3]); // first time + assert.equal(sortedCookieArray[3], cookieArray[1]); // invalid time + }); + }); }); });