Skip to content

Commit

Permalink
Add settings (enabled, addHeaders, count), Add feature to allow overr…
Browse files Browse the repository at this point in the history
…id route settings
  • Loading branch information
yonjah committed Aug 15, 2017
1 parent 71c5823 commit 25dc160
Show file tree
Hide file tree
Showing 4 changed files with 505 additions and 58 deletions.
85 changes: 84 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ server.register({
event: 'onPostAuth',
type: 'users',
limitd: limitdClient,
extractKey: function(request, reply, done){
extractKey: function(request, reply, done) {
var key = request.auth.credentials.userId;
done(null, key);
}
Expand All @@ -44,11 +44,94 @@ The object has the following schema (validated [here](./lib/index.js) using [Joi
* `done: (err: Error, key: String)` - A function that takes an error as the first parameter and the bucket key as the second parameter.

**Optional**
* `enabled: Boolean default(true)` - if `true` rate limiting will be enabled you can set it to `false` on route settings to disable rate limiting
* `addHeaders: Boolean default(true)` - if `true` rate limiting headers will be sent with response
* `count: Number default(1)` - how many tokens request should cost useful when you want one route to be more expensive than another but use the same bucket
* `onError: (error, reply) => ()` - A function that takes the `error` that occurred when trying to get a token from the bucket and the `reply` interface.
* `error: Error` - The error that occurred.
* `reply: Reply` - The hapi.js [reply interface](http://hapijs.com/api#reply-interface).
> If an error occurs and no function is provided, the request lifecycle continues normally as if there was no token bucket restriction. This is a useful default behavior in case the limitd server goes down.
Options can be overridden in route settings.
if plugin is disabled by default setting patova setting for route will also enable the plugin for the route.

**Advance Usage**
```javascript
const Hapi = require('hapi');
const patova = require('patova');

const server = new Hapi.Server();
server.connection({ /* options */ });

server.register({register: patova, options: {
event: 'onPostAuth',
type: 'users',
limitd: limitdClient,
extractKey: function(request, reply, done) {
done(null, request.auth.credentials.userId);
},
enabled: false //rate limiting will not be enabled for all routes
},
}, err => {
server.route({
method: 'GET',
path:'/resource', //no rate limiting for get
handler () { /*handler code */}
});

server.route({
method: 'POST',
path:'/resource',
config: {
plugins: {
patova: { //enable rate limiting for this route with default settings
enabled: true
}
}
},
handler () { /*handler code */}
});

server.route({
method: 'POST',
path:'/expensive_resource',
config: {
plugins: {
patova: { //enable rate limiting and make this route cost more than default one
count: 5
}
}
},
handler () { /*handler code */}
});

server.route({
method: 'POST',
path:'/login',

config: {
auth: {
mode: 'try'
},
plugins: {
patova: {
type: 'login', //logins are limited on a unique bucket
extractKey: function(request, reply, done) { //we also don't have a user yet so limit access by IP
done(null, request.info.address);
},
addHeaders: false //lets hide rate limiting information from client for logins
}
}
},
handler () { /*handler code */}
});


});
```



## Contributing
Feel free to open issues with questions/bugs/features. PRs are also welcome.

Expand Down
107 changes: 68 additions & 39 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const schema = Joi.object().keys({
type: [Joi.string(), Joi.func()],
limitd: Joi.object(),
onError: Joi.func(),
extractKey: Joi.func()
extractKey: Joi.func(),
enabled: Joi.boolean(),
addHeaders: Joi.boolean(),
count: Joi.number()
}).requiredKeys('type', 'event', 'limitd', 'extractKey');

function setResponseHeader(request, header, value) {
Expand All @@ -22,57 +25,41 @@ function setResponseHeader(request, header, value) {
}
}

function setupPreResponseExt(server, options) {
server.ext('onPreResponse', (request, reply) => {
const requestLimit = request.plugins.patova && request.plugins.patova.limit;

if (requestLimit && requestLimit.conformant){
const headers = new RateLimitHeaders(
requestLimit.limit,
requestLimit.remaining,
requestLimit.reset);

Object.keys(headers).forEach(
key => setResponseHeader(request, key, headers[key]));
}
reply.continue();
});
}

function getMinimumLimit(limit1, limit2) {
if (!limit1) { return limit2; }
if (!limit2) { return limit1; }

if (limit1 && limit2.remaining > limit1.remaining) {
if (limit2.remaining > limit1.remaining) {
return limit1;
}

return limit2;
}

function setupRateLimitEventExt(server, options) {
const event = options.event;
const extractKey = options.extractKey;
const onError = options.onError;
function setupRateLimitEventExt(server, pluginOptions) {
const event = pluginOptions.event;
const onError = pluginOptions.onError;

const extractKeyAndTakeToken = function(limitd, request, reply, type) {
extractKey(request, reply, (err, key) =>{
const extractKeyAndTakeToken = function(options, request, reply, type) {
options.extractKey(request, reply, (err, key) =>{
const limitd = options.limitd;
const count = options.count;
if (err) { return reply(err); }

if (!limitd) {
// limitd is not connected, do not fail!
return reply.continue();
}

limitd.take(type, key, (err, currentLimitResponse) => {
if (err){
limitd.take(type, key, count, (err, currentLimitResponse) => {
if (err) {
if (onError) { return onError(err, reply); }
// by default we don't fail if limitd is unavailable
return reply.continue();
}

const oldMinimumLimitResponse = request.plugins.patova && request.plugins.patova.limit
const newMinimumLimitResponse = getMinimumLimit(currentLimitResponse, oldMinimumLimitResponse)
const oldMinimumLimitResponse = request.plugins.patova && request.plugins.patova.limit;
const newMinimumLimitResponse = getMinimumLimit(currentLimitResponse, oldMinimumLimitResponse);

request.plugins.patova = request.plugins.patova || {};
request.plugins.patova.limit = newMinimumLimitResponse;
Expand All @@ -83,17 +70,19 @@ function setupRateLimitEventExt(server, options) {
}

const error = Boom.tooManyRequests();
error.output.headers = new RateLimitHeaders(
newMinimumLimitResponse.limit,
newMinimumLimitResponse.remaining,
newMinimumLimitResponse.reset);
if (options.addHeaders) {
error.output.headers = new RateLimitHeaders(
newMinimumLimitResponse.limit,
newMinimumLimitResponse.remaining,
newMinimumLimitResponse.reset);
}

reply(error);
});
});
};

const getType = function(request, reply, callback) {
const getType = function(options, request, reply, callback) {
const type = options.type;

if (typeof type !== 'function') {
Expand All @@ -114,18 +103,58 @@ function setupRateLimitEventExt(server, options) {
};

server.ext(event, (request, reply) => {
const options = parseRouteOptions(request);
if (!options.enabled) {
return reply.continue();
}
// This handler is going to be called one time per registration of patova
getType(request, reply, (err, type) => {
extractKeyAndTakeToken(options.limitd, request, reply, type);
getType(options, request, reply, (err, type) => {
extractKeyAndTakeToken(options, request, reply, type);
});
});

server.ext('onPreResponse', (request, reply) => {
const requestLimit = request.plugins.patova && request.plugins.patova.limit;

if (requestLimit && requestLimit.conformant) {
const options = parseRouteOptions(request);
if (options.addHeaders) {
const headers = new RateLimitHeaders(
requestLimit.limit,
requestLimit.remaining,
requestLimit.reset);

Object.keys(headers).forEach(
key => setResponseHeader(request, key, headers[key]));
}
}
reply.continue();
});

function parseRouteOptions(request) {
const routeOptions = request.route.settings.plugins.patova;
if (routeOptions === undefined) {
return pluginOptions;
}
if (routeOptions._merged) { //already merged with previous pluginOptions;
return routeOptions;
}
request.route.settings.plugins.patova = Object.assign({_merged: true}, pluginOptions, {enabled: true}, routeOptions);
return request.route.settings.plugins.patova;
}

}

exports.register = function (server, options, next) {
Joi.validate(options, schema, { abortEarly: false }, (err, processedOptions) => {
const defaults = {
enabled: true,
addHeaders: true,
count: 1
};

exports.register = function(server, pluginOptions, next) {
Joi.validate(Object.assign({}, defaults, pluginOptions), schema, { abortEarly: false }, (err, processedOptions) => {
if (err) { return next(err); }
setupRateLimitEventExt(server, processedOptions);
setupPreResponseExt(server, processedOptions);
next();
});
};
Expand Down
Loading

0 comments on commit 25dc160

Please sign in to comment.