diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js index eb628fe7..ee1538f4 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) => { @@ -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', @@ -106,13 +94,12 @@ 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), 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,60 @@ 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, { currentConfigVersion, currentHash }, { deviceId, settings }, info) => { + const isNone = x => _.isNil(x) || _.isEmpty(x) + + let latestTerms = configManager.getTermsConditions(settings.config) + if (isNone(latestTerms)) return null + + const hash = latestTerms.hash + if (!_.isString(hash)) return null + + latestTerms = massageTerms(latestTerms) + if (isNone(latestTerms)) return null + + const isHashNew = hash !== currentHash + const text = isHashNew ? latestTerms.text : null + + 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 })) +} + + module.exports = { Query: { - configs + configs, + terms, } } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index a9318978..04e2e69f 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -69,14 +69,19 @@ type Trigger { thresholdDays: Int } -type Terms { +type TermsDetails { delay: Boolean! title: String! - text: String! accept: String! cancel: String! } +type Terms { + hash: String! + text: 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, currentConfigVersion: Int): Terms } ` 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-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/new-settings-loader.js b/lib/new-settings-loader.js index 2f812c51..a9af0f30 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,29 @@ const SECRET_FIELDS = [ 'twilio.authToken' ] +/* + * JSON.stringify isn't necessarily deterministic so this function may compute + * different hashes for the same object. + */ +const md5hash = text => + crypto + .createHash('MD5') + .update(text) + .digest('hex') + +const addTermsHash = configs => { + const terms = _.omit(['hash'], getTermsConditions(configs)) + return _.isEmpty(terms) ? + configs : + _.flow( + _.get('text'), + 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 +100,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 })])) 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 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() +}