prebid/Prebid.js

View on GitHub
modules/parrableIdSystem.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * This module adds Parrable to the User ID module
 * The {@link module:modules/userId} module is required
 * @module modules/parrableIdSystem
 * @requires module:modules/userId
 */

// ci trigger: 1

import {
  contains,
  deepClone,
  inIframe,
  isEmpty,
  isPlainObject,
  logError,
  logWarn,
  pick,
  timestamp
} from '../src/utils.js';
import {find} from '../src/polyfill.js';
import {ajax} from '../src/ajax.js';
import {submodule} from '../src/hook.js';
import {getRefererInfo} from '../src/refererDetection.js';
import {uspDataHandler} from '../src/adapterManager.js';
import {getStorageManager} from '../src/storageManager.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
 */

const PARRABLE_URL = 'https://h.parrable.com/prebid';
const PARRABLE_COOKIE_NAME = '_parrable_id';
const PARRABLE_GVLID = 928;
const LEGACY_ID_COOKIE_NAME = '_parrable_eid';
const LEGACY_OPTOUT_COOKIE_NAME = '_parrable_optout';
const ONE_YEAR_MS = 364 * 24 * 60 * 60 * 1000;
const EXPIRE_COOKIE_DATE = 'Thu, 01 Jan 1970 00:00:00 GMT';
const MODULE_NAME = 'parrableId';

const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME});

function getExpirationDate() {
  const oneYearFromNow = new Date(timestamp() + ONE_YEAR_MS);
  return oneYearFromNow.toGMTString();
}

function deserializeParrableId(parrableIdStr) {
  const parrableId = {};
  const values = parrableIdStr.split(',');

  values.forEach(function(value) {
    const pair = value.split(':');
    if (pair[0] === 'ccpaOptout' || pair[0] === 'ibaOptout') { // unpack a value of 0 or 1 as boolean
      parrableId[pair[0]] = Boolean(+pair[1]);
    } else if (!isNaN(pair[1])) { // convert to number if is a number
      parrableId[pair[0]] = +pair[1]
    } else {
      parrableId[pair[0]] = pair[1]
    }
  });

  return parrableId;
}

function serializeParrableId(parrableIdAndParams) {
  let components = [];

  if (parrableIdAndParams.eid) {
    components.push('eid:' + parrableIdAndParams.eid);
  }
  if (parrableIdAndParams.ibaOptout) {
    components.push('ibaOptout:1');
  }
  if (parrableIdAndParams.ccpaOptout) {
    components.push('ccpaOptout:1');
  }
  if (parrableIdAndParams.tpcSupport !== undefined) {
    const tpcSupportComponent = parrableIdAndParams.tpcSupport === true ? 'tpc:1' : 'tpc:0';
    const tpcUntil = `tpcUntil:${parrableIdAndParams.tpcUntil}`;
    components.push(tpcSupportComponent);
    components.push(tpcUntil);
  }
  if (parrableIdAndParams.filteredUntil) {
    components.push(`filteredUntil:${parrableIdAndParams.filteredUntil}`);
    components.push(`filterHits:${parrableIdAndParams.filterHits}`);
  }

  return components.join(',');
}

function isValidConfig(configParams) {
  if (!configParams) {
    logError('User ID - parrableId submodule requires configParams');
    return false;
  }
  if (!configParams.partners && !configParams.partner) {
    logError('User ID - parrableId submodule requires partner list');
    return false;
  }
  if (configParams.storage) {
    logWarn('User ID - parrableId submodule does not require a storage config');
  }
  return true;
}

function encodeBase64UrlSafe(base64) {
  const ENC = {
    '+': '-',
    '/': '_',
    '=': '.'
  };
  return base64.replace(/[+/=]/g, (m) => ENC[m]);
}

function readCookie() {
  const parrableIdStr = storage.getCookie(PARRABLE_COOKIE_NAME);
  if (parrableIdStr) {
    const parsedCookie = deserializeParrableId(decodeURIComponent(parrableIdStr));
    const { tpc, tpcUntil, filteredUntil, filterHits, ...parrableId } = parsedCookie;
    let { eid, ibaOptout, ccpaOptout, ...params } = parsedCookie;

    if ((Date.now() / 1000) >= tpcUntil) {
      params.tpc = undefined;
    }

    if ((Date.now() / 1000) < filteredUntil) {
      params.shouldFilter = true;
      params.filteredUntil = filteredUntil;
    } else {
      params.shouldFilter = false;
      params.filterHits = filterHits;
    }
    return { parrableId, params };
  }
  return null;
}

function writeCookie(parrableIdAndParams) {
  if (parrableIdAndParams) {
    const parrableIdStr = encodeURIComponent(serializeParrableId(parrableIdAndParams));
    storage.setCookie(PARRABLE_COOKIE_NAME, parrableIdStr, getExpirationDate(), 'lax');
  }
}

function readLegacyCookies() {
  const eid = storage.getCookie(LEGACY_ID_COOKIE_NAME);
  const ibaOptout = (storage.getCookie(LEGACY_OPTOUT_COOKIE_NAME) === 'true');
  if (eid || ibaOptout) {
    const parrableId = {};
    if (eid) {
      parrableId.eid = eid;
    }
    if (ibaOptout) {
      parrableId.ibaOptout = ibaOptout;
    }
    return parrableId;
  }
  return null;
}

function migrateLegacyCookies(parrableId) {
  if (parrableId) {
    writeCookie(parrableId);
    if (parrableId.eid) {
      storage.setCookie(LEGACY_ID_COOKIE_NAME, '', EXPIRE_COOKIE_DATE);
    }
    if (parrableId.ibaOptout) {
      storage.setCookie(LEGACY_OPTOUT_COOKIE_NAME, '', EXPIRE_COOKIE_DATE);
    }
  }
}

function shouldFilterImpression(configParams, parrableId) {
  const config = configParams.timezoneFilter;

  if (!config) {
    return false;
  }

  if (parrableId) {
    return false;
  }

  const offset = (new Date()).getTimezoneOffset() / 60;
  const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;

  function isZoneListed(list, zone) {
    // IE does not provide a timeZone in IANA format so zone will be empty
    const zoneLowercase = zone && zone.toLowerCase();
    return !!(list && zone && find(list, zn => zn.toLowerCase() === zoneLowercase));
  }

  function isAllowed() {
    if (isEmpty(config.allowedZones) &&
      isEmpty(config.allowedOffsets)) {
      return true;
    }
    if (isZoneListed(config.allowedZones, zone)) {
      return true;
    }
    if (contains(config.allowedOffsets, offset)) {
      return true;
    }
    return false;
  }

  function isBlocked() {
    if (isEmpty(config.blockedZones) &&
      isEmpty(config.blockedOffsets)) {
      return false;
    }
    if (isZoneListed(config.blockedZones, zone)) {
      return true;
    }
    if (contains(config.blockedOffsets, offset)) {
      return true;
    }
    return false;
  }

  return isBlocked() || !isAllowed();
}

function epochFromTtl(ttl) {
  return Math.floor((Date.now() / 1000) + ttl);
}

function incrementFilterHits(parrableId, params) {
  params.filterHits += 1;
  writeCookie({ ...parrableId, ...params })
}

function fetchId(configParams, gdprConsentData) {
  if (!isValidConfig(configParams)) return;

  let { parrableId, params } = readCookie() || {};
  if (!parrableId) {
    parrableId = readLegacyCookies();
    migrateLegacyCookies(parrableId);
  }

  if (shouldFilterImpression(configParams, parrableId)) {
    return null;
  }

  const eid = parrableId ? parrableId.eid : null;
  const refererInfo = getRefererInfo();
  const tpcSupport = params ? params.tpc : null;
  const shouldFilter = params ? params.shouldFilter : null;
  const uspString = uspDataHandler.getConsentData();
  const gdprApplies = (gdprConsentData && typeof gdprConsentData.gdprApplies === 'boolean' && gdprConsentData.gdprApplies);
  const gdprConsentString = (gdprConsentData && gdprApplies && gdprConsentData.consentString) || '';
  const partners = configParams.partners || configParams.partner;
  const trackers = typeof partners === 'string'
    ? partners.split(',')
    : partners;

  const data = {
    eid,
    trackers,
    url: refererInfo.page,
    prebidVersion: '$prebid.version$',
    isIframe: inIframe(),
    tpcSupport
  };

  if (shouldFilter === false) {
    data.filterHits = params.filterHits;
  }

  const searchParams = {
    data: encodeBase64UrlSafe(btoa(JSON.stringify(data))),
    gdpr: gdprApplies ? 1 : 0,
    _rand: Math.random()
  };

  if (uspString) {
    searchParams.us_privacy = uspString;
  }

  if (gdprApplies) {
    searchParams.gdpr_consent = gdprConsentString;
  }

  const options = {
    method: 'GET',
    withCredentials: true
  };

  const callback = function (cb) {
    const callbacks = {
      success: response => {
        let newParrableId = parrableId ? deepClone(parrableId) : {};
        let newParams = {};
        if (response) {
          try {
            let responseObj = JSON.parse(response);
            if (responseObj) {
              if (responseObj.ccpaOptout !== true) {
                newParrableId.eid = responseObj.eid;
              } else {
                newParrableId.eid = null;
                newParrableId.ccpaOptout = true;
              }
              if (responseObj.ibaOptout === true) {
                newParrableId.ibaOptout = true;
              }
              if (responseObj.tpcSupport !== undefined) {
                newParams.tpcSupport = responseObj.tpcSupport;
                newParams.tpcUntil = epochFromTtl(responseObj.tpcSupportTtl);
              }
              if (responseObj.filterTtl) {
                newParams.filteredUntil = epochFromTtl(responseObj.filterTtl);
                newParams.filterHits = 0;
              }
            }
          } catch (error) {
            logError(error);
            cb();
          }
          writeCookie({ ...newParrableId, ...newParams });
          cb(newParrableId);
        } else {
          logError('parrableId: ID fetch returned an empty result');
          cb();
        }
      },
      error: error => {
        logError(`parrableId: ID fetch encountered an error`, error);
        cb();
      }
    };

    if (shouldFilter) {
      incrementFilterHits(parrableId, params);
    } else {
      ajax(PARRABLE_URL, callbacks, searchParams, options);
    }
  };

  return {
    callback,
    id: parrableId
  };
}

/** @type {Submodule} */
export const parrableIdSubmodule = {
  /**
   * used to link submodule with config
   * @type {string}
   */
  name: MODULE_NAME,
  /**
   * Global Vendor List ID
   * @type {number}
   */
  gvlid: PARRABLE_GVLID,

  /**
   * decode the stored id value for passing to bid requests
   * @function
   * @param {ParrableId} parrableId
   * @return {(Object|undefined}
   */
  decode(parrableId) {
    if (parrableId && isPlainObject(parrableId)) {
      return { parrableId };
    }
    return undefined;
  },

  /**
   * performs action to obtain id and return a value in the callback's response argument
   * @function
   * @param {SubmoduleConfig} [config]
   * @param {ConsentData} [consentData]
   * @returns {function(callback:function), id:ParrableId}
   */
  getId(config, gdprConsentData, currentStoredId) {
    const configParams = (config && config.params) || {};
    return fetchId(configParams, gdprConsentData);
  },
  eids: {
    'parrableId': {
      source: 'parrable.com',
      atype: 1,
      getValue: function(parrableId) {
        if (parrableId.eid) {
          return parrableId.eid;
        }
        if (parrableId.ccpaOptout) {
          // If the EID was suppressed due to a non consenting ccpa optout then
          // we still wish to provide this as a reason to the adapters
          return '';
        }
        return null;
      },
      getUidExt: function(parrableId) {
        const extendedData = pick(parrableId, [
          'ibaOptout',
          'ccpaOptout'
        ]);
        if (Object.keys(extendedData).length) {
          return extendedData;
        }
      }
    },
  },
};

submodule('userId', parrableIdSubmodule);