Skip to content

Commit

Permalink
✨Add AMP viewer capability to handle navigation of all external links (
Browse files Browse the repository at this point in the history
…#27649)

* Add viewer interceptNavigation capability

* Remove logging and add short documentation

* Fix doc formatting

* Update amp-doc-viewer-api.md with new capability

* Update viewer navigation interception to check for a trusted/local viewer and opted in doc

* Remove console.log statements

* Move asynchronous viewer behavior into the constructor

* Change name to viewerInterceptsNavigation

* Add unit test for viewerInterceptsNavigation

* Add src/service/navigation.js to isTrustedViewer caller whitelist
  • Loading branch information
ammaxwell committed Apr 24, 2020
1 parent a431650 commit 97453bd
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 2 deletions.
1 change: 1 addition & 0 deletions build-system/tasks/presubmit-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ const forbiddenTerms = {
'extensions/amp-bind/0.1/bind-impl.js',
'src/error.js',
'src/utils/xhr-utils.js',
'src/service/navigation.js',
'src/service/viewer-impl.js',
'src/service/viewer-interface.js',
'src/service/viewer-cid-api.js',
Expand Down
3 changes: 2 additions & 1 deletion extensions/amp-viewer-integration/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Viewers can communicate their supported "capabilities" to documents through the
| `fragment` | `fragment` | URL fragment support for the history API. |
| `handshakepoll` | `handshake-poll` | Mobile web handshake. |
| `iframeScroll` | | Viewer platform supports and configures scrolling on the AMP document's iframe. |
| `navigateTo` | `navigateTo` | Support for navigating to external URLs. |
| `interceptNavigation` | `navigateTo` | Support for navigating to external URLs. |
| `navigateTo` | `navigateTo` | Support for navigating to external URLs within a native app. |
| `replaceUrl` | `getReplaceUrl` | Support for replacing the document URL with one provided by the viewer. |
| `swipe` | `touchstart`, `touchmove`, `touchend` | Forwards touch events from the document to the viewer. |
| `viewerRenderTemplate` | `viewerRenderTemplate` | Proxies all mustache template rendering to the viewer. |
Expand Down
3 changes: 2 additions & 1 deletion extensions/amp-viewer-integration/amp-doc-viewer-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ A comma delimited list of capabilities supported by the Viewer. Other boolean pa
| `fragment` | `fragment` | URL fragment support for the history API. |
| `handshakepoll` | `handshake-poll` | Mobile web handshake. |
| `iframeScroll` | | Viewer platform supports and configures scrolling on the AMP document's iframe. |
| `navigateTo` | `navigateTo` | Support for navigating to external URLs. |
| `interceptNavigation` | `navigateTo` | Support for navigating to external URLs. |
| `navigateTo` | `navigateTo` | Support for navigating to external URLs within a native app. |
| `replaceUrl` | `getReplaceUrl` | Support for replacing the document URL with one provided by the Viewer. |
| `swipe` | `touchstart`, `touchmove`, `touchend` | Forwards touch events from the document to the Viewer. |
| `viewerRenderTemplate` | `viewerRenderTemplate` | Proxies all mustache template rendering to the Viewer. |
Expand Down
58 changes: 58 additions & 0 deletions src/service/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
installServiceInEmbedScope,
registerServiceBuilderForDoc,
} from '../service';
import {isLocalhostOrigin} from '../url';
import {toWin} from '../types';
import PriorityQueue from '../utils/priority-queue';

Expand Down Expand Up @@ -152,6 +153,18 @@ export class Navigation {
this.appendExtraParams_ = res;
});

/** @private {boolean} */
this.isTrustedViewer_ = false;
/** @private {boolean} */
this.isLocalViewer_ = false;
Promise.all([
this.viewer_.isTrustedViewer(),
this.viewer_.getViewerOrigin(),
]).then((values) => {
this.isTrustedViewer_ = values[0];
this.isLocalViewer_ = isLocalhostOrigin(values[1]);
});

/**
* Lazy-generated list of A2A-enabled navigation features.
* @private {?Array<string>}
Expand Down Expand Up @@ -566,6 +579,10 @@ export class Navigation {
) {
this.removeViewerQueryBeforeNavigation_(win, fromLocation, target);
}

if (this.viewerInterceptsNavigation(to, 'intercept_click')) {
e.preventDefault();
}
}
}

Expand Down Expand Up @@ -738,6 +755,47 @@ export class Navigation {
getMode().test && !this.isEmbed_ ? this.ampdoc.win.location.href : '';
return this.parseUrl_(baseHref);
}

/**
* Requests navigation through a Viewer to the given destination.
*
* This function only proceeds if:
* 1. The viewer supports the 'interceptNavigation' capability.
* 2. The contained AMP doc has 'opted in' via including the 'allow-navigation-interception'
* attribute on the <html> tag.
* 3. The viewer is trusted or from localhost.
*
* @param {string} url A URL.
* @param {string} requestedBy Informational string about the entity that
* requested the navigation.
* @return {boolean} Returns true if navigation message was sent to viewer.
* Otherwise, returns false.
*/
viewerInterceptsNavigation(url, requestedBy) {
const viewerHasCapability = this.viewer_.hasCapability(
'interceptNavigation'
);
const docOptedIn = this.ampdoc
.getRootNode()
.documentElement.hasAttribute('allow-navigation-interception');

if (
!viewerHasCapability ||
!docOptedIn ||
!(this.isTrustedViewer_ || this.isLocalViewer_)
) {
return false;
}

this.viewer_.sendMessage(
'navigateTo',
dict({
'url': url,
'requestedBy': requestedBy,
})
);
return true;
}
}

/**
Expand Down
120 changes: 120 additions & 0 deletions test/unit/test-navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,126 @@ describes.sandboxed('Navigation', {}, () => {
expect(win.location.href).to.equal('https://amp.pub.com/different');
});
});

describe('viewer intercept navigation', () => {
let ampdoc;
let viewerInterceptsNavigationSpy;
let sendMessageStub;
let hasCapabilityStub;

beforeEach(() => {
ampdoc = Services.ampdoc(doc);
viewerInterceptsNavigationSpy = env.sandbox.spy(
handler,
'viewerInterceptsNavigation'
);
sendMessageStub = env.sandbox.stub(handler.viewer_, 'sendMessage');
hasCapabilityStub = env.sandbox.stub(
handler.viewer_,
'hasCapability'
);

handler.isTrustedViewer_ = true;
handler.isLocalViewer_ = false;
hasCapabilityStub.returns(true);

ampdoc
.getRootNode()
.documentElement.setAttribute('allow-navigation-interception', '');
});

it('should allow with trusted viewer', () => {
handler.isTrustedViewer_ = true;
handler.isLocalViewer_ = false;

handler.handle_(event);

expect(viewerInterceptsNavigationSpy).to.be.calledOnce;
expect(viewerInterceptsNavigationSpy).to.be.calledWithExactly(
'https://www.google.com/other',
'intercept_click'
);
expect(sendMessageStub).to.be.calledOnce;
expect(sendMessageStub).to.be.calledWithExactly('navigateTo', {
url: 'https://www.google.com/other',
requestedBy: 'intercept_click',
});

expect(event.defaultPrevented).to.be.true;
});

it('should allow with local viewer', () => {
handler.isTrustedViewer_ = false;
handler.isLocalViewer_ = true;

handler.handle_(event);

expect(
ampdoc
.getRootNode()
.documentElement.hasAttribute('allow-navigation-interception')
).to.be.true;

expect(viewerInterceptsNavigationSpy).to.be.calledOnce;
expect(viewerInterceptsNavigationSpy).to.be.calledWithExactly(
'https://www.google.com/other',
'intercept_click'
);
expect(sendMessageStub).to.be.calledOnce;
expect(sendMessageStub).to.be.calledWithExactly('navigateTo', {
url: 'https://www.google.com/other',
requestedBy: 'intercept_click',
});

expect(event.defaultPrevented).to.be.true;
});

it('should require trusted or local viewer', () => {
handler.isTrustedViewer_ = false;
handler.isLocalViewer_ = false;
handler.handle_(event);

expect(viewerInterceptsNavigationSpy).to.be.calledOnce;
expect(viewerInterceptsNavigationSpy).to.be.calledWithExactly(
'https://www.google.com/other',
'intercept_click'
);

expect(sendMessageStub).to.not.be.called;
expect(event.defaultPrevented).to.be.false;
});

it('should require interceptNavigation viewer capability', () => {
hasCapabilityStub.returns(false);

handler.handle_(event);

expect(viewerInterceptsNavigationSpy).to.be.calledOnce;
expect(viewerInterceptsNavigationSpy).to.be.calledWithExactly(
'https://www.google.com/other',
'intercept_click'
);

expect(sendMessageStub).to.not.be.called;
expect(event.defaultPrevented).to.be.false;
});

it('should require opted in ampdoc', () => {
ampdoc
.getRootNode()
.documentElement.removeAttribute('allow-navigation-interception');
handler.handle_(event);

expect(viewerInterceptsNavigationSpy).to.be.calledOnce;
expect(viewerInterceptsNavigationSpy).to.be.calledWithExactly(
'https://www.google.com/other',
'intercept_click'
);

expect(sendMessageStub).to.not.be.called;
expect(event.defaultPrevented).to.be.false;
});
});
}
);

Expand Down

0 comments on commit 97453bd

Please sign in to comment.