@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,65 @@
|
|
|
1
|
+
import { Redis } from '@takaro/db';
|
|
2
|
+
import { logger, errors, ctx } from '@takaro/util';
|
|
3
|
+
import { RateLimiterRes, RateLimiterRedis, RateLimiterMemory } from 'rate-limiter-flexible';
|
|
4
|
+
export async function createRateLimitMiddleware(opts) {
|
|
5
|
+
const log = logger('http:rateLimit');
|
|
6
|
+
const redis = await Redis.getClient('http:rateLimit', {
|
|
7
|
+
// Legacy mode is required for rate-limiter-flexible which isn't updated
|
|
8
|
+
// to use the new v4 redis API.
|
|
9
|
+
// See: https://github.com/animir/node-rate-limiter-flexible/wiki/Redis#usage
|
|
10
|
+
legacyMode: true,
|
|
11
|
+
});
|
|
12
|
+
// We create a randomHash to use in Redis keys
|
|
13
|
+
// This makes sure that each endpoint can get different rate limits without too much hassle
|
|
14
|
+
const randomHash = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
15
|
+
let rateLimiter;
|
|
16
|
+
if (opts.useInMemory) {
|
|
17
|
+
rateLimiter = new RateLimiterMemory({
|
|
18
|
+
points: opts.max,
|
|
19
|
+
duration: opts.windowSeconds,
|
|
20
|
+
keyPrefix: `http:rateLimit:${opts.keyPrefix ? opts.keyPrefix : randomHash}`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
rateLimiter = new RateLimiterRedis({
|
|
25
|
+
points: opts.max,
|
|
26
|
+
duration: opts.windowSeconds,
|
|
27
|
+
storeClient: redis,
|
|
28
|
+
keyPrefix: `http:rateLimit:${opts.keyPrefix ? opts.keyPrefix : randomHash}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return async (req, res, next) => {
|
|
32
|
+
const ctxData = ctx.data;
|
|
33
|
+
let limitedKey = null;
|
|
34
|
+
if (ctxData.user) {
|
|
35
|
+
limitedKey = ctxData.user;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// TODO: should handle case ip is undefined
|
|
39
|
+
limitedKey = req.ip;
|
|
40
|
+
}
|
|
41
|
+
let rateLimiterRes = null;
|
|
42
|
+
try {
|
|
43
|
+
rateLimiterRes = await rateLimiter.consume(limitedKey);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err instanceof RateLimiterRes) {
|
|
47
|
+
rateLimiterRes = err;
|
|
48
|
+
log.warn(`rate limited, try again in ${err.msBeforeNext}ms`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (rateLimiterRes) {
|
|
55
|
+
res.set('X-RateLimit-Limit', opts.max.toString());
|
|
56
|
+
res.set('X-RateLimit-Remaining', rateLimiterRes.remainingPoints.toString());
|
|
57
|
+
res.set('X-RateLimit-Reset', rateLimiterRes.msBeforeNext.toString());
|
|
58
|
+
if (rateLimiterRes.remainingPoints === 0) {
|
|
59
|
+
next(new errors.TooManyRequestsError());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return next();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=rateLimit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../../src/middleware/rateLimit.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAS5F,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAAC,IAAiC;IAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC;IACrC,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,SAAS,CAAC,gBAAgB,EAAE;QACpD,wEAAwE;QACxE,+BAA+B;QAC/B,6EAA6E;QAC7E,UAAU,EAAE,IAAI;KACjB,CAAC,CAAC;IAEH,8CAA8C;IAC9C,2FAA2F;IAC3F,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAE7G,IAAI,WAAiD,CAAC;IAEtD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;QACrB,WAAW,GAAG,IAAI,iBAAiB,CAAC;YAClC,MAAM,EAAE,IAAI,CAAC,GAAG;YAChB,QAAQ,EAAE,IAAI,CAAC,aAAa;YAC5B,SAAS,EAAE,kBAAkB,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE;SAC5E,CAAC,CAAC;IACL,CAAC;SAAM,CAAC;QACN,WAAW,GAAG,IAAI,gBAAgB,CAAC;YACjC,MAAM,EAAE,IAAI,CAAC,GAAG;YAChB,QAAQ,EAAE,IAAI,CAAC,aAAa;YAC5B,WAAW,EAAE,KAAK;YAClB,SAAS,EAAE,kBAAkB,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE;SAC5E,CAAC,CAAC;IACL,CAAC;IAED,OAAO,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;QAC/D,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;QACzB,IAAI,UAAU,GAAG,IAAI,CAAC;QACtB,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YACjB,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;QAC5B,CAAC;aAAM,CAAC;YACN,2CAA2C;YAC3C,UAAU,GAAG,GAAG,CAAC,EAAG,CAAC;QACvB,CAAC;QAED,IAAI,cAAc,GAA0B,IAAI,CAAC;QAEjD,IAAI,CAAC;YACH,cAAc,GAAG,MAAM,WAAW,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;QACzD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,GAAG,YAAY,cAAc,EAAE,CAAC;gBAClC,cAAc,GAAG,GAAG,CAAC;gBACrB,GAAG,CAAC,IAAI,CAAC,8BAA8B,GAAG,CAAC,YAAY,IAAI,CAAC,CAAC;YAC/D,CAAC;iBAAM,CAAC;gBACN,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;QAED,IAAI,cAAc,EAAE,CAAC;YACnB,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC;YAClD,GAAG,CAAC,GAAG,CAAC,uBAAuB,EAAE,cAAc,CAAC,eAAe,CAAC,QAAQ,EAAE,CAAC,CAAC;YAC5E,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,cAAc,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,CAAC;YAErE,IAAI,cAAc,CAAC,eAAe,KAAK,CAAC,EAAE,CAAC;gBACzC,IAAI,CAAC,IAAI,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC;YAC1C,CAAC;QACH,CAAC;QAED,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { TakaroDTO } from '@takaro/util';
|
|
2
|
+
import { ValidationError as ClassValidatorError } from 'class-validator';
|
|
3
|
+
import { Request, Response } from 'express';
|
|
4
|
+
declare class ErrorOutput {
|
|
5
|
+
code?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
details?: string | ClassValidatorError[];
|
|
8
|
+
}
|
|
9
|
+
declare class MetadataOutput {
|
|
10
|
+
serverTime: string;
|
|
11
|
+
error?: ErrorOutput;
|
|
12
|
+
/**
|
|
13
|
+
* The page number of the response
|
|
14
|
+
*/
|
|
15
|
+
page?: number;
|
|
16
|
+
/**
|
|
17
|
+
* The number of items returned in the response (aka page size)
|
|
18
|
+
*/
|
|
19
|
+
limit?: number;
|
|
20
|
+
/**
|
|
21
|
+
* The total number of items in the collection
|
|
22
|
+
*/
|
|
23
|
+
total?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class APIOutput<T> extends TakaroDTO<APIOutput<T>> {
|
|
26
|
+
meta: MetadataOutput;
|
|
27
|
+
data: T;
|
|
28
|
+
}
|
|
29
|
+
interface IApiResponseOptions {
|
|
30
|
+
error?: Error;
|
|
31
|
+
meta?: Record<string, string | number>;
|
|
32
|
+
req: Request;
|
|
33
|
+
res: Response;
|
|
34
|
+
}
|
|
35
|
+
export declare function apiResponse(data?: unknown, opts?: IApiResponseOptions): APIOutput<unknown>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { errors, isTakaroDTO, TakaroDTO } from '@takaro/util';
|
|
11
|
+
import { Type } from 'class-transformer';
|
|
12
|
+
import { IsISO8601, IsNumber, IsOptional, ValidateNested } from 'class-validator';
|
|
13
|
+
import { IsString } from 'class-validator';
|
|
14
|
+
class ErrorOutput {
|
|
15
|
+
}
|
|
16
|
+
__decorate([
|
|
17
|
+
IsString(),
|
|
18
|
+
__metadata("design:type", String)
|
|
19
|
+
], ErrorOutput.prototype, "code", void 0);
|
|
20
|
+
__decorate([
|
|
21
|
+
IsString(),
|
|
22
|
+
__metadata("design:type", String)
|
|
23
|
+
], ErrorOutput.prototype, "message", void 0);
|
|
24
|
+
__decorate([
|
|
25
|
+
IsString(),
|
|
26
|
+
__metadata("design:type", Object)
|
|
27
|
+
], ErrorOutput.prototype, "details", void 0);
|
|
28
|
+
class MetadataOutput {
|
|
29
|
+
}
|
|
30
|
+
__decorate([
|
|
31
|
+
IsString(),
|
|
32
|
+
IsISO8601(),
|
|
33
|
+
__metadata("design:type", String)
|
|
34
|
+
], MetadataOutput.prototype, "serverTime", void 0);
|
|
35
|
+
__decorate([
|
|
36
|
+
Type(() => ErrorOutput),
|
|
37
|
+
ValidateNested(),
|
|
38
|
+
__metadata("design:type", ErrorOutput)
|
|
39
|
+
], MetadataOutput.prototype, "error", void 0);
|
|
40
|
+
__decorate([
|
|
41
|
+
IsNumber(),
|
|
42
|
+
IsOptional(),
|
|
43
|
+
__metadata("design:type", Number)
|
|
44
|
+
], MetadataOutput.prototype, "page", void 0);
|
|
45
|
+
__decorate([
|
|
46
|
+
IsNumber(),
|
|
47
|
+
IsOptional(),
|
|
48
|
+
__metadata("design:type", Number)
|
|
49
|
+
], MetadataOutput.prototype, "limit", void 0);
|
|
50
|
+
__decorate([
|
|
51
|
+
IsNumber(),
|
|
52
|
+
IsOptional(),
|
|
53
|
+
__metadata("design:type", Number)
|
|
54
|
+
], MetadataOutput.prototype, "total", void 0);
|
|
55
|
+
export class APIOutput extends TakaroDTO {
|
|
56
|
+
}
|
|
57
|
+
__decorate([
|
|
58
|
+
Type(() => MetadataOutput),
|
|
59
|
+
ValidateNested(),
|
|
60
|
+
__metadata("design:type", MetadataOutput)
|
|
61
|
+
], APIOutput.prototype, "meta", void 0);
|
|
62
|
+
export function apiResponse(data = {}, opts) {
|
|
63
|
+
const returnVal = new APIOutput();
|
|
64
|
+
returnVal.meta = new MetadataOutput();
|
|
65
|
+
returnVal.data = {};
|
|
66
|
+
if (opts?.error) {
|
|
67
|
+
returnVal.meta.error = new ErrorOutput();
|
|
68
|
+
returnVal.meta.error.code = String(opts.error.name);
|
|
69
|
+
if ('details' in opts.error) {
|
|
70
|
+
if (opts.error instanceof errors.ValidationError) {
|
|
71
|
+
returnVal.meta.error.details = opts.error.details;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
returnVal.meta.error.details = opts.error.details;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
returnVal.meta.error.message = String(opts.error.message);
|
|
78
|
+
}
|
|
79
|
+
if (opts?.meta) {
|
|
80
|
+
returnVal.meta.page = opts?.res.locals.page;
|
|
81
|
+
returnVal.meta.limit = opts?.res.locals.limit;
|
|
82
|
+
returnVal.meta.total = opts?.meta.total;
|
|
83
|
+
}
|
|
84
|
+
if (isTakaroDTO(data)) {
|
|
85
|
+
returnVal.data = data.toJSON();
|
|
86
|
+
}
|
|
87
|
+
else if (Array.isArray(data)) {
|
|
88
|
+
returnVal.data = data.map((item) => {
|
|
89
|
+
if (isTakaroDTO(item)) {
|
|
90
|
+
return item.toJSON();
|
|
91
|
+
}
|
|
92
|
+
return item;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
returnVal.data = data;
|
|
97
|
+
}
|
|
98
|
+
return returnVal;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=apiResponse.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiResponse.js","sourceRoot":"","sources":["../../src/util/apiResponse.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@takaro/http",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "An opinionated http server",
|
|
5
|
+
"main": "dist/main.js",
|
|
6
|
+
"types": "dist/main.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start:dev": "tsc --watch --preserveWatchOutput -p ./tsconfig.build.json",
|
|
10
|
+
"build": "tsc -p ./tsconfig.build.json",
|
|
11
|
+
"test": "npm run test:unit --if-present && npm run test:integration --if-present",
|
|
12
|
+
"test:unit": "mocha --config ../../.mocharc.js src/**/*.unit.test.ts --exit",
|
|
13
|
+
"test:integration": "mocha --config ../../.mocharc.js src/**/*.integration.test.ts --exit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@takaro/config": "0.0.1",
|
|
20
|
+
"@takaro/util": "0.0.1",
|
|
21
|
+
"body-parser": "^1.20.0",
|
|
22
|
+
"class-validator-jsonschema": "^3.1.1",
|
|
23
|
+
"cookie-parser": "^1.4.6",
|
|
24
|
+
"cors": "^2.8.5",
|
|
25
|
+
"express": "^4.18.1",
|
|
26
|
+
"i": "^0.3.7",
|
|
27
|
+
"rate-limiter-flexible": "^2.4.1",
|
|
28
|
+
"zod": "^3.17.10"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@takaro/test": "0.0.1",
|
|
32
|
+
"@types/cookie-parser": "^1.4.3",
|
|
33
|
+
"@types/cors": "^2.8.12",
|
|
34
|
+
"@types/express": "^4.17.13",
|
|
35
|
+
"supertest": "6.3.4"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import express, { Application } from 'express';
|
|
3
|
+
import { logger, errors, Sentry } from '@takaro/util';
|
|
4
|
+
import { getBullBoard } from '@takaro/queues';
|
|
5
|
+
import { Server, createServer } from 'http';
|
|
6
|
+
import { RoutingControllersOptions, useExpressServer } from 'routing-controllers';
|
|
7
|
+
import { Meta } from './controllers/meta.js';
|
|
8
|
+
import { LoggingMiddleware } from './middleware/logger.js';
|
|
9
|
+
import { ErrorHandler } from './middleware/errorHandler.js';
|
|
10
|
+
import bodyParser from 'body-parser';
|
|
11
|
+
import cors from 'cors';
|
|
12
|
+
import cookieParser from 'cookie-parser';
|
|
13
|
+
import { metricsMiddleware } from './main.js';
|
|
14
|
+
import { paginationMiddleware } from './middleware/paginationMiddleware.js';
|
|
15
|
+
|
|
16
|
+
interface IHTTPOptions {
|
|
17
|
+
port?: number;
|
|
18
|
+
allowedOrigins?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class HTTP {
|
|
22
|
+
private app: Application;
|
|
23
|
+
private httpServer: Server;
|
|
24
|
+
private logger;
|
|
25
|
+
|
|
26
|
+
constructor(options: RoutingControllersOptions = {}, private httpOptions: IHTTPOptions = {}) {
|
|
27
|
+
this.logger = logger('http');
|
|
28
|
+
this.app = express();
|
|
29
|
+
this.httpServer = createServer(this.app);
|
|
30
|
+
this.app.use(Sentry.Handlers.requestHandler());
|
|
31
|
+
this.app.use(
|
|
32
|
+
bodyParser.json({
|
|
33
|
+
verify: (req, res, buf) => {
|
|
34
|
+
(req as any).rawBody = buf.toString();
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
this.app.use(LoggingMiddleware);
|
|
39
|
+
this.app.use(metricsMiddleware);
|
|
40
|
+
this.app.use(paginationMiddleware);
|
|
41
|
+
this.app.set('x-powered-by', false);
|
|
42
|
+
this.app.use(
|
|
43
|
+
cors({
|
|
44
|
+
credentials: true,
|
|
45
|
+
exposedHeaders: ['X-Trace-Id'],
|
|
46
|
+
origin: (origin: string | undefined, callback: CallableFunction) => {
|
|
47
|
+
if (!origin) return callback(null, true);
|
|
48
|
+
const allowedOrigins = this.httpOptions.allowedOrigins ?? [];
|
|
49
|
+
if (!origin || allowedOrigins.includes(origin)) {
|
|
50
|
+
callback(null, true);
|
|
51
|
+
} else {
|
|
52
|
+
this.logger.warn(`Origin ${origin} not allowed by CORS`);
|
|
53
|
+
callback(new errors.BadRequestError('Not allowed by CORS'));
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
this.app.use(cookieParser());
|
|
59
|
+
|
|
60
|
+
if (options.controllers) {
|
|
61
|
+
useExpressServer(this.app, {
|
|
62
|
+
...options,
|
|
63
|
+
defaultErrorHandler: false,
|
|
64
|
+
validation: { whitelist: true, forbidNonWhitelisted: true },
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
66
|
+
controllers: [Meta, ...(options.controllers as Function[])],
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
useExpressServer(this.app, {
|
|
70
|
+
...options,
|
|
71
|
+
defaultErrorHandler: false,
|
|
72
|
+
controllers: [Meta],
|
|
73
|
+
validation: { whitelist: true, forbidNonWhitelisted: true },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.app.use('/queues', getBullBoard());
|
|
78
|
+
|
|
79
|
+
this.app.use(Sentry.Handlers.errorHandler());
|
|
80
|
+
|
|
81
|
+
this.app.use(ErrorHandler);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get expressInstance() {
|
|
85
|
+
return this.app;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get server() {
|
|
89
|
+
return this.httpServer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async start() {
|
|
93
|
+
this.httpServer = this.httpServer.listen(this.httpOptions.port, () => {
|
|
94
|
+
this.logger.info(`HTTP server listening on port ${this.httpOptions.port}`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async stop() {
|
|
99
|
+
if (this.httpServer) {
|
|
100
|
+
this.httpServer.close();
|
|
101
|
+
this.logger.info('HTTP server stopped');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HTTP } from '../../app.js';
|
|
2
|
+
import supertest from 'supertest';
|
|
3
|
+
import { expect } from '@takaro/test';
|
|
4
|
+
|
|
5
|
+
describe('app', () => {
|
|
6
|
+
let http: HTTP;
|
|
7
|
+
before(async () => {
|
|
8
|
+
http = new HTTP({}, { port: undefined });
|
|
9
|
+
await http.start();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
after(async () => {
|
|
13
|
+
await http.stop();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('Serves a health status', async () => {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
const response = await supertest(http.expressInstance).get('/healthz');
|
|
20
|
+
expect(response.status).to.be.equal(200);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('Serves a open api spec', async () => {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
25
|
+
// @ts-ignore
|
|
26
|
+
const response = await supertest(http.expressInstance).get('/openapi.json');
|
|
27
|
+
expect(response.status).to.be.equal(200);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Controller, Get } from 'routing-controllers';
|
|
2
|
+
import { getMetadataArgsStorage } from 'routing-controllers';
|
|
3
|
+
import { routingControllersToSpec } from 'routing-controllers-openapi';
|
|
4
|
+
import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
|
|
5
|
+
import { ResponseSchema } from 'routing-controllers-openapi';
|
|
6
|
+
import { IsBoolean } from 'class-validator';
|
|
7
|
+
import { getMetrics, health } from '@takaro/util';
|
|
8
|
+
import { OpenAPIObject } from 'openapi3-ts';
|
|
9
|
+
import { PERMISSIONS } from '@takaro/auth';
|
|
10
|
+
import { EventMapping } from '@takaro/modules';
|
|
11
|
+
|
|
12
|
+
let spec: OpenAPIObject | undefined;
|
|
13
|
+
|
|
14
|
+
export class HealthOutputDTO {
|
|
15
|
+
@IsBoolean()
|
|
16
|
+
healthy!: boolean;
|
|
17
|
+
}
|
|
18
|
+
@Controller()
|
|
19
|
+
export class Meta {
|
|
20
|
+
@Get('/healthz')
|
|
21
|
+
@ResponseSchema(HealthOutputDTO)
|
|
22
|
+
async getHealth() {
|
|
23
|
+
return { healthy: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Get('/readyz')
|
|
27
|
+
@ResponseSchema(HealthOutputDTO)
|
|
28
|
+
async getReadiness() {
|
|
29
|
+
const healthy = await health.check();
|
|
30
|
+
return { healthy };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Get('/openapi.json')
|
|
34
|
+
async getOpenApi() {
|
|
35
|
+
if (spec) return spec;
|
|
36
|
+
|
|
37
|
+
const { getMetadataStorage } = await import('class-validator');
|
|
38
|
+
const classTransformerStorage = await import(
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
40
|
+
// @ts-ignore were doing an import of internal code and ts doesnt like that
|
|
41
|
+
// But this does work, trust me bro...
|
|
42
|
+
'class-transformer/cjs/storage.js'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const storage = getMetadataArgsStorage();
|
|
46
|
+
const schemas = validationMetadatasToSchemas({
|
|
47
|
+
refPointerPrefix: '#/components/schemas/',
|
|
48
|
+
classTransformerMetadataStorage: classTransformerStorage.defaultMetadataStorage,
|
|
49
|
+
classValidatorMetadataStorage: getMetadataStorage(),
|
|
50
|
+
forbidNonWhitelisted: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
spec = routingControllersToSpec(
|
|
54
|
+
storage,
|
|
55
|
+
{},
|
|
56
|
+
{
|
|
57
|
+
components: {
|
|
58
|
+
schemas,
|
|
59
|
+
securitySchemes: {
|
|
60
|
+
adminAuth: {
|
|
61
|
+
description: 'Used for system administration, like creating or deleting domains',
|
|
62
|
+
type: 'http',
|
|
63
|
+
scheme: 'bearer',
|
|
64
|
+
bearerFormat: 'JWT',
|
|
65
|
+
},
|
|
66
|
+
domainAuth: {
|
|
67
|
+
description: 'Used for anything inside a domain. Players, GameServers, etc.',
|
|
68
|
+
type: 'apiKey',
|
|
69
|
+
in: 'cookie',
|
|
70
|
+
name: 'takaro-token',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Add required permissions to operation descriptions
|
|
78
|
+
|
|
79
|
+
const requiredPermsRegex = /authMiddleware\((.+)\)/;
|
|
80
|
+
|
|
81
|
+
storage.uses.forEach((use) => {
|
|
82
|
+
const requiredPerms =
|
|
83
|
+
use.middleware.name
|
|
84
|
+
.match(requiredPermsRegex)?.[1]
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((p) => `\`${p}\``)
|
|
87
|
+
.join(', ') || [];
|
|
88
|
+
|
|
89
|
+
const operationId = `${use.target.name}.${use.method}`;
|
|
90
|
+
|
|
91
|
+
if (!requiredPerms.length) return;
|
|
92
|
+
|
|
93
|
+
// Find the corresponding path and method in spec
|
|
94
|
+
Object.keys(spec?.paths ?? []).forEach((pathKey) => {
|
|
95
|
+
const pathItem = spec?.paths[pathKey];
|
|
96
|
+
Object.keys(pathItem).forEach((method) => {
|
|
97
|
+
const operation = pathItem[method];
|
|
98
|
+
if (operation.operationId === operationId) {
|
|
99
|
+
// Update the description with required permissions
|
|
100
|
+
operation.description = (operation.description || '') + ` Required permissions: ${requiredPerms}`;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (spec.components?.schemas) {
|
|
107
|
+
spec.components.schemas.PERMISSIONS = {
|
|
108
|
+
enum: Object.values(PERMISSIONS),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Force event meta to be the correct types
|
|
113
|
+
// TODO: figure out how to do this 'properly' with class-validator
|
|
114
|
+
const allEvents = Object.values(EventMapping).map((e) => e.name);
|
|
115
|
+
|
|
116
|
+
const eventOutputMetaSchema = spec.components?.schemas?.EventOutputDTO;
|
|
117
|
+
if (eventOutputMetaSchema && 'properties' in eventOutputMetaSchema && eventOutputMetaSchema.properties) {
|
|
118
|
+
eventOutputMetaSchema.properties.meta = {
|
|
119
|
+
oneOf: [...allEvents.map((e) => ({ $ref: `#/components/schemas/${e}` }))],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return spec;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Get('/api.html')
|
|
127
|
+
getOpenApiHtml() {
|
|
128
|
+
return `<!DOCTYPE html>
|
|
129
|
+
<html>
|
|
130
|
+
<head>
|
|
131
|
+
<meta charset="utf-8" />
|
|
132
|
+
<script
|
|
133
|
+
type="module"
|
|
134
|
+
src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"
|
|
135
|
+
></script>
|
|
136
|
+
</head>
|
|
137
|
+
<body>
|
|
138
|
+
<rapi-doc
|
|
139
|
+
spec-url="/openapi.json"
|
|
140
|
+
render-style="read"
|
|
141
|
+
fill-request-fields-with-example="false"
|
|
142
|
+
persist-auth="true"
|
|
143
|
+
|
|
144
|
+
sort-tags="true"
|
|
145
|
+
sort-endpoints-by="method"
|
|
146
|
+
|
|
147
|
+
show-method-in-nav-bar="as-colored-block"
|
|
148
|
+
show-header="false"
|
|
149
|
+
allow-authentication="false"
|
|
150
|
+
allow-server-selection="false"
|
|
151
|
+
|
|
152
|
+
schema-style="table"
|
|
153
|
+
schema-expand-level="1"
|
|
154
|
+
default-schema-tab="schema"
|
|
155
|
+
|
|
156
|
+
primary-color="#664de5"
|
|
157
|
+
bg-color="#151515"
|
|
158
|
+
text-color="#c2c2c2"
|
|
159
|
+
header-color="#353535"
|
|
160
|
+
/>
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@Get('/metrics')
|
|
167
|
+
getMetrics() {
|
|
168
|
+
return getMetrics();
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { HTTP } from './app.js';
|
|
2
|
+
|
|
3
|
+
export * from './middleware/metrics.js';
|
|
4
|
+
export { createRateLimitMiddleware } from './middleware/rateLimit.js';
|
|
5
|
+
export { adminAuthMiddleware } from './middleware/adminAuth.js';
|
|
6
|
+
export { paginationMiddleware } from './middleware/paginationMiddleware.js';
|
|
7
|
+
export { apiResponse, APIOutput } from './util/apiResponse.js';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HTTP } from '../../app.js';
|
|
2
|
+
import supertest from 'supertest';
|
|
3
|
+
import { expect } from '@takaro/test';
|
|
4
|
+
import { ory } from '@takaro/auth';
|
|
5
|
+
import { AdminClient } from '@takaro/apiclient';
|
|
6
|
+
import { adminAuthMiddleware } from '../adminAuth.js';
|
|
7
|
+
import { ErrorHandler } from '../errorHandler.js';
|
|
8
|
+
import { Request, Response } from 'express';
|
|
9
|
+
|
|
10
|
+
describe('adminAuth', () => {
|
|
11
|
+
let http: HTTP;
|
|
12
|
+
before(async () => {
|
|
13
|
+
http = new HTTP({}, { port: undefined });
|
|
14
|
+
http.expressInstance.use(
|
|
15
|
+
'/test',
|
|
16
|
+
adminAuthMiddleware,
|
|
17
|
+
(_req: Request, res: Response) => {
|
|
18
|
+
res.json({ ok: true });
|
|
19
|
+
},
|
|
20
|
+
ErrorHandler
|
|
21
|
+
);
|
|
22
|
+
await http.start();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
after(async () => {
|
|
26
|
+
await http.stop();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('Rejects requests with no credentials', async () => {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
31
|
+
// @ts-ignore
|
|
32
|
+
const response = await supertest(http.expressInstance).get('/test');
|
|
33
|
+
expect(response.status).to.be.equal(401);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('Rejects requests with invalid credentials', async () => {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
const response = await supertest(http.expressInstance).get('/test').set('Authorization', 'Bearer foobar');
|
|
40
|
+
expect(response.status).to.be.equal(403);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('Accepts requests with valid credentials', async () => {
|
|
44
|
+
const { clientId, clientSecret } = await ory.createOIDCClient();
|
|
45
|
+
|
|
46
|
+
const adminClient = new AdminClient({
|
|
47
|
+
url: 'http://localhost:3000',
|
|
48
|
+
auth: {
|
|
49
|
+
clientId,
|
|
50
|
+
clientSecret,
|
|
51
|
+
},
|
|
52
|
+
OAuth2URL: ory.OAuth2URL,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const token = await adminClient.getOidcToken();
|
|
56
|
+
|
|
57
|
+
const response = await supertest(http.expressInstance)
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
59
|
+
// @ts-ignore
|
|
60
|
+
.get('/test')
|
|
61
|
+
.set('Authorization', `Bearer ${token.access_token}`);
|
|
62
|
+
|
|
63
|
+
expect(response.status).to.be.equal(200);
|
|
64
|
+
});
|
|
65
|
+
});
|