@universis/janitor
Version:
Universis api plugin for handling user authorization and rate limiting
232 lines (222 loc) • 9.58 kB
JavaScript
import { ApplicationService, TraceUtils } from '@themost/common';
import { rateLimit } from 'express-rate-limit';
import express from 'express';
import path from 'path';
import { BehaviorSubject } from 'rxjs';
export class RateLimitService extends ApplicationService {
/**
* @param {import('@themost/express').ExpressDataApplication} app
*/
constructor(app) {
super(app);
// get proxy address forwarding option
const proxyAddressForwarding = app.getConfiguration().getSourceAt('settings/universis/api/proxyAddressForwarding');
this.proxyAddressForwarding = typeof proxyAddressForwarding !== 'boolean'? false : proxyAddressForwarding;
/**
* @type {BehaviorSubject<{ target: RateLimitService }>}
*/
this.loaded = new BehaviorSubject(null);
const serviceContainer = this.getServiceContainer();
if (serviceContainer == null) {
TraceUtils.warn(`${this.getServiceName()} is being started but the parent router seems to be unavailable.`);
return;
}
serviceContainer.subscribe((router) => {
if (router == null) {
return;
}
try {
// set router for further processing
Object.defineProperty(this, 'router', {
value: express.Router(),
writable: false,
enumerable: false,
configurable: true
});
const serviceConfiguration = this.getServiceConfiguration();
// create maps
const paths = serviceConfiguration.paths;
if (paths.size === 0) {
TraceUtils.warn(`${this.getServiceName()} is being started but the collection of paths is empty.`);
}
paths.forEach((value, path) => {
this.set(path, value);
});
if (router.stack) {
router.stack.unshift.apply(router.stack, this.router.stack);
} else {
// use router
router.use(this.router);
// get router stack (use a workaround for express 4.x)
const stack = router._router && router._router.stack;
if (Array.isArray(stack)) {
// stage #1 find logger middleware (for supporting request logging)
let index = stack.findIndex((item) => {
return item.name === 'logger';
});
if (index === -1) {
// stage #2 find expressInit middleware
index = stack.findIndex((item) => {
return item.name === 'expressInit';
});
}
// if found, move the last middleware to be after expressInit
if (index > -1) {
// move the last middleware to be after expressInit
stack.splice(index + 1, 0, stack.pop());
}
} else {
TraceUtils.warn(`${this.getServiceName()} is being started but the container stack is not available.`);
}
}
// notify that the service is loaded
this.loaded.next({ target: this });
} catch (err) {
TraceUtils.error('An error occurred while validating rate limit configuration.');
TraceUtils.error(err);
TraceUtils.warn('Rate limit service is inactive due to an error occured while loading configuration.')
}
});
}
/**
* Returns the service router that is used to register rate limit middleware.
* @returns {import('rxjs').BehaviorSubject<import('express').Router | import('express').Application>} The service router.
*/
getServiceContainer() {
return this.getApplication() && this.getApplication().serviceRouter;
}
/**
* Returns the service name.
* @returns {string} The service name.
*/
getServiceName() {
return '@universis/janitor#RateLimitService';
}
/**
* Returns the service configuration.
* @returns {{extends?: string, profiles?: Array, paths?: Array}} The service configuration.
*/
getServiceConfiguration() {
if (this.serviceConfiguration) {
return this.serviceConfiguration;
}
let serviceConfiguration = {
profiles: [],
paths: []
};
// get service configuration
const serviceConfigurationSource = this.getApplication().getConfiguration().getSourceAt('settings/universis/janitor/rateLimit');
if (serviceConfigurationSource) {
if (typeof serviceConfigurationSource.extends === 'string') {
// get additional configuration
const configurationPath = this.getApplication().getConfiguration().getConfigurationPath();
const extendsPath = path.resolve(configurationPath, serviceConfigurationSource.extends);
TraceUtils.log(`${this.getServiceName()} will try to extend service configuration using ${extendsPath}`);
serviceConfiguration = Object.assign({}, {
profiles: [],
paths: []
}, require(extendsPath));
} else {
TraceUtils.log(`${this.getServiceName()} will use service configuration from settings/universis/janitor/rateLimit`);
serviceConfiguration = Object.assign({}, {
profiles: [],
paths: []
}, serviceConfigurationSource);
}
}
const pathsArray = serviceConfiguration.paths || [];
const profilesArray = serviceConfiguration.profiles || [];
// create maps
serviceConfiguration.paths = new Map(pathsArray);
serviceConfiguration.profiles = new Map(profilesArray);
// set service configuration
Object.defineProperty(this, 'serviceConfiguration', {
value: serviceConfiguration,
writable: false,
enumerable: false,
configurable: true
});
return this.serviceConfiguration;
}
/**
* Sets the rate limit configuration for a specific path.
* @param {string} path
* @param {{ profile: string } | import('express-rate-limit').Options} options
* @returns {RateLimitService} The service instance for chaining.
*/
set(path, options) {
let opts;
// get profile
if (options.profile) {
opts = this.serviceConfiguration.profiles.get(options.profile);
} else {
// or options defined inline
opts = options
}
/**
* @type { import('express-rate-limit').Options }
*/
const rateLimitOptions = Object.assign({
windowMs: 5 * 60 * 1000, // 5 minutes
limit: 50, // 50 requests
legacyHeaders: true // send headers
}, opts, {
keyGenerator: (req) => {
let remoteAddress;
if (this.proxyAddressForwarding) {
// get proxy headers or remote address
remoteAddress = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || (req.connection ? req.connection.remoteAddress : req.socket.remoteAddress);
} else {
// get remote address
remoteAddress = (req.connection ? req.connection.remoteAddress : req.socket.remoteAddress);
}
return `${path}:${remoteAddress}`;
}
});
if (typeof rateLimitOptions.store === 'undefined') {
const StoreClass = this.getStoreType();
if (typeof StoreClass === 'function') {
rateLimitOptions.store = new StoreClass(this, rateLimitOptions);
}
}
this.router.use(path, rateLimit(rateLimitOptions));
return this;
}
/**
* @returns {function} The type of store used for rate limiting.
*/
getStoreType() {
const serviceConfiguration = this.getServiceConfiguration();
if (typeof serviceConfiguration.storeType !== 'string') {
return;
}
let StoreClass;
const store = serviceConfiguration.storeType.split('#');
if (store.length === 2) {
const storeModule = require(store[0]);
if (Object.prototype.hasOwnProperty.call(storeModule, store[1])) {
StoreClass = storeModule[store[1]];
return StoreClass
} else {
throw new Error(`${store} cannot be found or is inaccessible`);
}
} else {
StoreClass = require(store[0]);
return StoreClass;
}
}
/**
* Unsets the rate limit configuration for a specific path.
* @param {string} path
* @returns {RateLimitService} The service instance for chaining.
*/
unset(path) {
const index =this.router.stack.findIndex((layer) => {
return layer.route && layer.route.path === path;
});
if (index !== -1) {
this.router.stack.splice(index, 1);
}
return this;
}
}