diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js new file mode 100644 index 00000000..62c24d4b --- /dev/null +++ b/lib/graphql/resolvers.js @@ -0,0 +1,140 @@ +const _ = require('lodash/fp') + +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 state = require('../middlewares/state') + +const urlsToPing = [ + `us.archive.ubuntu.com`, + `uk.archive.ubuntu.com`, + `za.archive.ubuntu.com`, + `cn.archive.ubuntu.com` +] + +const speedtestFiles = [ + { + url: 'https://github.com/lamassu/speed-test-assets/raw/main/python-defaults_2.7.18-3.tar.gz', + size: 44668 + } +] + +const addSmthInfo = (dstField, srcFields) => smth => + smth && smth.active ? _.set(dstField, _.pick(srcFields, smth)) : _.identity + +const addOperatorInfo = addSmthInfo( + 'operatorInfo', + ['name', 'phone', 'email', 'website', 'companyNumber'] +) + +const addReceiptInfo = addSmthInfo( + 'receiptInfo', + [ + 'sms', + 'operatorWebsite', + 'operatorEmail', + 'operatorPhone', + 'companyNumber', + 'machineLocation', + 'customerNameOrPhoneNumber', + 'exchangeRate', + 'addressQRCode', + ] +) + +/* TODO: Simplify this. */ +const buildTriggers = (allTriggers) => { + const normalTriggers = [] + const customTriggers = _.filter(o => { + if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(o) + return !_.isNil(o.customInfoRequestId) && !_.isEmpty(o.customInfoRequestId) + }, allTriggers) + + return _.flow( + _.map(_.get('customInfoRequestId')), + customRequestQueries.batchGetCustomInfoRequest + )(customTriggers) + .then(res => { + res.forEach((details, index) => { + // make sure we aren't attaching the details to the wrong trigger + if (customTriggers[index].customInfoRequestId !== details.id) return + customTriggers[index] = { ...customTriggers[index], customInfoRequest: details } + }) + return [...normalTriggers, ...customTriggers] + }) +} + +const staticConfig = (parent, { currentConfigVersion }, { deviceId, deviceName, settings }, info) => + Promise.all([ + plugins(settings, deviceId).staticConfigQueries(), + !!configManager.getCompliance(settings.config).enablePaperWalletOnly, + configManager.getTriggersAutomation(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), + !!configManager.getCashOut(deviceId, settings.config).active, + ]) + .then(([ + staticConf, + enablePaperWalletOnly, + triggersAutomation, + triggers, + hasLightning, + localeInfo, + operatorInfo, + receiptInfo, + twoWayMode, + ]) => + (currentConfigVersion && currentConfigVersion >= staticConf.configVersion) ? + null : + _.flow( + _.assign({ + enablePaperWalletOnly, + triggersAutomation, + triggers, + hasLightning, + localeInfo: { + country: localeInfo.country, + languages: localeInfo.languages, + fiatCode: localeInfo.fiatCurrency + }, + machineInfo: { deviceId, deviceName }, + twoWayMode, + speedtestFiles, + urlsToPing, + }), + _.update('triggersAutomation', _.mapValues(_.eq('Automatic'))), + addOperatorInfo(operatorInfo), + addReceiptInfo(receiptInfo) + )(staticConf)) + + +const setZeroConfLimit = config => coin => + _.set( + 'zeroConfLimit', + configManager.getWalletSettings(coin.cryptoCode, config).zeroConfLimit, + coin + ) + +const dynamicConfig = (parent, variables, { deviceId, operatorId, pid, settings }, info) => { + state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids) + return plugins(settings, deviceId) + .dynamicConfigQueries() + .then(_.flow( + _.update('coins', _.map(setZeroConfLimit(settings.config))), + _.set('reboot', !!pid && state.reboots?.[operatorId]?.[deviceId] === pid), + _.set('shutdown', !!pid && state.shutdowns?.[operatorId]?.[deviceId] === pid), + _.set('restartServices', !!pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid), + )) +} + + +module.exports = { + Query: { + staticConfig, + dynamicConfig + } +} diff --git a/lib/graphql/server.js b/lib/graphql/server.js new file mode 100644 index 00000000..fda7d85c --- /dev/null +++ b/lib/graphql/server.js @@ -0,0 +1,27 @@ +const logger = require('../logger') + +const https = require('https') +const { ApolloServer } = require('apollo-server-express') + +const devMode = !!require('minimist')(process.argv.slice(2)).dev + +module.exports = new ApolloServer({ + typeDefs: require('./types'), + resolvers: require('./resolvers'), + context: ({ req, res }) => ({ + deviceId: req.deviceId, /* lib/middlewares/populateDeviceId.js */ + deviceName: req.deviceName, /* lib/middlewares/authorize.js */ + operatorId: res.locals.operatorId, /* lib/middlewares/operatorId.js */ + pid: req.query.pid, + settings: req.settings, /* lib/middlewares/populateSettings.js */ + }), + uploads: false, + playground: false, + introspection: false, + formatError: error => { + logger.error(error) + return error + }, + debug: devMode, + logger +}) diff --git a/lib/graphql/types.js b/lib/graphql/types.js new file mode 100644 index 00000000..1c8161ea --- /dev/null +++ b/lib/graphql/types.js @@ -0,0 +1,137 @@ +const { gql } = require('apollo-server-express') +module.exports = gql` +type Coin { + cryptoCode: String! + display: String! + minimumTx: String! + cashInFee: String! + cashInCommission: String! + cashOutCommission: String! + cryptoNetwork: Boolean! + cryptoUnits: String! + batchable: Boolean! +} + +type LocaleInfo { + country: String! + fiatCode: String! + languages: [String!]! +} + +type OperatorInfo { + name: String! + phone: String! + email: String! + website: String! + companyNumber: String! +} + +type MachineInfo { + deviceId: String! + deviceName: String +} + +type ReceiptInfo { + sms: Boolean! + operatorWebsite: Boolean! + operatorEmail: Boolean! + operatorPhone: Boolean! + companyNumber: Boolean! + machineLocation: Boolean! + customerNameOrPhoneNumber: Boolean! + exchangeRate: Boolean! + addressQRCode: Boolean! +} + +type SpeedtestFile { + url: String! + size: Int! +} + +# True if automatic, False otherwise +type TriggersAutomation { + sanctions: Boolean! + idCardPhoto: Boolean! + idCardData: Boolean! + facephoto: Boolean! + usSsn: Boolean! +} + +type Trigger { + id: String! + customInfoRequestId: String! + direction: String! + requirement: String! + triggerType: String! + + suspensionDays: Int + threshold: Int + thresholdDays: Int +} + +type StaticConfig { + configVersion: Int! + + areThereAvailablePromoCodes: Boolean! + coins: [Coin!]! + enablePaperWalletOnly: Boolean! + hasLightning: Boolean! + serverVersion: String! + timezone: Int! + twoWayMode: Boolean! + + localeInfo: LocaleInfo! + operatorInfo: OperatorInfo + machineInfo: MachineInfo! + receiptInfo: ReceiptInfo + + speedtestFiles: [SpeedtestFile!]! + urlsToPing: [String!]! + + triggersAutomation: TriggersAutomation! + triggers: [Trigger!]! +} + +type DynamicCoinValues { + # NOTE: Doesn't seem to be used anywhere outside of lib/plugins.js. + # However, it can be used to generate the cache key, if we ever move to an + # actual caching mechanism. + #timestamp: String! + + cryptoCode: String! + balance: String! + + # Raw rates + ask: String! + bid: String! + + # Rates with commissions applied + cashIn: String! + cashOut: String! + + zeroConfLimit: Int! +} + +type PhysicalCassette { + denomination: Int! + count: Int! +} + +type Cassettes { + physical: [PhysicalCassette!]! + virtual: [Int!]! +} + +type DynamicConfig { + cassettes: Cassettes + coins: [DynamicCoinValues!]! + reboot: Boolean! + shutdown: Boolean! + restartServices: Boolean! +} + +type Query { + staticConfig(currentConfigVersion: Int): StaticConfig + dynamicConfig: DynamicConfig! +} +` diff --git a/lib/plugins.js b/lib/plugins.js index 9030682f..4f3f172c 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -25,6 +25,9 @@ const customers = require('./customers') const commissionMath = require('./commission-math') const loyalty = require('./loyalty') const transactionBatching = require('./tx-batching') +const state = require('./middlewares/state') + +const VERSION = require('../package.json').version const { CASSETTE_MAX_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants') @@ -281,6 +284,78 @@ function plugins (settings, deviceId) { }) } + function staticConfigQueries () { + const massageCoins = _.map(_.pick([ + 'batchable', + 'cashInCommission', + 'cashInFee', + 'cashOutCommission', + 'cryptoCode', + 'cryptoNetwork', + 'cryptoUnits', + 'display', + 'minimumTx' + ])) + + return pollQueries() + .then(_.flow( + _.pick([ + 'areThereAvailablePromoCodes', + 'coins', + 'configVersion', + 'timezone' + ]), + _.update('coins', massageCoins), + _.set('serverVersion', VERSION) + )) + } + + function dynamicConfigQueries () { + const massageCassettes = cassettes => + cassettes ? + _.flow( + cassettes => _.set('physical', _.get('cassettes', cassettes), cassettes), + cassettes => _.set('virtual', _.get('virtualCassettes', cassettes), cassettes), + _.unset('cassettes'), + _.unset('virtualCassettes') + )(cassettes) : + null + + return pollQueries() + .then(_.flow( + _.pick(['balances', 'cassettes', 'coins', 'rates']), + + _.update('cassettes', massageCassettes), + + /* [{ cryptoCode, rates }, ...] => [[cryptoCode, rates], ...] */ + _.update('coins', _.map(({ cryptoCode, rates }) => [cryptoCode, rates])), + + /* [{ cryptoCode: balance }, ...] => [[cryptoCode, { balance }], ...] */ + _.update('balances', _.flow( + _.toPairs, + _.map(([cryptoCode, balance]) => [cryptoCode, { balance }]) + )), + + /* Group the separate objects by cryptoCode */ + /* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */ + ({ balances, cassettes, coins, rates }) => ({ + cassettes, + coins: _.flow( + _.reduce( + (ret, [cryptoCode, obj]) => _.update(cryptoCode, _.assign(obj), ret), + rates + ), + + /* { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } => [[cryptoCode, { balance, ask, bid, cashIn, cashOut }], ...] */ + _.toPairs, + + /* [[cryptoCode, { balance, ask, bid, cashIn, cashOut }], ...] => [{ cryptoCode, balance, ask, bid, cashIn, cashOut }, ...] */ + _.map(([cryptoCode, obj]) => _.set('cryptoCode', cryptoCode, obj)) + )(_.concat(balances, coins)) + }) + )) + } + function sendCoins (tx) { return wallet.supportsBatching(settings, tx.cryptoCode) .then(supportsBatching => { @@ -854,6 +929,8 @@ function plugins (settings, deviceId) { getRawRates, buildRatesNoCommission, pollQueries, + staticConfigQueries, + dynamicConfigQueries, sendCoins, newAddress, isHd, diff --git a/lib/routes.js b/lib/routes.js index 6f727b02..5a608dd9 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -30,6 +30,8 @@ const verifyUserRoutes = require('./routes/verifyUserRoutes') const verifyTxRoutes = require('./routes/verifyTxRoutes') const verifyPromoCodeRoutes = require('./routes/verifyPromoCodeRoutes') +const graphQLServer = require('./graphql/server') + const app = express() const configRequiredRoutes = [ @@ -39,7 +41,8 @@ const configRequiredRoutes = [ '/phone_code', '/customer', '/tx', - '/verify_promo_code' + '/verify_promo_code', + '/graphql' ] const devMode = argv.dev || options.http @@ -79,6 +82,8 @@ app.use('/tx', txRoutes) app.use('/logs', logsRoutes) +graphQLServer.applyMiddleware({ app }) + app.use(errorHandler) app.use((req, res) => { res.status(404).json({ error: 'No such route' })