From 6c43f7536d4835cdad10a7490a084c13db374a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Tue, 26 Apr 2022 15:23:42 +0100 Subject: [PATCH 1/6] refactor: move `getCustomInfoRequests` call out of config manager --- lib/graphql/resolvers.js | 6 +++--- lib/new-config-manager.js | 12 ++++++++---- lib/routes/pollingRoutes.js | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js index eb628fe7..01c2adb5 100644 --- a/lib/graphql/resolvers.js +++ b/lib/graphql/resolvers.js @@ -4,7 +4,7 @@ const nmd = require('nano-markdown') const { accounts: accountsConfig, countries, languages } = require('../new-admin/config') const plugins = require('../plugins') const configManager = require('../new-config-manager') -const customRequestQueries = require('../new-admin/services/customInfoRequests') +const { batchGetCustomInfoRequest, getCustomInfoRequests } = require('../new-admin/services/customInfoRequests') const state = require('../middlewares/state') const VERSION = require('../../package.json').version @@ -56,7 +56,7 @@ const buildTriggers = (allTriggers) => { return _.flow( _.map(_.get('customInfoRequestId')), - customRequestQueries.batchGetCustomInfoRequest + batchGetCustomInfoRequest )(customTriggers) .then(res => { res.forEach((details, index) => { @@ -106,7 +106,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings return Promise.all([ !!configManager.getCompliance(settings.config).enablePaperWalletOnly, - configManager.getTriggersAutomation(customRequestQueries.getCustomInfoRequests(), settings.config), + configManager.getTriggersAutomation(getCustomInfoRequests(true), settings.config), buildTriggers(configManager.getTriggers(settings.config)), configManager.getWalletSettings('BTC', settings.config).layer2 !== 'no-layer2', configManager.getLocale(deviceId, settings.config), diff --git a/lib/new-config-manager.js b/lib/new-config-manager.js index 8951b2a4..58638ebc 100644 --- a/lib/new-config-manager.js +++ b/lib/new-config-manager.js @@ -1,5 +1,4 @@ const _ = require('lodash/fp') -const { getCustomInfoRequests } = require('./new-admin/services/customInfoRequests') const namespaces = { ADVANCED: 'advanced', @@ -21,6 +20,7 @@ const filter = namespace => _.pickBy((value, key) => _.startsWith(`${namespace}_ const strip = key => _.mapKeys(stripl(`${key}_`)) const fromNamespace = _.curry((key, config) => _.compose(strip(key), filter(key))(config)) +const toNamespace = _.curry((ns, config) => _.mapKeys(key => `${ns}_${key}`, config)) const getCommissions = (cryptoCode, deviceId, config) => { const commissions = fromNamespace(namespaces.COMMISSIONS)(config) @@ -116,8 +116,9 @@ const getGlobalNotifications = config => getNotifications(null, null, config) const getTriggers = _.get('triggers') -const getTriggersAutomation = config => { - return getCustomInfoRequests(true) +/* `customInfoRequests` is the result of a call to `getCustomInfoRequests` */ +const getTriggersAutomation = (customInfoRequests, config) => { + return customInfoRequests .then(infoRequests => { const defaultAutomation = _.get('triggersConfig_automation')(config) const requirements = { @@ -154,6 +155,8 @@ const getCryptoUnits = (crypto, config) => { return getWalletSettings(crypto, config).cryptoUnits } +const setTermsConditions = toNamespace(namespaces.TERMS_CONDITIONS) + module.exports = { getWalletSettings, getCashInSettings, @@ -173,5 +176,6 @@ module.exports = { getGlobalCashOut, getCashOut, getCryptosFromWalletNamespace, - getCryptoUnits + getCryptoUnits, + setTermsConditions, } diff --git a/lib/routes/pollingRoutes.js b/lib/routes/pollingRoutes.js index 89e7aab1..e0f4b4c2 100644 --- a/lib/routes/pollingRoutes.js +++ b/lib/routes/pollingRoutes.js @@ -10,7 +10,7 @@ const plugins = require('../plugins') const semver = require('semver') const state = require('../middlewares/state') const version = require('../../package.json').version -const customRequestQueries = require('../new-admin/services/customInfoRequests') +const { batchGetCustomInfoRequest, getCustomInfoRequests } = require('../new-admin/services/customInfoRequests') const urlsToPing = [ `us.archive.ubuntu.com`, @@ -45,7 +45,7 @@ const buildTriggers = (allTriggers) => { return !_.isNil(o.customInfoRequestId) && !_.isEmpty(o.customInfoRequestId) }, allTriggers) - return _.flow([_.map(_.get('customInfoRequestId')), customRequestQueries.batchGetCustomInfoRequest])(customTriggers) + return _.flow([_.map(_.get('customInfoRequestId')), batchGetCustomInfoRequest])(customTriggers) .then(res => { res.forEach((details, index) => { // make sure we aren't attaching the details to the wrong trigger @@ -85,7 +85,7 @@ function poll (req, res, next) { pi.recordPing(deviceTime, machineVersion, machineModel), pi.pollQueries(), buildTriggers(configManager.getTriggers(settings.config)), - configManager.getTriggersAutomation(settings.config) + configManager.getTriggersAutomation(getCustomInfoRequests(true), settings.config), ]) .then(([_pingRes, results, triggers, triggersAutomation]) => { const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid From 7b951f961f22c7cdce4b63180d2f2722ff873d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Thu, 14 Apr 2022 12:01:20 +0100 Subject: [PATCH 2/6] feat: save T&C hash to the `user_config` --- lib/middlewares/populateDeviceId.js | 1 - lib/new-settings-loader.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/middlewares/populateDeviceId.js b/lib/middlewares/populateDeviceId.js index 3bf50761..4c8913ae 100644 --- a/lib/middlewares/populateDeviceId.js +++ b/lib/middlewares/populateDeviceId.js @@ -11,7 +11,6 @@ function sha256 (buf) { } const populateDeviceId = function (req, res, next) { - logger.info(`DEBUG LOG - Method: ${req.method} Path: ${req.path}`) const deviceId = _.isFunction(req.connection.getPeerCertificate) ? sha256(req.connection.getPeerCertificate().raw) : null diff --git a/lib/new-settings-loader.js b/lib/new-settings-loader.js index 2f812c51..9dbba793 100644 --- a/lib/new-settings-loader.js +++ b/lib/new-settings-loader.js @@ -1,8 +1,11 @@ +const crypto = require('crypto') + const _ = require('lodash/fp') const db = require('./db') const migration = require('./config-migration') const { asyncLocalStorage } = require('./async-storage') const { getOperatorId } = require('./operator') +const { getTermsConditions, setTermsConditions } = require('./new-config-manager') const OLD_SETTINGS_LOADER_SCHEMA_VERSION = 1 const NEW_SETTINGS_LOADER_SCHEMA_VERSION = 2 @@ -23,6 +26,30 @@ const SECRET_FIELDS = [ 'twilio.authToken' ] +/* + * JSON.stringify isn't necessarily deterministic so this function may compute + * different hashes for the same object. + */ +const md5hash = obj => + crypto + .createHash('MD5') + .update(JSON.stringify(obj)) + .digest('hex') + +const addTermsHash = configs => { + configs = _.omit('termsConditions_hash', configs) + const terms = getTermsConditions(configs) + return _.isEmpty(terms) ? + configs : + _.flow( + _.omit(['hash']), + md5hash, + hash => _.set('hash', hash, terms), + setTermsConditions, + _.assign(configs), + )(terms) +} + const accountsSql = `update user_config set data = $2, valid = $3, schema_version = $4 where type = $1; insert into user_config (type, data, valid, schema_version) select $1, $2, $3, $4 where $1 not in (select type from user_config)` @@ -74,7 +101,7 @@ const configSql = 'insert into user_config (type, data, valid, schema_version) v function saveConfig (config) { return Promise.all([loadLatestConfigOrNone(), getOperatorId('middleware')]) .then(([currentConfig, operatorId]) => { - const newConfig = _.assign(currentConfig, config) + const newConfig = addTermsHash(_.assign(currentConfig, config)) return db.tx(t => { return t.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION]) .then(() => t.none('NOTIFY $1:name, $2', ['reload', JSON.stringify({ schema: asyncLocalStorage.getStore().get('schema'), operatorId })])) From 469f38b7683d48b95805e0341f44f7839863f702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Tue, 26 Apr 2022 14:12:52 +0100 Subject: [PATCH 3/6] feat: put T&C in its own query --- lib/graphql/resolvers.js | 63 ++++++++++++++++++++++++++++++---------- lib/graphql/types.js | 10 +++++-- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js index 01c2adb5..2e0c68b0 100644 --- a/lib/graphql/resolvers.js +++ b/lib/graphql/resolvers.js @@ -68,18 +68,6 @@ const buildTriggers = (allTriggers) => { }) } -/* - * TODO: From `lib/routes/termsAndConditionsRoutes.js` -- remove this after - * terms are removed from the GraphQL API too. - */ -const massageTerms = terms => (terms.active && terms.text) ? ({ - delay: Boolean(terms.delay), - title: terms.title, - text: nmd(terms.text), - accept: terms.acceptButtonText, - cancel: terms.cancelButtonText, -}) : null - const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings, }) => { const massageCoins = _.map(_.pick([ 'batchable', @@ -112,7 +100,6 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings configManager.getLocale(deviceId, settings.config), configManager.getOperatorInfo(settings.config), configManager.getReceipt(settings.config), - massageTerms(configManager.getTermsConditions(settings.config)), !!configManager.getCashOut(deviceId, settings.config).active, ]) .then(([ @@ -123,7 +110,6 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings localeInfo, operatorInfo, receiptInfo, - terms, twoWayMode, ]) => (currentConfigVersion && currentConfigVersion >= staticConf.configVersion) ? @@ -143,7 +129,6 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings twoWayMode, speedtestFiles, urlsToPing, - terms, }), _.update('triggersAutomation', _.mapValues(_.eq('Automatic'))), addOperatorInfo(operatorInfo), @@ -232,8 +217,54 @@ const configs = (parent, { currentConfigVersion }, { deviceId, deviceName, opera }), })) + +const massageTerms = terms => (terms.active && terms.text) ? ({ + delay: Boolean(terms.delay), + title: terms.title, + text: nmd(terms.text), + accept: terms.acceptButtonText, + cancel: terms.cancelButtonText, +}) : null + +/* + * The type of the result of `configManager.getTermsConditions()` is more or + * less `Maybe (Maybe Hash, Maybe TC)`. Each case has a specific meaning to the + * machine: + * + * Nothing => Nothing + * There are no T&C or they've been removed/disabled. + * + * Just (Nothing, _) => Nothing + * Shouldn't happen! Treated as if there were no T&C. + * + * Just (Just hash, Nothing) => Nothing + * May happen (after `massageTerms`) if T&C are disabled. + * + * Just (Just hash, Just tc) => Just (hash, Just tc) or Just (hash, Nothing) + * `tc` is sent depending on whether the `hash` differs from `currentHash` or + * not. + */ +const terms = (parent, { currentHash }, { settings }, info) => { + const isNone = x => _.isNil(x) || _.isEmpty(x) + + const latestTerms = configManager.getTermsConditions(settings.config) + if (isNone(latestTerms)) return null + + const hash = latestTerms.hash + if (!_.isString(hash)) return null + + if (hash === currentHash) return { hash, details: null } + + const details = massageTerms(latestTerms) + if (isNone(details)) return null + + return { hash, details } +} + + module.exports = { Query: { - configs + configs, + terms, } } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index a9318978..ae4b24d3 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -69,7 +69,7 @@ type Trigger { thresholdDays: Int } -type Terms { +type TermsDetails { delay: Boolean! title: String! text: String! @@ -77,6 +77,11 @@ type Terms { cancel: String! } +type Terms { + hash: String! + details: TermsDetails +} + type StaticConfig { configVersion: Int! @@ -98,8 +103,6 @@ type StaticConfig { triggersAutomation: TriggersAutomation! triggers: [Trigger!]! - - terms: Terms } type DynamicCoinValues { @@ -147,5 +150,6 @@ type Configs { type Query { configs(currentConfigVersion: Int): Configs! + terms(currentHash: String): Terms } ` From 0213ceb7fe91fe3b35d0e6a29aa1f9e5de1b4d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Thu, 14 Apr 2022 16:59:16 +0100 Subject: [PATCH 4/6] feat: add DB migration to add the hash of T&C --- migrations/1649944954805-terms-and-conditions-hash.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 migrations/1649944954805-terms-and-conditions-hash.js diff --git a/migrations/1649944954805-terms-and-conditions-hash.js b/migrations/1649944954805-terms-and-conditions-hash.js new file mode 100644 index 00000000..3b905e22 --- /dev/null +++ b/migrations/1649944954805-terms-and-conditions-hash.js @@ -0,0 +1,11 @@ +const { saveConfig } = require('../lib/new-settings-loader') + +exports.up = function (next) { + return saveConfig({}) + .then(next) + .catch(next) +} + +exports.down = function (next) { + next() +} From c5e7627afb1895163752991289a6551904c07e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Wed, 27 Apr 2022 15:59:21 +0100 Subject: [PATCH 5/6] refactor: separate T&C text from rest of detials --- lib/graphql/resolvers.js | 18 ++++++++++++------ lib/graphql/types.js | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js index 2e0c68b0..ee1538f4 100644 --- a/lib/graphql/resolvers.js +++ b/lib/graphql/resolvers.js @@ -244,21 +244,27 @@ const massageTerms = terms => (terms.active && terms.text) ? ({ * `tc` is sent depending on whether the `hash` differs from `currentHash` or * not. */ -const terms = (parent, { currentHash }, { settings }, info) => { +const terms = (parent, { currentConfigVersion, currentHash }, { deviceId, settings }, info) => { const isNone = x => _.isNil(x) || _.isEmpty(x) - const latestTerms = configManager.getTermsConditions(settings.config) + let latestTerms = configManager.getTermsConditions(settings.config) if (isNone(latestTerms)) return null const hash = latestTerms.hash if (!_.isString(hash)) return null - if (hash === currentHash) return { hash, details: null } + latestTerms = massageTerms(latestTerms) + if (isNone(latestTerms)) return null - const details = massageTerms(latestTerms) - if (isNone(details)) return null + const isHashNew = hash !== currentHash + const text = isHashNew ? latestTerms.text : null - return { hash, details } + return plugins(settings, deviceId) + .fetchCurrentConfigVersion() + .catch(() => null) + .then(configVersion => isHashNew || _.isNil(currentConfigVersion) || currentConfigVersion < configVersion) + .then(isVersionNew => isVersionNew ? _.omit(['text'], latestTerms) : null) + .then(details => ({ hash, details, text })) } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index ae4b24d3..04e2e69f 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -72,13 +72,13 @@ type Trigger { type TermsDetails { delay: Boolean! title: String! - text: String! accept: String! cancel: String! } type Terms { hash: String! + text: String details: TermsDetails } @@ -150,6 +150,6 @@ type Configs { type Query { configs(currentConfigVersion: Int): Configs! - terms(currentHash: String): Terms + terms(currentHash: String, currentConfigVersion: Int): Terms } ` From 028c8c3b1357b998d3f298d3d24aebaeb8c46e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Wed, 27 Apr 2022 16:00:08 +0100 Subject: [PATCH 6/6] fix: compute hash of the T&C text only --- lib/new-settings-loader.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/new-settings-loader.js b/lib/new-settings-loader.js index 9dbba793..a9af0f30 100644 --- a/lib/new-settings-loader.js +++ b/lib/new-settings-loader.js @@ -30,19 +30,18 @@ const SECRET_FIELDS = [ * JSON.stringify isn't necessarily deterministic so this function may compute * different hashes for the same object. */ -const md5hash = obj => +const md5hash = text => crypto .createHash('MD5') - .update(JSON.stringify(obj)) + .update(text) .digest('hex') const addTermsHash = configs => { - configs = _.omit('termsConditions_hash', configs) - const terms = getTermsConditions(configs) + const terms = _.omit(['hash'], getTermsConditions(configs)) return _.isEmpty(terms) ? configs : _.flow( - _.omit(['hash']), + _.get('text'), md5hash, hash => _.set('hash', hash, terms), setTermsConditions,