Skip to content

Commit

Permalink
♻️ Build individual 3p iframe integration JS (#32448)
Browse files Browse the repository at this point in the history
* Add build task for ad vendors

* Move out shared parts of integration.js
  • Loading branch information
powerivq committed Feb 12, 2021
1 parent b1c1e2e commit 1d56891
Show file tree
Hide file tree
Showing 9 changed files with 549 additions and 360 deletions.
367 changes: 367 additions & 0 deletions 3p/integration-lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,367 @@
/**
* Copyright 2021 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {IntegrationAmpContext} from './ampcontext-integration';
import {dict} from '../src/utils/object.js';
import {endsWith} from '../src/string';
import {getAmpConfig, getEmbedType, getLocation} from './frame-metadata';
import {getSourceUrl, isProxyOrigin, parseUrlDeprecated} from '../src/url';
import {
initLogConstructor,
isUserErrorMessage,
setReportError,
userAssert,
} from '../src/log';
import {installEmbedStateListener, manageWin} from './environment';
import {internalRuntimeVersion} from '../src/internal-version';
import {parseJson} from '../src/json';
import {run, setExperimentToggles} from './3p';
import {urls} from '../src/config';

/**
* Whether the embed type may be used with amp-embed tag.
* @const {!Object<string, boolean>}
*/
const AMP_EMBED_ALLOWED = {
_ping_: true,
'1wo': true,
'24smi': true,
adsloom: true,
adstyle: true,
bringhub: true,
dable: true,
engageya: true,
epeex: true,
firstimpression: true,
forkmedia: true,
glomex: true,
idealmedia: true,
insticator: true,
jubna: true,
kuadio: true,
'mantis-recommend': true,
mediaad: true,
mgid: true,
miximedia: true,
mywidget: true,
nativery: true,
lentainform: true,
opinary: true,
outbrain: true,
plista: true,
postquare: true,
ppstudio: true,
pubexchange: true,
pulse: true,
rbinfox: true,
readmo: true,
recreativ: true,
runative: true,
smartclip: true,
smi2: true,
speakol: true,
strossle: true,
svknative: true,
taboola: true,
temedya: true,
vlyby: true,
whopainfeed: true,
yahoofedads: true,
yahoonativeads: true,
yektanet: true,
zen: true,
zergnet: true,
};

// For backward compat, we always allow these types without the iframe
// opting in.
const defaultAllowedTypesInCustomFrame = [
// Entries must be reasonably safe and not allow overriding the injected
// JS URL.
// Each custom iframe can override this through the second argument to
// draw3p. See amp-ad docs.
'facebook',
'twitter',
'doubleclick',
'yieldbot',
'_ping_',
];

/**
* Initialize 3p frame.
* @param {!Window} win
*/
export function init(win) {
initLogConstructor();
const config = getAmpConfig();

// Overriding to short-circuit src/mode#getMode()
win.__AMP_MODE = config.mode;

setReportError(console.error.bind(console));

setExperimentToggles(config.experimentToggles);
}

/**
* Visible for testing.
* Draws a 3p embed to the window. Expects the data to include the 3p type.
* @param {!Window} win
* @param {!Object} data
* @param {function(!Object, function(!Object))|undefined} configCallback
* Optional callback that allows user code to manipulate the incoming
* configuration. See
* https://github.com/ampproject/amphtml/issues/1210 for some context
* on this.
*/
export function draw3pInternal(win, data, configCallback) {
const type = data['type'];

userAssert(
isTagNameAllowed(type, win.context.tagName),
'Embed type %s not allowed with tag %s',
type,
win.context.tagName
);
if (configCallback) {
configCallback(data, (data) => {
userAssert(data, 'Expected configuration to be passed as first argument');
run(type, win, data);
});
} else {
run(type, win, data);
}
}

/**
* Draws an embed, optionally synchronously, to the DOM.
* @param {function(!Object, function(!Object))} opt_configCallback If provided
* will be invoked with two arguments:
* 1. The configuration parameters supplied to this embed.
* 2. A callback that MUST be called for rendering to proceed. It takes
* no arguments. Configuration is expected to be modified in-place.
* @param {!Array<string>=} opt_allowed3pTypes List of advertising network
* types you expect.
* @param {!Array<string>=} opt_allowedEmbeddingOrigins List of domain suffixes
* that are allowed to embed this frame.
*/
export function draw3p(
opt_configCallback,
opt_allowed3pTypes,
opt_allowedEmbeddingOrigins
) {
try {
const location = getLocation();

ensureFramed(window);
validateParentOrigin(window, location);
validateAllowedTypes(window, getEmbedType(), opt_allowed3pTypes);
if (opt_allowedEmbeddingOrigins) {
validateAllowedEmbeddingOrigins(window, opt_allowedEmbeddingOrigins);
}
window.context = new IntegrationAmpContext(window);
manageWin(window);
installEmbedStateListener();

// Ugly type annotation is due to Event.prototype.data being denylisted
// and the compiler not being able to discern otherwise
// TODO(alanorozco): Do this more elegantly once old impl is cleaned up.
draw3pInternal(
window,
/** @type {!IntegrationAmpContext} */ (window.context).data || {},
opt_configCallback
);

window.context.bootstrapLoaded();
} catch (e) {
if (window.context && window.context.report3pError) {
// window.context has initiated yet
if (e.message && isUserErrorMessage(e.message)) {
// report user error to parent window
window.context.report3pError(e);
}
}

const c = window.context || {mode: {test: false}};
if (!c.mode.test) {
lightweightErrorReport(e, c.canary);
throw e;
}
}
}

/**
* Throws if the current frame's parent origin is not equal to
* the claimed origin.
* Only check for browsers that support ancestorOrigins
* @param {!Window} window
* @param {!Location} parentLocation
* @visibleForTesting
*/
export function validateParentOrigin(window, parentLocation) {
const ancestors = window.location.ancestorOrigins;
// Currently only webkit and blink based browsers support
// ancestorOrigins. In that case we proceed but mark the origin
// as non-validated.
if (!ancestors || !ancestors.length) {
return;
}
userAssert(
ancestors[0] == parentLocation.origin,
'Parent origin mismatch: %s, %s',
ancestors[0],
parentLocation.origin
);
}

/**
* Check that this iframe intended this particular ad type to run.
* @param {!Window} window
* @param {string} type 3p type
* @param {!Array<string>|undefined} allowedTypes May be undefined.
* @visibleForTesting
*/
export function validateAllowedTypes(window, type, allowedTypes) {
const thirdPartyHost = parseUrlDeprecated(urls.thirdParty).hostname;

// Everything allowed in default iframe.
if (window.location.hostname == thirdPartyHost) {
return;
}
if (urls.thirdPartyFrameRegex.test(window.location.hostname)) {
return;
}
if (window.location.hostname == 'ads.localhost') {
return;
}
if (defaultAllowedTypesInCustomFrame.indexOf(type) != -1) {
return;
}
userAssert(
allowedTypes && allowedTypes.indexOf(type) != -1,
'3p type for custom iframe not allowed: %s',
type
);
}

/**
* Check that parent host name was allowed.
* @param {!Window} window
* @param {!Array<string>} allowedHostnames Suffixes of allowed host names.
* @visibleForTesting
*/
export function validateAllowedEmbeddingOrigins(window, allowedHostnames) {
if (!window.document.referrer) {
throw new Error('Referrer expected: ' + window.location.href);
}
const ancestors = window.location.ancestorOrigins;
// We prefer the unforgable ancestorOrigins, but referrer is better than
// nothing.
const ancestor = ancestors ? ancestors[0] : window.document.referrer;
let {hostname} = parseUrlDeprecated(ancestor);
if (isProxyOrigin(ancestor)) {
// If we are on the cache domain, parse the source hostname from
// the referrer. The referrer is used because it should be
// trustable.
hostname = parseUrlDeprecated(getSourceUrl(window.document.referrer))
.hostname;
}
for (let i = 0; i < allowedHostnames.length; i++) {
// Either the hostname is allowed
if (allowedHostnames[i] == hostname) {
return;
}
// Or it ends in .$hostname (aka is a sub domain of an allowed domain.
if (endsWith(hostname, '.' + allowedHostnames[i])) {
return;
}
}
throw new Error(
'Invalid embedding hostname: ' + hostname + ' not in ' + allowedHostnames
);
}

/**
* Throws if this window is a top level window.
* @param {!Window} window
* @visibleForTesting
*/
export function ensureFramed(window) {
if (window == window.parent) {
throw new Error('Must be framed: ' + window.location.href);
}
}

/**
* Expects the fragment to contain JSON.
* @param {string} fragment Value of location.fragment
* @return {?JsonObject}
* @visibleForTesting
*/
export function parseFragment(fragment) {
try {
let json = fragment.substr(1);
// Some browser, notably Firefox produce an encoded version of the fragment
// while most don't. Since we know how the string should start, this is easy
// to detect.
if (json.startsWith('{%22')) {
json = decodeURIComponent(json);
}
return /** @type {!JsonObject} */ (json ? parseJson(json) : dict());
} catch (err) {
return null;
}
}

/**
* Not all types of embeds are allowed to be used with all tag names on the
* AMP side. This function checks whether the current usage is permissible.
* @param {string} type
* @param {string|undefined} tagName The tagName that was used to embed this
* 3p-frame.
* @return {boolean}
*/
export function isTagNameAllowed(type, tagName) {
if (tagName == 'AMP-EMBED') {
return !!AMP_EMBED_ALLOWED[type];
}
return true;
}

/**
* Reports an error to the server. Must only be called once per page.
* Not for use in event handlers.
*
* We don't use the default error in error.js handler because it has
* too many deps for this small JS binary.
*
* @param {!Error} e
* @param {boolean} isCanary
*/
function lightweightErrorReport(e, isCanary) {
new Image().src =
urls.errorReporting +
'?3p=1&v=' +
encodeURIComponent(internalRuntimeVersion()) +
'&m=' +
encodeURIComponent(e.message) +
'&ca=' +
(isCanary ? 1 : 0) +
'&r=' +
encodeURIComponent(document.referrer) +
'&s=' +
encodeURIComponent(e.stack || '');
}

0 comments on commit 1d56891

Please sign in to comment.