diff --git a/deploy/auth/hbp-oidc-v2.js b/deploy/auth/hbp-oidc-v2.js index 102aa25c236b6a61801fcae04541622af23ea676..d5427480817ab9ad0a58e9a2c2a103eafa6d5f3b 100644 --- a/deploy/auth/hbp-oidc-v2.js +++ b/deploy/auth/hbp-oidc-v2.js @@ -78,8 +78,4 @@ module.exports = { console.error('oidcv2 auth error', e) } }, - getClient: async () => { - await memoizedInit() - return client - } } diff --git a/deploy/auth/index.spec.js b/deploy/auth/index.spec.js index 84691ddd6ccae9169f321858453c2eacf0dcb28c..8e33c30bd02054d71c31b862b22928d12f359928 100644 --- a/deploy/auth/index.spec.js +++ b/deploy/auth/index.spec.js @@ -8,7 +8,7 @@ const appGetStub = sinon.stub() describe('auth/index.js', () => { before(() => { require.cache[require.resolve('./util')] = { - exports: { initPassportJs: initPassportJsStub } + exports: { initPassportJs: initPassportJsStub, objStoreDb: new Map() } } require.cache[require.resolve('./hbp-oidc-v2')] = { exports: { diff --git a/deploy/package-lock.json b/deploy/package-lock.json index 3a8058b86a578efd909ca7b5d28a32edd93e5492..b8399147d5ab12a520ea0f2068cba33945db6f9b 100644 --- a/deploy/package-lock.json +++ b/deploy/package-lock.json @@ -503,6 +503,11 @@ "wrap-ansi": "^2.0.0" } }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + }, "clone-response": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", @@ -695,6 +700,14 @@ "type-detect": "^4.0.0" } }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "^1.0.2" + } + }, "defer-to-connect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.0.tgz", @@ -936,6 +949,11 @@ } } }, + "express-rate-limit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.5.1.tgz", + "integrity": "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==" + }, "express-session": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.15.6.tgz", @@ -2118,6 +2136,15 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" }, + "rate-limit-redis": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-2.1.0.tgz", + "integrity": "sha512-6SAsTCzY0v6UCIKLOLLYqR2XzFmgdtF7jWXlSPq2FrNIZk8tZ7xwBvyGW7GFMCe5I4S9lYNdrSJ9E84rz3/CpA==", + "requires": { + "defaults": "^1.0.3", + "redis": "^3.0.2" + } + }, "raw-body": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", diff --git a/deploy/package.json b/deploy/package.json index a8b5d1488439346a9c7b6adc2322a57a482f218e..04cdc908c505ae7a5d8663fbfae313f561b0be80 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -17,6 +17,7 @@ "connect-redis": "^5.0.0", "cookie-parser": "^1.4.5", "express": "^4.16.4", + "express-rate-limit": "^5.5.1", "express-session": "^1.15.6", "got": "^10.5.5", "hbp-seafile": "^0.2.0", @@ -26,6 +27,7 @@ "nomiseco": "0.0.2", "openid-client": "^4.4.0", "passport": "^0.4.0", + "rate-limit-redis": "^2.1.0", "redis": "^3.1.2", "request": "^2.88.0", "showdown": "^1.9.1", diff --git a/deploy/saneUrl/depcObjStore.js b/deploy/saneUrl/depcObjStore.js index a46d6c845936fef10b77b4ac9bb9ed278c8a00e9..c67876686470650d08217cc72141a6936f703511 100644 --- a/deploy/saneUrl/depcObjStore.js +++ b/deploy/saneUrl/depcObjStore.js @@ -21,6 +21,10 @@ class Store { }) } + async del(id){ + // noop + } + async set(id, val){ throw new Error(`Object store is deprecated. Please use seafile storage instead`) } diff --git a/deploy/saneUrl/index.js b/deploy/saneUrl/index.js index 09fe10b25a57e336b25118d57f4b5849c1f8234d..4bfcf3740c70b47b9f746f9fcda2170c5461643a 100644 --- a/deploy/saneUrl/index.js +++ b/deploy/saneUrl/index.js @@ -1,46 +1,31 @@ const express = require('express') const router = express.Router() -const { FallbackStore: Store, NotFoundError } = require('./store') +const { GitlabSnippetStore: Store, NotFoundError } = require('./store') const { Store: DepcStore } = require('./depcObjStore') +const RateLimit = require('express-rate-limit') +const RedisStore = require('rate-limit-redis') +const { redisURL } = require('../lruStore') +const { ProxyStore, NotExactlyPromiseAny } = require('./util') const store = new Store() const depStore = new DepcStore() -const { HOSTNAME, HOST_PATHNAME } = process.env +const proxyStore = new ProxyStore(store) -const acceptHtmlProg = /text\/html/i - -const getFileFromStore = async (name, store) => { - try { - const value = await store.get(name) - const json = JSON.parse(value) - const { expiry } = json - if ( expiry && ((Date.now() - expiry) > 0) ) { - return null - } - - return value - } catch (e) { - if (e instanceof NotFoundError) { - return null - } - throw e - } -} - -const getFile = async name => { - const value = await getFileFromStore(name, depStore) - || await getFileFromStore(name, store) +const { + HOSTNAME, + HOST_PATHNAME, + DISABLE_LIMITER, +} = process.env - return value -} +const limiter = new RateLimit({ + windowMs: 1e3 * 5, + max: 5, + ...( redisURL ? { store: new RedisStore({ redisURL }) } : {} ) +}) +const passthrough = (_, __, next) => next() -const hardCodedMap = new Map([ - ['bigbrainGreyWhite', '#/a:juelich:iav:atlas:v1.0.0:1/t:minds:core:referencespace:v1.0.0:a1655b99-82f1-420f-a3c2-fe80fd4c8588/p:juelich:iav:atlas:v1.0.0:4/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIx..gIW~.10AwC.B1KK~..1LSm'], - ['whs4', '#/a:minds:core:parcellationatlas:v1.0.0:522b368e-49a3-49fa-88d3-0870a307974a/t:minds:core:referencespace:v1.0.0:d5717c4a-0fa1-46e6-918c-b8003069ade8/p:minds:core:parcellationatlas:v1.0.0:ebb923ba-b4d5-4b82-8088-fa9215c2e1fe-v4/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..kxV..0.0.0..8Yu'], - ['allen2017', '#/a:juelich:iav:atlas:v1.0.0:2/t:minds:core:referencespace:v1.0.0:265d32a0-3d84-40a5-926f-bf89f68212b9/p:minds:core:parcellationatlas:v1.0.0:05655b58-3b6f-49db-b285-64b5a0276f83/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..kxV..0.0.0..8Yu'], - ['mebrains', '#/a:juelich:iav:atlas:v1.0.0:monkey/t:minds:core:referencespace:v1.0.0:MEBRAINS_T1.masked/p:minds:core:parcellationatlas:v1.0.0:mebrains-tmp-id/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIx..0.0.0..1LSm'] -]) +const acceptHtmlProg = /text\/html/i router.get('/:name', async (req, res) => { const { name } = req.params @@ -50,30 +35,29 @@ router.get('/:name', async (req, res) => { try { const REAL_HOSTNAME = `${HOSTNAME}${HOST_PATHNAME || ''}/` - const hardcodedRedir = hardCodedMap.get(name) - if (hardcodedRedir) { - if (redirectFlag) res.redirect(`${REAL_HOSTNAME}${hardcodedRedir}`) - else res.status(200).send(hardcodedRedir) - return - } - - const value = await getFile(name) - if (!value) throw new NotFoundError() - const json = JSON.parse(value) - const { queryString } = json + const json = await NotExactlyPromiseAny([ + ProxyStore.StaticGet(depStore, req, name), + proxyStore.get(req, name) + ]) - if (redirectFlag) res.redirect(`${REAL_HOSTNAME}?${queryString}`) - else res.status(200).send(value) + const { queryString, hashPath } = json + if (redirectFlag) { + if (queryString) return res.redirect(`${REAL_HOSTNAME}?${queryString}`) + if (hashPath) return res.redirect(`${REAL_HOSTNAME}#${hashPath}`) + } else { + return res.status(200).send(json) + } } catch (e) { + const notFoundFlag = e instanceof NotFoundError if (redirectFlag) { const REAL_HOSTNAME = `${HOSTNAME}${HOST_PATHNAME || ''}/` res.cookie( 'iav-error', - e instanceof NotFoundError ? `${name} + notFoundFlag ? `${name} not found` : `error while fetching ${name}.`, { @@ -84,13 +68,25 @@ router.get('/:name', async (req, res) => { ) return res.redirect(REAL_HOSTNAME) } - if (e instanceof NotFoundError) return res.status(404).end() + if (notFoundFlag) return res.status(404).end() else return res.status(500).send(e.toString()) } }) router.post('/:name', - (_req, res) => res.status(410).end() + DISABLE_LIMITER ? passthrough : limiter, + express.json(), + async (req, res) => { + if (req.headers['x-noop']) return res.status(200).end() + const { name } = req.params + try { + await proxyStore.set(req, name, req.body) + res.status(201).end() + } catch (e) { + console.log(e.body) + res.status(500).send(e.toString()) + } + } ) router.use((_, res) => { diff --git a/deploy/saneUrl/store.js b/deploy/saneUrl/store.js index 656a070f4fcd5805d816c54390449546fa2885a1..9ef35e8408fd089bb7778ed02d68bd3e18fede58 100644 --- a/deploy/saneUrl/store.js +++ b/deploy/saneUrl/store.js @@ -1,420 +1,112 @@ -const redis = require('redis') -const { promisify } = require('util') -const { getClient } = require('../auth/hbp-oidc-v2') -const { jwtDecode } = require('../auth/oidc') const request = require('request') -const HBP_OIDC_V2_REFRESH_TOKEN_KEY = `HBP_OIDC_V2_REFRESH_TOKEN_KEY` // only insert valid refresh token. needs to be monitored to ensure always get new refresh token(s) -const HBP_OIDC_V2_ACCESS_TOKEN_KEY = `HBP_OIDC_V2_ACCESS_TOKEN_KEY` // only insert valid access token. if expired, get new one via refresh token, then pop & push -const HBP_OIDC_V2_UPDATE_CHAN = `HBP_OIDC_V2_UPDATE_CHAN` // stringified JSON key val of above three, with time stamp -const HBP_OIDC_V2_UPDATE_KEY = `HBP_OIDC_V2_UPDATE_KEY` -const HBP_DATAPROXY_READURL = `HBP_DATAPROXY_READURL` -const HBP_DATAPROXY_WRITEURL = `HBP_DATAPROXY_WRITEURL` +const apiPath = '/api/v4' +const saneUrlVer = `0.0.1` +const titlePrefix = `[saneUrl]` const { __DEBUG__, - - HBP_V2_REFRESH_TOKEN, - HBP_V2_ACCESS_TOKEN, - - DATA_PROXY_URL, - DATA_PROXY_BUCKETNAME, - + GITLAB_ENDPOINT, + GITLAB_PROJECT_ID, + GITLAB_TOKEN } = process.env class NotFoundError extends Error{} -// not part of class, since class is exported, and prototype of class can be easily changed -// using stderr instead of console.error, as by default, logs are collected via fluentd -// debug messages should not be kept as logs -function log(message){ - if (__DEBUG__) { - process.stderr.write(`__DEBUG__`) - process.stdout.write(`\n`) - if (typeof message === 'object') { - process.stderr.write(JSON.stringify(message, null, 2)) - } - if (typeof message === 'number' || typeof message === 'string') { - process.stdout.write(message) - } - process.stdout.write(`\n`) - } -} - - -function _checkValid(urlString){ - log({ - breakpoint: '_checkValid', - payload: urlString - }) - if (!urlString) return false - const url = new URL(urlString) - const expiry = url.searchParams.get('temp_url_expires') - return (new Date() - new Date(expiry)) < 1e3 * 10 -} - -class FallbackStore { - async get(){ - throw new Error(`saneurl is currently offline for maintainence.`) - } - async set(){ - throw new Error(`saneurl is currently offline for maintainence.`) - } - async healthCheck() { - return false - } -} +class NotImplemented extends Error{} -class Store { +class GitlabSnippetStore { constructor(){ - throw new Error(`Not implemented`) - - this.healthFlag = false - this.seafileHandle = null - this.seafileRepoId = null - - /** - * setup redis(or mock) client - */ - this.redisClient = redisURL - ? redis.createClient({ - url: redisURL - }) - : ({ - onCbs: [], - keys: {}, - get: async (key, cb) => { - await Promise.resolve() - cb(null, this.keys[key]) - }, - set: async (key, value, cb) => { - await Promise.resolve() - this.keys[key] = value - cb(null) - }, - on(eventName, cb) { - if (eventName === 'message') { - this.onCbs.push(cb) - } - }, - publish(channel, message){ - for (const cb of this.onCbs){ - cb(channel, message) - } - }, - quit(){} - }) - - this.redisUtil = { - asyncGet: promisify(this.redisClient.get).bind(this.redisClient), - asyncSet: promisify(this.redisClient.set).bind(this.redisClient), - } - - this.pending = {} - this.keys = {} - - this.redisClient.on('message', async (chan, mess) => { - /** - * only liten to HBP_OIDC_V2_UPDATE_CHAN update - */ - if (chan === HBP_OIDC_V2_UPDATE_CHAN) { - try { - const { pending, update } = JSON.parse(mess) - this.pending = pending - for (const key in update) { - try { - this.keys[key] = await this.redisUtil.asyncGet(key) - console.log('on message get key', key, this.keys[key]) - } catch (e) { - console.error(`[saneUrl][store.js] get key ${key} error`) - } - } - } catch (e) { - console.error(`[saneUrl][store.js] parse message HBP_OIDC_V2_UPDATE_CHAN error`) - } - } - }) - this.init() - - /** - * check expiry - */ - this.intervalRef = setInterval(() => { - this.checkExpiry() - }, 1000 * 60) - } - - async init() { - this.keys = { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: (await this.redisUtil.asyncGet(HBP_OIDC_V2_REFRESH_TOKEN_KEY)) || HBP_V2_REFRESH_TOKEN, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: (await this.redisUtil.asyncGet(HBP_OIDC_V2_ACCESS_TOKEN_KEY)) || HBP_V2_ACCESS_TOKEN, - [HBP_DATAPROXY_READURL]: await this.redisUtil.asyncGet(HBP_DATAPROXY_READURL), - [HBP_DATAPROXY_WRITEURL]: await this.redisUtil.asyncGet(HBP_DATAPROXY_WRITEURL), - } - - this.healthFlag = true - } - - async checkExpiry(){ - - const { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: refreshToken, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: accessToken - } = this.keys - - log({ - breakpoint: 'async checkExpiry', - }) - - /** - * if access token is absent - * try to refresh token, without needing to check exp - */ - if (!accessToken) { - await this.doRefreshTokens() - return true - } - const { exp: refreshExp } = jwtDecode(refreshToken) - const { exp: accessExp } = jwtDecode(accessToken) - - const now = new Date().getTime() / 1000 - - if (now > refreshExp) { - console.warn(`[saneUrl] refresh token expired... Need a new refresh token`) - return false - } - if (now > accessExp) { - console.log(`[saneUrl] access token expired. Refreshing access token...`) - await this.doRefreshTokens() - console.log(`[saneUrl] access token successfully refreshed...`) - return true + if ( + GITLAB_ENDPOINT + && GITLAB_PROJECT_ID + && GITLAB_TOKEN + ) { + this.url = `${GITLAB_ENDPOINT}${apiPath}/projects/${GITLAB_PROJECT_ID}/snippets` + this.token = GITLAB_TOKEN + return } - - return true + throw new NotImplemented('Gitlab snippet key value store cannot be configured') } - async doRefreshTokens(){ - - log({ - breakpoint: 'doing refresh tokens' + _promiseRequest(...arg) { + return new Promise((rs, rj) => { + request(...arg, (err, resp, body) => { + if (err) return rj(err) + if (resp.statusCode >= 400) return rj(resp) + rs(body) + }) }) - - /** - * first, check if another process/pod is currently updating - * if they are, give them 1 minute to complete the process - * (usually takes takes less than 5 seconds) - */ - const pendingStart = this.pending[HBP_OIDC_V2_REFRESH_TOKEN_KEY] && - this.pending[HBP_OIDC_V2_REFRESH_TOKEN_KEY].start + 1000 * 60 - const now = new Date() - if (pendingStart && pendingStart > now) { - return - } - - /** - * When start refreshing the tokens, set pending attribute, and start timestamp - */ - const payload = { - pending: { - ...this.pending, - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: { - start: Date.now() - } - } - } - await this.redisUtil.asyncSet(HBP_OIDC_V2_UPDATE_KEY, JSON.stringify(payload)) - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify(payload)) - - const client = await getClient() - const tokenset = await client.refresh(this.keys[HBP_OIDC_V2_REFRESH_TOKEN_KEY]) - const { access_token: accessToken, refresh_token: refreshToken } = tokenset - - const { exp: accessTokenExp } = jwtDecode(accessToken) - const { exp: refreshTokenExp } = jwtDecode(refreshToken) - - if (refreshTokenExp - accessTokenExp < 60 * 60 ) { - console.warn(`[saneUrl] refreshToken expires within 1 hour of access token! ${accessTokenExp} ${refreshTokenExp}`) - } - - /** - * once tokens have been refreshed, set them in the redis store first - * Then publish the update message - */ - await this.redisUtil.asyncSet(HBP_OIDC_V2_REFRESH_TOKEN_KEY, refreshToken) - await this.redisUtil.asyncSet(HBP_OIDC_V2_ACCESS_TOKEN_KEY, accessToken) - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify({ - keys: { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: refreshToken, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: accessToken, - }, - pending: { - ...this.pending, - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: null - } - })) - this.keys = { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: refreshToken, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: accessToken, - } } - async _getTmpUrl(permission){ - if (permission !== 'read' && permission !== 'write') throw new Error(`permission need to be either read or write!`) - const method = permission === 'read' - ? 'GET' - : 'PUT' - log({ - breakpoint: 'access key', - access: this.keys[HBP_OIDC_V2_ACCESS_TOKEN_KEY] - }) - const { url } = await new Promise((rs, rj) => { - const payload = { + _request({ addPath = '', method = 'GET', headers = {}, opt = {} } = {}) { + return new Promise((rs, rj) => { + request(`${this.url}${addPath}`, { method, - uri: `${DATA_PROXY_URL}/tempurl/${DATA_PROXY_BUCKETNAME}?lifetime=very_long`, headers: { - 'Authorization': `Bearer ${this.keys[HBP_OIDC_V2_ACCESS_TOKEN_KEY]}` - } - } - log({ - breakpoint: '_getTmpUrl', - payload - }) - request(payload, (err, resp, body) => { + 'PRIVATE-TOKEN': this.token, + ...headers + }, + ...opt + }, (err, resp, body) => { if (err) return rj(err) - if (resp.statusCode >= 400) return rj(new Error(`${resp.statusCode}: ${resp.statusMessage}`)) - if (resp.statusCode === 200) { - rs(JSON.parse(body)) - return - } - return rj(new Error(`[saneurl] [get] unknown error`)) + if (resp.statusCode >= 400) return rj(resp) + return rs(body) }) }) - - return url - } - - _getTmplTag(key, _strings, _proxyUrl, bucketName, id){ - const defaultReturn = `${_strings[0]}${_proxyUrl}${_strings[1]}${bucketName}${_strings[2]}${id}` - if (!key) return defaultReturn - const { - [key]: urlOfInterest - } = this.keys - if (!urlOfInterest) return defaultReturn - const url = new URL(urlOfInterest) - url.pathname += id - return url - } - - _getWriteTmplTag(...args) { - return this._getTmplTag(HBP_DATAPROXY_WRITEURL, ...args) - } - - _getReadTmplTag(...args){ - return this._getTmplTag(HBP_DATAPROXY_READURL, ...args) } async get(id) { - log({ - breakpoint: 'async get', - id - }) - const { - [HBP_DATAPROXY_READURL]: readUrl - } = this.keys - - if (!_checkValid(readUrl)) { - log({ - breakpoint: 'read url not valid, getting new url' - }) - const url = await this._getTmpUrl('read') - const payload = { - keys: { - [HBP_DATAPROXY_READURL]: url - } - } - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify(payload)) - this.redisUtil.asyncSet(HBP_DATAPROXY_READURL, url) - this.keys[HBP_DATAPROXY_READURL] = url + const list = JSON.parse(await this._request()) + const found = list.find(item => item.title === `${titlePrefix}${id}`) + if (!found) throw new NotFoundError() + const payloadObj = found.files.find(f => f.path === 'payload') + if (!payloadObj) { + console.error(`id found, but payload not found... this is strange. Check id: ${id}`) + throw new NotFoundError() } - - return await new Promise((rs, rj) => { - request({ - method: 'GET', - uri: this._getReadTmplTag`${DATA_PROXY_URL}/buckets/${DATA_PROXY_BUCKETNAME}/${encodeURIComponent(id)}`, - }, (err, resp, body) => { - if (err) return rj(err) - if (resp.statusCode === 404) return rj(new NotFoundError()) - if (resp.statusCode >= 400) return rj(new Error(`${resp.statusCode}: ${resp.statusMessage}`)) - if (resp.statusCode === 200) return rs(body) - return rj(new Error(`[saneurl] [get] unknown error`)) - }) - }) + if (!payloadObj.raw_url) { + throw new Error(`payloadObj.raw_url not found!`) + } + return await this._promiseRequest(payloadObj.raw_url) } - async _set(id, value) { - const { - [HBP_DATAPROXY_WRITEURL]: writeUrl - } = this.keys - - if (!_checkValid(writeUrl)) { - log({ - breakpoint: '_set', - message: 'write url not valid, getting new one' - }) - const url = await this._getTmpUrl('write') - const payload = { - keys: { - [HBP_DATAPROXY_WRITEURL]: url + async set(id, value) { + return await this._request({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + opt: { + json: { + title: `${titlePrefix}${id}`, + description: `Created programmatically. v${saneUrlVer}`, + visibility: 'public', + files: [{ + file_path: 'payload', + content: value + }] } } - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify(payload)) - this.redisUtil.asyncSet(HBP_DATAPROXY_WRITEURL, url) - this.keys[HBP_DATAPROXY_WRITEURL] = url - log({ - breakpoint: '_set', - message: 'got new write url' - }) - } - - await new Promise((rs, rj) => { - const payload = { - method: 'PUT', - uri: this._getWriteTmplTag`${DATA_PROXY_URL}/buckets/${DATA_PROXY_BUCKETNAME}/${encodeURIComponent(id)}`, - headers: { - 'content-type': 'text/plain; charset=utf-8' - }, - body: value - } - log({ - breakpoint: 'pre set', - payload - }) - request(payload, (err, resp, body) => { - if (err) return rj(err) - if (resp.statusCode === 404) return rj(new NotFoundError()) - if (resp.statusCode >= 400) return rj(new Error(`${resp.statusCode}: ${resp.statusMessage}`)) - if (resp.statusCode >= 200 && resp.statusCode < 300) return rs(body) - return rj(new Error(`[saneurl] [get] unknown error`)) - }) }) } - async set(id, value) { - const result = await this._set(id, value) - return result + async del(id) { + return await this._request({ + addPath: `/${id}`, + method: 'DELETE', + }) } - dispose(){ - clearInterval(this.intervalRef) - this.redisClient && this.redisClient.quit() + async dispose(){ + } async healthCheck(){ - return this.healthFlag + return true } } -exports.FallbackStore = FallbackStore -exports.Store = Store +exports.GitlabSnippetStore = GitlabSnippetStore exports.NotFoundError = NotFoundError diff --git a/deploy/saneUrl/util.js b/deploy/saneUrl/util.js new file mode 100644 index 0000000000000000000000000000000000000000..249ec333d9ccc2d84cbba295425ebd55fb8c7b89 --- /dev/null +++ b/deploy/saneUrl/util.js @@ -0,0 +1,57 @@ +const { NotFoundError } = require('./store') + +class ProxyStore { + static async StaticGet(store, req, name) { + const payload = JSON.parse(await store.get(name)) + const { expiry, value, ...rest } = payload + if (expiry && (Date.now() > expiry)) { + await store.del(name) + throw new NotFoundError('Expired') + } + // backwards compatibility for depcObjStore . + // when depcObjStore is fully removed, || rest can also be removed + return value || rest + } + + constructor(store){ + this.store = store + } + async get(req, name) { + return await ProxyStore.StaticGet(this.store, req, name) + } + + async set(req, name, value) { + const supplementary = req.user + ? { + userId: req.user.id, + expiry: null + } + : { + userId: null, + expiry: Date.now() + 1000 * 60 * 60 * 72 + } + + const fullPayload = { + value, + ...supplementary + } + return await this.store.set(name, JSON.stringify(fullPayload)) + } +} + +const NotExactlyPromiseAny = async arg => { + const errs = [] + for (const pr of arg) { + try{ + return await pr + } catch (e) { + errs.push(e) + } + } + throw new NotFoundError(errs) +} + +module.exports = { + ProxyStore, + NotExactlyPromiseAny +} diff --git a/deploy/saneUrl/util.spec.js b/deploy/saneUrl/util.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0645c3291a71d381840d93d59ae836f150b9acfe --- /dev/null +++ b/deploy/saneUrl/util.spec.js @@ -0,0 +1,208 @@ +const { ProxyStore, NotExactlyPromiseAny } = require('./util') +const { expect, assert } = require('chai') +const sinon = require('sinon') +const { NotFoundError } = require('./store') + +const _name = 'foo-bar' +const _stringValue = 'hello world' +const _objValue = { + 'foo': 'bar' +} +const _req = {} + +describe('> saneUrl/util.js', () => { + describe('> ProxyStore', () => { + let store = { + set: sinon.stub(), + get: sinon.stub(), + del: sinon.stub(), + } + beforeEach(() => { + store.set.returns(Promise.resolve()) + store.get.returns(Promise.resolve('{}')) + store.del.returns(Promise.resolve()) + }) + afterEach(() => { + store.set.resetHistory() + store.get.resetHistory() + store.del.resetHistory() + }) + describe('> StaticGet', () => { + it('> should call store.get', () => { + ProxyStore.StaticGet(store, _req, _name) + assert(store.get.called, 'called') + assert(store.get.calledWith(_name), 'called with right param') + }) + describe('> if not hit', () => { + const err = new NotFoundError('not found') + beforeEach(() => { + store.get.returns( + Promise.reject(err) + ) + }) + it('should throw same error', async () => { + try{ + await ProxyStore.StaticGet(store, _req, _name) + assert(false, 'Should throw') + } catch (e) { + assert(e instanceof NotFoundError) + } + }) + }) + describe('> if hit', () => { + describe('> if expired', () => { + beforeEach(() => { + store.get.returns( + Promise.resolve( + JSON.stringify({ + expiry: Date.now() - 1000 + }) + ) + ) + }) + it('> should call store.del', async () => { + try { + await ProxyStore.StaticGet(store, _req, _name) + } catch (e) { + + } + assert(store.del.called, 'store.del should be called') + }) + it('> should throw NotFoundError', async () => { + try { + await ProxyStore.StaticGet(store, _req, _name) + assert(false, 'expect throw') + } catch (e) { + assert(e instanceof NotFoundError, 'throws NotFoundError') + } + }) + }) + describe('> if not expired', () => { + it('> should return .value, if exists', async () => { + store.get.returns( + Promise.resolve( + JSON.stringify({ + value: _objValue, + others: _stringValue + }) + ) + ) + const returnObj = await ProxyStore.StaticGet(store, _req, _name) + expect(returnObj).to.deep.equal(_objValue) + }) + it('> should return ...rest if .value does not exist', async () => { + store.get.returns( + Promise.resolve( + JSON.stringify({ + others: _stringValue + }) + ) + ) + const returnObj = await ProxyStore.StaticGet(store, _req, _name) + expect(returnObj).to.deep.equal({ + others: _stringValue + }) + }) + }) + }) + + }) + + describe('> get', () => { + let staticGetStub + beforeEach(() => { + staticGetStub = sinon.stub(ProxyStore, 'StaticGet') + staticGetStub.returns(Promise.resolve()) + }) + afterEach(() => { + staticGetStub.restore() + }) + it('> proxies calls to StaticGet', async () => { + const store = {} + const proxyStore = new ProxyStore(store) + await proxyStore.get(_req, _name) + assert(staticGetStub.called) + assert(staticGetStub.calledWith(store, _req, _name)) + }) + }) + + describe('> set', () => { + let proxyStore + beforeEach(() => { + proxyStore = new ProxyStore(store) + store.set.returns(Promise.resolve()) + }) + + describe('> no user', () => { + it('> sets expiry some time in the future', async () => { + await proxyStore.set(_req, _name, _objValue) + assert(store.set.called) + + const [ name, stringifiedJson ] = store.set.args[0] + assert(name === _name, 'name is correct') + const payload = JSON.parse(stringifiedJson) + expect(payload.value).to.deep.equal(_objValue, 'payload is correct') + assert(!!payload.expiry, 'expiry exists') + assert((payload.expiry - Date.now()) > 1000 * 60 * 60 * 24, 'expiry is at least 24 hrs in the future') + + assert(!payload.userId, 'userId does not exist') + }) + }) + describe('> yes user', () => { + let __req = { + ..._req, + user: { + id: 'foo-bar-2' + } + } + it('> does not set expiry, but sets userId', async () => { + await proxyStore.set(__req, _name, _objValue) + assert(store.set.called) + + const [ name, stringifiedJson ] = store.set.args[0] + assert(name === _name, 'name is correct') + const payload = JSON.parse(stringifiedJson) + expect(payload.value).to.deep.equal(_objValue, 'payload is correct') + assert(!payload.expiry, 'expiry does not exist') + + assert(payload.userId, 'userId exists') + assert(payload.userId === 'foo-bar-2', 'user-id matches') + }) + }) + }) + }) + + describe('> NotExactlyPromiseAny', () => { + describe('> nothing resolves', () => { + it('> throws not found error', async () => { + try { + await NotExactlyPromiseAny([ + (async () => { + throw new Error(`not here`) + })(), + new Promise((rs, rj) => setTimeout(rj, 100)), + Promise.reject('uhoh') + ]) + assert(false, 'expected to throw') + } catch (e) { + assert(e instanceof NotFoundError, 'expect to throw not found error') + } + }) + }) + describe('> something resolves', () => { + it('> returns the first in list order', async () => { + try { + + const result = await NotExactlyPromiseAny([ + Promise.reject('uhoh'), + new Promise(rs => setTimeout(() => rs('hello world'), 100)), + Promise.resolve('foo-bar') + ]) + assert(result == 'hello world', 'expecting first in list to resolve successfully') + } catch (e) { + assert(false, 'not expecting to throw') + } + }) + }) + }) +}) diff --git a/docs/releases/v2.5.7.md b/docs/releases/v2.5.7.md index 4a8698429cde82cf60e500e7cdb290a3c90a85de..f043d78016f86f7dc85fe95aea053beb348320e2 100644 --- a/docs/releases/v2.5.7.md +++ b/docs/releases/v2.5.7.md @@ -10,6 +10,7 @@ ## Feature - Add menu to change perspective orientation by coronal/sagittal/axial views. +- re-introduced saneUrl ## Under the hood diff --git a/src/state/stateAggregator.directive.ts b/src/state/stateAggregator.directive.ts index 62d66666227e91a18bd1aac9ba2309d7a5c43a8d..28df9bf19c5000333fbe228743b1495932f117fd 100644 --- a/src/state/stateAggregator.directive.ts +++ b/src/state/stateAggregator.directive.ts @@ -1,14 +1,14 @@ -import { Directive } from "@angular/core"; +import { Directive, OnDestroy } from "@angular/core"; import { NavigationEnd, Router } from "@angular/router"; -import { Observable } from "rxjs"; -import { filter, map } from "rxjs/operators"; +import { Observable, Subscription } from "rxjs"; +import { filter, map, startWith } from "rxjs/operators"; const jsonVersion = '1.0.0' // ver 0.0.1 === query param interface IJsonifiedState { ver: string - queryString: any + hashPath: string } @Directive({ @@ -16,20 +16,30 @@ interface IJsonifiedState { exportAs: 'iavStateAggregator' }) -export class StateAggregator{ +export class StateAggregator implements OnDestroy{ - public jsonifiedSstate$: Observable<IJsonifiedState> + public jsonifiedState: IJsonifiedState + public jsonifiedState$: Observable<IJsonifiedState> = this.router.events.pipe( + filter(ev => ev instanceof NavigationEnd), + map((ev: NavigationEnd) => ev.urlAfterRedirects), + startWith(this.router.url), + map((path: string) => { + return { + ver: jsonVersion, + hashPath: path + } + }), + ) constructor( - router: Router + private router: Router ){ - this.jsonifiedSstate$ = router.events.pipe( - filter(ev => ev instanceof NavigationEnd), - map((ev: NavigationEnd) => { - return { - ver: jsonVersion, - queryString: ev.urlAfterRedirects - } - }) + this.subscriptions.push( + this.jsonifiedState$.subscribe(val => this.jsonifiedState = val) ) } + + private subscriptions: Subscription[] = [] + ngOnDestroy(){ + while (this.subscriptions.length) this.subscriptions.pop().unsubscribe() + } } diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.ts index 76dfa6bbbdb0288b6755984fa651d71b3e50c5df..a6daa233d6acdc5197d19d4a9dbc284c3e26f329 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.ts @@ -57,8 +57,6 @@ export class StatusCardComponent implements OnInit, OnChanges{ order: 6, } - public saneUrlDeprecated = `Custom URL is going away. New custom URLs can no longer be created. Custom URLs you generated in the past will continue to work.` - public SHARE_BTN_ARIA_LABEL = ARIA_LABELS.SHARE_BTN public COPY_URL_TO_CLIPBOARD_ARIA_LABEL = ARIA_LABELS.SHARE_COPY_URL_CLIPBOARD public SHARE_CUSTOM_URL_ARIA_LABEL = ARIA_LABELS.SHARE_CUSTOM_URL diff --git a/src/viewerModule/nehuba/statusCard/statusCard.template.html b/src/viewerModule/nehuba/statusCard/statusCard.template.html index 5f57d9719055a265dbfb005c5c4d7d3cc46ab1ea..098ea21dfcc1b67442de144308f08a9b59a02d20 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.template.html +++ b/src/viewerModule/nehuba/statusCard/statusCard.template.html @@ -157,8 +157,9 @@ <mat-list-item [attr.aria-label]="SHARE_CUSTOM_URL_ARIA_LABEL" [attr.tab-index]="10" - [matTooltip]="saneUrlDeprecated" - class="text-muted"> + (click)="openDialog(shareSaneUrl, { ariaLabel: SHARE_CUSTOM_URL_ARIA_LABEL })" + [matTooltip]="SHARE_CUSTOM_URL_ARIA_LABEL" + > <mat-icon class="mr-4" fontSet="fas" @@ -212,7 +213,7 @@ </div> <iav-sane-url iav-state-aggregator - [stateTobeSaved]="stateAggregator.jsonifiedSstate$ | async" + [stateTobeSaved]="stateAggregator.jsonifiedState$ | async" #stateAggregator="iavStateAggregator"> </iav-sane-url> </div>