diff --git a/bin/server.js b/bin/server.js index 8482b4a..90382f5 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/config/system-user-dev.json b/config/system-user-dev.json index 76aa4ed..6f72a6e 100644 --- a/config/system-user-dev.json +++ b/config/system-user-dev.json @@ -1,6 +1,6 @@ { - "password": "TRJkYBg4qDNLZfG5PJO6", + "password": "RPWjFwYWyJ9S8ByjRDbi", "email": "system@system.com", - "id": "66e48033071977ff258c24ce", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2ZTQ4MDMzMDcxOTc3ZmYyNThjMjRjZSIsImlhdCI6MTcyNjI1MTA1OX0.RLjnUWy6NziVem0ciFcjfaQz_lplnNfxAZUEceygTf8" + "id": "678459df8ec91cd6c3973b63", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ODQ1OWRmOGVjOTFjZDZjMzk3M2I2MyIsImlhdCI6MTczNjcyNzAwN30.AHLNjmznprA5GzQ_z8GbhrYHOJUIlMzVC64SGRy-Xvs" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7b1bc44..a0aa0c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "datastore-fs": "9.1.7", "glob": "7.1.6", "helia": "4.2.5", - "helia-coord": "1.5.14", + "helia-coord": "1.5.15", "jsonrpc-lite": "2.2.0", "jsonwebtoken": "8.5.1", "jwt-bch-lib": "1.3.0", @@ -12582,9 +12582,9 @@ } }, "node_modules/helia-coord": { - "version": "1.5.14", - "resolved": "http://94.130.170.209:4873/helia-coord/-/helia-coord-1.5.14.tgz", - "integrity": "sha512-HUd6jYla/bM0tAV2bc5NWQFPBUScBTljoxl2SulvYWcH19k8LAwL5cZjbBJG88/nzC6RwtQFRV1zMQxKjBy/WA==", + "version": "1.5.15", + "resolved": "http://94.130.170.209:4873/helia-coord/-/helia-coord-1.5.15.tgz", + "integrity": "sha512-j3Oe0HKRf60iGfM0zigkaTEOE+KArWyI4LArQuVyew+EJ10anIfz6lgl+NBsOd8IlgtbaORmJlf2/Qed76HKkQ==", "license": "MIT", "dependencies": { "@chris.troutner/retry-queue": "1.0.10", diff --git a/package.json b/package.json index 91df363..72960fb 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "datastore-fs": "9.1.7", "glob": "7.1.6", "helia": "4.2.5", - "helia-coord": "1.5.14", + "helia-coord": "1.5.15", "jsonrpc-lite": "2.2.0", "jsonwebtoken": "8.5.1", "jwt-bch-lib": "1.3.0", diff --git a/src/adapters/ipfs/ipfs.js b/src/adapters/ipfs/ipfs.js index 73a9af9..2aa16f5 100644 --- a/src/adapters/ipfs/ipfs.js +++ b/src/adapters/ipfs/ipfs.js @@ -20,7 +20,7 @@ import { yamux } from '@chainsafe/libp2p-yamux' // import { bootstrap } from '@libp2p/bootstrap' // import { identifyService } from 'libp2p/identify' import { identify } from '@libp2p/identify' -// import { circuitRelayServer, circuitRelayTransport } from 'libp2p/circuit-relay' +// import { circuitRelayServer, circuitRelayTransport } from '@libp2p/circuit-relay-v2' import { circuitRelayServer } from '@libp2p/circuit-relay-v2' import { gossipsub } from '@chainsafe/libp2p-gossipsub' import { webSockets } from '@libp2p/websockets' @@ -193,8 +193,8 @@ class IpfsAdapter { listen: [ '/ip4/127.0.0.1/tcp/0', `/ip4/0.0.0.0/tcp/${this.config.ipfsTcpPort}`, - `/ip4/0.0.0.0/tcp/${this.config.ipfsWsPort}/ws`, - '/webrtc' + `/ip4/0.0.0.0/tcp/${this.config.ipfsWsPort}/ws` + // '/webrtc' ] }, transports, diff --git a/src/controllers/index.js b/src/controllers/index.js index f86a27b..9e7a4f9 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -23,6 +23,13 @@ class Controllers { this.useCases = new UseCases(localConfig) 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 cadba5f..a211d8a 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -16,6 +16,7 @@ import IpfsRESTController from './ipfs/index.js' import P2wdbRESTController from './p2wdb/index.js' import PriceRESTController from './price/index.js' import config from '../../../config/index.js' +import UsageRESTController from './usage/index.js' class RESTControllers { constructor (localConfig = {}) { @@ -33,6 +34,9 @@ class RESTControllers { ) } + // Bind 'this' object to all subfunctions. + this.attachRESTControllers = this.attachRESTControllers.bind(this) + // Encapsulate dependencies this.config = config } @@ -76,6 +80,10 @@ class RESTControllers { // Attach the REST API Controllers associated with the /price route const priceRESTController = new PriceRESTController(dependencies) priceRESTController.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 e6d0bc0..1186bfa 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 514314e..bc25ab1 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -7,6 +7,7 @@ // Local libraries import UserUseCases from './user.js' import IpfsUseCases from './ipfs-use-cases.js' +import { UsageUseCases } from './usage-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -26,6 +27,7 @@ class UseCases { // console.log('use-cases/index.js localConfig: ', localConfig) this.user = new UserUseCases(localConfig) this.ipfs = new IpfsUseCases(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 }