@thi.ng/server 0.2.0 → 0.4.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/CHANGELOG.md +38 -1
- package/README.md +9 -4
- package/api.d.ts +13 -7
- package/index.d.ts +3 -0
- package/index.js +3 -0
- package/interceptors/auth-route.js +1 -1
- package/interceptors/logging.js +3 -5
- package/interceptors/measure.d.ts +9 -0
- package/interceptors/measure.js +27 -0
- package/interceptors/rate-limit.d.ts +73 -0
- package/interceptors/rate-limit.js +82 -0
- package/package.json +15 -5
- package/server.d.ts +26 -8
- package/server.js +73 -38
- package/session/session.d.ts +36 -10
- package/session/session.js +69 -15
- package/static.js +2 -2
- package/utils/host.d.ts +42 -0
- package/utils/host.js +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
- **Last updated**: 2025-
|
|
3
|
+
- **Last updated**: 2025-02-10T21:44:04Z
|
|
4
4
|
- **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
|
|
5
5
|
|
|
6
6
|
All notable changes to this project will be documented in this file.
|
|
@@ -11,6 +11,43 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
|
|
|
11
11
|
**Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
|
|
12
12
|
and/or version bumps of transitive dependencies.
|
|
13
13
|
|
|
14
|
+
## [0.4.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.4.0) (2025-02-10)
|
|
15
|
+
|
|
16
|
+
#### 🚀 Features
|
|
17
|
+
|
|
18
|
+
- update ServerSession & interceptor ([71d26bb](https://github.com/thi-ng/umbrella/commit/71d26bb))
|
|
19
|
+
- add `ServerSession.ip`
|
|
20
|
+
- update `SessionInterceptor` to validate stored IP addr
|
|
21
|
+
- rename `.delete()` => `.deleteSession()`
|
|
22
|
+
- add `.replaceSession()`
|
|
23
|
+
- remove obsolete `FlashMsg` (for now)
|
|
24
|
+
- update ServerResponse, update host matching ([25a07f3](https://github.com/thi-ng/umbrella/commit/25a07f3))
|
|
25
|
+
- update `isMatchingHost()`
|
|
26
|
+
- add `ServerResponse.rateLimit()` and `.noResponse()`
|
|
27
|
+
- update tests
|
|
28
|
+
- update SessionInterceptor to create signed cookie ([d240107](https://github.com/thi-ng/umbrella/commit/d240107))
|
|
29
|
+
- add `SessionOpts.secret`
|
|
30
|
+
- sign session ID with salt & SHA256
|
|
31
|
+
- add validateSession()
|
|
32
|
+
- update pre() interceptor
|
|
33
|
+
- add `rateLimiter()` interceptor ([245cc9d](https://github.com/thi-ng/umbrella/commit/245cc9d))
|
|
34
|
+
- add `measure()` interceptor ([4702e84](https://github.com/thi-ng/umbrella/commit/4702e84))
|
|
35
|
+
- refactor logRequest/Response() interceptors
|
|
36
|
+
|
|
37
|
+
#### ⏱ Performance improvements
|
|
38
|
+
|
|
39
|
+
- update Server 404 & OPTIONS handling, remove method override ([71307af](https://github.com/thi-ng/umbrella/commit/71307af))
|
|
40
|
+
- process 404 asap (without full request ctx)
|
|
41
|
+
- process default HTTP OPTIONS handler asap
|
|
42
|
+
- in both cases no interceptors will be run anymore
|
|
43
|
+
|
|
44
|
+
## [0.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.3.0) (2025-02-02)
|
|
45
|
+
|
|
46
|
+
#### 🚀 Features
|
|
47
|
+
|
|
48
|
+
- add more HTTP error response methods ([5731ff3](https://github.com/thi-ng/umbrella/commit/5731ff3))
|
|
49
|
+
- add ServerResponse, IPv6 support ([22f64c5](https://github.com/thi-ng/umbrella/commit/22f64c5))
|
|
50
|
+
|
|
14
51
|
## [0.2.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.2.0) (2025-01-30)
|
|
15
52
|
|
|
16
53
|
#### 🚀 Features
|
package/README.md
CHANGED
|
@@ -75,8 +75,10 @@ for more details.
|
|
|
75
75
|
- [`crossOriginOpenerPolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginOpenerPolicy-1.html): Policy header injection
|
|
76
76
|
- [`crossOriginResourcePolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginResourcePolicy-1.html): Policy header injection
|
|
77
77
|
- [`injectHeaders()`](https://docs.thi.ng/umbrella/server/functions/injectHeaders.html): Arbitrary header injection
|
|
78
|
+
- [`measure()`](https://docs.thi.ng/umbrella/server/functions/measure.html): Request process timing info
|
|
78
79
|
- [`logRequest()`](https://docs.thi.ng/umbrella/server/functions/logRequest.html): Request detail logging
|
|
79
80
|
- [`logResponse()`](https://docs.thi.ng/umbrella/server/functions/logResponse.html): Response logging
|
|
81
|
+
- [`rateLimiter()`](https://docs.thi.ng/umbrella/server/functions/rateLimiter-1.html): Configurable rate limiting
|
|
80
82
|
- [`referrerPolicy()`](https://docs.thi.ng/umbrella/server/functions/referrerPolicy-1.html): Policy header injection
|
|
81
83
|
- [`serverSession()`](https://docs.thi.ng/umbrella/server/functions/serverSession-1.html): User defined in-memory sessions with TTL
|
|
82
84
|
- [`strictTransportSecurity()`](https://docs.thi.ng/umbrella/server/functions/strictTransportSecurity.html): Policy header injection
|
|
@@ -147,7 +149,7 @@ For Node.js REPL:
|
|
|
147
149
|
const ser = await import("@thi.ng/server");
|
|
148
150
|
```
|
|
149
151
|
|
|
150
|
-
Package sizes (brotli'd, pre-treeshake): ESM:
|
|
152
|
+
Package sizes (brotli'd, pre-treeshake): ESM: 5.24 KB
|
|
151
153
|
|
|
152
154
|
## Dependencies
|
|
153
155
|
|
|
@@ -162,6 +164,7 @@ Package sizes (brotli'd, pre-treeshake): ESM: 4.02 KB
|
|
|
162
164
|
- [@thi.ng/paths](https://github.com/thi-ng/umbrella/tree/develop/packages/paths)
|
|
163
165
|
- [@thi.ng/router](https://github.com/thi-ng/umbrella/tree/develop/packages/router)
|
|
164
166
|
- [@thi.ng/strings](https://github.com/thi-ng/umbrella/tree/develop/packages/strings)
|
|
167
|
+
- [@thi.ng/timestamp](https://github.com/thi-ng/umbrella/tree/develop/packages/timestamp)
|
|
165
168
|
- [@thi.ng/uuid](https://github.com/thi-ng/umbrella/tree/develop/packages/uuid)
|
|
166
169
|
|
|
167
170
|
Note: @thi.ng/api is in _most_ cases a type-only import (not used at runtime)
|
|
@@ -229,10 +232,12 @@ const app = srv.server<AppCtx>({
|
|
|
229
232
|
const { user, pass } = await srv.parseRequestFormData(ctx.req);
|
|
230
233
|
ctx.logger.info("login details", user, pass);
|
|
231
234
|
if (user === "thi.ng" && pass === "1234") {
|
|
232
|
-
|
|
235
|
+
// create new session for security reasons (session fixation)
|
|
236
|
+
const newSession = await session.replaceSession(ctx)!;
|
|
237
|
+
newSession!.user = user;
|
|
233
238
|
ctx.res.writeHead(200).end("logged in as " + user);
|
|
234
239
|
} else {
|
|
235
|
-
ctx.res.
|
|
240
|
+
ctx.res.unauthorized({}, "login failed");
|
|
236
241
|
}
|
|
237
242
|
},
|
|
238
243
|
},
|
|
@@ -246,7 +251,7 @@ const app = srv.server<AppCtx>({
|
|
|
246
251
|
handlers: {
|
|
247
252
|
get: async (ctx) => {
|
|
248
253
|
// remove session & force expire session cookie
|
|
249
|
-
await session.
|
|
254
|
+
await session.deleteSession(ctx, ctx.session!.id);
|
|
250
255
|
ctx.res.writeHead(200).end("logged out");
|
|
251
256
|
},
|
|
252
257
|
},
|
package/api.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Fn, Maybe, MaybePromise } from "@thi.ng/api";
|
|
2
2
|
import type { ILogger } from "@thi.ng/logger";
|
|
3
3
|
import type { Route, RouteMatch } from "@thi.ng/router";
|
|
4
|
-
import type { IncomingMessage
|
|
5
|
-
import type { Server } from "./server.js";
|
|
4
|
+
import type { IncomingMessage } from "node:http";
|
|
5
|
+
import type { ServerResponse, Server } from "./server.js";
|
|
6
6
|
export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
|
|
7
7
|
export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
|
|
8
8
|
logger: ILogger;
|
|
@@ -119,12 +119,18 @@ export interface Interceptor<CTX extends RequestCtx = RequestCtx> {
|
|
|
119
119
|
post?: Fn<CTX, InterceptorResult>;
|
|
120
120
|
}
|
|
121
121
|
export interface ServerSession {
|
|
122
|
+
/**
|
|
123
|
+
* Unique session ID
|
|
124
|
+
*/
|
|
122
125
|
id: string;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Client's remote IP address when session was originally created. To
|
|
128
|
+
* counteract session fixation, each request's remote address is being
|
|
129
|
+
* checked (by {@link SessionInterceptor.pre}) against this stored address.
|
|
130
|
+
* If there's a mismatch between the two, then a new session will be
|
|
131
|
+
* generated automatically.
|
|
132
|
+
*/
|
|
133
|
+
ip: string;
|
|
128
134
|
}
|
|
129
135
|
export interface ISessionStore<T extends ServerSession = ServerSession> {
|
|
130
136
|
/**
|
package/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./interceptors/auth-route.js";
|
|
|
5
5
|
export * from "./interceptors/cache-control.js";
|
|
6
6
|
export * from "./interceptors/inject-headers.js";
|
|
7
7
|
export * from "./interceptors/logging.js";
|
|
8
|
+
export * from "./interceptors/measure.js";
|
|
9
|
+
export * from "./interceptors/rate-limit.js";
|
|
8
10
|
export * from "./interceptors/referrer-policy.js";
|
|
9
11
|
export * from "./interceptors/strict-transport.js";
|
|
10
12
|
export * from "./interceptors/x-origin-opener.js";
|
|
@@ -13,6 +15,7 @@ export * from "./session/session.js";
|
|
|
13
15
|
export * from "./session/memory.js";
|
|
14
16
|
export * from "./utils/cookies.js";
|
|
15
17
|
export * from "./utils/cache.js";
|
|
18
|
+
export * from "./utils/host.js";
|
|
16
19
|
export * from "./utils/formdata.js";
|
|
17
20
|
export * from "./utils/multipart.js";
|
|
18
21
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./interceptors/auth-route.js";
|
|
|
5
5
|
export * from "./interceptors/cache-control.js";
|
|
6
6
|
export * from "./interceptors/inject-headers.js";
|
|
7
7
|
export * from "./interceptors/logging.js";
|
|
8
|
+
export * from "./interceptors/measure.js";
|
|
9
|
+
export * from "./interceptors/rate-limit.js";
|
|
8
10
|
export * from "./interceptors/referrer-policy.js";
|
|
9
11
|
export * from "./interceptors/strict-transport.js";
|
|
10
12
|
export * from "./interceptors/x-origin-opener.js";
|
|
@@ -13,5 +15,6 @@ export * from "./session/session.js";
|
|
|
13
15
|
export * from "./session/memory.js";
|
|
14
16
|
export * from "./utils/cookies.js";
|
|
15
17
|
export * from "./utils/cache.js";
|
|
18
|
+
export * from "./utils/host.js";
|
|
16
19
|
export * from "./utils/formdata.js";
|
|
17
20
|
export * from "./utils/multipart.js";
|
package/interceptors/logging.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { LogLevel } from "@thi.ng/logger";
|
|
3
|
-
const __method = (level) => (isString(level) ? level : LogLevel[level]).toLowerCase();
|
|
1
|
+
import { LogLevel, methodForLevel } from "@thi.ng/logger";
|
|
4
2
|
const logRequest = (level = "INFO") => {
|
|
5
|
-
const method =
|
|
3
|
+
const method = methodForLevel(level);
|
|
6
4
|
return {
|
|
7
5
|
pre: ({ logger, req, match, query, cookies }) => {
|
|
8
6
|
logger[method]("request route", req.method, match);
|
|
@@ -14,7 +12,7 @@ const logRequest = (level = "INFO") => {
|
|
|
14
12
|
};
|
|
15
13
|
};
|
|
16
14
|
const logResponse = (level = "INFO") => {
|
|
17
|
-
const method =
|
|
15
|
+
const method = methodForLevel(level);
|
|
18
16
|
return {
|
|
19
17
|
post: ({ logger, match, res }) => {
|
|
20
18
|
logger[method]("response status", res.statusCode, match);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type LogLevel, type LogLevelName } from "@thi.ng/logger";
|
|
2
|
+
import type { Interceptor } from "../api.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pre/post interceptor to measure & log a request's processing time.
|
|
5
|
+
*
|
|
6
|
+
* @param level
|
|
7
|
+
*/
|
|
8
|
+
export declare const measure: (level?: LogLevel | LogLevelName) => Interceptor;
|
|
9
|
+
//# sourceMappingURL=measure.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
methodForLevel
|
|
3
|
+
} from "@thi.ng/logger";
|
|
4
|
+
import { now, timeDiff } from "@thi.ng/timestamp";
|
|
5
|
+
const measure = (level = "DEBUG") => {
|
|
6
|
+
const requests = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
const method = methodForLevel(level);
|
|
8
|
+
return {
|
|
9
|
+
pre: (ctx) => {
|
|
10
|
+
requests.set(ctx, now());
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
post: (ctx) => {
|
|
14
|
+
const t0 = requests.get(ctx);
|
|
15
|
+
if (t0) {
|
|
16
|
+
requests.delete(ctx);
|
|
17
|
+
ctx.logger[method](
|
|
18
|
+
`request processed in: ${timeDiff(t0).toFixed(3)}ms`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export {
|
|
26
|
+
measure
|
|
27
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Fn, Fn2, Maybe } from "@thi.ng/api";
|
|
2
|
+
import { TLRUCache } from "@thi.ng/cache";
|
|
3
|
+
import type { Interceptor, RequestCtx } from "../api.js";
|
|
4
|
+
export interface RateLimitOpts<T extends RequestCtx = RequestCtx> {
|
|
5
|
+
/**
|
|
6
|
+
* Function to produce a unique ID for rate limiting a client. By default
|
|
7
|
+
* uses client's remote IP address. If this function returns undefined, the
|
|
8
|
+
* client will NOT be rate limited.
|
|
9
|
+
*/
|
|
10
|
+
id?: Fn<T, Maybe<string>>;
|
|
11
|
+
/**
|
|
12
|
+
* Function to compute the max number of allowed requests for given client
|
|
13
|
+
* ID and configured {@link RateLimitOpts.period}. If given as number, that
|
|
14
|
+
* limit will be enforced for all identified clients.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Quotas are only computed whenever a new client ID is first encountered or
|
|
18
|
+
* when its previous request records have expired from the cache (after
|
|
19
|
+
* {@link RateLimitOpts.period} seconds).
|
|
20
|
+
*/
|
|
21
|
+
quota: number | Fn2<T, string, number>;
|
|
22
|
+
/**
|
|
23
|
+
* Size of the time window (in secords) to which this rate limiter applies.
|
|
24
|
+
*
|
|
25
|
+
* @defaultValue 900
|
|
26
|
+
*/
|
|
27
|
+
period?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a new {@link RateLimiter} interceptor.
|
|
31
|
+
*
|
|
32
|
+
* @param opts
|
|
33
|
+
*/
|
|
34
|
+
export declare const rateLimiter: <T extends RequestCtx = RequestCtx>(opts: RateLimitOpts<T>) => RateLimiter<T>;
|
|
35
|
+
/**
|
|
36
|
+
* Configurable, sliding time window-based rate limiter interceptor.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RateLimiter<T extends RequestCtx = RequestCtx> implements Interceptor<T> {
|
|
39
|
+
cache: TLRUCache<string, Timestamps>;
|
|
40
|
+
clientQuota: Fn2<T, string, number>;
|
|
41
|
+
clientID: Fn<T, Maybe<string>>;
|
|
42
|
+
period: number;
|
|
43
|
+
constructor({ id, quota, period }: RateLimitOpts<T>);
|
|
44
|
+
pre(ctx: T): boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Ring-buffer based history of request timestamps (per client).
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* Based on thi.ng/buffers `FIFOBuffer` implementation.
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
declare class Timestamps {
|
|
55
|
+
quota: number;
|
|
56
|
+
buf: number[];
|
|
57
|
+
rpos: number;
|
|
58
|
+
wpos: number;
|
|
59
|
+
num: number;
|
|
60
|
+
constructor(quota: number);
|
|
61
|
+
/**
|
|
62
|
+
* Removes any timestamps older than `threshold` from the buffer and returns
|
|
63
|
+
* remaining number of timestamps.
|
|
64
|
+
*
|
|
65
|
+
* @param threshold
|
|
66
|
+
*/
|
|
67
|
+
expire(threshold: number): number;
|
|
68
|
+
peek(): number;
|
|
69
|
+
writable(): boolean;
|
|
70
|
+
write(x: number): boolean;
|
|
71
|
+
}
|
|
72
|
+
export {};
|
|
73
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { TLRUCache } from "@thi.ng/cache";
|
|
2
|
+
import { isNumber } from "@thi.ng/checks";
|
|
3
|
+
const rateLimiter = (opts) => new RateLimiter(opts);
|
|
4
|
+
class RateLimiter {
|
|
5
|
+
cache;
|
|
6
|
+
clientQuota;
|
|
7
|
+
clientID;
|
|
8
|
+
period;
|
|
9
|
+
constructor({ id, quota, period = 15 * 60 }) {
|
|
10
|
+
this.clientID = id ?? ((ctx) => ctx.req.socket.remoteAddress);
|
|
11
|
+
this.clientQuota = isNumber(quota) ? () => quota : quota;
|
|
12
|
+
this.period = (period ?? 15 * 60) * 1e3;
|
|
13
|
+
this.cache = new TLRUCache(null, {
|
|
14
|
+
ttl: this.period,
|
|
15
|
+
autoExtend: true
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
pre(ctx) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const id = this.clientID(ctx);
|
|
21
|
+
if (!id) return true;
|
|
22
|
+
let timestamps = this.cache.get(id);
|
|
23
|
+
if (timestamps) {
|
|
24
|
+
if (timestamps.expire(now - this.period) >= timestamps.quota) {
|
|
25
|
+
ctx.res.rateLimit(
|
|
26
|
+
{},
|
|
27
|
+
`Try again @ ${new Date(
|
|
28
|
+
timestamps.peek() + this.period
|
|
29
|
+
).toISOString()}`
|
|
30
|
+
);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
timestamps = new Timestamps(this.clientQuota(ctx, id));
|
|
35
|
+
this.cache.set(id, timestamps);
|
|
36
|
+
}
|
|
37
|
+
timestamps.write(now);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
class Timestamps {
|
|
42
|
+
constructor(quota) {
|
|
43
|
+
this.quota = quota;
|
|
44
|
+
}
|
|
45
|
+
buf = [];
|
|
46
|
+
rpos = 0;
|
|
47
|
+
wpos = 0;
|
|
48
|
+
num = 0;
|
|
49
|
+
/**
|
|
50
|
+
* Removes any timestamps older than `threshold` from the buffer and returns
|
|
51
|
+
* remaining number of timestamps.
|
|
52
|
+
*
|
|
53
|
+
* @param threshold
|
|
54
|
+
*/
|
|
55
|
+
expire(threshold) {
|
|
56
|
+
let { buf, rpos, num } = this;
|
|
57
|
+
const max = buf.length;
|
|
58
|
+
while (num && buf[rpos] < threshold) {
|
|
59
|
+
rpos = (rpos + 1) % max;
|
|
60
|
+
num--;
|
|
61
|
+
}
|
|
62
|
+
this.rpos = rpos;
|
|
63
|
+
return this.num = num;
|
|
64
|
+
}
|
|
65
|
+
peek() {
|
|
66
|
+
return this.buf[this.rpos];
|
|
67
|
+
}
|
|
68
|
+
writable() {
|
|
69
|
+
return this.num < this.quota;
|
|
70
|
+
}
|
|
71
|
+
write(x) {
|
|
72
|
+
const { buf, wpos } = this;
|
|
73
|
+
buf[wpos] = x;
|
|
74
|
+
this.wpos = (wpos + 1) % this.quota;
|
|
75
|
+
this.num++;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
RateLimiter,
|
|
81
|
+
rateLimiter
|
|
82
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thi.ng/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
"@thi.ng/cache": "^2.3.22",
|
|
45
45
|
"@thi.ng/checks": "^3.6.22",
|
|
46
46
|
"@thi.ng/errors": "^2.5.25",
|
|
47
|
-
"@thi.ng/file-io": "^2.1.
|
|
48
|
-
"@thi.ng/logger": "^3.0
|
|
49
|
-
"@thi.ng/mime": "^2.7.
|
|
47
|
+
"@thi.ng/file-io": "^2.1.26",
|
|
48
|
+
"@thi.ng/logger": "^3.1.0",
|
|
49
|
+
"@thi.ng/mime": "^2.7.1",
|
|
50
50
|
"@thi.ng/paths": "^5.2.0",
|
|
51
51
|
"@thi.ng/router": "^4.1.17",
|
|
52
52
|
"@thi.ng/strings": "^3.9.4",
|
|
53
|
+
"@thi.ng/timestamp": "^1.1.4",
|
|
53
54
|
"@thi.ng/uuid": "^1.1.16"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
@@ -112,6 +113,12 @@
|
|
|
112
113
|
"./interceptors/logging": {
|
|
113
114
|
"default": "./interceptors/logging.js"
|
|
114
115
|
},
|
|
116
|
+
"./interceptors/measure": {
|
|
117
|
+
"default": "./interceptors/measure.js"
|
|
118
|
+
},
|
|
119
|
+
"./interceptors/rate-limit": {
|
|
120
|
+
"default": "./interceptors/rate-limit.js"
|
|
121
|
+
},
|
|
115
122
|
"./interceptors/referrer-policy": {
|
|
116
123
|
"default": "./interceptors/referrer-policy.js"
|
|
117
124
|
},
|
|
@@ -145,6 +152,9 @@
|
|
|
145
152
|
"./utils/formdata": {
|
|
146
153
|
"default": "./utils/formdata.js"
|
|
147
154
|
},
|
|
155
|
+
"./utils/host": {
|
|
156
|
+
"default": "./utils/host.js"
|
|
157
|
+
},
|
|
148
158
|
"./utils/multipart": {
|
|
149
159
|
"default": "./utils/multipart.js"
|
|
150
160
|
}
|
|
@@ -153,5 +163,5 @@
|
|
|
153
163
|
"status": "alpha",
|
|
154
164
|
"year": 2024
|
|
155
165
|
},
|
|
156
|
-
"gitHead": "
|
|
166
|
+
"gitHead": "dcc1dbfa6eae31ac65e12843987b94d4a7edc144\n"
|
|
157
167
|
}
|
package/server.d.ts
CHANGED
|
@@ -7,21 +7,39 @@ export declare class Server<CTX extends RequestCtx = RequestCtx> {
|
|
|
7
7
|
opts: Partial<ServerOpts<CTX>>;
|
|
8
8
|
logger: ILogger;
|
|
9
9
|
router: Router<CompiledServerRoute<CTX>>;
|
|
10
|
-
server: http.Server
|
|
10
|
+
server: http.Server<typeof http.IncomingMessage, typeof ServerResponse>;
|
|
11
|
+
host: string;
|
|
11
12
|
protected augmentCtx: Fn<RequestCtx, CTX>;
|
|
12
13
|
constructor(opts?: Partial<ServerOpts<CTX>>);
|
|
13
14
|
start(): Promise<boolean>;
|
|
14
15
|
stop(): Promise<boolean>;
|
|
15
|
-
protected listener(req: http.IncomingMessage, res:
|
|
16
|
+
protected listener(req: http.IncomingMessage, res: ServerResponse): Promise<void>;
|
|
16
17
|
protected runHandler({ fn, pre, post }: CompiledHandler, ctx: CTX): Promise<void>;
|
|
17
18
|
protected compileRoute(route: ServerRoute<CTX>): CompiledServerRoute<CTX>;
|
|
18
|
-
addRoutes(routes: ServerRoute[]): void;
|
|
19
|
+
addRoutes(routes: ServerRoute<CTX>[]): void;
|
|
19
20
|
sendFile({ req, res }: RequestCtx, path: string, headers?: http.OutgoingHttpHeaders, compress?: boolean): Promise<void>;
|
|
20
|
-
|
|
21
|
-
unmodified(res: http.ServerResponse): void;
|
|
22
|
-
missing(res: http.ServerResponse): void;
|
|
23
|
-
redirectTo(res: http.ServerResponse, location: string): void;
|
|
24
|
-
redirectToRoute(res: http.ServerResponse, route: RouteMatch): void;
|
|
21
|
+
redirectToRoute(res: ServerResponse, route: RouteMatch): void;
|
|
25
22
|
}
|
|
26
23
|
export declare const server: <CTX extends RequestCtx>(opts?: Partial<ServerOpts<CTX>>) => Server<CTX>;
|
|
24
|
+
/**
|
|
25
|
+
* Extended version of the default NodeJS ServerResponse with additional methods
|
|
26
|
+
* for various commonly used HTTP statuses/errors.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ServerResponse extends http.ServerResponse<http.IncomingMessage> {
|
|
29
|
+
noContent(headers?: http.OutgoingHttpHeaders): void;
|
|
30
|
+
redirectTo(location: string, headers?: http.OutgoingHttpHeaders): void;
|
|
31
|
+
seeOther(location: string, headers?: http.OutgoingHttpHeaders): void;
|
|
32
|
+
unmodified(headers?: http.OutgoingHttpHeaders): void;
|
|
33
|
+
unauthorized(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
34
|
+
forbidden(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
35
|
+
missing(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
36
|
+
notAllowed(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
37
|
+
notAcceptable(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
38
|
+
rateLimit(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
39
|
+
/**
|
|
40
|
+
* HTTP 444. Indicates the server has returned no information to the client and closed
|
|
41
|
+
* the connection (useful as a deterrent for malware)
|
|
42
|
+
*/
|
|
43
|
+
noResponse(): void;
|
|
44
|
+
}
|
|
27
45
|
//# sourceMappingURL=server.d.ts.map
|
package/server.js
CHANGED
|
@@ -4,26 +4,30 @@ import { readText } from "@thi.ng/file-io";
|
|
|
4
4
|
import { ConsoleLogger } from "@thi.ng/logger";
|
|
5
5
|
import { preferredTypeForPath } from "@thi.ng/mime";
|
|
6
6
|
import { Router } from "@thi.ng/router";
|
|
7
|
+
import { upper } from "@thi.ng/strings";
|
|
7
8
|
import { createReadStream } from "node:fs";
|
|
8
9
|
import * as http from "node:http";
|
|
9
10
|
import * as https from "node:https";
|
|
11
|
+
import { isIPv6 } from "node:net";
|
|
10
12
|
import { pipeline, Transform } from "node:stream";
|
|
11
13
|
import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
|
|
12
14
|
import { parseCoookies } from "./utils/cookies.js";
|
|
13
15
|
import { parseSearchParams } from "./utils/formdata.js";
|
|
14
|
-
import {
|
|
16
|
+
import { isMatchingHost, normalizeIPv6Address } from "./utils/host.js";
|
|
15
17
|
const MISSING = "__missing";
|
|
16
18
|
class Server {
|
|
17
19
|
constructor(opts = {}) {
|
|
18
20
|
this.opts = opts;
|
|
19
21
|
this.logger = opts.logger ?? new ConsoleLogger("server");
|
|
22
|
+
this.host = opts.host ?? "localhost";
|
|
23
|
+
if (isIPv6(this.host)) this.host = normalizeIPv6Address(this.host);
|
|
20
24
|
this.augmentCtx = opts.context ?? identity;
|
|
21
25
|
const routes = [
|
|
22
26
|
{
|
|
23
27
|
id: MISSING,
|
|
24
28
|
match: ["__404__"],
|
|
25
29
|
handlers: {
|
|
26
|
-
get: async ({ res }) =>
|
|
30
|
+
get: async ({ res }) => res.missing()
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
...this.opts.routes ?? []
|
|
@@ -38,19 +42,22 @@ class Server {
|
|
|
38
42
|
logger;
|
|
39
43
|
router;
|
|
40
44
|
server;
|
|
45
|
+
host;
|
|
41
46
|
augmentCtx;
|
|
42
47
|
async start() {
|
|
43
|
-
const ssl = this.opts
|
|
44
|
-
const port = this.opts.port ?? (ssl ? 443 : 8080);
|
|
45
|
-
const host = this.opts.host ?? "localhost";
|
|
48
|
+
const { ssl, host = "localhost", port = ssl ? 443 : 8080 } = this.opts;
|
|
46
49
|
try {
|
|
47
50
|
this.server = ssl ? https.createServer(
|
|
48
51
|
{
|
|
49
52
|
key: readText(ssl.key, this.logger),
|
|
50
|
-
cert: readText(ssl.cert, this.logger)
|
|
53
|
+
cert: readText(ssl.cert, this.logger),
|
|
54
|
+
ServerResponse
|
|
51
55
|
},
|
|
52
56
|
this.listener.bind(this)
|
|
53
|
-
) : http.createServer(
|
|
57
|
+
) : http.createServer(
|
|
58
|
+
{ ServerResponse },
|
|
59
|
+
this.listener.bind(this)
|
|
60
|
+
);
|
|
54
61
|
this.server.listen(port, host, void 0, () => {
|
|
55
62
|
this.logger.info(
|
|
56
63
|
`starting server: http${ssl ? "s" : ""}://${host}:${port}`
|
|
@@ -70,18 +77,29 @@ class Server {
|
|
|
70
77
|
return true;
|
|
71
78
|
}
|
|
72
79
|
async listener(req, res) {
|
|
73
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
74
|
-
if (this.opts.host && this.opts.host !== url.host) {
|
|
75
|
-
res.writeHead(503).end();
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const path = decodeURIComponent(url.pathname);
|
|
79
80
|
try {
|
|
80
|
-
const
|
|
81
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
82
|
+
if (this.opts.host && !isMatchingHost(url.hostname, this.host)) {
|
|
83
|
+
this.logger.debug(
|
|
84
|
+
"ignoring request, host mismatch:",
|
|
85
|
+
url.hostname,
|
|
86
|
+
this.host
|
|
87
|
+
);
|
|
88
|
+
return res.noResponse();
|
|
89
|
+
}
|
|
90
|
+
const path = decodeURIComponent(url.pathname);
|
|
81
91
|
const match = this.router.route(path);
|
|
92
|
+
if (match.id === MISSING) return res.missing();
|
|
82
93
|
const route = this.router.routeForID(match.id).spec;
|
|
94
|
+
let method = req.method.toLowerCase();
|
|
95
|
+
if (method === "options" && !route.handlers.options) {
|
|
96
|
+
return res.noContent({
|
|
97
|
+
allow: Object.keys(route.handlers).map(upper).join(", ")
|
|
98
|
+
});
|
|
99
|
+
}
|
|
83
100
|
const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
|
|
84
101
|
const cookies = rawCookies ? parseCoookies(rawCookies) : {};
|
|
102
|
+
const query = parseSearchParams(url.searchParams);
|
|
85
103
|
const ctx = this.augmentCtx({
|
|
86
104
|
// @ts-ignore
|
|
87
105
|
server: this,
|
|
@@ -94,17 +112,6 @@ class Server {
|
|
|
94
112
|
route,
|
|
95
113
|
match
|
|
96
114
|
});
|
|
97
|
-
if (match.id === MISSING) {
|
|
98
|
-
this.runHandler(route.handlers.get, ctx);
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
let method = ctx.query?.__method || req.method.toLowerCase();
|
|
102
|
-
if (method === "options" && !route.handlers.options) {
|
|
103
|
-
res.writeHead(204, {
|
|
104
|
-
allow: Object.keys(route.handlers).map(upper).join(", ")
|
|
105
|
-
}).end();
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
115
|
if (method === "head" && !route.handlers.head && route.handlers.get) {
|
|
109
116
|
method = "get";
|
|
110
117
|
}
|
|
@@ -112,7 +119,7 @@ class Server {
|
|
|
112
119
|
if (handler) {
|
|
113
120
|
this.runHandler(handler, ctx);
|
|
114
121
|
} else {
|
|
115
|
-
res.
|
|
122
|
+
res.notAllowed();
|
|
116
123
|
}
|
|
117
124
|
} catch (e) {
|
|
118
125
|
this.logger.warn("error:", e.message);
|
|
@@ -196,29 +203,57 @@ class Server {
|
|
|
196
203
|
encoding ? pipeline(src, encoding.tx(), res, finalize) : pipeline(src, res, finalize);
|
|
197
204
|
} catch (e) {
|
|
198
205
|
this.logger.warn(e.message);
|
|
199
|
-
|
|
206
|
+
res.missing();
|
|
200
207
|
resolve();
|
|
201
208
|
}
|
|
202
209
|
});
|
|
203
210
|
}
|
|
204
|
-
|
|
205
|
-
res.
|
|
211
|
+
redirectToRoute(res, route) {
|
|
212
|
+
res.redirectTo(this.router.format(route));
|
|
206
213
|
}
|
|
207
|
-
|
|
208
|
-
|
|
214
|
+
}
|
|
215
|
+
const server = (opts) => new Server(opts);
|
|
216
|
+
class ServerResponse extends http.ServerResponse {
|
|
217
|
+
noContent(headers) {
|
|
218
|
+
this.writeHead(204, headers).end();
|
|
209
219
|
}
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
redirectTo(location, headers) {
|
|
221
|
+
this.writeHead(302, { ...headers, location }).end();
|
|
212
222
|
}
|
|
213
|
-
|
|
214
|
-
|
|
223
|
+
seeOther(location, headers) {
|
|
224
|
+
this.writeHead(303, { ...headers, location }).end();
|
|
215
225
|
}
|
|
216
|
-
|
|
217
|
-
this.
|
|
226
|
+
unmodified(headers) {
|
|
227
|
+
this.writeHead(304, headers).end();
|
|
228
|
+
}
|
|
229
|
+
unauthorized(headers, body) {
|
|
230
|
+
this.writeHead(401, headers).end(body);
|
|
231
|
+
}
|
|
232
|
+
forbidden(headers, body) {
|
|
233
|
+
this.writeHead(403, headers).end(body);
|
|
234
|
+
}
|
|
235
|
+
missing(headers, body) {
|
|
236
|
+
this.writeHead(404, headers).end(body);
|
|
237
|
+
}
|
|
238
|
+
notAllowed(headers, body) {
|
|
239
|
+
this.writeHead(405, headers).end(body);
|
|
240
|
+
}
|
|
241
|
+
notAcceptable(headers, body) {
|
|
242
|
+
this.writeHead(406, headers).end(body);
|
|
243
|
+
}
|
|
244
|
+
rateLimit(headers, body) {
|
|
245
|
+
this.writeHead(429, headers).end(body);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* HTTP 444. Indicates the server has returned no information to the client and closed
|
|
249
|
+
* the connection (useful as a deterrent for malware)
|
|
250
|
+
*/
|
|
251
|
+
noResponse() {
|
|
252
|
+
this.writeHead(444).end();
|
|
218
253
|
}
|
|
219
254
|
}
|
|
220
|
-
const server = (opts) => new Server(opts);
|
|
221
255
|
export {
|
|
222
256
|
Server,
|
|
257
|
+
ServerResponse,
|
|
223
258
|
server
|
|
224
259
|
};
|
package/session/session.d.ts
CHANGED
|
@@ -3,14 +3,13 @@ import { ServerResponse } from "node:http";
|
|
|
3
3
|
import type { Interceptor, ISessionStore, RequestCtx, ServerSession } from "../api.js";
|
|
4
4
|
export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> {
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Factory function to create a new session object. See {@link createSession}.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
factory: Fn<CTX, SESSION>;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* only contains a {@link ServerSession.id} (UUID v4).
|
|
10
|
+
* Session storage implementation. Default: {@link InMemorySessionStore}.
|
|
12
11
|
*/
|
|
13
|
-
|
|
12
|
+
store?: ISessionStore<SESSION>;
|
|
14
13
|
/**
|
|
15
14
|
* Session cookie name
|
|
16
15
|
*
|
|
@@ -23,21 +22,48 @@ export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extend
|
|
|
23
22
|
* @defaultValue "Secure;HttpOnly;SameSite=Strict;Path=/"
|
|
24
23
|
*/
|
|
25
24
|
cookieOpts?: string;
|
|
25
|
+
/**
|
|
26
|
+
* HMAC key/secret used for signing cookies (using SHA256). Max length 64
|
|
27
|
+
* bytes. If given as number, generates N random bytes.
|
|
28
|
+
*/
|
|
29
|
+
secret?: number | string | Buffer;
|
|
26
30
|
}
|
|
27
31
|
export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> implements Interceptor<CTX> {
|
|
28
|
-
store: ISessionStore<SESSION>;
|
|
29
32
|
factory: Fn<CTX, SESSION>;
|
|
33
|
+
store: ISessionStore<SESSION>;
|
|
30
34
|
cookieName: string;
|
|
31
35
|
cookieOpts: string;
|
|
32
|
-
|
|
36
|
+
secret: Buffer;
|
|
37
|
+
constructor({ factory, store, cookieName, cookieOpts, secret, }: SessionOpts<CTX, SESSION>);
|
|
33
38
|
pre(ctx: CTX): Promise<boolean>;
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
deleteSession(ctx: CTX, sessionID: string): Promise<void>;
|
|
40
|
+
newSession(ctx: CTX): Promise<SESSION | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Calls {@link SessionInterceptor.newSession} to create a new session and,
|
|
43
|
+
* if successful, associates it with current context & response. Deletes
|
|
44
|
+
* existing session (if any). Returns new session object.
|
|
45
|
+
*
|
|
46
|
+
* @param ctx
|
|
47
|
+
*/
|
|
48
|
+
replaceSession(ctx: CTX): Promise<SESSION | undefined>;
|
|
49
|
+
withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
|
|
50
|
+
validateSession(cookie: string): string | undefined;
|
|
36
51
|
}
|
|
37
52
|
/**
|
|
38
53
|
* Factory function to create a new {@link SessionInterceptor} instance.
|
|
39
54
|
*
|
|
40
55
|
* @param opts
|
|
41
56
|
*/
|
|
42
|
-
export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts
|
|
57
|
+
export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts: SessionOpts<CTX, SESSION>) => SessionInterceptor<CTX, SESSION>;
|
|
58
|
+
/**
|
|
59
|
+
* Creates a new basic {@link ServerSession}, using a UUID v4 for
|
|
60
|
+
* {@link ServerSession.id}.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* Intended to be used for {@link SessionOpts.factory} and/or as basis for
|
|
64
|
+
* creating custom session objects.
|
|
65
|
+
*
|
|
66
|
+
* @param ctx
|
|
67
|
+
*/
|
|
68
|
+
export declare const createSession: (ctx: RequestCtx) => ServerSession;
|
|
43
69
|
//# sourceMappingURL=session.d.ts.map
|
package/session/session.js
CHANGED
|
@@ -1,36 +1,47 @@
|
|
|
1
|
+
import { isNumber, isString } from "@thi.ng/checks";
|
|
1
2
|
import { uuid } from "@thi.ng/uuid";
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
4
|
import { ServerResponse } from "node:http";
|
|
3
5
|
import { inMemorySessionStore } from "./memory.js";
|
|
4
6
|
class SessionInterceptor {
|
|
5
|
-
store;
|
|
6
7
|
factory;
|
|
8
|
+
store;
|
|
7
9
|
cookieName;
|
|
8
10
|
cookieOpts;
|
|
11
|
+
secret;
|
|
9
12
|
constructor({
|
|
13
|
+
factory,
|
|
10
14
|
store = inMemorySessionStore(),
|
|
11
|
-
factory = () => ({ id: uuid() }),
|
|
12
15
|
cookieName = "__sid",
|
|
13
|
-
cookieOpts = "Secure;HttpOnly;SameSite=Strict;Path=/"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
cookieOpts = "Secure;HttpOnly;SameSite=Strict;Path=/",
|
|
17
|
+
secret = 32
|
|
18
|
+
}) {
|
|
16
19
|
this.factory = factory;
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.secret = isNumber(secret) ? randomBytes(secret) : isString(secret) ? Buffer.from(secret) : secret;
|
|
17
22
|
this.cookieName = cookieName;
|
|
18
23
|
this.cookieOpts = cookieOpts;
|
|
19
24
|
}
|
|
20
25
|
async pre(ctx) {
|
|
21
|
-
const
|
|
22
|
-
|
|
26
|
+
const cookie = ctx.cookies?.[this.cookieName];
|
|
27
|
+
let id;
|
|
28
|
+
if (cookie) {
|
|
29
|
+
id = this.validateSession(cookie);
|
|
30
|
+
if (!id) {
|
|
31
|
+
ctx.res.forbidden();
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
23
35
|
let session = id ? await this.store.get(id) : void 0;
|
|
24
|
-
if (!session) {
|
|
25
|
-
session = this.
|
|
26
|
-
|
|
27
|
-
this.store.set(session);
|
|
36
|
+
if (!session || session.ip !== ctx.req.socket.remoteAddress) {
|
|
37
|
+
session = await this.newSession(ctx);
|
|
38
|
+
if (!session) return false;
|
|
28
39
|
}
|
|
29
40
|
ctx.session = session;
|
|
30
|
-
this.withSession(res, session.id);
|
|
41
|
+
this.withSession(ctx.res, session.id);
|
|
31
42
|
return true;
|
|
32
43
|
}
|
|
33
|
-
async
|
|
44
|
+
async deleteSession(ctx, sessionID) {
|
|
34
45
|
if (await this.store.delete(sessionID)) {
|
|
35
46
|
ctx.logger.info("delete session:", sessionID);
|
|
36
47
|
ctx.session = void 0;
|
|
@@ -40,15 +51,58 @@ class SessionInterceptor {
|
|
|
40
51
|
);
|
|
41
52
|
}
|
|
42
53
|
}
|
|
54
|
+
async newSession(ctx) {
|
|
55
|
+
const session = this.factory(ctx);
|
|
56
|
+
ctx.logger.info("new session:", session.id);
|
|
57
|
+
if (!await this.store.set(session)) {
|
|
58
|
+
ctx.logger.warn("could not store session...");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
return session;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Calls {@link SessionInterceptor.newSession} to create a new session and,
|
|
65
|
+
* if successful, associates it with current context & response. Deletes
|
|
66
|
+
* existing session (if any). Returns new session object.
|
|
67
|
+
*
|
|
68
|
+
* @param ctx
|
|
69
|
+
*/
|
|
70
|
+
async replaceSession(ctx) {
|
|
71
|
+
const session = await this.newSession(ctx);
|
|
72
|
+
if (session) {
|
|
73
|
+
if (ctx.session?.id) this.store.delete(ctx.session.id);
|
|
74
|
+
ctx.session = session;
|
|
75
|
+
this.withSession(ctx.res, session.id);
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
43
79
|
withSession(res, sessionID) {
|
|
44
|
-
|
|
80
|
+
const cookie = sessionID + ":" + randomBytes(8).toString("base64url");
|
|
81
|
+
const signature = createHmac("sha256", this.secret).update(cookie, "ascii").digest().toString("base64url");
|
|
82
|
+
return res.appendHeader(
|
|
45
83
|
"set-cookie",
|
|
46
|
-
`${this.cookieName}=${
|
|
84
|
+
`${this.cookieName}=${cookie}:${signature};Max-Age=${this.store.ttl};${this.cookieOpts}`
|
|
47
85
|
);
|
|
48
86
|
}
|
|
87
|
+
validateSession(cookie) {
|
|
88
|
+
const parts = cookie.split(":");
|
|
89
|
+
if (parts.length < 3) return;
|
|
90
|
+
const actual = Buffer.from(parts[2], "base64url");
|
|
91
|
+
const expected = createHmac("sha256", this.secret).update(
|
|
92
|
+
cookie.substring(0, cookie.length - parts[2].length - 1),
|
|
93
|
+
"ascii"
|
|
94
|
+
).digest();
|
|
95
|
+
const sameLength = actual.length === expected.length;
|
|
96
|
+
return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? parts[0] : void 0;
|
|
97
|
+
}
|
|
49
98
|
}
|
|
50
99
|
const serverSession = (opts) => new SessionInterceptor(opts);
|
|
100
|
+
const createSession = (ctx) => ({
|
|
101
|
+
id: uuid(),
|
|
102
|
+
ip: ctx.req.socket.remoteAddress
|
|
103
|
+
});
|
|
51
104
|
export {
|
|
52
105
|
SessionInterceptor,
|
|
106
|
+
createSession,
|
|
53
107
|
serverSession
|
|
54
108
|
};
|
package/static.js
CHANGED
|
@@ -54,11 +54,11 @@ const staticFiles = ({
|
|
|
54
54
|
});
|
|
55
55
|
const __fileHeaders = async (path, ctx, filter, etag, headers) => {
|
|
56
56
|
if (!(existsSync(path) && filter(path))) {
|
|
57
|
-
return ctx.
|
|
57
|
+
return ctx.res.missing();
|
|
58
58
|
}
|
|
59
59
|
if (etag) {
|
|
60
60
|
const etagValue = await etag(path);
|
|
61
|
-
return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.
|
|
61
|
+
return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
|
|
62
62
|
}
|
|
63
63
|
return { ...headers };
|
|
64
64
|
};
|
package/utils/host.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takes a hostname or IPv6 address `test` and compares it against `expected`,
|
|
3
|
+
* returns true if matching.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* If `test` is an IPv6 address in the format `[...]`, it will be compared
|
|
7
|
+
* without surrounding square brackets. If `expected` is an IPv6 address it MUST
|
|
8
|
+
* have been pre-normalized using {@link normalizeIPv6Address}. For performance
|
|
9
|
+
* reasons only `test` will be automatically normalized, i.e. `::1` will be
|
|
10
|
+
* normalized as `0:0:0:0:0:0:0:1`.
|
|
11
|
+
*
|
|
12
|
+
* @param test
|
|
13
|
+
* @param expected
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export declare const isMatchingHost: (test: string, expected: string) => boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Parses given IPv6 address into an 8-tuple of 16bit uints. Throws error if
|
|
20
|
+
* address is invalid.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* https://en.wikipedia.org/wiki/IPv6_address
|
|
24
|
+
*
|
|
25
|
+
* @param addr
|
|
26
|
+
*/
|
|
27
|
+
export declare const parseIPv6Address: (addr: string) => number[];
|
|
28
|
+
/**
|
|
29
|
+
* Returns normalized version of given IPv6 address, i.e. expanding `::`
|
|
30
|
+
* sections, removing leading zeroes and performing other syntactic validations.
|
|
31
|
+
* The returned address always has 8 parts. Throws error if address is invalid.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Internally uses {@link parseIPv6Address}.
|
|
35
|
+
*
|
|
36
|
+
* Reference:
|
|
37
|
+
* https://en.wikipedia.org/wiki/IPv6_address
|
|
38
|
+
*
|
|
39
|
+
* @param addr
|
|
40
|
+
*/
|
|
41
|
+
export declare const normalizeIPv6Address: (addr: string) => string;
|
|
42
|
+
//# sourceMappingURL=host.d.ts.map
|
package/utils/host.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { illegalArgs } from "@thi.ng/errors";
|
|
2
|
+
import { HEX } from "@thi.ng/strings";
|
|
3
|
+
const isMatchingHost = (test, expected) => {
|
|
4
|
+
if (/^\[[0-9a-f:]{1,39}\]$/.test(test)) {
|
|
5
|
+
try {
|
|
6
|
+
return normalizeIPv6Address(test.substring(1, test.length - 1)) === expected;
|
|
7
|
+
} catch (_) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return test === expected;
|
|
12
|
+
};
|
|
13
|
+
const parseIPv6Address = (addr) => {
|
|
14
|
+
if (addr == "::") return [0, 0, 0, 0, 0, 0, 0, 0];
|
|
15
|
+
if (addr == "::1") return [0, 0, 0, 0, 0, 0, 0, 1];
|
|
16
|
+
const n = addr.length - 1;
|
|
17
|
+
if (n > 38) invalidIPv6(addr);
|
|
18
|
+
const parts = [];
|
|
19
|
+
let curr = 0;
|
|
20
|
+
let zeroes = -1;
|
|
21
|
+
for (let i = 0; i <= n; i++) {
|
|
22
|
+
const ch = addr[i];
|
|
23
|
+
if (i === n && ch == ":") illegalArgs(addr);
|
|
24
|
+
if (i === n || ch === ":") {
|
|
25
|
+
if (parts.length >= (zeroes >= 0 ? 6 : 8)) invalidIPv6(addr);
|
|
26
|
+
const end = i === n ? n + 1 : i > curr ? i : invalidIPv6(addr);
|
|
27
|
+
if (end - curr > 4) invalidIPv6(addr);
|
|
28
|
+
parts.push(parseInt(addr.substring(curr, end), 16));
|
|
29
|
+
if (addr[i + 1] === ":") {
|
|
30
|
+
if (zeroes >= 0) invalidIPv6(addr);
|
|
31
|
+
zeroes = parts.length;
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
curr = i + 1;
|
|
35
|
+
} else if (!HEX[ch]) invalidIPv6(addr);
|
|
36
|
+
}
|
|
37
|
+
if (zeroes >= 0) {
|
|
38
|
+
parts.splice(zeroes, 0, ...new Array(8 - parts.length).fill(0));
|
|
39
|
+
}
|
|
40
|
+
if (parts.length !== 8) invalidIPv6(addr);
|
|
41
|
+
return parts;
|
|
42
|
+
};
|
|
43
|
+
const normalizeIPv6Address = (addr) => parseIPv6Address(addr).map((x) => x.toString(16)).join(":");
|
|
44
|
+
const invalidIPv6 = (addr) => illegalArgs("invalid IPv6 address: " + addr);
|
|
45
|
+
export {
|
|
46
|
+
isMatchingHost,
|
|
47
|
+
normalizeIPv6Address,
|
|
48
|
+
parseIPv6Address
|
|
49
|
+
};
|