@naturalcycles/backend-lib 9.74.0 → 9.76.0
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/dist/admin/adminMiddleware.d.ts +17 -0
- package/dist/admin/adminMiddleware.js +11 -0
- package/dist/admin/base.admin.service.d.ts +17 -0
- package/dist/admin/base.admin.service.js +2 -0
- package/dist/admin/firebaseAuth.service.d.ts +41 -0
- package/dist/admin/firebaseAuth.service.js +103 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/secureHeaderMiddleware.d.ts +5 -0
- package/dist/admin/secureHeaderMiddleware.js +2 -0
- package/dist/auth/auth.service.d.ts +119 -0
- package/dist/auth/auth.service.js +147 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/server/basicAuthMiddleware.js +1 -0
- package/dist/server/request.util.d.ts +14 -0
- package/dist/server/request.util.js +28 -1
- package/dist/server/requestTimeoutMiddleware.d.ts +8 -1
- package/dist/server/requestTimeoutMiddleware.js +19 -5
- package/dist/server/serverStatsMiddleware.js +16 -3
- package/package.json +3 -1
- package/src/admin/adminMiddleware.ts +17 -0
- package/src/admin/base.admin.service.ts +17 -0
- package/src/admin/firebaseAuth.service.ts +139 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/login.html +16 -1
- package/src/admin/secureHeaderMiddleware.ts +5 -0
- package/src/auth/auth.service.ts +303 -0
- package/src/auth/index.ts +1 -0
- package/src/server/basicAuthMiddleware.ts +1 -0
- package/src/server/request.util.ts +37 -1
- package/src/server/requestTimeoutMiddleware.ts +32 -4
- package/src/server/serverStatsMiddleware.ts +18 -4
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { _assert, AppError } from '@naturalcycles/js-lib/error'
|
|
2
|
+
import type { ErrorData } from '@naturalcycles/js-lib/error'
|
|
3
|
+
import type { AnyObject, Promisable } from '@naturalcycles/js-lib/types'
|
|
4
|
+
import { timingSafeStringEqual } from '@naturalcycles/nodejs-lib'
|
|
5
|
+
import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Authenticates requests and checks permissions.
|
|
9
|
+
*/
|
|
10
|
+
export class AuthService<CTX = AnyObject> {
|
|
11
|
+
constructor(cfg: AuthServiceCfg<CTX>) {
|
|
12
|
+
this.cfg = {
|
|
13
|
+
authEnabled: true,
|
|
14
|
+
...cfg,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
cfg: AuthServiceCfg<CTX> & { authEnabled: boolean }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Responds JSON 401 / 403.
|
|
22
|
+
*/
|
|
23
|
+
getMiddleware(cfg: AuthMiddlewareCfg<CTX> = {}): AuthMiddleware {
|
|
24
|
+
return reqPermissions => this.createHandler(cfg, reqPermissions)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Like `getMiddleware`, but 401 redirects to the login page.
|
|
29
|
+
*/
|
|
30
|
+
getLoginRedirectMiddleware(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): AuthMiddleware {
|
|
31
|
+
const loginHref = this.getLoginHref(cfg)
|
|
32
|
+
return reqPermissions => this.createHandler(cfg, reqPermissions, loginHref)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private createHandler(
|
|
36
|
+
cfg: AuthMiddlewareCfg<CTX>,
|
|
37
|
+
reqPermissions: string[] = [],
|
|
38
|
+
loginHref?: string,
|
|
39
|
+
): BackendRequestHandler {
|
|
40
|
+
const {
|
|
41
|
+
getResolved = req => this.resolve(req),
|
|
42
|
+
getCtx,
|
|
43
|
+
andComparison = true,
|
|
44
|
+
secureHeader,
|
|
45
|
+
} = cfg
|
|
46
|
+
|
|
47
|
+
return async (req, res, next) => {
|
|
48
|
+
if (!this.cfg.authEnabled) return next()
|
|
49
|
+
|
|
50
|
+
if (secureHeader) {
|
|
51
|
+
const providedHeader = req.get(secureHeader.name)
|
|
52
|
+
if (providedHeader) {
|
|
53
|
+
if (timingSafeStringEqual(providedHeader, secureHeader.value)) {
|
|
54
|
+
return next()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return next(
|
|
58
|
+
new AppError('secureHeader or authentication required', this.authRequiredData()),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const resolved = await getResolved(req)
|
|
65
|
+
if (!resolved?.subject && loginHref) {
|
|
66
|
+
res.status(401).send(this.getLoginHtmlRedirect(loginHref))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.require(resolved, reqPermissions, { andComparison, ctx: getCtx?.(req) })
|
|
71
|
+
return next()
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return next(err)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runs `authenticate`, then `getPermissions` and `isAlwaysAuthorized` if there's a subject.
|
|
80
|
+
* Ignores `authEnabled`.
|
|
81
|
+
*/
|
|
82
|
+
async resolve(req: BackendRequest): Promise<ResolvedAuth> {
|
|
83
|
+
const { authenticate, getPermissions, isAlwaysAuthorized } = this.cfg
|
|
84
|
+
|
|
85
|
+
const subject = await authenticate(req)
|
|
86
|
+
if (!subject) return {}
|
|
87
|
+
|
|
88
|
+
const permissions = await getPermissions(subject, req)
|
|
89
|
+
const alwaysAuthorized = isAlwaysAuthorized
|
|
90
|
+
? await isAlwaysAuthorized(subject, permissions, req)
|
|
91
|
+
: false
|
|
92
|
+
|
|
93
|
+
return { subject, permissions, alwaysAuthorized }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Throws 401 if there's no subject, 403 if not granted.
|
|
98
|
+
* Returns the granted permissions (all of the subject's if alwaysAuthorized),
|
|
99
|
+
* or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled.
|
|
100
|
+
*/
|
|
101
|
+
require(
|
|
102
|
+
resolved: ResolvedAuth | undefined,
|
|
103
|
+
reqPermissions: string[] = [],
|
|
104
|
+
opt: AuthCheckOptions<CTX> = {},
|
|
105
|
+
): PermissionInfo {
|
|
106
|
+
if (!this.cfg.authEnabled) return { subject: 'authDisabled', permissions: [] }
|
|
107
|
+
|
|
108
|
+
this.assertAuthenticated(resolved)
|
|
109
|
+
const { subject, permissions, alwaysAuthorized } = resolved
|
|
110
|
+
|
|
111
|
+
const grantedPermissions = this.checkSubject(resolved, reqPermissions, opt)
|
|
112
|
+
this.assertGranted(subject, grantedPermissions, reqPermissions)
|
|
113
|
+
|
|
114
|
+
if (alwaysAuthorized) {
|
|
115
|
+
return { subject, permissions: permissions?.slice() || [] }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { subject, permissions: grantedPermissions.slice() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private checkSubject(
|
|
122
|
+
resolved: AuthenticatedAuth,
|
|
123
|
+
reqPermissions: string[],
|
|
124
|
+
opt: AuthCheckOptions<CTX>,
|
|
125
|
+
): string[] | undefined {
|
|
126
|
+
const { subject, permissions, alwaysAuthorized = false } = resolved
|
|
127
|
+
const { andComparison = true, ctx } = opt
|
|
128
|
+
|
|
129
|
+
const grantedPermissions = alwaysAuthorized
|
|
130
|
+
? reqPermissions
|
|
131
|
+
: this.getGrantedPermissions(permissions, reqPermissions, andComparison)
|
|
132
|
+
|
|
133
|
+
this.cfg.onCheck?.(
|
|
134
|
+
{
|
|
135
|
+
subject,
|
|
136
|
+
granted: !!grantedPermissions,
|
|
137
|
+
alwaysAuthorized,
|
|
138
|
+
reqPermissions,
|
|
139
|
+
checkedPermissions: grantedPermissions || reqPermissions,
|
|
140
|
+
},
|
|
141
|
+
ctx,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return grantedPermissions
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private assertAuthenticated(
|
|
148
|
+
resolved: ResolvedAuth | undefined,
|
|
149
|
+
): asserts resolved is AuthenticatedAuth {
|
|
150
|
+
_assert(resolved?.subject, 'authentication required', this.authRequiredData())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private authRequiredData(): ErrorData {
|
|
154
|
+
return {
|
|
155
|
+
authRequired: true,
|
|
156
|
+
backendResponseStatusCode: 401,
|
|
157
|
+
userFriendly: true,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private assertGranted(
|
|
162
|
+
subject: string,
|
|
163
|
+
grantedPermissions: string[] | undefined,
|
|
164
|
+
reqPermissions: string[],
|
|
165
|
+
): asserts grantedPermissions is string[] {
|
|
166
|
+
_assert(grantedPermissions, `Permissions required: [${reqPermissions.join(', ')}]`, {
|
|
167
|
+
permissionsRequired: reqPermissions,
|
|
168
|
+
subject,
|
|
169
|
+
backendResponseStatusCode: 403,
|
|
170
|
+
userFriendly: true,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* undefined - not granted.
|
|
176
|
+
*/
|
|
177
|
+
private getGrantedPermissions(
|
|
178
|
+
permissions: string[] | undefined,
|
|
179
|
+
reqPermissions: string[],
|
|
180
|
+
andComparison: boolean,
|
|
181
|
+
): string[] | undefined {
|
|
182
|
+
if (!permissions) return
|
|
183
|
+
const grantedPermissions = reqPermissions.filter(p => permissions.includes(p))
|
|
184
|
+
const granted = andComparison
|
|
185
|
+
? grantedPermissions.length === reqPermissions.length
|
|
186
|
+
: grantedPermissions.length > 0
|
|
187
|
+
return granted ? grantedPermissions : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private getLoginHref(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): string {
|
|
191
|
+
return `${cfg.loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private getLoginHtmlRedirect(href: string): string {
|
|
195
|
+
return `
|
|
196
|
+
<html>
|
|
197
|
+
<body>401 Authentication Required
|
|
198
|
+
<script>
|
|
199
|
+
document.write(\`: <a href="${href}" id="loginLink">Login</a>\`)
|
|
200
|
+
document.getElementById('loginLink').click()
|
|
201
|
+
</script>
|
|
202
|
+
</body>
|
|
203
|
+
</html>
|
|
204
|
+
`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface AuthServiceCfg<CTX> {
|
|
209
|
+
/**
|
|
210
|
+
* Returns the subject, or undefined if not authenticated.
|
|
211
|
+
*/
|
|
212
|
+
authenticate: (req: BackendRequest) => Promise<string | undefined>
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* undefined - no permission set (never granted).
|
|
216
|
+
*/
|
|
217
|
+
getPermissions: (subject: string, req: BackendRequest) => Promise<string[] | undefined>
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* true - every check is granted for this subject.
|
|
221
|
+
*/
|
|
222
|
+
isAlwaysAuthorized?: (
|
|
223
|
+
subject: string,
|
|
224
|
+
permissions: string[] | undefined,
|
|
225
|
+
req: BackendRequest,
|
|
226
|
+
) => Promisable<boolean>
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Called on every `require` check that has a subject, while auth is enabled. Must not throw.
|
|
230
|
+
*/
|
|
231
|
+
onCheck?: (check: AuthCheck, ctx: (CTX & AnyObject) | undefined) => void
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* false - every check is granted. For tests / local debugging. Read on every check.
|
|
235
|
+
*
|
|
236
|
+
* @default true
|
|
237
|
+
*/
|
|
238
|
+
authEnabled?: boolean
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface AuthCheck {
|
|
242
|
+
subject: string
|
|
243
|
+
granted: boolean
|
|
244
|
+
alwaysAuthorized: boolean
|
|
245
|
+
reqPermissions: string[]
|
|
246
|
+
/** The granted permissions, or reqPermissions if denied */
|
|
247
|
+
checkedPermissions: string[]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface AuthLoginRedirectMiddlewareCfg<CTX> extends AuthMiddlewareCfg<CTX> {
|
|
251
|
+
loginHtmlPath: string
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface AuthMiddlewareCfg<CTX> {
|
|
255
|
+
/**
|
|
256
|
+
* Defaults to `resolve(req)`.
|
|
257
|
+
*/
|
|
258
|
+
getResolved?: (req: BackendRequest) => Promisable<ResolvedAuth | undefined>
|
|
259
|
+
getCtx?: (req: BackendRequest) => (CTX & AnyObject) | undefined
|
|
260
|
+
/**
|
|
261
|
+
* @default true
|
|
262
|
+
*/
|
|
263
|
+
andComparison?: boolean
|
|
264
|
+
/**
|
|
265
|
+
* A request with this header passes without auth. A wrong value responds 401.
|
|
266
|
+
*/
|
|
267
|
+
secureHeader?: SecureHeaderCfg
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface SecureHeaderCfg {
|
|
271
|
+
/** e.g. 'Authorization' */
|
|
272
|
+
name: string
|
|
273
|
+
value: string
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type AuthMiddleware = (reqPermissions?: string[]) => BackendRequestHandler
|
|
277
|
+
|
|
278
|
+
export interface AuthCheckOptions<CTX> {
|
|
279
|
+
/**
|
|
280
|
+
* false - one granted permission is enough.
|
|
281
|
+
*
|
|
282
|
+
* @default true
|
|
283
|
+
*/
|
|
284
|
+
andComparison?: boolean
|
|
285
|
+
/** Passed to onCheck: the app's CTX, plus any extra keys */
|
|
286
|
+
ctx?: CTX & AnyObject
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface PermissionInfo {
|
|
290
|
+
subject: string
|
|
291
|
+
permissions: string[]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type AuthenticatedAuth = ResolvedAuth & { subject: string }
|
|
295
|
+
|
|
296
|
+
export interface ResolvedAuth {
|
|
297
|
+
/** undefined - not authenticated */
|
|
298
|
+
subject?: string
|
|
299
|
+
/** undefined - no permission set */
|
|
300
|
+
permissions?: string[]
|
|
301
|
+
/** Result of `isAlwaysAuthorized` */
|
|
302
|
+
alwaysAuthorized?: boolean
|
|
303
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './auth.service.js'
|
|
@@ -10,9 +10,34 @@ import type { BackendRequest } from './server.model.js'
|
|
|
10
10
|
*
|
|
11
11
|
* If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
|
|
12
12
|
* it will be stripped from the beginning of the path.
|
|
13
|
+
*
|
|
14
|
+
* The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
|
|
15
|
+
* (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
|
|
16
|
+
* `req.route.path` is the server-defined route pattern, so it's used as-is.
|
|
17
|
+
*
|
|
18
|
+
* Use `getRequestEndpointUnsafe` if the exact path is needed.
|
|
13
19
|
*/
|
|
14
20
|
export function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): string {
|
|
15
|
-
|
|
21
|
+
return buildRequestEndpoint(req, stripPrefix, sanitizePath)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Same as `getRequestEndpoint`, but without sanitization:
|
|
26
|
+
* for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
|
|
27
|
+
*
|
|
28
|
+
* Use at your own risk: the result must be escaped before it's embedded in html
|
|
29
|
+
* (or any other context where it could be interpreted).
|
|
30
|
+
*/
|
|
31
|
+
export function getRequestEndpointUnsafe(req: BackendRequest, stripPrefix?: string): string {
|
|
32
|
+
return buildRequestEndpoint(req, stripPrefix, identity)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildRequestEndpoint(
|
|
36
|
+
req: BackendRequest,
|
|
37
|
+
stripPrefix: string | undefined,
|
|
38
|
+
sanitize: (path: string) => string,
|
|
39
|
+
): string {
|
|
40
|
+
let path = (sanitize(req.baseUrl) + (req.route?.path || sanitize(req.path))).toLowerCase()
|
|
16
41
|
if (path.length > 1 && path.endsWith('/')) {
|
|
17
42
|
path = path.slice(0, path.length - 1)
|
|
18
43
|
}
|
|
@@ -23,3 +48,14 @@ export function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): s
|
|
|
23
48
|
|
|
24
49
|
return [req.method, path].join(' ')
|
|
25
50
|
}
|
|
51
|
+
|
|
52
|
+
// Letters, digits and the url punctuation that is inert in html, js, json and logs.
|
|
53
|
+
// Everything else (< > & " ' ( ) ` \ control chars, etc) is replaced, not encoded:
|
|
54
|
+
// the exact bytes are not worth preserving here, `req.url` still has them.
|
|
55
|
+
const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu
|
|
56
|
+
|
|
57
|
+
function sanitizePath(path: string): string {
|
|
58
|
+
return path.replace(UNSAFE_PATH_CHAR, '_')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const identity = (path: string): string => path
|
|
@@ -21,6 +21,14 @@ export interface RequestTimeoutMiddlewareCfg {
|
|
|
21
21
|
* @default 'Request timed out'
|
|
22
22
|
*/
|
|
23
23
|
httpErrorMessage?: string
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Upper bound for the `?requestTimeout=N` query override.
|
|
27
|
+
* Higher values are clamped to it, so a client cannot hold a request open for arbitrarily long.
|
|
28
|
+
*
|
|
29
|
+
* @default 600
|
|
30
|
+
*/
|
|
31
|
+
maxTimeoutSeconds?: NumberOfSeconds
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
const code = 'REQUEST_TIMEOUT'
|
|
@@ -33,6 +41,7 @@ export function requestTimeoutMiddleware(
|
|
|
33
41
|
timeoutSeconds: defTimeoutSeconds,
|
|
34
42
|
backendResponseStatusCode,
|
|
35
43
|
httpErrorMessage,
|
|
44
|
+
maxTimeoutSeconds,
|
|
36
45
|
} = {
|
|
37
46
|
// Considerations about the default value of the timeout.
|
|
38
47
|
// Ideally the default value here would be HIGHER than the default timeout for getGot (in nodejs-lib),
|
|
@@ -41,13 +50,12 @@ export function requestTimeoutMiddleware(
|
|
|
41
50
|
timeoutSeconds: 120,
|
|
42
51
|
backendResponseStatusCode: 503,
|
|
43
52
|
httpErrorMessage: 'Request timed out',
|
|
53
|
+
maxTimeoutSeconds: 600,
|
|
44
54
|
...cfg,
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
return function requestTimeoutHandler(req, res, next) {
|
|
48
|
-
const timeoutSeconds = req
|
|
49
|
-
? Number.parseInt(req.query[REQUEST_TIMEOUT_QUERY_KEY] as string)
|
|
50
|
-
: defTimeoutSeconds
|
|
58
|
+
const timeoutSeconds = getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds)
|
|
51
59
|
|
|
52
60
|
// If requestTimeout was previously set - cancel it first
|
|
53
61
|
// Then set the new requestTimeout and handler
|
|
@@ -76,6 +84,26 @@ export function requestTimeoutMiddleware(
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
/**
|
|
88
|
+
* `?requestTimeout=N` query param lets the client override the timeout of its request
|
|
89
|
+
* (used e.g by Cloud Tasks for long-running tasks).
|
|
90
|
+
* Being client-controlled, it is validated: non-numeric or non-positive values are ignored
|
|
91
|
+
* (fall back to the default), values above `maxTimeoutSeconds` are clamped.
|
|
92
|
+
*/
|
|
93
|
+
function getTimeoutSeconds(
|
|
94
|
+
req: BackendRequest,
|
|
95
|
+
defTimeoutSeconds: NumberOfSeconds,
|
|
96
|
+
maxTimeoutSeconds: NumberOfSeconds,
|
|
97
|
+
): NumberOfSeconds {
|
|
98
|
+
const raw = req.query[REQUEST_TIMEOUT_QUERY_KEY]
|
|
99
|
+
if (typeof raw !== 'string') return defTimeoutSeconds
|
|
100
|
+
|
|
101
|
+
const requested = Number.parseInt(raw, 10)
|
|
102
|
+
if (Number.isNaN(requested) || requested <= 0) return defTimeoutSeconds
|
|
103
|
+
|
|
104
|
+
return Math.min(requested, maxTimeoutSeconds)
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
export interface CustomRequestTimeoutMiddlewareCfg {
|
|
80
108
|
/**
|
|
81
109
|
* @default 120
|
|
@@ -87,7 +115,7 @@ export interface CustomRequestTimeoutMiddlewareCfg {
|
|
|
87
115
|
* Example:
|
|
88
116
|
*
|
|
89
117
|
* router.get('/', customRequestTimeoutMiddleware(
|
|
90
|
-
* (req, res) => res.status(409).send('my custom message!'),
|
|
118
|
+
* (req, res) => res.status(409).type('text/plain').send('my custom message!'),
|
|
91
119
|
* { timeoutSeconds: 30 },
|
|
92
120
|
* )
|
|
93
121
|
*/
|
|
@@ -5,11 +5,12 @@ import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js'
|
|
|
5
5
|
import { _percentile } from '@naturalcycles/js-lib/math/math.util.js'
|
|
6
6
|
import { NumberStack } from '@naturalcycles/js-lib/math/stack.util.js'
|
|
7
7
|
import { _get, _mapValues } from '@naturalcycles/js-lib/object/object.util.js'
|
|
8
|
+
import { htmlEscape } from '@naturalcycles/js-lib/string/escape.js'
|
|
8
9
|
import { _stringMapEntries, _stringMapValues } from '@naturalcycles/js-lib/types'
|
|
9
10
|
import type { StringMap } from '@naturalcycles/js-lib/types'
|
|
10
11
|
import { onFinished } from '../onFinished.js'
|
|
11
12
|
import { getRequestEndpoint } from './request.util.js'
|
|
12
|
-
import type { BackendRequestHandler } from './server.model.js'
|
|
13
|
+
import type { BackendRequest, BackendRequestHandler } from './server.model.js'
|
|
13
14
|
|
|
14
15
|
const { GAE_INSTANCE } = process.env
|
|
15
16
|
|
|
@@ -86,7 +87,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
|
|
|
86
87
|
}).map(([endpoint, stat]) => {
|
|
87
88
|
return [
|
|
88
89
|
'<tr>',
|
|
89
|
-
`<td><pre>${endpoint}</pre></td>`,
|
|
90
|
+
`<td><pre>${htmlEscape(endpoint)}</pre></td>`,
|
|
90
91
|
`<td align="right"><pre>${stat.total}</pre></td>`,
|
|
91
92
|
// oxlint-disable-next-line @typescript-eslint/no-base-to-string
|
|
92
93
|
...families.map(f => `<td align="right"><pre>${stat[f]}</pre></td>`),
|
|
@@ -97,7 +98,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
|
|
|
97
98
|
'</table>',
|
|
98
99
|
].join('\n')
|
|
99
100
|
|
|
100
|
-
res.send(html)
|
|
101
|
+
res.type('html').send(html)
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
|
@@ -113,7 +114,12 @@ export function serverStatsMiddleware(): BackendRequestHandler {
|
|
|
113
114
|
const now = Date.now()
|
|
114
115
|
const latency = now - started
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
// Only requests that matched a route are recorded by their route pattern.
|
|
118
|
+
// Unmatched requests (404s, requests rejected by router-level middleware) carry an
|
|
119
|
+
// arbitrary client-controlled path: recording them per-path would let any client
|
|
120
|
+
// grow the map unboundedly and plant arbitrary strings into it.
|
|
121
|
+
// So they're bucketed per mount point instead.
|
|
122
|
+
const endpoint = req.route ? getRequestEndpoint(req) : getUnmatchedEndpoint(req)
|
|
117
123
|
|
|
118
124
|
serverStatsMap[endpoint] ||= {
|
|
119
125
|
stack: new NumberStack(SIZE),
|
|
@@ -137,6 +143,14 @@ export function serverStatsMiddleware(): BackendRequestHandler {
|
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Returns e.g `GET /*` or `GET /api/v3/admin/*`.
|
|
148
|
+
* `req.baseUrl` is the mount point that matched, so it's bounded by the app's routing config.
|
|
149
|
+
*/
|
|
150
|
+
function getUnmatchedEndpoint(req: BackendRequest): string {
|
|
151
|
+
return `${req.method} ${req.baseUrl.toLowerCase()}/*`
|
|
152
|
+
}
|
|
153
|
+
|
|
140
154
|
function cleanupServerStats(): void {
|
|
141
155
|
if (Object.keys(serverStatsMap).length <= MAX_ENDPOINTS) return
|
|
142
156
|
|