Commit 41f1eb19 authored by Steel Wagstaff's avatar Steel Wagstaff

Merge remote-tracking branch 'hypothesis/master'

parents 70442411 e903ebac
...@@ -4,6 +4,42 @@ Entries in this change log follow the format suggested at http://keepachangelog. ...@@ -4,6 +4,42 @@ Entries in this change log follow the format suggested at http://keepachangelog.
# Change Log # Change Log
## [1.32.0] - 2017-07-20
### Changed
- Support using `dc.relation.ispartof` and `dc.identifier` meta tags to
generate a URN for documents which are part of a larger work (eg. a book
chapter)
([#500](https://github.com/hypothesis/client/pull/500)).
## [1.31.0] - 2017-07-17
### Changed
- Add mechanism for publishers to react to changes in the width and expanded
state of the sidebar ([#499](https://github.com/hypothesis/client/pull/499)).
## [1.30.0] - 2017-07-14
### Changed
- Enable annotating in iframes which have the same origin as the top-level page
([#498](https://github.com/hypothesis/client/pull/498)).
## [1.29.0] - 2017-07-14
### Changed
- Remove need to set feature flag to enable iframe support
([#496](https://github.com/hypothesis/client/pull/496)).
- Do not inject client into small or hidden iframes
([#497](https://github.com/hypothesis/client/pull/497)).
- Persist login between sessions when using OAuth
([#494](https://github.com/hypothesis/client/pull/494)).
## [1.28.0] - 2017-07-11 ## [1.28.0] - 2017-07-11
### Changed ### Changed
......
...@@ -327,7 +327,6 @@ gulp.task('serve-live-reload', ['serve-package'], function () { ...@@ -327,7 +327,6 @@ gulp.task('serve-live-reload', ['serve-package'], function () {
var LiveReloadServer = require('./scripts/gulp/live-reload-server'); var LiveReloadServer = require('./scripts/gulp/live-reload-server');
liveReloadServer = new LiveReloadServer(3000, { liveReloadServer = new LiveReloadServer(3000, {
clientUrl: `http://${packageServerHostname()}:3001/hypothesis`, clientUrl: `http://${packageServerHostname()}:3001/hypothesis`,
enableMultiFrameSupport: !!process.env.MULTI_FRAME_SUPPORT,
}); });
}); });
......
{ {
"name": "hypothesis", "name": "hypothesis",
"version": "1.28.0", "version": "1.32.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"dependencies": { "dependencies": {
"@gulp-sourcemaps/map-sources": { "@gulp-sourcemaps/map-sources": {
......
{ {
"name": "hypothesis", "name": "hypothesis",
"version": "1.28.0", "version": "1.32.0",
"description": "Annotate with anyone, anywhere.", "description": "Annotate with anyone, anywhere.",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"homepage": "https://hypothes.is", "homepage": "https://hypothes.is",
......
...@@ -70,16 +70,6 @@ function LiveReloadServer(port, config) { ...@@ -70,16 +70,6 @@ function LiveReloadServer(port, config) {
</html> </html>
`; `;
} else { } else {
var multiFrameContent = config.enableMultiFrameSupport ? `
<div style="margin: 10px 0 0 75px;">
<button id="add-test" style="padding: 0.6em; font-size: 0.75em">Toggle 2nd Frame</button>
</div>
<div style="margin: 10px 0 0 75px;">
<iframe id="iframe1" src="/document/license" style="width: 50%;height: 300px;"></iframe>
</div>
<div id="iframe2-container" style="margin: 10px 0 0 75px;">
</div>` : '';
content = ` content = `
<html> <html>
<head> <head>
...@@ -91,7 +81,14 @@ function LiveReloadServer(port, config) { ...@@ -91,7 +81,14 @@ function LiveReloadServer(port, config) {
Number of annotations: Number of annotations:
<span data-hypothesis-annotation-count>...</span> <span data-hypothesis-annotation-count>...</span>
</div> </div>
${multiFrameContent} <div style="margin: 10px 0 0 75px;">
<button id="add-test" style="padding: 0.6em; font-size: 0.75em">Toggle 2nd Frame</button>
</div>
<div style="margin: 10px 0 0 75px;">
<iframe id="iframe1" src="/document/license" style="width: 50%;height: 300px;"></iframe>
</div>
<div id="iframe2-container" style="margin: 10px 0 0 75px;">
</div>
<pre style="margin: 20px 75px 75px 75px;">${readmeText()}</pre> <pre style="margin: 20px 75px 75px 75px;">${readmeText()}</pre>
<script> <script>
var appHost = document.location.hostname; var appHost = document.location.hostname;
...@@ -102,10 +99,6 @@ function LiveReloadServer(port, config) { ...@@ -102,10 +99,6 @@ function LiveReloadServer(port, config) {
// Open the sidebar when the page loads // Open the sidebar when the page loads
openSidebar: true, openSidebar: true,
// Needed for multi frame support
enableMultiFrameSupport: ${config.enableMultiFrameSupport},
embedScriptUrl: '${config.clientUrl}'
}; };
}; };
......
...@@ -10,22 +10,26 @@ var settingsFrom = require('./settings'); ...@@ -10,22 +10,26 @@ var settingsFrom = require('./settings');
function configFrom(window_) { function configFrom(window_) {
var settings = settingsFrom(window_); var settings = settingsFrom(window_);
return { return {
sidebarAppUrl: settings.sidebarAppUrl,
query: settings.query,
annotations: settings.annotations, annotations: settings.annotations,
showHighlights: settings.showHighlights, // URL where client assets are served from. Used when injecting the client
openLoginForm: settings.hostPageSetting('openLoginForm', {allowInBrowserExt: true}), // into child iframes.
openSidebar: settings.hostPageSetting('openSidebar', {allowInBrowserExt: true}), assetRoot: settings.hostPageSetting('assetRoot', {allowInBrowserExt: true}),
branding: settings.hostPageSetting('branding'), branding: settings.hostPageSetting('branding'),
services: settings.hostPageSetting('services'), // URL of the client's boot script. Used when injecting the client into
// child iframes.
// Needed by the multi-frame feature for now clientUrl: settings.clientUrl,
enableMultiFrameSupport: settings.hostPageSetting('enableMultiFrameSupport'),
embedScriptUrl: settings.hostPageSetting('embedScriptUrl'),
subFrameIdentifier: settings.hostPageSetting('subFrameIdentifier'),
// Temporary feature flag override for 1st-party OAuth // Temporary feature flag override for 1st-party OAuth
oauthEnabled: settings.hostPageSetting('oauthEnabled'), oauthEnabled: settings.hostPageSetting('oauthEnabled'),
onLayoutChange: settings.hostPageSetting('onLayoutChange'),
openLoginForm: settings.hostPageSetting('openLoginForm', {allowInBrowserExt: true}),
openSidebar: settings.hostPageSetting('openSidebar', {allowInBrowserExt: true}),
query: settings.query,
services: settings.hostPageSetting('services'),
showHighlights: settings.showHighlights,
sidebarAppUrl: settings.sidebarAppUrl,
// Subframe identifier given when a frame is being embedded into
// by a top level client
subFrameIdentifier: settings.hostPageSetting('subFrameIdentifier', {allowInBrowserExt: true}),
}; };
} }
......
...@@ -10,10 +10,10 @@ function settingsFrom(window_) { ...@@ -10,10 +10,10 @@ function settingsFrom(window_) {
var configFuncSettings = configFuncSettingsFrom(window_); var configFuncSettings = configFuncSettingsFrom(window_);
/** /**
* Return the href URL of the first annotator link in the given document. * Return the href URL of the first annotator sidebar link in the given document.
* *
* Return the value of the href attribute of the first * Return the value of the href attribute of the first
* `<link type="application/annotator+html">` element in the given document. * `<link type="application/annotator+html" rel="sidebar">` element in the given document.
* *
* This URL is used as the src of the sidebar's iframe. * This URL is used as the src of the sidebar's iframe.
* *
...@@ -24,14 +24,44 @@ function settingsFrom(window_) { ...@@ -24,14 +24,44 @@ function settingsFrom(window_) {
* *
*/ */
function sidebarAppUrl() { function sidebarAppUrl() {
var link = window_.document.querySelector('link[type="application/annotator+html"]'); var link = window_.document.querySelector('link[type="application/annotator+html"][rel="sidebar"]');
if (!link) { if (!link) {
throw new Error('No application/annotator+html link in the document'); throw new Error('No application/annotator+html (rel="sidebar") link in the document');
} }
if (!link.href) { if (!link.href) {
throw new Error('application/annotator+html link has no href'); throw new Error('application/annotator+html (rel="sidebar") link has no href');
}
return link.href;
}
/**
* Return the href URL of the first annotator client link in the given document.
*
* Return the value of the href attribute of the first
* `<link type="application/annotator+html" rel="hypothesis-client">` element in the given document.
*
* This URL is used to identify where the client is from and what url should be
* used inside of subframes
*
* @return {string} - The URL that the client is hosted from
*
* @throws {Error} - If there's no annotator link or the first annotator has
* no href.
*
*/
function clientUrl() {
var link = window_.document.querySelector('link[type="application/annotator+javascript"][rel="hypothesis-client"]');
if (!link) {
throw new Error('No application/annotator+javascript (rel="hypothesis-client") link in the document');
}
if (!link.href) {
throw new Error('application/annotator+javascript (rel="hypothesis-client") link has no href');
} }
return link.href; return link.href;
...@@ -126,9 +156,10 @@ function settingsFrom(window_) { ...@@ -126,9 +156,10 @@ function settingsFrom(window_) {
} }
return { return {
get sidebarAppUrl() { return sidebarAppUrl(); },
get annotations() { return annotations(); }, get annotations() { return annotations(); },
get clientUrl() { return clientUrl(); },
get showHighlights() { return showHighlights(); }, get showHighlights() { return showHighlights(); },
get sidebarAppUrl() { return sidebarAppUrl(); },
get query() { return query(); }, get query() { return query(); },
hostPageSetting: hostPageSetting, hostPageSetting: hostPageSetting,
}; };
......
...@@ -60,6 +60,8 @@ describe('annotator.config.index', function() { ...@@ -60,6 +60,8 @@ describe('annotator.config.index', function() {
}); });
[ [
'assetRoot',
'subFrameIdentifier',
'openLoginForm', 'openLoginForm',
'openSidebar', 'openSidebar',
].forEach(function(settingName) { ].forEach(function(settingName) {
...@@ -85,6 +87,7 @@ describe('annotator.config.index', function() { ...@@ -85,6 +87,7 @@ describe('annotator.config.index', function() {
}); });
[ [
'assetRoot',
'openLoginForm', 'openLoginForm',
'openSidebar', 'openSidebar',
'branding', 'branding',
...@@ -92,6 +95,7 @@ describe('annotator.config.index', function() { ...@@ -92,6 +95,7 @@ describe('annotator.config.index', function() {
].forEach(function(settingName) { ].forEach(function(settingName) {
it('returns the ' + settingName + ' value from the host page', function() { it('returns the ' + settingName + ' value from the host page', function() {
var settings = { var settings = {
'assetRoot': 'chrome-extension://1234/client/',
'openLoginForm': 'OPEN_LOGIN_FORM_SETTING', 'openLoginForm': 'OPEN_LOGIN_FORM_SETTING',
'openSidebar': 'OPEN_SIDEBAR_SETTING', 'openSidebar': 'OPEN_SIDEBAR_SETTING',
'oauthEnabled': true, 'oauthEnabled': true,
......
...@@ -30,9 +30,10 @@ describe('annotator.config.settingsFrom', function() { ...@@ -30,9 +30,10 @@ describe('annotator.config.settingsFrom', function() {
}); });
describe('#sidebarAppUrl', function() { describe('#sidebarAppUrl', function() {
function appendLinkToDocument(href) { function appendSidebarLinkToDocument(href) {
var link = document.createElement('link'); var link = document.createElement('link');
link.type = 'application/annotator+html'; link.type = 'application/annotator+html';
link.rel = 'sidebar';
if (href) { if (href) {
link.href = href; link.href = href;
} }
...@@ -44,7 +45,7 @@ describe('annotator.config.settingsFrom', function() { ...@@ -44,7 +45,7 @@ describe('annotator.config.settingsFrom', function() {
var link; var link;
beforeEach('add an application/annotator+html <link>', function() { beforeEach('add an application/annotator+html <link>', function() {
link = appendLinkToDocument('http://example.com/app.html'); link = appendSidebarLinkToDocument('http://example.com/app.html');
}); });
afterEach('tidy up the link', function() { afterEach('tidy up the link', function() {
...@@ -61,8 +62,8 @@ describe('annotator.config.settingsFrom', function() { ...@@ -61,8 +62,8 @@ describe('annotator.config.settingsFrom', function() {
var link2; var link2;
beforeEach('add two links to the document', function() { beforeEach('add two links to the document', function() {
link1 = appendLinkToDocument('http://example.com/app1'); link1 = appendSidebarLinkToDocument('http://example.com/app1');
link2 = appendLinkToDocument('http://example.com/app2'); link2 = appendSidebarLinkToDocument('http://example.com/app2');
}); });
afterEach('tidy up the links', function() { afterEach('tidy up the links', function() {
...@@ -79,7 +80,7 @@ describe('annotator.config.settingsFrom', function() { ...@@ -79,7 +80,7 @@ describe('annotator.config.settingsFrom', function() {
var link; var link;
beforeEach('add an application/annotator+html <link> with no href', function() { beforeEach('add an application/annotator+html <link> with no href', function() {
link = appendLinkToDocument(); link = appendSidebarLinkToDocument();
}); });
afterEach('tidy up the link', function() { afterEach('tidy up the link', function() {
...@@ -91,7 +92,7 @@ describe('annotator.config.settingsFrom', function() { ...@@ -91,7 +92,7 @@ describe('annotator.config.settingsFrom', function() {
function() { function() {
settingsFrom(window).sidebarAppUrl; // eslint-disable-line no-unused-expressions settingsFrom(window).sidebarAppUrl; // eslint-disable-line no-unused-expressions
}, },
'application/annotator+html link has no href' 'application/annotator+html (rel="sidebar") link has no href'
); );
}); });
}); });
...@@ -102,7 +103,87 @@ describe('annotator.config.settingsFrom', function() { ...@@ -102,7 +103,87 @@ describe('annotator.config.settingsFrom', function() {
function() { function() {
settingsFrom(window).sidebarAppUrl; // eslint-disable-line no-unused-expressions settingsFrom(window).sidebarAppUrl; // eslint-disable-line no-unused-expressions
}, },
'No application/annotator+html link in the document' 'No application/annotator+html (rel="sidebar") link in the document'
);
});
});
});
describe('#clientUrl', function() {
function appendClientUrlLinkToDocument(href) {
var link = document.createElement('link');
link.type = 'application/annotator+javascript';
link.rel = 'hypothesis-client';
if (href) {
link.href = href;
}
document.head.appendChild(link);
return link;
}
context("when there's an application/annotator+javascript link", function() {
var link;
beforeEach('add an application/annotator+javascript <link>', function() {
link = appendClientUrlLinkToDocument('http://example.com/app.html');
});
afterEach('tidy up the link', function() {
document.head.removeChild(link);
});
it('returns the href from the link', function() {
assert.equal(settingsFrom(window).clientUrl, 'http://example.com/app.html');
});
});
context('when there are multiple annotator+javascript links', function() {
var link1;
var link2;
beforeEach('add two links to the document', function() {
link1 = appendClientUrlLinkToDocument('http://example.com/app1');
link2 = appendClientUrlLinkToDocument('http://example.com/app2');
});
afterEach('tidy up the links', function() {
document.head.removeChild(link1);
document.head.removeChild(link2);
});
it('returns the href from the first one', function() {
assert.equal(settingsFrom(window).clientUrl, 'http://example.com/app1');
});
});
context('when the annotator+javascript link has no href', function() {
var link;
beforeEach('add an application/annotator+javascript <link> with no href', function() {
link = appendClientUrlLinkToDocument();
});
afterEach('tidy up the link', function() {
document.head.removeChild(link);
});
it('throws an error', function() {
assert.throws(
function() {
settingsFrom(window).clientUrl; // eslint-disable-line no-unused-expressions
},
'application/annotator+javascript (rel="hypothesis-client") link has no href'
);
});
});
context("when there's no annotator+javascript link", function() {
it('throws an error', function() {
assert.throws(
function() {
settingsFrom(window).clientUrl; // eslint-disable-line no-unused-expressions
},
'No application/annotator+javascript (rel="hypothesis-client") link in the document'
); );
}); });
}); });
......
...@@ -37,8 +37,7 @@ var pluginClasses = { ...@@ -37,8 +37,7 @@ var pluginClasses = {
CrossFrame: require('./plugin/cross-frame'), CrossFrame: require('./plugin/cross-frame'),
}; };
var appLinkEl = var appLinkEl = document.querySelector('link[type="application/annotator+html"][rel="sidebar"]');
document.querySelector('link[type="application/annotator+html"]');
var config = configFrom(window); var config = configFrom(window);
$.noConflict(true)(function() { $.noConflict(true)(function() {
...@@ -51,7 +50,7 @@ $.noConflict(true)(function() { ...@@ -51,7 +50,7 @@ $.noConflict(true)(function() {
delete config.constructor; delete config.constructor;
} }
if (config.enableMultiFrameSupport && config.subFrameIdentifier) { if (config.subFrameIdentifier) {
Klass = Guest; Klass = Guest;
// Other modules use this to detect if this // Other modules use this to detect if this
......
...@@ -36,9 +36,9 @@ module.exports = class CrossFrame extends Plugin ...@@ -36,9 +36,9 @@ module.exports = class CrossFrame extends Plugin
this.pluginInit = -> this.pluginInit = ->
onDiscoveryCallback = (source, origin, token) -> onDiscoveryCallback = (source, origin, token) ->
bridge.createChannel(source, origin, token) bridge.createChannel(source, origin, token)
discovery.startDiscovery(onDiscoveryCallback) discovery.startDiscovery(onDiscoveryCallback)
if config.enableMultiFrameSupport
frameObserver.observe(_injectToFrame, _iframeUnloaded); frameObserver.observe(_injectToFrame, _iframeUnloaded);
this.destroy = -> this.destroy = ->
...@@ -64,14 +64,14 @@ module.exports = class CrossFrame extends Plugin ...@@ -64,14 +64,14 @@ module.exports = class CrossFrame extends Plugin
if !FrameUtil.hasHypothesis(frame) if !FrameUtil.hasHypothesis(frame)
# Take the embed script location from the config # Take the embed script location from the config
# until an alternative solution comes around. # until an alternative solution comes around.
embedScriptUrl = config.embedScriptUrl clientUrl = config.clientUrl
FrameUtil.isLoaded frame, () -> FrameUtil.isLoaded frame, () ->
subFrameIdentifier = discovery._generateToken() subFrameIdentifier = discovery._generateToken()
frameIdentifiers.set(frame, subFrameIdentifier) frameIdentifiers.set(frame, subFrameIdentifier)
injectedConfig = Object.assign({}, config, {subFrameIdentifier}) injectedConfig = Object.assign({}, config, {subFrameIdentifier})
FrameUtil.injectHypothesis(frame, embedScriptUrl, injectedConfig) FrameUtil.injectHypothesis(frame, clientUrl, injectedConfig)
_iframeUnloaded = (frame) -> _iframeUnloaded = (frame) ->
bridge.call('destroyFrame', frameIdentifiers.get(frame)) bridge.call('destroyFrame', frameIdentifiers.get(frame))
......
...@@ -159,6 +159,21 @@ module.exports = class Document extends Plugin ...@@ -159,6 +159,21 @@ module.exports = class Document extends Plugin
if id[0..3] == "doi:" if id[0..3] == "doi:"
@metadata.link.push(href: id) @metadata.link.push(href: id)
# look for a link to identify the resource in dublincore metadata
dcRelationValues = @metadata.dc['relation.ispartof']
dcIdentifierValues = @metadata.dc['identifier']
if dcRelationValues && dcIdentifierValues
dcUrnRelationComponent =
dcRelationValues[dcRelationValues.length - 1]
dcUrnIdentifierComponent =
dcIdentifierValues[dcIdentifierValues.length - 1]
dcUrn = 'urn:x-dc:' +
encodeURIComponent(dcUrnRelationComponent) + '/' +
encodeURIComponent(dcUrnIdentifierComponent)
@metadata.link.push(href: dcUrn)
# set this as the documentFingerprint as a hint to include this in search queries
@metadata.documentFingerprint = dcUrn
_getFavicon: => _getFavicon: =>
for link in $("link") for link in $("link")
if $(link).prop("rel") in ["shortcut icon", "icon"] if $(link).prop("rel") in ["shortcut icon", "icon"]
......
...@@ -41,7 +41,8 @@ describe 'Document', -> ...@@ -41,7 +41,8 @@ describe 'Document', ->
head.append('<meta name="citation_title" content="Foo">') head.append('<meta name="citation_title" content="Foo">')
head.append('<meta name="citation_pdf_url" content="foo.pdf">') head.append('<meta name="citation_pdf_url" content="foo.pdf">')
head.append('<meta name="dc.identifier" content="doi:10.1175/JCLI-D-11-00015.1">') head.append('<meta name="dc.identifier" content="doi:10.1175/JCLI-D-11-00015.1">')
head.append('<meta name="dc:identifier" content="isbn:123456789">') head.append('<meta name="dc:identifier" content="foobar-abcxyz">')
head.append('<meta name="dc.relation.ispartof" content="isbn:123456789">')
head.append('<meta name="DC.type" content="Article">') head.append('<meta name="DC.type" content="Article">')
head.append('<meta property="og:url" content="http://example.com">') head.append('<meta property="og:url" content="http://example.com">')
head.append('<meta name="twitter:site" content="@okfn">') head.append('<meta name="twitter:site" content="@okfn">')
...@@ -65,7 +66,7 @@ describe 'Document', -> ...@@ -65,7 +66,7 @@ describe 'Document', ->
it 'should have links with absolute hrefs and types', -> it 'should have links with absolute hrefs and types', ->
assert.ok(metadata.link) assert.ok(metadata.link)
assert.equal(metadata.link.length, 9) assert.equal(metadata.link.length, 10)
assert.match(metadata.link[0].href, docBaseUri) assert.match(metadata.link[0].href, docBaseUri)
assert.equal(metadata.link[1].rel, "alternate") assert.equal(metadata.link[1].rel, "alternate")
assert.match(metadata.link[1].href, /^.+foo\.pdf$/) assert.match(metadata.link[1].href, /^.+foo\.pdf$/)
...@@ -84,8 +85,16 @@ describe 'Document', -> ...@@ -84,8 +85,16 @@ describe 'Document', ->
assert.equal(metadata.link[7].type, "application/pdf") assert.equal(metadata.link[7].type, "application/pdf")
assert.equal(metadata.link[8].href, "doi:10.1175/JCLI-D-11-00015.1") assert.equal(metadata.link[8].href, "doi:10.1175/JCLI-D-11-00015.1")
# Link derived from dc resource identifiers in the form of urn:x-dc:<container>/<identifier>
# Where <container> is the percent-encoded value of the last dc.relation.ispartof meta element
# and <identifier> is the percent-encoded value of the last dc.identifier meta element.
assert.equal(
metadata.link[9].href
"urn:x-dc:isbn%3A123456789/foobar-abcxyz"
)
it 'should ignore atom and RSS feeds and alternate languages', -> it 'should ignore atom and RSS feeds and alternate languages', ->
assert.equal(metadata.link.length, 9) assert.equal(metadata.link.length, 10)
it 'should have highwire metadata', -> it 'should have highwire metadata', ->
assert.ok(metadata.highwire) assert.ok(metadata.highwire)
...@@ -95,7 +104,8 @@ describe 'Document', -> ...@@ -95,7 +104,8 @@ describe 'Document', ->
it 'should have dublincore metadata', -> it 'should have dublincore metadata', ->
assert.ok(metadata.dc) assert.ok(metadata.dc)
assert.deepEqual(metadata.dc.identifier, ["doi:10.1175/JCLI-D-11-00015.1", "isbn:123456789"]) assert.deepEqual(metadata.dc.identifier, ["doi:10.1175/JCLI-D-11-00015.1", "foobar-abcxyz"])
assert.deepEqual(metadata.dc['relation.ispartof'], ["isbn:123456789"])
assert.deepEqual(metadata.dc.type, ["Article"]) assert.deepEqual(metadata.dc.type, ["Article"])
it 'should have facebook metadata', -> it 'should have facebook metadata', ->
...@@ -116,7 +126,7 @@ describe 'Document', -> ...@@ -116,7 +126,7 @@ describe 'Document', ->
it 'should have unique uris', -> it 'should have unique uris', ->
uris = testDocument.uris() uris = testDocument.uris()
assert.equal(uris.length, 7) assert.equal(uris.length, 8)
it 'uri() returns the canonical uri', -> it 'uri() returns the canonical uri', ->
uri = testDocument.uri() uri = testDocument.uri()
...@@ -128,6 +138,10 @@ describe 'Document', -> ...@@ -128,6 +138,10 @@ describe 'Document', ->
'http://example.com/images/icon.ico' 'http://example.com/images/icon.ico'
) )
it 'should have a documentFingerprint as the dc resource identifiers URN href', ->
assert.equal(metadata.documentFingerprint, metadata.link[9].href)
describe '#_absoluteUrl', -> describe '#_absoluteUrl', ->
it 'should add the protocol when the url starts with two slashes', -> it 'should add the protocol when the url starts with two slashes', ->
......
...@@ -5,8 +5,8 @@ Hammer = require('hammerjs') ...@@ -5,8 +5,8 @@ Hammer = require('hammerjs')
Host = require('./host') Host = require('./host')
annotationCounts = require('./annotation-counts') annotationCounts = require('./annotation-counts')
sidebarTrigger = require('./sidebar-trigger') sidebarTrigger = require('./sidebar-trigger')
events = require('../shared/bridge-events'); events = require('../shared/bridge-events')
features = require('./features'); features = require('./features')
# Minimum width to which the frame can be resized. # Minimum width to which the frame can be resized.
MIN_RESIZE = 280 MIN_RESIZE = 280
...@@ -35,6 +35,7 @@ module.exports = class Sidebar extends Host ...@@ -35,6 +35,7 @@ module.exports = class Sidebar extends Host
@plugins.BucketBar.element.on 'click', (event) => this.show() @plugins.BucketBar.element.on 'click', (event) => this.show()
if @plugins.Toolbar? if @plugins.Toolbar?
@toolbarWidth = parseInt(window.getComputedStyle(this.plugins.Toolbar.toolbar[0]).width)
this._setupGestures() this._setupGestures()
# The partner-provided callback functions. # The partner-provided callback functions.
...@@ -46,6 +47,11 @@ module.exports = class Sidebar extends Host ...@@ -46,6 +47,11 @@ module.exports = class Sidebar extends Host
@onProfileRequest = serviceConfig.onProfileRequest @onProfileRequest = serviceConfig.onProfileRequest
@onHelpRequest = serviceConfig.onHelpRequest @onHelpRequest = serviceConfig.onHelpRequest
@onLayoutChange = config.onLayoutChange
# initial layout notification
this._notifyOfLayoutChange(false)
this._setupSidebarEvents() this._setupSidebarEvents()
_setupSidebarEvents: -> _setupSidebarEvents: ->
...@@ -58,23 +64,23 @@ module.exports = class Sidebar extends Host ...@@ -58,23 +64,23 @@ module.exports = class Sidebar extends Host
@crossframe.on(events.LOGIN_REQUESTED, => @crossframe.on(events.LOGIN_REQUESTED, =>
if @onLoginRequest if @onLoginRequest
@onLoginRequest() @onLoginRequest()
); )
@crossframe.on(events.LOGOUT_REQUESTED, => @crossframe.on(events.LOGOUT_REQUESTED, =>
if @onLogoutRequest if @onLogoutRequest
@onLogoutRequest() @onLogoutRequest()
); )
@crossframe.on(events.SIGNUP_REQUESTED, => @crossframe.on(events.SIGNUP_REQUESTED, =>
if @onSignupRequest if @onSignupRequest
@onSignupRequest() @onSignupRequest()
); )
@crossframe.on(events.PROFILE_REQUESTED, => @crossframe.on(events.PROFILE_REQUESTED, =>
if @onProfileRequest if @onProfileRequest
@onProfileRequest() @onProfileRequest()
); )
@crossframe.on(events.HELP_REQUESTED, => @crossframe.on(events.HELP_REQUESTED, =>
if @onHelpRequest if @onHelpRequest
@onHelpRequest() @onHelpRequest()
); )
# Return this for chaining # Return this for chaining
this this
...@@ -120,6 +126,62 @@ module.exports = class Sidebar extends Host ...@@ -120,6 +126,62 @@ module.exports = class Sidebar extends Host
w = -m w = -m
@frame.css('margin-left', "#{m}px") @frame.css('margin-left', "#{m}px")
if w >= MIN_RESIZE then @frame.css('width', "#{w}px") if w >= MIN_RESIZE then @frame.css('width', "#{w}px")
this._notifyOfLayoutChange()
###*
# Notify integrator when sidebar layout changes via `onLayoutChange` callback.
#
# @param [boolean] explicitExpandedState - `true` or `false` if the sidebar
# is being directly opened or closed, as opposed to being resized via
# the sidebar's drag handles.
###
_notifyOfLayoutChange: (explicitExpandedState) =>
toolbarWidth = @toolbarWidth || 0
# The sidebar structure is:
#
# [ Toolbar ][ ]
# [ ---------- ][ Sidebar iframe container (@frame) ]
# [ Bucket Bar ][ ]
#
# The sidebar iframe is hidden or shown by adjusting the left margin of its
# container.
if @onLayoutChange
rect = @frame[0].getBoundingClientRect()
computedStyle = window.getComputedStyle(@frame[0])
width = parseInt(computedStyle.width)
leftMargin = parseInt(computedStyle.marginLeft)
# The width of the sidebar that is visible on screen, including the
# toolbar, which is always visible.
frameVisibleWidth = toolbarWidth
if explicitExpandedState?
# When we are explicitly saying to open or close, jump
# straight to the upper and lower bounding widths.
if explicitExpandedState
frameVisibleWidth += width
else
if leftMargin < MIN_RESIZE
# When the width hits its threshold of MIN_RESIZE,
# the left margin continues to push the sidebar off screen.
# So it's the best indicator of width when we get below that threshold.
# Note: when we hit the right edge, it will be -0
frameVisibleWidth += -leftMargin
else
frameVisibleWidth += width
# Since we have added logic on if this is an explicit show/hide
# and applied proper width to the visible value above, we can infer
# expanded state on that width value vs the lower bound
expanded = frameVisibleWidth > toolbarWidth
@onLayoutChange({
expanded: expanded,
width: if expanded then frameVisibleWidth else toolbarWidth,
height: rect.height,
})
onPan: (event) => onPan: (event) =>
switch event.type switch event.type
...@@ -176,6 +238,8 @@ module.exports = class Sidebar extends Host ...@@ -176,6 +238,8 @@ module.exports = class Sidebar extends Host
if @options.showHighlights == 'whenSidebarOpen' if @options.showHighlights == 'whenSidebarOpen'
@setVisibleHighlights(true) @setVisibleHighlights(true)
this._notifyOfLayoutChange(true)
hide: -> hide: ->
@frame.css 'margin-left': '' @frame.css 'margin-left': ''
@frame.addClass 'annotator-collapsed' @frame.addClass 'annotator-collapsed'
...@@ -188,6 +252,8 @@ module.exports = class Sidebar extends Host ...@@ -188,6 +252,8 @@ module.exports = class Sidebar extends Host
if @options.showHighlights == 'whenSidebarOpen' if @options.showHighlights == 'whenSidebarOpen'
@setVisibleHighlights(false) @setVisibleHighlights(false)
this._notifyOfLayoutChange(false)
isOpen: -> isOpen: ->
!@frame.hasClass('annotator-collapsed') !@frame.hasClass('annotator-collapsed')
......
...@@ -40,8 +40,7 @@ describe('CrossFrame multi-frame scenario', function () { ...@@ -40,8 +40,7 @@ describe('CrossFrame multi-frame scenario', function () {
options = { options = {
config: { config: {
enableMultiFrameSupport: true, clientUrl: 'data:,', // empty data uri
embedScriptUrl: 'data:,', // empty data uri
}, },
on: sandbox.stub(), on: sandbox.stub(),
emit: sandbox.stub(), emit: sandbox.stub(),
...@@ -112,7 +111,7 @@ describe('CrossFrame multi-frame scenario', function () { ...@@ -112,7 +111,7 @@ describe('CrossFrame multi-frame scenario', function () {
isLoaded(frame, function () { isLoaded(frame, function () {
var scriptElement = frame.contentDocument.querySelector('script[src]'); var scriptElement = frame.contentDocument.querySelector('script[src]');
assert(scriptElement, 'expected embed script to be injected'); assert(scriptElement, 'expected embed script to be injected');
assert.equal(scriptElement.src, options.config.embedScriptUrl, assert.equal(scriptElement.src, options.config.clientUrl,
'unexpected embed script source'); 'unexpected embed script source');
resolve(); resolve();
}); });
......
events = require('../../shared/bridge-events') events = require('../../shared/bridge-events')
proxyquire = require('proxyquire') proxyquire = require('proxyquire')
Sidebar = proxyquire('../sidebar', {})
rafStub = (fn) ->
fn()
Sidebar = proxyquire('../sidebar', { raf: rafStub })
describe 'Sidebar', -> describe 'Sidebar', ->
sandbox = sinon.sandbox.create() sandbox = sinon.sandbox.create()
...@@ -253,3 +257,67 @@ describe 'Sidebar', -> ...@@ -253,3 +257,67 @@ describe 'Sidebar', ->
assert.calledWith(fakeCrossFrame.call, 'setVisibleHighlights', true) assert.calledWith(fakeCrossFrame.call, 'setVisibleHighlights', true)
assert.calledWith(sidebar.publish, 'setVisibleHighlights', true) assert.calledWith(sidebar.publish, 'setVisibleHighlights', true)
describe 'layout change notifier', ->
layoutChangeHandlerSpy = null
sidebar = null
frame = null
DEFAULT_WIDTH = 350
DEFAULT_HEIGHT = 600
assertLayoutValues = (args, expectations) ->
expected = Object.assign {
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
expanded: true
}, expectations
assert.deepEqual args, expected
beforeEach ->
layoutChangeHandlerSpy = sandbox.spy()
sidebar = createSidebar { onLayoutChange: layoutChangeHandlerSpy, sidebarAppUrl: '/' }
# remove info about call that happens on creation of sidebar
layoutChangeHandlerSpy.reset()
frame = sidebar.frame[0]
Object.assign frame.style, {
display: 'block',
width: DEFAULT_WIDTH + 'px',
height: DEFAULT_HEIGHT + 'px',
# width is based on left position of the window,
# we need to apply the css that puts the frame in the
# correct position
position: 'fixed',
top: 0,
left: '100%',
}
document.body.appendChild frame
afterEach ->
frame.remove()
it 'notifies when sidebar changes expanded state', ->
sidebar.show()
assert.calledOnce layoutChangeHandlerSpy
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {expanded: true}
sidebar.hide()
assert.calledTwice layoutChangeHandlerSpy
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {
expanded: false,
width: 0,
}
it 'notifies when sidebar is panned left', ->
sidebar.gestureState = { initial: -DEFAULT_WIDTH }
sidebar.onPan({type: 'panleft', deltaX: -50})
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], { width: 400 }
it 'notifies when sidebar is panned right', ->
sidebar.gestureState = { initial: -DEFAULT_WIDTH }
sidebar.onPan({type: 'panright', deltaX: 50})
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], { width: 300 }
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
// Find all iframes within this iframe only // Find all iframes within this iframe only
function findFrames (container) { function findFrames (container) {
var frames = Array.from(container.getElementsByTagName('iframe')); const frames = Array.from(container.getElementsByTagName('iframe'));
return frames.filter(isValid); return frames.filter(isValid);
} }
...@@ -13,13 +13,13 @@ function hasHypothesis (iframe) { ...@@ -13,13 +13,13 @@ function hasHypothesis (iframe) {
// Inject embed.js into the iframe // Inject embed.js into the iframe
function injectHypothesis (iframe, scriptUrl, config) { function injectHypothesis (iframe, scriptUrl, config) {
var configElement = document.createElement('script'); const configElement = document.createElement('script');
configElement.className = 'js-hypothesis-config'; configElement.className = 'js-hypothesis-config';
configElement.type = 'application/json'; configElement.type = 'application/json';
configElement.innerText = JSON.stringify(config); configElement.innerText = JSON.stringify(config);
var src = scriptUrl; const src = scriptUrl;
var embedElement = document.createElement('script'); const embedElement = document.createElement('script');
embedElement.className = 'js-hypothesis-embed'; embedElement.className = 'js-hypothesis-embed';
embedElement.async = true; embedElement.async = true;
embedElement.src = src; embedElement.src = src;
...@@ -37,10 +37,27 @@ function isAccessible (iframe) { ...@@ -37,10 +37,27 @@ function isAccessible (iframe) {
} }
} }
// Check if this is an iframe that we want to inject embed.js into
/**
* Check if the frame elements being considered for injection have the
* basic heuristics for content that a user might want to annotate.
* Rules:
* - avoid our client iframe
* - iframe should be sizeable - to avoid the small advertisement and social plugins
*
* @param {HTMLIFrameElement} iframe the frame being checked
* @returns {boolean} result of our validity checks
*/
function isValid (iframe) { function isValid (iframe) {
// Currently only checks if it's not the h-sidebar
return iframe.className !== 'h-sidebar-iframe'; const isNotClientFrame = !iframe.classList.contains('h-sidebar-iframe');
const frameRect = iframe.getBoundingClientRect();
const MIN_WIDTH = 150;
const MIN_HEIGHT = 150;
const hasSizableContainer = frameRect.width > MIN_WIDTH && frameRect.height > MIN_HEIGHT;
return isNotClientFrame && hasSizableContainer;
} }
function isDocumentReady (iframe, callback) { function isDocumentReady (iframe, callback) {
...@@ -68,7 +85,6 @@ module.exports = { ...@@ -68,7 +85,6 @@ module.exports = {
hasHypothesis: hasHypothesis, hasHypothesis: hasHypothesis,
injectHypothesis: injectHypothesis, injectHypothesis: injectHypothesis,
isAccessible: isAccessible, isAccessible: isAccessible,
isValid: isValid,
isLoaded: isLoaded, isLoaded: isLoaded,
isDocumentReady: isDocumentReady, isDocumentReady: isDocumentReady,
}; };
'use strict';
const frameUtil = require('../frame-util');
describe('frameUtil', function () {
describe('findFrames', function () {
let container;
const _addFrameToContainer = (options={})=>{
const frame = document.createElement('iframe');
frame.className = options.className || '';
frame.style.height = `${(options.height || 150)}px`;
frame.style.width = `${(options.width || 150)}px`;
container.appendChild(frame);
return frame;
};
beforeEach(function () {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(function () {
container.remove();
});
it('should find valid frames', function () {
let foundFrames = frameUtil.findFrames(container);
assert.lengthOf(foundFrames, 0, 'no frames appended so none should be found');
const frame1 = _addFrameToContainer();
const frame2 = _addFrameToContainer();
foundFrames = frameUtil.findFrames(container);
assert.deepEqual(foundFrames, [frame1, frame2], 'appended frames should be found');
});
it('should not find small frames', function () {
// add frames that are small in both demensions
_addFrameToContainer({width: 140});
_addFrameToContainer({height: 140});
const foundFrames = frameUtil.findFrames(container);
assert.lengthOf(foundFrames, 0, 'frames with small demensions should not be found');
});
it('should not find hypothesis frames', function () {
_addFrameToContainer({className: 'h-sidebar-iframe other-class-too'});
const foundFrames = frameUtil.findFrames(container);
assert.lengthOf(foundFrames, 0, 'frames with hypothesis className should not be found');
});
});
});
...@@ -45,11 +45,19 @@ function bootHypothesisClient(doc, config) { ...@@ -45,11 +45,19 @@ function bootHypothesisClient(doc, config) {
// Register the URL of the sidebar app which the Hypothesis client should load. // Register the URL of the sidebar app which the Hypothesis client should load.
// The <link> tag is also used by browser extensions etc. to detect the // The <link> tag is also used by browser extensions etc. to detect the
// presence of the Hypothesis client on the page. // presence of the Hypothesis client on the page.
var baseUrl = doc.createElement('link'); var sidebarUrl = doc.createElement('link');
baseUrl.rel = 'sidebar'; sidebarUrl.rel = 'sidebar';
baseUrl.href = config.sidebarAppUrl; sidebarUrl.href = config.sidebarAppUrl;
baseUrl.type = 'application/annotator+html'; sidebarUrl.type = 'application/annotator+html';
doc.head.appendChild(baseUrl); doc.head.appendChild(sidebarUrl);
// Register the URL of the annotation client which is currently being used to drive
// annotation interactions.
var clientUrl = doc.createElement('link');
clientUrl.rel = 'hypothesis-client';
clientUrl.href = config.assetRoot + 'build/boot.js';
clientUrl.type = 'application/annotator+javascript';
doc.head.appendChild(clientUrl);
injectAssets(doc, config, [ injectAssets(doc, config, [
// Vendor code and polyfills // Vendor code and polyfills
......
...@@ -5,6 +5,12 @@ var queryString = require('query-string'); ...@@ -5,6 +5,12 @@ var queryString = require('query-string');
var resolve = require('./util/url-util').resolve; var resolve = require('./util/url-util').resolve;
var serviceConfig = require('./service-config'); var serviceConfig = require('./service-config');
/**
* @typedef RefreshOptions
* @property {boolean} persist - True if access tokens should be persisted for
* use in future sessions.
*/
/** /**
* OAuth-based authorization service. * OAuth-based authorization service.
* *
...@@ -12,7 +18,7 @@ var serviceConfig = require('./service-config'); ...@@ -12,7 +18,7 @@ var serviceConfig = require('./service-config');
* an opaque access token. * an opaque access token.
*/ */
// @ngInject // @ngInject
function auth($http, $window, flash, random, settings) { function auth($http, $window, flash, localStorage, random, settings) {
/** /**
* Authorization code from auth popup window. * Authorization code from auth popup window.
...@@ -46,11 +52,7 @@ function auth($http, $window, flash, random, settings) { ...@@ -46,11 +52,7 @@ function auth($http, $window, flash, random, settings) {
* An object holding the details of an access token from the tokenUrl endpoint. * An object holding the details of an access token from the tokenUrl endpoint.
* @typedef {Object} TokenInfo * @typedef {Object} TokenInfo
* @property {string} accessToken - The access token itself. * @property {string} accessToken - The access token itself.
* @property {number} expiresIn - The lifetime of the access token, * @property {number} expiresAt - The date when the timestamp will expire.
* in seconds.
* @property {Date} refreshAfter - A time before the access token's expiry
* time, after which the code should
* attempt to refresh the access token.
* @property {string} refreshToken - The refresh token that can be used to * @property {string} refreshToken - The refresh token that can be used to
* get a new access token. * get a new access token.
*/ */
...@@ -65,12 +67,10 @@ function auth($http, $window, flash, random, settings) { ...@@ -65,12 +67,10 @@ function auth($http, $window, flash, random, settings) {
var data = response.data; var data = response.data;
return { return {
accessToken: data.access_token, accessToken: data.access_token,
expiresIn: data.expires_in,
// We actually have to refresh the access token _before_ it expires. // Set the expiry date to some time before the actual expiry date so that
// If the access token expires in one hour, this should refresh it in // we will refresh it before it actually expires.
// about 55 mins. expiresAt: Date.now() + (data.expires_in * 1000 * 0.91),
refreshAfter: new Date(Date.now() + (data.expires_in * 1000 * 0.91)),
refreshToken: data.refresh_token, refreshToken: data.refresh_token,
}; };
...@@ -86,6 +86,59 @@ function auth($http, $window, flash, random, settings) { ...@@ -86,6 +86,59 @@ function auth($http, $window, flash, random, settings) {
return $http.post(tokenUrl, data, requestConfig); return $http.post(tokenUrl, data, requestConfig);
} }
function grantTokenFromHostPage() {
var cfg = serviceConfig(settings);
if (!cfg) {
return null;
}
return cfg.grantToken;
}
/**
* Return the storage key used for storing access/refresh token data for a given
* annotation service.
*/
function storageKey() {
// Use a unique key per annotation service. Currently OAuth tokens are only
// persisted for the default annotation service. If in future we support
// logging into other services from the client, this function will need to
// take the API URL as an argument.
var apiDomain = new URL(settings.apiUrl).hostname;
// Percent-encode periods to avoid conflict with section delimeters.
apiDomain = apiDomain.replace(/\./g, '%2E');
return `hypothesis.oauth.${apiDomain}.token`;
}
/**
* Fetch the last-saved access/refresh tokens for `authority` from local
* storage.
*/
function loadToken() {
var token = localStorage.getObject(storageKey());
if (!token ||
typeof token.accessToken !== 'string' ||
typeof token.refreshToken !== 'string' ||
typeof token.expiresAt !== 'number') {
return null;
}
return {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresAt: token.expiresAt,
};
}
/**
* Persist access & refresh tokens for future use.
*/
function saveToken(token) {
localStorage.setObject(storageKey(), token);
}
// Exchange the JWT grant token for an access token. // Exchange the JWT grant token for an access token.
// See https://tools.ietf.org/html/rfc7523#section-4 // See https://tools.ietf.org/html/rfc7523#section-4
function exchangeToken(grantToken) { function exchangeToken(grantToken) {
...@@ -104,56 +157,107 @@ function auth($http, $window, flash, random, settings) { ...@@ -104,56 +157,107 @@ function auth($http, $window, flash, random, settings) {
}); });
} }
// Exchange the refresh token for a new access token and refresh token pair. /**
// See https://tools.ietf.org/html/rfc6749#section-6 * Exchange the refresh token for a new access token and refresh token pair.
function refreshAccessToken(refreshToken) { * See https://tools.ietf.org/html/rfc6749#section-6
var data = {grant_type: 'refresh_token', refresh_token: refreshToken}; *
postToTokenUrl(data).then(function (response) { * @param {string} refreshToken
* @param {RefreshOptions} options
* @return {Promise<string|null>} Promise for the new access token
*/
function refreshAccessToken(refreshToken, options) {
var data = { grant_type: 'refresh_token', refresh_token: refreshToken };
return postToTokenUrl(data).then((response) => {
var tokenInfo = tokenInfoFrom(response); var tokenInfo = tokenInfoFrom(response);
refreshAccessTokenBeforeItExpires(tokenInfo);
if (options.persist) {
saveToken(tokenInfo);
}
refreshAccessTokenBeforeItExpires(tokenInfo, {
persist: options.persist,
});
accessTokenPromise = Promise.resolve(tokenInfo.accessToken); accessTokenPromise = Promise.resolve(tokenInfo.accessToken);
return tokenInfo.accessToken;
}).catch(function() { }).catch(function() {
showAccessTokenExpiredErrorMessage( showAccessTokenExpiredErrorMessage(
'You must reload the page to continue annotating.'); 'You must reload the page to continue annotating.');
return null;
}); });
} }
// Set a timeout to refresh the access token a few minutes before it expires. /**
function refreshAccessTokenBeforeItExpires(tokenInfo) { * Schedule a refresh of an access token a few minutes before it expires.
*
* @param {TokenInfo} tokenInfo
* @param {RefreshOptions} options
*/
function refreshAccessTokenBeforeItExpires(tokenInfo, options) {
// The delay, in milliseconds, before we will poll again to see if it's // The delay, in milliseconds, before we will poll again to see if it's
// time to refresh the access token. // time to refresh the access token.
var delay = 30000; var delay = 30000;
// If the token info's refreshAfter time will have passed before the next // If the token info's refreshAfter time will have passed before the next
// time we poll, then refresh the token this time. // time we poll, then refresh the token this time.
var refreshAfter = tokenInfo.refreshAfter.valueOf() - delay; var refreshAfter = tokenInfo.expiresAt - delay;
function refreshAccessTokenIfNearExpiry() { function refreshAccessTokenIfNearExpiry() {
if (Date.now() > refreshAfter) { if (Date.now() > refreshAfter) {
refreshAccessToken(tokenInfo.refreshToken); refreshAccessToken(tokenInfo.refreshToken, {
persist: options.persist,
});
} else { } else {
refreshAccessTokenBeforeItExpires(tokenInfo); refreshAccessTokenBeforeItExpires(tokenInfo, options);
} }
} }
window.setTimeout(refreshAccessTokenIfNearExpiry, delay); window.setTimeout(refreshAccessTokenIfNearExpiry, delay);
} }
/**
* Retrieve an access token for the API.
*
* @return {Promise<string>} The API access token.
*/
function tokenGetter() { function tokenGetter() {
if (!accessTokenPromise) { if (!accessTokenPromise) {
var grantToken = (serviceConfig(settings) || {}).grantToken || authCode; var grantToken = grantTokenFromHostPage();
if (grantToken) { if (grantToken) {
// Exchange host-page provided grant token for a new access token.
accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) { accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) {
refreshAccessTokenBeforeItExpires(tokenInfo); refreshAccessTokenBeforeItExpires(tokenInfo, { persist: false });
return tokenInfo.accessToken; return tokenInfo.accessToken;
}).catch(function(err) { }).catch(function(err) {
showAccessTokenExpiredErrorMessage( showAccessTokenExpiredErrorMessage(
'You must reload the page to annotate.'); 'You must reload the page to annotate.');
throw err; throw err;
}); });
} else if (authCode) {
// Exchange authorization code retrieved from login popup for a new
// access token.
accessTokenPromise = exchangeToken(authCode).then((tokenInfo) => {
saveToken(tokenInfo);
refreshAccessTokenBeforeItExpires(tokenInfo, { persist: true });
return tokenInfo.accessToken;
});
} else { } else {
// Attempt to load the tokens from the previous session.
var tokenInfo = loadToken();
if (!tokenInfo) {
// No token. The user will need to log in.
accessTokenPromise = Promise.resolve(null); accessTokenPromise = Promise.resolve(null);
} else if (Date.now() > tokenInfo.expiresAt) {
// Token has expired. Attempt to refresh it.
accessTokenPromise = refreshAccessToken(tokenInfo.refreshToken, {
persist: true,
});
} else {
// Token still valid, but schedule a refresh.
refreshAccessTokenBeforeItExpires(tokenInfo, { persist: true });
accessTokenPromise = Promise.resolve(tokenInfo.accessToken);
}
} }
} }
......
# This class will parse the search filter and produce a faceted search filter object
# It expects a search query string where the search term are separated by space character
# and collects them into the given term arrays
module.exports = class SearchFilter
# Splits a search term into filter and data
# i.e.
# 'user:johndoe' -> ['user', 'johndoe']
# 'example:text' -> [null, 'example:text']
_splitTerm: (term) ->
filter = term.slice 0, term.indexOf ":"
unless filter?
# The whole term is data
return [null, term]
if filter in ['group', 'quote', 'result', 'since',
'tag', 'text', 'uri', 'user']
data = term[filter.length+1..]
return [filter, data]
else
# The filter is not a power search filter, so the whole term is data
return [null, term]
# This function will slice the search-text input
# Slice character: space,
# but an expression between quotes (' or ") is considered one
# I.e from the string: "text user:john 'to be or not to be' it will produce:
# ["text", "user:john", "to be or not to be"]
_tokenize: (searchtext) ->
return [] unless searchtext
# Small helper function for removing quote characters
# from the beginning- and end of a string, if the
# quote characters are the same.
# I.e.
# 'foo' -> foo
# "bar" -> bar
# 'foo" -> 'foo"
# bar" -> bar"
_removeQuoteCharacter = (text) ->
start = text.slice 0,1
end = text.slice -1
if (start is '"' or start is "'") and (start == end)
text = text.slice 1, text.length - 1
text
tokens = searchtext.match /(?:[^\s"']+|"[^"]*"|'[^']*')+/g
# Cut the opening and closing quote characters
tokens = tokens.map _removeQuoteCharacter
# Remove quotes for power search.
# I.e. 'tag:"foo bar"' -> 'tag:foo bar'
for token, index in tokens
[filter, data] = @_splitTerm(token)
if filter?
tokens[index] = filter + ':' + (_removeQuoteCharacter data)
tokens
# Turns string query into object, where the properties are the search terms
toObject: (searchtext) ->
obj = {}
filterToBackendFilter = (filter) ->
if filter is 'tag'
'tags'
else
filter
addToObj = (key, data) ->
if obj[key]?
obj[key].push data
else
obj[key] = [data]
if searchtext
terms = @_tokenize(searchtext)
for term in terms
[filter, data] = @_splitTerm(term)
unless filter?
filter = 'any'
data = term
addToObj(filterToBackendFilter(filter), data)
obj
# This function will generate the facets from the search-text input
# It'll first tokenize it and then sorts them into facet lists
# The output will be a dict with the following structure:
# An object with facet_names as keys.
# A value for a key:
# [facet_name]:
# [operator]: 'and'|'or'|'min' (for the elements of the facet terms list)
# [lowercase]: true|false
# [terms]: an array for the matched terms for this facet
# The facet selection is done by analyzing each token.
# It generally expects a <facet_name>:<facet_term> structure for a token
# Where the facet names are: 'quote', 'result', 'since', 'tag', 'text', 'uri', 'user
# Anything that didn't match go to the 'any' facet
# For the 'since' facet the the time string is scanned and is converted to seconds
# So i.e the 'since:7min' token will be converted to 7*60 = 420 for the since facet value
generateFacetedFilter: (searchtext) ->
any = []
quote = []
result = []
since = []
tag = []
text = []
uri = []
user = []
if searchtext
terms = @_tokenize(searchtext)
for term in terms
filter = term.slice 0, term.indexOf ":"
unless filter? then filter = ""
switch filter
when 'quote' then quote.push term[6..]
when 'result' then result.push term[7..]
when 'since'
# We'll turn this into seconds
time = term[6..].toLowerCase()
if time.match /^\d+$/
# Only digits, assuming seconds
since.push time
if time.match /^\d+sec$/
# Time given in seconds
t = /^(\d+)sec$/.exec(time)[1]
since.push t
if time.match /^\d+min$/
# Time given in minutes
t = /^(\d+)min$/.exec(time)[1]
since.push t * 60
if time.match /^\d+hour$/
# Time given in hours
t = /^(\d+)hour$/.exec(time)[1]
since.push t * 60 * 60
if time.match /^\d+day$/
# Time given in days
t = /^(\d+)day$/.exec(time)[1]
since.push t * 60 * 60 * 24
if time.match /^\d+week$/
# Time given in week
t = /^(\d+)week$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 7
if time.match /^\d+month$/
# Time given in month
t = /^(\d+)month$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 30
if time.match /^\d+year$/
# Time given in year
t = /^(\d+)year$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 365
when 'tag' then tag.push term[4..]
when 'text' then text.push term[5..]
when 'uri' then uri.push term[4..]
when 'user' then user.push term[5..]
else any.push term
any:
terms: any
operator: 'and'
quote:
terms: quote
operator: 'and'
result:
terms: result
operator: 'min'
since:
terms: since
operator: 'and'
tag:
terms: tag
operator: 'and'
text:
terms: text
operator: 'and'
uri:
terms: uri
operator: 'or'
user:
terms: user
operator: 'or'
'use strict';
/**
* Splits a search term into filter and data.
*
* ie. 'user:johndoe' -> ['user', 'johndoe']
* 'example:text' -> [null, 'example:text']
*/
function splitTerm(term) {
var filter = term.slice(0, term.indexOf(':'));
if (!filter) {
// The whole term is data
return [null, term];
}
if (['group', 'quote', 'result', 'since',
'tag', 'text', 'uri', 'user'].includes(filter)) {
var data = term.slice(filter.length+1);
return [filter, data];
} else {
// The filter is not a power search filter, so the whole term is data
return [null, term];
}
}
/**
* Tokenize a search query.
*
* Splits `searchtext` into tokens, separated by spaces.
* Quoted phrases in `searchtext` are returned as a single token.
*/
function tokenize(searchtext) {
if (!searchtext) { return []; }
// Small helper function for removing quote characters
// from the beginning- and end of a string, if the
// quote characters are the same.
// I.e.
// 'foo' -> foo
// "bar" -> bar
// 'foo" -> 'foo"
// bar" -> bar"
var _removeQuoteCharacter = function(text) {
var start = text.slice(0,1);
var end = text.slice(-1);
if (((start === '"') || (start === "'")) && (start === end)) {
text = text.slice(1, text.length - 1);
}
return text;
};
var tokens = searchtext.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
// Cut the opening and closing quote characters
tokens = tokens.map(_removeQuoteCharacter);
// Remove quotes for power search.
// I.e. 'tag:"foo bar"' -> 'tag:foo bar'
for (var index = 0; index < tokens.length; index++) {
var token = tokens[index];
var [filter, data] = splitTerm(token);
if (filter) {
tokens[index] = filter + ':' + (_removeQuoteCharacter(data));
}
}
return tokens;
}
/**
* Parse a search query into a map of search field to term.
*
* @param {string} searchtext
* @return {Object}
*/
function toObject(searchtext) {
var obj = {};
var backendFilter = f => f === 'tag' ? 'tags' : f;
var addToObj = function(key, data) {
if (obj[key]) {
return obj[key].push(data);
} else {
return obj[key] = [data];
}
};
if (searchtext) {
var terms = tokenize(searchtext);
for (var term of terms) {
var [filter, data] = splitTerm(term);
if (!filter) {
filter = 'any';
data = term;
}
addToObj(backendFilter(filter), data);
}
}
return obj;
}
/**
* @typedef Facet
* @property {'and'|'or'|'min'} operator
* @property {boolean} lowercase
* @property {string[]} terms
*/
/**
* Parse a search query into a map of filters.
*
* Returns an object mapping facet names to Facet.
*
* Terms that are not associated with a particular facet are stored in the "any"
* facet.
*
* @param {string} searchtext
* @return {Object}
*/
function generateFacetedFilter(searchtext) {
var terms;
var any = [];
var quote = [];
var result = [];
var since = [];
var tag = [];
var text = [];
var uri = [];
var user = [];
if (searchtext) {
terms = tokenize(searchtext);
for (var term of terms) {
var t;
var filter = term.slice(0, term.indexOf(':'));
switch (filter) {
case 'quote':
quote.push(term.slice(6));
break;
case 'result':
result.push(term.slice(7));
break;
case 'since':
{
// We'll turn this into seconds
let time = term.slice(6).toLowerCase();
if (time.match(/^\d+$/)) {
// Only digits, assuming seconds
since.push(time * 1);
}
if (time.match(/^\d+sec$/)) {
// Time given in seconds
t = /^(\d+)sec$/.exec(time)[1];
since.push(t * 1);
}
if (time.match(/^\d+min$/)) {
// Time given in minutes
t = /^(\d+)min$/.exec(time)[1];
since.push(t * 60);
}
if (time.match(/^\d+hour$/)) {
// Time given in hours
t = /^(\d+)hour$/.exec(time)[1];
since.push(t * 60 * 60);
}
if (time.match(/^\d+day$/)) {
// Time given in days
t = /^(\d+)day$/.exec(time)[1];
since.push(t * 60 * 60 * 24);
}
if (time.match(/^\d+week$/)) {
// Time given in week
t = /^(\d+)week$/.exec(time)[1];
since.push(t * 60 * 60 * 24 * 7);
}
if (time.match(/^\d+month$/)) {
// Time given in month
t = /^(\d+)month$/.exec(time)[1];
since.push(t * 60 * 60 * 24 * 30);
}
if (time.match(/^\d+year$/)) {
// Time given in year
t = /^(\d+)year$/.exec(time)[1];
since.push(t * 60 * 60 * 24 * 365);
}
}
break;
case 'tag': tag.push(term.slice(4)); break;
case 'text': text.push(term.slice(5)); break;
case 'uri': uri.push(term.slice(4)); break;
case 'user': user.push(term.slice(5)); break;
default: any.push(term);
}
}
}
return {
any: {
terms: any,
operator: 'and',
},
quote: {
terms: quote,
operator: 'and',
},
result: {
terms: result,
operator: 'min',
},
since: {
terms: since,
operator: 'and',
},
tag: {
terms: tag,
operator: 'and',
},
text: {
terms: text,
operator: 'and',
},
uri: {
terms: uri,
operator: 'or',
},
user: {
terms: user,
operator: 'or',
},
};
}
// @ngInject
function searchFilter() {
return {
toObject,
generateFacetedFilter,
};
}
module.exports = searchFilter;
'use strict'; 'use strict';
var angular = require('angular');
var { stringify } = require('query-string'); var { stringify } = require('query-string');
var authService = require('../oauth-auth');
var DEFAULT_TOKEN_EXPIRES_IN_SECS = 1000; var DEFAULT_TOKEN_EXPIRES_IN_SECS = 1000;
var TOKEN_KEY = 'hypothesis.oauth.hypothes%2Eis.token';
class FakeWindow { class FakeWindow {
constructor() { constructor() {
...@@ -48,12 +48,18 @@ describe('sidebar.oauth-auth', function () { ...@@ -48,12 +48,18 @@ describe('sidebar.oauth-auth', function () {
var nowStub; var nowStub;
var fakeHttp; var fakeHttp;
var fakeFlash; var fakeFlash;
var fakeLocalStorage;
var fakeRandom; var fakeRandom;
var fakeWindow; var fakeWindow;
var fakeSettings; var fakeSettings;
var clock; var clock;
var successfulFirstAccessTokenPromise; var successfulFirstAccessTokenPromise;
before(() => {
angular.module('app', [])
.service('auth', require('../oauth-auth'));
});
beforeEach(function () { beforeEach(function () {
nowStub = sinon.stub(window.performance, 'now'); nowStub = sinon.stub(window.performance, 'now');
nowStub.returns(300); nowStub.returns(300);
...@@ -91,13 +97,23 @@ describe('sidebar.oauth-auth', function () { ...@@ -91,13 +97,23 @@ describe('sidebar.oauth-auth', function () {
fakeWindow = new FakeWindow(); fakeWindow = new FakeWindow();
auth = authService( fakeLocalStorage = {
fakeHttp, getObject: sinon.stub().returns(null),
fakeWindow, setObject: sinon.stub(),
fakeFlash, };
fakeRandom,
fakeSettings angular.mock.module('app', {
); $http: fakeHttp,
$window: fakeWindow,
flash: fakeFlash,
localStorage: fakeLocalStorage,
random: fakeRandom,
settings: fakeSettings,
});
angular.mock.inject((_auth_) => {
auth = _auth_;
});
clock = sinon.useFakeTimers(); clock = sinon.useFakeTimers();
}); });
...@@ -120,6 +136,12 @@ describe('sidebar.oauth-auth', function () { ...@@ -120,6 +136,12 @@ describe('sidebar.oauth-auth', function () {
}); });
}); });
it('should not persist access tokens fetched using a grant token', function () {
return auth.tokenGetter().then(() => {
assert.notCalled(fakeLocalStorage.setObject);
});
});
context('when the access token request fails', function() { context('when the access token request fails', function() {
beforeEach('make access token requests fail', function () { beforeEach('make access token requests fail', function () {
fakeHttp.post.returns(Promise.resolve({status: 500})); fakeHttp.post.returns(Promise.resolve({status: 500}));
...@@ -296,6 +318,132 @@ describe('sidebar.oauth-auth', function () { ...@@ -296,6 +318,132 @@ describe('sidebar.oauth-auth', function () {
}); });
}); });
describe('persistence of tokens to storage', () => {
/**
* Login and retrieve an auth code.
*/
function login() {
var loggedIn = auth.login();
fakeWindow.sendMessage({
type: 'authorization_response',
code: 'acode',
state: 'notrandom',
});
return loggedIn;
}
beforeEach(() => {
fakeSettings.services = [];
});
it('persists tokens retrieved via auth code exchanges to storage', () => {
return login().then(() => {
return auth.tokenGetter();
}).then(() => {
assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, {
accessToken: 'firstAccessToken',
refreshToken: 'firstRefreshToken',
expiresAt: 910000,
});
});
});
it('persists refreshed tokens to storage', () => {
// 1. Perform initial token exchange.
return login().then(() => {
return auth.tokenGetter();
}).then(() => {
// 2. Refresh access token.
fakeLocalStorage.setObject.reset();
fakeHttp.post.returns(Promise.resolve({
status: 200,
data: {
access_token: 'secondToken',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
refresh_token: 'secondRefreshToken',
},
}));
expireAccessToken();
return auth.tokenGetter();
}).then(() => {
// 3. Check that updated token was persisted to storage.
assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, {
accessToken: 'secondToken',
refreshToken: 'secondRefreshToken',
expiresAt: 1910000,
});
});
});
it('loads and uses tokens from storage', () => {
fakeLocalStorage.getObject.withArgs(TOKEN_KEY).returns({
accessToken: 'foo',
refreshToken: 'bar',
expiresAt: 123,
});
return auth.tokenGetter().then((token) => {
assert.equal(token, 'foo');
});
});
it('refreshes the token if it expired after loading from storage', () => {
// Store an expired access token.
clock.tick(200);
fakeLocalStorage.getObject.withArgs(TOKEN_KEY).returns({
accessToken: 'foo',
refreshToken: 'bar',
expiresAt: 123,
});
fakeHttp.post.returns(Promise.resolve({
status: 200,
data: {
access_token: 'secondToken',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
refresh_token: 'secondRefreshToken',
},
}));
// Fetch the token again from the service and check that it gets
// refreshed.
return auth.tokenGetter().then((token) => {
assert.equal(token, 'secondToken');
assert.calledWith(
fakeLocalStorage.setObject,
TOKEN_KEY,
{
accessToken: 'secondToken',
refreshToken: 'secondRefreshToken',
expiresAt: 910200,
}
);
});
});
[{
when: 'keys are missing',
data: {
accessToken: 'foo',
},
},{
when: 'data types are wrong',
data: {
accessToken: 123,
expiresAt: 'notanumber',
refreshToken: null,
},
}].forEach(({ when, data }) => {
context(when, () => {
it('ignores invalid tokens in storage', () => {
fakeLocalStorage.getObject.withArgs('foo').returns(data);
return auth.tokenGetter().then((token) => {
assert.equal(token, null);
});
});
});
});
});
describe('#login', () => { describe('#login', () => {
beforeEach(() => { beforeEach(() => {
......
{module, inject} = angular.mock
describe 'searchFilter', ->
sandbox = null
searchFilter = null
before ->
angular.module('h', [])
.service('searchFilter', require('../search-filter'))
beforeEach module('h')
beforeEach ->
sandbox = sinon.sandbox.create()
beforeEach inject (_searchFilter_) ->
searchFilter = _searchFilter_
afterEach ->
sandbox.restore()
describe 'toObject', ->
it 'puts a simple search string under the any filter', ->
query = 'foo'
result = searchFilter.toObject(query)
assert.equal(result.any[0], query)
it 'uses the filters as keys in the result object', ->
query = 'user:john text:foo quote:bar group:agroup other'
result = searchFilter.toObject(query)
assert.equal(result.any[0], 'other')
assert.equal(result.user[0], 'john')
assert.equal(result.text[0], 'foo')
assert.equal(result.quote[0], 'bar')
assert.equal(result.group[0], 'agroup')
it 'collects the same filters into a list', ->
query = 'user:john text:foo quote:bar other user:doe text:fuu text:fii'
result = searchFilter.toObject(query)
assert.equal(result.any[0], 'other')
assert.equal(result.user[0], 'john')
assert.equal(result.user[1], 'doe')
assert.equal(result.text[0], 'foo')
assert.equal(result.text[1], 'fuu')
assert.equal(result.text[2], 'fii')
assert.equal(result.quote[0], 'bar')
it 'preserves data with semicolon characters', ->
query = 'uri:http://test.uri'
result = searchFilter.toObject(query)
assert.equal(result.uri[0], 'http://test.uri')
it 'collects valid filters and puts invalid into the any category', ->
query = 'uri:test foo:bar text:hey john:doe quote:according hi-fi a:bc'
result = searchFilter.toObject(query)
assert.isFalse(result.foo?)
assert.isFalse(result.john?)
assert.isFalse(result.a?)
assert.equal(result.uri[0], 'test')
assert.equal(result.text[0], 'hey')
assert.equal(result.quote[0], 'according')
assert.equal(result.any[0], 'foo:bar')
assert.equal(result.any[1], 'john:doe')
assert.equal(result.any[2], 'hi-fi')
assert.equal(result.any[3], 'a:bc')
'use strict';
var searchFilter = require('../search-filter')();
describe('sidebar.search-filter', () => {
describe('#toObject', () => {
it('puts a simple search string under the any filter', () => {
var query = 'foo';
var result = searchFilter.toObject(query);
assert.equal(result.any[0], query);
});
it('uses the filters as keys in the result object', () => {
var query = 'user:john text:foo quote:bar group:agroup other';
var result = searchFilter.toObject(query);
assert.equal(result.any[0], 'other');
assert.equal(result.user[0], 'john');
assert.equal(result.text[0], 'foo');
assert.equal(result.quote[0], 'bar');
assert.equal(result.group[0], 'agroup');
});
it('collects the same filters into a list', () => {
var query = 'user:john text:foo quote:bar other user:doe text:fuu text:fii';
var result = searchFilter.toObject(query);
assert.equal(result.any[0], 'other');
assert.equal(result.user[0], 'john');
assert.equal(result.user[1], 'doe');
assert.equal(result.text[0], 'foo');
assert.equal(result.text[1], 'fuu');
assert.equal(result.text[2], 'fii');
assert.equal(result.quote[0], 'bar');
});
it('preserves data with semicolon characters', () => {
var query = 'uri:http://test.uri';
var result = searchFilter.toObject(query);
assert.equal(result.uri[0], 'http://test.uri');
});
it('collects valid filters and puts invalid into the "any" category', () => {
var query = 'uri:test foo:bar text:hey john:doe quote:according hi-fi a:bc';
var result = searchFilter.toObject(query);
assert.isUndefined(result.foo);
assert.isUndefined(result.john);
assert.isUndefined(result.a);
assert.equal(result.uri[0], 'test');
assert.equal(result.text[0], 'hey');
assert.equal(result.quote[0], 'according');
assert.equal(result.any[0], 'foo:bar');
assert.equal(result.any[1], 'john:doe');
assert.equal(result.any[2], 'hi-fi');
assert.equal(result.any[3], 'a:bc');
});
});
describe('#generateFacetedFilter', () => {
[{
query: 'one two three',
expectedFilter: {
any: {
operator: 'and',
terms: ['one', 'two', 'three'],
},
},
},{
query: 'tag:foo tag:bar',
expectedFilter: {
tag: {
operator: 'and',
terms: ['foo', 'bar'],
},
},
},{
query: 'quote:inthequote text:inthetext',
expectedFilter: {
quote: {
operator: 'and',
terms: ['inthequote'],
},
text: {
operator: 'and',
terms: ['inthetext'],
},
},
},{
query: 'user:john user:james',
expectedFilter: {
user: {
operator: 'or',
terms: ['john', 'james'],
},
},
},{
query: 'uri:https://example.org/article.html',
expectedFilter: {
uri: {
operator: 'or',
terms: ['https://example.org/article.html'],
},
},
}].forEach(({ query, expectedFilter }) => {
it('parses a search query', () => {
var filter = searchFilter.generateFacetedFilter(query);
// Remove empty facets.
Object.keys(filter).forEach((k) => {
if (filter[k].terms.length === 0) {
delete filter[k];
}
});
assert.deepEqual(filter, expectedFilter);
});
});
[{
timeExpr: '8sec',
expectedSecs: 8,
},{
timeExpr: '7min',
expectedSecs: 420,
},{
timeExpr: '7hour',
expectedSecs: 7 * 60 * 60,
},{
timeExpr: '4day',
expectedSecs: 4 * 60 * 60 * 24,
},{
timeExpr: '1week',
expectedSecs: 1 * 60 * 60 * 24 * 7,
},{
timeExpr: '2month',
expectedSecs: 2 * 60 * 60 * 24 * 30,
},{
timeExpr: '2year',
expectedSecs: 2 * 60 * 60 * 24 * 365,
},{
timeExpr: '5wibble',
expectedSecs: null,
}].forEach(({ timeExpr, expectedSecs }) => {
it('parses a "since:" query', () => {
var query = `since:${timeExpr}`;
var filter = searchFilter.generateFacetedFilter(query);
if (expectedSecs === null) {
assert.deepEqual(filter.since.terms, []);
} else {
assert.deepEqual(filter.since.terms, [expectedSecs]);
}
});
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment