@increase21/simplenodejs 1.0.20 → 1.0.22
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 +418 -167
- package/dist/index.d.ts +3 -3
- package/dist/index.js +12 -2
- package/dist/router.d.ts +1 -3
- package/dist/router.js +37 -51
- package/dist/server.d.ts +9 -5
- package/dist/server.js +38 -33
- package/dist/typings/general.d.ts +6 -17
- package/dist/typings/simpletypes.d.ts +10 -0
- package/dist/utils/helpers.d.ts +6 -3
- package/dist/utils/helpers.js +51 -3
- package/dist/utils/simpleController.d.ts +5 -7
- package/dist/utils/simpleController.js +4 -21
- package/dist/utils/simpleMiddleware.d.ts +32 -4
- package/dist/utils/simpleMiddleware.js +136 -51
- package/dist/utils/simplePlugins.d.ts +67 -4
- package/dist/utils/simplePlugins.js +200 -2
- package/package.json +1 -1
|
@@ -1,41 +1,35 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
3
|
+
exports.SetCORS = SetCORS;
|
|
4
|
+
exports.SetHSTS = SetHSTS;
|
|
5
|
+
exports.SetCSP = SetCSP;
|
|
6
|
+
exports.SetFrameGuard = SetFrameGuard;
|
|
7
|
+
exports.SetNoSniff = SetNoSniff;
|
|
8
|
+
exports.SetReferrerPolicy = SetReferrerPolicy;
|
|
9
|
+
exports.SetPermissionsPolicy = SetPermissionsPolicy;
|
|
10
|
+
exports.SetCOEP = SetCOEP;
|
|
11
|
+
exports.SetCOOP = SetCOOP;
|
|
12
|
+
exports.SetHelmet = SetHelmet;
|
|
7
13
|
exports.SetRateLimiter = SetRateLimiter;
|
|
8
14
|
exports.SetBodyParser = SetBodyParser;
|
|
9
|
-
const node_querystring_1 = __importDefault(require("node:querystring"));
|
|
10
15
|
const helpers_1 = require("./helpers");
|
|
11
|
-
//
|
|
12
|
-
function
|
|
16
|
+
// ─── CORS ────────────────────────────────────────────────────────────────────
|
|
17
|
+
function SetCORS(opts) {
|
|
13
18
|
return async (req, res, next) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
};
|
|
23
|
-
let merged = { ...defaults };
|
|
24
|
-
for (const { name, value } of opts) {
|
|
25
|
-
merged[name] = value; // overrides defaults if same key
|
|
19
|
+
if (opts?.credentials) {
|
|
20
|
+
const reqOrigin = req.headers.origin;
|
|
21
|
+
if (!reqOrigin)
|
|
22
|
+
(0, helpers_1.throwHttpError)(403, "CORS Error: Origin header is required when credentials are enabled");
|
|
23
|
+
if (opts.origin && reqOrigin !== opts.origin)
|
|
24
|
+
(0, helpers_1.throwHttpError)(403, "CORS Error: Origin not allowed");
|
|
25
|
+
res.setHeader("Access-Control-Allow-Origin", reqOrigin);
|
|
26
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const origin = req.headers.origin;
|
|
30
|
-
if (!origin) {
|
|
31
|
-
(0, helpers_1.throwHttpError)(403, "CORS Error: Origin header is required when Access-Control-Allow-Credentials is true");
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
merged["Access-Control-Allow-Origin"] = origin;
|
|
35
|
-
}
|
|
36
|
-
for (const [key, value] of Object.entries(merged)) {
|
|
37
|
-
res.setHeader(key, value);
|
|
28
|
+
else {
|
|
29
|
+
res.setHeader("Access-Control-Allow-Origin", opts?.origin || "*");
|
|
38
30
|
}
|
|
31
|
+
res.setHeader("Access-Control-Allow-Headers", opts?.headers || "Origin, X-Requested-With, Content-Type, Accept, Authorization");
|
|
32
|
+
res.setHeader("Access-Control-Allow-Methods", opts?.methods || "GET, POST, DELETE, PUT, PATCH");
|
|
39
33
|
if (req.method === "OPTIONS") {
|
|
40
34
|
res.status(204).end();
|
|
41
35
|
return;
|
|
@@ -43,20 +37,115 @@ function SetRequestCORS(opts) {
|
|
|
43
37
|
await next();
|
|
44
38
|
};
|
|
45
39
|
}
|
|
46
|
-
//
|
|
40
|
+
// ─── HSTS ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
// Only meaningful on HTTPS. Browsers ignore this header over plain HTTP.
|
|
42
|
+
function SetHSTS(opts) {
|
|
43
|
+
let value = `max-age=${opts?.maxAge ?? 31536000}`;
|
|
44
|
+
if (opts?.includeSubDomains !== false)
|
|
45
|
+
value += "; includeSubDomains";
|
|
46
|
+
if (opts?.preload)
|
|
47
|
+
value += "; preload";
|
|
48
|
+
return async (_req, res, next) => {
|
|
49
|
+
res.setHeader("Strict-Transport-Security", value);
|
|
50
|
+
await next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// ─── Content Security Policy ──────────────────────────────────────────────────
|
|
54
|
+
function SetCSP(policy = "default-src 'none'") {
|
|
55
|
+
return async (_req, res, next) => {
|
|
56
|
+
res.setHeader("Content-Security-Policy", policy);
|
|
57
|
+
await next();
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// ─── X-Frame-Options (clickjacking) ──────────────────────────────────────────
|
|
61
|
+
function SetFrameGuard(action = "DENY") {
|
|
62
|
+
return async (_req, res, next) => {
|
|
63
|
+
res.setHeader("X-Frame-Options", action);
|
|
64
|
+
await next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// ─── X-Content-Type-Options (MIME sniffing) ───────────────────────────────────
|
|
68
|
+
function SetNoSniff() {
|
|
69
|
+
return async (_req, res, next) => {
|
|
70
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
71
|
+
await next();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ─── Referrer-Policy ──────────────────────────────────────────────────────────
|
|
75
|
+
function SetReferrerPolicy(policy = "no-referrer") {
|
|
76
|
+
return async (_req, res, next) => {
|
|
77
|
+
res.setHeader("Referrer-Policy", policy);
|
|
78
|
+
await next();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// ─── Permissions-Policy (browser feature control) ────────────────────────────
|
|
82
|
+
function SetPermissionsPolicy(policy = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=()") {
|
|
83
|
+
return async (_req, res, next) => {
|
|
84
|
+
res.setHeader("Permissions-Policy", policy);
|
|
85
|
+
await next();
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ─── Cross-Origin-Embedder-Policy ────────────────────────────────────────────
|
|
89
|
+
function SetCOEP(value = "require-corp") {
|
|
90
|
+
return async (_req, res, next) => {
|
|
91
|
+
res.setHeader("Cross-Origin-Embedder-Policy", value);
|
|
92
|
+
await next();
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// ─── Cross-Origin-Opener-Policy ──────────────────────────────────────────────
|
|
96
|
+
function SetCOOP(value = "same-origin") {
|
|
97
|
+
return async (_req, res, next) => {
|
|
98
|
+
res.setHeader("Cross-Origin-Opener-Policy", value);
|
|
99
|
+
await next();
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ─── Helmet (security headers bundle, excludes CORS) ─────────────────────────
|
|
103
|
+
function SetHelmet(opts) {
|
|
104
|
+
return async (_req, res, next) => {
|
|
105
|
+
if (opts?.noSniff !== false)
|
|
106
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
107
|
+
if (opts?.frameGuard !== false)
|
|
108
|
+
res.setHeader("X-Frame-Options", typeof opts?.frameGuard === "string" ? opts.frameGuard : "DENY");
|
|
109
|
+
if (opts?.referrerPolicy !== false)
|
|
110
|
+
res.setHeader("Referrer-Policy", typeof opts?.referrerPolicy === "string" ? opts.referrerPolicy : "no-referrer");
|
|
111
|
+
if (opts?.csp !== false)
|
|
112
|
+
res.setHeader("Content-Security-Policy", typeof opts?.csp === "string" ? opts.csp : "default-src 'none'");
|
|
113
|
+
if (opts?.hsts !== false) {
|
|
114
|
+
const hsts = (opts?.hsts && typeof opts.hsts === "object") ? opts.hsts : {};
|
|
115
|
+
let hstsValue = `max-age=${hsts.maxAge ?? 31536000}`;
|
|
116
|
+
if (hsts.includeSubDomains !== false)
|
|
117
|
+
hstsValue += "; includeSubDomains";
|
|
118
|
+
if (hsts.preload)
|
|
119
|
+
hstsValue += "; preload";
|
|
120
|
+
res.setHeader("Strict-Transport-Security", hstsValue);
|
|
121
|
+
}
|
|
122
|
+
if (opts?.permissionsPolicy !== false)
|
|
123
|
+
res.setHeader("Permissions-Policy", typeof opts?.permissionsPolicy === "string" ? opts.permissionsPolicy : "camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=()");
|
|
124
|
+
if (opts?.coep !== false)
|
|
125
|
+
res.setHeader("Cross-Origin-Embedder-Policy", typeof opts?.coep === "string" ? opts.coep : "require-corp");
|
|
126
|
+
if (opts?.coop !== false)
|
|
127
|
+
res.setHeader("Cross-Origin-Opener-Policy", typeof opts?.coop === "string" ? opts.coop : "same-origin");
|
|
128
|
+
await next();
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// ─── Rate Limiter ─────────────────────────────────────────────────────────────
|
|
132
|
+
const RATE_LIMIT_MAX_STORE = 100000;
|
|
47
133
|
function SetRateLimiter(opts) {
|
|
48
134
|
const store = new Map();
|
|
49
135
|
const timer = setInterval(() => {
|
|
50
136
|
const now = Date.now();
|
|
51
137
|
for (const [k, v] of store) {
|
|
52
|
-
if (now - v.ts > opts.windowMs
|
|
138
|
+
if (now - v.ts > opts.windowMs)
|
|
53
139
|
store.delete(k);
|
|
54
140
|
}
|
|
55
141
|
}, opts.windowMs);
|
|
56
142
|
timer.unref();
|
|
57
143
|
return async (req, res, next) => {
|
|
58
|
-
const
|
|
59
|
-
|
|
144
|
+
const ip = opts.trustProxy
|
|
145
|
+
? (Array.isArray(req.headers["x-forwarded-for"])
|
|
146
|
+
? req.headers["x-forwarded-for"][0]
|
|
147
|
+
: String(req.headers["x-forwarded-for"] || "").split(",")[0].trim()) || req.socket.remoteAddress || "unknown"
|
|
148
|
+
: req.socket.remoteAddress || "unknown";
|
|
60
149
|
const key = String(opts.keyGenerator?.(req) || ip || "unknown");
|
|
61
150
|
const now = Date.now();
|
|
62
151
|
const entry = store.get(key) || { count: 0, ts: now };
|
|
@@ -65,6 +154,9 @@ function SetRateLimiter(opts) {
|
|
|
65
154
|
entry.ts = now;
|
|
66
155
|
}
|
|
67
156
|
entry.count++;
|
|
157
|
+
if (!store.has(key) && store.size >= RATE_LIMIT_MAX_STORE) {
|
|
158
|
+
store.delete(store.keys().next().value); // evict oldest entry
|
|
159
|
+
}
|
|
68
160
|
store.set(key, entry);
|
|
69
161
|
if (entry.count > opts.max) {
|
|
70
162
|
res.setHeader("Retry-After", Math.ceil(opts.windowMs / 1000));
|
|
@@ -75,7 +167,7 @@ function SetRateLimiter(opts) {
|
|
|
75
167
|
await next();
|
|
76
168
|
};
|
|
77
169
|
}
|
|
78
|
-
//
|
|
170
|
+
// ─── Body Parser ──────────────────────────────────────────────────────────────
|
|
79
171
|
function SetBodyLimit(limit = "1mb") {
|
|
80
172
|
if (typeof limit === "number")
|
|
81
173
|
return limit;
|
|
@@ -90,15 +182,11 @@ function SetBodyLimit(limit = "1mb") {
|
|
|
90
182
|
return n * 1024 * 1024;
|
|
91
183
|
return n;
|
|
92
184
|
}
|
|
93
|
-
//For
|
|
94
185
|
function SetBodyParser(opts) {
|
|
95
186
|
const maxSize = SetBodyLimit(opts.limit);
|
|
96
187
|
return (req, res, next) => new Promise((resolve, reject) => {
|
|
97
|
-
//get the content type of the request
|
|
98
188
|
const contentType = req.headers["content-type"] || "";
|
|
99
|
-
|
|
100
|
-
if (contentType.includes("multipart/form-data"))
|
|
101
|
-
return resolve(next());
|
|
189
|
+
const isMultipart = contentType.includes("multipart/form-data");
|
|
102
190
|
let size = 0;
|
|
103
191
|
let body = "";
|
|
104
192
|
req.on("data", chunk => {
|
|
@@ -111,34 +199,31 @@ function SetBodyParser(opts) {
|
|
|
111
199
|
req.socket.destroy();
|
|
112
200
|
return;
|
|
113
201
|
}
|
|
114
|
-
|
|
115
|
-
|
|
202
|
+
if (!isMultipart)
|
|
203
|
+
body += chunk;
|
|
116
204
|
});
|
|
117
205
|
req.on("end", () => {
|
|
118
206
|
if (res.writableEnded)
|
|
119
207
|
return resolve();
|
|
120
208
|
try {
|
|
121
|
-
if (body && contentType.includes("application/json")) {
|
|
209
|
+
if (!isMultipart && body && contentType.includes("application/json")) {
|
|
122
210
|
req.body = JSON.parse(body);
|
|
123
211
|
}
|
|
124
|
-
else {
|
|
212
|
+
else if (!isMultipart) {
|
|
125
213
|
req.body = body;
|
|
126
214
|
}
|
|
127
|
-
//parse query
|
|
128
|
-
if (req.query) {
|
|
129
|
-
req.query = JSON.parse(JSON.stringify(node_querystring_1.default.parse(req.query)));
|
|
130
|
-
}
|
|
131
215
|
resolve(next());
|
|
132
216
|
}
|
|
133
217
|
catch (e) {
|
|
218
|
+
reject({ code: 400, error: "Invalid Payload" });
|
|
134
219
|
if (!res.writableEnded)
|
|
135
|
-
|
|
220
|
+
res.status(400).end("Invalid Payload");
|
|
136
221
|
}
|
|
137
222
|
});
|
|
138
223
|
req.on("error", () => {
|
|
224
|
+
reject({ code: 400, error: "Request stream ended" });
|
|
139
225
|
if (!res.writableEnded)
|
|
140
|
-
|
|
226
|
+
res.status(400).end("Request stream ended");
|
|
141
227
|
});
|
|
142
228
|
});
|
|
143
229
|
}
|
|
144
|
-
;
|
|
@@ -1,9 +1,72 @@
|
|
|
1
1
|
import { SimpleJsServer } from "../typings/general";
|
|
2
2
|
import { SimpleJSRateLimitType } from "../typings/simpletypes";
|
|
3
|
+
import { SetCORS, SetHelmet } from "./simpleMiddleware";
|
|
3
4
|
export declare function SimpleJsSecurityPlugin(app: SimpleJsServer, opts: {
|
|
4
|
-
cors?:
|
|
5
|
-
|
|
6
|
-
value: string;
|
|
7
|
-
}[];
|
|
5
|
+
cors?: Parameters<typeof SetCORS>[0];
|
|
6
|
+
helmet?: true | Parameters<typeof SetHelmet>[0];
|
|
8
7
|
rateLimit?: SimpleJSRateLimitType;
|
|
9
8
|
}): void;
|
|
9
|
+
/**
|
|
10
|
+
* Restricts access based on client IP.
|
|
11
|
+
* mode "allow" = only listed IPs can access (whitelist).
|
|
12
|
+
* mode "deny" = listed IPs are blocked (blacklist).
|
|
13
|
+
*/
|
|
14
|
+
export declare function SimpleJsIPWhitelistPlugin(app: SimpleJsServer, opts: {
|
|
15
|
+
ips: string[];
|
|
16
|
+
mode?: "allow" | "deny";
|
|
17
|
+
trustProxy?: boolean;
|
|
18
|
+
}): void;
|
|
19
|
+
/**
|
|
20
|
+
* Creates a signed cookie value. Use this when setting a cookie in a response.
|
|
21
|
+
* The client sends it back as-is; SimpleJsCookiePlugin will verify and strip the signature.
|
|
22
|
+
*/
|
|
23
|
+
export declare function SignCookie(value: string, secret: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Parses the Cookie header on every request.
|
|
26
|
+
* Cookies are available at `this._custom_data[dataKey]` inside controllers.
|
|
27
|
+
* If a secret is provided, signed cookies (prefixed "s:") are verified and unsigned.
|
|
28
|
+
* Cookies with invalid signatures are silently dropped.
|
|
29
|
+
*/
|
|
30
|
+
export declare function SimpleJsCookiePlugin(app: SimpleJsServer, opts?: {
|
|
31
|
+
secret?: string;
|
|
32
|
+
/** Key used to attach cookies on _custom_data. Default: "cookies" */
|
|
33
|
+
dataKey?: string;
|
|
34
|
+
}): void;
|
|
35
|
+
/**
|
|
36
|
+
* Logs every request after it completes, including method, URL, status code, and duration.
|
|
37
|
+
*/
|
|
38
|
+
export declare function SimpleJsRequestLoggerPlugin(app: SimpleJsServer, opts?: {
|
|
39
|
+
/** Custom log function. Defaults to console.log. */
|
|
40
|
+
logger?: (message: string) => void;
|
|
41
|
+
format?: "simple" | "json";
|
|
42
|
+
}): void;
|
|
43
|
+
/**
|
|
44
|
+
* Automatically closes requests that exceed the configured time limit.
|
|
45
|
+
*/
|
|
46
|
+
export declare function SimpleJsTimeoutPlugin(app: SimpleJsServer, opts: {
|
|
47
|
+
/** Timeout in milliseconds */
|
|
48
|
+
ms: number;
|
|
49
|
+
message?: string;
|
|
50
|
+
}): void;
|
|
51
|
+
/**
|
|
52
|
+
* Sets Cache-Control response headers on every request.
|
|
53
|
+
*/
|
|
54
|
+
export declare function SimpleJsCachePlugin(app: SimpleJsServer, opts: {
|
|
55
|
+
/** Max age in seconds for public caching */
|
|
56
|
+
maxAge?: number;
|
|
57
|
+
/** Mark response as private (user-specific, not shared caches) */
|
|
58
|
+
private?: boolean;
|
|
59
|
+
/** Disable all caching entirely */
|
|
60
|
+
noStore?: boolean;
|
|
61
|
+
}): void;
|
|
62
|
+
/**
|
|
63
|
+
* Returns 503 for all requests when maintenance mode is enabled.
|
|
64
|
+
* Optionally allow specific IPs to bypass (e.g. for internal testing).
|
|
65
|
+
*/
|
|
66
|
+
export declare function SimpleJsMaintenanceModePlugin(app: SimpleJsServer, opts: {
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
message?: string;
|
|
69
|
+
/** IPs that bypass maintenance mode (e.g. your office/server IP) */
|
|
70
|
+
allowIPs?: string[];
|
|
71
|
+
trustProxy?: boolean;
|
|
72
|
+
}): void;
|
|
@@ -1,8 +1,206 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.SimpleJsSecurityPlugin = SimpleJsSecurityPlugin;
|
|
7
|
+
exports.SimpleJsIPWhitelistPlugin = SimpleJsIPWhitelistPlugin;
|
|
8
|
+
exports.SignCookie = SignCookie;
|
|
9
|
+
exports.SimpleJsCookiePlugin = SimpleJsCookiePlugin;
|
|
10
|
+
exports.SimpleJsRequestLoggerPlugin = SimpleJsRequestLoggerPlugin;
|
|
11
|
+
exports.SimpleJsTimeoutPlugin = SimpleJsTimeoutPlugin;
|
|
12
|
+
exports.SimpleJsCachePlugin = SimpleJsCachePlugin;
|
|
13
|
+
exports.SimpleJsMaintenanceModePlugin = SimpleJsMaintenanceModePlugin;
|
|
14
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
15
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
16
|
+
const helpers_1 = require("./helpers");
|
|
4
17
|
const simpleMiddleware_1 = require("./simpleMiddleware");
|
|
18
|
+
// ─── IP normalization ─────────────────────────────────────────────────────────
|
|
19
|
+
// Converts IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) to plain IPv4.
|
|
20
|
+
function normalizeIP(raw) {
|
|
21
|
+
const stripped = raw.replace(/^::ffff:/i, "");
|
|
22
|
+
return node_net_1.default.isIPv4(stripped) ? stripped : raw;
|
|
23
|
+
}
|
|
24
|
+
// ─── Security Plugin ──────────────────────────────────────────────────────────
|
|
5
25
|
function SimpleJsSecurityPlugin(app, opts) {
|
|
6
|
-
|
|
7
|
-
|
|
26
|
+
if (opts.cors)
|
|
27
|
+
app.use((0, simpleMiddleware_1.SetCORS)(opts.cors));
|
|
28
|
+
if (opts.helmet)
|
|
29
|
+
app.use((0, simpleMiddleware_1.SetHelmet)(opts.helmet === true ? undefined : opts.helmet));
|
|
30
|
+
if (opts.rateLimit)
|
|
31
|
+
app.use((0, simpleMiddleware_1.SetRateLimiter)(opts.rateLimit));
|
|
32
|
+
}
|
|
33
|
+
// ─── IP Whitelist / Blacklist Plugin ──────────────────────────────────────────
|
|
34
|
+
/**
|
|
35
|
+
* Restricts access based on client IP.
|
|
36
|
+
* mode "allow" = only listed IPs can access (whitelist).
|
|
37
|
+
* mode "deny" = listed IPs are blocked (blacklist).
|
|
38
|
+
*/
|
|
39
|
+
function SimpleJsIPWhitelistPlugin(app, opts) {
|
|
40
|
+
const mode = opts.mode || "allow";
|
|
41
|
+
const ipSet = new Set(opts.ips);
|
|
42
|
+
app.use(async (req, _res, next) => {
|
|
43
|
+
const raw = opts.trustProxy
|
|
44
|
+
? (Array.isArray(req.headers["x-forwarded-for"])
|
|
45
|
+
? req.headers["x-forwarded-for"][0]
|
|
46
|
+
: String(req.headers["x-forwarded-for"] || "").split(",")[0].trim()) || req.socket.remoteAddress || ""
|
|
47
|
+
: req.socket.remoteAddress || "";
|
|
48
|
+
const ip = normalizeIP(raw);
|
|
49
|
+
const inList = ipSet.has(ip);
|
|
50
|
+
if (mode === "allow" && !inList)
|
|
51
|
+
(0, helpers_1.throwHttpError)(403, "Access denied");
|
|
52
|
+
if (mode === "deny" && inList)
|
|
53
|
+
(0, helpers_1.throwHttpError)(403, "Access denied");
|
|
54
|
+
await next();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// ─── Cookie Plugin ────────────────────────────────────────────────────────────
|
|
58
|
+
function parseCookieHeader(header) {
|
|
59
|
+
const result = {};
|
|
60
|
+
for (const part of header.split(";")) {
|
|
61
|
+
const idx = part.indexOf("=");
|
|
62
|
+
if (idx < 0)
|
|
63
|
+
continue;
|
|
64
|
+
const key = part.slice(0, idx).trim();
|
|
65
|
+
const val = part.slice(idx + 1).trim();
|
|
66
|
+
try {
|
|
67
|
+
result[key] = decodeURIComponent(val);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
result[key] = val;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Creates a signed cookie value. Use this when setting a cookie in a response.
|
|
77
|
+
* The client sends it back as-is; SimpleJsCookiePlugin will verify and strip the signature.
|
|
78
|
+
*/
|
|
79
|
+
function SignCookie(value, secret) {
|
|
80
|
+
const sig = node_crypto_1.default.createHmac("sha256", secret).update(value).digest("base64url");
|
|
81
|
+
return `s:${value}.${sig}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Parses the Cookie header on every request.
|
|
85
|
+
* Cookies are available at `this._custom_data[dataKey]` inside controllers.
|
|
86
|
+
* If a secret is provided, signed cookies (prefixed "s:") are verified and unsigned.
|
|
87
|
+
* Cookies with invalid signatures are silently dropped.
|
|
88
|
+
*/
|
|
89
|
+
function SimpleJsCookiePlugin(app, opts) {
|
|
90
|
+
const dataKey = opts?.dataKey || "cookies";
|
|
91
|
+
app.use(async (req, _res, next) => {
|
|
92
|
+
const raw = parseCookieHeader(req.headers.cookie || "");
|
|
93
|
+
if (opts?.secret) {
|
|
94
|
+
const verified = {};
|
|
95
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
96
|
+
if (v.startsWith("s:")) {
|
|
97
|
+
const inner = v.slice(2);
|
|
98
|
+
const dotIdx = inner.lastIndexOf(".");
|
|
99
|
+
if (dotIdx < 0)
|
|
100
|
+
continue; // malformed signed cookie — drop
|
|
101
|
+
const val = inner.slice(0, dotIdx);
|
|
102
|
+
const sig = inner.slice(dotIdx + 1);
|
|
103
|
+
const expected = node_crypto_1.default.createHmac("sha256", opts.secret).update(val).digest("base64url");
|
|
104
|
+
const sigBuf = Buffer.from(sig, "base64url");
|
|
105
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
106
|
+
if (sigBuf.length === expectedBuf.length && node_crypto_1.default.timingSafeEqual(sigBuf, expectedBuf)) {
|
|
107
|
+
verified[k] = val; // valid — attach unsigned value
|
|
108
|
+
}
|
|
109
|
+
// invalid signature — silently dropped
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
verified[k] = v; // unsigned cookie — pass through
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
req._custom_data = { ...(req._custom_data || {}), [dataKey]: verified };
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
req._custom_data = { ...(req._custom_data || {}), [dataKey]: raw };
|
|
119
|
+
}
|
|
120
|
+
await next();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// ─── Request Logger Plugin ────────────────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Logs every request after it completes, including method, URL, status code, and duration.
|
|
126
|
+
*/
|
|
127
|
+
function SimpleJsRequestLoggerPlugin(app, opts) {
|
|
128
|
+
const log = opts?.logger || console.log;
|
|
129
|
+
const format = opts?.format || "simple";
|
|
130
|
+
app.use(async (req, res, next) => {
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
const method = req.method || "?";
|
|
133
|
+
const url = req.url || "/";
|
|
134
|
+
const path = url.split("?")[0]; // omit query string to avoid logging sensitive params
|
|
135
|
+
res.on("finish", () => {
|
|
136
|
+
const ms = Date.now() - start;
|
|
137
|
+
const status = res.statusCode;
|
|
138
|
+
if (format === "json") {
|
|
139
|
+
log(JSON.stringify({ time: new Date().toISOString(), method, path, status, ms, id: req.id }));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
log(`[${new Date().toISOString()}] ${method} ${path} ${status} ${ms}ms`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
await next();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// ─── Request Timeout Plugin ───────────────────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Automatically closes requests that exceed the configured time limit.
|
|
151
|
+
*/
|
|
152
|
+
function SimpleJsTimeoutPlugin(app, opts) {
|
|
153
|
+
app.use(async (req, res, next) => {
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
if (!res.writableEnded) {
|
|
156
|
+
res.statusCode = 408;
|
|
157
|
+
res.end(opts.message || "Request timeout");
|
|
158
|
+
req.socket.destroy();
|
|
159
|
+
}
|
|
160
|
+
}, opts.ms);
|
|
161
|
+
res.on("finish", () => clearTimeout(timer));
|
|
162
|
+
res.on("close", () => clearTimeout(timer));
|
|
163
|
+
await next();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// ─── Cache Control Plugin ─────────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Sets Cache-Control response headers on every request.
|
|
169
|
+
*/
|
|
170
|
+
function SimpleJsCachePlugin(app, opts) {
|
|
171
|
+
let directive;
|
|
172
|
+
if (opts.noStore) {
|
|
173
|
+
directive = "no-store";
|
|
174
|
+
}
|
|
175
|
+
else if (opts.private) {
|
|
176
|
+
directive = `private, max-age=${opts.maxAge ?? 0}`;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
directive = `public, max-age=${opts.maxAge ?? 0}`;
|
|
180
|
+
}
|
|
181
|
+
app.use(async (_req, res, next) => {
|
|
182
|
+
res.setHeader("Cache-Control", directive);
|
|
183
|
+
await next();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// ─── Maintenance Mode Plugin ──────────────────────────────────────────────────
|
|
187
|
+
/**
|
|
188
|
+
* Returns 503 for all requests when maintenance mode is enabled.
|
|
189
|
+
* Optionally allow specific IPs to bypass (e.g. for internal testing).
|
|
190
|
+
*/
|
|
191
|
+
function SimpleJsMaintenanceModePlugin(app, opts) {
|
|
192
|
+
app.use(async (req, res, next) => {
|
|
193
|
+
if (!opts.enabled)
|
|
194
|
+
return next();
|
|
195
|
+
const raw = opts.trustProxy
|
|
196
|
+
? (Array.isArray(req.headers["x-forwarded-for"])
|
|
197
|
+
? req.headers["x-forwarded-for"][0]
|
|
198
|
+
: String(req.headers["x-forwarded-for"] || "").split(",")[0].trim()) || req.socket.remoteAddress || ""
|
|
199
|
+
: req.socket.remoteAddress || "";
|
|
200
|
+
const ip = normalizeIP(raw);
|
|
201
|
+
if (opts.allowIPs?.includes(ip))
|
|
202
|
+
return next();
|
|
203
|
+
res.setHeader("Retry-After", "3600");
|
|
204
|
+
(0, helpers_1.throwHttpError)(503, opts.message || "Service is under maintenance. Please try again later.");
|
|
205
|
+
});
|
|
8
206
|
}
|
package/package.json
CHANGED