From 02d3fdcb22e494f086aeb5df56efc221a185d7e3 Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Mon, 21 Dec 2020 14:57:23 +0100 Subject: [PATCH] feat: added user permission to plugin csp policy --- common/util.js | 32 ++ deploy/app.js | 69 +-- deploy/csp/index.js | 121 +++-- deploy/csp/index.spec.js | 109 +++++ deploy/lruStore/index.js | 14 +- deploy/package.json | 2 +- deploy/plugins/index.js | 29 ++ deploy/user/index.js | 43 ++ deploy/user/index.spec.js | 192 ++++++++ docs/releases/v2.4.0.md | 9 + mkdocs.yml | 1 + package.json | 4 +- .../atlasViewer.pluginService.service.spec.ts | 425 +++++++++++++----- .../atlasViewer.pluginService.service.ts | 320 ++++++++----- .../confirmDialog.component.spec.ts | 54 +++ .../confirmDialog/confirmDialog.component.ts | 8 +- .../confirmDialog/confirmDialog.template.html | 13 +- src/services/dialogService.service.ts | 1 + src/services/effect/pluginUseEffect.spec.ts | 3 +- src/services/effect/pluginUseEffect.ts | 3 +- src/services/state/pluginState.helper.ts | 5 + src/services/state/pluginState.store.ts | 6 +- .../state/userConfigState.helper.spec.ts | 47 ++ src/services/state/userConfigState.helper.ts | 11 + .../state/userConfigState.store.spec.ts | 81 ++++ src/services/state/userConfigState.store.ts | 160 ++++--- src/services/state/viewerConfig.store.ts | 3 +- src/services/state/viewerState/actions.ts | 5 + src/services/stateStore.service.ts | 45 +- src/ui/config/config.template.html | 5 + .../config/pluginCsp/pluginCsp.component.ts | 32 ++ src/ui/config/pluginCsp/pluginCsp.style.css | 0 .../config/pluginCsp/pluginCsp.template.html | 52 +++ .../nehubaContainer.component.spec.ts | 4 +- src/ui/ui.module.ts | 2 + src/util/fn.ts | 20 + src/util/pipes/objToArray.pipe.spec.ts | 17 + src/util/pipes/objToArray.pipe.ts | 22 + src/util/util.module.ts | 3 + 39 files changed, 1550 insertions(+), 422 deletions(-) create mode 100644 deploy/csp/index.spec.js create mode 100644 deploy/user/index.spec.js create mode 100644 docs/releases/v2.4.0.md create mode 100644 src/components/confirmDialog/confirmDialog.component.spec.ts create mode 100644 src/services/state/pluginState.helper.ts create mode 100644 src/services/state/userConfigState.helper.spec.ts create mode 100644 src/services/state/userConfigState.helper.ts create mode 100644 src/services/state/userConfigState.store.spec.ts create mode 100644 src/ui/config/pluginCsp/pluginCsp.component.ts create mode 100644 src/ui/config/pluginCsp/pluginCsp.style.css create mode 100644 src/ui/config/pluginCsp/pluginCsp.template.html create mode 100644 src/util/pipes/objToArray.pipe.spec.ts create mode 100644 src/util/pipes/objToArray.pipe.ts diff --git a/common/util.js b/common/util.js index 118d3079a..4029c6744 100644 --- a/common/util.js +++ b/common/util.js @@ -172,4 +172,36 @@ ) ) } + + exports.serialiseParcellationRegion = ({ ngId, labelIndex }) => { + if (!ngId) { + throw new Error(`#serialiseParcellationRegion error: ngId must be defined`) + } + + if (!labelIndex) { + throw new Error(`#serialiseParcellationRegion error labelIndex must be defined`) + } + + return `${ngId}#${labelIndex}` + } + + const deserialiseParcRegionId = labelIndexId => { + const _ = labelIndexId && labelIndexId.split && labelIndexId.split('#') || [] + const ngId = _.length > 1 + ? _[0] + : null + const labelIndex = _.length > 1 + ? Number(_[1]) + : _.length === 0 + ? null + : Number(_[0]) + return { labelIndex, ngId } + } + + exports.deserialiseParcRegionId = deserialiseParcRegionId + + exports.deserialiseParcellationRegion = ({ region, labelIndexId, inheritedNgId = 'root' }) => { + const { labelIndex, ngId } = deserialiseParcRegionId(labelIndexId) + } + })(typeof exports === 'undefined' ? module.exports : exports) diff --git a/deploy/app.js b/deploy/app.js index d83356e4e..420720e79 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -7,6 +7,40 @@ const MemoryStore = require('memorystore')(session) const crypto = require('crypto') const cookieParser = require('cookie-parser') +/** + * memorystore (or perhaps lru-cache itself) does not properly close when server shuts + * this causes problems during tests + * So when testing app.js, set USE_DEFAULT_MEMORY_STORE to true + * see app.spec.js + */ +const { USE_DEFAULT_MEMORY_STORE } = process.env +const store = USE_DEFAULT_MEMORY_STORE + ? (console.warn(`USE_DEFAULT_MEMORY_STORE is set to true, memleak expected. Do NOT use in prod.`), null) + : new MemoryStore({ + checkPeriod: 86400000 + }) + +const SESSIONSECRET = process.env.SESSIONSECRET || 'this is not really a random session secret' + +/** + * passport application of oidc requires session + */ +app.use(session({ + secret: SESSIONSECRET, + resave: true, + saveUninitialized: true, + store, +})) + +/** + * configure CSP + */ +if (process.env.DISABLE_CSP && process.env.DISABLE_CSP === 'true') { + console.warn(`DISABLE_CSP is set to true, csp will not be enabled`) +} else { + require('./csp')(app) +} + const { router: regionalFeaturesRouter, regionalFeatureIsReady } = require('./regionalFeatures') const { router: datasetRouter, ready: datasetRouteIsReady } = require('./datasets') @@ -46,46 +80,13 @@ app.use((req, _, next) => { const { configureAuth, ready: authReady } = require('./auth') -/** - * memorystore (or perhaps lru-cache itself) does not properly close when server shuts - * this causes problems during tests - * So when testing app.js, set USE_DEFAULT_MEMORY_STORE to true - * see app.spec.js - */ -const { USE_DEFAULT_MEMORY_STORE } = process.env -const store = USE_DEFAULT_MEMORY_STORE - ? (console.warn(`USE_DEFAULT_MEMORY_STORE is set to true, memleak expected. Do NOT use in prod.`), null) - : new MemoryStore({ - checkPeriod: 86400000 - }) - -const SESSIONSECRET = process.env.SESSIONSECRET || 'this is not really a random session secret' - -/** - * passport application of oidc requires session - */ -app.use(session({ - secret: SESSIONSECRET, - resave: true, - saveUninitialized: false, - store -})) - -/** - * configure CSP - */ -if (process.env.DISABLE_CSP && process.env.DISABLE_CSP === 'true') { - console.warn(`DISABLE_CSP is set to true, csp will not be enabled`) -} else { - require('./csp')(app) -} /** * configure Auth * async function, but can start server without */ -(async () => { +const _ = (async () => { await configureAuth(app) app.use('/user', require('./user')) })() diff --git a/deploy/csp/index.js b/deploy/csp/index.js index 1b702ad03..8f664fae9 100644 --- a/deploy/csp/index.js +++ b/deploy/csp/index.js @@ -55,52 +55,81 @@ module.exports = (app) => { next() }) - app.use(csp({ - directives: { - defaultSrc: [ - ...defaultAllowedSites, - ...WHITE_LIST_SRC - ], - styleSrc: [ - ...defaultAllowedSites, - 'stackpath.bootstrapcdn.com/bootstrap/4.3.1/', - 'use.fontawesome.com/releases/v5.8.1/', - "'unsafe-inline'", // required for angular [style.xxx] bindings - ...WHITE_LIST_SRC - ], - fontSrc: [ - "'self'", - 'use.fontawesome.com/releases/v5.8.1/', - ...WHITE_LIST_SRC - ], - connectSrc: [ - ...defaultAllowedSites, - ...connectSrc, - ...WHITE_LIST_SRC - ], - imgSrc: [ - "'self'", - "hbp-kg-dataset-previewer.apps.hbp.eu/v2/" - ], - scriptSrc:[ - "'self'", - 'code.jquery.com', // plugin load external library -> jquery v2 and v3 - 'cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/', // plugin load external library -> web components - 'cdnjs.cloudflare.com/ajax/libs/d3/', // plugin load external lib -> d3 - 'cdn.jsdelivr.net/npm/vue@2.5.16/', // plugin load external lib -> vue 2 - 'cdn.jsdelivr.net/npm/preact@8.4.2/', // plugin load external lib -> preact - 'unpkg.com/react@16/umd/', // plugin load external lib -> react - 'unpkg.com/kg-dataset-previewer@1.1.5/', // preview component - 'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax - (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, - ...SCRIPT_SRC, - ...WHITE_LIST_SRC, - ...defaultAllowedSites - ], - reportUri: CSP_REPORT_URI || '/report-violation' - }, - reportOnly - })) + app.use((req, res, next) => { + const permittedCsp = (req.session && req.session.permittedCsp) || {} + const userConnectSrc = [] + const userScriptSrc = [] + for (const key in permittedCsp) { + userConnectSrc.push( + ...(permittedCsp[key]['connect-src'] || []), + ...(permittedCsp[key]['connectSrc'] || []) + ) + userScriptSrc.push( + ...(permittedCsp[key]['script-src'] || []), + ...(permittedCsp[key]['scriptSrc'] || []) + ) + } + res.locals.userCsp = { + userConnectSrc, + userScriptSrc, + } + next() + }) + + app.use((req, res, next) => { + const { + userConnectSrc = [], + userScriptSrc = [], + } = res.locals.userCsp || {} + csp({ + directives: { + defaultSrc: [ + ...defaultAllowedSites, + ...WHITE_LIST_SRC + ], + styleSrc: [ + ...defaultAllowedSites, + 'stackpath.bootstrapcdn.com/bootstrap/4.3.1/', + 'use.fontawesome.com/releases/v5.8.1/', + "'unsafe-inline'", // required for angular [style.xxx] bindings + ...WHITE_LIST_SRC + ], + fontSrc: [ + "'self'", + 'use.fontawesome.com/releases/v5.8.1/', + ...WHITE_LIST_SRC + ], + connectSrc: [ + ...userConnectSrc, + ...defaultAllowedSites, + ...connectSrc, + ...WHITE_LIST_SRC + ], + imgSrc: [ + "'self'", + "hbp-kg-dataset-previewer.apps.hbp.eu/v2/" + ], + scriptSrc:[ + "'self'", + ...userScriptSrc, + 'code.jquery.com', // plugin load external library -> jquery v2 and v3 + 'cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/', // plugin load external library -> web components + 'cdnjs.cloudflare.com/ajax/libs/d3/', // plugin load external lib -> d3 + 'cdn.jsdelivr.net/npm/vue@2.5.16/', // plugin load external lib -> vue 2 + 'cdn.jsdelivr.net/npm/preact@8.4.2/', // plugin load external lib -> preact + 'unpkg.com/react@16/umd/', // plugin load external lib -> react + 'unpkg.com/kg-dataset-previewer@1.1.5/', // preview component + 'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax + (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, + ...SCRIPT_SRC, + ...WHITE_LIST_SRC, + ...defaultAllowedSites + ], + reportUri: CSP_REPORT_URI || '/report-violation' + }, + reportOnly + })(req, res, next) + }) if (!CSP_REPORT_URI) { app.post('/report-violation', bodyParser.json({ diff --git a/deploy/csp/index.spec.js b/deploy/csp/index.spec.js new file mode 100644 index 000000000..70b4e1442 --- /dev/null +++ b/deploy/csp/index.spec.js @@ -0,0 +1,109 @@ +const express = require('express') +const app = express() +const csp = require('./index') +const got = require('got') +const { expect, assert } = require('chai') + +const checkBaseFn = async (rules = []) => { + + const resp = await got(`http://localhost:1234/`) + const stringifiedHeader = JSON.stringify(resp.headers) + + /** + * expect stats.humanbrainproject.eu and neuroglancer.humanbrainproject.eu to be present + */ + assert( + /stats\.humanbrainproject\.eu/.test(stringifiedHeader), + 'stats.humanbrainproject.eu present in header' + ) + + assert( + /neuroglancer\.humanbrainproject\.eu/.test(stringifiedHeader), + 'neuroglancer.humanbrainproject.eu present in header' + ) + + assert( + /content-security-policy/.test(stringifiedHeader), + 'content-security-policy present in header' + ) + + for (const rule of rules) { + assert( + rule.test(stringifiedHeader), + `${rule.toString()} present in header` + ) + } +} + +describe('> csp/index.js', () => { + let server, permittedCsp + const middleware = (req, res, next) => { + if (!!permittedCsp) { + req.session = { permittedCsp } + } + next() + } + before(done => { + app.use(middleware) + csp(app) + app.get('/', (req, res) => { + res.status(200).send('OK') + }) + server = app.listen(1234, () => console.log(`app listening`)) + setTimeout(() => { + done() + }, 1000); + }) + + it('> should work when session is unset', async () => { + await checkBaseFn() + }) + + describe('> if session and permittedCsp are both set', () => { + describe('> if permittedCsp is malformed', () => { + describe('> if permittedCsp is set to string', () => { + before(() => { + permittedCsp = 'hello world' + }) + it('> base csp should work', async () => { + await checkBaseFn() + }) + }) + + describe('> if permittedCsp is number', () => { + before(() => { + permittedCsp = 420 + }) + it('> base csp should work', async () => { + await checkBaseFn() + }) + }) + }) + + describe('> if premittedCsp defines', () => { + + before(() => { + permittedCsp = { + 'foo-bar': { + 'connect-src': [ + 'connect.int.dev' + ], + 'script-src': [ + 'script.int.dev' + ] + } + } + }) + + it('> csp should include permittedCsp should work', async () => { + await checkBaseFn([ + /connect\.int\.dev/, + /script\.int\.dev/, + ]) + }) + }) + }) + after(done => { + server.close(done) + }) +}) \ No newline at end of file diff --git a/deploy/lruStore/index.js b/deploy/lruStore/index.js index e63e2e139..564b89566 100644 --- a/deploy/lruStore/index.js +++ b/deploy/lruStore/index.js @@ -58,11 +58,14 @@ if (redisURL) { const keys = [] + /** + * maxage in milli seconds + */ exports.store = { - set: async (key, val) => { + set: async (key, val, { maxAge } = {}) => { ensureString(key) ensureString(val) - asyncSet(key, val) + asyncSet(key, val, ...( maxAge ? [ 'PX', maxAge ] : [] )) keys.push(key) }, get: async (key) => { @@ -90,10 +93,13 @@ if (redisURL) { }) exports.store = { - set: async (key, val) => { + /** + * maxage in milli seconds + */ + set: async (key, val, { maxAge } = {}) => { ensureString(key) ensureString(val) - store.set(key, val) + store.set(key, val, ...( maxAge ? [ maxAge ] : [] )) }, get: async (key) => { ensureString(key) diff --git a/deploy/package.json b/deploy/package.json index 26715ffd6..39df6e6fc 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -18,6 +18,7 @@ "express": "^4.16.4", "express-rate-limit": "^5.1.1", "express-session": "^1.15.6", + "got": "^10.5.5", "hbp-seafile": "0.0.6", "helmet-csp": "^2.8.0", "jwt-decode": "^2.2.0", @@ -39,7 +40,6 @@ "cors": "^2.8.5", "dotenv": "^6.2.0", "google-spreadsheet": "^3.0.13", - "got": "^10.5.5", "mocha": "^6.1.4", "nock": "^12.0.3", "sinon": "^8.0.2" diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js index b9e0e92d5..cd829c98d 100644 --- a/deploy/plugins/index.js +++ b/deploy/plugins/index.js @@ -4,6 +4,8 @@ */ const express = require('express') +const { store } = require('../lruStore') +const got = require('got') const router = express.Router() const PLUGIN_URLS = (process.env.PLUGIN_URLS && process.env.PLUGIN_URLS.split(';')) || [] const STAGING_PLUGIN_URLS = (process.env.STAGING_PLUGIN_URLS && process.env.STAGING_PLUGIN_URLS.split(';')) || [] @@ -15,4 +17,31 @@ router.get('', (_req, res) => { ]) }) +const getKey = url => `plugin:manifest-cache:${url}}` + +router.get('/manifests', async (_req, res) => { + + const allManifests = await Promise.all([ + ...PLUGIN_URLS, + ...STAGING_PLUGIN_URLS + ].map(async url => { + const key = getKey(url) + try { + const storedManifest = await store.get(key) + if (storedManifest) return JSON.parse(storedManifest) + else throw `not found` + } catch (e) { + const resp = await got(url) + const json = JSON.parse(resp.body) + + await store.set(key, JSON.stringify(json), { maxAge: 1000 * 60 * 60 }) + return json + } + })) + + res.status(200).json( + allManifests.filter(v => !!v) + ) +}) + module.exports = router \ No newline at end of file diff --git a/deploy/user/index.js b/deploy/user/index.js index 9d1809c96..1d6a364aa 100644 --- a/deploy/user/index.js +++ b/deploy/user/index.js @@ -23,6 +23,49 @@ router.get('/config', loggedInOnlyMiddleware, async (req, res) => { } }) +router.get('/pluginPermissions', async (req, res) => { + const { user } = req + /** + * only using session to store user csp for now + * in future, if use is logged in, look for **signed** config file, and verify the signature + */ + const permittedCsp = req.session.permittedCsp || {} + res.status(200).json(permittedCsp) +}) + +router.post('/pluginPermissions', bodyParser.json(), async (req, res) => { + const { user, body } = req + /** + * only using session to store user csp for now + * in future, if use is logged in, **signed** config file, and store in user space + */ + + const newPermittedCsp = req.session.permittedCsp || {} + for (const key in body) { + newPermittedCsp[key] = body[key] + } + req.session.permittedCsp = newPermittedCsp + res.status(200).json({ ok: true }) +}) + +router.delete('/pluginPermissions/:pluginKey', async (req, res) => { + const { user, params } = req + const { pluginKey } = params + /** + * only using session to store user csp for now + * in future, if use is logged in, **signed** config file, and store in user space + */ + const newPermission = {} + const permittedCsp = req.session.permittedCsp || {} + for (const key in permittedCsp) { + if (!pluginKey !== key) { + newPermission[key] = permittedCsp[key] + } + } + req.session.permittedCsp = newPermission + res.status(200).json({ ok: true }) +}) + router.post('/config', loggedInOnlyMiddleware, bodyParser.json(), async (req, res) => { const { user, body } = req try { diff --git a/deploy/user/index.spec.js b/deploy/user/index.spec.js new file mode 100644 index 000000000..abae29293 --- /dev/null +++ b/deploy/user/index.spec.js @@ -0,0 +1,192 @@ +const router = require('./index') +const app = require('express')() +const sinon = require('sinon') +const { stub, spy } = require('sinon') +const { default: got } = require('got/dist/source') +const { expect } = require('chai') +const { assert } = require('console') + + + +const sessionObj = { + permittedCspVal: {}, + get permittedCsp(){ + return this.permittedCspVal + }, + set permittedCsp(val) { + + } +} + +const permittedCspSpy = spy(sessionObj, 'permittedCsp', ['get', 'set']) + +const middleware = (req, res, next) => { + req.session = sessionObj + next() +} + +describe('> user/index.js', () => { + let server + + before(done => { + app.use(middleware) + app.use(router) + server = app.listen(1234) + setTimeout(() => { + done() + }, 1000); + }) + + afterEach(() => { + permittedCspSpy.get.resetHistory() + permittedCspSpy.set.resetHistory() + sessionObj.permittedCspVal = {} + }) + + after(done => server.close(done)) + + describe('> GET /pluginPermissions', () => { + it('> getter called, setter not called', async () => { + await got.get('http://localhost:1234/pluginPermissions') + + assert( + permittedCspSpy.get.calledOnce, + `permittedCsp getter accessed once` + ) + + assert( + permittedCspSpy.set.notCalled, + `permittedCsp setter not called` + ) + }) + it('> if no value present, returns {}', async () => { + sessionObj.permittedCspVal = null + const { body } = await got.get('http://localhost:1234/pluginPermissions') + expect(JSON.parse(body)).to.deep.equal({}) + }) + + it('> if value present, return value', async () => { + const val = { + 'hot-dog': { + 'weatherman': 'tolerable' + } + } + sessionObj.permittedCspVal = val + + const { body } = await got.get('http://localhost:1234/pluginPermissions') + expect(JSON.parse(body)).to.deep.equal(val) + }) + }) + + describe('> POST /pluginPermissions', () => { + it('> getter called once, then setter called once', async () => { + const jsonPayload = { + 'hotdog-world': 420 + } + await got.post('http://localhost:1234/pluginPermissions', { + json: jsonPayload + }) + assert( + permittedCspSpy.get.calledOnce, + `permittedCsp getter called once` + ) + assert( + permittedCspSpy.set.calledOnce, + `permittedCsp setter called once` + ) + + assert( + permittedCspSpy.get.calledBefore(permittedCspSpy.set), + `getter called before setter` + ) + + assert( + permittedCspSpy.set.calledWith(jsonPayload), + `setter called with payload` + ) + }) + + it('> if sessio obj exists, will set with merged obj', async () => { + const prevVal = { + 'foo-bar': [ + 123, + 'fuzz-buzz' + ], + 'hot-dog-world': 'baz' + } + sessionObj.permittedCspVal = prevVal + + const jsonPayload = { + 'hot-dog-world': [ + 'fussball' + ] + } + + await got.post('http://localhost:1234/pluginPermissions', { + json: jsonPayload + }) + assert( + permittedCspSpy.set.calledWith({ + ...prevVal, + ...jsonPayload, + }), + 'setter called with merged payload' + ) + }) + }) + + describe('> DELETE /pluginPermissions/:pluginId', () => { + const prevVal = { + 'foo': 'bar', + 'buzz': 'lightyear' + } + before(() => { + sessionObj.permittedCspVal = prevVal + }) + + it('> getter and setter gets called once and in correct order', async () => { + + await got.delete(`http://localhost:1234/pluginPermissions/foolish`) + + assert( + permittedCspSpy.get.calledOnce, + 'getter called once' + ) + + assert( + permittedCspSpy.set.calledOnce, + 'setter called once' + ) + + assert( + permittedCspSpy.get.calledBefore(permittedCspSpy.set), + 'getter called before setter' + ) + }) + + it('> if attempts at delete non existent key, still returns ok', async () => { + + const { body } = await got.delete(`http://localhost:1234/pluginPermissions/foolish`) + const json = JSON.parse(body) + expect(json).to.deep.equal({ ok: true }) + + assert( + permittedCspSpy.set.calledWith(prevVal), + 'permittedCsp setter called with the prev value (nothing changed)' + ) + }) + + it('> if attempts at delete exisiting key, returns ok, and value is set', async () => { + + const { body } = await got.delete(`http://localhost:1234/pluginPermissions/foo`) + const json = JSON.parse(body) + expect(json).to.deep.equal({ ok: true }) + + const { foo, ...rest } = prevVal + assert( + permittedCspSpy.set.calledWith(rest), + 'permittedCsp setter called with the prev value, less the deleted key' + ) + }) + }) +}) \ No newline at end of file diff --git a/docs/releases/v2.4.0.md b/docs/releases/v2.4.0.md new file mode 100644 index 000000000..9e047ec46 --- /dev/null +++ b/docs/releases/v2.4.0.md @@ -0,0 +1,9 @@ +# v2.4.0 + +## New features + +- plugins will now follow a permission model, if they would like to access external resources + +## Under the hood stuff + +- refactored code, added additional test coverage diff --git a/mkdocs.yml b/mkdocs.yml index 5d484b503..02d1d4267 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ pages: - Fetching datasets: 'advanced/datasets.md' - Display non-atlas volumes: 'advanced/otherVolumes.md' - Release notes: + - v2.4.0: 'releases/v2.4.0.md' - v2.3.0: 'releases/v2.3.0.md' - v2.2.7: 'releases/v2.2.7.md' - v2.2.6: 'releases/v2.2.6.md' diff --git a/package.json b/package.json index aa5a9d92d..cf99e4aca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "interactive-viewer", - "version": "2.3.0", - "description": "HBP interactive atlas viewer. Integrating KG query, dataset previews & more. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular.io", + "version": "2.4.0", + "description": "HBP interactive atlas viewer. Integrating KG query, dataset previews & more. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "dev-server-export": "webpack-dev-server --config webpack.export.js", "build-export": "webpack --config webpack.export.js", diff --git a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts index acb0d9425..9020d72d2 100644 --- a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts +++ b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts @@ -1,110 +1,315 @@ -// import { PluginServices } from "./atlasViewer.pluginService.service"; -// import { TestBed, inject } from "@angular/core/testing"; -// import { MainModule } from "src/main.module"; -// import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing' - -// const MOCK_PLUGIN_MANIFEST = { -// name: 'fzj.xg.MOCK_PLUGIN_MANIFEST', -// templateURL: 'http://localhost:10001/template.html', -// scriptURL: 'http://localhost:10001/script.js' -// } - -// describe('PluginServices', () => { -// let pluginService: PluginServices - -// beforeEach(async () => { -// await TestBed.configureTestingModule({ -// imports: [ -// HttpClientTestingModule, -// MainModule -// ] -// }).compileComponents() - -// pluginService = TestBed.get(PluginServices) -// }) - -// it( -// 'is instantiated in test suite OK', -// () => expect(TestBed.get(PluginServices)).toBeTruthy() -// ) - -// it( -// 'expectOne is working as expected', -// inject([HttpTestingController], (httpMock: HttpTestingController) => { -// expect(httpMock.match('test').length).toBe(0) -// pluginService.fetch('test') -// expect(httpMock.match('test').length).toBe(1) -// pluginService.fetch('test') -// pluginService.fetch('test') -// expect(httpMock.match('test').length).toBe(2) -// }) -// ) - -// describe('#launchPlugin', () => { - -// describe('basic fetching functionality', () => { -// it( -// 'fetches templateURL and scriptURL properly', -// inject([HttpTestingController], (httpMock: HttpTestingController) => { - -// pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) - -// const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) -// const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) - -// expect(mockTemplate).toBeTruthy() -// expect(mockScript).toBeTruthy() -// }) -// ) -// it( -// 'template overrides templateURL', -// inject([HttpTestingController], (httpMock: HttpTestingController) => { -// pluginService.launchPlugin({ -// ...MOCK_PLUGIN_MANIFEST, -// template: '' -// }) - -// httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) -// const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) - -// expect(mockScript).toBeTruthy() -// }) -// ) - -// it( -// 'script overrides scriptURL', - -// inject([HttpTestingController], (httpMock: HttpTestingController) => { -// pluginService.launchPlugin({ -// ...MOCK_PLUGIN_MANIFEST, -// script: '' -// }) - -// const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) -// httpMock.expectNone(MOCK_PLUGIN_MANIFEST.scriptURL) - -// expect(mockTemplate).toBeTruthy() -// }) -// ) -// }) - -// describe('racing slow cconnection when launching plugin', () => { -// it( -// 'when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', -// inject([HttpTestingController], (httpMock:HttpTestingController) => { - -// expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() -// pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) -// pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) -// expect(httpMock.match(MOCK_PLUGIN_MANIFEST.scriptURL).length).toBe(1) -// expect(httpMock.match(MOCK_PLUGIN_MANIFEST.templateURL).length).toBe(1) - -// expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeTruthy() -// }) -// ) -// }) -// }) -// }) - -// TODO currently crashes test somehow -// TODO figure out why +import { CommonModule } from "@angular/common" +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing" +import { NgModule } from "@angular/core" +import { async, fakeAsync, flushMicrotasks, TestBed, tick } from "@angular/core/testing" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { ComponentsModule } from "src/components" +import { DialogService } from "src/services/dialogService.service" +import { selectorPluginCspPermission } from "src/services/state/userConfigState.helper" +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN } from "src/util/constants" +import { WidgetModule, WidgetServices } from "src/widget" +import { PluginServices } from "./atlasViewer.pluginService.service" +import { PluginUnit } from "./pluginUnit.component" + +const MOCK_PLUGIN_MANIFEST = { + name: 'fzj.xg.MOCK_PLUGIN_MANIFEST', + templateURL: 'http://localhost:10001/template.html', + scriptURL: 'http://localhost:10001/script.js' +} + +@NgModule({ + declarations: [ + PluginUnit, + ], + entryComponents: [ + PluginUnit + ], + exports: [ + PluginUnit + ] +}) + +class PluginUnitModule{} + +const spyfn = { + appendSrc: jasmine.createSpy('appendSrc') +} + + + +describe('> atlasViewer.pluginService.service.ts', () => { + describe('> PluginServices', () => { + + let pluginService: PluginServices + let httpMock: HttpTestingController + let mockStore: MockStore + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + AngularMaterialModule, + CommonModule, + WidgetModule, + PluginUnitModule, + HttpClientTestingModule, + ComponentsModule, + ], + providers: [ + provideMockStore(), + PluginServices, + { + provide: APPEND_SCRIPT_TOKEN, + useValue: spyfn.appendSrc + }, + { + provide: REMOVE_SCRIPT_TOKEN, + useValue: () => Promise.resolve() + }, + { + provide: DialogService, + useValue: { + getUserConfirm: () => Promise.resolve() + } + } + ] + }).compileComponents().then(() => { + + httpMock = TestBed.inject(HttpTestingController) + pluginService = TestBed.inject(PluginServices) + mockStore = TestBed.inject(MockStore) + pluginService.pluginViewContainerRef = { + createComponent: () => { + return { + onDestroy: () => {}, + instance: { + elementRef: { + nativeElement: { + append: () => {} + } + } + } + } + } + } as any + + httpMock.expectOne('http://localhost:3000/plugins/manifests').flush('[]') + + const widgetService = TestBed.inject(WidgetServices) + /** + * widget service floatingcontainer not inst in this circumstance + * TODO fix widget service tests importing widget service are not as flaky + */ + widgetService.addNewWidget = () => { + return {} as any + } + }) + })) + + afterEach(() => { + spyfn.appendSrc.calls.reset() + const ctrl = TestBed.inject(HttpTestingController) + ctrl.verify() + }) + + it('> service can be inst', () => { + expect(pluginService).toBeTruthy() + }) + + it('expectOne is working as expected', done => { + + pluginService.fetch('test') + .then(text => { + expect(text).toEqual('bla') + done() + }) + httpMock.expectOne('test').flush('bla') + + }) + + /** + * need to consider user confirmation on csp etc + */ + describe('#launchPlugin', () => { + + beforeEach(() => { + mockStore.overrideSelector(selectorPluginCspPermission, { value: false }) + }) + + describe('> basic fetching functionality', () => { + it('> fetches templateURL and scriptURL properly', fakeAsync(() => { + + pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST}) + + tick(100) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + mockTemplate.flush('hello world') + + tick(100) + + expect(spyfn.appendSrc).toHaveBeenCalledTimes(1) + expect(spyfn.appendSrc).toHaveBeenCalledWith(MOCK_PLUGIN_MANIFEST.scriptURL) + + })) + + it('> template overrides templateURL', fakeAsync(() => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + template: '' + }) + + tick(20) + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) + })) + + it('> script with scriptURL throws', done => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + script: '', + scriptURL: null + }) + .then(() => { + /** + * should not pass + */ + expect(true).toEqual(false) + }) + .catch(e => { + done() + }) + + /** + * http call will not be made, as rejection happens by Promise.reject, while fetch call probably happens at the next event cycle + */ + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) + }) + + describe('> user permission', () => { + let userConfirmSpy: jasmine.Spy + let readyPluginSpy: jasmine.Spy + let cspManifest = { + ...MOCK_PLUGIN_MANIFEST, + csp: { + 'connect-src': [`'unsafe-eval'`] + } + } + afterEach(() => { + userConfirmSpy.calls.reset() + readyPluginSpy.calls.reset() + }) + beforeEach(() => { + readyPluginSpy = spyOn(pluginService, 'readyPlugin').and.callFake(() => Promise.reject()) + const dialogService = TestBed.inject(DialogService) + userConfirmSpy = spyOn(dialogService, 'getUserConfirm') + }) + + describe('> if user permission has been given', () => { + beforeEach(fakeAsync(() => { + mockStore.overrideSelector(selectorPluginCspPermission, { value: true }) + userConfirmSpy.and.callFake(() => Promise.reject()) + pluginService.launchPlugin({ + ...cspManifest + }).catch(() => { + /** + * expecting to throw because call fake returning promise.reject in beforeEach + */ + }) + tick(20) + })) + it('> will not ask for permission', () => { + expect(userConfirmSpy).not.toHaveBeenCalled() + }) + + it('> will call ready plugin', () => { + expect(readyPluginSpy).toHaveBeenCalled() + }) + }) + + describe('> if user permission has not yet been given', () => { + beforeEach(() => { + mockStore.overrideSelector(selectorPluginCspPermission, { value: false }) + }) + describe('> user permission', () => { + beforeEach(fakeAsync(() => { + pluginService.launchPlugin({ + ...cspManifest + }).catch(() => { + /** + * expecting to throw because call fake returning promise.reject in beforeEach + */ + }) + tick(40) + })) + it('> will be asked for', () => { + expect(userConfirmSpy).toHaveBeenCalled() + }) + }) + + describe('> if user accepts', () => { + beforeEach(fakeAsync(() => { + userConfirmSpy.and.callFake(() => Promise.resolve()) + + pluginService.launchPlugin({ + ...cspManifest + }).catch(() => { + /** + * expecting to throw because call fake returning promise.reject in beforeEach + */ + }) + })) + it('> calls /POST user/pluginPermissions', () => { + httpMock.expectOne({ + method: 'POST', + url: 'http://localhost:3000/user/pluginPermissions' + }) + }) + }) + + describe('> if user declines', () => { + + beforeEach(fakeAsync(() => { + userConfirmSpy.and.callFake(() => Promise.reject()) + + pluginService.launchPlugin({ + ...cspManifest + }).catch(() => { + /** + * expecting to throw because call fake returning promise.reject in beforeEach + */ + }) + })) + it('> calls /POST user/pluginPermissions', () => { + httpMock.expectNone({ + method: 'POST', + url: 'http://localhost:3000/user/pluginPermissions' + }) + }) + }) + }) + }) + }) + + describe('> racing slow connection when launching plugin', () => { + it('> when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', fakeAsync(() => { + + expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() + expect(pluginService.pluginHasLaunched(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() + pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST}) + pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST}) + tick(20) + const req = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + req.flush('baba') + tick(20) + expect(spyfn.appendSrc).toHaveBeenCalledTimes(1) + + expect( + pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name) || + pluginService.pluginHasLaunched(MOCK_PLUGIN_MANIFEST.name) + ).toBeTruthy() + })) + }) + + }) + }) +}) diff --git a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts index dff123181..bf6104a32 100644 --- a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts @@ -1,16 +1,21 @@ import { HttpClient } from '@angular/common/http' -import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef, Inject, InjectionToken } from "@angular/core"; -import { PLUGINSTORE_ACTION_TYPES } from "src/services/state/pluginState.store"; -import { IavRootStoreInterface, isDefined } from 'src/services/stateStore.service' +import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef, Inject, SecurityContext } from "@angular/core"; +import { PLUGINSTORE_ACTION_TYPES } from "src/services/state/pluginState.helper"; import { PluginUnit } from "./pluginUnit.component"; import { select, Store } from "@ngrx/store"; -import { BehaviorSubject, merge, Observable, of, Subject, zip } from "rxjs"; -import { filter, map, shareReplay, switchMap, catchError } from "rxjs/operators"; +import { BehaviorSubject, from, merge, Observable, of } from "rxjs"; +import { catchError, filter, map, mapTo, shareReplay, switchMap, switchMapTo, take, tap } from "rxjs/operators"; import { LoggingService } from 'src/logging'; import { PluginHandler } from 'src/util/pluginHandler'; import { WidgetUnit, WidgetServices } from "src/widget"; import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN, BACKENDURL, getHttpHeader } from 'src/util/constants'; import { PluginFactoryDirective } from './pluginFactory.directive'; +import { selectorPluginCspPermission } from 'src/services/state/userConfigState.helper'; +import { DialogService } from 'src/services/dialogService.service'; +import { DomSanitizer } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +const requiresReloadMd = `\n\n***\n\n**warning**: interactive atlas viewer needs to be reloaded for the change to take effect.` export const registerPluginFactoryDirectiveFactory = (pSer: PluginServices) => { return (pFactoryDirective: PluginFactoryDirective) => { @@ -45,9 +50,12 @@ export class PluginServices { constructor( private widgetService: WidgetServices, private cfr: ComponentFactoryResolver, - private store: Store<IavRootStoreInterface>, + private store: Store<any>, + private dialogService: DialogService, + private snackbar: MatSnackBar, private http: HttpClient, private log: LoggingService, + private sanitizer: DomSanitizer, @Inject(APPEND_SCRIPT_TOKEN) private appendSrc: (src: string) => Promise<HTMLScriptElement>, @Inject(REMOVE_SCRIPT_TOKEN) private removeSrc: (src: HTMLScriptElement) => void, ) { @@ -65,34 +73,14 @@ export class PluginServices { * TODO convert to rxjs streams, instead of Promise.all */ - const pluginUrl = `${BACKENDURL.replace(/\/$/,'')}/plugins` - const streamFetchedManifests$ = this.http.get(pluginUrl,{ + const pluginManifestsUrl = `${BACKENDURL.replace(/\/$/,'/')}plugins/manifests` + + this.http.get<IPluginManifest[]>(pluginManifestsUrl, { responseType: 'json', headers: getHttpHeader(), - }).pipe( - switchMap((arr: string[]) => { - return zip( - ...arr.map(url => this.http.get(url, { - responseType: 'json', - headers: getHttpHeader() - }).pipe( - catchError((err, caught) => of(null)) - )) - ).pipe( - map(arr => arr.filter(v => !!v)) - ) - }) - ) - - streamFetchedManifests$.subscribe( - arr => { - this.fetchedPluginManifests = arr - this.log.log(this.fetchedPluginManifests) - }, + }).subscribe( + arr => this.fetchedPluginManifests = arr, this.log.error, - () => { - this.log.log(`fetching end`) - } ) this.minimisedPlugins$ = merge( @@ -123,14 +111,20 @@ export class PluginServices { }) public readyPlugin(plugin: IPluginManifest): Promise<any> { - return Promise.all([ - isDefined(plugin.template) - ? Promise.resolve() - : isDefined(plugin.templateURL) - ? this.fetch(plugin.templateURL, {responseType: 'text'}).then(template => plugin.template = template) - : Promise.reject('both template and templateURL are not defined') , - isDefined(plugin.scriptURL) ? Promise.resolve() : Promise.reject(`inline script has been deprecated. use scriptURL instead`), - ]) + const isDefined = input => typeof input !== 'undefined' && input !== null + if (!isDefined(plugin.scriptURL)) { + return Promise.reject(`inline script has been deprecated. use scriptURL instead`) + } + if (isDefined(plugin.template)) { + return Promise.resolve() + } + if (plugin.templateURL) { + return this.fetch(plugin.templateURL, {responseType: 'text'}) + .then(template => { + plugin.template = template + }) + } + return Promise.reject('both template and templateURL are not defined') } private launchedPlugins: Set<string> = new Set() @@ -165,7 +159,36 @@ export class PluginServices { private launchingPlugins: Set<string> = new Set() public orphanPlugins: Set<IPluginManifest> = new Set() - public launchPlugin(plugin: IPluginManifest) { + + public async revokePluginPermission(pluginKey: string) { + const createRevokeMd = (pluginKey: string) => `You are about to revoke the permission given to ${pluginKey}.${requiresReloadMd}` + + try { + await this.dialogService.getUserConfirm({ + markdown: createRevokeMd(pluginKey) + }) + + this.http.delete( + `${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions/${encodeURIComponent(pluginKey)}`, + { + headers: getHttpHeader() + } + ).subscribe( + () => { + window.location.reload() + }, + err => { + this.snackbar.open(`Error revoking plugin permission ${err.toString()}`, 'Dismiss') + } + ) + } catch (_e) { + /** + * user cancelled workflow + */ + } + } + + public async launchPlugin(plugin: IPluginManifest): Promise<PluginHandler> { if (this.pluginIsLaunching(plugin.name)) { // plugin launching please be patient // TODO add visual feedback @@ -188,107 +211,169 @@ export class PluginServices { this.addPluginToIsLaunchingSet(plugin.name) - return this.readyPlugin(plugin) - .then(async () => { - const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) - /* TODO in v0.2, I used: + const { csp, displayName, name = '', version = 'latest' } = plugin + const pluginKey = `${name}::${version}` + const createPermissionMd = ({ csp, name, version }) => { + const sanitize = val => this.sanitizer.sanitize(SecurityContext.HTML, val) + const getCspRow = ({ key }) => { + return `| ${sanitize(key)} | ${csp[key].map(v => '`' + sanitize(v) + '`').join(',')} |` + } + return `**${sanitize(displayName || name)}** version **${sanitize(version)}** requires additional permission from you to run:\n\n| permission | detail |\n| --- | --- |\n${Object.keys(csp).map(key => getCspRow({ key })).join('\n')}${requiresReloadMd}` + } + + await new Promise((rs, rj) => { + this.store.pipe( + select(selectorPluginCspPermission, { key: pluginKey }), + take(1), + switchMap(userAgreed => { + if (userAgreed.value) return of(true) + + /** + * check if csp exists + */ + if (!csp || Object.keys(csp).length === 0) { + return of(true) + } + /** + * TODO: check do not ask status + */ + return from( + this.dialogService.getUserConfirm({ + markdown: createPermissionMd({ csp, name, version }) + }) + ).pipe( + mapTo(true), + catchError(() => of(false)), + filter(v => !!v), + switchMapTo( + this.http.post(`${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions`, + { [pluginKey]: csp }, + { + responseType: 'json', + headers: getHttpHeader() + }) + ), + tap(() => { + window.location.reload() + }), + mapTo(false) + ) + }), + take(1), + ).subscribe( + val => val ? rs() : rj(), + err => rj(err) + ) + }) - const template = document.createElement('div') - template.insertAdjacentHTML('afterbegin',template) + await this.readyPlugin(plugin) + + /** + * catch when pluginViewContainerRef as not been overwritten? + */ + if (!this.pluginViewContainerRef) { + throw new Error(`pluginViewContainerRef not populated`) + } + const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) + /* TODO in v0.2, I used: - // reason was: - // changed from innerHTML to insertadjacenthtml to accomodate angular elements ... not too sure about the actual ramification + const template = document.createElement('div') + template.insertAdjacentHTML('afterbegin',template) - */ + // reason was: + // changed from innerHTML to insertadjacenthtml to accomodate angular elements ... not too sure about the actual ramification - const handler = new PluginHandler() - this.pluginHandlersMap.set(plugin.name, handler) + */ - /** - * define the handler properties prior to appending plugin script - * so that plugin script can access properties w/o timeout - */ - handler.initState = plugin.initState - ? plugin.initState - : null + const handler = new PluginHandler() + this.pluginHandlersMap.set(plugin.name, handler) - handler.initStateUrl = plugin.initStateUrl - ? plugin.initStateUrl - : null + /** + * define the handler properties prior to appending plugin script + * so that plugin script can access properties w/o timeout + */ + handler.initState = plugin.initState + ? plugin.initState + : null + + handler.initStateUrl = plugin.initStateUrl + ? plugin.initStateUrl + : null + + handler.setInitManifestUrl = (url) => this.store.dispatch({ + type : PLUGINSTORE_ACTION_TYPES.SET_INIT_PLUGIN, + manifest : { + name : plugin.name, + initManifestUrl : url, + }, + }) - handler.setInitManifestUrl = (url) => this.store.dispatch({ - type : PLUGINSTORE_ACTION_TYPES.SET_INIT_PLUGIN, - manifest : { - name : plugin.name, - initManifestUrl : url, - }, - }) + const shutdownCB = [ + () => { + this.removePluginFromLaunchedSet(plugin.name) + }, + ] - const shutdownCB = [ - () => { - this.removePluginFromLaunchedSet(plugin.name) - }, - ] + handler.onShutdown = (cb) => { + if (typeof cb !== 'function') { + this.log.warn('onShutdown requires the argument to be a function') + return + } + shutdownCB.push(cb) + } - handler.onShutdown = (cb) => { - if (typeof cb !== 'function') { - this.log.warn('onShutdown requires the argument to be a function') - return - } - shutdownCB.push(cb) - } + const scriptEl = await this.appendSrc(plugin.scriptURL) - const scriptEl = await this.appendSrc(plugin.scriptURL) - handler.onShutdown(() => this.removeSrc(scriptEl)) + handler.onShutdown(() => this.removeSrc(scriptEl)) - const template = document.createElement('div') - template.insertAdjacentHTML('afterbegin', plugin.template) - pluginUnit.instance.elementRef.nativeElement.append( template ) + const template = document.createElement('div') + template.insertAdjacentHTML('afterbegin', plugin.template) + pluginUnit.instance.elementRef.nativeElement.append( template ) - const widgetCompRef = this.widgetService.addNewWidget(pluginUnit, { - state : 'floating', - exitable : true, - persistency: plugin.persistency, - title : plugin.displayName || plugin.name, - }) + const widgetCompRef = this.widgetService.addNewWidget(pluginUnit, { + state : 'floating', + exitable : true, + persistency: plugin.persistency, + title : plugin.displayName || plugin.name, + }) - this.addPluginToLaunchedSet(plugin.name) - this.removePluginFromIsLaunchingSet(plugin.name) + this.addPluginToLaunchedSet(plugin.name) + this.removePluginFromIsLaunchingSet(plugin.name) - this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance) + this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance) - const unsubscribeOnPluginDestroy = [] + const unsubscribeOnPluginDestroy = [] - // TODO deprecate sec - handler.blink = (_sec?: number) => { - widgetCompRef.instance.blinkOn = true - } + // TODO deprecate sec + handler.blink = (_sec?: number) => { + widgetCompRef.instance.blinkOn = true + } - handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val + handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val - handler.shutdown = () => { - widgetCompRef.instance.exit() - } + handler.shutdown = () => { + widgetCompRef.instance.exit() + } - handler.onShutdown(() => { - unsubscribeOnPluginDestroy.forEach(s => s.unsubscribe()) - this.pluginHandlersMap.delete(plugin.name) - this.mapPluginNameToWidgetUnit.delete(plugin.name) - }) + handler.onShutdown(() => { + unsubscribeOnPluginDestroy.forEach(s => s.unsubscribe()) + this.pluginHandlersMap.delete(plugin.name) + this.mapPluginNameToWidgetUnit.delete(plugin.name) + }) - pluginUnit.onDestroy(() => { - while (shutdownCB.length > 0) { - shutdownCB.pop()() - } - }) + pluginUnit.onDestroy(() => { + while (shutdownCB.length > 0) { + shutdownCB.pop()() + } + }) - return handler - }) + return handler } } export interface IPluginManifest { name?: string + version?: string displayName?: string templateURL?: string template?: string @@ -303,4 +388,9 @@ export interface IPluginManifest { homepage?: string authors?: string + + csp?: { + 'connect-src'?: string[] + 'script-src'?: string[] + } } diff --git a/src/components/confirmDialog/confirmDialog.component.spec.ts b/src/components/confirmDialog/confirmDialog.component.spec.ts new file mode 100644 index 000000000..535e6f9fb --- /dev/null +++ b/src/components/confirmDialog/confirmDialog.component.spec.ts @@ -0,0 +1,54 @@ +import { CommonModule } from "@angular/common" +import { TestBed, async } from "@angular/core/testing" +import { MAT_DIALOG_DATA } from "@angular/material/dialog" +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { ComponentsModule } from "../components.module" +import { ConfirmDialogComponent } from "./confirmDialog.component" + +describe('> confirmDialog.component.spec.ts', () => { + + describe('> ConfirmDialogComponent', () => { + let matDialogData = {} + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + AngularMaterialModule, + CommonModule, + ComponentsModule, + ], + providers: [{ + provide: MAT_DIALOG_DATA, + useFactory: () => { + return matDialogData as any + } + }] + }).compileComponents() + })) + + it('> can be created', () => { + const fixutre = TestBed.createComponent(ConfirmDialogComponent) + expect(fixutre).toBeTruthy() + }) + + describe('> if both markdown and message are truthy', () => { + beforeEach(() => { + matDialogData = { + markdown: `hello world`, + message: `foo bar`, + } + }) + it('> should show markdown in preference', () => { + const fixture = TestBed.createComponent(ConfirmDialogComponent) + fixture.detectChanges() + const text = fixture.debugElement.nativeElement.textContent + expect( + /hello\sworld/.test(text) + ).toBeTruthy() + expect( + /foo\sbar/.test(text) + ).toBeFalsy() + }) + }) + }) + +}) \ No newline at end of file diff --git a/src/components/confirmDialog/confirmDialog.component.ts b/src/components/confirmDialog/confirmDialog.component.ts index b17827614..5c3d9eb02 100644 --- a/src/components/confirmDialog/confirmDialog.component.ts +++ b/src/components/confirmDialog/confirmDialog.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, Input } from "@angular/core"; -import {MAT_DIALOG_DATA} from "@angular/material/dialog"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; @Component({ selector: 'confirm-dialog-component', @@ -22,10 +22,14 @@ export class ConfirmDialogComponent { @Input() public cancelBtnText: string = `Cancel` + @Input() + public markdown: string + constructor(@Inject(MAT_DIALOG_DATA) data: any) { - const { title = null, message = null, okBtnText, cancelBtnText} = data || {} + const { title = null, message = null, markdown, okBtnText, cancelBtnText} = data || {} if (title) this.title = title if (message) this.message = message + if (markdown) this.markdown = markdown if (okBtnText) this.okBtnText = okBtnText if (cancelBtnText) this.cancelBtnText = cancelBtnText } diff --git a/src/components/confirmDialog/confirmDialog.template.html b/src/components/confirmDialog/confirmDialog.template.html index df52270e9..401261f1a 100644 --- a/src/components/confirmDialog/confirmDialog.template.html +++ b/src/components/confirmDialog/confirmDialog.template.html @@ -3,9 +3,16 @@ </h1> <mat-dialog-content> - <p> - {{ message }} - </p> + <ng-template [ngIf]="markdown" [ngIfElse]="stringMessageTmpl"> + <markdown-dom [markdown]="markdown"> + </markdown-dom> + </ng-template> + + <ng-template #stringMessageTmpl> + <p> + {{ message }} + </p> + </ng-template> </mat-dialog-content> <mat-divider></mat-divider> diff --git a/src/services/dialogService.service.ts b/src/services/dialogService.service.ts index 05911fdc7..c202eda58 100644 --- a/src/services/dialogService.service.ts +++ b/src/services/dialogService.service.ts @@ -63,5 +63,6 @@ export interface DialogConfig { placeholder: string defaultValue: string message: string + markdown?: string iconClass: string } diff --git a/src/services/effect/pluginUseEffect.spec.ts b/src/services/effect/pluginUseEffect.spec.ts index 2bd9efda5..cda6dccb7 100644 --- a/src/services/effect/pluginUseEffect.spec.ts +++ b/src/services/effect/pluginUseEffect.spec.ts @@ -6,7 +6,8 @@ import { Action } from "@ngrx/store"; import { provideMockActions } from "@ngrx/effects/testing"; import { provideMockStore } from "@ngrx/store/testing"; import { defaultRootState } from "../stateStore.service"; -import { PLUGINSTORE_CONSTANTS, PLUGINSTORE_ACTION_TYPES } from '../state/pluginState.store' +import { PLUGINSTORE_CONSTANTS } from '../state/pluginState.store' +import { PLUGINSTORE_ACTION_TYPES } from '../state/pluginState.helper' import { Injectable } from "@angular/core"; import { getRandomHex } from 'common/util' import { PluginServices } from "src/atlasViewer/pluginUnit"; diff --git a/src/services/effect/pluginUseEffect.ts b/src/services/effect/pluginUseEffect.ts index 1c60a3d5a..c6d0048d7 100644 --- a/src/services/effect/pluginUseEffect.ts +++ b/src/services/effect/pluginUseEffect.ts @@ -5,7 +5,8 @@ import { Observable, forkJoin } from "rxjs" import { filter, map, startWith, switchMap } from "rxjs/operators" import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service" import { PluginServices } from "src/atlasViewer/pluginUnit" -import { PLUGINSTORE_ACTION_TYPES, PLUGINSTORE_CONSTANTS } from 'src/services/state/pluginState.store' +import { PLUGINSTORE_CONSTANTS } from 'src/services/state/pluginState.store' +import { PLUGINSTORE_ACTION_TYPES } from 'src/services/state/pluginState.helper' import { IavRootStoreInterface } from "../stateStore.service" import { HttpClient } from "@angular/common/http" diff --git a/src/services/state/pluginState.helper.ts b/src/services/state/pluginState.helper.ts new file mode 100644 index 000000000..2c6494747 --- /dev/null +++ b/src/services/state/pluginState.helper.ts @@ -0,0 +1,5 @@ + +export const PLUGINSTORE_ACTION_TYPES = { + SET_INIT_PLUGIN: `SET_INIT_PLUGIN`, + CLEAR_INIT_PLUGIN: 'CLEAR_INIT_PLUGIN', +} \ No newline at end of file diff --git a/src/services/state/pluginState.store.ts b/src/services/state/pluginState.store.ts index de5b76754..ac8ee4862 100644 --- a/src/services/state/pluginState.store.ts +++ b/src/services/state/pluginState.store.ts @@ -1,6 +1,6 @@ import { Action } from '@ngrx/store' import { GENERAL_ACTION_TYPES } from '../stateStore.service' - +import { PLUGINSTORE_ACTION_TYPES } from './pluginState.helper' export const defaultState: StateInterface = { initManifests: [] } @@ -16,10 +16,6 @@ export interface ActionInterface extends Action { } } -export const PLUGINSTORE_ACTION_TYPES = { - SET_INIT_PLUGIN: `SET_INIT_PLUGIN`, - CLEAR_INIT_PLUGIN: 'CLEAR_INIT_PLUGIN', -} export const PLUGINSTORE_CONSTANTS = { INIT_MANIFEST_SRC: 'INIT_MANIFEST_SRC', diff --git a/src/services/state/userConfigState.helper.spec.ts b/src/services/state/userConfigState.helper.spec.ts new file mode 100644 index 000000000..e007700e8 --- /dev/null +++ b/src/services/state/userConfigState.helper.spec.ts @@ -0,0 +1,47 @@ +import { selectorPluginCspPermission } from "./userConfigState.helper" + +describe('> userConfigState.helper.ts', () => { + describe('> selectorPluginCspPermission', () => { + const expectedTrue = { + value: true + } + const expectedFalse = { + value: false + } + describe('> malformed init value', () => { + describe('> undefined userconfigstate', () => { + it('> return expected false val', () => { + const returnVal = selectorPluginCspPermission.projector(null, { key: 'foo-bar' }) + expect(returnVal).toEqual(expectedFalse) + }) + }) + describe('> undefined pluginCsp property', () => { + it('> return expected false val', () => { + const returnVal = selectorPluginCspPermission.projector({}, { key: 'foo-bar' }) + expect(returnVal).toEqual(expectedFalse) + }) + }) + }) + + describe('> well fored init valu', () => { + + describe('> undefined key', () => { + it('> return expected false val', () => { + const returnVal = selectorPluginCspPermission.projector({ + pluginCsp: {'yes-man': true} + }, { key: 'foo-bar' }) + expect(returnVal).toEqual(expectedFalse) + }) + }) + + describe('> truthly defined key', () => { + it('> return expected true val', () => { + const returnVal = selectorPluginCspPermission.projector({ pluginCsp: + { 'foo-bar': true } + }, { key: 'foo-bar' }) + expect(returnVal).toEqual(expectedTrue) + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/services/state/userConfigState.helper.ts b/src/services/state/userConfigState.helper.ts new file mode 100644 index 000000000..e71be024c --- /dev/null +++ b/src/services/state/userConfigState.helper.ts @@ -0,0 +1,11 @@ +import { createSelector } from "@ngrx/store" + +export const selectorPluginCspPermission = createSelector( + (state: any) => state.userConfigState, + (userConfigState: any, props: any = {}) => { + const { key } = props as { key: string } + return { + value: !!userConfigState?.pluginCsp?.[key] + } + } +) diff --git a/src/services/state/userConfigState.store.spec.ts b/src/services/state/userConfigState.store.spec.ts new file mode 100644 index 000000000..00ef5fb33 --- /dev/null +++ b/src/services/state/userConfigState.store.spec.ts @@ -0,0 +1,81 @@ +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing" +import { fakeAsync, TestBed, tick } from "@angular/core/testing" +import { provideMockActions } from "@ngrx/effects/testing" +import { Action } from "@ngrx/store" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { from, Observable } from "rxjs" +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { DialogService } from "../dialogService.service" +import { actionUpdatePluginCsp, UserConfigStateUseEffect } from "./userConfigState.store" +import { viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector } from "./viewerState/selectors" + +describe('> userConfigState.store.spec.ts', () => { + describe('> UserConfigStateUseEffect', () => { + let action$: Observable<Action> + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + AngularMaterialModule, + ], + providers: [ + provideMockActions(() => action$), + provideMockStore({ + initialState: { + viewerConfigState: { + gpuLimit: 1e9, + animation: true + } + } + }), + DialogService + ] + }) + + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(viewerStateSelectedTemplateSelector, null) + mockStore.overrideSelector(viewerStateSelectedParcellationSelector, null) + mockStore.overrideSelector(viewerStateSelectedRegionsSelector, []) + }) + + it('> can be init', () => { + const useEffect = TestBed.inject(UserConfigStateUseEffect) + expect(useEffect).toBeTruthy() + }) + describe('> setInitPluginPermission$', () => { + let mockHttp: HttpTestingController + let useEffect: UserConfigStateUseEffect + const mockpluginPer = { + 'foo-bar': { + 'script-src': [ + '1', + '2', + ] + } + } + beforeEach(() => { + mockHttp = TestBed.inject(HttpTestingController) + useEffect = TestBed.inject(UserConfigStateUseEffect) + }) + afterEach(() => { + mockHttp.verify() + }) + it('> calls /GET user/pluginPermissions', fakeAsync(() => { + let val + useEffect.setInitPluginPermission$.subscribe(v => val = v) + tick(20) + const req = mockHttp.expectOne(`http://localhost:3000/user/pluginPermissions`) + req.flush(mockpluginPer) + expect(val).toEqual(actionUpdatePluginCsp({ payload: mockpluginPer })) + })) + + it('> if get fn fails', fakeAsync(() => { + let val + useEffect.setInitPluginPermission$.subscribe(v => val = v) + const req = mockHttp.expectOne(`http://localhost:3000/user/pluginPermissions`) + req.error(null, { status: 500, statusText: 'Internal Error' }) + expect(val).toEqual(actionUpdatePluginCsp({ payload: {} })) + })) + }) + }) +}) \ No newline at end of file diff --git a/src/services/state/userConfigState.store.ts b/src/services/state/userConfigState.store.ts index db913779d..de8d74829 100644 --- a/src/services/state/userConfigState.store.ts +++ b/src/services/state/userConfigState.store.ts @@ -1,21 +1,35 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Actions, Effect, ofType } from "@ngrx/effects"; -import { Action, select, Store } from "@ngrx/store"; +import { Action, createAction, createReducer, props, select, Store, on, createSelector } from "@ngrx/store"; import { combineLatest, from, Observable, of, Subscription } from "rxjs"; -import { catchError, distinctUntilChanged, filter, map, share, shareReplay, switchMap, take, withLatestFrom } from "rxjs/operators"; -import { LOCAL_STORAGE_CONST } from "src/util//constants"; +import { catchError, distinctUntilChanged, filter, map, mapTo, share, shareReplay, switchMap, take, withLatestFrom } from "rxjs/operators"; +import { BACKENDURL, LOCAL_STORAGE_CONST } from "src/util//constants"; import { DialogService } from "../dialogService.service"; -import { generateLabelIndexId, IavRootStoreInterface, recursiveFindRegionWithLabelIndexId } from "../stateStore.service"; -import { NEWVIEWER, SELECT_PARCELLATION, SELECT_REGIONS } from "./viewerState.store"; - +import { recursiveFindRegionWithLabelIndexId } from "src/util/fn"; +import { serialiseParcellationRegion } from 'common/util' // Get around the problem of importing duplicated string (ACTION_TYPES), even using ES6 alias seems to trip up the compiler // TODO file bug and reverse -import * as viewerConfigStore from './viewerConfig.store' +import { HttpClient } from "@angular/common/http"; +import { actionSetMobileUi, viewerStateNewViewer, viewerStateSelectParcellation, viewerStateSetSelectedRegions } from "./viewerState/actions"; +import { viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector } from "./viewerState/selectors"; -const SET_MOBILE_UI = viewerConfigStore.VIEWER_CONFIG_ACTION_TYPES.SET_MOBILE_UI +interface ICsp{ + 'connect-src'?: string[] + 'script-src'?: string[] +} export interface StateInterface { savedRegionsSelection: RegionSelection[] + /** + * plugin csp - currently store in localStorage + * if user log in, store in user profile + */ + pluginCsp: { + /** + * key === plugin version id + */ + [key: string]: ICsp + } } export interface RegionSelection { @@ -43,11 +57,31 @@ interface UserConfigAction extends Action { } export const defaultState: StateInterface = { - savedRegionsSelection: [] + savedRegionsSelection: [], + pluginCsp: {} } +export const actionUpdateRegionSelections = createAction( + `[userConfig] updateRegionSelections`, + props<{ config: { savedRegionsSelection: RegionSelection[]} }>() +) + +export const selectorAllPluginsCspPermission = createSelector( + (state: any) => state.userConfigState, + userConfigState => userConfigState.pluginCsp +) + +export const actionUpdatePluginCsp = createAction( + `[userConfig] updatePluginCspPermission`, + props<{ + payload: { + [key: string]: ICsp + } + }>() +) + export const ACTION_TYPES = { - UPDATE_REGIONS_SELECTIONS: `UPDATE_REGIONS_SELECTIONS`, + UPDATE_REGIONS_SELECTIONS: actionUpdateRegionSelections.type, UPDATE_REGIONS_SELECTION: 'UPDATE_REGIONS_SELECTION', SAVE_REGIONS_SELECTION: `SAVE_REGIONS_SELECTIONN`, DELETE_REGIONS_SELECTION: 'DELETE_REGIONS_SELECTION', @@ -55,32 +89,23 @@ export const ACTION_TYPES = { LOAD_REGIONS_SELECTION: 'LOAD_REGIONS_SELECTION', } -export const getStateStore = ({ state = defaultState } = {}) => (prevState: StateInterface = state, action: UserConfigAction) => { - switch (action.type) { - case ACTION_TYPES.UPDATE_REGIONS_SELECTIONS: { - const { config = {} } = action + +export const userConfigReducer = createReducer( + defaultState, + on(actionUpdateRegionSelections, (state, { config }) => { const { savedRegionsSelection } = config return { - ...prevState, - savedRegionsSelection, + ...state, + savedRegionsSelection } - } - default: return prevState - } -} - -// must export a named function for aot compilation -// see https://github.com/angular/angular/issues/15587 -// https://github.com/amcdnl/ngrx-actions/issues/23 -// or just google for: -// -// angular function expressions are not supported in decorators - -const defaultStateStore = getStateStore() - -export function stateStore(state, action) { - return defaultStateStore(state, action) -} + }), + on(actionUpdatePluginCsp, (state, { payload }) => { + return { + ...state, + pluginCsp: payload + } + }) +) @Injectable({ providedIn: 'root', @@ -91,28 +116,28 @@ export class UserConfigStateUseEffect implements OnDestroy { constructor( private actions$: Actions, - private store$: Store<IavRootStoreInterface>, + private store$: Store<any>, private dialogService: DialogService, + private http: HttpClient, ) { const viewerState$ = this.store$.pipe( select('viewerState'), shareReplay(1), ) - this.parcellationSelected$ = viewerState$.pipe( - select('parcellationSelected'), + this.parcellationSelected$ = this.store$.pipe( + select(viewerStateSelectedParcellationSelector), distinctUntilChanged(), - share(), ) this.tprSelected$ = combineLatest( - viewerState$.pipe( - select('templateSelected'), + this.store$.pipe( + select(viewerStateSelectedTemplateSelector), distinctUntilChanged(), ), this.parcellationSelected$, - viewerState$.pipe( - select('regionsSelected'), + this.store$.pipe( + select(viewerStateSelectedRegionsSelector) /** * TODO * distinct selectedRegions @@ -124,7 +149,6 @@ export class UserConfigStateUseEffect implements OnDestroy { templateSelected, parcellationSelected, regionsSelected, } }), - shareReplay(1), ) this.savedRegionsSelections$ = this.store$.pipe( @@ -224,11 +248,12 @@ export class UserConfigStateUseEffect implements OnDestroy { /** * template different, dispatch NEWVIEWER */ - this.store$.dispatch({ - type: NEWVIEWER, - selectParcellation: savedRegionsSelection.parcellationSelected, - selectTemplate: savedRegionsSelection.templateSelected, - }) + this.store$.dispatch( + viewerStateNewViewer({ + selectParcellation: savedRegionsSelection.parcellationSelected, + selectTemplate: savedRegionsSelection.templateSelected, + }) + ) return this.parcellationSelected$.pipe( filter(p => p.updated), take(1), @@ -244,11 +269,11 @@ export class UserConfigStateUseEffect implements OnDestroy { /** * parcellation different, dispatch SELECT_PARCELLATION */ - - this.store$.dispatch({ - type: SELECT_PARCELLATION, - selectParcellation: savedRegionsSelection.parcellationSelected, - }) + this.store$.dispatch( + viewerStateSelectParcellation({ + selectParcellation: savedRegionsSelection.parcellationSelected, + }) + ) return this.parcellationSelected$.pipe( filter(p => p.updated), take(1), @@ -265,10 +290,11 @@ export class UserConfigStateUseEffect implements OnDestroy { }) }), ).subscribe(({ regionsSelected }) => { - this.store$.dispatch({ - type: SELECT_REGIONS, - selectRegions: regionsSelected, - }) + this.store$.dispatch( + viewerStateSetSelectedRegions({ + selectRegions: regionsSelected, + }) + ) }), ) @@ -288,8 +314,7 @@ export class UserConfigStateUseEffect implements OnDestroy { this.subscriptions.push( this.actions$.pipe( - - ofType(SET_MOBILE_UI), + ofType(actionSetMobileUi.type), map((action: any) => { const { payload } = action const { useMobileUI } = payload @@ -313,7 +338,7 @@ export class UserConfigStateUseEffect implements OnDestroy { name, tName: templateSelected.name, pName: parcellationSelected.name, - rSelected: regionsSelected.map(({ ngId, labelIndex }) => generateLabelIndexId({ ngId, labelIndex })), + rSelected: regionsSelected.map(({ ngId, labelIndex }) => serialiseParcellationRegion({ ngId, labelIndex })), } as SimpleRegionSelection }) @@ -334,7 +359,11 @@ export class UserConfigStateUseEffect implements OnDestroy { map(fetchedTemplates => savedSRSs.map(({ id, name, tName, pName, rSelected }) => { const templateSelected = fetchedTemplates.find(t => t.name === tName) const parcellationSelected = templateSelected && templateSelected.parcellations.find(p => p.name === pName) - const regionsSelected = parcellationSelected && rSelected.map(labelIndexId => recursiveFindRegionWithLabelIndexId({ regions: parcellationSelected.regions, labelIndexId, inheritedNgId: parcellationSelected.ngId })) + const regionsSelected = parcellationSelected && rSelected.map(labelIndexId => recursiveFindRegionWithLabelIndexId({ + regions: parcellationSelected.regions, + labelIndexId, + inheritedNgId: parcellationSelected.ngId + })) return { templateSelected, parcellationSelected, @@ -378,4 +407,15 @@ export class UserConfigStateUseEffect implements OnDestroy { @Effect() public restoreSRSsFromStorage$: Observable<any> + + @Effect() + public setInitPluginPermission$ = this.http.get(`${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions`, { + responseType: 'json' + }).pipe( + /** + * TODO show warning? + */ + catchError(() => of({})), + map((json: any) => actionUpdatePluginCsp({ payload: json })) + ) } diff --git a/src/services/state/viewerConfig.store.ts b/src/services/state/viewerConfig.store.ts index 74d2a6572..b3a80aeb9 100644 --- a/src/services/state/viewerConfig.store.ts +++ b/src/services/state/viewerConfig.store.ts @@ -2,6 +2,7 @@ import { Action } from "@ngrx/store"; import { LOCAL_STORAGE_CONST } from "src/util/constants"; import { IViewerConfigState as StateInterface } from './viewerConfig.store.helper' +import { actionSetMobileUi } from "./viewerState/actions"; export { StateInterface } interface ViewerConfigurationAction extends Action { @@ -23,7 +24,7 @@ export const VIEWER_CONFIG_ACTION_TYPES = { SET_ANIMATION: `SET_ANIMATION`, UPDATE_CONFIG: `UPDATE_CONFIG`, CHANGE_GPU_LIMIT: `CHANGE_GPU_LIMIT`, - SET_MOBILE_UI: 'SET_MOBILE_UI', + SET_MOBILE_UI: actionSetMobileUi.type, } // get gpu limit diff --git a/src/services/state/viewerState/actions.ts b/src/services/state/viewerState/actions.ts index 1b2efd6f7..1a066605f 100644 --- a/src/services/state/viewerState/actions.ts +++ b/src/services/state/viewerState/actions.ts @@ -109,3 +109,8 @@ export const viewerStateChangeNavigation = createAction( `[viewerState] changeNavigation`, props<{ navigation: any }>() ) + +export const actionSetMobileUi = createAction( + `[viewerState] setMobileUi`, + props<{ payload: { useMobileUI: boolean } }>() +) \ No newline at end of file diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts index 1dcde5194..209c3a2a9 100644 --- a/src/services/stateStore.service.ts +++ b/src/services/stateStore.service.ts @@ -1,4 +1,12 @@ import { filter } from 'rxjs/operators'; +export { + serialiseParcellationRegion as generateLabelIndexId, + deserialiseParcRegionId as getNgIdLabelIndexFromId, + +} from 'common/util' +export { + recursiveFindRegionWithLabelIndexId +} from 'src/util/fn' export { getNgIds } from 'src/util/fn' @@ -23,7 +31,7 @@ import { ACTION_TYPES as USER_CONFIG_ACTION_TYPES, defaultState as userConfigDefaultState, StateInterface as UserConfigStateInterface, - stateStore as userConfigState, + userConfigReducer as userConfigState, } from './state/userConfigState.store' import { defaultState as viewerConfigDefaultState, @@ -149,41 +157,6 @@ export function isDefined(obj) { return typeof obj !== 'undefined' && obj !== null } -export function generateLabelIndexId({ ngId, labelIndex }) { - return `${ngId}#${labelIndex}` -} - -export function getNgIdLabelIndexFromId({ labelIndexId } = {labelIndexId: ''}) { - const _ = labelIndexId && labelIndexId.split && labelIndexId.split('#') || [] - const ngId = _.length > 1 - ? _[0] - : null - const labelIndex = _.length > 1 - ? Number(_[1]) - : _.length === 0 - ? null - : Number(_[0]) - return { ngId, labelIndex } -} - -const recursiveFlatten = (region, {ngId}) => { - return [{ - ngId, - ...region, - }].concat( - ...((region.children && region.children.map && region.children.map(c => recursiveFlatten(c, { ngId : region.ngId || ngId })) ) || []), - ) -} - -export function recursiveFindRegionWithLabelIndexId({ regions, labelIndexId, inheritedNgId = 'root' }: {regions: any[], labelIndexId: string, inheritedNgId: string}) { - const { ngId, labelIndex } = getNgIdLabelIndexFromId({ labelIndexId }) - const fr1 = regions.map(r => recursiveFlatten(r, { ngId: inheritedNgId })) - const fr2 = fr1.reduce((acc, curr) => acc.concat(...curr), []) - const found = fr2.find(r => r.ngId === ngId && Number(r.labelIndex) === Number(labelIndex)) - if (found) { return found } - return null -} - export interface IavRootStoreInterface { pluginState: PluginStateInterface viewerConfigState: ViewerConfigStateInterface diff --git a/src/ui/config/config.template.html b/src/ui/config/config.template.html index adfe40f93..deb625baa 100644 --- a/src/ui/config/config.template.html +++ b/src/ui/config/config.template.html @@ -201,5 +201,10 @@ </div> </div> </mat-tab> + + <!-- plugin csp --> + <mat-tab label="Plugin Permission"> + <plugin-csp-controller></plugin-csp-controller> + </mat-tab> </mat-tab-group> diff --git a/src/ui/config/pluginCsp/pluginCsp.component.ts b/src/ui/config/pluginCsp/pluginCsp.component.ts new file mode 100644 index 000000000..73a50c6b4 --- /dev/null +++ b/src/ui/config/pluginCsp/pluginCsp.component.ts @@ -0,0 +1,32 @@ +import { Component } from "@angular/core"; +import { select, Store } from "@ngrx/store"; +import { map, tap } from "rxjs/operators"; +import { PluginServices } from "src/atlasViewer/pluginUnit"; +import { selectorAllPluginsCspPermission } from "src/services/state/userConfigState.store"; + +@Component({ + selector: 'plugin-csp-controller', + templateUrl: './pluginCsp.template.html', + styleUrls: [ + './pluginCsp.style.css' + ] +}) + +export class PluginCspCtrlCmp{ + + public pluginCsp$ = this.store$.pipe( + select(selectorAllPluginsCspPermission), + map(pluginCsp => Object.keys(pluginCsp).map(key => ({ pluginKey: key, pluginCsp: pluginCsp[key] }))), + ) + + constructor( + private store$: Store<any>, + private pluginService: PluginServices, + ){ + + } + + revoke(pluginKey: string){ + this.pluginService.revokePluginPermission(pluginKey) + } +} \ No newline at end of file diff --git a/src/ui/config/pluginCsp/pluginCsp.style.css b/src/ui/config/pluginCsp/pluginCsp.style.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/ui/config/pluginCsp/pluginCsp.template.html b/src/ui/config/pluginCsp/pluginCsp.template.html new file mode 100644 index 000000000..477d41a9c --- /dev/null +++ b/src/ui/config/pluginCsp/pluginCsp.template.html @@ -0,0 +1,52 @@ + +<ng-container *ngIf="pluginCsp$ | async as pluginsCsp; else fallbackTmpl"> + + <ng-template #pluginsCspContainerTmpl> + <ng-container *ngTemplateOutlet="pluginCpTmpl; context: { pluginsCsp: pluginsCsp }"> + </ng-container> + </ng-template> + + <ng-container *ngIf="pluginsCsp.length === 0; else pluginsCspContainerTmpl"> + <ng-container *ngTemplateOutlet="fallbackTmpl"> + </ng-container> + </ng-container> +</ng-container> + +<ng-template #fallbackTmpl> + You have not granted permission to any plugins. +</ng-template> + +<ng-template #pluginCpTmpl let-pluginsCsp="pluginsCsp"> + <p> + You have granted permission to the following plugins + </p> + + <mat-accordion> + <mat-expansion-panel *ngFor="let pluginCsp of pluginCsp$ | async"> + <mat-expansion-panel-header> + <mat-panel-title> + {{ pluginCsp['pluginKey'] }} + </mat-panel-title> + </mat-expansion-panel-header> + + <button mat-raised-button + color="warn" + (click)="revoke(pluginCsp['pluginKey'])"> + Revoke + </button> + + <mat-list> + <ng-container *ngFor="let csp of pluginCsp['pluginCsp'] | objToArray"> + <span mat-subheader> + {{ csp['key'] }} + </span> + <mat-list-item *ngFor="let item of csp['value']"> + {{ item }} + </mat-list-item> + </ng-container> + </mat-list> + + </mat-expansion-panel> + </mat-accordion> + +</ng-template> diff --git a/src/ui/nehubaContainer/nehubaContainer.component.spec.ts b/src/ui/nehubaContainer/nehubaContainer.component.spec.ts index 6feb8bf61..796c6ce28 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.spec.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.spec.ts @@ -295,7 +295,9 @@ describe('> nehubaContainer.component.ts', () => { templateSelected: bigbrainJson, parcellationSelected: bigbrainJson.parcellations[0], regionsSelected: [{ - name: "foobar" + name: "foobar", + ngId: 'untitled', + labelIndex: 15 }] }, [viewerStateHelperStoreName]: { diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 0c116696a..b45831e8f 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -85,6 +85,7 @@ import { RegionAccordionTooltipTextPipe } from './util' import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; import { RegionalFeaturesModule } from "./regionalFeatures"; import { Landmark2DModule } from "./nehubaContainer/2dLandmarks/module"; +import { PluginCspCtrlCmp } from "./config/pluginCsp/pluginCsp.component"; @NgModule({ imports : [ @@ -123,6 +124,7 @@ import { Landmark2DModule } from "./nehubaContainer/2dLandmarks/module"; AtlasDropdownSelector, AtlasLayerSelector, AtlasDropdownSelector, + PluginCspCtrlCmp, StatusCardComponent, CookieAgreement, diff --git a/src/util/fn.ts b/src/util/fn.ts index c589873f2..9307ae093 100644 --- a/src/util/fn.ts +++ b/src/util/fn.ts @@ -1,3 +1,5 @@ +import { deserialiseParcRegionId } from 'common/util' + export function isSame(o, n) { if (!o) { return !n } return o === n || (o && n && o.name === n.name) @@ -31,3 +33,21 @@ export function getNgIds(regions: any[]): string[] { .filter(ngId => !!ngId) : [] } + +const recursiveFlatten = (region, {ngId}) => { + return [{ + ngId, + ...region, + }].concat( + ...((region.children && region.children.map && region.children.map(c => recursiveFlatten(c, { ngId : region.ngId || ngId })) ) || []), + ) +} + +export function recursiveFindRegionWithLabelIndexId({ regions, labelIndexId, inheritedNgId = 'root' }: {regions: any[], labelIndexId: string, inheritedNgId: string}) { + const { ngId, labelIndex } = deserialiseParcRegionId( labelIndexId ) + const fr1 = regions.map(r => recursiveFlatten(r, { ngId: inheritedNgId })) + const fr2 = fr1.reduce((acc, curr) => acc.concat(...curr), []) + const found = fr2.find(r => r.ngId === ngId && Number(r.labelIndex) === Number(labelIndex)) + if (found) { return found } + return null +} diff --git a/src/util/pipes/objToArray.pipe.spec.ts b/src/util/pipes/objToArray.pipe.spec.ts new file mode 100644 index 000000000..d1a54e082 --- /dev/null +++ b/src/util/pipes/objToArray.pipe.spec.ts @@ -0,0 +1,17 @@ +import { ObjectToArrayPipe } from "./objToArray.pipe" + +describe('> objToArray.pipe.ts', () => { + describe('> ObjectToArrayPipe', () => { + const pipe = new ObjectToArrayPipe() + it('> transforms obj to array', () => { + const result = pipe.transform({'a': '1', 'b': '2'}) + expect(result).toEqual([{ + key: 'a', + value: '1' + }, { + key: 'b', + value: '2' + }]) + }) + }) +}) \ No newline at end of file diff --git a/src/util/pipes/objToArray.pipe.ts b/src/util/pipes/objToArray.pipe.ts new file mode 100644 index 000000000..9320b3631 --- /dev/null +++ b/src/util/pipes/objToArray.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +interface ITransformedObj{ + key: string + value: string +} + +@Pipe({ + name: 'objToArray', + pure: true +}) + +export class ObjectToArrayPipe implements PipeTransform{ + public transform(input: { [key: string]: any }): ITransformedObj[]{ + return Object.keys(input).map(key => { + return { + key, + value: input[key] + } + }) + } +} \ No newline at end of file diff --git a/src/util/util.module.ts b/src/util/util.module.ts index 8d45bb6c9..ad736e0d1 100644 --- a/src/util/util.module.ts +++ b/src/util/util.module.ts @@ -25,6 +25,7 @@ import { FilterByPropertyPipe } from "./pipes/filterByProperty.pipe"; import { ArrayContainsPipe } from "./pipes/arrayContains.pipe"; import { DoiParserPipe } from "./pipes/doiPipe.pipe"; import { TmpParcNamePipe } from "./pipes/_tmpParcName.pipe"; +import { ObjectToArrayPipe } from "./pipes/objToArray.pipe"; @NgModule({ imports:[ @@ -55,6 +56,7 @@ import { TmpParcNamePipe } from "./pipes/_tmpParcName.pipe"; ArrayContainsPipe, DoiParserPipe, TmpParcNamePipe, + ObjectToArrayPipe, ], exports: [ FilterNullPipe, @@ -81,6 +83,7 @@ import { TmpParcNamePipe } from "./pipes/_tmpParcName.pipe"; ArrayContainsPipe, DoiParserPipe, TmpParcNamePipe, + ObjectToArrayPipe, ] }) -- GitLab