@takaro/http 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/app.d.ts +21 -0
- package/dist/app.js +87 -0
- package/dist/app.js.map +1 -0
- package/dist/controllers/meta.d.ts +15 -0
- package/dist/controllers/meta.js +186 -0
- package/dist/controllers/meta.js.map +1 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +7 -0
- package/dist/main.js.map +1 -0
- package/dist/middleware/adminAuth.d.ts +2 -0
- package/dist/middleware/adminAuth.js +27 -0
- package/dist/middleware/adminAuth.js.map +1 -0
- package/dist/middleware/errorHandler.d.ts +2 -0
- package/dist/middleware/errorHandler.js +51 -0
- package/dist/middleware/errorHandler.js.map +1 -0
- package/dist/middleware/logger.d.ts +5 -0
- package/dist/middleware/logger.js +51 -0
- package/dist/middleware/logger.js.map +1 -0
- package/dist/middleware/metrics.d.ts +2 -0
- package/dist/middleware/metrics.js +38 -0
- package/dist/middleware/metrics.js.map +1 -0
- package/dist/middleware/paginationMiddleware.d.ts +2 -0
- package/dist/middleware/paginationMiddleware.js +26 -0
- package/dist/middleware/paginationMiddleware.js.map +1 -0
- package/dist/middleware/rateLimit.d.ts +8 -0
- package/dist/middleware/rateLimit.js +65 -0
- package/dist/middleware/rateLimit.js.map +1 -0
- package/dist/util/apiResponse.d.ts +36 -0
- package/dist/util/apiResponse.js +100 -0
- package/dist/util/apiResponse.js.map +1 -0
- package/package.json +37 -0
- package/src/app.ts +104 -0
- package/src/controllers/__tests__/meta.integration.test.ts +29 -0
- package/src/controllers/defaultMetadatastorage.d.ts +5 -0
- package/src/controllers/meta.ts +170 -0
- package/src/main.ts +7 -0
- package/src/middleware/__tests__/adminAuth.unit.test.ts +65 -0
- package/src/middleware/__tests__/paginationMiddleware.unit.test.ts +48 -0
- package/src/middleware/__tests__/rateLimit.integration.test.ts +125 -0
- package/src/middleware/adminAuth.ts +33 -0
- package/src/middleware/errorHandler.ts +67 -0
- package/src/middleware/logger.ts +62 -0
- package/src/middleware/metrics.ts +44 -0
- package/src/middleware/paginationMiddleware.ts +29 -0
- package/src/middleware/rateLimit.ts +78 -0
- package/src/util/apiResponse.ts +106 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +8 -0
- package/typedoc.json +3 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { sandbox, expect } from '@takaro/test';
|
|
2
|
+
import { NextFunction, Request, Response } from 'express';
|
|
3
|
+
import { errors } from '@takaro/util';
|
|
4
|
+
import { paginationMiddleware } from '../paginationMiddleware.js';
|
|
5
|
+
|
|
6
|
+
async function runPagination(page?: number, limit?: number) {
|
|
7
|
+
const req = { query: { page, limit } } as unknown as Request;
|
|
8
|
+
const res = { locals: {} } as Response;
|
|
9
|
+
const next = sandbox.stub<errors.ValidationError[]>();
|
|
10
|
+
await paginationMiddleware(req, res, next as unknown as NextFunction);
|
|
11
|
+
return { req, res, next };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('pagination middleware', () => {
|
|
15
|
+
it('Handles setting defaults', async () => {
|
|
16
|
+
const { res, next } = await runPagination();
|
|
17
|
+
expect(res.locals.page).to.equal(0);
|
|
18
|
+
expect(res.locals.limit).to.equal(100);
|
|
19
|
+
expect(next).to.have.been.calledOnce;
|
|
20
|
+
});
|
|
21
|
+
it('Works when pagination is passed', async () => {
|
|
22
|
+
const { res, next } = await runPagination(2, 20);
|
|
23
|
+
expect(res.locals.page).to.equal(2);
|
|
24
|
+
expect(res.locals.limit).to.equal(20);
|
|
25
|
+
expect(next).to.have.been.calledOnce;
|
|
26
|
+
});
|
|
27
|
+
it('Handles negative limit', async () => {
|
|
28
|
+
const { next } = await runPagination(1, -1);
|
|
29
|
+
expect(next).to.have.been.calledOnce;
|
|
30
|
+
const callArg = next.getCalls()[0].args[0];
|
|
31
|
+
expect(callArg).to.be.an.instanceOf(Error);
|
|
32
|
+
expect(callArg.message).to.equal('Invalid pagination: limit must be greater than or equal to 1');
|
|
33
|
+
});
|
|
34
|
+
it('Handles negative page', async () => {
|
|
35
|
+
const { next } = await runPagination(-1, 5);
|
|
36
|
+
expect(next).to.have.been.calledOnce;
|
|
37
|
+
const callArg = next.getCalls()[0].args[0];
|
|
38
|
+
expect(callArg).to.be.an.instanceOf(Error);
|
|
39
|
+
expect(callArg.message).to.equal('Invalid pagination: page must be greater than or equal to 0');
|
|
40
|
+
});
|
|
41
|
+
it('Handles limit too high', async () => {
|
|
42
|
+
const { next } = await runPagination(1, 1001);
|
|
43
|
+
expect(next).to.have.been.calledOnce;
|
|
44
|
+
const callArg = next.getCalls()[0].args[0];
|
|
45
|
+
expect(callArg).to.be.an.instanceOf(Error);
|
|
46
|
+
expect(callArg.message).to.equal('Invalid pagination: limit must be less than or equal to 1000');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Response, NextFunction, Request } from 'express';
|
|
2
|
+
import { Redis } from '@takaro/db';
|
|
3
|
+
import { expect } from '@takaro/test';
|
|
4
|
+
import { Controller, UseBefore, Get } from 'routing-controllers';
|
|
5
|
+
import { HTTP } from '../../main.js';
|
|
6
|
+
import { createRateLimitMiddleware } from '../rateLimit.js';
|
|
7
|
+
import { ctx } from '@takaro/util';
|
|
8
|
+
import supertest from 'supertest';
|
|
9
|
+
|
|
10
|
+
describe('rateLimit middleware', () => {
|
|
11
|
+
let http: HTTP;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
@Controller()
|
|
14
|
+
class TestController {
|
|
15
|
+
@Get('/low-limit')
|
|
16
|
+
@UseBefore(
|
|
17
|
+
await createRateLimitMiddleware({
|
|
18
|
+
max: 5,
|
|
19
|
+
windowSeconds: 5,
|
|
20
|
+
useInMemory: false,
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
getLow() {
|
|
24
|
+
return 'Hello World';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Get('/high-limit')
|
|
28
|
+
@UseBefore(
|
|
29
|
+
await createRateLimitMiddleware({
|
|
30
|
+
max: 15,
|
|
31
|
+
windowSeconds: 5,
|
|
32
|
+
useInMemory: false,
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
getHigh() {
|
|
36
|
+
return 'Hello World';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Get('/authenticated')
|
|
40
|
+
@UseBefore((req: Request, _res: Response, next: NextFunction) => {
|
|
41
|
+
ctx.addData({ user: req.query.user as string });
|
|
42
|
+
next();
|
|
43
|
+
}, await createRateLimitMiddleware({ max: 5, windowSeconds: 5, useInMemory: false }))
|
|
44
|
+
getAuthenticated() {
|
|
45
|
+
return 'Hello World';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
http = new HTTP(
|
|
50
|
+
{
|
|
51
|
+
controllers: [TestController],
|
|
52
|
+
},
|
|
53
|
+
{ port: undefined }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
await http.start();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await http.stop();
|
|
61
|
+
|
|
62
|
+
const redis = await Redis.getClient('http:rateLimit');
|
|
63
|
+
await redis.flushAll();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
after(async () => {
|
|
67
|
+
await Redis.destroy();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should limit requests', async () => {
|
|
71
|
+
const agent = supertest(http.expressInstance);
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
73
|
+
// @ts-ignore
|
|
74
|
+
agent.get('/low-limit').set('X-Forwarded-For', '127.0.0.2');
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < 4; i++) {
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
78
|
+
// @ts-ignore
|
|
79
|
+
await agent.get('/low-limit').expect(200);
|
|
80
|
+
}
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
await agent.get('/low-limit').expect(429);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should apply distinct limits per user', async () => {
|
|
87
|
+
const agent = supertest(http.expressInstance);
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < 4; i++) {
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
91
|
+
// @ts-ignore
|
|
92
|
+
await agent.get('/authenticated?user=1').expect(200);
|
|
93
|
+
}
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
95
|
+
// @ts-ignore
|
|
96
|
+
await agent.get('/authenticated?user=1').expect(429);
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < 4; i++) {
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
100
|
+
// @ts-ignore
|
|
101
|
+
await agent.get('/authenticated?user=2').expect(200);
|
|
102
|
+
}
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
104
|
+
// @ts-ignore
|
|
105
|
+
await agent.get('/authenticated?user=2').expect(429);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('Should accurately report metadata info via HTTP headers', async () => {
|
|
109
|
+
const agent = supertest(http.expressInstance);
|
|
110
|
+
|
|
111
|
+
for (let i = 1; i < 5; i++) {
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
113
|
+
// @ts-ignore
|
|
114
|
+
const res = await agent.get('/low-limit').expect(200);
|
|
115
|
+
expect(res.header['x-ratelimit-remaining']).to.equal((5 - i).toString());
|
|
116
|
+
expect(res.header['x-ratelimit-limit']).to.equal('5');
|
|
117
|
+
expect(res.header['x-ratelimit-reset']).to.be.a('string');
|
|
118
|
+
}
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
120
|
+
// @ts-ignore
|
|
121
|
+
const res = await agent.get('/low-limit').expect(429);
|
|
122
|
+
expect(res.header['x-ratelimit-remaining']).to.equal('0');
|
|
123
|
+
expect(res.header['x-ratelimit-limit']).to.equal('5');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { errors, logger } from '@takaro/util';
|
|
3
|
+
import { ory, AUDIENCES } from '@takaro/auth';
|
|
4
|
+
|
|
5
|
+
const log = logger('http:middleware:adminAuth');
|
|
6
|
+
|
|
7
|
+
export async function adminAuthMiddleware(request: Request, response: Response, next: NextFunction) {
|
|
8
|
+
try {
|
|
9
|
+
const rawToken = request.headers['authorization']?.replace('Bearer ', '');
|
|
10
|
+
|
|
11
|
+
if (!rawToken) {
|
|
12
|
+
log.warn('No token provided');
|
|
13
|
+
return next(new errors.UnauthorizedError());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const token = await ory.introspectToken(rawToken);
|
|
17
|
+
|
|
18
|
+
if (!token.active) {
|
|
19
|
+
log.warn('Token is not active');
|
|
20
|
+
return next(new errors.ForbiddenError());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!token.aud.includes(AUDIENCES.TAKARO_API_ADMIN)) {
|
|
24
|
+
log.warn('Token is not for admin API', { token });
|
|
25
|
+
return next(new errors.ForbiddenError());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return next();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
log.error('Unexpected error', { error });
|
|
31
|
+
next(new errors.ForbiddenError());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { HttpError } from 'routing-controllers';
|
|
3
|
+
import { logger, errors } from '@takaro/util';
|
|
4
|
+
import { apiResponse } from '../util/apiResponse.js';
|
|
5
|
+
import { ValidationError } from 'class-validator';
|
|
6
|
+
|
|
7
|
+
const log = logger('errorHandler');
|
|
8
|
+
|
|
9
|
+
export async function ErrorHandler(
|
|
10
|
+
originalError: Error,
|
|
11
|
+
req: Request,
|
|
12
|
+
res: Response,
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
14
|
+
_next: NextFunction
|
|
15
|
+
) {
|
|
16
|
+
let status = 500;
|
|
17
|
+
let parsedError = new errors.InternalServerError();
|
|
18
|
+
|
|
19
|
+
if (originalError.name === 'BadRequestError') {
|
|
20
|
+
if (originalError.hasOwnProperty('errors')) {
|
|
21
|
+
// @ts-expect-error Error typing is weird in ts... but we validate during runtime so should be OK
|
|
22
|
+
const validationErrors = originalError['errors'] as ValidationError[];
|
|
23
|
+
parsedError = new errors.ValidationError('Validation error', validationErrors);
|
|
24
|
+
log.warn('⚠️ Validation errror', { details: validationErrors.map((e) => JSON.stringify(e.target, null, 2)) });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (originalError instanceof HttpError) {
|
|
29
|
+
status = originalError.httpCode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (originalError.name === 'UniqueViolationError') {
|
|
33
|
+
status = 409;
|
|
34
|
+
parsedError = new errors.ConflictError('Unique constraint violation');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (originalError.name === 'NotNullViolationError') {
|
|
38
|
+
status = 400;
|
|
39
|
+
parsedError = new errors.BadRequestError('Missing required field');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (originalError instanceof errors.TakaroError) {
|
|
43
|
+
status = originalError.http;
|
|
44
|
+
parsedError = originalError;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If error is a JSON.parse error
|
|
48
|
+
if (originalError instanceof SyntaxError) {
|
|
49
|
+
if (
|
|
50
|
+
originalError.message.includes('Unexpected token') ||
|
|
51
|
+
originalError.message.includes('Unexpected end of JSON input')
|
|
52
|
+
) {
|
|
53
|
+
status = 400;
|
|
54
|
+
parsedError = new errors.BadRequestError('Invalid JSON');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
log.error(originalError);
|
|
59
|
+
if (status >= 500) {
|
|
60
|
+
log.error(`🔴 FAIL ${req.method} ${req.originalUrl}`, parsedError);
|
|
61
|
+
} else {
|
|
62
|
+
log.warn(`⚠️ FAIL ${req.method} ${req.originalUrl}`, parsedError);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
res.status(status).json(apiResponse({}, { error: parsedError, req, res }));
|
|
66
|
+
return res.end();
|
|
67
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { logger, ctx } from '@takaro/util';
|
|
3
|
+
|
|
4
|
+
const SUPPRESS_BODY_KEYWORDS = ['password', 'newPassword'];
|
|
5
|
+
const HIDDEN_ROUTES = ['/metrics', '/health', '/healthz', '/ready', '/readyz', '/queues/api/queues'];
|
|
6
|
+
import { context, trace } from '@opentelemetry/api';
|
|
7
|
+
const log = logger('http');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* This middleware is called very early in the request lifecycle, so it's
|
|
11
|
+
* we leverage this fact to inject the context tracking at this stage
|
|
12
|
+
*/
|
|
13
|
+
export const LoggingMiddleware = ctx.wrap('HTTP', loggingMiddleware);
|
|
14
|
+
|
|
15
|
+
async function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
16
|
+
if (HIDDEN_ROUTES.some((route) => req.originalUrl.startsWith(route))) {
|
|
17
|
+
return next();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const requestStartMs = Date.now();
|
|
21
|
+
|
|
22
|
+
const hideData = SUPPRESS_BODY_KEYWORDS.some((keyword) => (JSON.stringify(req.body) || '').includes(keyword));
|
|
23
|
+
|
|
24
|
+
log.debug(`⬇️ ${req.method} ${req.originalUrl}`, {
|
|
25
|
+
ip: req.ip,
|
|
26
|
+
method: req.method,
|
|
27
|
+
path: req.originalUrl,
|
|
28
|
+
body: hideData ? { suppressed_output: true } : req.body,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const span = trace.getSpan(context.active());
|
|
32
|
+
|
|
33
|
+
if (span) {
|
|
34
|
+
// get the trace ID from the span and set it in the headers
|
|
35
|
+
const traceId = span.spanContext().traceId;
|
|
36
|
+
res.header('X-Trace-Id', traceId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Log on API Call Finish to add responseTime
|
|
40
|
+
res.once('finish', () => {
|
|
41
|
+
const responseTime = Date.now() - requestStartMs;
|
|
42
|
+
|
|
43
|
+
log.info(`⬆️ ${req.method} ${req.originalUrl}`, {
|
|
44
|
+
responseTime,
|
|
45
|
+
requestMethod: req.method,
|
|
46
|
+
requestUrl: req.originalUrl,
|
|
47
|
+
requestSize: req.headers['content-length'],
|
|
48
|
+
status: res.statusCode,
|
|
49
|
+
responseSize: res.getHeader('Content-Length'),
|
|
50
|
+
userAgent: req.get('User-Agent'),
|
|
51
|
+
remoteIp: req.ip,
|
|
52
|
+
serverIp: '127.0.0.1',
|
|
53
|
+
referer: req.get('Referer'),
|
|
54
|
+
cacheLookup: false,
|
|
55
|
+
cacheHit: false,
|
|
56
|
+
cacheValidatedWithOriginServer: false,
|
|
57
|
+
protocol: req.protocol,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
next();
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { Counter, Histogram } from 'prom-client';
|
|
3
|
+
|
|
4
|
+
const counter = new Counter({
|
|
5
|
+
name: 'http_requests_total',
|
|
6
|
+
help: 'Total number of HTTP requests made',
|
|
7
|
+
labelNames: ['path', 'method', 'status'],
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const histogram = new Histogram({
|
|
11
|
+
name: 'http_request_duration_seconds',
|
|
12
|
+
help: 'Duration of HTTP requests in seconds',
|
|
13
|
+
labelNames: ['path', 'method', 'status'],
|
|
14
|
+
buckets: [0.1, 0.5, 1, 2, 5, 10],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export async function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
18
|
+
const rawPath = req.path;
|
|
19
|
+
const method = req.method;
|
|
20
|
+
// Filter out anything that looks like a UUID from path
|
|
21
|
+
const path = rawPath.replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g, '/:id');
|
|
22
|
+
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await next();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw error;
|
|
29
|
+
} finally {
|
|
30
|
+
counter.inc({
|
|
31
|
+
path,
|
|
32
|
+
method,
|
|
33
|
+
status: res.statusCode.toString(),
|
|
34
|
+
});
|
|
35
|
+
histogram.observe(
|
|
36
|
+
{
|
|
37
|
+
path,
|
|
38
|
+
method,
|
|
39
|
+
status: res.statusCode.toString(),
|
|
40
|
+
},
|
|
41
|
+
(Date.now() - start) / 1000
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import * as yup from 'yup';
|
|
3
|
+
import { errors, logger } from '@takaro/util';
|
|
4
|
+
|
|
5
|
+
const log = logger('http:pagination');
|
|
6
|
+
|
|
7
|
+
const paginationSchema = yup.object({
|
|
8
|
+
page: yup.number().default(0).min(0),
|
|
9
|
+
limit: yup.number().default(100).min(1).max(1000),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export async function paginationMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
13
|
+
const merged = { ...req.query, ...req.body };
|
|
14
|
+
try {
|
|
15
|
+
const result = await paginationSchema.validate(merged);
|
|
16
|
+
|
|
17
|
+
res.locals.page = result.page;
|
|
18
|
+
res.locals.limit = result.limit;
|
|
19
|
+
|
|
20
|
+
next();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error instanceof yup.ValidationError) {
|
|
23
|
+
next(new errors.ValidationError('Invalid pagination', [error]));
|
|
24
|
+
} else {
|
|
25
|
+
log.error('Unexpected error', error);
|
|
26
|
+
next(new errors.InternalServerError());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { Redis } from '@takaro/db';
|
|
3
|
+
import { logger, errors, ctx } from '@takaro/util';
|
|
4
|
+
import { RateLimiterRes, RateLimiterRedis, RateLimiterMemory } from 'rate-limiter-flexible';
|
|
5
|
+
|
|
6
|
+
export interface IRateLimitMiddlewareOptions {
|
|
7
|
+
max: number;
|
|
8
|
+
windowSeconds: number;
|
|
9
|
+
keyPrefix?: string;
|
|
10
|
+
useInMemory?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function createRateLimitMiddleware(opts: IRateLimitMiddlewareOptions) {
|
|
14
|
+
const log = logger('http:rateLimit');
|
|
15
|
+
const redis = await Redis.getClient('http:rateLimit', {
|
|
16
|
+
// Legacy mode is required for rate-limiter-flexible which isn't updated
|
|
17
|
+
// to use the new v4 redis API.
|
|
18
|
+
// See: https://github.com/animir/node-rate-limiter-flexible/wiki/Redis#usage
|
|
19
|
+
legacyMode: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// We create a randomHash to use in Redis keys
|
|
23
|
+
// This makes sure that each endpoint can get different rate limits without too much hassle
|
|
24
|
+
const randomHash = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
25
|
+
|
|
26
|
+
let rateLimiter: RateLimiterMemory | RateLimiterRedis;
|
|
27
|
+
|
|
28
|
+
if (opts.useInMemory) {
|
|
29
|
+
rateLimiter = new RateLimiterMemory({
|
|
30
|
+
points: opts.max,
|
|
31
|
+
duration: opts.windowSeconds,
|
|
32
|
+
keyPrefix: `http:rateLimit:${opts.keyPrefix ? opts.keyPrefix : randomHash}`,
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
rateLimiter = new RateLimiterRedis({
|
|
36
|
+
points: opts.max,
|
|
37
|
+
duration: opts.windowSeconds,
|
|
38
|
+
storeClient: redis,
|
|
39
|
+
keyPrefix: `http:rateLimit:${opts.keyPrefix ? opts.keyPrefix : randomHash}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
44
|
+
const ctxData = ctx.data;
|
|
45
|
+
let limitedKey = null;
|
|
46
|
+
if (ctxData.user) {
|
|
47
|
+
limitedKey = ctxData.user;
|
|
48
|
+
} else {
|
|
49
|
+
// TODO: should handle case ip is undefined
|
|
50
|
+
limitedKey = req.ip!;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let rateLimiterRes: RateLimiterRes | null = null;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
rateLimiterRes = await rateLimiter.consume(limitedKey);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof RateLimiterRes) {
|
|
59
|
+
rateLimiterRes = err;
|
|
60
|
+
log.warn(`rate limited, try again in ${err.msBeforeNext}ms`);
|
|
61
|
+
} else {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (rateLimiterRes) {
|
|
67
|
+
res.set('X-RateLimit-Limit', opts.max.toString());
|
|
68
|
+
res.set('X-RateLimit-Remaining', rateLimiterRes.remainingPoints.toString());
|
|
69
|
+
res.set('X-RateLimit-Reset', rateLimiterRes.msBeforeNext.toString());
|
|
70
|
+
|
|
71
|
+
if (rateLimiterRes.remainingPoints === 0) {
|
|
72
|
+
next(new errors.TooManyRequestsError());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return next();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { errors, isTakaroDTO, TakaroDTO } from '@takaro/util';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { IsISO8601, IsNumber, IsOptional, ValidateNested } from 'class-validator';
|
|
4
|
+
import { IsString, ValidationError as ClassValidatorError } from 'class-validator';
|
|
5
|
+
import { Request, Response } from 'express';
|
|
6
|
+
|
|
7
|
+
class ErrorOutput {
|
|
8
|
+
@IsString()
|
|
9
|
+
code?: string;
|
|
10
|
+
|
|
11
|
+
@IsString()
|
|
12
|
+
message?: string;
|
|
13
|
+
|
|
14
|
+
@IsString()
|
|
15
|
+
details?: string | ClassValidatorError[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class MetadataOutput {
|
|
19
|
+
@IsString()
|
|
20
|
+
@IsISO8601()
|
|
21
|
+
serverTime!: string;
|
|
22
|
+
|
|
23
|
+
@Type(() => ErrorOutput)
|
|
24
|
+
@ValidateNested()
|
|
25
|
+
error?: ErrorOutput;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The page number of the response
|
|
29
|
+
*/
|
|
30
|
+
@IsNumber()
|
|
31
|
+
@IsOptional()
|
|
32
|
+
page?: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The number of items returned in the response (aka page size)
|
|
36
|
+
*/
|
|
37
|
+
@IsNumber()
|
|
38
|
+
@IsOptional()
|
|
39
|
+
limit?: number;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The total number of items in the collection
|
|
43
|
+
*/
|
|
44
|
+
@IsNumber()
|
|
45
|
+
@IsOptional()
|
|
46
|
+
total?: number;
|
|
47
|
+
}
|
|
48
|
+
export class APIOutput<T> extends TakaroDTO<APIOutput<T>> {
|
|
49
|
+
@Type(() => MetadataOutput)
|
|
50
|
+
@ValidateNested()
|
|
51
|
+
meta!: MetadataOutput;
|
|
52
|
+
|
|
53
|
+
data!: T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface IApiResponseOptions {
|
|
57
|
+
error?: Error;
|
|
58
|
+
meta?: Record<string, string | number>;
|
|
59
|
+
req: Request;
|
|
60
|
+
res: Response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function apiResponse(data: unknown = {}, opts?: IApiResponseOptions): APIOutput<unknown> {
|
|
64
|
+
const returnVal = new APIOutput<unknown>();
|
|
65
|
+
|
|
66
|
+
returnVal.meta = new MetadataOutput();
|
|
67
|
+
returnVal.data = {};
|
|
68
|
+
|
|
69
|
+
if (opts?.error) {
|
|
70
|
+
returnVal.meta.error = new ErrorOutput();
|
|
71
|
+
|
|
72
|
+
returnVal.meta.error.code = String(opts.error.name);
|
|
73
|
+
|
|
74
|
+
if ('details' in opts.error) {
|
|
75
|
+
if (opts.error instanceof errors.ValidationError) {
|
|
76
|
+
returnVal.meta.error.details = opts.error.details as ClassValidatorError[];
|
|
77
|
+
} else {
|
|
78
|
+
returnVal.meta.error.details = opts.error.details as string;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
returnVal.meta.error.message = String(opts.error.message);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (opts?.meta) {
|
|
86
|
+
returnVal.meta.page = opts?.res.locals.page;
|
|
87
|
+
returnVal.meta.limit = opts?.res.locals.limit;
|
|
88
|
+
returnVal.meta.total = opts?.meta.total as number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isTakaroDTO(data)) {
|
|
92
|
+
returnVal.data = data.toJSON();
|
|
93
|
+
} else if (Array.isArray(data)) {
|
|
94
|
+
returnVal.data = data.map((item) => {
|
|
95
|
+
if (isTakaroDTO(item)) {
|
|
96
|
+
return item.toJSON();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return item;
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
returnVal.data = data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return returnVal;
|
|
106
|
+
}
|
package/tsconfig.json
ADDED
package/typedoc.json
ADDED