cpeak 2.4.3 → 2.6.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.
@@ -0,0 +1,170 @@
1
+ import { randomBytes, pbkdf2, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+ import type { Middleware } from "../types";
4
+ import { frameworkError, ErrorCode } from "../index";
5
+
6
+ const pbkdf2Async = promisify(pbkdf2);
7
+
8
+ const DEFAULTS = {
9
+ iterations: 210_000,
10
+ keylen: 64,
11
+ digest: "sha512",
12
+ saltSize: 32,
13
+ hmacAlgorithm: "sha256",
14
+ tokenIdSize: 20,
15
+ tokenExpiry: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
16
+ } as const;
17
+
18
+ export interface PbkdfOptions {
19
+ iterations?: number;
20
+ keylen?: number;
21
+ digest?: string;
22
+ saltSize?: number;
23
+ }
24
+
25
+ export interface AuthOptions extends PbkdfOptions {
26
+ secret: string;
27
+ saveToken: (
28
+ tokenId: string,
29
+ userId: string,
30
+ expiresAt: Date
31
+ ) => Promise<void>;
32
+ findToken: (
33
+ tokenId: string
34
+ ) => Promise<{ userId: string; expiresAt: Date } | null>;
35
+ tokenExpiry?: number;
36
+ hmacAlgorithm?: string;
37
+ tokenIdSize?: number;
38
+ revokeToken?: (tokenId: string) => Promise<void>;
39
+ }
40
+
41
+ export async function hashPassword(
42
+ password: string,
43
+ options?: PbkdfOptions
44
+ ): Promise<string> {
45
+ const iterations = options?.iterations ?? DEFAULTS.iterations;
46
+ const keylen = options?.keylen ?? DEFAULTS.keylen;
47
+ const digest = options?.digest ?? DEFAULTS.digest;
48
+ const saltSize = options?.saltSize ?? DEFAULTS.saltSize;
49
+ const salt = randomBytes(saltSize);
50
+ const hash = await pbkdf2Async(password, salt, iterations, keylen, digest);
51
+ return `pbkdf2:${iterations}:${keylen}:${digest}:${salt.toString("hex")}:${hash.toString("hex")}`;
52
+ }
53
+
54
+ export async function verifyPassword(
55
+ password: string,
56
+ stored: string
57
+ ): Promise<boolean> {
58
+ // When argon2 is added, dispatch on the prefix here.
59
+ const withoutPrefix = stored.slice(stored.indexOf(":") + 1);
60
+ const parts = withoutPrefix.split(":");
61
+ if (parts.length !== 5) return false;
62
+ const [itersStr, keylenStr, digest, saltHex, hashHex] = parts;
63
+ const iterations = parseInt(itersStr, 10);
64
+ const keylen = parseInt(keylenStr, 10);
65
+ if (!digest || !saltHex || !hashHex || isNaN(iterations) || isNaN(keylen))
66
+ return false;
67
+ const salt = Buffer.from(saltHex, "hex");
68
+ const hash = await pbkdf2Async(password, salt, iterations, keylen, digest);
69
+ const storedHash = Buffer.from(hashHex, "hex");
70
+ if (storedHash.length !== hash.length) return false;
71
+ return timingSafeEqual(hash, storedHash);
72
+ }
73
+
74
+ function signToken(tokenId: string, secret: string, algorithm: string): string {
75
+ const sig = createHmac(algorithm, secret).update(tokenId).digest("hex");
76
+ return `${tokenId}.${sig}`;
77
+ }
78
+
79
+ function extractTokenId(
80
+ token: string,
81
+ secret: string,
82
+ algorithm: string
83
+ ): string | null {
84
+ const dot = token.indexOf(".");
85
+ if (dot === -1) return null;
86
+ const tokenId = token.slice(0, dot);
87
+ const sig = token.slice(dot + 1);
88
+ const expected = createHmac(algorithm, secret).update(tokenId).digest("hex");
89
+ const expectedBuf = Buffer.from(expected, "hex");
90
+ const actualBuf = Buffer.from(sig, "hex");
91
+ if (expectedBuf.length !== actualBuf.length) return null;
92
+ if (!timingSafeEqual(expectedBuf, actualBuf)) return null;
93
+ return tokenId;
94
+ }
95
+
96
+ export function auth(options: AuthOptions): Middleware {
97
+ if (!options.secret || options.secret.length < 32) {
98
+ throw frameworkError(
99
+ "Secret must be at least 32 characters. HMAC security is only as strong as the key.",
100
+ auth,
101
+ ErrorCode.WEAK_SECRET
102
+ );
103
+ }
104
+
105
+ const {
106
+ secret,
107
+ saveToken,
108
+ findToken,
109
+ revokeToken,
110
+ tokenExpiry = DEFAULTS.tokenExpiry,
111
+ hmacAlgorithm = DEFAULTS.hmacAlgorithm,
112
+ tokenIdSize = DEFAULTS.tokenIdSize
113
+ } = options;
114
+
115
+ const pbkdfOpts: PbkdfOptions = {
116
+ iterations: options.iterations,
117
+ keylen: options.keylen,
118
+ digest: options.digest,
119
+ saltSize: options.saltSize
120
+ };
121
+
122
+ const _hashPassword = ({ password }: { password: string }) =>
123
+ hashPassword(password, pbkdfOpts);
124
+
125
+ const login = async ({
126
+ password,
127
+ hashedPassword,
128
+ userId
129
+ }: {
130
+ password: string;
131
+ hashedPassword: string;
132
+ userId: string;
133
+ }): Promise<string | null> => {
134
+ const isMatch = await verifyPassword(password, hashedPassword);
135
+ if (!isMatch) return null;
136
+ const tokenId = randomBytes(tokenIdSize).toString("hex");
137
+ const token = signToken(tokenId, secret, hmacAlgorithm);
138
+ await saveToken(tokenId, userId, new Date(Date.now() + tokenExpiry));
139
+ return token;
140
+ };
141
+
142
+ const verifyToken = async (
143
+ token: string
144
+ ): Promise<{ userId: string } | null> => {
145
+ if (!token) return null;
146
+ const tokenId = extractTokenId(token, secret, hmacAlgorithm);
147
+ if (!tokenId) return null;
148
+ const record = await findToken(tokenId);
149
+ if (!record) return null;
150
+ if (new Date(record.expiresAt) < new Date()) return null;
151
+ return { userId: record.userId };
152
+ };
153
+
154
+ const logout = revokeToken
155
+ ? async (token: string): Promise<boolean> => {
156
+ const tokenId = extractTokenId(token, secret, hmacAlgorithm);
157
+ if (!tokenId) return false;
158
+ await revokeToken(tokenId);
159
+ return true;
160
+ }
161
+ : undefined;
162
+
163
+ return (req, _res, next) => {
164
+ req.hashPassword = _hashPassword;
165
+ req.login = login;
166
+ req.verifyToken = verifyToken;
167
+ if (logout) req.logout = logout;
168
+ next();
169
+ };
170
+ }
@@ -0,0 +1,189 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import type { CpeakRequest, CpeakResponse, Next } from "../types";
3
+ import { frameworkError, ErrorCode } from "../index";
4
+
5
+ export interface CookieOptions {
6
+ signed?: boolean;
7
+ httpOnly?: boolean;
8
+ secure?: boolean;
9
+ sameSite?: "strict" | "lax" | "none";
10
+ maxAge?: number; // ms
11
+ expires?: Date;
12
+ path?: string;
13
+ domain?: string;
14
+ }
15
+
16
+ // This will sign the cookie value with HMAC with the secret.
17
+ // Ideal for data like user IDs or session IDs, where you want to ensure the integrity of the cookie value without encryption.
18
+ // So this way, imagine you save a user ID in the cookie. By signing it, you can detect if the client has tampered with the cookie value
19
+ // (e.g., changing the user ID to impersonate another user).
20
+ // However, since it's not encrypted, the actual user ID is still visible to the client.
21
+ // This is a common approach for session cookies where you want to prevent tampering but don't mind if the value is visible.
22
+ function sign(value: string, secret: string): string {
23
+ const sig = createHmac("sha256", secret).update(value).digest("base64url");
24
+ return `s:${value}.${sig}`;
25
+ }
26
+
27
+ function unsign(signed: string, secret: string): string | false {
28
+ if (!signed.startsWith("s:")) return false;
29
+ const withoutPrefix = signed.slice(2);
30
+ const lastDot = withoutPrefix.lastIndexOf(".");
31
+ if (lastDot === -1) return false;
32
+ const value = withoutPrefix.slice(0, lastDot);
33
+ const sig = withoutPrefix.slice(lastDot + 1);
34
+ const expected = createHmac("sha256", secret)
35
+ .update(value)
36
+ .digest("base64url");
37
+ const expectedBuf = Buffer.from(expected);
38
+ const actualBuf = Buffer.from(sig);
39
+ if (expectedBuf.length !== actualBuf.length) return false;
40
+ if (!timingSafeEqual(expectedBuf, actualBuf)) return false;
41
+ return value;
42
+ }
43
+
44
+ // Parses the raw value of an HTTP `Cookie` request header into a name->value
45
+ // This should be compatible with the RFC 6265 HTTP specification
46
+ function parseRawCookies(header: string): Record<string, string> {
47
+ // Use a null-prototype object to prevent prototype pollution attacks when assigning cookie names like "__proto__" or "constructor".
48
+ const cookies: Record<string, string> = Object.create(null);
49
+ if (!header) return cookies;
50
+
51
+ const pairs = header.split(";");
52
+
53
+ for (let i = 0; i < pairs.length; i++) {
54
+ const pair = pairs[i];
55
+ const equalSignIndex = pair.indexOf("=");
56
+
57
+ // RFC 6265: cookie-pair requires '='. Pairs without one (e.g. a
58
+ // bare flag like `Cookie: foo`) are not valid cookie-pairs and we skip them.
59
+ // Note we use the FIRST '=' only. So values like base64 padding (`token=YWJjPT0=`) must keep trailing '='s.
60
+ if (equalSignIndex === -1) continue;
61
+
62
+ const key = pair.slice(0, equalSignIndex).trim();
63
+ // Drop empty names and honour the FIRST occurrence on duplicates (Specs say servers SHOULD NOT rely on order.
64
+ // We pick first-wins for stability).
65
+ if (!key || cookies[key] !== undefined) continue;
66
+
67
+ let val = pair.slice(equalSignIndex + 1).trim();
68
+
69
+ // Cookie values are sometimes sent wrapped in double quotes (like name="hello world"), so we strip the outer "
70
+ // characters to get the actual value hello world.
71
+ // The val.length > 1 guard handles the edge case where the value is literally just a single " — without it, that one
72
+ // character would match both the "starts with quote" and "ends with quote" checks, and slice(1, -1) would wipe it out
73
+ // to an empty string.
74
+ if (val.length > 1 && val[0] === '"' && val[val.length - 1] === '"') {
75
+ val = val.slice(1, -1);
76
+ }
77
+
78
+ // Percent-decoding cookie values is a server-side convention (not part of
79
+ // RFC 6265 itself), but it's what Express and most ecosystem libraries do,
80
+ // so we follow suit for compatibility. Skip the decode entirely when there's no
81
+ // '%' to save work on the common case, and fall back to the raw value if
82
+ // decodeURIComponent throws on malformed input rather than crashing the
83
+ // whole request.
84
+ try {
85
+ cookies[key] = val.indexOf("%") !== -1 ? decodeURIComponent(val) : val;
86
+ } catch (e) {
87
+ cookies[key] = val;
88
+ }
89
+ }
90
+ return cookies;
91
+ }
92
+
93
+ // One example output: "session=abc123; Path=/dashboard; Domain=example.com; Max-Age=86400; Expires=Thu, 31 Dec 2026 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict"
94
+ function buildSetCookieHeader(
95
+ name: string,
96
+ value: string,
97
+ options: CookieOptions
98
+ ): string {
99
+ const parts: string[] = [`${name}=${encodeURIComponent(value)}`];
100
+ const path = options.path ?? "/";
101
+ parts.push(`Path=${path}`);
102
+ if (options.domain) parts.push(`Domain=${options.domain}`);
103
+ if (options.maxAge !== undefined)
104
+ parts.push(`Max-Age=${Math.floor(options.maxAge / 1000)}`);
105
+ if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
106
+ if (options.httpOnly) parts.push("HttpOnly");
107
+ if (options.secure) parts.push("Secure");
108
+ if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
109
+ return parts.join("; ");
110
+ }
111
+
112
+ // Without this helper, calling res.cookie("a", "1") then res.cookie("b", "2") would overwrite the first cookie instead
113
+ // of sending both.
114
+ function appendSetCookie(res: CpeakResponse, header: string) {
115
+ const existing = res.getHeader("Set-Cookie");
116
+ if (!existing) {
117
+ res.setHeader("Set-Cookie", [header]);
118
+ } else if (Array.isArray(existing)) {
119
+ res.setHeader("Set-Cookie", [...existing, header]);
120
+ } else {
121
+ res.setHeader("Set-Cookie", [String(existing), header]);
122
+ }
123
+ }
124
+
125
+ export function cookieParser(options: { secret?: string } = {}) {
126
+ const { secret } = options;
127
+
128
+ if (secret !== undefined && secret.length < 32) {
129
+ throw frameworkError(
130
+ "Secret must be at least 32 characters. HMAC security is only as strong as the key.",
131
+ cookieParser,
132
+ ErrorCode.WEAK_SECRET
133
+ );
134
+ }
135
+
136
+ return (req: CpeakRequest, res: CpeakResponse, next: Next) => {
137
+ const rawHeader = req.headers["cookie"] || "";
138
+ const raw = parseRawCookies(rawHeader);
139
+
140
+ // Mirror parseRawCookies and use null-prototype maps here too. If we used
141
+ // a regular `{}`, the assignment below would invoke Object.prototype's
142
+ // __proto__ setter (no-op for string values), silently dropping any
143
+ // cookie literally named __proto__ — undoing the fix in parseRawCookies.
144
+ const cookies: Record<string, string> = Object.create(null);
145
+ const signedCookies: Record<string, string | false> = Object.create(null);
146
+
147
+ for (const [key, val] of Object.entries(raw)) {
148
+ // The "s:" prefix is the marker we add in `sign()` for HMAC-signed
149
+ // cookies. Route those through unsign so the handler sees the original
150
+ // value (or `false` if the signature didn't verify).
151
+ if (val.startsWith("s:") && secret) {
152
+ signedCookies[key] = unsign(val, secret);
153
+ } else {
154
+ cookies[key] = val;
155
+ }
156
+ }
157
+
158
+ // The separation is intentional signal: "these were signed and verified, trust them more."
159
+ req.cookies = cookies;
160
+ req.signedCookies = signedCookies;
161
+
162
+ res.cookie = (name: string, value: string, options: CookieOptions = {}) => {
163
+ let finalValue = value;
164
+ if (options.signed) {
165
+ if (!secret)
166
+ throw new Error(
167
+ "cookieParser: secret is required to use signed cookies"
168
+ );
169
+ finalValue = sign(value, secret);
170
+ }
171
+ appendSetCookie(res, buildSetCookieHeader(name, finalValue, options));
172
+ return res;
173
+ };
174
+
175
+ res.clearCookie = (name: string, options: CookieOptions = {}) => {
176
+ appendSetCookie(
177
+ res,
178
+ buildSetCookieHeader(name, "", {
179
+ ...options,
180
+ maxAge: 0,
181
+ expires: new Date(0)
182
+ })
183
+ );
184
+ return res;
185
+ };
186
+
187
+ next();
188
+ };
189
+ }
@@ -1,5 +1,20 @@
1
1
  import { parseJSON } from "./parseJSON";
2
2
  import { serveStatic } from "./serveStatic";
3
3
  import { render } from "./render";
4
+ import { swagger } from "./swagger";
5
+ import { auth, hashPassword, verifyPassword } from "./auth";
6
+ import type { AuthOptions, PbkdfOptions } from "./auth";
7
+ import { cookieParser } from "./cookieParser";
8
+ import type { CookieOptions } from "./cookieParser";
4
9
 
5
- export { serveStatic, parseJSON, render };
10
+ export {
11
+ serveStatic,
12
+ parseJSON,
13
+ render,
14
+ swagger,
15
+ auth,
16
+ hashPassword,
17
+ verifyPassword,
18
+ cookieParser
19
+ };
20
+ export type { AuthOptions, PbkdfOptions, CookieOptions };
@@ -1,30 +1,83 @@
1
1
  import type { CpeakRequest, CpeakResponse, Next } from "../types";
2
+ import { Buffer } from "node:buffer";
3
+ import { frameworkError, ErrorCode } from "../index";
4
+
5
+ // Check if Content-Type is JSON
6
+ function isJSON(contentType: string | undefined) {
7
+ if (!contentType) return false;
8
+ if (contentType === "application/json") return true;
9
+ return (
10
+ contentType.startsWith("application/json") || contentType.includes("+json")
11
+ );
12
+ }
2
13
 
3
14
  // Parsing JSON
4
- const parseJSON = (req: CpeakRequest, res: CpeakResponse, next: Next) => {
5
- // This is only good for bodies that their size is less than the highWaterMark value
6
-
7
- function isJSON(contentType: string = "") {
8
- // Remove any params like "; charset=UTF-8"
9
- const [type] = contentType.split(";");
10
- return (
11
- type.trim().toLowerCase() === "application/json" ||
12
- /\+json$/i.test(type.trim())
13
- );
14
- }
15
-
16
- if (!isJSON(req.headers["content-type"] as string)) return next();
17
-
18
- let body = "";
19
- req.on("data", (chunk: Buffer) => {
20
- body += chunk.toString("utf-8");
21
- });
22
-
23
- req.on("end", () => {
24
- body = JSON.parse(body);
25
- req.body = body;
26
- return next();
27
- });
15
+ const parseJSON = (options: { limit?: number } = {}) => {
16
+ // Default limit to 1MB
17
+ const limit = options.limit || 1024 * 1024;
18
+
19
+ return (req: CpeakRequest, res: CpeakResponse, next: Next) => {
20
+ if (!isJSON(req.headers["content-type"])) return next();
21
+
22
+ const chunks: Buffer[] = [];
23
+ let bytesReceived = 0;
24
+
25
+ const onData = (chunk: Buffer) => {
26
+ bytesReceived += chunk.length;
27
+
28
+ // To prevent Denial of Service (DoS) attacks, enforce a maximum body size
29
+ if (bytesReceived > limit) {
30
+ // Stop listening to data
31
+ req.pause();
32
+
33
+ // Remove listeners so we don't trigger 'end' or more 'data'
34
+ req.removeListener("data", onData);
35
+ req.removeListener("end", onEnd);
36
+
37
+ next(
38
+ frameworkError(
39
+ "JSON body too large",
40
+ onData,
41
+ ErrorCode.PAYLOAD_TOO_LARGE,
42
+ 413 // HTTP 413 Payload Too Large
43
+ )
44
+ );
45
+
46
+ return;
47
+ }
48
+
49
+ chunks.push(chunk);
50
+ };
51
+
52
+ const onEnd = () => {
53
+ try {
54
+ // For better performance, we concat buffers once, then convert to string
55
+ // Optimization: If only one chunk exists, avoid the memory copy of concat
56
+ const rawBody =
57
+ chunks.length === 1
58
+ ? chunks[0].toString("utf-8")
59
+ : Buffer.concat(chunks).toString("utf-8");
60
+
61
+ // Handle empty body case
62
+ req.body = rawBody ? JSON.parse(rawBody) : {};
63
+
64
+ next();
65
+ } catch (err) {
66
+ // Handle Invalid JSON without crashing
67
+ next(
68
+ frameworkError(
69
+ "Invalid JSON format",
70
+ onEnd,
71
+ ErrorCode.INVALID_JSON,
72
+ 400 // HTTP 400 Bad Request
73
+ )
74
+ );
75
+ }
76
+ };
77
+
78
+ req.on("data", onData);
79
+ req.on("end", onEnd);
80
+ };
28
81
  };
29
82
 
30
83
  export { parseJSON };
@@ -17,14 +17,24 @@ const MIME_TYPES: StringMap = {
17
17
  ttf: "font/ttf",
18
18
  woff: "font/woff",
19
19
  woff2: "font/woff2",
20
+ gif: "image/gif",
21
+ ico: "image/x-icon",
22
+ json: "application/json",
23
+ webmanifest: "application/manifest+json"
20
24
  };
21
25
 
22
- const serveStatic = (folderPath: string, newMimeTypes?: StringMap) => {
26
+ const serveStatic = (
27
+ folderPath: string,
28
+ newMimeTypes?: StringMap,
29
+ options?: { prefix?: string }
30
+ ) => {
23
31
  // For new user defined mime types
24
32
  if (newMimeTypes) {
25
33
  Object.assign(MIME_TYPES, newMimeTypes);
26
34
  }
27
35
 
36
+ const prefix = options?.prefix ?? "";
37
+
28
38
  function processFolder(folderPath: string, parentFolder: string) {
29
39
  const staticFiles: string[] = [];
30
40
 
@@ -55,9 +65,9 @@ const serveStatic = (folderPath: string, newMimeTypes?: StringMap) => {
55
65
  const filesMap: Record<string, { path: string; mime: string }> = {};
56
66
  for (const file of filesArray) {
57
67
  const fileExtension = path.extname(file).slice(1);
58
- filesMap[file] = {
68
+ filesMap[prefix + file] = {
59
69
  path: folderPath + file,
60
- mime: MIME_TYPES[fileExtension],
70
+ mime: MIME_TYPES[fileExtension]
61
71
  };
62
72
  }
63
73
  return filesMap;
@@ -70,8 +80,9 @@ const serveStatic = (folderPath: string, newMimeTypes?: StringMap) => {
70
80
  const url = req.url;
71
81
  if (typeof url !== "string") return next();
72
82
 
73
- if (Object.prototype.hasOwnProperty.call(filesMap, url)) {
74
- const fileRoute = filesMap[url];
83
+ const pathname = url.split("?")[0];
84
+ if (Object.prototype.hasOwnProperty.call(filesMap, pathname)) {
85
+ const fileRoute = filesMap[pathname];
75
86
  return res.sendFile(fileRoute.path, fileRoute.mime);
76
87
  }
77
88
 
@@ -0,0 +1,31 @@
1
+ import type { CpeakRequest, CpeakResponse, Next } from "../types";
2
+
3
+ const swagger = (spec: object, prefix = "/api-docs") => {
4
+ const initializerJs = `window.onload = function() {
5
+ SwaggerUIBundle({
6
+ url: "${prefix}/spec.json",
7
+ dom_id: '#swagger-ui',
8
+ presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
9
+ layout: "StandaloneLayout"
10
+ });
11
+ };`;
12
+
13
+ return (req: CpeakRequest, res: CpeakResponse, next: Next) => {
14
+ if (req.url === prefix || req.url === `${prefix}/`) {
15
+ res.writeHead(302, { Location: `${prefix}/index.html` });
16
+ res.end();
17
+ return;
18
+ }
19
+ if (req.url === `${prefix}/spec.json`) {
20
+ return res.json(spec);
21
+ }
22
+ if (req.url === `${prefix}/swagger-initializer.js`) {
23
+ res.setHeader("Content-Type", "application/javascript");
24
+ res.end(initializerJs);
25
+ return;
26
+ }
27
+ next();
28
+ };
29
+ };
30
+
31
+ export { swagger };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cpeak",
3
- "version": "2.4.3",
3
+ "version": "2.6.0",
4
4
  "description": "A minimal and fast Node.js HTTP framework.",
5
5
  "type": "module",
6
6
  "scripts": {