diff --git a/modules/nextrollBidAdapter.js b/modules/nextrollBidAdapter.js new file mode 100644 index 00000000000..d371448e195 --- /dev/null +++ b/modules/nextrollBidAdapter.js @@ -0,0 +1,305 @@ +import * as utils from '../src/utils'; +import { registerBidder } from '../src/adapters/bidderFactory'; +import { BANNER } from '../src/mediaTypes'; +import { loadExternalScript } from '../src/adloader'; +import JSEncrypt from 'jsencrypt/bin/jsencrypt'; +import sha256 from 'crypto-js/sha256'; + +const BIDDER_CODE = 'nextroll'; +const BIDDER_ENDPOINT = 'https://d.adroll.com/bid/prebid/'; +const PUBTAG_URL = 'https://s.adroll.com/prebid/pubtag.min.js'; +const MAX_PUBTAG_AGE_IN_DAYS = 3; +const ADAPTER_VERSION = 3; + +export const PUBTAG_PUBKEY = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/TZ6Gpm7gYg0j6o8LK+sKfYsl ++Z3vY2flsA/KFllKyXKTTtC2nJSJlSTuNToIcXnW+2L3Q2V3yM8VExfhCtVg5oZd +YEe1TfPmu7UyGP4rCJM3wD7Z3+3XPy4pWWiTvGhHOO0bdT9JfwaezJYObJBcfkpK +PX0z1E1oDVf6nJT7rwIDAQAB +-----END PUBLIC KEY-----`; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + + /** + * Determines whether or not the given bid request is valid. + * + * @param {BidRequest} bid The bid params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + isBidRequestValid: function (bidRequest) { + return bidRequest !== undefined && !!bidRequest.params && !!bidRequest.bidId; + }, + + /** + * Make a server request from the list of BidRequests. + * + * @param {validBidRequests[]} - an array of bids + * @return ServerRequest Info describing the request to the server. + */ + buildRequests: function (validBidRequests, bidderRequest) { + if (!pubtagAvailable()) { + tryGetPubtag(); + + setTimeout(() => { + loadExternalScript(PUBTAG_URL, BIDDER_CODE); + }, bidderRequest.timeout); + } + + if (pubtagAvailable()) { + const adapter = new window.NextRoll.Adapters.Prebid(ADAPTER_VERSION); + return adapter.buildRequests(validBidRequests, bidderRequest); + } + return _buildRequests(validBidRequests, bidderRequest); + }, + + /** + * Unpack the response from the server into a list of bids. + * + * @param {ServerResponse} serverResponse A successful response from the server. + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse: function (serverResponse, bidRequest) { + if (pubtagAvailable()) { + return window.NextRoll.Adapters.Prebid.interpretResponse(serverResponse, bidRequest); + } + return _interpretResponse(serverResponse, bidRequest); + }, + + /** + * Register the user sync pixels which should be dropped after the auction. + * + * @param {SyncOptions} syncOptions Which user syncs are allowed? + * @param {ServerResponse[]} serverResponses List of server's responses. + * @return {UserSync[]} The user syncs which should be dropped. + */ + getUserSyncs: function (syncOptions, serverResponses, gdprConsent) { + if (pubtagAvailable()) { + return window.NextRoll.Adapters.Prebid.getUserSyncs(syncOptions, serverResponses, gdprConsent); + } + return []; + } +} + +function _buildRequests(validBidRequests, bidderRequest) { + let topLocation = _parseUrl(utils.deepAccess(bidderRequest, 'refererInfo.referer')); + return validBidRequests.map((bidRequest, index) => { + return { + method: 'POST', + url: BIDDER_ENDPOINT, + data: { + id: bidRequest.bidId, + imp: { + id: bidRequest.bidId, + bidfloor: utils.getBidIdParameter('bidfloor', bidRequest.params), + banner: { + format: _getSizes(bidRequest) + }, + ext: { + zone: { + id: utils.getBidIdParameter('zoneId', bidRequest.params) + } + } + }, + + user: _getUser(validBidRequests), + site: _getSite(bidRequest, topLocation), + seller: _getSeller(bidRequest), + device: _getDevice(bidRequest), + } + } + }) +} + +function _getUser(requests) { + let id = utils.deepAccess(requests, '0.userId.nextroll'); + if (id === undefined) { + return + } + + return { + ext: { + eid: [{ + 'source': 'nextroll', + id + }] + } + } +} + +function _interpretResponse(serverResponse, _bidRequest) { + if (!serverResponse.body) { + return []; + } else { + let response = serverResponse.body + let bids = response.seatbid.reduce((acc, seatbid) => acc.concat(seatbid.bid), []); + return bids.map((bid) => _buildResponse(response, bid)); + } +} + +function _buildResponse(bidResponse, bid) { + const adm = utils.replaceAuctionPrice(bid.adm, bid.price); + return { + requestId: bidResponse.id, + cpm: bid.price, + width: bid.w, + height: bid.h, + creativeId: bid.crid, + dealId: bidResponse.dealId, + currency: 'USD', + netRevenue: true, + ttl: 300, + ad: adm + } +} + +function pubtagAvailable() { + let NextRoll = window.NextRoll + return typeof NextRoll !== 'undefined' && NextRoll.Adapters && NextRoll.Adapters.Prebid; +} + +function _getSite(bidRequest, topLocation) { + return { + page: topLocation.href, + domain: topLocation.hostname, + publisher: { + id: utils.getBidIdParameter('publisherId', bidRequest.params) + } + } +} + +function _getSeller(bidRequest) { + return { + id: utils.getBidIdParameter('sellerId', bidRequest.params) + } +} + +function _getSizes(bidRequest) { + return bidRequest.sizes.filter(_isValidSize).map(size => { + return { + w: size[0], + h: size[1] + } + }) +} + +function _isValidSize(size) { + const isNumber = x => typeof x === 'number'; + return (size.length === 2 && isNumber(size[0]) && isNumber(size[1])); +} + +function _getDevice(_bidRequest) { + return { + ua: navigator.userAgent, + language: navigator['language'], + ip: '', + os: _getOs(navigator.userAgent.toLowerCase()), + osv: _getOsVersion(navigator.userAgent) + } +} + +function _getOs(userAgent) { + const osTable = { + 'android': /android/i, + 'ios': /iphone|ipad/i, + 'mac': /mac/i, + 'linux': /linux/i, + 'windows': /windows/i + }; + + return Object.keys(osTable).find(os => { + if (userAgent.match(osTable[os])) { + return os; + } + }) || 'etc'; +} + +function _getOsVersion(userAgent) { + let clientStrings = [ + { s: 'Android', r: /Android/ }, + { s: 'iOS', r: /(iPhone|iPad|iPod)/ }, + { s: 'Mac OS X', r: /Mac OS X/ }, + { s: 'Mac OS', r: /(MacPPC|MacIntel|Mac_PowerPC|Macintosh)/ }, + { s: 'Linux', r: /(Linux|X11)/ }, + { s: 'Windows 10', r: /(Windows 10.0|Windows NT 10.0)/ }, + { s: 'Windows 8.1', r: /(Windows 8.1|Windows NT 6.3)/ }, + { s: 'Windows 8', r: /(Windows 8|Windows NT 6.2)/ }, + { s: 'Windows 7', r: /(Windows 7|Windows NT 6.1)/ }, + { s: 'Windows Vista', r: /Windows NT 6.0/ }, + { s: 'Windows Server 2003', r: /Windows NT 5.2/ }, + { s: 'Windows XP', r: /(Windows NT 5.1|Windows XP)/ }, + { s: 'UNIX', r: /UNIX/ }, + { s: 'Search Bot', r: /(nuhk|Googlebot|Yammybot|Openbot|Slurp|MSNBot|Ask Jeeves\/Teoma|ia_archiver)/ } + ]; + let cs = clientStrings.find(cs => cs.r.test(userAgent)); + return cs ? cs.s : 'unknown'; +} + +function _parseUrl(url) { + let parsed = document.createElement('a'); + parsed.href = url; + return { + href: parsed.href, + hostname: parsed.hostname + }; +} + +/** + * @return {boolean} + */ +function tryGetPubtag() { + const pubtagStorageKey = 'nextroll_fast_bid'; + const dateSuffix = '_set_date'; + const hashPrefix = '// Hash: '; + + let pubtagFromStorage = null; + let pubtagAge = null; + + try { + pubtagFromStorage = localStorage.getItem(pubtagStorageKey); + pubtagAge = localStorage.getItem(pubtagStorageKey + dateSuffix); + } catch (e) { + return; + } + + if (pubtagStorageKey === null || pubtagAge === null || isPubtagTooOld(pubtagAge)) { + return; + } + + // The value stored must contain the file's encrypted hash as first line + const firstLineEndPosition = pubtagFromStorage.indexOf('\n'); + const firstLine = pubtagFromStorage.substr(0, firstLineEndPosition).trim(); + + if (firstLine.substr(0, hashPrefix.length) !== hashPrefix) { + utils.logWarn('No hash found in Pubtag'); + localStorage.removeItem(pubtagStorageKey); + } else { + // Remove the hash part from the locally stored value + const publisherTagHash = firstLine.substr(hashPrefix.length); + const publisherTag = pubtagFromStorage.substr(firstLineEndPosition + 1); + + var jsEncrypt = new JSEncrypt(); + jsEncrypt.setPublicKey(PUBTAG_PUBKEY); + if (jsEncrypt.verify(publisherTag, publisherTagHash, sha256)) { + utils.logInfo('Using NextRoll Pubtag'); + eval(publisherTag); // eslint-disable-line no-eval + } else { + utils.logWarn('Invalid NextRoll Pubtag found'); + localStorage.removeItem(pubtagStorageKey); + } + } +} + +function isPubtagTooOld(pubtagAge) { + const currentDate = (new Date()).getTime(); + const ptSetDate = parseInt(pubtagAge); + const maxAgeMs = MAX_PUBTAG_AGE_IN_DAYS * 1000 * 60 * 60 * 24; + + if (currentDate - ptSetDate > maxAgeMs) { + return true + } + return false +} + +registerBidder(spec); diff --git a/modules/nextrollBidAdapter.md b/modules/nextrollBidAdapter.md new file mode 100644 index 00000000000..11ac2d9dc4b --- /dev/null +++ b/modules/nextrollBidAdapter.md @@ -0,0 +1,48 @@ +# Overview + +``` +Module Name: NextRoll Bid Adapter +Module Type: Bidder Adapter +Maintainer: +``` + +# Description + +Module that connects to NextRoll's bidders. +The NextRoll bid adapter supports Banner format only. + +# Test Parameters +``` javascript +var adunits = [ + { + code: 'div-1', + mediatypes: { + banner: {sizes: [[300, 250], [160, 600]]} + }, + bids: [{ + bidder: 'nextroll', + params: { + bidfloor: 1, + zoneid: 13144370, + publisherid: "publisherid", + } + }] + }, + { + code: 'div-2', + mediatypes: { + banner: { + sizes: [[728, 90], [970, 250]] + } + }, + bids: [{ + bidder: 'nextroll', + params: { + bidfloor: 2.3, + zoneid: 13144370, + publisherid: "publisherid", + } + }] + } +] +``` \ No newline at end of file diff --git a/src/adloader.js b/src/adloader.js index 22bfe0ef4f2..90aec05a7f7 100644 --- a/src/adloader.js +++ b/src/adloader.js @@ -7,7 +7,8 @@ const _approvedLoadExternalJSList = [ 'criteo', 'outstream', 'adagio', - 'browsi' + 'browsi', + 'nextroll' ] /** diff --git a/test/spec/modules/nextrollBidAdapter_spec.js b/test/spec/modules/nextrollBidAdapter_spec.js new file mode 100644 index 00000000000..e502face1f0 --- /dev/null +++ b/test/spec/modules/nextrollBidAdapter_spec.js @@ -0,0 +1,137 @@ +import { expect } from 'chai'; +import { spec } from 'modules/nextrollBidAdapter'; + +describe('nextrollBidAdapter', function() { + let validBid = { + bidder: 'nextroll', + adUnitCode: 'adunit-code', + bidId: 'bid_id', + sizes: [[300, 200]], + params: { + ip: 'ip', + bidfloor: 1, + sizes: [[300, 200]], + zoneId: 'zone1', + publisherId: 'publisher_id' + } + }; + let bidWithoutValidId = { id: '' }; + let bidWithoutId = { params: { zoneId: 'zone1' } }; + + describe('isBidRequestValid', function() { + it('validates the bids correctly when the bid has an id', function() { + expect(spec.isBidRequestValid(validBid)).to.be.true; + }); + + it('validates the bids correcly when the bid does not have an id', function() { + expect(spec.isBidRequestValid(bidWithoutValidId)).to.be.false; + expect(spec.isBidRequestValid(bidWithoutId)).to.be.false; + }); + }); + + describe('buildRequests', function() { + it('builds the same amount of requests as valid requests it takes', function() { + expect(spec.buildRequests([validBid, validBid], {})).to.be.lengthOf(2); + }); + + it('doest not build a request when there is no valid requests', function () { + expect(spec.buildRequests([], {})).to.be.lengthOf(0); + }); + + it('builds a request with POST method', function () { + expect(spec.buildRequests([validBid], {})[0].method).to.equal('POST'); + }); + + it('builds a request with id, url and imp object', function () { + const request = spec.buildRequests([validBid], {})[0]; + expect(request.data.id).to.be.an('string').that.is.not.empty; + expect(request.url).to.equal('https://d.adroll.com/bid/prebid/'); + expect(request.data.imp).to.exist.and.to.be.a('object'); + }); + + it('builds a request with site and device information', function () { + const request = spec.buildRequests([validBid], {})[0]; + + expect(request.data.site).to.exist.and.to.be.a('object'); + expect(request.data.device).to.exist.and.to.be.a('object'); + }); + + it('builds a request with a complete imp object', function () { + const request = spec.buildRequests([validBid], {})[0]; + + expect(request.data.imp.id).to.equal('bid_id'); + expect(request.data.imp.bidfloor).to.be.equal(1); + expect(request.data.imp.banner).to.exist.and.to.be.a('object'); + expect(request.data.imp.ext.zone.id).to.be.equal('zone1'); + }); + + it('includes the sizes into the request correctly', function () { + const bannerObject = spec.buildRequests([validBid], {})[0].data.imp.banner; + + expect(bannerObject.format).to.exist; + expect(bannerObject.format).to.be.lengthOf(1); + expect(bannerObject.format[0].w).to.be.equal(300); + expect(bannerObject.format[0].h).to.be.equal(200); + }); + }); + + describe('interpretResponse', function () { + let responseBody = { + id: 'bidresponse_id', + dealId: 'deal_id', + seatbid: [ + { + bid: [ + { + price: 1.2, + w: 300, + h: 200, + crid: 'crid1', + adm: 'adm1' + } + ] + }, + { + bid: [ + { + price: 2.1, + w: 250, + h: 300, + crid: 'crid2', + adm: 'adm2' + } + ] + } + ] + }; + + it('returns an empty list when there is no response body', function () { + expect(spec.interpretResponse({}, {})).to.be.eql([]); + }); + + it('builds the same amount of responses as server responses it receives', function () { + expect(spec.interpretResponse({body: responseBody}, {})).to.be.lengthOf(2); + }); + + it('builds a response with the expected fields', function () { + const response = spec.interpretResponse({body: responseBody}, {})[0]; + + expect(response.requestId).to.be.equal('bidresponse_id'); + expect(response.cpm).to.be.equal(1.2); + expect(response.width).to.be.equal(300); + expect(response.height).to.be.equal(200); + expect(response.creativeId).to.be.equal('crid1'); + expect(response.dealId).to.be.equal('deal_id'); + expect(response.currency).to.be.equal('USD'); + expect(response.netRevenue).to.be.equal(true); + expect(response.ttl).to.be.equal(300); + expect(response.ad).to.be.equal('adm1'); + }); + }); + + describe('getUserSyncs', function () { + it('returns an empty list', function () { + expect(spec.getUserSyncs({}, {})).to.be.eql([]); + }) + }) +});