diff --git a/integrationExamples/gpt/topics_frame.html b/integrationExamples/gpt/topics_frame.html new file mode 100644 index 00000000000..7a12b030a6a --- /dev/null +++ b/integrationExamples/gpt/topics_frame.html @@ -0,0 +1,43 @@ + + + + Topics demo + + + + + + + + + \ No newline at end of file diff --git a/modules/topicsFpdModule.js b/modules/topicsFpdModule.js index dbd1d3e90e9..f5a3c0a2c70 100644 --- a/modules/topicsFpdModule.js +++ b/modules/topicsFpdModule.js @@ -1,8 +1,31 @@ -import {logError, logWarn, mergeDeep} from '../src/utils.js'; +import {logError, logWarn, mergeDeep, isEmpty, safeJSONParse, logInfo, hasDeviceAccess} from '../src/utils.js'; import {getRefererInfo} from '../src/refererDetection.js'; import {submodule} from '../src/hook.js'; import {GreedyPromise} from '../src/utils/promise.js'; +import {config} from '../src/config.js'; +import {getCoreStorageManager} from '../src/storageManager.js'; +import {includes} from '../src/polyfill.js'; +import {gdprDataHandler} from '../src/adapterManager.js'; +const MODULE_NAME = 'topicsFpd'; +const DEFAULT_EXPIRATION_DAYS = 21; +const TCF_REQUIRED_PURPOSES = ['1', '2', '3', '4']; +let HAS_GDPR_CONSENT = true; +let LOAD_TOPICS_INITIALISE = false; +const HAS_DEVICE_ACCESS = hasDeviceAccess(); + +const bidderIframeList = { + maxTopicCaller: 1, + bidders: [{ + bidder: 'pubmatic', + iframeURL: 'https://ads.pubmatic.com/AdServer/js/topics/topics_frame.html' + }] +} +export const coreStorage = getCoreStorageManager(MODULE_NAME); +export const topicStorageName = 'prebid:topics'; +export const lastUpdated = 'lastUpdated'; + +const iframeLoadedURL = []; const TAXONOMIES = { // map from topic taxonomyVersion to IAB segment taxonomy '1': 600 @@ -17,6 +40,20 @@ function partitionBy(field, items) { }, {}); } +/** + * function to get list of loaded Iframes calling Topics API + */ +function getLoadedIframeURL() { + return iframeLoadedURL; +} + +/** + * function to set/push iframe in the list which is loaded to called topics API. + */ +function setLoadedIframeURL(url) { + return iframeLoadedURL.push(url); +} + export function getTopicsData(name, topics, taxonomies = TAXONOMIES) { return Object.entries(partitionBy('taxonomyVersion', topics)) .filter(([taxonomyVersion]) => { @@ -61,7 +98,12 @@ export function getTopics(doc = document) { const topicsData = getTopics().then((topics) => getTopicsData(getRefererInfo().domain, topics)); export function processFpd(config, {global}, {data = topicsData} = {}) { + if (!LOAD_TOPICS_INITIALISE) { + loadTopicsForBidders(); + LOAD_TOPICS_INITIALISE = true; + } return data.then((data) => { + data = [].concat(data, getCachedTopics()); // Add cached data in FPD data. if (data.length) { mergeDeep(global, { user: { @@ -73,6 +115,145 @@ export function processFpd(config, {global}, {data = topicsData} = {}) { }); } +/** + * function to fetch the cached topic data from storage for bidders and return it + */ +export function getCachedTopics() { + let cachedTopicData = []; + if (!HAS_GDPR_CONSENT || !HAS_DEVICE_ACCESS) { + return cachedTopicData; + } + const topics = config.getConfig('userSync.topics') || bidderIframeList; + const bidderList = topics.bidders || []; + let storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName))); + storedSegments && storedSegments.forEach((value, cachedBidder) => { + // Check bidder exist in config for cached bidder data and then only retrieve the cached data + let bidderConfigObj = bidderList.find(({bidder}) => cachedBidder == bidder) + if (bidderConfigObj) { + if (!isCachedDataExpired(value[lastUpdated], bidderConfigObj?.expiry || DEFAULT_EXPIRATION_DAYS)) { + Object.keys(value).forEach((segData) => { + segData != lastUpdated && cachedTopicData.push(value[segData]); + }) + } else { + // delete the specific bidder map from the store and store the updated maps + storedSegments.delete(cachedBidder); + coreStorage.setDataInLocalStorage(topicStorageName, JSON.stringify([...storedSegments])); + } + } + }); + return cachedTopicData; +} + +/** + * Recieve messages from iframe loaded for bidders to fetch topic + * @param {MessageEvent} evt + */ +export function receiveMessage(evt) { + if (evt && evt.data) { + try { + let data = safeJSONParse(evt.data); + if (includes(getLoadedIframeURL(), evt.origin) && data && data.segment && !isEmpty(data.segment.topics)) { + const {domain, topics, bidder} = data.segment; + const iframeTopicsData = getTopicsData(domain, topics)[0]; + iframeTopicsData && storeInLocalStorage(bidder, iframeTopicsData); + } + } catch (err) { } + } +} + +/** +Function to store Topics data recieved from iframe in storage(name: "prebid:topics") +* @param {Topics} topics +*/ +export function storeInLocalStorage(bidder, topics) { + const storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName))); + if (storedSegments.has(bidder)) { + storedSegments.get(bidder)[topics['ext']['segclass']] = topics; + storedSegments.get(bidder)[lastUpdated] = new Date().getTime(); + storedSegments.set(bidder, storedSegments.get(bidder)); + } else { + storedSegments.set(bidder, {[topics.ext.segclass]: topics, [lastUpdated]: new Date().getTime()}) + } + coreStorage.setDataInLocalStorage(topicStorageName, JSON.stringify([...storedSegments])); +} + +function isCachedDataExpired(storedTime, cacheTime) { + const _MS_PER_DAY = 1000 * 60 * 60 * 24; + const currentTime = new Date().getTime(); + const daysDifference = Math.ceil((currentTime - storedTime) / _MS_PER_DAY); + return daysDifference > cacheTime; +} + +/** +* Function to get random bidders based on count passed with array of bidders +**/ +function getRandomBidders(arr, count) { + return ([...arr].sort(() => 0.5 - Math.random())).slice(0, count) +} + +/** + * function to add listener for message receiving from IFRAME + */ +function listenMessagesFromTopicIframe() { + window.addEventListener('message', receiveMessage, false); +} + +function checkTCFv2(vendorData, requiredPurposes = TCF_REQUIRED_PURPOSES) { + const {gdprApplies, purpose} = vendorData; + if (!gdprApplies || !purpose) { + return true; + } + return requiredPurposes.map((purposeNo) => { + const purposeConsent = purpose.consents ? purpose.consents[purposeNo] : false; + if (purposeConsent) { + return true; + } + return false; + }).reduce((a, b) => a && b, true); +} + +export function hasGDPRConsent() { + // Check for GDPR consent for purpose 1,2,3,4 and return false if consent has not been given + const gdprConsent = gdprDataHandler.getConsentData(); + const hasGdpr = (gdprConsent && typeof gdprConsent.gdprApplies === 'boolean' && gdprConsent.gdprApplies) ? 1 : 0; + const gdprConsentString = hasGdpr ? gdprConsent.consentString : ''; + if (hasGdpr) { + if ((!gdprConsentString || gdprConsentString === '') || !gdprConsent.vendorData) { + return false; + } + return checkTCFv2(gdprConsent.vendorData); + } + return true; +} + +/** + * function to load the iframes of the bidder to load the topics data + */ +function loadTopicsForBidders() { + HAS_GDPR_CONSENT = hasGDPRConsent(); + if (!HAS_GDPR_CONSENT || !HAS_DEVICE_ACCESS) { + logInfo('Topics Module : Consent string is required to fetch the topics from third party domains.'); + return; + } + const topics = config.getConfig('userSync.topics') || bidderIframeList; + if (topics) { + listenMessagesFromTopicIframe(); + const randomBidders = getRandomBidders(topics.bidders || [], topics.maxTopicCaller || 1) + randomBidders && randomBidders.forEach(({ bidder, iframeURL }) => { + if (bidder && iframeURL) { + let ifrm = document.createElement('iframe'); + ifrm.name = 'ifrm_'.concat(bidder); + ifrm.src = ''.concat(iframeURL, '?bidder=').concat(bidder); + ifrm.style.display = 'none'; + setLoadedIframeURL(new URL(iframeURL).origin); + iframeURL && window.document.documentElement.appendChild(ifrm); + } + }) + } else { + logWarn(`Topics config not defined under userSync Object`); + } +} + submodule('firstPartyData', { name: 'topics', queue: 1, diff --git a/test/spec/modules/topicsFpdModule_spec.js b/test/spec/modules/topicsFpdModule_spec.js index 3781768497b..69e7cc16ca8 100644 --- a/test/spec/modules/topicsFpdModule_spec.js +++ b/test/spec/modules/topicsFpdModule_spec.js @@ -1,5 +1,7 @@ -import {getTopics, getTopicsData, processFpd} from '../../../modules/topicsFpdModule.js'; -import {deepClone} from '../../../src/utils.js'; +import {getTopics, getTopicsData, processFpd, hasGDPRConsent, getCachedTopics, receiveMessage, topicStorageName} from '../../../modules/topicsFpdModule.js'; +import {deepClone, safeJSONParse} from '../../../src/utils.js'; +import {gdprDataHandler} from 'src/adapterManager.js'; +import {getCoreStorageManager} from 'src/storageManager.js'; describe('getTopicsData', () => { function makeTopic(topic, modelv, taxv = '1') { @@ -237,3 +239,175 @@ describe('processFpd', () => { }); }); }); + +describe('Topics Module GDPR consent check', () => { + let gdprDataHdlrStub; + beforeEach(() => { + gdprDataHdlrStub = sinon.stub(gdprDataHandler, 'getConsentData'); + }) + + afterEach(() => { + gdprDataHdlrStub.restore(); + }); + + it('should return false when GDPR is applied but consent string is not present', () => { + const consentString = ''; + const consentConfig = { + consentString: consentString, + gdprApplies: true, + vendorData: {} + }; + gdprDataHdlrStub.returns(consentConfig); + expect(hasGDPRConsent()).to.equal(false); + }); + + it("should return true when GDPR doesn't apply", () => { + const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D=='; + const consentConfig = { + consentString: consentString, + gdprApplies: false, + vendorData: {} + }; + + gdprDataHdlrStub.returns(consentConfig); + expect(hasGDPRConsent()).to.equal(true); + }); + + it('should return true when GDPR is applied and purpose consent is true for all purpose[1,2,3,4]', () => { + const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D=='; + const consentConfig = { + consentString: consentString, + gdprApplies: true, + vendorData: { + metadata: consentString, + gdprApplies: true, + purpose: { + consents: { + 1: true, + 2: true, + 3: true, + 4: true + } + } + } + }; + + gdprDataHdlrStub.returns(consentConfig); + expect(hasGDPRConsent()).to.equal(true); + }); + + it('should return false when GDPR is applied and purpose consent is false for one of the purpose[1,2,3,4]', () => { + const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D=='; + const consentConfig = { + consentString: consentString, + gdprApplies: true, + vendorData: { + metadata: consentString, + gdprApplies: true, + purpose: { + consents: { + 1: true, + 2: true, + 3: true, + 4: false + } + } + } + }; + + gdprDataHdlrStub.returns(consentConfig); + expect(hasGDPRConsent()).to.equal(false); + }); +}); + +describe('getCachedTopics()', () => { + const storage = getCoreStorageManager('topicsFpd'); + const expected = [{ + ext: { + segtax: 600, + segclass: '2206021246' + }, + segment: [{ + 'id': '243' + }, { + 'id': '265' + }], + name: 'ads.pubmatic.com' + }]; + const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D=='; + const consentConfig = { + consentString: consentString, + gdprApplies: true, + vendorData: { + metadata: consentString, + gdprApplies: true, + purpose: { + consents: { + 1: true, + 2: true, + 3: true, + 4: true + } + } + } + }; + const mockData = [ + { + name: 'domain', + segment: [{id: 123}] + }, + { + name: 'domain', + segment: [{id: 321}], + } + ]; + + const evt = { + data: '{"segment":{"domain":"ads.pubmatic.com","topics":[{"configVersion":"chrome.1","modelVersion":"2206021246","taxonomyVersion":"1","topic":165,"version":"chrome.1:1:2206021246"}],"bidder":"pubmatic"},"date":1669743901858}', + origin: 'https://ads.pubmatic.com' + } + + let gdprDataHdlrStub; + beforeEach(() => { + gdprDataHdlrStub = sinon.stub(gdprDataHandler, 'getConsentData'); + }); + + afterEach(() => { + storage.removeDataFromLocalStorage(topicStorageName); + gdprDataHdlrStub.restore(); + }); + + it('should return segments for bidder if GDPR consent is true and there is cached segments stored which is not expired', () => { + let storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1669719242027}]]'; + storage.setDataInLocalStorage(topicStorageName, storedSegments); + gdprDataHdlrStub.returns(consentConfig); + assert.deepEqual(getCachedTopics(), expected); + }); + + it('should return empty segments for bidder if GDPR consent is true and there is cached segments stored which is expired', () => { + let storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1659719242027}]]'; + storage.setDataInLocalStorage(topicStorageName, storedSegments); + gdprDataHdlrStub.returns(consentConfig); + assert.deepEqual(getCachedTopics(), []); + }); + + it('should stored segments if receiveMessage event is triggerred with segment data', () => { + return processFpd({}, {global: {}}, {data: Promise.resolve(mockData)}) + .then(({global}) => { + receiveMessage(evt) + let segments = new Map(safeJSONParse(storage.getDataFromLocalStorage(topicStorageName))); + expect(segments.has('pubmatic')).to.equal(true); + }); + }); + + it('should update stored segments if receiveMessage event is triggerred with segment data', () => { + let storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1669719242027}]]'; + storage.setDataInLocalStorage(topicStorageName, storedSegments); + return processFpd({}, {global: {}}, {data: Promise.resolve(mockData)}) + .then(({global}) => { + receiveMessage(evt); + let segments = new Map(safeJSONParse(storage.getDataFromLocalStorage(topicStorageName))); + expect(segments.get('pubmatic')[2206021246].segment.length).to.equal(1); + }); + }); +})