feat: add graphql support (#349)

* fix: eslint warnings

* refactor: use ramda + sanctuary instead of lodash

* refactor: use prettier-standard for formatting

* feat: enable security

* feat: add graphql

* chore: remove trailing commas from linter

* docs: new scripts on react and new-admin-server

* feat: handle authentication on graphql

* fix: perf improvement to date picker

* chore: add insecure-dev script to run servers
This commit is contained in:
Rafael Taranto 2019-12-24 14:36:41 +00:00 committed by Josh Harvey
parent 49f434f1d1
commit b8e0c2175b
182 changed files with 8827 additions and 4623 deletions

View file

@ -95,4 +95,15 @@ function getMachineLogs (deviceId, until = new Date().toISOString()) {
}))
}
module.exports = { getUnlimitedMachineLogs, getMachineLogs, update, getLastSeen, clearOldLogs }
function simpleGetMachineLogs (deviceId, until = new Date().toISOString()) {
const sql = `select id, log_level, timestamp, message from logs
where device_id=$1
and timestamp <= $3
order by timestamp desc, serial desc
limit $2`
return db.any(sql, [ deviceId, NUM_RESULTS, until ])
.then(_.map(_.mapKeys(_.camelCase)))
}
module.exports = { getUnlimitedMachineLogs, getMachineLogs, simpleGetMachineLogs, update, getLastSeen, clearOldLogs }

6
lib/new-admin/README.md Normal file
View file

@ -0,0 +1,6 @@
## Running
Differences from main lamassu-admin:
- `bin/new-lamassu-register <username>` to add a user
- `bin/insecure-dev` to run the server

View file

@ -1,103 +1,92 @@
const bodyParser = require('body-parser')
const cors = require('cors')
const fs = require('fs')
const express = require('express')
const http = require('http')
const got = require('got')
const https = require('https')
const helmet = require('helmet')
const cookieParser = require('cookie-parser')
const { ApolloServer, AuthenticationError } = require('apollo-server-express')
const supportLogs = require('../support_logs')
const machineLoader = require('../machine-loader')
const logs = require('../logs')
const transactions = require('./transactions')
const T = require('../time')
const options = require('../options')
const serverLogs = require('./server-logs')
const supervisor = require('./supervisor')
const funding = require('./funding')
const config = require('./config')
const login = require('./login')
const { typeDefs, resolvers } = require('./graphql/schema')
const devMode = require('minimist')(process.argv.slice(2)).dev
const NEVER = new Date(Date.now() + 100 * T.years)
const app = express()
app.use(bodyParser.json())
if (devMode) {
app.use(cors())
const hostname = options.hostname
if (!hostname) {
console.error('Error: no hostname specified.')
process.exit(1)
}
app.get('/api/config', async (req, res, next) => {
const state = config.getConfig(req.params.config)
const data = await config.fetchData()
res.json({ state, data })
next()
const app = express()
app.use(helmet({ noCache: true }))
app.use(cookieParser())
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
playground: false,
introspection: false,
formatError: error => {
console.log(error)
return error
},
context: async ({ req }) => {
const token = req.cookies && req.cookies.token
const success = await login.authenticate(token)
if (!success) throw new AuthenticationError('Authentication failed')
}
})
app.post('/api/config', (req, res, next) => {
config.saveConfig(req.body)
.then(it => res.json(it))
.then(() => dbNotify())
.catch(next)
apolloServer.applyMiddleware({
app,
cors: {
credentials: true,
origin: devMode && 'https://localhost:3000'
}
})
app.get('/api/funding', (req, res) => {
return funding.getFunding()
.then(r => res.json(r))
app.get('/api/register', (req, res, next) => {
const otp = req.query.otp
if (!otp) return next()
return login.register(otp)
.then(r => {
if (r.expired) return res.status(401).send('OTP expired, generate new registration link')
// Maybe user is using old registration key, attempt to authenticate
if (!r.success) return next()
const cookieOpts = {
httpOnly: true,
secure: true,
domain: hostname,
sameSite: true,
expires: NEVER
}
const token = r.token
req.token = token
res.cookie('token', token, cookieOpts)
res.sendStatus(200)
})
})
app.get('/api/machines', (req, res) => {
machineLoader.getMachineNames()
.then(r => res.send({ machines: r }))
})
app.get('/api/logs/:deviceId', (req, res, next) => {
return logs.getMachineLogs(req.params.deviceId)
.then(r => res.send(r))
.catch(next)
})
app.post('/api/support_logs', (req, res, next) => {
return supportLogs.insert(req.query.deviceId)
.then(r => res.send(r))
.catch(next)
})
app.get('/api/version', (req, res, next) => {
res.send(require('../../package.json').version)
})
app.get('/api/uptimes', (req, res, next) => {
return supervisor.getAllProcessInfo()
.then(r => res.send(r))
.catch(next)
})
app.post('/api/server_support_logs', (req, res, next) => {
return serverLogs.insert()
.then(r => res.send(r))
.catch(next)
})
app.get('/api/server_logs', (req, res, next) => {
return serverLogs.getServerLogs()
.then(r => res.send(r))
.catch(next)
})
app.get('/api/txs', (req, res, next) => {
return transactions.batch()
.then(r => res.send(r))
.catch(next)
})
function dbNotify () {
return got.post('http://localhost:3030/dbChange')
.catch(e => console.error('Error: lamassu-server not responding'))
const certOptions = {
key: fs.readFileSync(options.keyPath),
cert: fs.readFileSync(options.certPath)
}
function run () {
const serverPort = 8070
const serverPort = devMode ? 8070 : 443
const serverLog = `lamassu-admin-server listening on port ${serverPort}`
const webServer = http.createServer(app)
const webServer = https.createServer(certOptions, app)
webServer.listen(serverPort, () => console.log(serverLog))
}

View file

@ -1,109 +0,0 @@
const _ = require('lodash/fp')
const devMode = require('minimist')(process.argv.slice(2)).dev
const settingsLoader = require('../new-settings-loader')
const machineLoader = require('../machine-loader')
const currencies = require('../../currencies.json')
const languageRec = require('../../languages.json')
const countries = require('../../countries.json')
function saveConfig (config) {
return settingsLoader.saveConfig(config)
}
function getConfig () {
return settingsLoader.getConfig()
}
function massageCurrencies (currencies) {
const convert = r => ({
code: r['Alphabetic Code'],
display: r['Currency']
})
const top5Codes = ['USD', 'EUR', 'GBP', 'CAD', 'AUD']
const mapped = _.map(convert, currencies)
const codeToRec = code => _.find(_.matchesProperty('code', code), mapped)
const top5 = _.map(codeToRec, top5Codes)
const raw = _.uniqBy(_.get('code'), _.concat(top5, mapped))
return raw.filter(r => r.code[0] !== 'X' && r.display.indexOf('(') === -1)
}
const mapLanguage = lang => {
const arr = lang.split('-')
const code = arr[0]
const country = arr[1]
const langNameArr = languageRec.lang[code]
if (!langNameArr) return null
const langName = langNameArr[0]
if (!country) return {code: lang, display: langName}
return {code: lang, display: `${langName} [${country}]`}
}
const supportedLanguages = languageRec.supported
const languages = supportedLanguages.map(mapLanguage).filter(r => r)
const ALL_CRYPTOS = ['BTC', 'ETH', 'LTC', 'DASH', 'ZEC', 'BCH']
const filterAccounts = (data, isDevMode) => {
const notAllowed = ['mock-ticker', 'mock-wallet', 'mock-exchange', 'mock-sms', 'mock-id-verify', 'mock-zero-conf']
const filterOut = o => _.includes(o.code, notAllowed)
return isDevMode ? data : {...data, accounts: _.filter(a => !filterOut(a), data.accounts)}
}
function fetchData () {
return machineLoader.getMachineNames()
.then(machineList => ({
currencies: massageCurrencies(currencies),
cryptoCurrencies: [
{code: 'BTC', display: 'Bitcoin'},
{code: 'ETH', display: 'Ethereum'},
{code: 'LTC', display: 'Litecoin'},
{code: 'DASH', display: 'Dash'},
{code: 'ZEC', display: 'Zcash'},
{code: 'BCH', display: 'Bitcoin Cash'}
],
languages: languages,
countries,
accounts: [
{code: 'bitpay', display: 'Bitpay', class: 'ticker', cryptos: ['BTC', 'BCH']},
{code: 'kraken', display: 'Kraken', class: 'ticker', cryptos: ['BTC', 'ETH', 'LTC', 'DASH', 'ZEC', 'BCH']},
{code: 'bitstamp', display: 'Bitstamp', class: 'ticker', cryptos: ['BTC', 'ETH', 'LTC', 'BCH']},
{code: 'coinbase', display: 'Coinbase', class: 'ticker', cryptos: ['BTC', 'ETH', 'LTC', 'BCH']},
{code: 'itbit', display: 'itBit', class: 'ticker', cryptos: ['BTC']},
{code: 'mock-ticker', display: 'Mock (Caution!)', class: 'ticker', cryptos: ALL_CRYPTOS},
{code: 'bitcoind', display: 'bitcoind', class: 'wallet', cryptos: ['BTC']},
{code: 'no-layer2', display: 'No Layer 2', class: 'layer2', cryptos: ALL_CRYPTOS},
{code: 'infura', display: 'Infura', class: 'wallet', cryptos: ['ETH']},
{code: 'geth', display: 'geth', class: 'wallet', cryptos: ['ETH']},
{code: 'zcashd', display: 'zcashd', class: 'wallet', cryptos: ['ZEC']},
{code: 'litecoind', display: 'litecoind', class: 'wallet', cryptos: ['LTC']},
{code: 'dashd', display: 'dashd', class: 'wallet', cryptos: ['DASH']},
{code: 'bitcoincashd', display: 'bitcoincashd', class: 'wallet', cryptos: ['BCH']},
{code: 'bitgo', display: 'BitGo', class: 'wallet', cryptos: ['BTC', 'ZEC', 'LTC', 'BCH', 'DASH']},
{code: 'bitstamp', display: 'Bitstamp', class: 'exchange', cryptos: ['BTC', 'ETH', 'LTC', 'BCH']},
{code: 'itbit', display: 'itBit', class: 'exchange', cryptos: ['BTC']},
{code: 'kraken', display: 'Kraken', class: 'exchange', cryptos: ['BTC', 'ETH', 'LTC', 'DASH', 'ZEC', 'BCH']},
{code: 'mock-wallet', display: 'Mock (Caution!)', class: 'wallet', cryptos: ALL_CRYPTOS},
{code: 'no-exchange', display: 'No exchange', class: 'exchange', cryptos: ALL_CRYPTOS},
{code: 'mock-exchange', display: 'Mock exchange', class: 'exchange', cryptos: ALL_CRYPTOS},
{code: 'mock-sms', display: 'Mock SMS', class: 'sms'},
{code: 'mock-id-verify', display: 'Mock ID verifier', class: 'idVerifier'},
{code: 'twilio', display: 'Twilio', class: 'sms'},
{code: 'mailgun', display: 'Mailgun', class: 'email'},
{code: 'all-zero-conf', display: 'Always 0-conf', class: 'zeroConf', cryptos: ['BTC', 'ZEC', 'LTC', 'DASH', 'BCH']},
{code: 'no-zero-conf', display: 'Always 1-conf', class: 'zeroConf', cryptos: ALL_CRYPTOS},
{code: 'blockcypher', display: 'Blockcypher', class: 'zeroConf', cryptos: ['BTC']},
{code: 'mock-zero-conf', display: 'Mock 0-conf', class: 'zeroConf', cryptos: ['BTC', 'ZEC', 'LTC', 'DASH', 'BCH', 'ETH']}
],
machines: machineList.map(machine => ({machine: machine.deviceId, display: machine.name}))
}))
.then((data) => {
return filterAccounts(data, devMode)
})
}
module.exports = {
saveConfig,
getConfig,
fetchData
}

View file

@ -0,0 +1,46 @@
const { COINS, ALL_CRYPTOS } = require('./coins')
const { BTC, BCH, DASH, ETH, LTC, ZEC } = COINS
const TICKER = 'ticker'
const WALLET = 'wallet'
const LAYER_2 = 'layer2'
const EXCHANGE = 'exchange'
const SMS = 'sms'
const ID_VERIFIER = 'idVerifier'
const EMAIL = 'email'
const ZERO_CONF = 'zeroConf'
const ACCOUNT_LIST = [
{ code: 'bitpay', display: 'Bitpay', class: TICKER, cryptos: [BTC, BCH] },
{ code: 'kraken', display: 'Kraken', class: TICKER, cryptos: [BTC, ETH, LTC, DASH, ZEC, BCH] },
{ code: 'bitstamp', display: 'Bitstamp', class: TICKER, cryptos: [BTC, ETH, LTC, BCH] },
{ code: 'coinbase', display: 'Coinbase', class: TICKER, cryptos: [BTC, ETH, LTC, BCH] },
{ code: 'itbit', display: 'itBit', class: TICKER, cryptos: [BTC] },
{ code: 'mock-ticker', display: 'Mock (Caution!)', class: TICKER, cryptos: ALL_CRYPTOS },
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH] },
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH] },
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },
{ code: 'bitcoincashd', display: 'bitcoincashd', class: WALLET, cryptos: [BCH] },
{ code: 'bitgo', display: 'BitGo', class: WALLET, cryptos: [BTC, ZEC, LTC, BCH, DASH] },
{ code: 'bitstamp', display: 'Bitstamp', class: EXCHANGE, cryptos: [BTC, ETH, LTC, BCH] },
{ code: 'itbit', display: 'itBit', class: EXCHANGE, cryptos: [BTC] },
{ code: 'kraken', display: 'Kraken', class: EXCHANGE, cryptos: [BTC, ETH, LTC, DASH, ZEC, BCH] },
{ code: 'mock-wallet', display: 'Mock (Caution!)', class: WALLET, cryptos: ALL_CRYPTOS },
{ code: 'no-exchange', display: 'No exchange', class: EXCHANGE, cryptos: ALL_CRYPTOS },
{ code: 'mock-exchange', display: 'Mock exchange', class: EXCHANGE, cryptos: ALL_CRYPTOS },
{ code: 'mock-sms', display: 'Mock SMS', class: SMS },
{ code: 'mock-id-verify', display: 'Mock ID verifier', class: ID_VERIFIER },
{ code: 'twilio', display: 'Twilio', class: SMS },
{ code: 'mailgun', display: 'Mailgun', class: EMAIL },
{ code: 'all-zero-conf', display: 'Always 0-conf', class: ZERO_CONF, cryptos: [BTC, ZEC, LTC, DASH, BCH] },
{ code: 'no-zero-conf', display: 'Always 1-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS },
{ code: 'blockcypher', display: 'Blockcypher', class: ZERO_CONF, cryptos: [BTC] },
{ code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: [BTC, ZEC, LTC, DASH, BCH, ETH] }
]
module.exports = { ACCOUNT_LIST }

View file

@ -0,0 +1,23 @@
const _ = require('lodash/fp')
const COINS = {
BTC: 'BTC',
ETH: 'ETH',
LTC: 'LTC',
DASH: 'DASH',
ZEC: 'ZEC',
BCH: 'BCH'
}
const COIN_LIST = [
{ code: COINS.BTC, display: 'Bitcoin' },
{ code: COINS.ETH, display: 'Ethereum' },
{ code: COINS.LTC, display: 'Litecoin' },
{ code: COINS.DASH, display: 'Dash' },
{ code: COINS.ZEC, display: 'Zcash' },
{ code: COINS.BCH, display: 'Bitcoin Cash' }
]
const ALL_CRYPTOS = _.keys(COINS)
module.exports = { COINS, ALL_CRYPTOS, COIN_LIST }

View file

@ -0,0 +1,39 @@
const _ = require('lodash/fp')
const { COIN_LIST: coins } = require('./coins')
const { ACCOUNT_LIST: accounts } = require('./accounts')
const countries = require('../../../countries.json')
const currenciesRec = require('../../../currencies.json')
const languageRec = require('../../../languages.json')
function massageCurrencies (currencies) {
const convert = r => ({
code: r['Alphabetic Code'],
display: r['Currency']
})
const top5Codes = ['USD', 'EUR', 'GBP', 'CAD', 'AUD']
const mapped = _.map(convert, currencies)
const codeToRec = code => _.find(_.matchesProperty('code', code), mapped)
const top5 = _.map(codeToRec, top5Codes)
const raw = _.uniqBy(_.get('code'), _.concat(top5, mapped))
return raw.filter(r => r.code[0] !== 'X' && r.display.indexOf('(') === -1)
}
const mapLanguage = lang => {
const arr = lang.split('-')
const code = arr[0]
const country = arr[1]
const langNameArr = languageRec.lang[code]
if (!langNameArr) return null
const langName = langNameArr[0]
if (!country) return { code: lang, display: langName }
return { code: lang, display: `${langName} [${country}]` }
}
const supportedLanguages = languageRec.supported
const languages = supportedLanguages.map(mapLanguage).filter(r => r)
const currencies = massageCurrencies(currenciesRec)
module.exports = { coins, accounts, countries, currencies, languages }

View file

@ -0,0 +1,23 @@
const bodyParser = require('body-parser')
const express = require('express')
const { ApolloServer } = require('apollo-server-express')
const { typeDefs, resolvers } = require('./graphql/schema')
const app = express()
const server = new ApolloServer({
typeDefs,
resolvers
})
server.applyMiddleware({ app })
app.use(bodyParser.json())
function run () {
const serverLog = `lamassu-admin-server listening on port ${8080}${server.graphqlPath}`
app.listen(8080, () => console.log(serverLog))
}
module.exports = { run }

View file

@ -0,0 +1,197 @@
const { gql } = require('apollo-server-express')
const { GraphQLDateTime } = require('graphql-iso-date')
const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const got = require('got')
const machineLoader = require('../../machine-loader')
const logs = require('../../logs')
const supportLogs = require('../../support_logs')
const settingsLoader = require('../../new-settings-loader')
const serverVersion = require('../../../package.json').version
const transactions = require('../transactions')
const funding = require('../funding')
const supervisor = require('../supervisor')
const serverLogs = require('../server-logs')
const { accounts, coins, countries, currencies, languages } = require('../config')
// TODO why does server logs messages can be null?
const typeDefs = gql`
scalar JSON
scalar JSONObject
scalar Date
type Currency {
code: String!
display: String!
}
type CryptoCurrency {
code: String!
display: String!
}
type Country {
code: String!
display: String!
}
type Language {
code: String!
display: String!
}
type Machine {
name: String!
deviceId: ID!
paired: Boolean!
cashbox: Int
cassette1: Int
cassette2: Int
}
type Account {
code: String!
display: String!
class: String!
cryptos: [String]
}
type MachineLog {
id: ID!
logLevel: String!
timestamp: Date!
message: String!
}
type ServerLog {
id: ID!
logLevel: String!
timestamp: Date!
message: String
}
type CoinFunds {
cryptoCode: String!
fundingAddress: String!
fundingAddressUrl: String!
confirmedBalance: String!
pending: String!
fiatConfirmedBalance: String!
fiatPending: String!
fiatCode: String!
display: String!
unitScale: String!
}
type ProcessStatus {
name: String!
state: String!
uptime: Date!
}
type Transaction {
id: ID!
txClass: String!
deviceId: ID!
toAddress: String
cryptoAtoms: String!
cryptoCode: String!
fiat: String!
fiatCode: String!
fee: String
txHash: String
phone: String
error: String
created: Date
send: Boolean
sendConfirmed: Boolean
timedout: Boolean
sendTime: Date
errorCode: String
operatorCompleted: Boolean
sendPending: Boolean
cashInFee: String
cashInFeeCrypto: String
minimumTx: Float
customerId: ID
txVersion: Int!
termsAccepted: Boolean
commissionPercentage: String
rawTickerPrice: String
isPaperWallet: Boolean
customerPhone: String
customerIdCardDataNumber: String
customerIdCardDataExpiration: Date
customerIdCardData: JSONObject
customerName: String
customerFrontCameraPath: String
customerIdCardPhotoPath: String
expired: Boolean
machineName: String
}
type Query {
countries: [Country]
currencies: [Currency]
languages: [Language]
accounts: [Account]
cryptoCurrencies: [CryptoCurrency]
machines: [Machine]
machineLogs(deviceId: ID!): [MachineLog]
funding: [CoinFunds]
serverVersion: String!
uptime: [ProcessStatus]
serverLogs: [ServerLog]
transactions: [Transaction]
config: JSONObject
}
type SupportLogsResponse {
id: ID!
timestamp: Date!
deviceId: ID
}
type Mutation {
machineSupportLogs(deviceId: ID!): SupportLogsResponse
serverSupportLogs: SupportLogsResponse
saveConfig(config: JSONObject): JSONObject
}
`
const notify = () => got.post('http://localhost:3030/dbChange')
.catch(e => console.error('Error: lamassu-server not responding'))
const resolvers = {
JSON: GraphQLJSON,
JSONObject: GraphQLJSONObject,
Date: GraphQLDateTime,
Query: {
countries: () => countries,
currencies: () => currencies,
languages: () => languages,
accounts: () => accounts,
cryptoCurrencies: () => coins,
machines: () => machineLoader.getMachineNames(),
funding: () => funding.getFunding(),
machineLogs: (...[, { deviceId }]) => logs.simpleGetMachineLogs(deviceId),
serverVersion: () => serverVersion,
uptime: () => supervisor.getAllProcessInfo(),
serverLogs: () => serverLogs.getServerLogs(),
transactions: () => transactions.batch(),
config: () => settingsLoader.getConfig()
},
Mutation: {
machineSupportLogs: (...[, { deviceId }]) => supportLogs.insert(deviceId),
serverSupportLogs: () => serverLogs.insert(),
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)
.then(it => {
notify()
return it
})
}
}
module.exports = { resolvers, typeDefs }

48
lib/new-admin/login.js Normal file
View file

@ -0,0 +1,48 @@
const crypto = require('crypto')
const db = require('../db')
function generateOTP (name) {
const otp = crypto.randomBytes(32).toString('hex')
const sql = 'insert into one_time_passes (token, name) values ($1, $2)'
return db.none(sql, [otp, name])
.then(() => otp)
}
function validateOTP (otp) {
const sql = `delete from one_time_passes
where token=$1
returning name, created < now() - interval '1 hour' as expired`
return db.one(sql, [otp])
.then(r => ({ success: !r.expired, expired: r.expired, name: r.name }))
.catch(() => ({ success: false, expired: false }))
}
function register (otp) {
return validateOTP(otp)
.then(r => {
if (!r.success) return r
const token = crypto.randomBytes(32).toString('hex')
const sql = 'insert into user_tokens (token, name) values ($1, $2)'
return db.none(sql, [token, r.name])
.then(() => ({ success: true, token: token }))
})
.catch(() => ({ success: false, expired: false }))
}
function authenticate (token) {
const sql = 'select token from user_tokens where token=$1'
return db.one(sql, [token]).then(() => true).catch(() => false)
}
module.exports = {
generateOTP,
register,
authenticate
}

View file

@ -10,10 +10,8 @@ function getServerLogs (until = new Date().toISOString()) {
order by timestamp desc
limit $1`
return Promise.all([db.any(sql, [ NUM_RESULTS ])])
.then(([logs]) => ({
logs: _.map(_.mapKeys(_.camelCase), logs)
}))
return db.any(sql, [ NUM_RESULTS ])
.then(_.map(_.mapKeys(_.camelCase)))
}
function insert () {

View file

@ -18,7 +18,7 @@ function saveConfig (config) {
.then(() => newState)
}
function getConfig (config) {
function getConfig () {
return db.getState()
}