From bf36377cb1cd2d69f17ac05aff2775ddd0ef0e24 Mon Sep 17 00:00:00 2001 From: Juozas Rimantas Date: Sat, 7 Aug 2021 23:43:33 +0300 Subject: [PATCH 1/8] [feat] Added option to add api keys through settings page --- api/package.json | 2 + api/src/app.ts | 78 ++++----------- api/src/controllers/bitmexController.ts | 17 ---- api/src/routes/bitmex.ts | 8 -- api/src/routes/bitmexRouter.ts | 20 ++++ api/src/routes/settingsRouter.ts | 54 +++++++++++ api/src/server.ts | 26 +++++ client/src/pages/Bitmex.tsx | 59 ++++++++++++ client/src/pages/Home.tsx | 75 +++++++++++++++ client/src/pages/Settings.tsx | 96 +++++++++++++++++++ client/src/pages/paths.ts | 8 ++ client/src/redux/helpers/actionHelpers.ts | 59 +++++++++--- client/src/redux/helpers/hookHelpers.ts | 6 +- .../redux/modules/settings/settingsModule.ts | 80 ++++++++++++++++ client/src/redux/modules/settings/types.ts | 24 +++++ client/src/redux/modules/state.ts | 2 + client/src/tests/mockData/orders.ts | 3 + 17 files changed, 516 insertions(+), 101 deletions(-) delete mode 100644 api/src/controllers/bitmexController.ts delete mode 100644 api/src/routes/bitmex.ts create mode 100644 api/src/routes/bitmexRouter.ts create mode 100644 api/src/routes/settingsRouter.ts create mode 100644 api/src/server.ts create mode 100644 client/src/pages/Bitmex.tsx create mode 100644 client/src/pages/Home.tsx create mode 100644 client/src/pages/Settings.tsx create mode 100644 client/src/pages/paths.ts create mode 100644 client/src/redux/modules/settings/settingsModule.ts create mode 100644 client/src/redux/modules/settings/types.ts diff --git a/api/package.json b/api/package.json index c37528d2..8ddeebb7 100644 --- a/api/package.json +++ b/api/package.json @@ -23,6 +23,7 @@ "crypto": "^1.0.1", "dotenv": "8.0.0", "express": "4.17.1", + "flat-cache": "^3.0.4", "helmet": "3.21.1", "morgan": "^1.10.0", "request": "2.88.0", @@ -33,6 +34,7 @@ "@types/cors": "2.8.6", "@types/dotenv": "6.1.1", "@types/express": "4.17.1", + "@types/flat-cache": "^2.0.0", "@types/helmet": "0.0.44", "@types/morgan": "^1.9.0", "@types/node": "12.11.1", diff --git a/api/src/app.ts b/api/src/app.ts index f84ff1a2..5f73130b 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -1,26 +1,29 @@ import path from 'path'; -import dotenv from 'dotenv'; -dotenv.config({path: path.join(__dirname, '../../client/.env')}); - import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import morgan from 'morgan'; +import flatCache from 'flat-cache'; import {logger} from './util/logger'; -import Router from './routes/bitmex'; +import {SettingsRouter} from './routes/settingsRouter'; +import {BitmexRouter} from './routes/bitmexRouter'; + +export const cache = flatCache.create('apiKeyCache', path.resolve('./cache')); const app: express.Application = express(); const port = process.env.PORT || 3001; -app.set('port', port); -app.use(helmet()); -app.use(cors()); -app.disable('etag').disable('x-powered-by'); - -app.use(express.urlencoded({extended: true})); -app.use(express.json()); -app.use('/bitmex', Router); +app + .set('port', port) + .use(helmet()) + .use(cors()) + .disable('etag') + .disable('x-powered-by') + .use(express.urlencoded({extended: true})) + .use(express.json()) + .use('/bitmex', BitmexRouter()) + .use('/settings', SettingsRouter()); if (process.env.NODE_ENV != 'development') { console.log(`Server is running at http://localhost:${app.get('port')} in ${app.get('env')} mode`); @@ -33,26 +36,9 @@ if (process.env.NODE_ENV != 'development') { }); } -// ============LOGGING============ const morganFormat = process.env.NODE_ENV !== 'production' ? 'dev' : 'combined'; - -app.use( - morgan(morganFormat, { - skip: function (req, res) { - return res.statusCode < 400; - }, - stream: process.stderr, - }), -); - -app.use( - morgan(morganFormat, { - skip: function (req, res) { - return res.statusCode >= 400; - }, - stream: process.stdout, - }), -); +app.use(morgan(morganFormat, {skip: (req, res) => res.statusCode < 400, stream: process.stderr})); +app.use(morgan(morganFormat, {skip: (req, res) => res.statusCode >= 400, stream: process.stdout})); app.get('/', function (req, res) { logger.debug('Debug statement'); @@ -60,7 +46,7 @@ app.get('/', function (req, res) { res.send(req.method + ' ' + req.originalUrl); }); -app.get('/error', function (req, res) { +app.get('/error', (req, res) => { throw new Error('Problem Here!'); }); @@ -78,32 +64,4 @@ app.use((err: any, req: any, res: any, next: any) => { res.json({error: err.message}); }); -// eslint-disable-next-line @typescript-eslint/no-empty-function -const server = app.listen(app.get('port'), () => {}); - -// on kill -process.on('SIGTERM', () => { - logger.log('warn', 'process.on::SIGTERM'); - server.close(function () { - process.exit(0); - }); -}); - -process.on('exit', () => { - logger.log('warn', 'process.on::exit'); - console.log('exit'); - server.close(function () { - process.exit(2); - }); -}); - -// on crash -process.on('uncaughtException', (error) => { - logger.log('error', 'process.on::uncaughtException'); - logger.log('error', `Something terrible happened: ${error}`); - server.close(function () { - process.exit(1); - }); // exit application -}); - export default app; diff --git a/api/src/controllers/bitmexController.ts b/api/src/controllers/bitmexController.ts deleted file mode 100644 index beec4817..00000000 --- a/api/src/controllers/bitmexController.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {Request, Response} from 'express'; -import {fetchBitmexExchange} from '../util/auth'; -import {logger} from '../util/logger'; - -export const fetch = async ({originalUrl, baseUrl, body: {data, method}, query}: Request, res: Response) => { - try { - const path = originalUrl.replace(`${baseUrl}/`, ''); - const body = method === 'GET' ? (query ? {filter: JSON.parse((query.filter as string) || '{}')} : undefined) : data; - - const response = await fetchBitmexExchange(path, method, body); - logger.info(`Successful ${method} request (${originalUrl})`); - - return res.send({data: response, statusCode: res.statusCode}); - } catch (error) { - return res.status(400).send({error: error}); - } -}; diff --git a/api/src/routes/bitmex.ts b/api/src/routes/bitmex.ts deleted file mode 100644 index c4b3bce1..00000000 --- a/api/src/routes/bitmex.ts +++ /dev/null @@ -1,8 +0,0 @@ -import express from 'express'; -import * as bitmexController from '../controllers/bitmexController'; - -const Router = express.Router(); - -Router.post('/*', bitmexController.fetch); - -export default Router; diff --git a/api/src/routes/bitmexRouter.ts b/api/src/routes/bitmexRouter.ts new file mode 100644 index 00000000..54980043 --- /dev/null +++ b/api/src/routes/bitmexRouter.ts @@ -0,0 +1,20 @@ +import {Request, Response, Router} from 'express'; +import {fetchBitmexExchange} from '../util/auth'; +import {logger} from '../util/logger'; + +export function BitmexRouter(): Router { + return Router().post('/*', async ({originalUrl, baseUrl, body: {data, method}, query}: Request, res: Response) => { + try { + const path = originalUrl.replace(`${baseUrl}/`, ''); + const body = + method === 'GET' ? (query ? {filter: JSON.parse((query.filter as string) || '{}')} : undefined) : data; + + const response = await fetchBitmexExchange(path, method, body); + logger.info(`Successful ${method} request (${originalUrl})`); + + return res.send({data: response, statusCode: res.statusCode}); + } catch (error) { + return res.status(400).send({error: error}); + } + }); +} diff --git a/api/src/routes/settingsRouter.ts b/api/src/routes/settingsRouter.ts new file mode 100644 index 00000000..dacf7a11 --- /dev/null +++ b/api/src/routes/settingsRouter.ts @@ -0,0 +1,54 @@ +import {Router} from 'express'; +import {cache} from '../app'; +import {logger} from '../util/logger'; + +export function SettingsRouter(): Router { + return Router() + .post('/apiKey', async function saveApiKey({body}, res) { + try { + cache.setKey(body.exchange, {key: body.key, secret: body.secret}); + cache.save(true); + console.log('GET API KEY', body); + logger.info('Successfully saved api key'); + return res.send({data: {exchange: body.exchange}, statusCode: res.statusCode}); + } catch (error) { + return res.status(400).send({error: error}); + } + }) + .get('/apiKey', async function getApiKey({query}, res) { + try { + const exchange = query.exchange as string; + const data = cache.getKey(exchange); + return res.send({data: {exchange, key: data.key ?? '', secret: data.secret ?? ''}, statusCode: res.statusCode}); + } catch (error) { + return res.status(400).send({error: error}); + } + }) + .get('/apiKeys', async function getAllApiKeys(req, res) { + try { + const data = cache.keys(); + return res.send({data: {exchanges: data}, statusCode: res.statusCode}); + } catch (error) { + return res.status(400).send({error: error}); + } + }) + .delete('/apiKey', async function deleteApiKey({body: {data}}, res) { + try { + cache.removeKey(data.exchange); + cache.save(true); + logger.info('Successfully deleted api key'); + return res.send({data: {exchange: data.exchange}, statusCode: res.statusCode}); + } catch (error) { + return res.status(400).send({error: error}); + } + }) + .delete('/apiKeys', async function deleteAllApiKeys(req, res) { + try { + cache.destroy(); + logger.info('Successfully deleted all api keys'); + return res.send({statusCode: res.statusCode}); + } catch (error) { + return res.status(400).send({error: error}); + } + }); +} diff --git a/api/src/server.ts b/api/src/server.ts new file mode 100644 index 00000000..58130f60 --- /dev/null +++ b/api/src/server.ts @@ -0,0 +1,26 @@ +import path from 'path'; +import dotenv from 'dotenv'; +dotenv.config({path: path.join(__dirname, '../../client/.env')}); + +import {logger} from './util/logger'; +import app from './app'; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const server = app.listen(app.get('port'), () => {}); + +// on kill +process + .on('SIGTERM', () => { + logger.log('warn', 'process.on::SIGTERM'); + server.close(() => void process.exit(0)); + }) + .on('exit', () => { + logger.log('warn', 'process.on::exit'); + console.log('exit'); + server.close(() => void process.exit(2)); + }) + .on('uncaughtException', (error) => { + logger.log('error', 'process.on::uncaughtException'); + logger.log('error', `Something terrible happened: ${error}`); + server.close(() => void process.exit(1)); // exit application + }); diff --git a/client/src/pages/Bitmex.tsx b/client/src/pages/Bitmex.tsx new file mode 100644 index 00000000..4caba7a3 --- /dev/null +++ b/client/src/pages/Bitmex.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import {useDispatch} from 'react-redux'; +import {Box} from '@chakra-ui/react'; +import { + ScaledOrders, + MarketOrderContainer, + TrailingLimitOrder, + TickerPricesContainer, + // CrossOrderContainer, + OpenOrdersContainer, +} from 'containers'; +import {Spinner, ToastContainer} from 'components'; +import {useReduxSelector} from 'redux/helpers/hookHelpers'; +import {wsConnect, wsDisconnect, wsSubscribeTo, wsAuthenticate} from 'redux/modules/websocket/websocketModule'; +import {useApi} from 'general/hooks'; + +import 'scss/root.module.scss'; + +const BitmexExchange = React.memo(() => { + const dispatch = useDispatch(); + const {getBalance} = useApi(); + const {previewLoading, trailLoading, wsLoading, connected} = useReduxSelector( + 'previewLoading', + 'trailLoading', + 'wsLoading', + 'connected', + ); + + React.useEffect(() => { + dispatch(wsConnect()); + + return () => { + dispatch(wsDisconnect()); + }; + }, [dispatch]); + + React.useEffect(() => { + if (connected) { + getBalance(); + dispatch(wsAuthenticate()); + dispatch(wsSubscribeTo('order')); + } + }, [dispatch, getBalance, connected]); + + return ( + + + + + + {/* TODO: disabling for now */} + + + + + ); +}); + +export default BitmexExchange; diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx new file mode 100644 index 00000000..83206da4 --- /dev/null +++ b/client/src/pages/Home.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {Link} from 'react-router-dom'; +import {CheckIcon, WarningIcon} from '@chakra-ui/icons'; +import {Box, Divider, Heading, Text, Tooltip} from '@chakra-ui/react'; +import {ExchangePresenter} from 'presenters/general-presenters'; +import {Exchange} from 'redux/modules/settings/types'; +import {useDispatch, useSelector} from 'react-redux'; +import {AppState} from 'redux/modules/state'; +import {getAllApiKeys} from 'redux/modules/settings/settingsModule'; + +interface ExchangeRowProps { + exchange: Exchange; + isActive: boolean; +} + +function ExchangeRow({exchange, isActive}: ExchangeRowProps) { + return ( + + + + + {ExchangePresenter[exchange]} + + + + + + + {isActive ? ( + + + + ) : ( + + + + )} + + + + ); +} + +const Home = React.memo(() => { + const dispatch = useDispatch(); + const activeApiKeys = useSelector((state: AppState) => state.settings.activeApiKeys); + + React.useEffect(() => { + dispatch(getAllApiKeys()); + }, [dispatch]); + + return ( + + + Available Exchanges + + {Object.entries(activeApiKeys).map(([exchange, isActive]) => ( + + ))} + + + ); +}); + +export default Home; diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx new file mode 100644 index 00000000..16e24ac6 --- /dev/null +++ b/client/src/pages/Settings.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {Badge, Box, Divider, Heading, Text} from '@chakra-ui/react'; +import {Button} from 'components'; +import {AppState} from 'redux/modules/state'; +import {Exchange} from 'redux/modules/settings/types'; +import {useModal} from 'general/hooks'; +import {deleteAllApiKeys, deleteApiKey, getAllApiKeys} from 'redux/modules/settings/settingsModule'; +import {ExchangePresenter} from 'presenters/general-presenters'; + +interface ItemProps { + title: string; + isActive: boolean; + exchange: Exchange; + onClick: (isActive: boolean, exchange: Exchange) => void; +} + +const ApiKeySettingRow = React.memo(({title, isActive, exchange, onClick}: ItemProps) => { + const color = isActive ? '#4caf50' : 'grey'; + return ( + onClick(isActive, exchange)} + > + + + {title} + + Add api keys for authenticated requests + + {isActive ? Active : Empty} + + ); +}); + +export default function Settings() { + const dispatch = useDispatch(); + const {modals} = useModal(); + + const activeApiKeys = useSelector((state: AppState) => state.settings.activeApiKeys); + + React.useEffect(() => { + dispatch(getAllApiKeys()); + }, [dispatch]); + + const confirmDeleteAllApiKeys = React.useCallback(() => { + modals.showGeneralModal({ + title: 'Clear all API Keys', + subtitle: 'This will clear all api keys and remove the folder saving them', + onConfirm: () => dispatch(deleteAllApiKeys()), + }); + }, [dispatch, modals]); + + const configureApiKey = React.useCallback( + (isActive: boolean, exchange: Exchange) => { + isActive + ? modals.showGeneralModal({ + title: `Clear ${ExchangePresenter[exchange]} API Key`, + subtitle: `This will clear api key entry of ${ExchangePresenter[exchange]} exchange`, + onConfirm: () => dispatch(deleteApiKey(exchange)), + }) + : modals.showAddApiKeys({exchange}); + }, + [modals, dispatch], + ); + + return ( + + + Settings + + {Object.entries(activeApiKeys).map(([exchange, isActive]) => ( + + ))} + +