diff --git a/css/amp.css b/css/amp.css index 9946a74d7f92..073badcd74f4 100644 --- a/css/amp.css +++ b/css/amp.css @@ -334,3 +334,12 @@ amp-analytics { overflow: hidden !important; visibility: hidden; } + + +/** + * Minimal AMP Access CSS. This part has to be here so that the correct UI + * can be provided before AMP Access JS has been loaded. + */ +[amp-access][amp-access-off] { + display: none; +} diff --git a/examples/article-access.amp.html b/examples/article-access.amp.html index 1cf057018962..8a4785888458 100644 --- a/examples/article-access.amp.html +++ b/examples/article-access.amp.html @@ -7,9 +7,9 @@ @@ -179,11 +179,11 @@

Lorem Ipsum

-
+
Login to read more!
-
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus diff --git a/extensions/amp-access/0.1/access-expr.js b/extensions/amp-access/0.1/access-expr.js new file mode 100644 index 000000000000..8811c2b22c23 --- /dev/null +++ b/extensions/amp-access/0.1/access-expr.js @@ -0,0 +1,33 @@ +/** + * Copyright 2015 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. + */ + + +/** + * Evaluates access expression. + * @param {string} expr + * @param {!JSONObjectDef} data + * @return {boolean} + */ +export function evaluateAccessExpr(expr, data) { + // TODO(dvoytenko): the complete expression semantics + if (expr == 'access') { + return !!data.access; + } + if (expr == 'NOT access') { + return !data.access; + } + return false; +} diff --git a/extensions/amp-access/0.1/amp-access.js b/extensions/amp-access/0.1/amp-access.js index 14124fd9db1f..2d36cddba1ec 100644 --- a/extensions/amp-access/0.1/amp-access.js +++ b/extensions/amp-access/0.1/amp-access.js @@ -16,10 +16,15 @@ import {actionServiceFor} from '../../../src/action'; import {assertHttpsUrl} from '../../../src/url'; +import {evaluateAccessExpr} from './access-expr'; import {getService} from '../../../src/service'; import {installStyles} from '../../../src/styles'; import {isExperimentOn} from '../../../src/experiments'; import {log} from '../../../src/log'; +import {onDocumentReady} from '../../../src/document-state'; +import {urlReplacementsFor} from '../../../src/url-replacements'; +import {vsyncFor} from '../../../src/vsync'; +import {xhrFor} from '../../../src/xhr'; /** @@ -72,8 +77,17 @@ export class AccessService { /** @const @private {!Element} */ this.accessElement_ = accessElement; - /** @const {!AccessConfigDef} */ + /** @const @private {!AccessConfigDef} */ this.config_ = this.buildConfig_(); + + /** @const @private {!Vsync} */ + this.vsync_ = vsyncFor(this.win); + + /** @const @private {!Xhr} */ + this.xhr_ = xhrFor(this.win); + + /** @const @private {!UrlReplacements} */ + this.urlReplacements_ = urlReplacementsFor(this.win); } /** @@ -125,6 +139,78 @@ export class AccessService { startInternal_() { actionServiceFor(this.win).installActionHandler( this.accessElement_, this.handleAction_.bind(this)); + + // Start authorization XHR immediately. + this.runAuthorization_(); + } + + /** + * @return {!Promise} + * @private + */ + runAuthorization_() { + log.fine(TAG, 'Start authorization via ', this.config_.authorization); + this.toggleTopClass_('amp-access-loading', true); + + // TODO(dvoytenko): produce READER_ID and create the URL substition for it. + return this.urlReplacements_.expand(this.config_.authorization) + .then(url => { + log.fine(TAG, 'Authorization URL: ', url); + return this.xhr_.fetchJson(url, {credentials: 'include'}); + }) + .then(response => { + log.fine(TAG, 'Authorization response: ', response); + this.toggleTopClass_('amp-access-loading', false); + onDocumentReady(this.win.document, () => { + this.applyAuthorization_(response); + }); + }) + .catch(error => { + log.error(TAG, 'Authorization failed: ', error); + this.toggleTopClass_('amp-access-loading', false); + }); + } + + /** + * @param {!JSONObjectDef} response + * @private + */ + applyAuthorization_(response) { + const elements = this.win.document.querySelectorAll('[amp-access]'); + for (let i = 0; i < elements.length; i++) { + this.applyAuthorizationToElement_(elements[i], response); + } + } + + /** + * @param {!Element} element + * @param {!JSONObjectDef} response + * @private + */ + applyAuthorizationToElement_(element, response) { + const expr = element.getAttribute('amp-access'); + const on = evaluateAccessExpr(expr, response); + + // TODO(dvoytenko): support templates + + this.vsync_.mutate(() => { + if (on) { + element.removeAttribute('amp-access-off'); + } else { + element.setAttribute('amp-access-off', ''); + } + }); + } + + /** + * @param {string} className + * @param {boolean} on + * @private + */ + toggleTopClass_(className, on) { + this.vsync_.mutate(() => { + this.win.document.documentElement.classList.toggle(className, on); + }); } /** diff --git a/extensions/amp-access/0.1/test/test-access-expr.js b/extensions/amp-access/0.1/test/test-access-expr.js new file mode 100644 index 000000000000..94ec6684ba97 --- /dev/null +++ b/extensions/amp-access/0.1/test/test-access-expr.js @@ -0,0 +1,47 @@ +/** + * Copyright 2015 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 {evaluateAccessExpr} from '../access-expr'; + + +describe('evaluateAccessExpr', () => { + + it('should evaluate simple boolean expressions', () => { + expect(evaluateAccessExpr('access', {})).to.be.false; + + expect(evaluateAccessExpr('access', {access: true})).to.be.true; + expect(evaluateAccessExpr('access', {access: false})).to.be.false; + + expect(evaluateAccessExpr('access', {access: 1})).to.be.true; + expect(evaluateAccessExpr('access', {access: 0})).to.be.false; + + expect(evaluateAccessExpr('access', {access: '1'})).to.be.true; + expect(evaluateAccessExpr('access', {access: ''})).to.be.false; + }); + + it('should evaluate simple boolean NOT expressions', () => { + expect(evaluateAccessExpr('NOT access', {})).to.be.true; + + expect(evaluateAccessExpr('NOT access', {access: true})).to.be.false; + expect(evaluateAccessExpr('NOT access', {access: false})).to.be.true; + + expect(evaluateAccessExpr('NOT access', {access: 1})).to.be.false; + expect(evaluateAccessExpr('NOT access', {access: 0})).to.be.true; + + expect(evaluateAccessExpr('NOT access', {access: '1'})).to.be.false; + expect(evaluateAccessExpr('NOT access', {access: ''})).to.be.true; + }); +}); diff --git a/extensions/amp-access/0.1/test/test-amp-access.js b/extensions/amp-access/0.1/test/test-amp-access.js index 98e7708887d7..4567ab2184c1 100644 --- a/extensions/amp-access/0.1/test/test-amp-access.js +++ b/extensions/amp-access/0.1/test/test-amp-access.js @@ -144,3 +144,105 @@ describe('AccessService', () => { expect(service.startInternal_.callCount).to.equal(1); }); }); + + +describe('AccessService authorization', () => { + + let sandbox; + let configElement, elementOn, elementOff; + let vsyncMutates; + let urlReplacementsMock; + let xhrMock; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + + configElement = document.createElement('script'); + configElement.setAttribute('id', 'amp-access'); + configElement.setAttribute('type', 'application/json'); + configElement.textContent = JSON.stringify({ + 'authorization': 'https://acme.com/a?rid=READER_ID', + 'pingback': 'https://acme.com/p?rid=READER_ID', + 'login': 'https://acme.com/l?rid=READER_ID' + }); + document.body.appendChild(configElement); + + elementOn = document.createElement('div'); + elementOn.setAttribute('amp-access', 'access'); + document.body.appendChild(elementOn); + + elementOff = document.createElement('div'); + elementOff.setAttribute('amp-access', 'NOT access'); + document.body.appendChild(elementOff); + + service = new AccessService(window); + service.isExperimentOn_ = true; + + vsyncMutates = []; + service.vsync_ = {mutate: callback => vsyncMutates.push(callback)}; + urlReplacementsMock = sandbox.mock(service.urlReplacements_); + xhrMock = sandbox.mock(service.xhr_); + }); + + afterEach(() => { + if (configElement.parentElement) { + configElement.parentElement.removeChild(configElement); + } + if (elementOn.parentElement) { + elementOn.parentElement.removeChild(elementOn); + } + if (elementOff.parentElement) { + elementOff.parentElement.removeChild(elementOff); + } + sandbox.restore(); + sandbox = null; + }); + + it('should run authorization flow', () => { + urlReplacementsMock.expects('expand') + .withExactArgs('https://acme.com/a?rid=READER_ID') + .returns(Promise.resolve('https://acme.com/a?rid=reader1')) + .once(); + xhrMock.expects('fetchJson') + .withExactArgs('https://acme.com/a?rid=reader1', + {credentials: 'include'}) + .returns(Promise.resolve({access: true})) + .once(); + return service.runAuthorization_().then(() => { + expect(vsyncMutates).to.have.length.greaterThan(2); + vsyncMutates.shift()(); + expect(document.documentElement).to.have.class('amp-access-loading'); + + while (vsyncMutates.length > 0) { + vsyncMutates.shift()(); + } + expect(document.documentElement).not.to.have.class('amp-access-loading'); + expect(elementOn).not.to.have.attribute('amp-access-off'); + expect(elementOff).to.have.attribute('amp-access-off'); + }); + }); + + it('should recover from authorization failure', () => { + urlReplacementsMock.expects('expand') + .withExactArgs('https://acme.com/a?rid=READER_ID') + .returns(Promise.resolve('https://acme.com/a?rid=reader1')) + .once(); + xhrMock.expects('fetchJson') + .withExactArgs('https://acme.com/a?rid=reader1', + {credentials: 'include'}) + .returns(Promise.reject()) + .once(); + return service.runAuthorization_().then(() => { + expect(vsyncMutates).to.have.length.greaterThan(1); + vsyncMutates.shift()(); + expect(document.documentElement).to.have.class('amp-access-loading'); + + while (vsyncMutates.length > 0) { + vsyncMutates.shift()(); + } + expect(document.documentElement).not.to.have.class('amp-access-loading'); + expect(elementOn).not.to.have.attribute('amp-access-off'); + expect(elementOff).not.to.have.attribute('amp-access-off'); + }); + }); +});