prebid/Prebid.js

View on GitHub
modules/connectIdSystem.js

Summary

Maintainability
D
2 days
Test Coverage
/**
 * This module adds support for Yahoo ConnectID to the user ID module system.
 * The {@link module:modules/userId} module is required
 * @module modules/connectIdSystem
 * @requires module:modules/userId
 */

import {ajax} from '../src/ajax.js';
import {submodule} from '../src/hook.js';
import {includes} from '../src/polyfill.js';
import {getRefererInfo} from '../src/refererDetection.js';
import {getStorageManager} from '../src/storageManager.js';
import {formatQS, isNumber, isPlainObject, logError, parseUrl} from '../src/utils.js';
import {uspDataHandler, gppDataHandler} from '../src/adapterManager.js';
import {MODULE_TYPE_UID} from '../src/activities/modules.js';

/**
 * @typedef {import('../modules/userId/index.js').Submodule} Submodule
 * @typedef {import('../modules/userId/index.js').SubmoduleConfig} SubmoduleConfig
 * @typedef {import('../modules/userId/index.js').ConsentData} ConsentData
 * @typedef {import('../modules/userId/index.js').IdResponse} IdResponse
 */

const MODULE_NAME = 'connectId';
const STORAGE_EXPIRY_DAYS = 365;
const STORAGE_DURATION = 60 * 60 * 24 * 1000 * STORAGE_EXPIRY_DAYS;
const ID_EXPIRY_DAYS = 14;
const VALID_ID_DURATION = 60 * 60 * 24 * 1000 * ID_EXPIRY_DAYS;
const PUID_EXPIRY_DAYS = 30;
const PUID_EXPIRY = 60 * 60 * 24 * 1000 * PUID_EXPIRY_DAYS;
const VENDOR_ID = 25;
const PLACEHOLDER = '__PIXEL_ID__';
const UPS_ENDPOINT = `https://ups.analytics.yahoo.com/ups/${PLACEHOLDER}/fed`;
const OVERRIDE_OPT_OUT_KEY = 'connectIdOptOut';
const INPUT_PARAM_KEYS = ['pixelId', 'he', 'puid'];
const O_AND_O_DOMAINS = [
  'yahoo.com',
  'aol.com',
  'aol.ca',
  'aol.de',
  'aol.co.uk',
  'engadget.com',
  'techcrunch.com',
  'autoblog.com',
];
export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME});

/**
 * @function
 * @param {Object} obj
 */
function storeObject(obj) {
  const expires = Date.now() + STORAGE_DURATION;
  if (storage.cookiesAreEnabled()) {
    setEtldPlusOneCookie(MODULE_NAME, JSON.stringify(obj), new Date(expires), getSiteHostname());
  }
  if (storage.localStorageIsEnabled()) {
    storage.setDataInLocalStorage(MODULE_NAME, JSON.stringify(obj));
  }
}

/**
 * Attempts to store a cookie on eTLD + 1
 *
 * @function
 * @param {String} key
 * @param {String} value
 * @param {Date} expirationDate
 * @param {String} hostname
 */
function setEtldPlusOneCookie(key, value, expirationDate, hostname) {
  const subDomains = hostname.split('.');
  for (let i = 0; i < subDomains.length; ++i) {
    const domain = subDomains.slice(subDomains.length - i - 1, subDomains.length).join('.');
    try {
      storage.setCookie(key, value, expirationDate.toUTCString(), null, '.' + domain);
      const storedCookie = storage.getCookie(key);
      if (storedCookie && storedCookie === value) {
        break;
      }
    } catch (error) {}
  }
}

function getIdFromCookie() {
  if (storage.cookiesAreEnabled()) {
    try {
      return JSON.parse(storage.getCookie(MODULE_NAME));
    } catch {}
  }
  return null;
}

function getIdFromLocalStorage() {
  if (storage.localStorageIsEnabled()) {
    let storedIdData = storage.getDataFromLocalStorage(MODULE_NAME);
    if (storedIdData) {
      try {
        storedIdData = JSON.parse(storedIdData);
      } catch (e) {
        logError(`${MODULE_NAME} module: error while reading the local storage data.`);
      }
      if (isPlainObject(storedIdData) && storedIdData.__expires &&
          storedIdData.__expires <= Date.now()) {
        storage.removeDataFromLocalStorage(MODULE_NAME);
        return null;
      }
      return storedIdData;
    }
  }
  return null;
}

function syncLocalStorageToCookie() {
  if (!storage.cookiesAreEnabled()) {
    return;
  }
  const value = getIdFromLocalStorage();
  const newCookieExpireTime = Date.now() + STORAGE_DURATION;
  setEtldPlusOneCookie(MODULE_NAME, JSON.stringify(value), new Date(newCookieExpireTime), getSiteHostname());
}

function isStale(storedIdData) {
  if (isOAndOTraffic()) {
    return true;
  } else if (isPlainObject(storedIdData) && storedIdData.lastSynced) {
    const validTTL = storedIdData.ttl || VALID_ID_DURATION;
    return storedIdData.lastSynced + validTTL <= Date.now();
  }
  return false;
}

function getStoredId() {
  let storedId = getIdFromCookie();
  if (!storedId) {
    storedId = getIdFromLocalStorage();
    if (storedId && !isStale(storedId)) {
      syncLocalStorageToCookie();
    }
  }
  return storedId;
}

function getSiteHostname() {
  const pageInfo = parseUrl(getRefererInfo().page);
  return pageInfo.hostname;
}

function isOAndOTraffic() {
  let referer = getRefererInfo().ref;

  if (referer) {
    referer = parseUrl(referer).hostname;
    const subDomains = referer.split('.');
    referer = subDomains.slice(subDomains.length - 2, subDomains.length).join('.');
  }
  return O_AND_O_DOMAINS.indexOf(referer) >= 0;
}

/** @type {Submodule} */
export const connectIdSubmodule = {
  /**
   * used to link submodule with config
   * @type {string}
   */
  name: MODULE_NAME,
  /**
   * @type {Number}
   */
  gvlid: VENDOR_ID,
  /**
   * decode the stored id value for passing to bid requests
   * @function
   * @returns {{connectId: string} | undefined}
   */
  decode(value) {
    if (connectIdSubmodule.userHasOptedOut()) {
      return undefined;
    }
    return (isPlainObject(value) && (value.connectId || value.connectid))
      ? {connectId: value.connectId || value.connectid} : undefined;
  },
  /**
   * Gets the Yahoo ConnectID
   * @function
   * @param {SubmoduleConfig} [config]
   * @param {ConsentData} [consentData]
   * @returns {IdResponse|undefined}
   */
  getId(config, consentData) {
    if (connectIdSubmodule.userHasOptedOut()) {
      return;
    }
    const params = config.params || {};
    if (!params ||
        (typeof params.pixelId === 'undefined' && typeof params.endpoint === 'undefined')) {
      logError(`${MODULE_NAME} module: configuration requires the 'pixelId'.`);
      return;
    }

    const storedId = getStoredId();

    let shouldResync = isStale(storedId);

    if (storedId) {
      if (isPlainObject(storedId) && storedId.puid && storedId.lastUsed && !params.puid &&
        (storedId.lastUsed + PUID_EXPIRY) <= Date.now()) {
        delete storedId.puid;
        shouldResync = true;
      }
      if ((params.he && params.he !== storedId.he) ||
        (params.puid && params.puid !== storedId.puid)) {
        shouldResync = true;
      }
      if (!shouldResync) {
        storedId.lastUsed = Date.now();
        storeObject(storedId);
        return {id: storedId};
      }
    }

    const uspString = uspDataHandler.getConsentData() || '';
    const data = {
      v: '1',
      '1p': includes([1, '1', true], params['1p']) ? '1' : '0',
      gdpr: connectIdSubmodule.isEUConsentRequired(consentData) ? '1' : '0',
      gdpr_consent: connectIdSubmodule.isEUConsentRequired(consentData) ? consentData.consentString : '',
      us_privacy: uspString
    };

    const gppConsent = gppDataHandler.getConsentData();
    if (gppConsent) {
      data.gpp = `${gppConsent.gppString ? gppConsent.gppString : ''}`;
      if (Array.isArray(gppConsent.applicableSections)) {
        data.gpp_sid = gppConsent.applicableSections.join(',');
      }
    }

    let topmostLocation = getRefererInfo().topmostLocation;
    if (typeof topmostLocation === 'string') {
      data.url = topmostLocation.split('?')[0];
    }

    INPUT_PARAM_KEYS.forEach(key => {
      if (typeof params[key] != 'undefined') {
        data[key] = params[key];
      }
    });

    const hashedEmail = params.he || storedId?.he;
    if (hashedEmail) {
      data.he = hashedEmail;
    }
    if (!data.puid && storedId?.puid) {
      data.puid = storedId.puid;
    }

    const resp = function (callback) {
      const callbacks = {
        success: response => {
          let responseObj;
          if (response) {
            try {
              responseObj = JSON.parse(response);
              if (isPlainObject(responseObj) && Object.keys(responseObj).length > 0 &&
                 (!!responseObj.connectId || !!responseObj.connectid)) {
                responseObj.he = params.he;
                responseObj.puid = params.puid || responseObj.puid;
                responseObj.lastSynced = Date.now();
                responseObj.lastUsed = Date.now();
                if (isNumber(responseObj.ttl)) {
                  let validTTLMiliseconds = responseObj.ttl * 60 * 60 * 1000;
                  if (validTTLMiliseconds > VALID_ID_DURATION) {
                    validTTLMiliseconds = VALID_ID_DURATION;
                  }
                  responseObj.ttl = validTTLMiliseconds;
                }
                storeObject(responseObj);
              } else {
                logError(`${MODULE_NAME} module: UPS response returned an invalid payload ${response}`);
              }
            } catch (error) {
              logError(error);
            }
          }
          callback(responseObj);
        },
        error: error => {
          logError(`${MODULE_NAME} module: ID fetch encountered an error`, error);
          callback();
        }
      };
      const endpoint = UPS_ENDPOINT.replace(PLACEHOLDER, params.pixelId);
      let url = `${params.endpoint || endpoint}?${formatQS(data)}`;
      connectIdSubmodule.getAjaxFn()(url, callbacks, null, {method: 'GET', withCredentials: true});
    };
    const result = {callback: resp};
    if (shouldResync && storedId) {
      result.id = storedId;
    }

    return result;
  },

  /**
   * Utility function that returns a boolean flag indicating if the opportunity
   * is subject to GDPR
   * @returns {Boolean}
   */
  isEUConsentRequired(consentData) {
    return !!(consentData?.gdprApplies);
  },

  /**
   * Utility function that returns a boolean flag indicating if the user
   * has opeted out via the Yahoo easy-opt-out mechanism.
   * @returns {Boolean}
   */
  userHasOptedOut() {
    try {
      return localStorage.getItem(OVERRIDE_OPT_OUT_KEY) === '1';
    } catch {
      return false;
    }
  },

  /**
   * Return the function used to perform XHR calls.
   * Utilised for each of testing.
   * @returns {Function}
   */
  getAjaxFn() {
    return ajax;
  },
  eids: {
    'connectId': {
      source: 'yahoo.com',
      atype: 3
    },
  }
};

submodule('userId', connectIdSubmodule);