Skip to content

Commit

Permalink
Implement outgoing link URL replacements. (#5628)
Browse files Browse the repository at this point in the history
- Only exposes `QUERY_PARAM` and `CLIENT_ID`
- Requires opt-in per `<a>` tag.
- Only done for destinations going to the page's source or canonical origin.

Implements #4078
  • Loading branch information
cramforce committed Oct 17, 2016
1 parent 98ece0a commit 156c8ee
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 2 deletions.
4 changes: 3 additions & 1 deletion src/document-click.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {viewerForDoc} from './viewer';
import {viewportForDoc} from './viewport';
import {platformFor} from './platform';
import {timerFor} from './timer';
import {urlReplacementsForDoc} from './url-replacements';


/**
Expand Down Expand Up @@ -113,9 +114,10 @@ export function onDocumentElementClick_(
}

const target = closestByTag(dev().assertElement(e.target), 'A');
if (!target) {
if (!target || !target.href) {
return;
}
urlReplacementsForDoc(ampdoc).maybeExpandLink(target);

/** @const {!Window} */
const win = ampdoc.win;
Expand Down
75 changes: 75 additions & 0 deletions src/service/url-replacements-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ import {viewerForDoc} from '../viewer';
import {viewportForDoc} from '../viewport';
import {userNotificationManagerFor} from '../user-notification';
import {activityFor} from '../activity';
import {isExperimentOn} from '../experiments';


/** @private @const {string} */
const TAG = 'UrlReplacements';
const EXPERIMENT_DELIMITER = '!';
const VARIANT_DELIMITER = '.';
const ORIGINAL_HREF_PROPERTY = 'amp-original-href';

/** @typedef {string|number|boolean|undefined|null} */
let ResolverReturnDef;
Expand Down Expand Up @@ -195,6 +197,12 @@ export class UrlReplacements {
defaultValue;
});

/**
* Stores client ids that were generated during this page view
* indexed by scope.
* @type {?Object<string, string>}
*/
let clientIds = null;
this.setAsync_('CLIENT_ID', (scope, opt_userNotificationId) => {
user().assertString(scope,
'The first argument to CLIENT_ID, the fallback c' +
Expand All @@ -213,8 +221,22 @@ export class UrlReplacements {
scope: dev().assertString(scope),
createCookieIfNotPresent: true,
}, consent);
}).then(cid => {
if (!clientIds) {
clientIds = Object.create(null);
}
clientIds[scope] = cid;
return cid;
});
});
// Synchronous alternative. Only works for scopes that were previously
// requested using the async method.
this.set_('CLIENT_ID', scope => {
if (!clientIds) {
return null;
}
return clientIds[dev().assertString(scope)];
});

// Returns assigned variant name for the given experiment.
this.setAsync_('VARIANT', experiment => {
Expand Down Expand Up @@ -605,6 +627,59 @@ export class UrlReplacements {
return /** @type {!Promise<string>} */(this.expand_(url, opt_bindings));
}

/**
* Replaces values in the link of an anchor tag if
* - the link opts into it (via data-amp-replace argument)
* - the destination is the source or canonical origin of this doc.
* @param {!Element} element An anchor element.
* @return {string|undefined} Replaced string for testing
*/
maybeExpandLink(element) {
if (!isExperimentOn(this.ampdoc.win, 'link-url-replace')) {
return;
}
dev().assert(element.tagName == 'A');
const whitelist = element.getAttribute('data-amp-replace');
if (!whitelist) {
return;
}
const docInfo = documentInfoForDoc(this.ampdoc);
// ORIGINAL_HREF_PROPERTY has the value of the href "pre-replacement".
// We set this to the original value before doing any work and use it
// on subsequent replacements, so that each run gets a fresh value.
const href = dev().assertString(
element[ORIGINAL_HREF_PROPERTY] || element.getAttribute('href'));
const url = parseUrl(href);
if (url.origin != parseUrl(docInfo.canonicalUrl).origin &&
url.origin != parseUrl(docInfo.sourceUrl).origin) {
user().warn('URL', 'Ignoring link replacement', href,
' because the link does not go to the document\'s' +
' source or canonical origin.');
return;
}
if (element[ORIGINAL_HREF_PROPERTY] == null) {
element[ORIGINAL_HREF_PROPERTY] = href;
}
const supportedReplacements = {
'CLIENT_ID': true,
'QUERY_PARAM': true,
};
const requestedReplacements = {};
whitelist.trim().split(/\s*,\s*/).forEach(replacement => {
if (supportedReplacements.hasOwnProperty(replacement)) {
requestedReplacements[replacement] = true;
} else {
user().warn('URL', 'Ignoring unsupported link replacement',
replacement);
}
});
return element.href = this.expandSync(
href,
/* opt_bindings */ undefined,
/* opt_collectVars */ undefined,
requestedReplacements);
}

/**
* @param {string} url
* @param {!Object<string, *>=} opt_bindings
Expand Down
50 changes: 50 additions & 0 deletions test/functional/test-document-click.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@

import {onDocumentElementClick_} from '../../src/document-click';
import {installTimerService} from '../../src/service/timer-impl';
import {
installUrlReplacementsServiceForDoc,
} from '../../src/service/url-replacements-impl';
import * as sinon from 'sinon';
import {toggleExperiment} from '../../src/experiments';

describe('test-document-click onDocumentElementClick_', () => {
let sandbox;
Expand Down Expand Up @@ -48,6 +52,7 @@ describe('test-document-click onDocumentElementClick_', () => {
tgt = document.createElement('a');
tgt.href = 'https://www.google.com';
win = {
document: {},
location: {
href: 'https://www.google.com/some-path?hello=world#link',
replace: replaceLocSpy,
Expand All @@ -56,15 +61,22 @@ describe('test-document-click onDocumentElementClick_', () => {
timerFuncSpy();
fn();
},
Object,
Math,
services: {
'viewport': {obj: {}},
},
};
ampdoc = {
win,
isSingleDoc: () => true,
getRootNode: () => {
return {
getElementById: getElementByIdSpy,
querySelector: querySelectorSpy,
};
},
getUrl: () => win.location.href,
};
doc = {defaultView: win};
docElem = {
Expand All @@ -83,6 +95,7 @@ describe('test-document-click onDocumentElementClick_', () => {
push: () => {},
};
installTimerService(win);
installUrlReplacementsServiceForDoc(ampdoc);
});

afterEach(() => {
Expand Down Expand Up @@ -346,4 +359,41 @@ describe('test-document-click onDocumentElementClick_', () => {
expect(preventDefaultSpy.callCount).to.equal(0);
});
});

describe('link expansion', () => {
it('should expand a link', () => {
querySelectorSpy.returns({
href: 'https://www.google.com',
});
toggleExperiment(win, 'link-url-replace', true);
tgt.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)';
tgt.setAttribute('data-amp-replace', 'QUERY_PARAM');
onDocumentElementClick_(evt, ampdoc, viewport, history);
expect(tgt.href).to.equal(
'https://www.google.com/link?out=world');
});

it('should only expand with whitelist', () => {
querySelectorSpy.returns({
href: 'https://www.google.com',
});
toggleExperiment(win, 'link-url-replace', true);
tgt.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)';
onDocumentElementClick_(evt, ampdoc, viewport, history);
expect(tgt.href).to.equal(
'https://www.google.com/link?out=QUERY_PARAM(hello)');
});

it('should not expand a link with experiment off', () => {
querySelectorSpy.returns({
href: 'https://www.google.com',
});
toggleExperiment(win, 'link-url-replace', false);
tgt.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)';
tgt.setAttribute('data-amp-replace', 'QUERY_PARAM');
onDocumentElementClick_(evt, ampdoc, viewport, history);
expect(tgt.href).to.equal(
'https://www.google.com/link?out=QUERY_PARAM(hello)');
});
});
});
129 changes: 128 additions & 1 deletion test/functional/test-url-replacements.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,22 @@ import {
import {getService} from '../../src/service';
import {setCookie} from '../../src/cookies';
import {parseUrl} from '../../src/url';
import {toggleExperiment} from '../../src/experiments';
import {viewerForDoc} from '../../src/viewer';
import * as sinon from 'sinon';


describe('UrlReplacements', () => {

let canonical;
let sandbox;
let loadObservable;
let replacements;
let viewerService;
let userErrorStub;

beforeEach(() => {
canonical = 'https://canonical.com/doc1';
sandbox = sinon.sandbox.create();
userErrorStub = sandbox.stub(user(), 'error');
});
Expand Down Expand Up @@ -114,11 +117,17 @@ describe('UrlReplacements', () => {
},
document: {
nodeType: /* document */ 9,
querySelector: () => {return {href: 'https://example.com/doc1'};},
querySelector: () => {return {href: canonical};},
cookie: '',
},
Math: window.Math,
services: {
'viewport': {obj: {}},
'cid': {
promise: Promise.resolve({
get: config => Promise.resolve('test-cid(' + config.scope + ')'),
}),
},
},
};
win.document.defaultView = win;
Expand Down Expand Up @@ -224,6 +233,21 @@ describe('UrlReplacements', () => {
});
});

it('should replace CLIENT_ID synchronously when available', () => {
return getReplacements({withCid: true}).then(urlReplacements => {
setCookie(window, 'url-abc', 'cid-for-abc');
setCookie(window, 'url-xyz', 'cid-for-xyz');
// Only requests cid-for-xyz in async path
return urlReplacements.expandAsync('b=CLIENT_ID(url-xyz)').then(res => {
expect(res).to.equal('b=cid-for-xyz');
}).then(() => {
const result = urlReplacements.expandSync(
'?a=CLIENT_ID(url-abc)&b=CLIENT_ID(url-xyz)&c=CLIENT_ID(other)');
expect(result).to.equal('?a=&b=cid-for-xyz&c=');
});
});
});

it('should replace VARIANT', () => {
return expect(expandAsync('?x1=VARIANT(x1)&x2=VARIANT(x2)&x3=VARIANT(x3)',
/*opt_bindings*/undefined, {withVariant: true}))
Expand Down Expand Up @@ -894,4 +918,107 @@ describe('UrlReplacements', () => {
});
});
});

describe('link expansion', () => {
let urlReplacements;
let a;
let win;

beforeEach(() => {
a = document.createElement('a');
win = getFakeWindow();
win.location = parseUrl('https://example.com/base?foo=bar&bar=abc');
urlReplacements = installUrlReplacementsServiceForDoc(win.ampdoc);
toggleExperiment(win, 'link-url-replace', true);
});

it('should replace href', () => {
a.href = 'https://example.com/link?out=QUERY_PARAM(foo)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=bar');
});

it('should replace href 2x', () => {
a.href = 'https://example.com/link?out=QUERY_PARAM(foo)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=bar');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=bar');
});

it('should not do anything with experiment off', () => {
toggleExperiment(win, 'link-url-replace', false);
a.href = 'https://example.com/link?out=QUERY_PARAM(foo)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=QUERY_PARAM(foo)');
});

it('should replace href 2', () => {
a.href = 'https://example.com/link?out=QUERY_PARAM(foo)&' +
'out2=QUERY_PARAM(bar)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=bar&out2=abc');
});

it('has nothing to replace', () => {
a.href = 'https://example.com/link';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link');
});

it('should not replace without user whitelisting', () => {
a.href = 'https://example.com/link?out=QUERY_PARAM(foo)';
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=QUERY_PARAM(foo)');
});

it('should not replace without user whitelisting 2', () => {
a.href = 'https://example.com/link?out=QUERY_PARAM(foo)';
a.setAttribute('data-amp-replace', 'ABC');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=QUERY_PARAM(foo)');
});

it('should not replace unwhitelisted fields', () => {
a.href = 'https://example.com/link?out=RANDOM';
a.setAttribute('data-amp-replace', 'RANDOM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://example.com/link?out=RANDOM');
});

it('should replace with canonical origin', () => {
a.href = 'https://canonical.com/link?out=QUERY_PARAM(foo)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal('https://canonical.com/link?out=bar');
});

it('should not replace to different origin', () => {
a.href = 'https://example2.com/link?out=QUERY_PARAM(foo)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM');
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal(
'https://example2.com/link?out=QUERY_PARAM(foo)');
});

it('should replace CID', () => {
a.href = 'https://canonical.com/link?out=QUERY_PARAM(foo)&c=CLIENT_ID(abc)';
a.setAttribute('data-amp-replace', 'QUERY_PARAM,CLIENT_ID');
// No replacement without previous async replacement
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal(
'https://canonical.com/link?out=bar&c=');
// Get a cid, then proceed.
return urlReplacements.expandAsync('CLIENT_ID(abc)').then(() => {
urlReplacements.maybeExpandLink(a);
expect(a.href).to.equal(
'https://canonical.com/link?out=bar&c=test-cid(abc)');
});
});
});
});

0 comments on commit 156c8ee

Please sign in to comment.