@naturalcycles/backend-lib 9.75.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/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 +1 -1
- package/src/admin/login.html +16 -1
- 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
|
@@ -9,5 +9,19 @@ import type { BackendRequest } from './server.model.js';
|
|
|
9
9
|
*
|
|
10
10
|
* If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
|
|
11
11
|
* it will be stripped from the beginning of the path.
|
|
12
|
+
*
|
|
13
|
+
* The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
|
|
14
|
+
* (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
|
|
15
|
+
* `req.route.path` is the server-defined route pattern, so it's used as-is.
|
|
16
|
+
*
|
|
17
|
+
* Use `getRequestEndpointUnsafe` if the exact path is needed.
|
|
12
18
|
*/
|
|
13
19
|
export declare function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Same as `getRequestEndpoint`, but without sanitization:
|
|
22
|
+
* for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
|
|
23
|
+
*
|
|
24
|
+
* Use at your own risk: the result must be escaped before it's embedded in html
|
|
25
|
+
* (or any other context where it could be interpreted).
|
|
26
|
+
*/
|
|
27
|
+
export declare function getRequestEndpointUnsafe(req: BackendRequest, stripPrefix?: string): string;
|
|
@@ -8,9 +8,28 @@
|
|
|
8
8
|
*
|
|
9
9
|
* If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
|
|
10
10
|
* it will be stripped from the beginning of the path.
|
|
11
|
+
*
|
|
12
|
+
* The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
|
|
13
|
+
* (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
|
|
14
|
+
* `req.route.path` is the server-defined route pattern, so it's used as-is.
|
|
15
|
+
*
|
|
16
|
+
* Use `getRequestEndpointUnsafe` if the exact path is needed.
|
|
11
17
|
*/
|
|
12
18
|
export function getRequestEndpoint(req, stripPrefix) {
|
|
13
|
-
|
|
19
|
+
return buildRequestEndpoint(req, stripPrefix, sanitizePath);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Same as `getRequestEndpoint`, but without sanitization:
|
|
23
|
+
* for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
|
|
24
|
+
*
|
|
25
|
+
* Use at your own risk: the result must be escaped before it's embedded in html
|
|
26
|
+
* (or any other context where it could be interpreted).
|
|
27
|
+
*/
|
|
28
|
+
export function getRequestEndpointUnsafe(req, stripPrefix) {
|
|
29
|
+
return buildRequestEndpoint(req, stripPrefix, identity);
|
|
30
|
+
}
|
|
31
|
+
function buildRequestEndpoint(req, stripPrefix, sanitize) {
|
|
32
|
+
let path = (sanitize(req.baseUrl) + (req.route?.path || sanitize(req.path))).toLowerCase();
|
|
14
33
|
if (path.length > 1 && path.endsWith('/')) {
|
|
15
34
|
path = path.slice(0, path.length - 1);
|
|
16
35
|
}
|
|
@@ -19,3 +38,11 @@ export function getRequestEndpoint(req, stripPrefix) {
|
|
|
19
38
|
}
|
|
20
39
|
return [req.method, path].join(' ');
|
|
21
40
|
}
|
|
41
|
+
// Letters, digits and the url punctuation that is inert in html, js, json and logs.
|
|
42
|
+
// Everything else (< > & " ' ( ) ` \ control chars, etc) is replaced, not encoded:
|
|
43
|
+
// the exact bytes are not worth preserving here, `req.url` still has them.
|
|
44
|
+
const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu;
|
|
45
|
+
function sanitizePath(path) {
|
|
46
|
+
return path.replace(UNSAFE_PATH_CHAR, '_');
|
|
47
|
+
}
|
|
48
|
+
const identity = (path) => path;
|
|
@@ -13,6 +13,13 @@ export interface RequestTimeoutMiddlewareCfg {
|
|
|
13
13
|
* @default 'Request timed out'
|
|
14
14
|
*/
|
|
15
15
|
httpErrorMessage?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Upper bound for the `?requestTimeout=N` query override.
|
|
18
|
+
* Higher values are clamped to it, so a client cannot hold a request open for arbitrarily long.
|
|
19
|
+
*
|
|
20
|
+
* @default 600
|
|
21
|
+
*/
|
|
22
|
+
maxTimeoutSeconds?: NumberOfSeconds;
|
|
16
23
|
}
|
|
17
24
|
export declare function requestTimeoutMiddleware(cfg?: RequestTimeoutMiddlewareCfg): BackendRequestHandler;
|
|
18
25
|
export interface CustomRequestTimeoutMiddlewareCfg {
|
|
@@ -25,7 +32,7 @@ export interface CustomRequestTimeoutMiddlewareCfg {
|
|
|
25
32
|
* Example:
|
|
26
33
|
*
|
|
27
34
|
* router.get('/', customRequestTimeoutMiddleware(
|
|
28
|
-
* (req, res) => res.status(409).send('my custom message!'),
|
|
35
|
+
* (req, res) => res.status(409).type('text/plain').send('my custom message!'),
|
|
29
36
|
* { timeoutSeconds: 30 },
|
|
30
37
|
* )
|
|
31
38
|
*/
|
|
@@ -6,7 +6,7 @@ import { getRequestEndpoint } from './request.util.js';
|
|
|
6
6
|
const code = 'REQUEST_TIMEOUT';
|
|
7
7
|
const REQUEST_TIMEOUT_QUERY_KEY = 'requestTimeout';
|
|
8
8
|
export function requestTimeoutMiddleware(cfg = {}) {
|
|
9
|
-
const { timeoutSeconds: defTimeoutSeconds, backendResponseStatusCode, httpErrorMessage, } = {
|
|
9
|
+
const { timeoutSeconds: defTimeoutSeconds, backendResponseStatusCode, httpErrorMessage, maxTimeoutSeconds, } = {
|
|
10
10
|
// Considerations about the default value of the timeout.
|
|
11
11
|
// Ideally the default value here would be HIGHER than the default timeout for getGot (in nodejs-lib),
|
|
12
12
|
// so, cross-service communication has a chance to fail SOONER than server times out,
|
|
@@ -14,12 +14,11 @@ export function requestTimeoutMiddleware(cfg = {}) {
|
|
|
14
14
|
timeoutSeconds: 120,
|
|
15
15
|
backendResponseStatusCode: 503,
|
|
16
16
|
httpErrorMessage: 'Request timed out',
|
|
17
|
+
maxTimeoutSeconds: 600,
|
|
17
18
|
...cfg,
|
|
18
19
|
};
|
|
19
20
|
return function requestTimeoutHandler(req, res, next) {
|
|
20
|
-
const timeoutSeconds = req
|
|
21
|
-
? Number.parseInt(req.query[REQUEST_TIMEOUT_QUERY_KEY])
|
|
22
|
-
: defTimeoutSeconds;
|
|
21
|
+
const timeoutSeconds = getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds);
|
|
23
22
|
// If requestTimeout was previously set - cancel it first
|
|
24
23
|
// Then set the new requestTimeout and handler
|
|
25
24
|
if (req.requestTimeout)
|
|
@@ -39,11 +38,26 @@ export function requestTimeoutMiddleware(cfg = {}) {
|
|
|
39
38
|
next();
|
|
40
39
|
};
|
|
41
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* `?requestTimeout=N` query param lets the client override the timeout of its request
|
|
43
|
+
* (used e.g by Cloud Tasks for long-running tasks).
|
|
44
|
+
* Being client-controlled, it is validated: non-numeric or non-positive values are ignored
|
|
45
|
+
* (fall back to the default), values above `maxTimeoutSeconds` are clamped.
|
|
46
|
+
*/
|
|
47
|
+
function getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds) {
|
|
48
|
+
const raw = req.query[REQUEST_TIMEOUT_QUERY_KEY];
|
|
49
|
+
if (typeof raw !== 'string')
|
|
50
|
+
return defTimeoutSeconds;
|
|
51
|
+
const requested = Number.parseInt(raw, 10);
|
|
52
|
+
if (Number.isNaN(requested) || requested <= 0)
|
|
53
|
+
return defTimeoutSeconds;
|
|
54
|
+
return Math.min(requested, maxTimeoutSeconds);
|
|
55
|
+
}
|
|
42
56
|
/**
|
|
43
57
|
* Example:
|
|
44
58
|
*
|
|
45
59
|
* router.get('/', customRequestTimeoutMiddleware(
|
|
46
|
-
* (req, res) => res.status(409).send('my custom message!'),
|
|
60
|
+
* (req, res) => res.status(409).type('text/plain').send('my custom message!'),
|
|
47
61
|
* { timeoutSeconds: 30 },
|
|
48
62
|
* )
|
|
49
63
|
*/
|
|
@@ -5,6 +5,7 @@ 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 { onFinished } from '../onFinished.js';
|
|
10
11
|
import { getRequestEndpoint } from './request.util.js';
|
|
@@ -61,7 +62,7 @@ export const serverStatsHTMLHandler = (req, res) => {
|
|
|
61
62
|
}).map(([endpoint, stat]) => {
|
|
62
63
|
return [
|
|
63
64
|
'<tr>',
|
|
64
|
-
`<td><pre>${endpoint}</pre></td>`,
|
|
65
|
+
`<td><pre>${htmlEscape(endpoint)}</pre></td>`,
|
|
65
66
|
`<td align="right"><pre>${stat.total}</pre></td>`,
|
|
66
67
|
// oxlint-disable-next-line @typescript-eslint/no-base-to-string
|
|
67
68
|
...families.map(f => `<td align="right"><pre>${stat[f]}</pre></td>`),
|
|
@@ -71,7 +72,7 @@ export const serverStatsHTMLHandler = (req, res) => {
|
|
|
71
72
|
}),
|
|
72
73
|
'</table>',
|
|
73
74
|
].join('\n');
|
|
74
|
-
res.send(html);
|
|
75
|
+
res.type('html').send(html);
|
|
75
76
|
};
|
|
76
77
|
/**
|
|
77
78
|
* Unlocks serverStatsHTMLHandler
|
|
@@ -83,7 +84,12 @@ export function serverStatsMiddleware() {
|
|
|
83
84
|
onFinished(res, () => {
|
|
84
85
|
const now = Date.now();
|
|
85
86
|
const latency = now - started;
|
|
86
|
-
|
|
87
|
+
// Only requests that matched a route are recorded by their route pattern.
|
|
88
|
+
// Unmatched requests (404s, requests rejected by router-level middleware) carry an
|
|
89
|
+
// arbitrary client-controlled path: recording them per-path would let any client
|
|
90
|
+
// grow the map unboundedly and plant arbitrary strings into it.
|
|
91
|
+
// So they're bucketed per mount point instead.
|
|
92
|
+
const endpoint = req.route ? getRequestEndpoint(req) : getUnmatchedEndpoint(req);
|
|
87
93
|
serverStatsMap[endpoint] ||= {
|
|
88
94
|
stack: new NumberStack(SIZE),
|
|
89
95
|
'2xx': 0,
|
|
@@ -102,6 +108,13 @@ export function serverStatsMiddleware() {
|
|
|
102
108
|
next();
|
|
103
109
|
};
|
|
104
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns e.g `GET /*` or `GET /api/v3/admin/*`.
|
|
113
|
+
* `req.baseUrl` is the mount point that matched, so it's bounded by the app's routing config.
|
|
114
|
+
*/
|
|
115
|
+
function getUnmatchedEndpoint(req) {
|
|
116
|
+
return `${req.method} ${req.baseUrl.toLowerCase()}/*`;
|
|
117
|
+
}
|
|
105
118
|
function cleanupServerStats() {
|
|
106
119
|
if (Object.keys(serverStatsMap).length <= MAX_ENDPOINTS)
|
|
107
120
|
return;
|
package/package.json
CHANGED
package/src/admin/login.html
CHANGED
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
const qs = Object.fromEntries(new URLSearchParams(location.search))
|
|
66
|
-
const { autoLogin, logout,
|
|
66
|
+
const { autoLogin, logout, adminTokenKey = 'admin_token' } = qs
|
|
67
|
+
const returnUrl = toSafeReturnUrl(qs.returnUrl)
|
|
67
68
|
// console.log(qs)
|
|
68
69
|
|
|
69
70
|
const app = createApp({
|
|
@@ -137,6 +138,20 @@
|
|
|
137
138
|
alert('Error\n ' + JSON.stringify(err, null, 2))
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* `returnUrl` is client-controlled. Only http(s) urls are allowed:
|
|
143
|
+
* anything else (e.g `javascript:`) would otherwise be executed by `location.href = returnUrl`
|
|
144
|
+
* in the context of this page, where the token is exposed.
|
|
145
|
+
*/
|
|
146
|
+
function toSafeReturnUrl(url) {
|
|
147
|
+
if (!url) return
|
|
148
|
+
try {
|
|
149
|
+
const { protocol } = new URL(url, location.href)
|
|
150
|
+
if (protocol === 'http:' || protocol === 'https:') return url
|
|
151
|
+
} catch {}
|
|
152
|
+
console.warn('ignoring invalid returnUrl', url)
|
|
153
|
+
}
|
|
154
|
+
|
|
140
155
|
async function postToken(token) {
|
|
141
156
|
await fetch(`/admin/login`, {
|
|
142
157
|
method: 'post',
|
|
@@ -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
|
|