diff --git a/bin/server.js b/bin/server.js index defd5e9..8fb2d65 100644 --- a/bin/server.js +++ b/bin/server.js @@ -25,6 +25,7 @@ import config from '../config/index.js' // this first. import AdminLib from '../src/adapters/admin.js' import errorMiddleware from '../src/controllers/rest-api/middleware/error.js' +import { usageMiddleware } from '../src/use-cases/usage-use-cases.js' import wlogger from '../src/adapters/wlogger.js' import Controllers from '../src/controllers/index.js' import { applyPassportMods } from '../config/passport.js' @@ -67,6 +68,7 @@ class Server { app.use(bodyParser()) app.use(session()) app.use(errorMiddleware()) + app.use(usageMiddleware()) // Used to generate the docs. app.use(mount('/', serve(`${process.cwd()}/docs`))) diff --git a/src/controllers/index.js b/src/controllers/index.js index 117de46..12f91ac 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -21,6 +21,13 @@ class Controllers { this.useCases = new UseCases({ adapters: this.adapters }) this.timerControllers = new TimerControllers({ adapters: this.adapters, useCases: this.useCases }) this.config = config + + // Bind 'this' object to all subfunction + this.initAdapters = this.initAdapters.bind(this) + this.initUseCases = this.initUseCases.bind(this) + this.attachRESTControllers = this.attachRESTControllers.bind(this) + this.attachControllers = this.attachControllers.bind(this) + this.attachRPCControllers = this.attachRPCControllers.bind(this) } // Spin up any adapter libraries that have async startup needs. diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 64b2f35..74d5175 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -13,6 +13,7 @@ import ContactRESTController from './contact/index.js' import LogsRESTController from './logs/index.js' import IpfsRESTController from './ipfs/index.js' import config from '../../../config/index.js' +import UsageRESTController from './usage/index.js' class RESTControllers { constructor (localConfig = {}) { @@ -30,6 +31,9 @@ class RESTControllers { ) } + // Bind 'this' object to all subfunctions. + this.attachRESTControllers = this.attachRESTControllers.bind(this) + // Encapsulate dependencies this.config = config } @@ -61,6 +65,10 @@ class RESTControllers { // Attach the REST API Controllers associated with the /ipfs route const ipfsRESTController = new IpfsRESTController(dependencies) ipfsRESTController.attach(app) + + // Attach the REST API Controllers associated with the /usage route + const usageRESTController = new UsageRESTController(dependencies) + usageRESTController.attach(app) } } diff --git a/src/controllers/rest-api/usage/controller.js b/src/controllers/rest-api/usage/controller.js new file mode 100644 index 0000000..d78db1e --- /dev/null +++ b/src/controllers/rest-api/usage/controller.js @@ -0,0 +1,121 @@ +/* + REST API Controller library for the /ipfs route +*/ + +// Global npm libraries + +// Local libraries +import wlogger from '../../../adapters/wlogger.js' + +class UsageRESTControllerLib { + constructor (localConfig = {}) { + // Dependency Injection. + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating /ipfs REST Controller.' + ) + } + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating /ipfs REST Controller.' + ) + } + + // Encapsulate dependencies + // this.UserModel = this.adapters.localdb.Users + // this.userUseCases = this.useCases.user + + // Bind 'this' object to all subfunctions + this.getStatus = this.getStatus.bind(this) + this.getTopIps = this.getTopIps.bind(this) + this.getTopEndpoints = this.getTopEndpoints.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /usage Get status on IPFS infrastructure + * @apiPermission public + * @apiName GetUsageStatus + * @apiGroup REST Usage + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X GET localhost:5020/usage + * + */ + getStatus (ctx) { + try { + // const status = await this.adapters.ipfs.getStatus() + const status = this.useCases.usage.getRestSummary() + + ctx.body = { status } + } catch (err) { + wlogger.error('Error in usage/controller.js/getStatus(): ') + // ctx.throw(422, err.message) + this.handleError(ctx, err) + } + } + + /** + * @api {get} /usage/ips Get top IP addresses consuming the REST API + * @apiPermission public + * @apiName GetUsageIPs + * @apiGroup REST Usage + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X GET localhost:5020/usage/ips + * + */ + getTopIps (ctx) { + try { + const ips = this.useCases.usage.getTopIps() + + ctx.body = { ips } + } catch (err) { + wlogger.error('Error in usage/controller.js/getTopIps(): ') + // ctx.throw(422, err.message) + this.handleError(ctx, err) + } + } + + /** + * @api {get} /usage/endpoints Get top endpoints consumed from the REST API + * @apiPermission public + * @apiName GetUsageEndpoints + * @apiGroup REST Usage + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X GET localhost:5020/usage/endpoints + * + */ + getTopEndpoints (ctx) { + try { + const endpoints = this.useCases.usage.getTopEndpoints() + + ctx.body = { endpoints } + } catch (err) { + wlogger.error('Error in usage/controller.js/getTopEndpoints(): ') + // ctx.throw(422, err.message) + this.handleError(ctx, err) + } + } + + // DRY error handler + handleError (ctx, err) { + // If an HTTP status is specified by the buisiness logic, use that. + if (err.status) { + if (err.message) { + ctx.throw(err.status, err.message) + } else { + ctx.throw(err.status) + } + } else { + // By default use a 422 error if the HTTP status is not specified. + ctx.throw(422, err.message) + } + } +} + +// module.exports = IpfsRESTControllerLib +export default UsageRESTControllerLib diff --git a/src/controllers/rest-api/usage/index.js b/src/controllers/rest-api/usage/index.js new file mode 100644 index 0000000..f0b1004 --- /dev/null +++ b/src/controllers/rest-api/usage/index.js @@ -0,0 +1,65 @@ +/* + REST API library for the /usage route. +*/ + +// Public npm libraries. +import Router from 'koa-router' + +// Local libraries. +import UsageRESTControllerLib from './controller.js' +import Validators from '../middleware/validators.js' + +// let _this + +class UsageRouter { + constructor (localConfig = {}) { + // Dependency Injection. + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating IPFS REST Controller.' + ) + } + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating IPFS REST Controller.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + // Encapsulate dependencies. + this.usageRESTController = new UsageRESTControllerLib(dependencies) + this.validators = new Validators() + + // Instantiate the router and set the base route. + const baseUrl = '/usage' + this.router = new Router({ prefix: baseUrl }) + + // _this = this + } + + attach (app) { + if (!app) { + throw new Error( + 'Must pass app object when attaching REST API controllers.' + ) + } + + // Define the routes and attach the controller. + this.router.get('/', this.usageRESTController.getStatus) + this.router.get('/ips', this.usageRESTController.getTopIps) + this.router.get('/endpoints', this.usageRESTController.getTopEndpoints) + + // Attach the Controller routes to the Koa app. + app.use(this.router.routes()) + app.use(this.router.allowedMethods()) + } +} + +// module.exports = BchRouter +export default UsageRouter diff --git a/src/controllers/timer-controllers.js b/src/controllers/timer-controllers.js index daa0c64..10224b9 100644 --- a/src/controllers/timer-controllers.js +++ b/src/controllers/timer-controllers.js @@ -28,6 +28,7 @@ class TimerControllers { // Bind 'this' object to all subfunctions. this.exampleTimerFunc = this.exampleTimerFunc.bind(this) + this.cleanUsage = this.cleanUsage.bind(this) // this.startTimers() } @@ -36,13 +37,15 @@ class TimerControllers { startTimers () { // Any new timer control functions can be added here. They will be started // when the server starts. - this.optimizeWalletHandle = setInterval(this.exampleTimerFunc, 60000 * 10) + this.optimizeWalletHandle = setInterval(this.exampleTimerFunc, 60000 * 60) + this.cleanUsageHandle = setInterval(this.cleanUsage, 60000 * 60) // 1 hour return true } stopTimers () { clearInterval(this.optimizeWalletHandle) + clearInterval(this.cleanusageHandle) } // Replace this example function with your own timer handler. @@ -60,6 +63,18 @@ class TimerControllers { return false } } + + // Clean the usage state so that stats reflect the last 24 hours. + cleanUsage () { + try { + this.useCases.usage.cleanUsage() + } catch (err) { + console.error('Error in time-controller.js/cleanUsage(): ', err) + + // Note: Do not throw an error. This is a top-level function. + return false + } + } } export default TimerControllers diff --git a/src/use-cases/index.js b/src/use-cases/index.js index a2ffac6..8ce82b3 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -4,7 +4,9 @@ https://troutsblog.com/blog/clean-architecture */ +// Local libraries import UserUseCases from './user.js' +import { UsageUseCases } from './usage-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -17,6 +19,7 @@ class UseCases { // console.log('use-cases/index.js localConfig: ', localConfig) this.user = new UserUseCases(localConfig) + this.usage = new UsageUseCases(localConfig) } // Run any startup Use Cases at the start of the app. diff --git a/src/use-cases/usage-use-cases.js b/src/use-cases/usage-use-cases.js new file mode 100644 index 0000000..356485a --- /dev/null +++ b/src/use-cases/usage-use-cases.js @@ -0,0 +1,135 @@ +/* + Use Case library for tracking usage. This library contains business logic + for tracking the usage of REST API and JSON RPC calls. This library is used + by admins to keep an eye on how many API calls were made in a 24-hour and + 1-hour time period. +*/ + +// This global variable is used to share data between the REST middleware and +// the Usage Use Case class instance. +let restCalls = [] + +class UsageUseCases { + constructor (localConfig = {}) { + // console.log('User localConfig: ', localConfig) + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of adapters must be passed in when instantiating Usage Use Cases library.' + ) + } + + // Bind 'this' object to all subfunctions + this.getRestSummary = this.getRestSummary.bind(this) + this.getTopIps = this.getTopIps.bind(this) + this.getTopEndpoints = this.getTopEndpoints.bind(this) + + // State + } + + // Clean up the state by removing entries that are older than 24 hours. This + // ensures stats reflect only the last 24 hours. + // This function is called by a Timer Controller. + cleanUsage () { + try { + const now = new Date() + const twentyFourHoursAgo = now.getTime() - (60000 * 60 * 24) + + restCalls = restCalls.filter(x => x.timestamp > twentyFourHoursAgo) + } catch (err) { + console.error('Error in usage-use-cases.js/cleanUsage()') + throw err + } + } + + // Track the calls to a REST API + getRestSummary (inObj = {}) { + try { + console.log(`getRestSummary(): There have been ${restCalls.length} REST calls`) + + return restCalls.length + } catch (err) { + console.error('Error in usage-use-cases.js/getRestSummary()') + throw err + } + } + + // Get the top 20 IP addresses from the stats. + getTopIps () { + try { + const ips = restCalls.map(x => x.ip) + + // Create a Map to count occurrences of each IP address string + const countMap = new Map() + ips.forEach(ip => { + countMap.set(ip, (countMap.get(ip) || 0) + 1) + }) + + // Convert the Map into an array of objects with `str` and `cnt` properties + const result = Array.from(countMap, ([ip, cnt]) => ({ ip, cnt })) + + // Sort the results by the `cnt` property in descending order + result.sort((a, b) => b.cnt - a.cnt) + + // Ensure the result has at most 20 elements + return result.slice(0, 20) + } catch (err) { + console.error('Error in usage-use-cases.js/getTopIps()') + throw err + } + } + + // Get the top 20 most consumed endpoints. + getTopEndpoints () { + try { + const endpoints = restCalls.map(x => `${x.method} ${x.url}`) + + // Create a Map to count occurrences of each IP address string + const countMap = new Map() + endpoints.forEach(endpoint => { + countMap.set(endpoint, (countMap.get(endpoint) || 0) + 1) + }) + + // Convert the Map into an array of objects with `str` and `cnt` properties + const result = Array.from(countMap, ([endpoint, cnt]) => ({ endpoint, cnt })) + + // Sort the results by the `cnt` property in descending order + result.sort((a, b) => b.cnt - a.cnt) + + // Ensure the result has at most 20 elements + return result.slice(0, 20) + } catch (err) { + console.error('Error in usage-use-cases.js/getTopEndpoints()') + throw err + } + } +} + +// This Koa middleware is called any time there is a REST API. It logs the +// details from the request object. +function usageMiddleware () { + return async (ctx, next) => { + try { + await next() + + console.log('ctx.request: ', ctx.request) + const now = new Date() + + const reqObj = { + ip: ctx.request.ip, + url: ctx.request.url, + method: ctx.request.method, + timestamp: now.getTime() + } + console.log('reqObj: ', reqObj) + + restCalls.push(reqObj) + } catch (err) { + ctx.status = err.status || 500 + ctx.body = err.message + ctx.app.emit('error', err, ctx) + } + } +}; + +export { UsageUseCases, usageMiddleware }