From 8838740b466fac686d8299e54698eaa02c219abf Mon Sep 17 00:00:00 2001 From: "Brian R. Bondy" Date: Tue, 20 Sep 2016 15:07:05 -0400 Subject: [PATCH] Add /extensions endpoint --- package.json | 1 + src/controllers/extensions.js | 96 +++++++++++++++++++++++++++++++++ src/index.js | 6 ++- src/setup.js | 13 +++++ test/extensions.js | 99 +++++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/controllers/extensions.js create mode 100644 test/extensions.js diff --git a/package.json b/package.json index 949baee1..565f3c75 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "mongodb": "^2.1.4", "newrelic": "^1.25.3", "underscore": "^1.8.3", + "xmldoc": "^0.5.1", "yargs": "^3.31.0" }, "devDependencies": { diff --git a/src/controllers/extensions.js b/src/controllers/extensions.js new file mode 100644 index 00000000..61f8c7cd --- /dev/null +++ b/src/controllers/extensions.js @@ -0,0 +1,96 @@ +const builder = require('xmlbuilder') +const xmldoc = require('xmldoc') +const {comparableVersion} = require('../common') + +/** + * Extracts an array of requested extensions along with their version + * + * @param @requestXML - The input extension request XML protocol 3.0 + * @return undefined if there was an error parsing the document, or an array of + * [extensionId, extensionVersion] if successful. + */ +const getRequestedExtensions = (requestXML) => { + const doc = new xmldoc.XmlDocument(requestXML) + if (doc.attr.protocol !== '3.0') { + console.error('Only protocol v3 is supproted') + return undefined + } + const extensions = doc.childrenNamed('app') + .map((app) => [app.attr.appid, app.attr.version]) + return extensions +} + +/** + * Filters out to only the availableExtensions that should be updated for the request. + * For example some extensions may not be requested, and some may already have a fully + * updated, or even newer versions. + */ +const getExtensionsWithUpdates = (availableExtensions, requestedExtensions) => + requestedExtensions.reduce((resultExtensions, requestedExtension) => { + const foundExtension = availableExtensions.find((extension) => extension[0] === requestedExtension[0]) + if (foundExtension) { + if (comparableVersion(foundExtension[1]) > comparableVersion(requestedExtension[1])) { + resultExtensions.push(foundExtension) + } + } + return resultExtensions + }, []) + +const getExtensionsResponse = (baseCRXUrl, extensions) => { + const doc = builder + .create('response') + .att('protocol', '3.0') + .att('server', 'prod') + extensions.forEach(([extensionId, extensionVersion, extensionSHA256]) => { + doc.ele('app') + .att('appid', extensionId) + .ele('updatecheck') + .att('status', 'ok') + .ele('urls') + .ele('url') + .att('codebase', `${baseCRXUrl}/${extensionId}/extension_${extensionVersion.replace(/\./g, '_')}.crx`) + .up() + .up() + .ele('manifest') + .att('version', extensionVersion) + .ele('packages') + .ele('package') + .att('name', `extension_${extensionVersion.replace(/\./g, '_')}.crx`) + .att('hash_sha256', extensionSHA256) + .att('required', true) + .up() + .up() + .up() + }) + return doc.toString({ pretty: true }) +} + +const setup = (runtime, availableExtensions) => { + let extensionsRoute = { + method: ['POST'], + path: '/extensions', + config: { + handler: function (request, reply) { + const requestedExtensions = getRequestedExtensions(request.payload.toString()) + const extensionsWithUpdates = getExtensionsWithUpdates(availableExtensions, requestedExtensions) + reply(getExtensionsResponse('https://s3.amazonaws.com/brave-extensions/release', extensionsWithUpdates)) + .type('application/xml') + }, + payload: { + parse: false, + allow: 'application/xml' + } + } + } + + return [ + extensionsRoute + ] +} + +module.exports = { + getRequestedExtensions, + getExtensionsWithUpdates, + getExtensionsResponse, + setup +} diff --git a/src/index.js b/src/index.js index 154ca132..46d9de4a 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,7 @@ let mq = require('./mq') // Read in the channel / platform releases meta-data let releases = setup.readReleases('data') +let extensions = setup.readExtensions() if (process.env.DEBUG) { console.log(_.keys(releases)) @@ -37,7 +38,8 @@ mq.setup((sender) => { } // POST, DEL and GET /1/releases/{platform}/{version} - let routes = require('./controllers/releases').setup(runtime, releases) + let releaseRoutes = require('./controllers/releases').setup(runtime, releases) + let extensionRoutes = require('./controllers/extensions').setup(runtime, extensions) let crashes = require('./controllers/crashes').setup(runtime) let monitoring = require('./controllers/monitoring').setup(runtime) @@ -80,7 +82,7 @@ mq.setup((sender) => { server.route( [ common.root - ].concat(routes, crashes, monitoring) + ].concat(releaseRoutes, extensionRoutes, crashes, monitoring) ) server.start((err) => { diff --git a/src/setup.js b/src/setup.js index 3758fe89..6574177d 100644 --- a/src/setup.js +++ b/src/setup.js @@ -46,3 +46,16 @@ exports.readReleases = (directory) => { return releases } + +// I'm not sure how we'll organize this in the future, but for now just pass along static data +// Format is: [extensionId, version, hash] +exports.readExtensions = () => [ + // 1Password + ['aomjjhallfgjeglblehebfpbcfeobpgk', '4.5.9.90', 'f75d7808766429ec63ec41d948c1cb6a486407945d604961c6adf54fe3f459b7'], + // PDFJS + ['oemmndcbldboiebfnladdacbdfmadadm', '1.5.294', '499e05d5cde9a1e735e29fa49af7839690f34eb27a3d952b8e4396ea50c77526'], + // Dashlane + ['fdjamakpfbbddfjaooikfcpapjohcfmg', '4.2.4', '0be29a787290db4c554fd7c77e5c45939d2161688b6cb6b51d39cdedb9cc69d4'], + // LastPass + ['hdokiejnpimakedhajhdlcegeplioahd', '4.1.28', '1e94a15dfaa59afd8ceb8b8cace7194aea3cc718d9a77fcff812eac918246e80'] +] diff --git a/test/extensions.js b/test/extensions.js new file mode 100644 index 00000000..64ef095e --- /dev/null +++ b/test/extensions.js @@ -0,0 +1,99 @@ +var tap = require('tap') + +var {getRequestedExtensions, getExtensionsWithUpdates} = require('../dist/controllers/extensions') + +const request = (appId) => (version) => ` + + + + + + + + +` +const onePasswordRequest = request('aomjjhallfgjeglblehebfpbcfeobpgk') +const unknownExtensionRequest = request('this-is-a-fake-id') +const onePasswordAndPDFJSRequest = (onePasswordVersion, pdfJSVersion) => ` + + + + + + + + + + + + +` +const noUpdatesRequest = ` + + + + +` +const unsupportedProtocolRequest = ` + + + + + +` + +const availableExtensions = [ + ['aomjjhallfgjeglblehebfpbcfeobpgk', '4.5.9.90', 'f75d7808766429ec63ec41d948c1cb6a486407945d604961c6adf54fe3f459b7'], + // PDFJS + ['oemmndcbldboiebfnladdacbdfmadadm', '1.5.294', '499e05d5cde9a1e735e29fa49af7839690f34eb27a3d952b8e4396ea50c77526'], + // Dashlane + ['fdjamakpfbbddfjaooikfcpapjohcfmg', '4.2.4', '0be29a787290db4c554fd7c77e5c45939d2161688b6cb6b51d39cdedb9cc69d4'], + // LastPass + ['hdokiejnpimakedhajhdlcegeplioahd', '4.1.28', '1e94a15dfaa59afd8ceb8b8cace7194aea3cc718d9a77fcff812eac918246e80'] +] + +tap.test('Extracts extension information from requests', (test) => { + tap.same(getRequestedExtensions(onePasswordRequest('0.0.0.0')), [['aomjjhallfgjeglblehebfpbcfeobpgk', '0.0.0.0']]) + tap.same(getRequestedExtensions(onePasswordRequest('4.5.9.90')), [['aomjjhallfgjeglblehebfpbcfeobpgk', '4.5.9.90']]) + tap.same(getRequestedExtensions(onePasswordAndPDFJSRequest('4.5.9.90', '1.5.294')), [['aomjjhallfgjeglblehebfpbcfeobpgk', '4.5.9.90'], ['oemmndcbldboiebfnladdacbdfmadadm', '1.5.294']]) + tap.equal(getRequestedExtensions(unsupportedProtocolRequest), undefined) + test.end() +}) + +tap.test('Initial update for an extension works', (test) => { + tap.same(getExtensionsWithUpdates(availableExtensions, getRequestedExtensions(onePasswordRequest('0.0.0.0'))), + [ + ['aomjjhallfgjeglblehebfpbcfeobpgk', '4.5.9.90', 'f75d7808766429ec63ec41d948c1cb6a486407945d604961c6adf54fe3f459b7'] + ]) + test.end() +}) + +tap.test('No updates returned for same version', (test) => { + tap.same(getExtensionsWithUpdates(availableExtensions, getRequestedExtensions(onePasswordRequest('4.5.9.90'))), []) + test.end() +}) + +tap.test('No updates returned for unknown extension ID', (test) => { + tap.same(getExtensionsWithUpdates(availableExtensions, getRequestedExtensions(unknownExtensionRequest('0.0.0.0'))), []) + test.end() +}) + +tap.test('No updates returned for newer extension ID', (test) => { + tap.same(getExtensionsWithUpdates(availableExtensions, getRequestedExtensions(onePasswordRequest('9.5.9.90'))), []) + test.end() +}) + +tap.test('Blank update request returns no updates', (test) => { + tap.same(getExtensionsWithUpdates(availableExtensions, getRequestedExtensions(noUpdatesRequest)), []) + test.end() +}) + +tap.test('Update for multiple extensions works', (test) => { + tap.same(getExtensionsWithUpdates(availableExtensions, getRequestedExtensions(onePasswordAndPDFJSRequest('0.0.0.0', '0.0.0.0'))), + [ + ['aomjjhallfgjeglblehebfpbcfeobpgk', '4.5.9.90', 'f75d7808766429ec63ec41d948c1cb6a486407945d604961c6adf54fe3f459b7'], + ['oemmndcbldboiebfnladdacbdfmadadm', '1.5.294', '499e05d5cde9a1e735e29fa49af7839690f34eb27a3d952b8e4396ea50c77526'] + ]) + test.end() +}) +